feat: directory history (Alt+Left/Alt+Right), filter fixes (sticky per-pane, navigation, Shift+select, offset, Esc clear)

This commit is contained in:
vrubelroman 2026-04-27 18:11:19 +03:00
parent 08c095e74f
commit 7a55fb289e
3 changed files with 242 additions and 35 deletions

View file

@ -36,6 +36,8 @@ type KeyMap struct {
ProgressCancel key.Binding ProgressCancel key.Binding
Cancel key.Binding Cancel key.Binding
Quit key.Binding Quit key.Binding
HistoryBack key.Binding
HistoryForward key.Binding
} }
func DefaultKeyMap() KeyMap { func DefaultKeyMap() KeyMap {
@ -71,6 +73,8 @@ func DefaultKeyMap() KeyMap {
Confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("Enter/y", "confirm")), Confirm: key.NewBinding(key.WithKeys("enter", "y"), key.WithHelp("Enter/y", "confirm")),
Background: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "background")), Background: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "background")),
ProgressCancel: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "cancel transfer")), 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")), Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("Esc", "cancel")),
Quit: key.NewBinding(key.WithKeys("f10", "q", "ctrl+c"), key.WithHelp("F10/q", "quit")), Quit: key.NewBinding(key.WithKeys("f10", "q", "ctrl+c"), key.WithHelp("F10/q", "quit")),
} }

View file

