From 9737cad6de40e54ebe65cd25df198b353514e92d Mon Sep 17 00:00:00 2001 From: Jokin Date: Sat, 11 Jul 2026 22:05:23 +0200 Subject: [PATCH] Type-to-filter in file panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing while the list is focused builds a filter query: a bar appears under the column headers (search icon, query, live match count, Esc hint, clear button) and the listing narrows to substring matches — '..' always stays. Backspace edits the query, Escape clears it, cursor parks on the first match, arrows/Enter keep working (the list never loses focus), and Space keeps its select meaning until a query exists. Filter resets on directory change and survives refresh; operations act only on visible (filtered) selection. Co-Authored-By: Claude Fable 5 --- ui/src/app/file-panel/file-panel.html | 21 ++++++++++++++++ ui/src/app/file-panel/file-panel.ts | 36 +++++++++++++++++++++++++++ ui/src/app/icons.ts | 2 ++ ui/src/app/panels.service.ts | 16 +++++++++++- 4 files changed, 74 insertions(+), 1 deletion(-) diff --git a/ui/src/app/file-panel/file-panel.html b/ui/src/app/file-panel/file-panel.html index a985a5d..62f1b5a 100644 --- a/ui/src/app/file-panel/file-panel.html +++ b/ui/src/app/file-panel/file-panel.html @@ -186,6 +186,24 @@ + +@if (panel().filter(); as query) { +
+ + {{ query }} + + {{ filterMatches() }} match{{ filterMatches() === 1 ? '' : 'es' }} · Esc + + +
+} +
Empty directory
} + @if (panel().filter() && filterMatches() === 0) { +
No matches
+ } diff --git a/ui/src/app/file-panel/file-panel.ts b/ui/src/app/file-panel/file-panel.ts index 3cd2927..86ebb5b 100644 --- a/ui/src/app/file-panel/file-panel.ts +++ b/ui/src/app/file-panel/file-panel.ts @@ -100,6 +100,17 @@ export class FilePanel { return this.panel().selected().has(entry.name); } + clearFilter(): void { + this.panel().setFilter(''); + this.listEl().nativeElement.focus(); + } + + filterMatches(): number { + return this.panel() + .sorted() + .filter((e) => e.name !== '..').length; + } + sortIndicator(key: SortKey): string { if (this.panel().sortKey() !== key) return ''; return this.panel().sortAsc() ? '↑' : '↓'; @@ -151,6 +162,31 @@ export class FilePanel { return; } + // type-to-filter: printable characters build the query (Space only once a + // query exists — an empty-filter Space keeps its toggle-select meaning) + if ( + key.length === 1 && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + (key !== ' ' || panel.filter() !== '') + ) { + event.preventDefault(); + panel.setFilter(panel.filter() + key); + this.scrollCursorIntoView(); + return; + } + if (key === 'Backspace' && panel.filter() !== '') { + event.preventDefault(); + panel.setFilter(panel.filter().slice(0, -1)); + return; + } + if (key === 'Escape' && panel.filter() !== '') { + event.preventDefault(); + panel.setFilter(''); + return; + } + switch (key) { case 'ArrowDown': panel.moveCursor(1); diff --git a/ui/src/app/icons.ts b/ui/src/app/icons.ts index c8332fb..c2190ff 100644 --- a/ui/src/app/icons.ts +++ b/ui/src/app/icons.ts @@ -23,6 +23,7 @@ import { mynaLogout, mynaPlus, mynaRefresh, + mynaSearch, mynaServers, mynaSpinner, mynaTrash, @@ -52,6 +53,7 @@ export const APP_ICONS = { mynaLogout, mynaPlus, mynaRefresh, + mynaSearch, mynaServers, mynaSpinner, mynaTrash, diff --git a/ui/src/app/panels.service.ts b/ui/src/app/panels.service.ts index d5785f1..26e3854 100644 --- a/ui/src/app/panels.service.ts +++ b/ui/src/app/panels.service.ts @@ -24,10 +24,16 @@ export class PanelState { readonly sortAsc = signal(true); readonly cursor = signal(0); readonly selected = signal>(new Set()); + /** Type-to-filter query; entries not matching are hidden (".." always stays). */ + readonly filter = signal(''); /** Directories first, then the active sort column. ".." (parent) is pinned on top. */ readonly sorted = computed(() => { - const list = [...this.entries()]; + const query = this.filter().toLowerCase(); + let list = [...this.entries()]; + if (query) { + list = list.filter((e) => e.name.toLowerCase().includes(query)); + } const key = this.sortKey(); const dir = this.sortAsc() ? 1 : -1; list.sort((a, b) => { @@ -69,6 +75,7 @@ export class PanelState { this.entries.set(res.entries); this.cursor.set(0); this.selected.set(new Set()); + this.filter.set(''); } finally { this.loading.set(false); } @@ -110,6 +117,13 @@ export class PanelState { void this.load(`${this.cwd()}/..`); } + /** Update the filter and park the cursor on the first real match. */ + setFilter(query: string): void { + this.filter.set(query); + const first = this.sorted().findIndex((e) => e.name !== '..'); + this.cursor.set(first >= 0 ? first : 0); + } + toggleSort(key: SortKey): void { if (this.sortKey() === key) { this.sortAsc.update((v) => !v);