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
This commit is contained in:
parent
bba8783f10
commit
08c095e74f
3 changed files with 204 additions and 5 deletions
|
|
@ -24,6 +24,7 @@ type KeyMap struct {
|
||||||
Open key.Binding
|
Open key.Binding
|
||||||
Back key.Binding
|
Back key.Binding
|
||||||
Switch key.Binding
|
Switch key.Binding
|
||||||
|
Filter key.Binding
|
||||||
Refresh key.Binding
|
Refresh key.Binding
|
||||||
DirSize key.Binding
|
DirSize key.Binding
|
||||||
Copy 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")),
|
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")),
|
PageUp: key.NewBinding(key.WithKeys("pgup"), key.WithHelp("PgUp", "page up")),
|
||||||
PageDown: key.NewBinding(),
|
PageDown: key.NewBinding(),
|
||||||
|
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||||
Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")),
|
Open: key.NewBinding(key.WithKeys("enter", "right"), key.WithHelp("Enter", "open")),
|
||||||
Back: key.NewBinding(key.WithKeys("backspace", "left"), key.WithHelp("←", "parent")),
|
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")),
|
Switch: key.NewBinding(key.WithKeys("tab", "h", "l"), key.WithHelp("Tab/h/l", "switch pane")),
|
||||||
|
|
|
||||||
|
|
@ -205,6 +205,10 @@ type Model struct {
|
||||||
previewModel viewport.Model
|
previewModel viewport.Model
|
||||||
previewData vfs.Preview
|
previewData vfs.Preview
|
||||||
|
|
||||||
|
filterMode bool
|
||||||
|
filterQuery string
|
||||||
|
filterInput textinput.Model
|
||||||
|
|
||||||
modal modalState
|
modal modalState
|
||||||
status string
|
status string
|
||||||
busy bool
|
busy bool
|
||||||
|
|
@ -263,6 +267,12 @@ func NewModel(cfg config.Config, configPath string) (Model, error) {
|
||||||
model.status = "Ready"
|
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)
|
model.previewModel = viewport.New(0, 0)
|
||||||
if err := model.reloadPane(PaneLeft, ""); err != nil {
|
if err := model.reloadPane(PaneLeft, ""); err != nil {
|
||||||
return Model{}, err
|
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 {
|
switch {
|
||||||
case key.Matches(msg, m.keys.Quit):
|
case key.Matches(msg, m.keys.Quit):
|
||||||
m.cleanupArchiveMounts()
|
m.cleanupArchiveMounts()
|
||||||
|
|
@ -851,6 +906,10 @@ func (m Model) View() string {
|
||||||
Background(m.palette.Panel).
|
Background(m.palette.Panel).
|
||||||
Render("")
|
Render("")
|
||||||
|
|
||||||
|
// Use filtered panes when a filter query is active.
|
||||||
|
leftPane := m.filteredPane(m.left)
|
||||||
|
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 {
|
||||||
panels = lipgloss.NewStyle().
|
panels = lipgloss.NewStyle().
|
||||||
|
|
@ -868,7 +927,7 @@ func (m Model) View() string {
|
||||||
if m.active == PaneLeft {
|
if m.active == PaneLeft {
|
||||||
panels = lipgloss.JoinHorizontal(
|
panels = lipgloss.JoinHorizontal(
|
||||||
lipgloss.Top,
|
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,
|
gap,
|
||||||
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight, m.nerdIcons),
|
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight, m.nerdIcons),
|
||||||
)
|
)
|
||||||
|
|
@ -877,20 +936,23 @@ func (m Model) View() string {
|
||||||
lipgloss.Top,
|
lipgloss.Top,
|
||||||
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight, m.nerdIcons),
|
renderPreviewPane(m.previewData, &m.previewModel, m.cfg, m.palette, previewWidth, bodyHeight, m.nerdIcons),
|
||||||
gap,
|
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 {
|
} else {
|
||||||
panels = lipgloss.JoinHorizontal(
|
panels = lipgloss.JoinHorizontal(
|
||||||
lipgloss.Top,
|
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,
|
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)
|
parts = append(parts, panels)
|
||||||
|
if m.filterMode {
|
||||||
|
parts = append(parts, renderFilterBar(m))
|
||||||
|
}
|
||||||
if m.cfg.UI.ShowFooter && !m.viewMode {
|
if m.cfg.UI.ShowFooter && !m.viewMode {
|
||||||
parts = append(parts, renderFooter(m))
|
parts = append(parts, renderFooter(m))
|
||||||
}
|
}
|
||||||
|
|
@ -1120,6 +1182,59 @@ func (m *Model) moveCursor(delta int) {
|
||||||
m.hover = hoverState{}
|
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) {
|
func (m *Model) selectMoveCursor(delta int) {
|
||||||
pane := m.activePane()
|
pane := m.activePane()
|
||||||
if selected, ok := pane.Selected(); ok && !selected.IsParent {
|
if selected, ok := pane.Selected(); ok && !selected.IsParent {
|
||||||
|
|
@ -1177,6 +1292,13 @@ func (m *Model) enterSelected() error {
|
||||||
return nil
|
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) {
|
func (m *Model) handleOpenSelected() (tea.Model, tea.Cmd) {
|
||||||
selected, ok := m.activePane().Selected()
|
selected, ok := m.activePane().Selected()
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|
@ -1184,6 +1306,7 @@ 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
|
||||||
|
|
@ -1192,6 +1315,7 @@ 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
|
||||||
|
|
@ -1207,6 +1331,7 @@ 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 {
|
||||||
|
|
@ -2576,6 +2701,34 @@ func renderFooter(m Model) string {
|
||||||
return line
|
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 {
|
func renderModal(m Model, palette theme.Palette, width int) string {
|
||||||
if m.modal.kind == modalCopyProgress && m.copyJob != nil {
|
if m.modal.kind == modalCopyProgress && m.copyJob != nil {
|
||||||
return renderCopyProgressModal(*m.copyJob, palette, width)
|
return renderCopyProgressModal(*m.copyJob, palette, width)
|
||||||
|
|
|
||||||
44
plans/feature-roadmap.md
Normal file
44
plans/feature-roadmap.md
Normal file
|
|
@ -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 (от простого к сложному)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue