From 3229d9b2630a4754ee3d3f57bc3b9a786f8751cd Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Wed, 29 Apr 2026 13:42:59 +0300 Subject: [PATCH] feat: merge F8/x delete with F11 archive extraction - Delete mode toggle ('d') inside existing confirm dialog instead of separate modal - F11/e shows confirm dialog before extracting archive to opposite pane - deleteKind field toggles between 'permanent' and 'trash' - Active pane reloads after extraction so files appear immediately --- internal/fs/archive.go | 17 +++++ internal/ui/keymap.go | 152 ++++++++++++++++++++--------------------- internal/ui/model.go | 149 +++++++++++++++++++++++++++++----------- 3 files changed, 204 insertions(+), 114 deletions(-) diff --git a/internal/fs/archive.go b/internal/fs/archive.go index 8cc03f4..7db9083 100644 --- a/internal/fs/archive.go +++ b/internal/fs/archive.go @@ -44,6 +44,23 @@ func ExtractArchiveToTemp(sourcePath string) (string, error) { return tempDir, nil } +// 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 { + sourceLower := strings.ToLower(sourcePath) + switch { + case strings.HasSuffix(sourceLower, ".zip"): + return extractZipArchive(sourcePath, targetDir) + case strings.HasSuffix(sourceLower, ".tar"): + return extractTarArchive(sourcePath, targetDir, false) + case strings.HasSuffix(sourceLower, ".tar.gz"), strings.HasSuffix(sourceLower, ".tgz"): + return extractTarArchive(sourcePath, targetDir, true) + default: + return fmt.Errorf("archive format is not supported: %s", filepath.Ext(sourcePath)) + } +} + func extractZipArchive(sourcePath string, targetDir string) error { reader, err := zip.OpenReader(sourcePath) if err != nil { diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go index 8af6c98..039bbdc 100644 --- a/internal/ui/keymap.go +++ b/internal/ui/keymap.go @@ -3,95 +3,95 @@ package ui import "github.com/charmbracelet/bubbles/key" type KeyMap struct { - Help key.Binding - Visual key.Binding - Caret key.Binding - View key.Binding - Edit key.Binding - Rename key.Binding - Info key.Binding - Archive key.Binding - SelectText key.Binding - ToggleHidden key.Binding - CycleTheme key.Binding - CycleSort key.Binding - SSH key.Binding - Up key.Binding - Down key.Binding - SelectUp key.Binding - SelectDown key.Binding - PageUp key.Binding - PageDown key.Binding - Open key.Binding - Back key.Binding - Switch key.Binding - Filter key.Binding - Refresh key.Binding - DirSize key.Binding - Copy key.Binding - Move key.Binding - Mkdir key.Binding - Delete key.Binding - PermanentDelete key.Binding - Confirm key.Binding - Background key.Binding - ProgressCancel key.Binding - Cancel key.Binding - Quit key.Binding - HistoryBack key.Binding - HistoryForward key.Binding + Help key.Binding + Visual key.Binding + Caret key.Binding + View key.Binding + Edit key.Binding + Rename key.Binding + Info key.Binding + Archive key.Binding + SelectText key.Binding + ToggleHidden key.Binding + CycleTheme key.Binding + CycleSort key.Binding + SSH key.Binding + Up key.Binding + Down key.Binding + SelectUp key.Binding + SelectDown key.Binding + PageUp key.Binding + PageDown key.Binding + Open key.Binding + Back key.Binding + Switch key.Binding + Filter key.Binding + Refresh key.Binding + DirSize key.Binding + Copy key.Binding + Move key.Binding + Mkdir key.Binding + Delete key.Binding + ExtractArchive key.Binding + Confirm key.Binding + Background key.Binding + ProgressCancel key.Binding + Cancel key.Binding + Quit key.Binding + HistoryBack key.Binding + HistoryForward key.Binding } func DefaultKeyMap() KeyMap { return KeyMap{ - Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")), - Rename: key.NewBinding(key.WithKeys("f2", "r"), key.WithHelp("F2/r", "rename")), - View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")), - Visual: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "visual")), - Caret: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "caret")), - Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), - Archive: key.NewBinding(key.WithKeys("f4", "a"), key.WithHelp("F4/a", "archive")), - Info: key.NewBinding(key.WithKeys("f9", "o"), key.WithHelp("F9/o", "info")), - SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")), - ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")), - CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")), - CycleSort: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "sort")), - SSH: key.NewBinding(key.WithKeys("f12", "s"), key.WithHelp("F12/s", "ssh")), - Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), - Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), - SelectUp: key.NewBinding(key.WithKeys("shift+up", "K"), key.WithHelp("S-↑/K", "select up")), - SelectDown: key.NewBinding(key.WithKeys("shift+down", "J"), key.WithHelp("S-↓/J", "select down")), - PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("PgUp", "page up")), - PageDown: key.NewBinding(), - Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), - Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")), - Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")), - Switch: key.NewBinding(key.WithKeys("tab", "h", "l"), key.WithHelp("Tab/h/l", "switch pane")), - Refresh: key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("C-r", "refresh")), - DirSize: key.NewBinding(key.WithKeys(" "), key.WithHelp("Space", "dir size")), - Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")), - Move: key.NewBinding(key.WithKeys("f6", "m"), key.WithHelp("F6/m", "move")), - Mkdir: key.NewBinding(key.WithKeys("f7", "n"), key.WithHelp("F7/n", "mkdir")), - Delete: key.NewBinding(key.WithKeys("f8", "delete", "x"), key.WithHelp("F8/x", "trash")), - PermanentDelete: key.NewBinding(key.WithKeys("f11", "d"), key.WithHelp("F11/d", "permanent delete")), - Confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("Enter/y", "confirm")), - Background: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "background")), - ProgressCancel: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "cancel transfer")), - HistoryBack: key.NewBinding(key.WithKeys("alt+left"), key.WithHelp("A-←", "back")), - HistoryForward: key.NewBinding(key.WithKeys("alt+right"), key.WithHelp("A-→", "forward")), - Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("Esc", "cancel")), - Quit: key.NewBinding(key.WithKeys("f10", "q", "ctrl+c"), key.WithHelp("F10/q", "quit")), + Help: key.NewBinding(key.WithKeys("f1", "?"), key.WithHelp("F1/?", "help")), + Rename: key.NewBinding(key.WithKeys("f2", "r"), key.WithHelp("F2/r", "rename")), + View: key.NewBinding(key.WithKeys("f3", "v"), key.WithHelp("F3/v", "view")), + Visual: key.NewBinding(key.WithKeys("v"), key.WithHelp("v", "visual")), + Caret: key.NewBinding(key.WithKeys("i"), key.WithHelp("i", "caret")), + Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")), + Archive: key.NewBinding(key.WithKeys("f4", "a"), key.WithHelp("F4/a", "archive")), + Info: key.NewBinding(key.WithKeys("f9", "o"), key.WithHelp("F9/o", "info")), + SelectText: key.NewBinding(key.WithKeys("ctrl+t"), key.WithHelp("C-t", "text select")), + ToggleHidden: key.NewBinding(key.WithKeys("."), key.WithHelp(".", "hidden")), + CycleTheme: key.NewBinding(key.WithKeys("t"), key.WithHelp("t", "theme")), + CycleSort: key.NewBinding(key.WithKeys("g"), key.WithHelp("g", "sort")), + SSH: key.NewBinding(key.WithKeys("f12", "s"), key.WithHelp("F12/s", "ssh")), + Up: key.NewBinding(key.WithKeys("up", "k"), key.WithHelp("↑/k", "up")), + Down: key.NewBinding(key.WithKeys("down", "j"), key.WithHelp("↓/j", "down")), + SelectUp: key.NewBinding(key.WithKeys("shift+up", "K"), key.WithHelp("S-↑/K", "select up")), + SelectDown: key.NewBinding(key.WithKeys("shift+down", "J"), key.WithHelp("S-↓/J", "select down")), + PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("PgUp", "page up")), + PageDown: key.NewBinding(), + Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), + Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")), + Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")), + Switch: key.NewBinding(key.WithKeys("tab", "h", "l"), key.WithHelp("Tab/h/l", "switch pane")), + Refresh: key.NewBinding(key.WithKeys("ctrl+r"), key.WithHelp("C-r", "refresh")), + DirSize: key.NewBinding(key.WithKeys(" "), key.WithHelp("Space", "dir size")), + Copy: key.NewBinding(key.WithKeys("f5", "c"), key.WithHelp("F5/c", "copy")), + Move: key.NewBinding(key.WithKeys("f6", "m"), key.WithHelp("F6/m", "move")), + Mkdir: key.NewBinding(key.WithKeys("f7", "n"), key.WithHelp("F7/n", "mkdir")), + Delete: key.NewBinding(key.WithKeys("f8", "delete", "x"), key.WithHelp("F8/x", "delete")), + ExtractArchive: key.NewBinding(key.WithKeys("f11", "e"), key.WithHelp("F11/e", "extract archive")), + Confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("Enter/y", "confirm")), + Background: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "background")), + ProgressCancel: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "cancel transfer")), + HistoryBack: key.NewBinding(key.WithKeys("alt+left"), key.WithHelp("A-←", "back")), + HistoryForward: key.NewBinding(key.WithKeys("alt+right"), key.WithHelp("A-→", "forward")), + Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("Esc", "cancel")), + Quit: key.NewBinding(key.WithKeys("f10", "q", "ctrl+c"), key.WithHelp("F10/q", "quit")), } } func (k KeyMap) ShortHelp() []key.Binding { - return []key.Binding{k.Help, k.Rename, k.View, k.Archive, k.Copy, k.Move, k.Mkdir, k.Delete, k.Info, k.Quit, k.PermanentDelete, k.SSH} + return []key.Binding{k.Help, k.Rename, k.View, k.Archive, k.Copy, k.Move, k.Mkdir, k.Delete, k.Info, k.Quit, k.ExtractArchive, k.SSH} } func (k KeyMap) FullHelp() [][]key.Binding { return [][]key.Binding{ {k.Help, k.Up, k.Down, k.SelectUp, k.SelectDown, k.Open, k.Back}, {k.Rename, k.View, k.Caret, k.Edit, k.Archive, k.Copy, k.Move, k.Delete}, - {k.PermanentDelete, k.SelectText, k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit}, + {k.ExtractArchive, k.SelectText, k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit}, } } diff --git a/internal/ui/model.go b/internal/ui/model.go index ba222c4..261ebe4 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -57,6 +57,7 @@ const ( opArchive opExecute opDeleteHost + opExtractArchive ) type pendingOperation struct { @@ -248,6 +249,7 @@ type Model struct { nextArchiveJob int archiveProgress chan tea.Msg archiveFormat string + deleteKind string // "trash" or "permanent" — selected in delete modal ssh *sshState preSSHPath string // original path before entering SSH mode } @@ -511,6 +513,21 @@ 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 @@ -593,20 +610,24 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { msg.kind, len(msg.sourcePaths), msg.stats.FilesTotal, msg.stats.BytesTotal, msg.srcClient != nil) title := "Move selected entr" + pluralSuffix(len(msg.sourcePaths), "y", "ies") + " to trash?" - if msg.kind == opPermanentDelete { - title = "Permanently delete selected entr" + pluralSuffix(len(msg.sourcePaths), "y", "ies") + "?" - } else if msg.srcClient != nil { + if msg.srcClient != nil { title = "Delete selected entr" + pluralSuffix(len(msg.sourcePaths), "y", "ies") + " from remote?" + } else if m.deleteKind == "permanent" { + title = "Permanently delete selected entr" + pluralSuffix(len(msg.sourcePaths), "y", "ies") + "?" } bodyLines := []string{ fmt.Sprintf("Items: %d", len(msg.sourcePaths)), fmt.Sprintf("Files: %d", msg.stats.FilesTotal), fmt.Sprintf("Size: %s", formatSize(msg.stats.BytesTotal, true)), } + 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) + } m.openConfirmModal( title, strings.Join(bodyLines, "\n"), - "confirm-actions", + note, pendingOperation{ kind: msg.kind, sourcePaths: append([]string(nil), msg.sourcePaths...), @@ -1177,8 +1198,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case key.Matches(msg, m.keys.Delete): return m.handleDelete() - case key.Matches(msg, m.keys.PermanentDelete): - return m.handlePermanentDelete() + case key.Matches(msg, m.keys.ExtractArchive): + return m.handleExtractArchive() case key.Matches(msg, m.keys.SSH): log.Printf("[KEY] SSH toggle — active=%s path=%s", m.active, activePane.Path) return m.handleSSHToggle() @@ -1386,9 +1407,48 @@ func (m Model) handleModalKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.remoteDeleteCmd(pending.sourcePaths, pending.srcClient) } + // Adjust delete kind based on mode toggle + if pending.kind == opDelete || pending.kind == opPermanentDelete { + if m.deleteKind == "permanent" && pending.kind == opDelete { + pending.kind = opPermanentDelete + } else if m.deleteKind == "trash" && pending.kind == opPermanentDelete { + pending.kind = opDelete + } + } + + // Extract archive + if pending.kind == opExtractArchive { + m.busy = true + log.Printf("[MODAL] Confirm — extract archive source=%s target=%s", pending.sourcePaths[0], pending.targetDir) + return m, extractArchiveCmd(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": + if m.modal.pending == nil { + return m, nil + } + pkind := m.modal.pending.kind + if (pkind == opDelete || pkind == opPermanentDelete) && m.modal.pending.srcClient == nil { + if m.deleteKind == "permanent" { + m.deleteKind = "trash" + } else { + m.deleteKind = "permanent" + } + sources := m.modal.pending.sourcePaths + if m.deleteKind == "permanent" { + m.modal.title = "Permanently delete selected entr" + pluralSuffix(len(sources), "y", "ies") + "?" + } else { + 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", + m.deleteKind, + ) + return m, nil + } } case modalArchiveType: @@ -2188,7 +2248,7 @@ func (m *Model) handleDelete() (tea.Model, tea.Cmd) { log.Printf("[ACTION] Delete: sources=%v remote=%v", sources, m.activePane().InRemote()) - // Remote delete via SFTP (no trash on remote) + // Remote delete via SFTP (no trash on remote — always permanent) if mount, ok := m.activePane().CurrentRemote(); ok { log.Printf("[ACTION] Delete: remote — sources=%v", sources) m.busy = true @@ -2196,47 +2256,53 @@ func (m *Model) handleDelete() (tea.Model, tea.Cmd) { return m, m.remoteDeletePlanCmd(sources, mount.Client) } + // Default to permanent delete + m.deleteKind = "permanent" + if !m.cfg.Behavior.ConfirmDelete { - log.Printf("[ACTION] Delete: immediate trash — sources=%v", sources) - m.busy = true - m.status = fmt.Sprintf("Moving %d entr%s to trash", len(sources), pluralSuffix(len(sources), "y", "ies")) - return m, trashPathsCmd(sources) + // 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) + pending := pendingOperation{ + kind: opPermanentDelete, + sourcePaths: append([]string(nil), sources...), + } + m.openConfirmModal(title, body, note, pending) + return m, nil } - log.Printf("[ACTION] Delete: plan — sources=%v", sources) + // Compute plan first, then show confirm modal with mode toggle m.busy = true - m.status = "Calculating trash size" + m.status = "Calculating delete size" return m, trashPlanCmd(sources) } -func (m *Model) handlePermanentDelete() (tea.Model, tea.Cmd) { - if m.activePane().InArchive() { - log.Printf("[SKIP] PermanentDelete: archive read-only") - m.status = "Archive mode is read-only; permanent delete is disabled" +func (m *Model) handleExtractArchive() (tea.Model, tea.Cmd) { + selected, ok := m.activePane().Selected() + if !ok || !isArchiveEntry(selected) { + log.Printf("[SKIP] ExtractArchive: no archive selected") + m.status = "Select an archive file to extract" return m, nil } - // SSH host list — delete user-added hosts (same as F8) - if m.activePane().Path == "ssh://" { - return m.handleSSHHostDelete() - } + // Target is the opposite pane's current directory + targetPane := m.passivePane() + targetDir := targetPane.Path - sources := m.operationSources() - if len(sources) == 0 { - log.Printf("[SKIP] PermanentDelete: no sources") - m.status = "Nothing to delete" - return m, nil - } - log.Printf("[ACTION] PermanentDelete: sources=%v confirm=%v", sources, m.cfg.Behavior.ConfirmDelete) - if !m.cfg.Behavior.ConfirmDelete { - m.busy = true - m.status = fmt.Sprintf("Permanently deleting %d entr%s", len(sources), pluralSuffix(len(sources), "y", "ies")) - return m, deletePathsPermanentCmd(sources) - } + log.Printf("[ACTION] ExtractArchive: source=%s target=%s", selected.Path, targetDir) - m.busy = true - m.status = "Calculating delete size" - return m, deletePlanPermanentCmd(sources) + // Show confirm dialog before extracting + title := fmt.Sprintf("Extract %s?", selected.DisplayName()) + body := fmt.Sprintf("Archive: %s\nTarget: %s", selected.Path, targetDir) + note := "Enter / y to confirm, Esc / n to cancel" + pending := pendingOperation{ + kind: opExtractArchive, + sourcePaths: []string{selected.Path}, + targetDir: targetDir, + } + m.openConfirmModal(title, body, note, pending) + return m, nil } func (m *Model) handleView() (tea.Model, tea.Cmd) { @@ -2794,8 +2860,8 @@ func (m *Model) openHelpModal() { " t cycle theme", "", "Dialogs and Transfers", - " F8 / x move selected entry to trash", - " F11 / d permanently delete selected entry", + " F8 / x delete (trash or permanent, d to toggle)", + " F11 extract archive to opposite pane", " r rename selected entry", " Enter / y confirm action", " Esc / n cancel action", @@ -4603,6 +4669,13 @@ 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 {