From 813c40a41ea239cb331df37c6a6ff28f905e4f61 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Mon, 27 Apr 2026 18:56:20 +0300 Subject: [PATCH] fix: cursor position on Enter for '..' now lands on source folder (same as Backspace); feat: permanent delete via F11/d; fix: footer F-key order (F1-F11) --- internal/fs/ops.go | 59 +++++++++++++++ internal/ui/keymap.go | 146 ++++++++++++++++++------------------- internal/ui/model.go | 162 +++++++++++++++++++++++++++++++----------- 3 files changed, 253 insertions(+), 114 deletions(-) diff --git a/internal/fs/ops.go b/internal/fs/ops.go index 6260ebf..5d0aac8 100644 --- a/internal/fs/ops.go +++ b/internal/fs/ops.go @@ -262,6 +262,65 @@ func DeletePath(path string) error { return os.RemoveAll(path) } +// MoveToTrash moves a file or directory to the FreeDesktop Trash directory +// (~/.local/share/Trash). Follows the FreeDesktop Trash specification: +// - The original item is moved to Trash/files/ +// - A .trashinfo file is written to Trash/info/.trashinfo +// - If already exists in Trash/files, a numeric suffix is appended. +func MoveToTrash(path string) error { + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("cannot determine home directory: %w", err) + } + + trashDir := filepath.Join(home, ".local", "share", "Trash") + filesDir := filepath.Join(trashDir, "files") + infoDir := filepath.Join(trashDir, "info") + + if err := os.MkdirAll(filesDir, 0o700); err != nil { + return fmt.Errorf("cannot create trash files directory: %w", err) + } + if err := os.MkdirAll(infoDir, 0o700); err != nil { + return fmt.Errorf("cannot create trash info directory: %w", err) + } + + baseName := filepath.Base(path) + + // Generate a unique name in the trash directory + destName := baseName + for counter := 1; ; counter++ { + destPath := filepath.Join(filesDir, destName) + if _, err := os.Stat(destPath); os.IsNotExist(err) { + break + } else if err != nil { + return fmt.Errorf("cannot stat trash path: %w", err) + } + destName = fmt.Sprintf("%s.%d", baseName, counter) + } + + destPath := filepath.Join(filesDir, destName) + if err := os.Rename(path, destPath); err != nil { + // Cross-filesystem move: fall back to copy+delete + return fmt.Errorf("cannot move to trash: %w", err) + } + + // Write .trashinfo file + absPath, err := filepath.Abs(path) + if err != nil { + absPath = path + } + now := time.Now().Format("2006-01-02T15:04:05") + infoContent := fmt.Sprintf("[Trash Info]\nPath=%s\nDeletionDate=%s\n", absPath, now) + infoPath := filepath.Join(infoDir, destName+".trashinfo") + if err := os.WriteFile(infoPath, []byte(infoContent), 0o600); err != nil { + // Best-effort: if info file fails, try to move the file back + _ = os.Rename(destPath, path) + return fmt.Errorf("cannot write trash info: %w", err) + } + + return nil +} + func MakeDir(parent string, name string) (string, error) { target := filepath.Join(parent, name) if err := os.MkdirAll(target, 0o755); err != nil { diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go index 7903d8b..342f938 100644 --- a/internal/ui/keymap.go +++ b/internal/ui/keymap.go @@ -3,91 +3,93 @@ 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 - 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 - 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 + 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 } 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("s"), key.WithHelp("s", "sort")), - 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")), - 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("s"), key.WithHelp("s", "sort")), + 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")), } } 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} + 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} } 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.SelectText, k.DirSize, k.Refresh, k.ToggleHidden, k.CycleSort, k.CycleTheme, k.Quit}, + {k.PermanentDelete, 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 78575e1..4f8a3e1 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -45,6 +45,7 @@ const ( opCopy fileOpKind = iota opMove opDelete + opPermanentDelete opMkdir opRename opEdit @@ -104,6 +105,7 @@ type copyProgressMsg struct { } type deletePlanMsg struct { + kind fileOpKind sourcePaths []string stats vfs.TransferStats err error @@ -336,7 +338,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case opMove: m.status = fmt.Sprintf("Moved to %s", msg.targetPath) case opDelete: - m.status = "Deleted" + m.status = "Moved to trash" + m.activePane().ClearMarks() + case opPermanentDelete: + m.status = "Permanently deleted" m.activePane().ClearMarks() case opMkdir: m.status = fmt.Sprintf("Created %s", msg.targetPath) @@ -396,7 +401,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } - title := "Delete selected entr" + pluralSuffix(len(msg.sourcePaths), "y", "ies") + "?" + 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") + "?" + } bodyLines := []string{ fmt.Sprintf("Items: %d", len(msg.sourcePaths)), fmt.Sprintf("Files: %d", msg.stats.FilesTotal), @@ -407,7 +415,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { strings.Join(bodyLines, "\n"), "confirm-actions", pendingOperation{ - kind: opDelete, + kind: msg.kind, sourcePaths: append([]string(nil), msg.sourcePaths...), stats: msg.stats, }, @@ -893,6 +901,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 tea.MouseMsg: @@ -1376,19 +1386,10 @@ func (m *Model) enterSelected() error { m.status = "File is shown in the middle pane. Use F3 for pager or F4 for editor." return nil } - // When inside an archive mount and selecting "..", use archive-aware - // navigation (goParent) instead of blindly setting pane.Path to the - // parent directory (which would be /tmp for a temp-mounted archive). - if selected.IsParent { - if _, archiveMounted := pane.CurrentArchive(); archiveMounted { - return m.goParent() - } - } // Save current directory to history before navigating. pane.PushHistory(pane.Path) - currentName := selected.Name pane.Path = selected.Path - if err := m.reloadPane(pane.ID, currentName); err != nil { + if err := m.reloadPane(pane.ID, selected.Name); err != nil { return err } m.status = fmt.Sprintf("Entered %s", pane.Path) @@ -1409,6 +1410,19 @@ func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) { return m, nil } + // Navigating up via ".." — use goParent which preserves the cursor + // position on the directory/archive we came from (by finding its name + // in the parent listing via FindSelected). This applies both inside + // archive mounts (where pane.Path must stay within the temp mount) + // and regular directories (for consistent cursor placement). + if selected.IsParent { + if err := m.goParent(); err != nil { + m.status = err.Error() + return m, nil + } + return m, m.loadPreviewCmd() + } + if selected.IsDir { if err := m.enterSelected(); err != nil { m.status = err.Error() @@ -1647,13 +1661,35 @@ func (m *Model) handleDelete() (tea.Model, tea.Cmd) { } if !m.cfg.Behavior.ConfirmDelete { m.busy = true - m.status = fmt.Sprintf("Deleting %d entr%s", len(sources), pluralSuffix(len(sources), "y", "ies")) - return m, deletePathsCmd(sources) + m.status = fmt.Sprintf("Moving %d entr%s to trash", len(sources), pluralSuffix(len(sources), "y", "ies")) + return m, trashPathsCmd(sources) + } + + m.busy = true + m.status = "Calculating trash size" + return m, trashPlanCmd(sources) +} + +func (m *Model) handlePermanentDelete() (tea.Model, tea.Cmd) { + if m.activePane().InArchive() { + m.status = "Archive mode is read-only; permanent delete is disabled" + return m, nil + } + + sources := m.operationSources() + if len(sources) == 0 { + m.status = "Nothing to delete" + return m, nil + } + 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) } m.busy = true m.status = "Calculating delete size" - return m, deletePlanCmd(sources) + return m, deletePlanPermanentCmd(sources) } func (m *Model) handleView() (tea.Model, tea.Cmd) { @@ -2181,6 +2217,8 @@ func (m *Model) openHelpModal() { " t cycle theme", "", "Dialogs and Transfers", + " F8 / x move selected entry to trash", + " F11 / d permanently delete selected entry", " r rename selected entry", " Enter / y confirm action", " Esc / n cancel action", @@ -3526,7 +3564,9 @@ func (p pendingOperation) cmd() tea.Cmd { case opMove: return nil case opDelete: - return deletePathsCmd(p.sourcePaths) + return trashPathsCmd(p.sourcePaths) + case opPermanentDelete: + return deletePathsPermanentCmd(p.sourcePaths) default: return nil } @@ -3607,27 +3647,6 @@ func (m *Model) cleanupArchiveMounts() { } } -func deletePlanCmd(sourcePaths []string) tea.Cmd { - return func() tea.Msg { - stats := vfs.TransferStats{} - var err error - for _, sourcePath := range sourcePaths { - part, statErr := vfs.CopyStats(sourcePath) - if statErr != nil { - err = statErr - break - } - stats.FilesTotal += part.FilesTotal - stats.BytesTotal += part.BytesTotal - } - return deletePlanMsg{ - sourcePaths: append([]string(nil), sourcePaths...), - stats: stats, - err: err, - } - } -} - func archivePlanCmd(sourcePaths []string, targetDir string) tea.Cmd { return func() tea.Msg { stats := vfs.TransferStats{} @@ -3822,10 +3841,10 @@ func moveCmd(sourcePath, targetDir string, overwrite bool) tea.Cmd { } } -func deletePathsCmd(paths []string) tea.Cmd { +func trashPathsCmd(paths []string) tea.Cmd { return func() tea.Msg { for _, path := range paths { - if err := vfs.DeletePath(path); err != nil { + if err := vfs.MoveToTrash(path); err != nil { return opMsg{kind: opDelete, sourcePath: path, err: err} } } @@ -3833,6 +3852,61 @@ func deletePathsCmd(paths []string) tea.Cmd { } } +func deletePathsPermanentCmd(paths []string) tea.Cmd { + return func() tea.Msg { + for _, path := range paths { + if err := vfs.DeletePath(path); err != nil { + return opMsg{kind: opPermanentDelete, sourcePath: path, err: err} + } + } + return opMsg{kind: opPermanentDelete} + } +} + +func trashPlanCmd(sourcePaths []string) tea.Cmd { + return func() tea.Msg { + stats := vfs.TransferStats{} + var err error + for _, sourcePath := range sourcePaths { + part, statErr := vfs.CopyStats(sourcePath) + if statErr != nil { + err = statErr + break + } + stats.FilesTotal += part.FilesTotal + stats.BytesTotal += part.BytesTotal + } + return deletePlanMsg{ + kind: opDelete, + sourcePaths: append([]string(nil), sourcePaths...), + stats: stats, + err: err, + } + } +} + +func deletePlanPermanentCmd(sourcePaths []string) tea.Cmd { + return func() tea.Msg { + stats := vfs.TransferStats{} + var err error + for _, sourcePath := range sourcePaths { + part, statErr := vfs.CopyStats(sourcePath) + if statErr != nil { + err = statErr + break + } + stats.FilesTotal += part.FilesTotal + stats.BytesTotal += part.BytesTotal + } + return deletePlanMsg{ + kind: opPermanentDelete, + sourcePaths: append([]string(nil), sourcePaths...), + stats: stats, + err: err, + } + } +} + func mkdirCmd(parent, name string) tea.Cmd { return func() tea.Msg { targetPath, err := vfs.MakeDir(parent, name) @@ -3932,7 +4006,9 @@ func operationDoneLabel(kind fileOpKind) string { case opCopy: return "Copied" case opDelete: - return "Deleted" + return "Moved to trash" + case opPermanentDelete: + return "Permanently deleted" case opArchive: return "Archived" default: @@ -3947,7 +4023,9 @@ func operationVerb(kind fileOpKind) string { case opMove: return "move" case opDelete: - return "delete" + return "trash" + case opPermanentDelete: + return "permanent delete" case opArchive: return "archive" default: