Type-to-filter in file panels
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 <noreply@anthropic.com>
This commit is contained in:
@@ -186,6 +186,24 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- type-to-filter bar (appears while a query is active) -->
|
||||||
|
@if (panel().filter(); as query) {
|
||||||
|
<div class="flex items-center gap-1.5 border-b border-border bg-accent/50 px-2 py-1">
|
||||||
|
<ng-icon name="mynaSearch" size="0.85rem" class="shrink-0 text-muted-foreground" />
|
||||||
|
<span class="min-w-0 flex-1 truncate font-mono text-xs">{{ query }}</span>
|
||||||
|
<span class="shrink-0 text-[11px] text-muted-foreground">
|
||||||
|
{{ filterMatches() }} match{{ filterMatches() === 1 ? '' : 'es' }} · Esc
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
(click)="clearFilter()"
|
||||||
|
title="Clear filter (Esc)"
|
||||||
|
class="grid size-5 shrink-0 place-items-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ng-icon name="mynaX" size="0.8rem" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- file list -->
|
<!-- file list -->
|
||||||
<div
|
<div
|
||||||
#list
|
#list
|
||||||
@@ -224,4 +242,7 @@
|
|||||||
} @empty {
|
} @empty {
|
||||||
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
|
<div class="p-6 text-center text-xs text-muted-foreground">Empty directory</div>
|
||||||
}
|
}
|
||||||
|
@if (panel().filter() && filterMatches() === 0) {
|
||||||
|
<div class="p-6 text-center text-xs text-muted-foreground">No matches</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -100,6 +100,17 @@ export class FilePanel {
|
|||||||
return this.panel().selected().has(entry.name);
|
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 {
|
sortIndicator(key: SortKey): string {
|
||||||
if (this.panel().sortKey() !== key) return '';
|
if (this.panel().sortKey() !== key) return '';
|
||||||
return this.panel().sortAsc() ? '↑' : '↓';
|
return this.panel().sortAsc() ? '↑' : '↓';
|
||||||
@@ -151,6 +162,31 @@ export class FilePanel {
|
|||||||
return;
|
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) {
|
switch (key) {
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
panel.moveCursor(1);
|
panel.moveCursor(1);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
mynaLogout,
|
mynaLogout,
|
||||||
mynaPlus,
|
mynaPlus,
|
||||||
mynaRefresh,
|
mynaRefresh,
|
||||||
|
mynaSearch,
|
||||||
mynaServers,
|
mynaServers,
|
||||||
mynaSpinner,
|
mynaSpinner,
|
||||||
mynaTrash,
|
mynaTrash,
|
||||||
@@ -52,6 +53,7 @@ export const APP_ICONS = {
|
|||||||
mynaLogout,
|
mynaLogout,
|
||||||
mynaPlus,
|
mynaPlus,
|
||||||
mynaRefresh,
|
mynaRefresh,
|
||||||
|
mynaSearch,
|
||||||
mynaServers,
|
mynaServers,
|
||||||
mynaSpinner,
|
mynaSpinner,
|
||||||
mynaTrash,
|
mynaTrash,
|
||||||
|
|||||||
@@ -24,10 +24,16 @@ export class PanelState {
|
|||||||
readonly sortAsc = signal(true);
|
readonly sortAsc = signal(true);
|
||||||
readonly cursor = signal(0);
|
readonly cursor = signal(0);
|
||||||
readonly selected = signal<ReadonlySet<string>>(new Set());
|
readonly selected = signal<ReadonlySet<string>>(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. */
|
/** Directories first, then the active sort column. ".." (parent) is pinned on top. */
|
||||||
readonly sorted = computed(() => {
|
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 key = this.sortKey();
|
||||||
const dir = this.sortAsc() ? 1 : -1;
|
const dir = this.sortAsc() ? 1 : -1;
|
||||||
list.sort((a, b) => {
|
list.sort((a, b) => {
|
||||||
@@ -69,6 +75,7 @@ export class PanelState {
|
|||||||
this.entries.set(res.entries);
|
this.entries.set(res.entries);
|
||||||
this.cursor.set(0);
|
this.cursor.set(0);
|
||||||
this.selected.set(new Set());
|
this.selected.set(new Set());
|
||||||
|
this.filter.set('');
|
||||||
} finally {
|
} finally {
|
||||||
this.loading.set(false);
|
this.loading.set(false);
|
||||||
}
|
}
|
||||||
@@ -110,6 +117,13 @@ export class PanelState {
|
|||||||
void this.load(`${this.cwd()}/..`);
|
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 {
|
toggleSort(key: SortKey): void {
|
||||||
if (this.sortKey() === key) {
|
if (this.sortKey() === key) {
|
||||||
this.sortAsc.update((v) => !v);
|
this.sortAsc.update((v) => !v);
|
||||||
|
|||||||
Reference in New Issue
Block a user