From 8352441bda87e2796ad61ca67222639bc8f8b940 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Tue, 12 May 2026 17:39:22 +0300 Subject: [PATCH] perf: skip plan phases for copy/delete, remove size tracking, show file count only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- internal/fs/ops.go | 58 +++++++------ internal/ui/model.go | 189 +++++++++++++++++++++++++------------------ 2 files changed, 145 insertions(+), 102 deletions(-) diff --git a/internal/fs/ops.go b/internal/fs/ops.go index 5d0aac8..47b02f3 100644 --- a/internal/fs/ops.go +++ b/internal/fs/ops.go @@ -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 - } - stats = resolved + tracker := copyProgressState{ + ctx: ctx, + stats: stats, + callback: progress, + stage: "Scanning files...", + discover: stats.FilesTotal == 0, } - 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, }) } diff --git a/internal/ui/model.go b/internal/ui/model.go index 6e8cce8..2e9a239 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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) + overwrite := existingTargets > 0 + if existingTargets > 0 && !m.cfg.Behavior.ConfirmOverwrite { + overwrite = true } + + // 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,35 +2451,34 @@ 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 - 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, - 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) + // 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) + m.openConfirmModal(title, body, note, pendingOperation{ + kind: opDelete, + sourcePaths: append([]string(nil), sources...), + }) + return m, nil } func (m *Model) handleUnpack() (tea.Model, tea.Cmd) { @@ -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(),