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)

This commit is contained in:
vrubelroman 2026-04-27 18:56:20 +03:00
parent 7a55fb289e
commit 813c40a41e
3 changed files with 253 additions and 114 deletions

View file

@ -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/<basename>
// - A .trashinfo file is written to Trash/info/<basename>.trashinfo
// - If <basename> 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 {

View file

@ -31,6 +31,7 @@ type KeyMap struct {
Move key.Binding
Mkdir key.Binding
Delete key.Binding
PermanentDelete key.Binding
Confirm key.Binding
Background key.Binding
ProgressCancel key.Binding
@ -69,7 +70,8 @@ func DefaultKeyMap() KeyMap {
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")),
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")),
@ -81,13 +83,13 @@ func DefaultKeyMap() KeyMap {
}
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},
}
}

View file

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