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)
This commit is contained in:
vrubelroman 2026-05-12 17:39:22 +03:00
parent bd157b41b0
commit 8352441bda
2 changed files with 145 additions and 102 deletions

View file

@ -33,6 +33,16 @@ type copyProgressState struct {
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) {
@ -73,15 +83,15 @@ func CopyPathWithProgressContext(ctx context.Context, srcPath string, dstDir str
if progress == nil {
progress = func(CopyProgress) {}
}
if stats.FilesTotal == 0 && stats.BytesTotal == 0 {
resolved, err := CopyStats(srcPath)
if err != nil {
return "", err
tracker := copyProgressState{
ctx: ctx,
stats: stats,
callback: progress,
stage: "Scanning files...",
discover: stats.FilesTotal == 0,
}
stats = resolved
}
tracker := copyProgressState{ctx: ctx, stats: stats, callback: progress}
tracker.emit(srcPath, true)
tracker.stage = "Copying files..."
cleanupOnErr := func(copyErr error) (string, error) {
if copyErr != nil {
@ -144,16 +154,7 @@ func CopyStats(srcPath string) (TransferStats, error) {
if d.IsDir() {
return nil
}
info, err := os.Lstat(current)
if err != nil {
return err
}
stats.FilesTotal++
if info.Mode()&os.ModeSymlink == 0 {
stats.BytesTotal += info.Size()
}
return nil
})
if err != nil {
@ -204,14 +205,6 @@ func MovePathWithProgressContext(ctx context.Context, srcPath string, dstDir str
if err := ctx.Err(); err != nil {
return "", err
}
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 {
progress(CopyProgress{
FilesDone: stats.FilesTotal,
@ -373,6 +366,17 @@ func copyDir(srcDir string, dstDir string, tracker *copyProgressState) error {
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 {
@ -496,13 +500,17 @@ func (s *copyProgressState) emit(currentPath string, force bool) {
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: "Transferring data",
Stage: stage,
})
}

View file

@ -2355,22 +2355,7 @@ func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
log.Printf("[ACTION] Transfer: kind=%d active=%s srcPath=%s dstPath=%s srcRemote=%v dstRemote=%v sources=%v",
kind, m.active, srcPane.Path, dstPane.Path, srcHasRemote, dstHasRemote, sources)
// Remote-involved transfer (Local↔Remote or Remote→Remote)
if srcHasRemote || dstHasRemote {
if m.copyJob != nil {
log.Printf("[SKIP] Transfer: already running (remote)")
m.status = "Transfer is already running"
return m, nil
}
log.Printf("[ACTION] Transfer: remote copyPlan — targetDir=%s", targetDir)
m.busy = true
m.status = fmt.Sprintf("Calculating %s size", operationVerb(kind))
return m, m.remoteCopyPlanCmd(kind, sources, targetDir,
srcHasRemote, dstHasRemote, srcRemote.Client, dstRemote.Client)
}
// Local-only transfer (existing logic)
// Check for existing targets (fast — one Stat per top-level item)
existingTargets := 0
for _, sourcePath := range sources {
targetPath := filepath.Join(targetDir, filepath.Base(sourcePath))
@ -2384,23 +2369,45 @@ func (m *Model) handleTransfer(kind fileOpKind) (tea.Model, tea.Cmd) {
existingTargets++
}
}
if kind == opCopy || kind == opMove {
if m.copyJob != nil {
log.Printf("[SKIP] Transfer: already running (local)")
m.status = "Transfer is already running"
return m, nil
}
overwrite := existingTargets > 0
if existingTargets > 0 && !m.cfg.Behavior.ConfirmOverwrite {
overwrite = true
}
log.Printf("[ACTION] Transfer: local copyPlan — targetDir=%s existingTargets=%d overwrite=%v", targetDir, existingTargets, overwrite)
m.busy = true
m.status = fmt.Sprintf("Calculating %s size", operationVerb(kind))
return m, copyPlanCmd(kind, sources, targetDir, overwrite, existingTargets)
// Show confirm dialog immediately (no plan — skip walking & counting files)
verb := strings.Title(operationVerb(kind))
title := fmt.Sprintf("%s selected entry?", verb)
if srcHasRemote || dstHasRemote {
title = fmt.Sprintf("%s selected entry via SFTP?", verb)
}
bodyLines := []string{
fmt.Sprintf("Items: %d", len(sources)),
fmt.Sprintf("Source: %s", srcPane.Path),
fmt.Sprintf("Target: %s", targetDir),
}
if existingTargets > 0 {
bodyLines = append(bodyLines, fmt.Sprintf("Overwrite: %d existing target(s)", existingTargets))
}
if srcHasRemote || dstHasRemote {
bodyLines = append(bodyLines, "")
if srcHasRemote {
hostName := srcRemote.Host.Name
bodyLines = append(bodyLines, fmt.Sprintf("Source host: %s", hostName))
}
if dstHasRemote {
hostName := dstRemote.Host.Name
bodyLines = append(bodyLines, fmt.Sprintf("Target host: %s", hostName))
}
}
m.openConfirmModal(title, strings.Join(bodyLines, "\n"), "confirm-actions", pendingOperation{
kind: kind,
sourcePaths: append([]string(nil), sources...),
targetDir: targetDir,
overwrite: overwrite,
existingTargets: existingTargets,
srcClient: srcRemote.Client,
dstClient: dstRemote.Client,
})
return m, nil
}
@ -2444,37 +2451,36 @@ func (m *Model) handleDelete() (tea.Model, tea.Cmd) {
}
log.Printf("[ACTION] Delete: sources=%v remote=%v", sources, m.activePane().InRemote())
m.busy = false
// Remote delete via SFTP (no trash on remote — always permanent)
if mount, ok := m.activePane().CurrentRemote(); ok {
log.Printf("[ACTION] Delete: remote — sources=%v", sources)
m.busy = true
m.status = "Calculating delete size"
return m, m.remoteDeletePlanCmd(sources, mount.Client)
title := "Delete selected entr" + pluralSuffix(len(sources), "y", "ies") + " from remote?"
body := fmt.Sprintf("Items: %d", len(sources))
note := "Enter / y to confirm, Esc / n to cancel"
m.openConfirmModal(title, body, note, pendingOperation{
kind: opDelete,
sourcePaths: append([]string(nil), sources...),
srcClient: mount.Client,
})
return m, nil
}
// Default to permanent delete
m.deleteKind = "permanent"
if !m.cfg.Behavior.ConfirmDelete {
// No plan needed — show a simple confirm dialog with mode toggle
// Show confirm dialog with mode toggle (no plan — skip counting files)
title := "Permanently delete selected entr" + pluralSuffix(len(sources), "y", "ies") + "?"
body := fmt.Sprintf("Items: %d", len(sources))
note := fmt.Sprintf("Mode: %s (D/d to change)\nEnter / y to confirm, Esc / n to cancel", m.deleteKind)
pending := pendingOperation{
kind: opPermanentDelete,
m.openConfirmModal(title, body, note, pendingOperation{
kind: opDelete,
sourcePaths: append([]string(nil), sources...),
}
m.openConfirmModal(title, body, note, pending)
})
return m, nil
}
// Compute plan first, then show confirm modal with mode toggle
m.busy = true
m.status = "Calculating delete size"
return m, trashPlanCmd(sources)
}
func (m *Model) handleUnpack() (tea.Model, tea.Cmd) {
selected, ok := m.activePane().Selected()
if !ok || !isArchiveEntry(selected) {
@ -4318,26 +4324,33 @@ func renderCopyProgressModal(job copyJobState, palette theme.Palette, width int)
BorderBackground(palette.Panel)
progress := job.progress
filesLabel := fmt.Sprintf("%d / ?", progress.FilesDone)
if progress.FilesTotal > 0 {
filesLabel = fmt.Sprintf("%d / %d", progress.FilesDone, progress.FilesTotal)
}
ratio := 0.0
if progress.BytesTotal > 0 {
ratio = float64(progress.BytesDone) / float64(progress.BytesTotal)
if progress.FilesTotal > 0 {
ratio = float64(progress.FilesDone) / float64(progress.FilesTotal)
}
lines := []string{
titleStyle.Render(progressTitle(job.kind)),
spacer,
renderProgressBarLine(ratio, contentWidth, palette),
spacer,
renderProgressPercentLine(ratio, contentWidth, palette),
renderProgressStatLine("Stage:", progressStageLabel(progress, job.kind), contentWidth, palette),
renderProgressStatLine("Target:", job.targetDir, contentWidth, palette),
spacer,
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,
renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle),
renderProgressStatLine("Files:", filesLabel, contentWidth, palette),
}
if progress.FilesTotal > 0 {
barAndPct := []string{
spacer,
renderProgressBarLine(ratio, contentWidth, palette),
renderProgressPercentLine(ratio, contentWidth, palette),
}
lines = append(lines, barAndPct...)
}
lines = append(lines, spacer, renderModalNoteLine("Cancel / c", contentWidth, palette, mutedStyle))
if job.background {
lines = append(lines, mutedStyle.Render("Transfer continues in background"))
}
@ -4932,6 +4945,7 @@ func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir st
BytesDone: 0,
BytesTotal: stats.BytesTotal,
CurrentPath: sourcePaths[0],
Stage: "Counting files...",
},
cancel: cancel,
startedAt: time.Now(),
@ -4942,30 +4956,50 @@ func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir st
return tea.Batch(
func() tea.Msg {
go func() {
var statErr error
doneFiles := 0
var doneBytes int64
for _, sourcePath := range sourcePaths {
entryStats, statErr := vfs.CopyStats(sourcePath)
if statErr != nil {
totalFiles := 0
// Phase 1: count files across all sources
entriesStats := make([]vfs.TransferStats, len(sourcePaths))
for i, sourcePath := range sourcePaths {
entryStats, err := vfs.CopyStats(sourcePath)
if err != nil {
m.copyProgress <- copyDoneMsg{
jobID: jobID,
kind: kind,
sourcePaths: append([]string(nil), sourcePaths...),
targetDir: targetDir,
err: statErr,
err: err,
}
return
}
entriesStats[i] = entryStats
totalFiles += entryStats.FilesTotal
m.copyProgress <- copyProgressMsg{
jobID: jobID,
progress: vfs.CopyProgress{
FilesDone: 0,
FilesTotal: totalFiles,
CurrentPath: sourcePath,
Stage: fmt.Sprintf("Counting files... %d found", totalFiles),
},
}
}
// Phase 2: copy with known total
for i, sourcePath := range sourcePaths {
entryStats := entriesStats[i]
progressFn := func(progress vfs.CopyProgress) {
m.copyProgress <- copyProgressMsg{
jobID: jobID,
progress: vfs.CopyProgress{
FilesDone: doneFiles + progress.FilesDone,
FilesTotal: stats.FilesTotal,
BytesDone: doneBytes + progress.BytesDone,
BytesTotal: stats.BytesTotal,
FilesTotal: totalFiles,
BytesDone: 0,
BytesTotal: 0,
CurrentPath: progress.CurrentPath,
Stage: progress.Stage,
Stage: "Copying files...",
},
}
}
@ -4986,7 +5020,6 @@ func (m *Model) startCopyJob(kind fileOpKind, sourcePaths []string, targetDir st
return
}
doneFiles += entryStats.FilesTotal
doneBytes += entryStats.BytesTotal
}
m.copyProgress <- copyDoneMsg{
jobID: jobID,
@ -5440,13 +5473,14 @@ func formatSize(size int64, human bool) string {
}
func formatCopyStatus(kind fileOpKind, progress vfs.CopyProgress) string {
filesLabel := fmt.Sprintf("%d/?", progress.FilesDone)
if progress.FilesTotal > 0 {
filesLabel = fmt.Sprintf("%d/%d", progress.FilesDone, progress.FilesTotal)
}
return fmt.Sprintf(
"%s in background: %d/%d files, %s/%s",
"%s in background: %s files",
strings.Title(operationVerb(kind)),
progress.FilesDone,
progress.FilesTotal,
formatSize(progress.BytesDone, true),
formatSize(progress.BytesTotal, true),
filesLabel,
)
}
@ -6263,6 +6297,7 @@ func (m *Model) startRemoteCopyJob(kind fileOpKind, sources []string, targetDir
BytesDone: 0,
BytesTotal: stats.BytesTotal,
CurrentPath: sources[0],
Stage: "Counting files...",
},
cancel: cancel,
startedAt: time.Now(),