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:
parent
3229d9b263
commit
42c51f0ef5
2 changed files with 247 additions and 51 deletions
|
|
@ -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",
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue