Add transfer progress workflow for move and polish labels

This commit is contained in:
vrubelroman 2026-04-23 12:38:19 +03:00
parent a196a16c6f
commit 5a5923099b
2 changed files with 94 additions and 27 deletions

View file

@ -141,6 +141,10 @@ func CopyPathWithProgress(srcPath string, dstDir string, overwrite bool, stats T
} }
func MovePath(srcPath string, dstDir string, overwrite bool) (string, error) { 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) {
targetPath := filepath.Join(dstDir, filepath.Base(srcPath)) targetPath := filepath.Join(dstDir, filepath.Base(srcPath))
if same, err := samePath(srcPath, targetPath); err != nil { if same, err := samePath(srcPath, targetPath); err != nil {
return "", err return "", err
@ -159,13 +163,31 @@ func MovePath(srcPath string, dstDir string, overwrite bool) (string, error) {
} }
} }
if progress == nil {
progress = func(CopyProgress) {}
}
if stats.FilesTotal == 0 && stats.BytesTotal == 0 {
resolved, err := CopyStats(srcPath)
if err != nil {
return "", err
}
stats = resolved
}
if err := os.Rename(srcPath, targetPath); err == nil { if err := os.Rename(srcPath, targetPath); err == nil {
progress(CopyProgress{
FilesDone: stats.FilesTotal,
FilesTotal: stats.FilesTotal,
BytesDone: stats.BytesTotal,
BytesTotal: stats.BytesTotal,
CurrentPath: srcPath,
})
return targetPath, nil return targetPath, nil
} else if !errors.Is(err, syscall.EXDEV) { } else if !errors.Is(err, syscall.EXDEV) {
return "", err return "", err
} }
targetPath, err := CopyPath(srcPath, dstDir, overwrite) targetPath, err := CopyPathWithProgress(srcPath, dstDir, overwrite, stats, progress)
if err != nil { if err != nil {
return "", err return "", err
} }

View file

