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
This commit is contained in:
vrubelroman 2026-04-29 13:42:59 +03:00
parent 8589187a10
commit 3229d9b263
3 changed files with 204 additions and 114 deletions

View file

@ -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},
}
}

View file

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