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
This commit is contained in:
vrubelroman 2026-04-29 14:01:36 +03:00
parent 3229d9b263
commit 42c51f0ef5
2 changed files with 247 additions and 51 deletions

View file

@ -14,6 +14,9 @@ import (
) )
func ExtractArchiveToTemp(sourcePath string) (string, error) { func ExtractArchiveToTemp(sourcePath string) (string, error) {
// Count total files for progress reporting
totalFiles, totalBytes := countArchiveEntries(sourcePath)
tempDir, err := os.MkdirTemp("", "vcom-archive-") tempDir, err := os.MkdirTemp("", "vcom-archive-")
if err != nil { if err != nil {
return "", err return "", err
@ -26,15 +29,15 @@ func ExtractArchiveToTemp(sourcePath string) (string, error) {
sourceLower := strings.ToLower(sourcePath) sourceLower := strings.ToLower(sourcePath)
switch { switch {
case strings.HasSuffix(sourceLower, ".zip"): 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) return cleanupOnErr(err)
} }
case strings.HasSuffix(sourceLower, ".tar"): 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) return cleanupOnErr(err)
} }
case strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"): 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) return cleanupOnErr(err)
} }
default: default:
@ -46,28 +49,96 @@ func ExtractArchiveToTemp(sourcePath string) (string, error) {
// ExtractArchiveToDir extracts an archive to the specified target directory. // ExtractArchiveToDir extracts an archive to the specified target directory.
// Unlike ExtractArchiveToTemp, it extracts directly to targetDir without // Unlike ExtractArchiveToTemp, it extracts directly to targetDir without
// creating a temporary directory. // creating a temporary directory. The progress callback is called after each
func ExtractArchiveToDir(sourcePath, targetDir string) error { // file is extracted; it may be nil.
func ExtractArchiveToDir(sourcePath, targetDir string, progress func(CopyProgress)) error {
totalFiles, totalBytes := countArchiveEntries(sourcePath)
sourceLower := strings.ToLower(sourcePath) sourceLower := strings.ToLower(sourcePath)
switch { switch {
case strings.HasSuffix(sourceLower, ".zip"): case strings.HasSuffix(sourceLower, ".zip"):
return extractZipArchive(sourcePath, targetDir) return extractZipArchive(sourcePath, targetDir, progress, totalFiles, totalBytes)
case strings.HasSuffix(sourceLower, ".tar"): 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"): case strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"):
return extractTarArchive(sourcePath, targetDir, true) return extractTarArchive(sourcePath, targetDir, true, progress, totalFiles, totalBytes)
default: default:
return fmt.Errorf("archive format is not supported: %s", filepath.Ext(sourcePath)) 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) reader, err := zip.OpenReader(sourcePath)
if err != nil { if err != nil {
return err return err
} }
defer reader.Close() defer reader.Close()
var filesDone int64
for _, file := range reader.File { for _, file := range reader.File {
relPath, ok := safeArchivePath(file.Name) relPath, ok := safeArchivePath(file.Name)
if !ok { if !ok {
@ -94,11 +165,22 @@ func extractZipArchive(sourcePath string, targetDir string) error {
return err return err
} }
src.Close() src.Close()
filesDone++
if progress != nil {
progress(CopyProgress{
FilesDone: int(filesDone),
FilesTotal: int(totalFiles),
BytesDone: 0,
BytesTotal: totalBytes,
Stage: "Extracting data",
})
}
} }
return nil 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) file, err := os.Open(sourcePath)
if err != nil { if err != nil {
return err return err
@ -116,6 +198,7 @@ func extractTarArchive(sourcePath string, targetDir string, gzipped bool) error
} }
tarReader := tar.NewReader(reader) tarReader := tar.NewReader(reader)
var filesDone int64
for { for {
header, err := tarReader.Next() header, err := tarReader.Next()
if err == io.EOF { 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 { if err := writeArchiveFile(fullPath, tarReader, os.FileMode(header.Mode)); err != nil {
return err return err
} }
filesDone++
if progress != nil {
progress(CopyProgress{
FilesDone: int(filesDone),
FilesTotal: int(totalFiles),
BytesDone: 0,
BytesTotal: totalBytes,
Stage: "Extracting data",
})
}
} }
} }

View file

@ -2,6 +2,7 @@ package ui
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -178,6 +179,7 @@ type copyJobState struct {
type archiveJobState struct { type archiveJobState struct {
id int id int
kind string // "archive" for creation, "extract" for extraction
sourcePaths []string sourcePaths []string
targetPath string targetPath string
progress vfs.CopyProgress progress vfs.CopyProgress
@ -513,21 +515,6 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case opExecute: case opExecute:
m.status = "Executable closed" m.status = "Executable closed"
return m, tea.Batch(m.loadPreviewCmd(), enableMouseCmd()) 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 // 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" note := "confirm-actions"
if msg.srcClient == nil { 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( m.openConfirmModal(
title, title,
@ -691,14 +678,22 @@ 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 {
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()) activeSelection := selectedName(m.activePane())
_ = m.reloadPane(PaneLeft, activeSelection) _ = m.reloadPane(PaneLeft, activeSelection)
_ = m.reloadPane(PaneRight, activeSelection) _ = m.reloadPane(PaneRight, activeSelection)
if msg.err == context.Canceled { if msg.err == context.Canceled {
m.status = "Archiving cancelled" if m.archiveJob.kind == "extract" {
m.status = "Extraction cancelled"
} else {
m.status = "Archiving cancelled"
}
} else { } 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 m.archiveJob = nil
if m.modal.kind == modalArchiveProgress { if m.modal.kind == modalArchiveProgress {
@ -707,6 +702,33 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, m.loadPreviewCmd() 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)) 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) 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()) activeSelection := selectedName(m.activePane())
@ -1418,15 +1440,18 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
// Extract archive // Extract archive
if pending.kind == opExtractArchive { 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) 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 m.busy = true
log.Printf("[MODAL] Confirm — %s sources=%d", operationVerb(pending.kind), len(pending.sourcePaths)) log.Printf("[MODAL] Confirm — %s sources=%d", operationVerb(pending.kind), len(pending.sourcePaths))
return m, pending.cmd() return m, pending.cmd()
case msg.String() == "d": case msg.String() == "d" || msg.String() == "D":
if m.modal.pending == nil { if m.modal.pending == nil {
return m, 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.title = "Move selected entr" + pluralSuffix(len(sources), "y", "ies") + " to trash?"
} }
m.modal.note = fmt.Sprintf( 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, m.deleteKind,
) )
return m, nil return m, nil
@ -1473,7 +1498,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.busy = true m.busy = true
log.Printf("[MODAL] Archive confirmed — sources=%d targetDir=%s format=%s", len(pending.sourcePaths), pending.targetDir, m.archiveFormat) 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) 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 { switch m.archiveFormat {
case "zip": case "zip":
m.archiveFormat = "tar" m.archiveFormat = "tar"
@ -1483,7 +1508,7 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.archiveFormat = "zip" m.archiveFormat = "zip"
} }
m.modal.note = fmt.Sprintf( 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, m.archiveFormat,
) )
return m, nil 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 // No plan needed — show a simple confirm dialog with mode toggle
title := "Permanently delete selected entr" + pluralSuffix(len(sources), "y", "ies") + "?" title := "Permanently delete selected entr" + pluralSuffix(len(sources), "y", "ies") + "?"
body := fmt.Sprintf("Items: %d", len(sources)) 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{ pending := pendingOperation{
kind: opPermanentDelete, kind: opPermanentDelete,
sourcePaths: append([]string(nil), sources...), sourcePaths: append([]string(nil), sources...),
@ -3956,17 +3981,33 @@ func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, widt
progress := job.progress progress := job.progress
ratio := 0.0 ratio := 0.0
if progress.BytesTotal > 0 { if job.kind == "extract" {
ratio = float64(progress.BytesDone) / float64(progress.BytesTotal) // 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 stage := progress.Stage
if 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{ lines := []string{
titleStyle.Render("Archiving"), titleStyle.Render(title),
spacer, spacer,
renderProgressBarLine(ratio, contentWidth, palette), renderProgressBarLine(ratio, contentWidth, palette),
spacer, spacer,
@ -3974,13 +4015,26 @@ func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, widt
renderProgressStatLine("Stage:", stage, contentWidth, palette), renderProgressStatLine("Stage:", stage, contentWidth, palette),
spacer, spacer,
renderProgressStatLine("Files:", fmt.Sprintf("%d / %d", progress.FilesDone, progress.FilesTotal), 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),
// 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, spacer,
renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle), renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle),
} )
if job.background { 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")) return box.Render(strings.Join(lines, "\n"))
@ -4550,6 +4604,7 @@ func (m *Model) startArchiveJob(sourcePaths []string, targetDir string, format s
m.archiveJob = &archiveJobState{ m.archiveJob = &archiveJobState{
id: jobID, id: jobID,
kind: "archive",
sourcePaths: append([]string(nil), sourcePaths...), sourcePaths: append([]string(nil), sourcePaths...),
targetPath: archivePath, targetPath: archivePath,
progress: vfs.CopyProgress{ 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 { func moveCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
targetPath, err := vfs.MovePath(sourcePath, targetDir, overwrite) 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 { func (m *Model) mkdirCmd(parent, name string) tea.Cmd {
// Remote mkdir via SFTP // Remote mkdir via SFTP
if mount, ok := m.activePane().CurrentRemote(); ok { if mount, ok := m.activePane().CurrentRemote(); ok {