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/ // - A .trashinfo file is written to Trash/info/.trashinfo // - If 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 }