fix: add cancel button support for extraction; feat: delete progress dialog
- ExtractArchiveToDir now accepts context.Context for cancellation - extractZipArchive/extractTarArchive check ctx.Done() in extraction loops - startExtractJob creates context with cancel (C/c now works for extraction) - Added startDeleteJob method with per-file progress reporting - Local delete (trash/permanent) now shows progress dialog with B/b and C/c - renderArchiveProgressModal handles 'delete' kind (file-based progress, no speed) - archiveDoneMsg handles 'delete' completion (reload panes, clear marks)
This commit is contained in:
parent
42c51f0ef5
commit
3b9eb4afa5
2 changed files with 184 additions and 26 deletions
|
|
@ -26,18 +26,21 @@ func ExtractArchiveToTemp(sourcePath string) (string, error) {
|
|||
return "", extractErr
|
||||
}
|
||||
|
||||
// Use background context for temp extraction (no cancellation needed)
|
||||
ctx := context.Background()
|
||||
|
||||
sourceLower := strings.ToLower(sourcePath)
|
||||
switch {
|
||||
case strings.HasSuffix(sourceLower, ".zip"):
|
||||
if err := extractZipArchive(sourcePath, tempDir, nil, totalFiles, totalBytes); err != nil {
|
||||
if err := extractZipArchive(ctx, sourcePath, tempDir, nil, totalFiles, totalBytes); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
case strings.HasSuffix(sourceLower, ".tar"):
|
||||
if err := extractTarArchive(sourcePath, tempDir, false, nil, totalFiles, totalBytes); err != nil {
|
||||
if err := extractTarArchive(ctx, 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, nil, totalFiles, totalBytes); err != nil {
|
||||
if err := extractTarArchive(ctx, sourcePath, tempDir, true, nil, totalFiles, totalBytes); err != nil {
|
||||
return cleanupOnErr(err)
|
||||
}
|
||||
default:
|
||||
|
|
@ -50,17 +53,17 @@ 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. The progress callback is called after each
|
||||
// file is extracted; it may be nil.
|
||||
func ExtractArchiveToDir(sourcePath, targetDir string, progress func(CopyProgress)) error {
|
||||
// file is extracted; it may be nil. Cancellation is supported via ctx.
|
||||
func ExtractArchiveToDir(ctx context.Context, 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, progress, totalFiles, totalBytes)
|
||||
return extractZipArchive(ctx, sourcePath, targetDir, progress, totalFiles, totalBytes)
|
||||
case strings.HasSuffix(sourceLower, ".tar"):
|
||||
return extractTarArchive(sourcePath, targetDir, false, progress, totalFiles, totalBytes)
|
||||
return extractTarArchive(ctx, sourcePath, targetDir, false, progress, totalFiles, totalBytes)
|
||||
case strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"):
|
||||
return extractTarArchive(sourcePath, targetDir, true, progress, totalFiles, totalBytes)
|
||||
return extractTarArchive(ctx, sourcePath, targetDir, true, progress, totalFiles, totalBytes)
|
||||
default:
|
||||
return fmt.Errorf("archive format is not supported: %s", filepath.Ext(sourcePath))
|
||||
}
|
||||
|
|
@ -131,7 +134,7 @@ func countTarEntries(sourcePath string) (int64, int64) {
|
|||
return files, bytes
|
||||
}
|
||||
|
||||
func extractZipArchive(sourcePath string, targetDir string, progress func(CopyProgress), totalFiles, totalBytes int64) error {
|
||||
func extractZipArchive(ctx context.Context, sourcePath string, targetDir string, progress func(CopyProgress), totalFiles, totalBytes int64) error {
|
||||
reader, err := zip.OpenReader(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -140,6 +143,12 @@ func extractZipArchive(sourcePath string, targetDir string, progress func(CopyPr
|
|||
|
||||
var filesDone int64
|
||||
for _, file := range reader.File {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
relPath, ok := safeArchivePath(file.Name)
|
||||
if !ok {
|
||||
continue
|
||||
|
|
@ -180,7 +189,7 @@ func extractZipArchive(sourcePath string, targetDir string, progress func(CopyPr
|
|||
return nil
|
||||
}
|
||||
|
||||
func extractTarArchive(sourcePath string, targetDir string, gzipped bool, progress func(CopyProgress), totalFiles, totalBytes int64) error {
|
||||
func extractTarArchive(ctx context.Context, sourcePath string, targetDir string, gzipped bool, progress func(CopyProgress), totalFiles, totalBytes int64) error {
|
||||
file, err := os.Open(sourcePath)
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -200,6 +209,12 @@ func extractTarArchive(sourcePath string, targetDir string, gzipped bool, progre
|
|||
tarReader := tar.NewReader(reader)
|
||||
var filesDone int64
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
|
|
|
|||
|
|
@ -683,15 +683,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
_ = m.reloadPane(PaneLeft, activeSelection)
|
||||
_ = m.reloadPane(PaneRight, activeSelection)
|
||||
if msg.err == context.Canceled {
|
||||
if m.archiveJob.kind == "extract" {
|
||||
switch m.archiveJob.kind {
|
||||
case "extract":
|
||||
m.status = "Extraction cancelled"
|
||||
} else {
|
||||
case "delete":
|
||||
m.status = "Delete cancelled"
|
||||
default:
|
||||
m.status = "Archiving cancelled"
|
||||
}
|
||||
} else {
|
||||
if m.archiveJob.kind == "extract" {
|
||||
switch m.archiveJob.kind {
|
||||
case "extract":
|
||||
m.status = fmt.Sprintf("Extraction failed: %v", msg.err)
|
||||
} else {
|
||||
case "delete":
|
||||
m.status = fmt.Sprintf("Delete failed: %v", msg.err)
|
||||
default:
|
||||
m.status = fmt.Sprintf("Archiving failed: %v", msg.err)
|
||||
}
|
||||
}
|
||||
|
|
@ -728,6 +734,37 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
return m, cmd
|
||||
}
|
||||
|
||||
// Delete completion — reload both panes, clear marks
|
||||
if m.archiveJob.kind == "delete" {
|
||||
log.Printf("[DONE] deleteDoneMsg: job=%d sources=%d", msg.jobID, len(msg.sourcePaths))
|
||||
sourceCount := len(m.archiveJob.sourcePaths)
|
||||
verb := "moved to trash"
|
||||
if m.archiveJob.progress.Stage == "delete permanently" {
|
||||
verb = "deleted"
|
||||
}
|
||||
m.status = fmt.Sprintf("%d entr%s %s", sourceCount, pluralSuffix(sourceCount, "y", "ies"), verb)
|
||||
activeSelection := selectedName(m.activePane())
|
||||
_ = m.reloadPane(PaneLeft, activeSelection)
|
||||
_ = m.reloadPane(PaneRight, activeSelection)
|
||||
background := m.archiveJob.background
|
||||
m.archiveJob = nil
|
||||
m.activePane().ClearMarks()
|
||||
|
||||
cmd := m.loadPreviewCmd()
|
||||
if m.modal.kind == modalArchiveProgress {
|
||||
m.modal = modalState{}
|
||||
}
|
||||
if background {
|
||||
m.modal = modalState{
|
||||
kind: modalNotice,
|
||||
title: "Delete complete",
|
||||
body: fmt.Sprintf("%d entr%s %s.", sourceCount, pluralSuffix(sourceCount, "y", "ies"), verb),
|
||||
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)
|
||||
|
|
@ -1438,6 +1475,16 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
}
|
||||
|
||||
// Local delete with progress dialog
|
||||
if (pending.kind == opDelete || pending.kind == opPermanentDelete) && pending.srcClient == nil {
|
||||
if m.archiveJob != nil {
|
||||
m.status = "Delete is already running"
|
||||
return m, nil
|
||||
}
|
||||
log.Printf("[MODAL] Confirm — local %s sources=%d", operationVerb(pending.kind), len(pending.sourcePaths))
|
||||
return m, m.startDeleteJob(pending.kind, pending.sourcePaths)
|
||||
}
|
||||
|
||||
// Extract archive
|
||||
if pending.kind == opExtractArchive {
|
||||
if m.archiveJob != nil {
|
||||
|
|
@ -3981,12 +4028,13 @@ func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, widt
|
|||
|
||||
progress := job.progress
|
||||
ratio := 0.0
|
||||
if job.kind == "extract" {
|
||||
// Extraction: progress by file count (no byte tracking during extraction)
|
||||
switch job.kind {
|
||||
case "extract", "delete":
|
||||
// Extraction/delete: progress by file count (no byte tracking)
|
||||
if progress.FilesTotal > 0 {
|
||||
ratio = float64(progress.FilesDone) / float64(progress.FilesTotal)
|
||||
}
|
||||
} else {
|
||||
default:
|
||||
if progress.BytesTotal > 0 {
|
||||
ratio = float64(progress.BytesDone) / float64(progress.BytesTotal)
|
||||
}
|
||||
|
|
@ -3994,16 +4042,24 @@ func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, widt
|
|||
|
||||
stage := progress.Stage
|
||||
if stage == "" {
|
||||
if job.kind == "extract" {
|
||||
switch job.kind {
|
||||
case "extract":
|
||||
stage = "Extracting data"
|
||||
} else {
|
||||
case "delete":
|
||||
stage = "Deleting files"
|
||||
default:
|
||||
stage = "Archiving data"
|
||||
}
|
||||
}
|
||||
|
||||
title := "Archiving"
|
||||
if job.kind == "extract" {
|
||||
var title string
|
||||
switch job.kind {
|
||||
case "extract":
|
||||
title = "Extracting"
|
||||
case "delete":
|
||||
title = "Deleting"
|
||||
default:
|
||||
title = "Archiving"
|
||||
}
|
||||
|
||||
lines := []string{
|
||||
|
|
@ -4017,8 +4073,8 @@ func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, widt
|
|||
renderProgressStatLine("Files:", fmt.Sprintf("%d / %d", progress.FilesDone, progress.FilesTotal), contentWidth, palette),
|
||||
}
|
||||
|
||||
// Only show size and speed for archive creation (not tracked during extraction)
|
||||
if job.kind != "extract" {
|
||||
// Only show size and speed for archive creation (not tracked during extraction/delete)
|
||||
if job.kind == "archive" {
|
||||
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),
|
||||
|
|
@ -4030,9 +4086,12 @@ func renderArchiveProgressModal(job archiveJobState, palette theme.Palette, widt
|
|||
renderModalNoteLine("Background / b, Cancel / c", contentWidth, palette, mutedStyle),
|
||||
)
|
||||
if job.background {
|
||||
if job.kind == "extract" {
|
||||
switch job.kind {
|
||||
case "extract":
|
||||
lines = append(lines, mutedStyle.Render("Extraction continues in background"))
|
||||
} else {
|
||||
case "delete":
|
||||
lines = append(lines, mutedStyle.Render("Delete continues in background"))
|
||||
default:
|
||||
lines = append(lines, mutedStyle.Render("Archive continues in background"))
|
||||
}
|
||||
}
|
||||
|
|
@ -4654,6 +4713,7 @@ func (m *Model) startArchiveJob(sourcePaths []string, targetDir string, format s
|
|||
func (m *Model) startExtractJob(sourcePath, targetDir string) tea.Cmd {
|
||||
m.nextArchiveJob++
|
||||
jobID := m.nextArchiveJob
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
m.archiveJob = &archiveJobState{
|
||||
id: jobID,
|
||||
|
|
@ -4667,6 +4727,7 @@ func (m *Model) startExtractJob(sourcePath, targetDir string) tea.Cmd {
|
|||
BytesTotal: 0,
|
||||
CurrentPath: sourcePath,
|
||||
},
|
||||
cancel: cancel,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
m.modal = modalState{kind: modalArchiveProgress}
|
||||
|
|
@ -4681,7 +4742,7 @@ func (m *Model) startExtractJob(sourcePath, targetDir string) tea.Cmd {
|
|||
progress: p,
|
||||
}
|
||||
}
|
||||
err := vfs.ExtractArchiveToDir(sourcePath, targetDir, emitProgress)
|
||||
err := vfs.ExtractArchiveToDir(ctx, sourcePath, targetDir, emitProgress)
|
||||
if err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
err = context.Canceled
|
||||
|
|
@ -4706,6 +4767,88 @@ func (m *Model) startExtractJob(sourcePath, targetDir string) tea.Cmd {
|
|||
)
|
||||
}
|
||||
|
||||
func (m *Model) startDeleteJob(kind fileOpKind, sources []string) tea.Cmd {
|
||||
m.nextArchiveJob++
|
||||
jobID := m.nextArchiveJob
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
m.archiveJob = &archiveJobState{
|
||||
id: jobID,
|
||||
kind: "delete",
|
||||
sourcePaths: append([]string(nil), sources...),
|
||||
targetPath: "",
|
||||
progress: vfs.CopyProgress{
|
||||
FilesDone: 0,
|
||||
FilesTotal: len(sources),
|
||||
BytesDone: 0,
|
||||
BytesTotal: 0,
|
||||
CurrentPath: sources[0],
|
||||
},
|
||||
cancel: cancel,
|
||||
startedAt: time.Now(),
|
||||
}
|
||||
m.modal = modalState{kind: modalArchiveProgress}
|
||||
verb := "Moving to trash"
|
||||
if kind == opPermanentDelete {
|
||||
verb = "Deleting"
|
||||
}
|
||||
m.status = verb + " started"
|
||||
|
||||
return tea.Batch(
|
||||
func() tea.Msg {
|
||||
go func() {
|
||||
verb := "move to trash"
|
||||
if kind == opPermanentDelete {
|
||||
verb = "delete permanently"
|
||||
}
|
||||
for i, sourcePath := range sources {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
m.archiveProgress <- archiveDoneMsg{
|
||||
jobID: jobID,
|
||||
sourcePaths: append([]string(nil), sources...),
|
||||
err: context.Canceled,
|
||||
}
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
var err error
|
||||
if kind == opPermanentDelete {
|
||||
err = vfs.DeletePath(sourcePath)
|
||||
} else {
|
||||
err = vfs.MoveToTrash(sourcePath)
|
||||
}
|
||||
if err != nil {
|
||||
m.archiveProgress <- archiveDoneMsg{
|
||||
jobID: jobID,
|
||||
sourcePaths: append([]string(nil), sources...),
|
||||
err: fmt.Errorf("%s %s: %w", verb, sourcePath, err),
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
m.archiveProgress <- archiveProgressMsg{
|
||||
jobID: jobID,
|
||||
progress: vfs.CopyProgress{
|
||||
FilesDone: i + 1,
|
||||
FilesTotal: len(sources),
|
||||
CurrentPath: sourcePath,
|
||||
Stage: verb,
|
||||
},
|
||||
}
|
||||
}
|
||||
m.archiveProgress <- archiveDoneMsg{
|
||||
jobID: jobID,
|
||||
sourcePaths: append([]string(nil), sources...),
|
||||
}
|
||||
}()
|
||||
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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue