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
Cancel key.Binding
Quit key.Binding
HistoryBack key.Binding
HistoryForward key.Binding
}
func DefaultKeyMap() KeyMap {
@ -71,6 +73,8 @@ func DefaultKeyMap() KeyMap {
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")),
}

View file

@ -208,6 +208,7 @@ type Model struct {
filterMode bool
filterQuery string
filterInput textinput.Model
filterPaneID PaneID
modal modalState
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)
return m, nil
case key.Matches(msg, m.keys.Up):
m.moveCursor(-1)
m.moveFilteredCursor(-1)
return m, m.loadPreviewCmd()
case key.Matches(msg, m.keys.Down):
m.moveCursor(1)
m.moveFilteredCursor(1)
return m, m.loadPreviewCmd()
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()
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()
default:
var cmd tea.Cmd
m.filterInput, cmd = m.filterInput.Update(msg)
m.filterQuery = m.filterInput.Value()
m.adjustCursorForFilter()
m.snapFilterCursor()
return m, cmd
}
}
// Toggle filter mode
// Toggle filter mode — attaches filter to the currently active pane
if key.Matches(msg, m.keys.Filter) {
m.filterMode = true
m.filterPaneID = m.active
m.filterInput.Focus()
m.filterInput.SetValue(m.filterQuery)
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()
return m, nil
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 {
m.infoMode = false
m.selectMode = false
@ -868,6 +876,10 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.status = err.Error()
}
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):
return m.refreshAllPanes("Refreshed")
case key.Matches(msg, m.keys.DirSize):
@ -906,9 +918,18 @@ func (m Model) View() string {
Background(m.palette.Panel).
Render("")
// Use filtered panes when a filter query is active.
leftPane := m.filteredPane(m.left)
rightPane := m.filteredPane(m.right)
// Filter is sticky: once activated on a pane it stays on that pane
// even after switching to the other pane with Tab.
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
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) {
// 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.Move(delta, max(m.bodyHeight()-4, 1))
m.hover = hoverState{}
}
// adjustCursorForFilter ensures the cursor stays within bounds of the
// filtered entry list. Called after each filter keystroke.
func (m *Model) adjustCursorForFilter() {
// snapFilterCursor moves the real cursor to the nearest entry matching the
// current filter query. Called after each filter keystroke so the user
// always sees a selected item in the filtered view.
func (m *Model) snapFilterCursor() {
pane := m.activePane()
if m.filterQuery == "" {
if m.filterQuery == "" || len(pane.Entries) == 0 {
return
}
count := m.filteredCount(pane)
if pane.Cursor >= count {
pane.Cursor = max(count-1, 0)
query := strings.ToLower(m.filterQuery)
// 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
}
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.
@ -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.
// 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 {
if m.filterQuery == "" {
return pane
}
query := strings.ToLower(m.filterQuery)
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 i == pane.Cursor {
filteredCursor = len(filtered)
}
filtered = append(filtered, entry)
}
}
pane.Entries = filtered
pane.Cursor = filteredCursor
if pane.Cursor >= len(filtered) {
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
}
@ -1240,7 +1337,11 @@ func (m *Model) selectMoveCursor(delta int) {
if selected, ok := pane.Selected(); ok && !selected.IsParent {
pane.ToggleMarked(selected.Path)
}
if m.filterQuery != "" && m.filterPaneID == m.active {
m.moveFilteredCursor(delta)
} else {
pane.Move(delta, max(m.bodyHeight()-4, 1))
}
m.hover = hoverState{}
}
@ -1283,6 +1384,8 @@ func (m *Model) enterSelected() error {
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 {
@ -1297,6 +1400,7 @@ func (m *Model) clearFilter() {
m.filterInput.SetValue("")
m.filterInput.Blur()
m.filterMode = false
m.filterPaneID = ""
}
func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
@ -1306,7 +1410,6 @@ func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
}
if selected.IsDir {
m.clearFilter()
if err := m.enterSelected(); err != nil {
m.status = err.Error()
return m, nil
@ -1315,7 +1418,6 @@ func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
}
if isArchiveEntry(selected) {
m.clearFilter()
if err := m.enterArchive(selected); err != nil {
m.status = err.Error()
return m, nil
@ -1331,7 +1433,6 @@ func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
func (m *Model) goParent() error {
m.hover = hoverState{}
m.clearFilter()
pane := m.activePane()
if mount, ok := pane.CurrentArchive(); ok {
@ -1339,6 +1440,8 @@ func (m *Model) goParent() error {
current := filepath.Clean(pane.Path)
if current == root {
// 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 {
_ = os.RemoveAll(mount.TempDir)
}
@ -1350,6 +1453,7 @@ func (m *Model) goParent() error {
return nil
}
// Inside archive subdirectory — go up one level within the archive
pane.PushHistory(pane.Path)
parent := filepath.Dir(current)
pane.Path = parent
if err := m.reloadPane(pane.ID, filepath.Base(current)); err != nil {
@ -1363,6 +1467,7 @@ func (m *Model) goParent() error {
if parent == pane.Path {
return nil
}
pane.PushHistory(pane.Path)
currentName := filepath.Base(pane.Path)
pane.Path = parent
if err := m.reloadPane(pane.ID, currentName); err != nil {
@ -1372,6 +1477,48 @@ func (m *Model) goParent() error {
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 {
selected, ok := m.activePane().Selected()
if !ok {
@ -2013,20 +2160,20 @@ func (m *Model) openHelpModal() {
" Enter / Right open selected entry",
" Backspace/Left go to parent directory",
" 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",
"",
"View and Panels",
" F9 / o toggle preview/info pane",
" F3 plain text view or fullscreen image viewer",
" o toggle preview/info pane",
" i show text caret in preview pane",
" 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",
" y copy visual selection to clipboard",
" h / l move caret left/right",
" w / b move caret by word",
" q / Esc close caret/info mode",
" Ctrl+t mouse selection mode in text preview pane",
" Space calculate selected directory size",
" s cycle sort mode",
@ -2034,11 +2181,11 @@ func (m *Model) openHelpModal() {
" t cycle theme",
"",
"Dialogs and Transfers",
" r rename selected entry",
" Enter / y confirm action",
" Esc / n cancel action",
" b run copy/move in background (progress dialog)",
" c cancel active copy/move transfer",
" F5/F6/F8 apply to marked entries when selection exists",
"",
"Mouse",
" Left click select entry and activate pane",
@ -2046,7 +2193,7 @@ func (m *Model) openHelpModal() {
" Right click toggle preview/info mode for clicked entry",
" Wheel scroll list or preview area",
"",
"F-key actions are shown in the footer.",
"F1F10 actions are shown in the footer.",
}
m.modal = modalState{
@ -3428,6 +3575,8 @@ func copyPlanCmd(kind fileOpKind, sourcePaths []string, targetDir string, overwr
func (m *Model) enterArchive(selected vfs.Entry) error {
pane := m.activePane()
// Save current path to history before opening the archive.
pane.PushHistory(pane.Path)
tempDir, err := vfs.ExtractArchiveToTemp(selected.Path)
if err != nil {
return err

View file

@ -27,6 +27,9 @@ type BrowserPane struct {
Offset int
Marked map[string]struct{}
Archive []ArchiveMount
dirHistory []string
dirFuture []string
}
type ArchiveMount struct {
@ -184,6 +187,57 @@ func (p *BrowserPane) InArchive() bool {
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) {
p.Archive = append(p.Archive, mount)
}