From 08c095e74fb973d29cc37438631ebc3342419185 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Mon, 27 Apr 2026 17:14:48 +0300 Subject: [PATCH] feat: add interactive file search/filter (feature #1) Pressing / opens a filter bar at the bottom of the screen. As the user types, the active pane's entries are filtered in real-time using case-insensitive substring matching. The .. entry is always preserved. Key bindings: / - open filter input (re-opens with previous query if active) Esc - clear filter and restore full list Enter - commit filter (keeps filtering active, closes input bar) Up/Down - navigate the filtered list while typing PgUp/Dn - scroll the filtered list Filter is automatically cleared when navigating to a different directory (Enter on a folder, Backspace to parent, opening an archive). Modified files: - internal/ui/keymap.go: added Filter key binding (/) - internal/ui/model.go: filterMode, filterQuery, filterInput fields; filteredPane() helper for View rendering; filter handling in Update(); renderFilterBar() for the bottom bar UI; clearFilter() helper; adjustCursorForFilter() to keep cursor in bounds Created files: - plans/feature-roadmap.md --- internal/ui/keymap.go | 2 + internal/ui/model.go | 163 +++++++++++++++++++++++++++++++++++++-- plans/feature-roadmap.md | 44 +++++++++++ 3 files changed, 204 insertions(+), 5 deletions(-) create mode 100644 plans/feature-roadmap.md diff --git a/internal/ui/keymap.go b/internal/ui/keymap.go index 7184de2..6a4bc8c 100644 --- a/internal/ui/keymap.go +++ b/internal/ui/keymap.go @@ -24,6 +24,7 @@ type KeyMap struct { Open key.Binding Back key.Binding Switch key.Binding + Filter key.Binding Refresh key.Binding DirSize key.Binding Copy key.Binding @@ -57,6 +58,7 @@ func DefaultKeyMap() KeyMap { 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")), diff --git a/internal/ui/model.go b/internal/ui/model.go index 1d98217..e9eb99a 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -205,6 +205,10 @@ type Model struct { previewModel viewport.Model previewData vfs.Preview + filterMode bool + filterQuery string + filterInput textinput.Model + modal modalState status string busy bool @@ -263,6 +267,12 @@ func NewModel(cfg config.Config, configPath string) (Model, error) { model.status = "Ready" } + filterInput := textinput.New() + filterInput.Placeholder = "filter by name…" + filterInput.CharLimit = 64 + filterInput.Width = 40 + model.filterInput = filterInput + model.previewModel = viewport.New(0, 0) if err := model.reloadPane(PaneLeft, ""); err != nil { return Model{}, err @@ -728,6 +738,51 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } } + // Filter mode: route keys to the filter input + if m.filterMode { + switch { + case msg.String() == "esc": + m.filterQuery = "" + m.filterInput.SetValue("") + m.filterInput.Blur() + m.filterMode = false + m.status = "Filter cleared" + return m, nil + case key.Matches(msg, m.keys.Confirm): + m.filterMode = false + m.filterInput.Blur() + m.status = fmt.Sprintf("Filter: %s", m.filterQuery) + return m, nil + case key.Matches(msg, m.keys.Up): + m.moveCursor(-1) + return m, m.loadPreviewCmd() + case key.Matches(msg, m.keys.Down): + m.moveCursor(1) + return m, m.loadPreviewCmd() + case key.Matches(msg, m.keys.PageUp): + m.moveCursor(-max(m.bodyHeight()-6, 5)) + return m, m.loadPreviewCmd() + case key.Matches(msg, m.keys.PageDown): + m.moveCursor(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() + return m, cmd + } + } + + // Toggle filter mode + if key.Matches(msg, m.keys.Filter) { + m.filterMode = true + m.filterInput.Focus() + m.filterInput.SetValue(m.filterQuery) + m.status = "Filter: type to filter, Enter to confirm, Esc to clear" + return m, nil + } + switch { case key.Matches(msg, m.keys.Quit): m.cleanupArchiveMounts() @@ -851,6 +906,10 @@ 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) + var panels string if m.viewMode && m.previewData.Kind == vfs.PreviewKindImage { panels = lipgloss.NewStyle(). @@ -868,7 +927,7 @@ func (m Model) View() string { if m.active == PaneLeft { panels = lipgloss.JoinHorizontal( lipgloss.Top, - renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, true, m.hoverIndexFor(PaneLeft), m.nerdIcons), + renderPane(leftPane, m.cfg, m.palette, leftWidth, bodyHeight, true, m.hoverIndexFor(PaneLeft), m.nerdIcons), gap, renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight, m.nerdIcons), ) @@ -877,20 +936,23 @@ func (m Model) View() string { lipgloss.Top, renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight, m.nerdIcons), gap, - renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, true, m.hoverIndexFor(PaneRight), m.nerdIcons), + renderPane(rightPane, m.cfg, m.palette, rightWidth, bodyHeight, true, m.hoverIndexFor(PaneRight), m.nerdIcons), ) } } else { panels = lipgloss.JoinHorizontal( lipgloss.Top, - renderPane(m.left, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft, m.hoverIndexFor(PaneLeft), m.nerdIcons), + renderPane(leftPane, m.cfg, m.palette, leftWidth, bodyHeight, m.active == PaneLeft, m.hoverIndexFor(PaneLeft), m.nerdIcons), gap, - renderPane(m.right, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight, m.hoverIndexFor(PaneRight), m.nerdIcons), + renderPane(rightPane, m.cfg, m.palette, rightWidth, bodyHeight, m.active == PaneRight, m.hoverIndexFor(PaneRight), m.nerdIcons), ) } - parts := make([]string, 0, 3) + parts := make([]string, 0, 4) parts = append(parts, panels) + if m.filterMode { + parts = append(parts, renderFilterBar(m)) + } if m.cfg.UI.ShowFooter && !m.viewMode { parts = append(parts, renderFooter(m)) } @@ -1120,6 +1182,59 @@ func (m *Model) moveCursor(delta int) { m.hover = hoverState{} } +// adjustCursorForFilter ensures the cursor stays within bounds of the +// filtered entry list. Called after each filter keystroke. +func (m *Model) adjustCursorForFilter() { + pane := m.activePane() + if m.filterQuery == "" { + return + } + count := m.filteredCount(pane) + if pane.Cursor >= count { + pane.Cursor = max(count-1, 0) + } + if pane.Offset > pane.Cursor { + pane.Offset = pane.Cursor + } +} + +// filteredCount returns the number of entries matching the current filter. +func (m *Model) filteredCount(pane *BrowserPane) int { + if m.filterQuery == "" { + return len(pane.Entries) + } + query := strings.ToLower(m.filterQuery) + count := 0 + for _, entry := range pane.Entries { + if strings.Contains(strings.ToLower(entry.DisplayName()), query) { + count++ + } + } + return count +} + +// filteredPane returns a copy of the pane with entries filtered by the current query. +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 { + if entry.IsParent || strings.Contains(strings.ToLower(entry.DisplayName()), query) { + filtered = append(filtered, entry) + } + } + pane.Entries = filtered + if pane.Cursor >= len(filtered) { + pane.Cursor = max(len(filtered)-1, 0) + } + if pane.Offset > pane.Cursor { + pane.Offset = pane.Cursor + } + return pane +} + func (m *Model) selectMoveCursor(delta int) { pane := m.activePane() if selected, ok := pane.Selected(); ok && !selected.IsParent { @@ -1177,6 +1292,13 @@ func (m *Model) enterSelected() error { return nil } +func (m *Model) clearFilter() { + m.filterQuery = "" + m.filterInput.SetValue("") + m.filterInput.Blur() + m.filterMode = false +} + func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) { selected, ok := m.activePane().Selected() if !ok { @@ -1184,6 +1306,7 @@ 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 @@ -1192,6 +1315,7 @@ 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 @@ -1207,6 +1331,7 @@ 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 { @@ -2576,6 +2701,34 @@ func renderFooter(m Model) string { return line } +func renderFilterBar(m Model) string { + prompt := lipgloss.NewStyle(). + Background(m.palette.Footer). + Foreground(m.palette.FooterKey). + Bold(true). + Render(" / ") + inputView := m.filterInput.View() + inputStyle := lipgloss.NewStyle(). + Background(m.palette.Footer). + Foreground(m.palette.Text) + line := prompt + inputStyle.Render(inputView) + filtered := m.filteredCount(m.activePane()) + if m.filterQuery != "" { + countStyle := lipgloss.NewStyle(). + Background(m.palette.Footer). + Foreground(m.palette.Muted) + line += countStyle.Render(fmt.Sprintf(" (%d)", filtered)) + } + line = ansi.Truncate(line, m.width, "") + fill := m.width - ansi.StringWidth(line) + if fill > 0 { + line += lipgloss.NewStyle(). + Background(m.palette.Footer). + Render(strings.Repeat(" ", fill)) + } + return line +} + func renderModal(m Model, palette theme.Palette, width int) string { if m.modal.kind == modalCopyProgress && m.copyJob != nil { return renderCopyProgressModal(*m.copyJob, palette, width) diff --git a/plans/feature-roadmap.md b/plans/feature-roadmap.md new file mode 100644 index 0000000..6f218dd --- /dev/null +++ b/plans/feature-roadmap.md @@ -0,0 +1,44 @@ +# Feature Roadmap — vcom + +## Выбранные фичи (по приоритету) + +### 1. Поиск/фильтрация файлов (`/`) +- [ ] **Filter mode**: при нажатии `/` открывается текстовый инпут внизу экрана (поверх footer, как модальное окно) +- [ ] Фильтрация `[]Entry` в активной панели на лету по `strings.Contains`/fuzzy-match +- [ ] Подсветка совпадений в строках (изменить `renderEntryRow` — передать query, подсветить matched part) +- [ ] `Esc` — выход из filter mode, восстановление полного списка +- [ ] `Enter` — зафиксировать фильтр (оставить отфильтрованный список), выход из filter mode +- [ ] При смене директории фильтр сбрасывается + +### 2. Bulk rename (массовое переименование) +- [ ] Выделить файлы (`Shift+↑/↓`), нажать `Ctrl+R` (новая клавиша) +- [ ] Модальное окно с текстовым полем для паттерна: `prefix_%N.ext` +- [ ] Превью результата (старое имя → новое имя) +- [ ] Выполнить rename для всех выделенных + +### 3. Корзина (trash support) +- [ ] `Delete/F8` — перемещать в `~/.local/share/Trash/` по freedesktop spec +- [ ] `Shift+Delete` —永久ное удаление (как сейчас) +- [ ] `browser.confirm_delete` применяется к永久ному удалению +- [ ] Новая опция конфига: `behavior.use_trash = true` (default: true) + +### 4. Directory history (назад/вперед) +- [ ] `[]string` стек истории на каждую панель +- [ ] `Alt+←` / `Alt+→` — навигация назад/вперед +- [ ] При переходе в новую директорию (Enter, Backspace, клик) — push в history +- [ ] При навигации по истории — не создавать новые записи + +### 5. Расширенный превью форматов +- [ ] PDF — извлечение текста через `pdftotext` (если доступен) +- [ ] Аудио — метаданные через `ffprobe` (битрейт, длительность, кодек) +- [ ] Видео — метаданные + превью через `ffmpegthumbnailer`/`ffprobe` +- [ ] Fallback если утилита не установлена + +--- + +## Процесс + +1. Каждая фича реализуется в отдельной ветке `feature/N-имя` +2. После реализации — commit + push +3. После апрува — merge в main +4. Порядок: 1 → 4 → 3 → 2 → 5 (от простого к сложному)