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) {
// 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",
})
}
}
}

View file

@ -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 {