vcom/internal/fs/ops.go
vrubelroman 8352441bda perf: skip plan phases for copy/delete, remove size tracking, show file count only
- Delete: skip remoteDeletePlanCmd and trashPlanCmd, show dialog immediately
- Copy: skip copyPlanCmd and remoteCopyPlanCmd, show dialog immediately
- CopyStats: no lstat per file, count files via WalkDir only
- Copy: two-phase (count first, then copy with known total + progress bar)
- Progress: file-based ratio, remove Size/Speed display
- Stage: Counting files... → Coping files... (no empty stage)
2026-05-12 17:39:22 +03:00

527 lines
13 KiB
Go

package vfs
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"syscall"
"time"
)
type TransferStats struct {
FilesTotal int
BytesTotal int64
}
type CopyProgress struct {
FilesDone int
FilesTotal int
BytesDone int64
BytesTotal int64
CurrentPath string
Stage string
}
type copyProgressState struct {
ctx context.Context
filesDone int
bytesDone int64
stats TransferStats
callback func(CopyProgress)
lastEmit time.Time
stage string
discover bool // if true, count files during copy for progress total
}
func (s *copyProgressState) discoverFiles(count int, dirPath string) {
if count == 0 {
return
}
s.stats.FilesTotal += count
s.emit(dirPath, false)
}
func CopyPath(srcPath string, dstDir string, overwrite bool) (string, error) {
return CopyPathWithProgress(srcPath, dstDir, overwrite, TransferStats{}, nil)
}
func CopyPathWithProgressContext(ctx context.Context, srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
if ctx == nil {
ctx = context.Background()
}
srcInfo, err := os.Lstat(srcPath)
if err != nil {
return "", fmt.Errorf("stat %s: %w", srcPath, err)
}
targetPath := filepath.Join(dstDir, filepath.Base(srcPath))
if same, err := samePath(srcPath, targetPath); err != nil {
return "", err
} else if same {
return "", fmt.Errorf("source and target are the same: %s", targetPath)
}
if exists, err := PathExists(targetPath); err != nil {
return "", err
} else if exists {
if !overwrite {
return "", ErrOverwrite(targetPath)
}
if err := os.RemoveAll(targetPath); err != nil {
return "", err
}
}
if err := ctx.Err(); err != nil {
return "", err
}
if progress == nil {
progress = func(CopyProgress) {}
}
tracker := copyProgressState{
ctx: ctx,
stats: stats,
callback: progress,
stage: "Scanning files...",
discover: stats.FilesTotal == 0,
}
tracker.emit(srcPath, true)
tracker.stage = "Copying files..."
cleanupOnErr := func(copyErr error) (string, error) {
if copyErr != nil {
_ = os.RemoveAll(targetPath)
}
return "", copyErr
}
if srcInfo.Mode()&os.ModeSymlink != 0 {
target, err := os.Readlink(srcPath)
if err != nil {
return "", err
}
if err := ctx.Err(); err != nil {
return "", err
}
if err := os.Symlink(target, targetPath); err != nil {
return "", err
}
tracker.finishFile(srcPath)
return targetPath, nil
}
if srcInfo.IsDir() {
if err := copyDir(srcPath, targetPath, &tracker); err != nil {
return cleanupOnErr(err)
}
if err := ctx.Err(); err != nil {
return cleanupOnErr(err)
}
tracker.emit(srcPath, true)
return targetPath, nil
}
if err := copyFile(srcPath, targetPath, srcInfo.Mode(), &tracker); err != nil {
return cleanupOnErr(err)
}
tracker.emit(srcPath, true)
return targetPath, nil
}
func CopyStats(srcPath string) (TransferStats, error) {
srcInfo, err := os.Lstat(srcPath)
if err != nil {
return TransferStats{}, fmt.Errorf("stat %s: %w", srcPath, err)
}
if srcInfo.Mode()&os.ModeSymlink != 0 {
return TransferStats{FilesTotal: 1, BytesTotal: 0}, nil
}
if !srcInfo.IsDir() {
return TransferStats{FilesTotal: 1, BytesTotal: srcInfo.Size()}, nil
}
stats := TransferStats{}
err = filepath.WalkDir(srcPath, func(current string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() {
return nil
}
stats.FilesTotal++
return nil
})
if err != nil {
return TransferStats{}, err
}
return stats, nil
}
func CopyPathWithProgress(srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
return CopyPathWithProgressContext(context.Background(), srcPath, dstDir, overwrite, stats, progress)
}
func MovePath(srcPath string, dstDir string, overwrite bool) (string, error) {
return MovePathWithProgress(srcPath, dstDir, overwrite, TransferStats{}, nil)
}
func MovePathWithProgress(srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
return MovePathWithProgressContext(context.Background(), srcPath, dstDir, overwrite, stats, progress)
}
func MovePathWithProgressContext(ctx context.Context, srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) {
if ctx == nil {
ctx = context.Background()
}
targetPath := filepath.Join(dstDir, filepath.Base(srcPath))
if same, err := samePath(srcPath, targetPath); err != nil {
return "", err
} else if same {
return "", fmt.Errorf("source and target are the same: %s", targetPath)
}
if exists, err := PathExists(targetPath); err != nil {
return "", err
} else if exists {
if !overwrite {
return "", ErrOverwrite(targetPath)
}
if err := os.RemoveAll(targetPath); err != nil {
return "", err
}
}
if progress == nil {
progress = func(CopyProgress) {}
}
if err := ctx.Err(); err != nil {
return "", err
}
if err := os.Rename(srcPath, targetPath); err == nil {
progress(CopyProgress{
FilesDone: stats.FilesTotal,
FilesTotal: stats.FilesTotal,
BytesDone: stats.BytesTotal,
BytesTotal: stats.BytesTotal,
CurrentPath: srcPath,
Stage: "Move completed",
})
return targetPath, nil
} else if !errors.Is(err, syscall.EXDEV) {
return "", err
}
targetPath, err := CopyPathWithProgressContext(ctx, srcPath, dstDir, overwrite, stats, progress)
if err != nil {
return "", err
}
if err := ctx.Err(); err != nil {
_ = os.RemoveAll(targetPath)
return "", err
}
progress(CopyProgress{
FilesDone: stats.FilesTotal,
FilesTotal: stats.FilesTotal,
BytesDone: stats.BytesTotal,
BytesTotal: stats.BytesTotal,
CurrentPath: srcPath,
Stage: "Finalizing move",
})
if err := DeletePath(srcPath); err != nil {
return "", err
}
return targetPath, nil
}
func PathExists(path string) (bool, error) {
if _, err := os.Lstat(path); err == nil {
return true, nil
} else if errors.Is(err, os.ErrNotExist) {
return false, nil
} else {
return false, err
}
}
func DeletePath(path string) error {
return os.RemoveAll(path)
}
// MoveToTrash moves a file or directory to the FreeDesktop Trash directory
// (~/.local/share/Trash). Follows the FreeDesktop Trash specification:
// - The original item is moved to Trash/files/<basename>
// - A .trashinfo file is written to Trash/info/<basename>.trashinfo
// - If <basename> already exists in Trash/files, a numeric suffix is appended.
func MoveToTrash(path string) error {
home, err := os.UserHomeDir()
if err != nil {
return fmt.Errorf("cannot determine home directory: %w", err)
}
trashDir := filepath.Join(home, ".local", "share", "Trash")
filesDir := filepath.Join(trashDir, "files")
infoDir := filepath.Join(trashDir, "info")
if err := os.MkdirAll(filesDir, 0o700); err != nil {
return fmt.Errorf("cannot create trash files directory: %w", err)
}
if err := os.MkdirAll(infoDir, 0o700); err != nil {
return fmt.Errorf("cannot create trash info directory: %w", err)
}
baseName := filepath.Base(path)
// Generate a unique name in the trash directory
destName := baseName
for counter := 1; ; counter++ {
destPath := filepath.Join(filesDir, destName)
if _, err := os.Stat(destPath); os.IsNotExist(err) {
break
} else if err != nil {
return fmt.Errorf("cannot stat trash path: %w", err)
}
destName = fmt.Sprintf("%s.%d", baseName, counter)
}
destPath := filepath.Join(filesDir, destName)
if err := os.Rename(path, destPath); err != nil {
// Cross-filesystem move: fall back to copy+delete
return fmt.Errorf("cannot move to trash: %w", err)
}
// Write .trashinfo file
absPath, err := filepath.Abs(path)
if err != nil {
absPath = path
}
now := time.Now().Format("2006-01-02T15:04:05")
infoContent := fmt.Sprintf("[Trash Info]\nPath=%s\nDeletionDate=%s\n", absPath, now)
infoPath := filepath.Join(infoDir, destName+".trashinfo")
if err := os.WriteFile(infoPath, []byte(infoContent), 0o600); err != nil {
// Best-effort: if info file fails, try to move the file back
_ = os.Rename(destPath, path)
return fmt.Errorf("cannot write trash info: %w", err)
}
return nil
}
func MakeDir(parent string, name string) (string, error) {
target := filepath.Join(parent, name)
if err := os.MkdirAll(target, 0o755); err != nil {
return "", err
}
return target, nil
}
func RenamePath(sourcePath string, newName string) (string, error) {
newName = filepath.Base(filepath.Clean(newName))
if newName == "." || newName == "" {
return "", fmt.Errorf("invalid target name")
}
targetPath := filepath.Join(filepath.Dir(sourcePath), newName)
if same, err := samePath(sourcePath, targetPath); err != nil {
return "", err
} else if same {
return "", fmt.Errorf("source and target are the same: %s", targetPath)
}
if exists, err := PathExists(targetPath); err != nil {
return "", err
} else if exists {
return "", ErrOverwrite(targetPath)
}
if err := os.Rename(sourcePath, targetPath); err != nil {
return "", err
}
return targetPath, nil
}
func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error {
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
info, err := os.Lstat(srcDir)
if err != nil {
return err
}
if err := os.MkdirAll(dstDir, info.Mode().Perm()); err != nil {
return err
}
entries, err := os.ReadDir(srcDir)
if err != nil {
return err
}
// Count files in this directory so progress total converges
if tracker != nil && tracker.discover {
fileCount := 0
for _, entry := range entries {
if !entry.IsDir() {
fileCount++
}
}
tracker.discoverFiles(fileCount, srcDir)
}
for _, entry := range entries {
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
srcPath := filepath.Join(srcDir, entry.Name())
dstPath := filepath.Join(dstDir, entry.Name())
info, err := os.Lstat(srcPath)
if err != nil {
return err
}
switch {
case info.Mode()&os.ModeSymlink != 0:
target, err := os.Readlink(srcPath)
if err != nil {
return err
}
if err := os.Symlink(target, dstPath); err != nil {
return err
}
if tracker != nil {
tracker.finishFile(srcPath)
}
case info.IsDir():
if err := copyDir(srcPath, dstPath, tracker); err != nil {
return err
}
default:
if err := copyFile(srcPath, dstPath, info.Mode(), tracker); err != nil {
return err
}
}
}
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
return nil
}
func copyFile(srcPath string, dstPath string, mode os.FileMode, tracker *copyProgressState) error {
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
return err
}
}
srcFile, err := os.Open(srcPath)
if err != nil {
return err
}
defer srcFile.Close()
dstFile, err := os.OpenFile(dstPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, mode.Perm())
if err != nil {
return err
}
defer dstFile.Close()
writer := io.Writer(dstFile)
if tracker != nil {
writer = &progressWriter{base: dstFile, tracker: tracker, path: srcPath}
}
if _, err := io.Copy(writer, srcFile); err != nil {
_ = dstFile.Close()
_ = os.Remove(dstPath)
return err
}
if tracker != nil && tracker.ctx != nil {
if err := tracker.ctx.Err(); err != nil {
_ = dstFile.Close()
_ = os.Remove(dstPath)
return err
}
}
if tracker != nil {
tracker.finishFile(srcPath)
}
return nil
}
type progressWriter struct {
base io.Writer
tracker *copyProgressState
path string
}
func (w *progressWriter) Write(data []byte) (int, error) {
if w.tracker != nil && w.tracker.ctx != nil {
if err := w.tracker.ctx.Err(); err != nil {
return 0, err
}
}
n, err := w.base.Write(data)
if n > 0 {
w.tracker.addBytes(int64(n), w.path)
}
return n, err
}
func (s *copyProgressState) addBytes(delta int64, currentPath string) {
s.bytesDone += delta
s.emit(currentPath, false)
}
func (s *copyProgressState) finishFile(currentPath string) {
s.filesDone++
s.emit(currentPath, true)
}
func (s *copyProgressState) emit(currentPath string, force bool) {
if s.callback == nil {
return
}
if !force && time.Since(s.lastEmit) < 75*time.Millisecond {
return
}
s.lastEmit = time.Now()
stage := s.stage
if stage == "" {
stage = "Transferring data"
}
s.callback(CopyProgress{
FilesDone: s.filesDone,
FilesTotal: s.stats.FilesTotal,
BytesDone: s.bytesDone,
BytesTotal: s.stats.BytesTotal,
CurrentPath: currentPath,
Stage: stage,
})
}
func samePath(left string, right string) (bool, error) {
leftAbs, err := filepath.Abs(left)
if err != nil {
return false, err
}
rightAbs, err := filepath.Abs(right)
if err != nil {
return false, err
}
return leftAbs == rightAbs, nil
}