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);