@ -208,6 +208,7 @@ type Model struct {
filterMode bool filterMode bool
filterQuery string filterQuery string
filterInput textinput.Model filterInput textinput.Model
filterPaneID PaneID
modal modalState modal modalState
status string status string
@ -754,29 +755,30 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = fmt.Sprintf("Filter: %s", m.filterQuery) m.status = fmt.Sprintf("Filter: %s", m.filterQuery)
return m, nil return m, nil
case key.Matches(msg, m.keys.Up): case key.Matches(msg, m.keys.Up):
m.moveCursor(-1) m.moveFilteredCursor(-1)
return m, m.loadPreviewCmd() return m, m.loadPreviewCmd()
case key.Matches(msg, m.keys.Down): case key.Matches(msg, m.keys.Down):
m.moveCursor(1) m.moveFilteredCursor(1)
return m, m.loadPreviewCmd() return m, m.loadPreviewCmd()
case key.Matches(msg, m.keys.PageUp): case key.Matches(msg, m.keys.PageUp):
m.moveCursor(-max(m.bodyHeight()-6, 5)) m.moveFilteredCursor(-max(m.bodyHeight()-6, 5))
return m, m.loadPreviewCmd() return m, m.loadPreviewCmd()
case key.Matches(msg, m.keys.PageDown): case key.Matches(msg, m.keys.PageDown):
m.moveCursor(max(m.bodyHeight()-6, 5)) m.moveFilteredCursor(max(m.bodyHeight()-6, 5))
return m, m.loadPreviewCmd() return m, m.loadPreviewCmd()
default: default:
var cmd tea.Cmd var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg) m.filterInput, cmd = m.filterInput.Update(msg)
m.filterQuery = m.filterInput.Value() m.filterQuery = m.filterInput.Value()
m.adjustCursorForFilter() m.snapFilterCursor()
return m, cmd return m, cmd
} }
} }
// Toggle filter mode // Toggle filter mode — attaches filter to the currently active pane
if key.Matches(msg, m.keys.Filter) { if key.Matches(msg, m.keys.Filter) {
m.filterMode = true m.filterMode = true
m.filterPaneID = m.active
m.filterInput.Focus() m.filterInput.Focus()
m.filterInput.SetValue(m.filterQuery) m.filterInput.SetValue(m.filterQuery)
m.status = "Filter: type to filter, Enter to confirm, Esc to clear" m.status = "Filter: type to filter, Enter to confirm, Esc to clear"
@ -795,6 +797,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.openRenameModal() m.openRenameModal()
return m, nil return m, nil
case key.Matches(msg, m.keys.Cancel), msg.String() == "q": case key.Matches(msg, m.keys.Cancel), msg.String() == "q":
// Esc on the pane where filter is active clears it.
if m.filterQuery != "" && m.filterPaneID == m.active {
m.clearFilter()
m.status = "Filter cleared"
return m, nil
}
if m.infoMode { if m.infoMode {
m.infoMode = false m.infoMode = false
m.selectMode = false m.selectMode = false
@ -868,6 +876,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = err.Error() m.status = err.Error()
} }
return m, m.loadPreviewCmd() return m, m.loadPreviewCmd()
case key.Matches(msg, m.keys.HistoryBack):
return m.historyBack()
case key.Matches(msg, m.keys.HistoryForward):
return m.historyForward()
case key.Matches(msg, m.keys.Refresh): case key.Matches(msg, m.keys.Refresh):
return m.refreshAllPanes("Refreshed") return m.refreshAllPanes("Refreshed")
case key.Matches(msg, m.keys.DirSize): case key.Matches(msg, m.keys.DirSize):
@ -906,9 +918,18 @@ func (m Model) View() string {
Background(m.palette.Panel). Background(m.palette.Panel).
Render("") Render("")
// Use filtered panes when a filter query is active. // Filter is sticky: once activated on a pane it stays on that pane
leftPane := m.filteredPane(m.left) // even after switching to the other pane with Tab.
rightPane := m.filteredPane(m.right) leftPane := m.left
rightPane := m.right
if m.filterQuery != "" {
switch m.filterPaneID {
case PaneLeft:
leftPane = m.filteredPane(m.left)
case PaneRight:
rightPane = m.filteredPane(m.right)
}
}
var panels string var panels string
if m.viewMode && m.previewData.Kind == vfs.PreviewKindImage { if m.viewMode && m.previewData.Kind == vfs.PreviewKindImage {
@ -1177,25 +1198,85 @@ func (m *Model) refreshAllPanes(status string) (tea.Model, tea.Cmd) {
} }
func (m *Model) moveCursor(delta int) { func (m *Model) moveCursor(delta int) {
// When a filter query is active on this pane, move through filtered entries
// only, so the cursor always lands on a matching item.
if m.filterQuery != "" && m.filterPaneID == m.active {
m.moveFilteredCursor(delta)
return
}
pane := m.activePane() pane := m.activePane()
pane.Move(delta, max(m.bodyHeight()-4, 1)) pane.Move(delta, max(m.bodyHeight()-4, 1))
m.hover = hoverState{} m.hover = hoverState{}
} }
// adjustCursorForFilter ensures the cursor stays within bounds of the // snapFilterCursor moves the real cursor to the nearest entry matching the
// filtered entry list. Called after each filter keystroke. // current filter query. Called after each filter keystroke so the user
func (m *Model) adjustCursorForFilter() { // always sees a selected item in the filtered view.
func (m *Model) snapFilterCursor() {
pane := m.activePane() pane := m.activePane()
if m.filterQuery == "" { if m.filterQuery == "" || len(pane.Entries) == 0 {
return return
} }
count := m.filteredCount(pane) query := strings.ToLower(m.filterQuery)
if pane.Cursor >= count {
pane.Cursor = max(count-1, 0) // If current cursor position already matches, keep it.
if pane.Cursor >= 0 && pane.Cursor < len(pane.Entries) {
entry := pane.Entries[pane.Cursor]
if entry.IsParent || strings.Contains(strings.ToLower(entry.DisplayName()), query) {
return
} }
if pane.Offset > pane.Cursor { }
// Search forward from cursor.
for i := pane.Cursor + 1; i < len(pane.Entries); i++ {
entry := pane.Entries[i]
if entry.IsParent || strings.Contains(strings.ToLower(entry.DisplayName()), query) {
pane.Cursor = i
return
}
}
// Search backward from cursor.
for i := pane.Cursor - 1; i >= 0; i-- {
entry := pane.Entries[i]
if entry.IsParent || strings.Contains(strings.ToLower(entry.DisplayName()), query) {
pane.Cursor = i
return
}
}
}
// moveFilteredCursor moves the real cursor to the next/prev entry matching
// the current filter query. Used for Up/Down navigation in filter mode.
func (m *Model) moveFilteredCursor(delta int) {
pane := m.activePane()
if m.filterQuery == "" || len(pane.Entries) == 0 {
m.moveCursor(delta)
return
}
query := strings.ToLower(m.filterQuery)
maxIdx := len(pane.Entries) - 1
idx := pane.Cursor + delta
for idx >= 0 && idx <= maxIdx {
entry := pane.Entries[idx]
if entry.IsParent || strings.Contains(strings.ToLower(entry.DisplayName()), query) {
pane.Cursor = idx
pageSize := max(m.bodyHeight()-4, 1)
if pane.Cursor < pane.Offset {
pane.Offset = pane.Cursor pane.Offset = pane.Cursor
} }
if pageSize > 0 && pane.Cursor >= pane.Offset+pageSize {
pane.Offset = pane.Cursor - pageSize + 1
}
if pane.Offset < 0 {
pane.Offset = 0
}
m.hover = hoverState{}
return
}
idx += delta
}
} }
// filteredCount returns the number of entries matching the current filter. // filteredCount returns the number of entries matching the current filter.
@ -1214,24 +1295,40 @@ func (m *Model) filteredCount(pane *BrowserPane) int {
} }
// filteredPane returns a copy of the pane with entries filtered by the current query. // filteredPane returns a copy of the pane with entries filtered by the current query.
// The cursor in the returned copy reflects the position of the real cursor
// within the filtered subset, so Selected() on the original pane still returns
// the correct entry. Offset is recomputed in filtered-entry space so the
// viewport does not inherit the real-list offset (which would be out of range).
func (m Model) filteredPane(pane BrowserPane) BrowserPane { func (m Model) filteredPane(pane BrowserPane) BrowserPane {
if m.filterQuery == "" { if m.filterQuery == "" {
return pane return pane
} }
query := strings.ToLower(m.filterQuery) query := strings.ToLower(m.filterQuery)
filtered := make([]vfs.Entry, 0, len(pane.Entries)) filtered := make([]vfs.Entry, 0, len(pane.Entries))
for _, entry := range pane.Entries { filteredCursor := 0
for i, entry := range pane.Entries {
if entry.IsParent || strings.Contains(strings.ToLower(entry.DisplayName()), query) { if entry.IsParent || strings.Contains(strings.ToLower(entry.DisplayName()), query) {
if i == pane.Cursor {
filteredCursor = len(filtered)
}
filtered = append(filtered, entry) filtered = append(filtered, entry)
} }
} }
pane.Entries = filtered pane.Entries = filtered
pane.Cursor = filteredCursor
if pane.Cursor >= len(filtered) { if pane.Cursor >= len(filtered) {
pane.Cursor = max(len(filtered)-1, 0) pane.Cursor = max(len(filtered)-1, 0)
} }
if pane.Offset > pane.Cursor {
pane.Offset = pane.Cursor // Recompute offset in filtered-entry space. The source offset is in
// real-entry-index space and is meaningless for the shorter filtered list.
pageSize := max(m.bodyHeight()-4, 1)
offset := 0
if pane.Cursor >= pageSize {
offset = pane.Cursor - pageSize + 1
} }
pane.Offset = offset
return pane return pane
} }
@ -1240,7 +1337,11 @@ func (m *Model) selectMoveCursor(delta int) {
if selected, ok := pane.Selected(); ok && !selected.IsParent { if selected, ok := pane.Selected(); ok && !selected.IsParent {
pane.ToggleMarked(selected.Path) pane.ToggleMarked(selected.Path)
} }
if m.filterQuery != "" && m.filterPaneID == m.active {
m.moveFilteredCursor(delta)
} else {
pane.Move(delta, max(m.bodyHeight()-4, 1)) pane.Move(delta, max(m.bodyHeight()-4, 1))
}
m.hover = hoverState{} m.hover = hoverState{}
} }
@ -1283,6 +1384,8 @@ func (m *Model) enterSelected() error {
return m.goParent() return m.goParent()
} }
} }
// Save current directory to history before navigating.
pane.PushHistory(pane.Path)
currentName := selected.Name currentName := selected.Name
pane.Path = selected.Path pane.Path = selected.Path
if err := m.reloadPane(pane.ID, currentName); err != nil { if err := m.reloadPane(pane.ID, currentName); err != nil {
@ -1297,6 +1400,7 @@ func (m *Model) clearFilter() {
m.filterInput.SetValue("") m.filterInput.SetValue("")
m.filterInput.Blur() m.filterInput.Blur()
m.filterMode = false m.filterMode = false
m.filterPaneID = ""
} }
func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) { func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
@ -1306,7 +1410,6 @@ func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
} }
if selected.IsDir { if selected.IsDir {
m.clearFilter()
if err := m.enterSelected(); err != nil { if err := m.enterSelected(); err != nil {
m.status = err.Error() m.status = err.Error()
return m, nil return m, nil
@ -1315,7 +1418,6 @@ func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
} }
if isArchiveEntry(selected) { if isArchiveEntry(selected) {
m.clearFilter()
if err := m.enterArchive(selected); err != nil { if err := m.enterArchive(selected); err != nil {
m.status = err.Error() m.status = err.Error()
return m, nil return m, nil
@ -1331,7 +1433,6 @@ func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
func (m *Model) goParent() error { func (m *Model) goParent() error {
m.hover = hoverState{} m.hover = hoverState{}
m.clearFilter()
pane := m.activePane() pane := m.activePane()
if mount, ok := pane.CurrentArchive(); ok { if mount, ok := pane.CurrentArchive(); ok {
@ -1339,6 +1440,8 @@ func (m *Model) goParent() error {
current := filepath.Clean(pane.Path) current := filepath.Clean(pane.Path)
if current == root { if current == root {
// At archive root — pop archive and return to the directory containing it // At archive root — pop archive and return to the directory containing it
// Save current path to history before leaving so forward can restore it.
pane.PushHistory(pane.Path)
if _, popped := pane.PopArchive(); popped { if _, popped := pane.PopArchive(); popped {
_ = os.RemoveAll(mount.TempDir) _ = os.RemoveAll(mount.TempDir)
} }
@ -1350,6 +1453,7 @@ func (m *Model) goParent() error {
return nil return nil
} }
// Inside archive subdirectory — go up one level within the archive // Inside archive subdirectory — go up one level within the archive
pane.PushHistory(pane.Path)
parent := filepath.Dir(current) parent := filepath.Dir(current)
pane.Path = parent pane.Path = parent
if err := m.reloadPane(pane.ID, filepath.Base(current)); err != nil { if err := m.reloadPane(pane.ID, filepath.Base(current)); err != nil {
@ -1363,6 +1467,7 @@ func (m *Model) goParent() error {
if parent == pane.Path { if parent == pane.Path {
return nil return nil
} }
pane.PushHistory(pane.Path)
currentName := filepath.Base(pane.Path) currentName := filepath.Base(pane.Path)
pane.Path = parent pane.Path = parent
if err := m.reloadPane(pane.ID, currentName); err != nil { if err := m.reloadPane(pane.ID, currentName); err != nil {
@ -1372,6 +1477,48 @@ func (m *Model) goParent() error {
return nil return nil
} }
// historyBack navigates the active pane to the previous directory in its history.
func (m *Model) historyBack() (tea.Model, tea.Cmd) {
pane := m.activePane()
prevPath, ok := pane.PopHistory()
if !ok {
m.status = "No directory history"
return m, nil
}
// Save current path to forward-stack so forward navigation can restore it.
pane.PushFuture(pane.Path)
pane.Path = prevPath
pane.Cursor = 0
pane.Offset = 0
if err := m.reloadPane(pane.ID, ""); err != nil {
m.status = err.Error()
return m, nil
}
m.status = fmt.Sprintf("History back to %s", prevPath)
return m, m.loadPreviewCmd()
}
// historyForward navigates the active pane to the next directory in its forward-stack.
func (m *Model) historyForward() (tea.Model, tea.Cmd) {
pane := m.activePane()
nextPath, ok := pane.PopFuture()
if !ok {
m.status = "No forward history"
return m, nil
}
// Save current path to back-stack so back navigation can restore it.
pane.PushHistory(pane.Path)
pane.Path = nextPath
pane.Cursor = 0
pane.Offset = 0
if err := m.reloadPane(pane.ID, ""); err != nil {
m.status = err.Error()
return m, nil
}
m.status = fmt.Sprintf("History forward to %s", nextPath)
return m, m.loadPreviewCmd()
}
func (m Model) loadPreviewCmd() tea.Cmd { func (m Model) loadPreviewCmd() tea.Cmd {
selected, ok := m.activePane().Selected() selected, ok := m.activePane().Selected()
if !ok { if !ok {
@ -2013,20 +2160,20 @@ func (m *Model) openHelpModal() {
" Enter / Right open selected entry", " Enter / Right open selected entry",
" Backspace/Left go to parent directory", " Backspace/Left go to parent directory",
" Tab / h / l switch active pane", " Tab / h / l switch active pane",
" F2 / r rename selected entry", " Alt+Left directory history back",
" Alt+Right directory history forward",
" / filter entries by name in current pane",
" Ctrl+r refresh both panes", " Ctrl+r refresh both panes",
"", "",
"View and Panels", "View and Panels",
" F9 / o toggle preview/info pane", " o toggle preview/info pane",
" F3 plain text view or fullscreen image viewer",
" i show text caret in preview pane", " i show text caret in preview pane",
" v start visual selection from caret", " v start visual selection from caret",
" F3 / Esc / q close view mode", " Esc / q close view/info/caret mode",
" yy copy current line in caret mode", " yy copy current line in caret mode",
" y copy visual selection to clipboard", " y copy visual selection to clipboard",
" h / l move caret left/right", " h / l move caret left/right",
" w / b move caret by word", " w / b move caret by word",
" q / Esc close caret/info mode",
" Ctrl+t mouse selection mode in text preview pane", " Ctrl+t mouse selection mode in text preview pane",
" Space calculate selected directory size", " Space calculate selected directory size",
" s cycle sort mode", " s cycle sort mode",
@ -2034,11 +2181,11 @@ func (m *Model) openHelpModal() {
" t cycle theme", " t cycle theme",
"", "",
"Dialogs and Transfers", "Dialogs and Transfers",
" r rename selected entry",
" Enter / y confirm action", " Enter / y confirm action",
" Esc / n cancel action", " Esc / n cancel action",
" b run copy/move in background (progress dialog)", " b run copy/move in background (progress dialog)",
" c cancel active copy/move transfer", " c cancel active copy/move transfer",
" F5/F6/F8 apply to marked entries when selection exists",
"", "",
"Mouse", "Mouse",
" Left click select entry and activate pane", " Left click select entry and activate pane",
@ -2046,7 +2193,7 @@ func (m *Model) openHelpModal() {
" Right click toggle preview/info mode for clicked entry", " Right click toggle preview/info mode for clicked entry",
" Wheel scroll list or preview area", " Wheel scroll list or preview area",
"", "",
"F-key actions are shown in the footer.", "F1F10 actions are shown in the footer.",
} }
m.modal = modalState{ m.modal = modalState{
@ -3428,6 +3575,8 @@ func copyPlanCmd(kind fileOpKind, sourcePaths []string, targetDir string, overwr
func (m *Model) enterArchive(selected vfs.Entry) error { func (m *Model) enterArchive(selected vfs.Entry) error {
pane := m.activePane() pane := m.activePane()
// Save current path to history before opening the archive.
pane.PushHistory(pane.Path)
tempDir, err := vfs.ExtractArchiveToTemp(selected.Path) tempDir, err := vfs.ExtractArchiveToTemp(selected.Path)
if err != nil { if err != nil {
return err return err

View file

@ -27,6 +27,9 @@ type BrowserPane struct {
Offset int Offset int
Marked map[string]struct{} Marked map[string]struct{}
Archive []ArchiveMount Archive []ArchiveMount
dirHistory []string
dirFuture []string
} }
type ArchiveMount struct { type ArchiveMount struct {
@ -184,6 +187,57 @@ func (p *BrowserPane) InArchive() bool {
return len(p.Archive) > 0 return len(p.Archive) > 0
} }
// PushHistory saves the current path to the back-stack and clears the forward-stack.
func (p *BrowserPane) PushHistory(path string) {
p.dirHistory = append(p.dirHistory, path)
p.dirFuture = nil
}
// PopHistory returns the most recent path from the back-stack.
func (p *BrowserPane) PopHistory() (string, bool) {
if len(p.dirHistory) == 0 {
return "", false
}
path := p.dirHistory[len(p.dirHistory)-1]
p.dirHistory = p.dirHistory[:len(p.dirHistory)-1]
return path, true
}
// PushFuture saves the current path to the forward-stack.
func (p *BrowserPane) PushFuture(path string) {
p.dirFuture = append(p.dirFuture, path)
}
// PopFuture returns the most recent path from the forward-stack.
func (p *BrowserPane) PopFuture() (string, bool) {
if len(p.dirFuture) == 0 {
return "", false
}
path := p.dirFuture[len(p.dirFuture)-1]
p.dirFuture = p.dirFuture[:len(p.dirFuture)-1]
return path, true
}
// HasHistory returns true if there are entries in the back-stack.
func (p *BrowserPane) HasHistory() bool {
return len(p.dirHistory) > 0
}
// HasFuture returns true if there are entries in the forward-stack.
func (p *BrowserPane) HasFuture() bool {
return len(p.dirFuture) > 0
}
// HistoryDepth returns the number of entries in the back-stack.
func (p *BrowserPane) HistoryDepth() int {
return len(p.dirHistory)
}
// FutureDepth returns the number of entries in the forward-stack.
func (p *BrowserPane) FutureDepth() int {
return len(p.dirFuture)
}
func (p *BrowserPane) PushArchive(mount ArchiveMount) { func (p *BrowserPane) PushArchive(mount ArchiveMount) {
p.Archive = append(p.Archive, mount) p.Archive = append(p.Archive, mount)
} }