diff --git a/internal/fs/archive.go b/internal/fs/archive.go index 70ab112..b90843e 100644 --- a/internal/fs/archive.go +++ b/internal/fs/archive.go @@ -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 diff --git a/internal/ui/model.go b/internal/ui/model.go index a0a9928..ccdb5fd 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -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)