From 42c51f0ef550d7a6366674673a61a15e4142e0f7 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 29 Apr 2026 14:01:36 +0300 Subject: [PATCH] feat: add extraction progress dialog (F11/e) - ExtractArchiveToDir now accepts progress callback for file-by-file reporting - Added countArchiveEntries/countZipEntries/countTarEntries helpers - Added startExtractJob method following startArchiveJob pattern - archiveJobState now has kind field ('archive'/'extract') - archiveDoneMsg handler reloads only passive pane for extraction - renderArchiveProgressModal shows file-based progress for extraction (no size/speed) - Removed old synchronous extractArchiveCmd() and opExtractArchive case in opMsg --- internal/fs/archive.go | 113 ++++++++++++++++++++++--- internal/ui/model.go | 185 ++++++++++++++++++++++++++++++++--------- 2 files changed, 247 insertions(+), 51 deletions(-) diff --git a/internal/fs/archive.go b/internal/fs/archive.go index 7db9083..70ab112 100644 --- a/internal/fs/archive.go +++ b/internal/fs/archive.go @@ -14,6 +14,9 @@ import ( ) func ExtractArchiveToTemp(sourcePath string) (string, error) { + // Count total files for progress reporting + totalFiles, totalBytes := countArchiveEntries(sourcePath) + tempDir, err := os.MkdirTemp("", "vcom-archive-") if err != nil { return "", err @@ -26,15 +29,15 @@ func ExtractArchiveToTemp(sourcePath string) (string, error) { sourceLower := strings.ToLower(sourcePath) switch { case strings.HasSuffix(sourceLower, ".zip"): - if err := extractZipArchive(sourcePath, tempDir); err != nil { + if err := extractZipArchive(sourcePath, tempDir, nil, totalFiles, totalBytes); err != nil { return cleanupOnErr(err) } case strings.HasSuffix(sourceLower, ".tar"): - if err := extractTarArchive(sourcePath, tempDir, false); err != nil { + if err := extractTarArchive(sourcePath, tempDir, false, nil, totalFiles, totalBytes); err != nil { return cleanupOnErr(err) } case strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"): - if err := extractTarArchive(sourcePath, tempDir, true); err != nil { + if err := extractTarArchive(sourcePath, tempDir, true, nil, totalFiles, totalBytes); err != nil { return cleanupOnErr(err) } default: @@ -46,28 +49,96 @@ func ExtractArchiveToTemp(sourcePath string) (string, error) { // ExtractArchiveToDir extracts an archive to the specified target directory. // Unlike ExtractArchiveToTemp, it extracts directly to targetDir without -// creating a temporary directory. -func ExtractArchiveToDir(sourcePath, targetDir string) error { +// creating a temporary directory. The progress callback is called after each +// file is extracted; it may be nil. +func ExtractArchiveToDir(sourcePath, targetDir string, progress func(CopyProgress)) error { + totalFiles, totalBytes := countArchiveEntries(sourcePath) sourceLower := strings.ToLower(sourcePath) switch { case strings.HasSuffix(sourceLower, ".zip"): - return extractZipArchive(sourcePath, targetDir) + return extractZipArchive(sourcePath, targetDir, progress, totalFiles, totalBytes) case strings.HasSuffix(sourceLower, ".tar"): - return extractTarArchive(sourcePath, targetDir, false) + return extractTarArchive(sourcePath, targetDir, false, progress, totalFiles, totalBytes) case strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"): - return extractTarArchive(sourcePath, targetDir, true) + return extractTarArchive(sourcePath, targetDir, true, progress, totalFiles, totalBytes) default: return fmt.Errorf("archive format is not supported: %s", filepath.Ext(sourcePath)) } } -func extractZipArchive(sourcePath string, targetDir string) error { +// countArchiveEntries counts the total number of files and total uncompressed +// bytes in an archive without extracting. Used for progress reporting. +func countArchiveEntries(sourcePath string) (int64, int64) { + sourceLower := strings.ToLower(sourcePath) + switch { + case strings.HasSuffix(sourceLower, ".zip"): + return countZipEntries(sourcePath) + case strings.HasSuffix(sourceLower, ".tar"), strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"): + return countTarEntries(sourcePath) + default: + return 0, 0 + } +} + +func countZipEntries(sourcePath string) (int64, int64) { + r, err := zip.OpenReader(sourcePath) + if err != nil { + return 0, 0 + } + defer r.Close() + var files, bytes int64 + for _, f := range r.File { + if !f.FileInfo().IsDir() { + files++ + bytes += int64(f.UncompressedSize64) + } + } + return files, bytes +} + +func countTarEntries(sourcePath string) (int64, int64) { + f, err := os.Open(sourcePath) + if err != nil { + return 0, 0 + } + defer f.Close() + + var reader io.Reader = f + if strings.HasSuffix(strings.ToLower(sourcePath), ".tar.gz") || strings.HasSuffix(strings.ToLower(sourcePath), ".tgz") { + gr, err := gzip.NewReader(f) + if err != nil { + return 0, 0 + } + defer gr.Close() + reader = gr + } + + tarReader := tar.NewReader(reader) + var files, bytes int64 + for { + hdr, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + break + } + if hdr.Typeflag == tar.TypeReg || hdr.Typeflag == tar.TypeRegA { + files++ + bytes += hdr.Size + } + } + return files, bytes +} + +func extractZipArchive(sourcePath string, targetDir string, progress func(CopyProgress), totalFiles, totalBytes int64) error { reader, err := zip.OpenReader(sourcePath) if err != nil { return err } defer reader.Close() + var filesDone int64 for _, file := range reader.File { relPath, ok := safeArchivePath(file.Name) if !ok { @@ -94,11 +165,22 @@ func extractZipArchive(sourcePath string, targetDir string) error { return err } src.Close() + + filesDone++ + if progress != nil { + progress(CopyProgress{ + FilesDone: int(filesDone), + FilesTotal: int(totalFiles), + BytesDone: 0, + BytesTotal: totalBytes, + Stage: "Extracting data", + }) + } } return nil } -func extractTarArchive(sourcePath string, targetDir string, gzipped bool) error { +func extractTarArchive(sourcePath string, targetDir string, gzipped bool, progress func(CopyProgress), totalFiles, totalBytes int64) error { file, err := os.Open(sourcePath) if err != nil { return err @@ -116,6 +198,7 @@ func extractTarArchive(sourcePath string, targetDir string, gzipped bool) error } tarReader := tar.NewReader(reader) + var filesDone int64 for { header, err := tarReader.Next() if err == io.EOF { @@ -142,6 +225,16 @@ func extractTarArchive(sourcePath string, targetDir string, gzipped bool) error if err := writeArchiveFile(fullPath, tarReader, os.FileMode(header.Mode)); err != nil { return err } + filesDone++ + if progress != nil { + progress(CopyProgress{ + FilesDone: int(filesDone), + FilesTotal: int(totalFiles), + BytesDone: 0, + BytesTotal: totalBytes, + Stage: "Extracting data", + }) + } } } diff --git a/internal/ui/model.go b/internal/ui/model.go index 261ebe4..a0a9928 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -2,6 +2,7 @@ package ui import ( "context" + "errors" "fmt" "io" "log" @@ -178,6 +179,7 @@ type copyJobState struct { type archiveJobState struct { id int + kind string // "archive" for creation, "extract" for extraction sourcePaths []string targetPath string progress vfs.CopyProgress @@ -513,21 +515,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case opExecute: m.status = "Executable closed" return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd()) - case opExtractArchive: - if msg.err != nil { - log.Printf("[ERROR] opMsg: ExtractArchive failed — err=%v", msg.err) - m.status = msg.err.Error() - return m, nil - } - log.Printf("[ACTION] opMsg: ExtractArchive done — source=%s target=%s", msg.sourcePath, msg.targetPath) - m.status = fmt.Sprintf("Extracted to %s", msg.targetPath) - // Reload the target (passive) pane so extracted files appear - targetID := PaneRight - if m.active == PaneRight { - targetID = PaneLeft - } - _ = m.reloadPane(targetID, "") - return m, nil } // Reload panes — use remote reload for remote mounts @@ -622,7 +609,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } note := "confirm-actions" if msg.srcClient == nil { - note = fmt.Sprintf("Mode: %s (d to change)\nEnter / y to confirm, Esc / n to cancel", m.deleteKind) + note = fmt.Sprintf("Mode: %s (D/d to change)\nEnter / y to confirm, Esc / n to cancel", m.deleteKind) } m.openConfirmModal( title, @@ -691,14 +678,22 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.busy = false if msg.err != nil { - log.Printf("[ERROR] archiveDoneMsg: job=%d err=%v", msg.jobID, msg.err) + log.Printf("[ERROR] archiveDoneMsg: job=%d kind=%s err=%v", msg.jobID, m.archiveJob.kind, msg.err) activeSelection := selectedName(m.activePane()) _ = m.reloadPane(PaneLeft, activeSelection) _ = m.reloadPane(PaneRight, activeSelection) if msg.err == context.Canceled { - m.status = "Archiving cancelled" + if m.archiveJob.kind == "extract" { + m.status = "Extraction cancelled" + } else { + m.status = "Archiving cancelled" + } } else { - m.status = fmt.Sprintf("Archiving failed: %v", msg.err) + if m.archiveJob.kind == "extract" { + m.status = fmt.Sprintf("Extraction failed: %v", msg.err) + } else { + m.status = fmt.Sprintf("Archiving failed: %v", msg.err) + } } m.archiveJob = nil if m.modal.kind == modalArchiveProgress { @@ -707,6 +702,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, m.loadPreviewCmd() } + // Extraction completion — reload only the passive pane + if m.archiveJob.kind == "extract" { + log.Printf("[DONE] extractDoneMsg: job=%d targetDir=%s source=%s", msg.jobID, msg.targetPath, msg.sourcePaths[0]) + m.status = fmt.Sprintf("Extracted to %s", msg.targetPath) + targetID := PaneRight + if m.active == PaneRight { + targetID = PaneLeft + } + background := m.archiveJob.background + m.archiveJob = nil + cmd := m.loadPreviewCmd() + _ = m.reloadPane(targetID, "") + if m.modal.kind == modalArchiveProgress { + m.modal = modalState{} + } + if background { + m.modal = modalState{ + kind: modalNotice, + title: "Extraction complete", + body: "Archive extracted successfully.", + note: "Press Esc to close", + } + } + return m, cmd + } + + // Archive creation completion log.Printf("[DONE] archiveDoneMsg: job=%d targetPath=%s sources=%d", msg.jobID, msg.targetPath, len(msg.sourcePaths)) m.status = fmt.Sprintf("Archived %d entr%s to %s", len(msg.sourcePaths), pluralSuffix(len(msg.sourcePaths), "y", "ies"), msg.targetPath) activeSelection := selectedName(m.activePane()) @@ -1418,15 +1440,18 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Extract archive if pending.kind == opExtractArchive { - m.busy = true + if m.archiveJob != nil { + m.status = "Extraction is already running" + return m, nil + } log.Printf("[MODAL] Confirm — extract archive source=%s target=%s", pending.sourcePaths[0], pending.targetDir) - return m, extractArchiveCmd(pending.sourcePaths[0], pending.targetDir) + return m, m.startExtractJob(pending.sourcePaths[0], pending.targetDir) } m.busy = true log.Printf("[MODAL] Confirm — %s sources=%d", operationVerb(pending.kind), len(pending.sourcePaths)) return m, pending.cmd() - case msg.String() == "d": + case msg.String() == "d" || msg.String() == "D": if m.modal.pending == nil { return m, nil } @@ -1444,7 +1469,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.modal.title = "Move selected entr" + pluralSuffix(len(sources), "y", "ies") + " to trash?" } m.modal.note = fmt.Sprintf( - "Mode: %s (d to change)\nEnter / y to confirm, Esc / n to cancel", + "Mode: %s (D/d to change)\nEnter / y to confirm, Esc / n to cancel", m.deleteKind, ) return m, nil @@ -1473,7 +1498,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.busy = true log.Printf("[MODAL] Archive confirmed — sources=%d targetDir=%s format=%s", len(pending.sourcePaths), pending.targetDir, m.archiveFormat) return m, m.startArchiveJob(pending.sourcePaths, pending.targetDir, m.archiveFormat, pending.stats) - case msg.String() == "f": + case msg.String() == "f" || msg.String() == "F": switch m.archiveFormat { case "zip": m.archiveFormat = "tar" @@ -1483,7 +1508,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.archiveFormat = "zip" } m.modal.note = fmt.Sprintf( - "Format: %s (f to change)\nEnter / y to confirm, Esc / n to cancel", + "Format: %s (F/f to change)\nEnter / y to confirm, Esc / n to cancel", m.archiveFormat, ) return m, nil @@ -2263,7 +2288,7 @@ func (m *Model) handleDelete() (tea.Model, tea.Cmd) { // 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 to change)\nEnter / y to confirm, Esc / n to cancel", m.deleteKind) + 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...), @@ -3956,17 +3981,33 @@ func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, widt progress := job.progress ratio := 0.0 - if progress.BytesTotal > 0 { - ratio = float64(progress.BytesDone) / float64(progress.BytesTotal) + if job.kind == "extract" { + // Extraction: progress by file count (no byte tracking during extraction) + if progress.FilesTotal > 0 { + ratio = float64(progress.FilesDone) / float64(progress.FilesTotal) + } + } else { + if progress.BytesTotal > 0 { + ratio = float64(progress.BytesDone) / float64(progress.BytesTotal) + } } stage := progress.Stage if stage == "" { - stage = "Archiving data" + if job.kind == "extract" { + stage = "Extracting data" + } else { + stage = "Archiving data" + } + } + + title := "Archiving" + if job.kind == "extract" { + title = "Extracting" } lines := []string{ - titleStyle.Render("Archiving"), + titleStyle.Render(title), spacer, renderProgressBarLine(ratio, contentWidth, palette), spacer, @@ -3974,13 +4015,26 @@ func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, widt renderProgressStatLine("Stage:", stage, 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), + } + + // Only show size and speed for archive creation (not tracked during extraction) + if job.kind != "extract" { + lines = append(lines, + 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), + ) + } + + lines = append(lines, spacer, renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle), - } + ) if job.background { - lines = append(lines, mutedStyle.Render("Archive continues in background")) + if job.kind == "extract" { + lines = append(lines, mutedStyle.Render("Extraction continues in background")) + } else { + lines = append(lines, mutedStyle.Render("Archive continues in background")) + } } return box.Render(strings.Join(lines, "\n")) @@ -4550,6 +4604,7 @@ func (m *Model) startArchiveJob(sourcePaths []string, targetDir string, format s m.archiveJob = &archiveJobState{ id: jobID, + kind: "archive", sourcePaths: append([]string(nil), sourcePaths...), targetPath: archivePath, progress: vfs.CopyProgress{ @@ -4596,6 +4651,61 @@ func (m *Model) startArchiveJob(sourcePaths []string, targetDir string, format s ) } +func (m *Model) startExtractJob(sourcePath, targetDir string) tea.Cmd { + m.nextArchiveJob++ + jobID := m.nextArchiveJob + + m.archiveJob = &archiveJobState{ + id: jobID, + kind: "extract", + sourcePaths: []string{sourcePath}, + targetPath: targetDir, + progress: vfs.CopyProgress{ + FilesDone: 0, + FilesTotal: 0, + BytesDone: 0, + BytesTotal: 0, + CurrentPath: sourcePath, + }, + startedAt: time.Now(), + } + m.modal = modalState{kind: modalArchiveProgress} + m.status = "Extracting started" + + return tea.Batch( + func() tea.Msg { + go func() { + emitProgress := func(p vfs.CopyProgress) { + m.archiveProgress <- archiveProgressMsg{ + jobID: jobID, + progress: p, + } + } + err := vfs.ExtractArchiveToDir(sourcePath, targetDir, emitProgress) + if err != nil { + if errors.Is(err, context.Canceled) { + err = context.Canceled + } + m.archiveProgress <- archiveDoneMsg{ + jobID: jobID, + sourcePaths: []string{sourcePath}, + targetPath: targetDir, + err: err, + } + return + } + m.archiveProgress <- archiveDoneMsg{ + jobID: jobID, + sourcePaths: []string{sourcePath}, + targetPath: targetDir, + } + }() + return nil + }, + waitArchiveProgressCmd(m.archiveProgress), + ) +} + func moveCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd { return func() tea.Msg { targetPath, err := vfs.MovePath(sourcePath, targetDir, overwrite) @@ -4669,13 +4779,6 @@ func deletePlanPermanentCmd(sourcePaths []string) tea.Cmd { } } -func extractArchiveCmd(sourcePath, targetDir string) tea.Cmd { - return func() tea.Msg { - err := vfs.ExtractArchiveToDir(sourcePath, targetDir) - return opMsg{kind: opExtractArchive, sourcePath: sourcePath, targetPath: targetDir, err: err} - } -} - func (m *Model) mkdirCmd(parent, name string) tea.Cmd { // Remote mkdir via SFTP if mount, ok := m.activePane().CurrentRemote(); ok {