@ -77,6 +77,7 @@ type opMsg struct {
} }
type copyPlanMsg struct { type copyPlanMsg struct {
kind fileOpKind
sourcePath string sourcePath string
targetDir string targetDir string
targetPath string targetPath string
@ -92,6 +93,7 @@ type copyProgressMsg struct {
type copyDoneMsg struct { type copyDoneMsg struct {
jobID int jobID int
kind fileOpKind
sourcePath string sourcePath string
targetPath string targetPath string
err error err error
@ -101,6 +103,7 @@ type dismissNoticeMsg struct{}
type copyJobState struct { type copyJobState struct {
id int id int
kind fileOpKind
sourcePath string sourcePath string
targetDir string targetDir string
targetPath string targetPath string
@ -263,20 +266,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
title := "Copy selected entry?" verb := operationVerb(msg.kind)
title := fmt.Sprintf("%s selected entry?", strings.Title(verb))
body := strings.Join([]string{ body := strings.Join([]string{
fmt.Sprintf("From: %s", msg.sourcePath), fmt.Sprintf("From: %s", msg.sourcePath),
fmt.Sprintf("To: %s", msg.targetPath), fmt.Sprintf("To: %s", msg.targetPath),
"", "",
fmt.Sprintf("Files: %d", msg.stats.FilesTotal), fmt.Sprintf("Files: %d", msg.stats.FilesTotal),
fmt.Sprintf("Data: %s", formatSize(msg.stats.BytesTotal, true)), fmt.Sprintf("Size: %s", formatSize(msg.stats.BytesTotal, true)),
}, "\n") }, "\n")
if msg.overwrite { if msg.overwrite {
body += "\n\nTarget exists and will be overwritten." body += "\n\nTarget exists and will be overwritten."
} }
note := "Enter/y to start copy, Esc/n to cancel" note := fmt.Sprintf("Enter/y to start %s, Esc/n to cancel", verb)
m.openConfirmModal(title, body, note, pendingOperation{ m.openConfirmModal(title, body, note, pendingOperation{
kind: opCopy, kind: msg.kind,
sourcePath: msg.sourcePath, sourcePath: msg.sourcePath,
targetDir: msg.targetDir, targetDir: msg.targetDir,
overwrite: msg.overwrite, overwrite: msg.overwrite,
@ -290,7 +294,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.copyJob.progress = msg.progress m.copyJob.progress = msg.progress
if m.copyJob.background { if m.copyJob.background {
m.status = formatCopyStatus(msg.progress) m.status = formatCopyStatus(m.copyJob.kind, msg.progress)
} }
return m, waitCopyProgressCmd(m.copyProgress) return m, waitCopyProgressCmd(m.copyProgress)
@ -301,7 +305,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.busy = false m.busy = false
if msg.err != nil { if msg.err != nil {
m.status = fmt.Sprintf("Copy failed: %v", msg.err) m.status = fmt.Sprintf("%s failed: %v", strings.Title(operationVerb(msg.kind)), msg.err)
m.copyJob = nil m.copyJob = nil
if m.modal.kind == modalCopyProgress { if m.modal.kind == modalCopyProgress {
m.modal = modalState{} m.modal = modalState{}
@ -309,11 +313,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
m.status = fmt.Sprintf("Copied to %s", msg.targetPath) m.status = fmt.Sprintf("%s to %s", operationDoneLabel(msg.kind), msg.targetPath)
activeSelection := selectedName(m.activePane()) activeSelection := selectedName(m.activePane())
_ = m.reloadPane(PaneLeft, activeSelection) _ = m.reloadPane(PaneLeft, activeSelection)
_ = m.reloadPane(PaneRight, activeSelection) _ = m.reloadPane(PaneRight, activeSelection)
background := m.copyJob.background background := m.copyJob.background
kind := m.copyJob.kind
m.copyJob = nil m.copyJob = nil
cmd := m.loadPreviewCmd() cmd := m.loadPreviewCmd()
@ -321,10 +326,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.modal = modalState{} m.modal = modalState{}
} }
if background { if background {
doneWord := "copied"
if kind == opMove {
doneWord = "moved"
}
m.modal = modalState{ m.modal = modalState{
kind: modalNotice, kind: modalNotice,
title: "Copy complete", title: strings.Title(operationVerb(kind)) + " complete",
body: filepath.Base(msg.sourcePath) + " copied successfully.", body: filepath.Base(msg.sourcePath) + " " + doneWord + " successfully.",
} }
cmd = tea.Batch(cmd, dismissNoticeCmd(time.Second)) cmd = tea.Batch(cmd, dismissNoticeCmd(time.Second))
} }
@ -508,13 +517,13 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
pending := *m.modal.pending pending := *m.modal.pending
m.modal = modalState{} m.modal = modalState{}
if pending.kind == opCopy { if pending.kind == opCopy || pending.kind == opMove {
if m.copyJob != nil { if m.copyJob != nil {
m.status = "Copy is already running" m.status = "Transfer is already running"
return m, nil return m, nil
} }
m.busy = true m.busy = true
return m, m.startCopyJob(pending.sourcePath, pending.targetDir, pending.overwrite, pending.stats) return m, m.startCopyJob(pending.kind, pending.sourcePath, pending.targetDir, pending.overwrite, pending.stats)
} }
m.busy = true m.busy = true
return m, pending.cmd() return m, pending.cmd()
@ -528,7 +537,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
} }
m.copyJob.background = true m.copyJob.background = true
m.modal = modalState{} m.modal = modalState{}
m.status = "Copy continues in background" m.status = "Transfer continues in background"
return m, nil return m, nil
} }
return m, nil return m, nil
@ -704,9 +713,9 @@ func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
return m, nil return m, nil
} }
if kind == opCopy { if kind == opCopy || kind == opMove {
if m.copyJob != nil { if m.copyJob != nil {
m.status = "Copy is already running" m.status = "Transfer is already running"
return m, nil return m, nil
} }
@ -715,8 +724,8 @@ func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
overwrite = true overwrite = true
} }
m.busy = true m.busy = true
m.status = fmt.Sprintf("Calculating copy size for %s", selected.DisplayName()) m.status = fmt.Sprintf("Calculating %s size for %s", operationVerb(kind), selected.DisplayName())
return m, copyPlanCmd(selected.Path, targetDir, overwrite) return m, copyPlanCmd(kind, selected.Path, targetDir, overwrite)
} }
if exists && m.cfg.Behavior.ConfirmOverwrite { if exists && m.cfg.Behavior.ConfirmOverwrite {
@ -1340,13 +1349,13 @@ func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int)
} }
lines := []string{ lines := []string{
titleStyle.Render("Copying"), titleStyle.Render(progressTitle(job.kind)),
lineStyle.Render(fmt.Sprintf("From: %s", job.sourcePath)), lineStyle.Render(fmt.Sprintf("From: %s", job.sourcePath)),
lineStyle.Render(fmt.Sprintf("To: %s", job.targetPath)), lineStyle.Render(fmt.Sprintf("To: %s", job.targetPath)),
spacer, spacer,
lineStyle.Render(renderProgressBar(ratio, max(width-8, 10), palette)), lineStyle.Render(renderProgressBar(ratio, max(width-8, 10), palette)),
lineStyle.Render(fmt.Sprintf("Files: %d / %d", progress.FilesDone, progress.FilesTotal)), lineStyle.Render(fmt.Sprintf("Files: %d / %d", progress.FilesDone, progress.FilesTotal)),
lineStyle.Render(fmt.Sprintf("Data: %s / %s", formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true))), lineStyle.Render(fmt.Sprintf("Size: %s / %s", formatSize(progress.BytesDone, true), formatSize(progress.BytesTotal, true))),
} }
if strings.TrimSpace(progress.CurrentPath) != "" { if strings.TrimSpace(progress.CurrentPath) != "" {
@ -1490,10 +1499,11 @@ func dirSizeCmd(path string) tea.Cmd {
} }
} }
func copyPlanCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd { func copyPlanCmd(kind fileOpKind, sourcePath, targetDir string, overwrite bool) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
stats, err := vfs.CopyStats(sourcePath) stats, err := vfs.CopyStats(sourcePath)
return copyPlanMsg{ return copyPlanMsg{
kind: kind,
sourcePath: sourcePath, sourcePath: sourcePath,
targetDir: targetDir, targetDir: targetDir,
targetPath: filepath.Join(targetDir, filepath.Base(sourcePath)), targetPath: filepath.Join(targetDir, filepath.Base(sourcePath)),
@ -1516,12 +1526,13 @@ func dismissNoticeCmd(delay time.Duration) tea.Cmd {
}) })
} }
func (m *Model) startCopyJob(sourcePath, targetDir string, overwrite bool, stats vfs.TransferStats) tea.Cmd { func (m *Model) startCopyJob(kind fileOpKind, sourcePath, targetDir string, overwrite bool, stats vfs.TransferStats) tea.Cmd {
m.nextCopyJob++ m.nextCopyJob++
jobID := m.nextCopyJob jobID := m.nextCopyJob
targetPath := filepath.Join(targetDir, filepath.Base(sourcePath)) targetPath := filepath.Join(targetDir, filepath.Base(sourcePath))
m.copyJob = &copyJobState{ m.copyJob = &copyJobState{
id: jobID, id: jobID,
kind: kind,
sourcePath: sourcePath, sourcePath: sourcePath,
targetDir: targetDir, targetDir: targetDir,
targetPath: targetPath, targetPath: targetPath,
@ -1535,16 +1546,27 @@ func (m *Model) startCopyJob(sourcePath, targetDir string, overwrite bool, stats
}, },
} }
m.modal = modalState{kind: modalCopyProgress} m.modal = modalState{kind: modalCopyProgress}
m.status = "Copy started" m.status = strings.Title(operationVerb(kind)) + " started"
return tea.Batch( return tea.Batch(
func() tea.Msg { func() tea.Msg {
go func() { go func() {
target, err := vfs.CopyPathWithProgress(sourcePath, targetDir, overwrite, stats, func(progress vfs.CopyProgress) { var (
target string
err error
)
progressFn := func(progress vfs.CopyProgress) {
m.copyProgress <- copyProgressMsg{jobID: jobID, progress: progress} m.copyProgress <- copyProgressMsg{jobID: jobID, progress: progress}
}) }
switch kind {
case opMove:
target, err = vfs.MovePathWithProgress(sourcePath, targetDir, overwrite, stats, progressFn)
default:
target, err = vfs.CopyPathWithProgress(sourcePath, targetDir, overwrite, stats, progressFn)
}
m.copyProgress <- copyDoneMsg{ m.copyProgress <- copyDoneMsg{
jobID: jobID, jobID: jobID,
kind: kind,
sourcePath: sourcePath, sourcePath: sourcePath,
targetPath: target, targetPath: target,
err: err, err: err,
@ -1606,9 +1628,10 @@ func formatSize(size int64, human bool) string {
return fmt.Sprintf("%d", size) return fmt.Sprintf("%d", size)
} }
func formatCopyStatus(progress vfs.CopyProgress) string { func formatCopyStatus(kind fileOpKind, progress vfs.CopyProgress) string {
return fmt.Sprintf( return fmt.Sprintf(
"Copy in background: %d/%d files, %s/%s", "%s in background: %d/%d files, %s/%s",
strings.Title(operationVerb(kind)),
progress.FilesDone, progress.FilesDone,
progress.FilesTotal, progress.FilesTotal,
formatSize(progress.BytesDone, true), formatSize(progress.BytesDone, true),
@ -1616,6 +1639,28 @@ func formatCopyStatus(progress vfs.CopyProgress) string {
) )
} }
func progressTitle(kind fileOpKind) string {
switch kind {
case opMove:
return "Moving"
default:
return "Copying"
}
}
func operationDoneLabel(kind fileOpKind) string {
switch kind {
case opMove:
return "Moved"
case opCopy:
return "Copied"
case opDelete:
return "Deleted"
default:
return "Done"
}
}
func operationVerb(kind fileOpKind) string { func operationVerb(kind fileOpKind) string {
switch kind { switch kind {
case opCopy: case opCopy: