diff --git a/internal/fs/ops.go b/internal/fs/ops.go index 9d6f947..f739c47 100644 --- a/internal/fs/ops.go +++ b/internal/fs/ops.go @@ -1,6 +1,7 @@ package vfs import ( + "context" "errors" "fmt" "io" @@ -25,6 +26,7 @@ type CopyProgress struct { } type copyProgressState struct { + ctx context.Context filesDone int bytesDone int64 stats TransferStats @@ -36,6 +38,87 @@ 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) {} + } + if stats.FilesTotal == 0 && stats.BytesTotal == 0 { + resolved, err := CopyStats(srcPath) + if err != nil { + return "", err + } + stats = resolved + } + tracker := copyProgressState{ctx: ctx, stats: stats, callback: progress} + tracker.emit(srcPath, true) + + 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) + } + 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 { @@ -77,67 +160,7 @@ func CopyStats(srcPath string) (TransferStats, error) { } func CopyPathWithProgress(srcPath string, dstDir string, overwrite bool, stats TransferStats, progress func(CopyProgress)) (string, error) { - 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 progress == nil { - progress = func(CopyProgress) {} - } - if stats.FilesTotal == 0 && stats.BytesTotal == 0 { - resolved, err := CopyStats(srcPath) - if err != nil { - return "", err - } - stats = resolved - } - tracker := copyProgressState{stats: stats, callback: progress} - tracker.emit(srcPath, true) - - if srcInfo.Mode()&os.ModeSymlink != 0 { - target, err := os.Readlink(srcPath) - if 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 "", err - } - tracker.emit(srcPath, true) - return targetPath, nil - } - - if err := copyFile(srcPath, targetPath, srcInfo.Mode(), &tracker); err != nil { - return "", err - } - tracker.emit(srcPath, true) - return targetPath, nil + return CopyPathWithProgressContext(context.Background(), srcPath, dstDir, overwrite, stats, progress) } func MovePath(srcPath string, dstDir string, overwrite bool) (string, error) { @@ -145,6 +168,14 @@ func MovePath(srcPath string, dstDir string, overwrite bool) (string, error) { } 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 @@ -166,6 +197,9 @@ func MovePathWithProgress(srcPath string, dstDir string, overwrite bool, stats T if progress == nil { progress = func(CopyProgress) {} } + if err := ctx.Err(); err != nil { + return "", err + } if stats.FilesTotal == 0 && stats.BytesTotal == 0 { resolved, err := CopyStats(srcPath) if err != nil { @@ -187,10 +221,14 @@ func MovePathWithProgress(srcPath string, dstDir string, overwrite bool, stats T return "", err } - targetPath, err := CopyPathWithProgress(srcPath, dstDir, overwrite, stats, progress) + 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 + } if err := DeletePath(srcPath); err != nil { return "", err } @@ -220,6 +258,11 @@ func MakeDir(parent string, name string) (string, error) { } 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 @@ -234,6 +277,11 @@ func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error { } 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()) @@ -269,6 +317,11 @@ func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error { } 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 @@ -286,6 +339,8 @@ func copyFile(srcPath string, dstPath string, mode os.FileMode, tracker *copyPro 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 { @@ -301,6 +356,11 @@ type progressWriter struct { } 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) diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go index 8af69d9..a02063a 100644 --- a/internal/ui/keymap.go +++ b/internal/ui/keymap.go @@ -3,64 +3,66 @@ package ui import "github.com/charmbracelet/bubbles/key" type KeyMap struct { - Help key.Binding - View key.Binding - Edit key.Binding - Info key.Binding - SelectText key.Binding - ToggleHidden key.Binding - CycleTheme key.Binding - CycleSort key.Binding - Up key.Binding - Down key.Binding - SelectUp key.Binding - SelectDown key.Binding - PageUp key.Binding - PageDown key.Binding - Open key.Binding - Back key.Binding - Switch key.Binding - Refresh key.Binding - DirSize key.Binding - Copy key.Binding - Move key.Binding - Mkdir key.Binding - Delete key.Binding - Confirm key.Binding - Background key.Binding - Cancel key.Binding - Quit key.Binding + Help key.Binding + View key.Binding + Edit key.Binding + Info key.Binding + SelectText key.Binding + ToggleHidden key.Binding + CycleTheme key.Binding + CycleSort key.Binding + Up key.Binding + Down key.Binding + SelectUp key.Binding + SelectDown key.Binding + PageUp key.Binding + PageDown key.Binding + Open key.Binding + Back key.Binding + Switch key.Binding + Refresh key.Binding + DirSize key.Binding + Copy key.Binding + Move key.Binding + Mkdir key.Binding + Delete key.Binding + Confirm key.Binding + Background key.Binding + ProgressCancel key.Binding + Cancel key.Binding + Quit key.Binding } func DefaultKeyMap() KeyMap { return KeyMap{ - Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")), - View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")), - Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")), - Info: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "info")), - SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")), - ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")), - CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")), - CycleSort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), - Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), - Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), - SelectUp: key.NewBinding(key.WithKeys("shift+up", "K"), key.WithHelp("S-↑/K", "select up")), - SelectDown: key.NewBinding(key.WithKeys("shift+down", "J"), key.WithHelp("S-↓/J", "select down")), - PageUp: key.NewBinding(key.WithKeys("pgup", "b"), key.WithHelp("PgUp/b", "page up")), - PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("PgDn/f", "page down")), - Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")), - Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")), - Switch: key.NewBinding(key.WithKeys("tab", "h", "l"), key.WithHelp("Tab/h/l", "switch pane")), - Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), - DirSize: key.NewBinding(key.WithKeys(" "), key.WithHelp("Space", "dir size")), - Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")), - Move: key.NewBinding(key.WithKeys("f6", "m"), key.WithHelp("F6/m", "move")), - Mkdir: key.NewBinding(key.WithKeys("f7", "n"), key.WithHelp("F7/n", "mkdir")), - Delete: key.NewBinding(key.WithKeys("f8", "delete", "x"), key.WithHelp("F8/x", "delete")), - Confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("Enter/y", "confirm")), - Background: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "background")), - Cancel: key.NewBinding(key.WithKeys("esc", "n"), key.WithHelp("Esc/n", "cancel")), - Quit: key.NewBinding(key.WithKeys("f10", "q", "ctrl+c"), key.WithHelp("F10/q", "quit")), + Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")), + View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")), + Edit: key.NewBinding(key.WithKeys("f4", "e"), key.WithHelp("F4/e", "edit")), + Info: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "info")), + SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")), + ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")), + CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")), + CycleSort: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "sort")), + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + SelectUp: key.NewBinding(key.WithKeys("shift+up", "K"), key.WithHelp("S-↑/K", "select up")), + SelectDown: key.NewBinding(key.WithKeys("shift+down", "J"), key.WithHelp("S-↓/J", "select down")), + PageUp: key.NewBinding(key.WithKeys("pgup", "b"), key.WithHelp("PgUp/b", "page up")), + PageDown: key.NewBinding(key.WithKeys("pgdown", "f"), key.WithHelp("PgDn/f", "page down")), + Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")), + Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")), + Switch: key.NewBinding(key.WithKeys("tab", "h", "l"), key.WithHelp("Tab/h/l", "switch pane")), + Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")), + DirSize: key.NewBinding(key.WithKeys(" "), key.WithHelp("Space", "dir size")), + Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")), + Move: key.NewBinding(key.WithKeys("f6", "m"), key.WithHelp("F6/m", "move")), + Mkdir: key.NewBinding(key.WithKeys("f7", "n"), key.WithHelp("F7/n", "mkdir")), + Delete: key.NewBinding(key.WithKeys("f8", "delete", "x"), key.WithHelp("F8/x", "delete")), + Confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("Enter/y", "confirm")), + Background: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "background")), + ProgressCancel: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "cancel transfer")), + Cancel: key.NewBinding(key.WithKeys("esc", "n"), key.WithHelp("Esc/n", "cancel")), + Quit: key.NewBinding(key.WithKeys("f10", "q", "ctrl+c"), key.WithHelp("F10/q", "quit")), } } diff --git a/internal/ui/model.go b/internal/ui/model.go index a0d2e2d..7422b8a 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -1,6 +1,7 @@ package ui import ( + "context" "fmt" "os" "os/exec" @@ -117,6 +118,8 @@ type copyJobState struct { progress vfs.CopyProgress overwrite bool background bool + cancel context.CancelFunc + startedAt time.Time } type mouseClickState struct { @@ -334,7 +337,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.busy = false if msg.err != nil { - m.status = fmt.Sprintf("%s failed: %v", strings.Title(operationVerb(msg.kind)), msg.err) + if msg.err == context.Canceled { + m.status = strings.Title(operationVerb(msg.kind)) + " cancelled" + } else { + m.status = fmt.Sprintf("%s failed: %v", strings.Title(operationVerb(msg.kind)), msg.err) + } m.copyJob = nil if m.modal.kind == modalCopyProgress { m.modal = modalState{} @@ -584,7 +591,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } case modalCopyProgress: - if isModalCloseKey(msg, m.keys) { + if key.Matches(msg, m.keys.Background) { if m.copyJob == nil { m.modal = modalState{} return m, nil @@ -594,14 +601,15 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.status = "Transfer continues in background" return m, nil } - if key.Matches(msg, m.keys.Background) { + if key.Matches(msg, m.keys.ProgressCancel) { if m.copyJob == nil { m.modal = modalState{} return m, nil } - m.copyJob.background = true - m.modal = modalState{} - m.status = "Transfer continues in background" + if m.copyJob.cancel != nil { + m.copyJob.cancel() + } + m.status = strings.Title(operationVerb(m.copyJob.kind)) + " cancelling..." return m, nil } return m, nil @@ -1120,6 +1128,7 @@ func (m *Model) openHelpModal() { " Enter / y confirm action", " Esc / n cancel action", " b run copy/move in background (progress dialog)", + " c cancel active copy/move transfer", " F5/F6/F8 apply to marked entries when selection exists", "", "Mouse", @@ -1711,19 +1720,19 @@ func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int) lines := []string{ titleStyle.Render(progressTitle(job.kind)), - lineStyle.Render(fmt.Sprintf("From: %s", transferSourceLabel(job.sourcePaths))), - lineStyle.Render(fmt.Sprintf("To: %s", job.targetDir)), spacer, lineStyle.Render(renderProgressBar(ratio, max(contentWidth-8, 10), palette)), - lineStyle.Render(fmt.Sprintf("Files: %d / %d", progress.FilesDone, progress.FilesTotal)), - lineStyle.Render(fmt.Sprintf("Size: %s / %s", formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true))), + spacer, + renderProgressPercentLine(ratio, contentWidth, palette), + renderProgressStatLine("Files:", fmt.Sprintf("%d / %d", progress.FilesDone, progress.FilesTotal), contentWidth, palette), + renderProgressStatLine("Size:", fmt.Sprintf("%s / %s", formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true)), contentWidth, palette), + renderProgressStatLine("Speed:", transferSpeed(progress.BytesDone, job.startedAt), contentWidth, palette), + spacer, + renderProgressActions(contentWidth, palette), } - - if strings.TrimSpace(progress.CurrentPath) != "" { - lines = append(lines, lineStyle.Render("Current: "+truncateMiddle(progress.CurrentPath, max(contentWidth-10, 16)))) + if job.background { + lines = append(lines, mutedStyle.Render("Transfer continues in background")) } - lines = append(lines, spacer) - lines = append(lines, mutedStyle.Render("Press b to continue in background")) return box.Render(strings.Join(lines, "\n")) } @@ -1748,8 +1757,97 @@ func renderProgressBar(ratio float64, width int, palette theme.Palette) string { bar := lipgloss.NewStyle().Foreground(palette.ProgressFill).Render(strings.Repeat("█", filled)) rest := lipgloss.NewStyle().Foreground(palette.ProgressEmpty).Render(strings.Repeat("░", width-filled)) - percent := fmt.Sprintf(" %3.0f%%", ratio*100) - return bar + rest + percent + return bar + rest +} + +func renderProgressStatLine(label string, value string, width int, palette theme.Palette) string { + keyWidth := min(max(lipgloss.Width(label)+1, 8), width) + valueWidth := max(width-keyWidth, 0) + keyStyle := lipgloss.NewStyle(). + Width(keyWidth). + Background(palette.Panel). + Foreground(palette.FooterKey). + Bold(true) + valueStyle := lipgloss.NewStyle(). + Width(valueWidth). + Background(palette.Panel). + Foreground(palette.Text) + return keyStyle.Render(label) + valueStyle.Render(value) +} + +func renderProgressActions(width int, palette theme.Palette) string { + const minButtonWidth = 14 + const maxButtonWidth = 18 + const gapWidth = 4 + labelWidth := max(lipgloss.Width("Background / b"), lipgloss.Width("Cancel / c")) + buttonWidth := min(max(labelWidth+2, minButtonWidth), maxButtonWidth) + buttonWidth = min(buttonWidth, max((width-gapWidth)/2, labelWidth)) + + backgroundBtn := lipgloss.NewStyle(). + Width(buttonWidth). + Align(lipgloss.Center). + Background(palette.Info). + Foreground(palette.Background). + Bold(true). + Render("Background / b") + cancelBtn := lipgloss.NewStyle(). + Width(buttonWidth). + Align(lipgloss.Center). + Background(palette.CancelButton). + Foreground(palette.Background). + Bold(true). + Render("Cancel / c") + + gap := lipgloss.NewStyle(). + Width(gapWidth). + Background(palette.Panel). + Render("") + backgroundBias := lipgloss.NewStyle(). + Width(10). + Background(palette.Panel). + Render("") + cancelBias := lipgloss.NewStyle(). + Width(4). + Background(palette.Panel). + Render("") + group := lipgloss.JoinHorizontal(lipgloss.Top, backgroundBtn, backgroundBias, gap, cancelBtn, cancelBias) + row := lipgloss.PlaceHorizontal( + width, + lipgloss.Center, + group, + lipgloss.WithWhitespaceBackground(palette.Panel), + ) + return lipgloss.NewStyle(). + Width(width). + Background(palette.Panel). + Render(row) +} + +func renderProgressPercentLine(ratio float64, width int, palette theme.Palette) string { + percent := lipgloss.NewStyle(). + Foreground(palette.Info). + Bold(true). + Render(fmt.Sprintf("%3.0f%%", ratio*100)) + return lipgloss.NewStyle(). + Width(width). + Background(palette.Panel). + Align(lipgloss.Right). + Render(percent) +} + +func transferSpeed(bytesDone int64, startedAt time.Time) string { + if startedAt.IsZero() || bytesDone <= 0 { + return "calculating..." + } + elapsed := time.Since(startedAt) + if elapsed <= 0 { + return "calculating..." + } + perSecond := int64(float64(bytesDone) / elapsed.Seconds()) + if perSecond <= 0 { + return "calculating..." + } + return fmt.Sprintf("%s/s", vfs.HumanSize(perSecond)) } func overlayCenter(base string, overlay string, width int) string { @@ -1913,6 +2011,7 @@ func dismissNoticeCmd(delay time.Duration) tea.Cmd { func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir string, overwrite bool, stats vfs.TransferStats) tea.Cmd { m.nextCopyJob++ jobID := m.nextCopyJob + ctx, cancel := context.WithCancel(context.Background()) m.copyJob = ©JobState{ id: jobID, kind: kind, @@ -1926,6 +2025,8 @@ func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir st BytesTotal: stats.BytesTotal, CurrentPath: sourcePaths[0], }, + cancel: cancel, + startedAt: time.Now(), } m.modal = modalState{kind: modalCopyProgress} m.status = strings.Title(operationVerb(kind)) + " started" @@ -1961,9 +2062,9 @@ func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir st } switch kind { case opMove: - _, statErr = vfs.MovePathWithProgress(sourcePath, targetDir, overwrite, entryStats, progressFn) + _, statErr = vfs.MovePathWithProgressContext(ctx, sourcePath, targetDir, overwrite, entryStats, progressFn) default: - _, statErr = vfs.CopyPathWithProgress(sourcePath, targetDir, overwrite, entryStats, progressFn) + _, statErr = vfs.CopyPathWithProgressContext(ctx, sourcePath, targetDir, overwrite, entryStats, progressFn) } if statErr != nil { m.copyProgress <- copyDoneMsg{