Skip to content

Commit cf4faf0

Browse files
VirusAlexaegupov-nclaude
authored
feat(ui): clickable sort columns + per-panel filter (#67)
Both panels gain two controls users coming from any file manager expect: - **Sort columns**: clicking the Name / Size / Modified column header sorts by that column ascending; clicking it again flips to descending. A small ▲ / ▼ glyph marks the active column in accent colour. The "dirs first → files → symlinks" grouping is preserved (file-manager convention); the chosen column orders WITHIN each group. Sort state persists across breadcrumb navigation but resets per panel session. - **Filter row** between the breadcrumb and the filelist: substring match (case-insensitive) on the entry name. 150 ms debounce so typing doesn't thrash. Esc clears, × button clears. Filter does NOT recurse — it operates on the current view only; finding files deeper still needs the cross-tree comparison via /api/browse/stats. Filter resets on directory navigation since a stale filter from another folder is rarely what the user wants. Selection semantics under filter: - "Select all" (header checkbox) toggles only the visible/filtered entries — selecting hidden entries silently would surprise users. - Selections made before a filter survive while the filter is active (the underlying selection list isn't pruned), so toggling the filter off shows them again. - allSelected() returns true iff every visible entry is in the selection. Pure client-side: no API changes, no Java touched. ~150 lines of browser.js + index.html + style.css across both panels. Co-authored-by: VirusAlex <alexey.egupov@norse.bh> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3956ceb commit cf4faf0

3 files changed

Lines changed: 203 additions & 17 deletions

File tree

src/main/resources/web/browser.js

Lines changed: 118 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,15 @@ function dualBrowser() {
360360
};
361361
}
362362

363+
/** Deterministic type bucket: dirs first, files second, symlinks (and anything
364+
* unknown) last. Mirrors the server's BrowseRoutes.typeOrder so the client view
365+
* starts in the same grouping the server returned. */
366+
function _typeOrder(t) {
367+
if (t === 'dir') return 0;
368+
if (t === 'file') return 1;
369+
return 2;
370+
}
371+
363372
function makePanel(side) {
364373
return {
365374
side,
@@ -376,10 +385,89 @@ function makePanel(side) {
376385
loading: false,
377386
error: null,
378387

388+
// ----- Sort + filter state (v0.4.1+) -----
389+
// sortBy chooses the in-group ordering (within "dirs first, files second,
390+
// symlinks last" — that grouping is non-negotiable for the file-manager
391+
// UX). sortDir flips it. filterText is a substring match on entry name.
392+
// All three persist while the panel stays on the same path; filterText
393+
// resets on navigation, sort* persists across navigations within the
394+
// panel session.
395+
sortBy: 'name', // 'name' | 'type' | 'size' | 'mtime'
396+
sortDir: 'asc', // 'asc' | 'desc'
397+
filterText: '',
398+
379399
get crumbsRecomputed() {
380400
return this.path ? this.path.split('/').filter(Boolean) : [];
381401
},
382402

403+
/**
404+
* Computed view: entries filtered by `filterText` (case-insensitive
405+
* substring match on `name`) and ordered by `sortBy`/`sortDir` within
406+
* the type-grouping (dirs → files → symlinks). Recomputed every render
407+
* — fine at typical N=100-200 entries; would need memoisation only at
408+
* 10k+. Selection / select-all / match-highlight all key off entry
409+
* identity (name+type), so filtering doesn't lose them.
410+
*/
411+
displayEntries() {
412+
let arr = this.entries;
413+
const q = (this.filterText || '').toLowerCase();
414+
if (q) {
415+
arr = arr.filter(e => e && e.name && e.name.toLowerCase().includes(q));
416+
}
417+
arr = arr.slice().sort((a, b) => this._compareEntries(a, b));
418+
return arr;
419+
},
420+
421+
_compareEntries(a, b) {
422+
const aT = _typeOrder(a && a.type);
423+
const bT = _typeOrder(b && b.type);
424+
if (aT !== bT) return aT - bT;
425+
let cmp;
426+
switch (this.sortBy) {
427+
case 'type':
428+
cmp = (a.type || '').localeCompare(b.type || '');
429+
break;
430+
case 'size':
431+
// Dirs / symlinks have no meaningful size; treat as 0 so
432+
// they rank below the smallest real file in size-asc.
433+
cmp = (a.size || 0) - (b.size || 0);
434+
break;
435+
case 'mtime':
436+
cmp = (a.mtime || 0) - (b.mtime || 0);
437+
break;
438+
default:
439+
cmp = (a.name || '').localeCompare(b.name || '');
440+
}
441+
// Stable tie-break by name so two entries with equal size/mtime
442+
// don't shuffle on every sort flip.
443+
if (cmp === 0 && this.sortBy !== 'name') {
444+
cmp = (a.name || '').localeCompare(b.name || '');
445+
}
446+
return this.sortDir === 'desc' ? -cmp : cmp;
447+
},
448+
449+
/** Click on a column header. Same column → flip direction; different
450+
* column → switch to that column ascending. */
451+
setSort(col) {
452+
if (this.sortBy === col) {
453+
this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc';
454+
} else {
455+
this.sortBy = col;
456+
this.sortDir = 'asc';
457+
}
458+
},
459+
460+
/** Glyph for the column header. Empty string for inactive columns. */
461+
sortIndicator(col) {
462+
if (this.sortBy !== col) return '';
463+
return this.sortDir === 'asc' ? ' ▲' : ' ▼';
464+
},
465+
466+
/** Clear the filter — wired to the × button and the Esc key. */
467+
clearFilter() {
468+
this.filterText = '';
469+
},
470+
383471
async discoverRoots() {
384472
// We don't have a /api/peer/info contract yet, so probe
385473
// rootIdx=0..7 against both /api/browse (shared) and
@@ -561,12 +649,19 @@ function makePanel(side) {
561649
this.selectedFiles = 0;
562650
this.selectedBytes = 0;
563651
this.rootFree = null;
652+
// Filter is per-directory ergonomically; clear it on root change.
653+
// Sort preference persists — user's chosen ordering applies to the
654+
// new root too.
655+
this.filterText = '';
564656
this.refresh();
565657
},
566658

567659
goPath(p) {
568660
this.path = p || '';
569661
this.selection = [];
662+
// Stale filter from a different folder is rarely what the user wants
663+
// when they navigate; reset it.
664+
this.filterText = '';
570665
this.refresh();
571666
},
572667
goCrumb(idx) {
@@ -613,17 +708,35 @@ function makePanel(side) {
613708
this.recomputeSelectionStats();
614709
},
615710
toggleAll(checked) {
711+
// Operate on the FILTERED view: "select all" with an active filter
712+
// should select what's visible, not silently include hidden entries.
713+
// Likewise unchecking removes only the visible ones — selections
714+
// made while the filter was off survive a temporary filter session.
715+
const visible = this.displayEntries();
616716
if (checked) {
617-
this.selection = this.entries.map(e => ({
618-
name: e.name, type: e.type, size: e.size, mtime: e.mtime,
619-
}));
717+
for (const e of visible) {
718+
if (!this.isSelected(e)) {
719+
this.selection.push({
720+
name: e.name, type: e.type, size: e.size, mtime: e.mtime,
721+
});
722+
}
723+
}
620724
} else {
621-
this.selection = [];
725+
const visKeys = new Set(visible.map(e => e.type + ':' + e.name));
726+
this.selection = this.selection.filter(
727+
s => !visKeys.has(s.type + ':' + s.name));
622728
}
623729
this.recomputeSelectionStats();
624730
},
625731
allSelected() {
626-
return this.entries.length > 0 && this.selection.length === this.entries.length;
732+
// True iff every currently-visible entry is in the selection. Empty
733+
// view ⇒ checkbox is unchecked (nothing to select).
734+
const visible = this.displayEntries();
735+
if (visible.length === 0) return false;
736+
for (const e of visible) {
737+
if (!this.isSelected(e)) return false;
738+
}
739+
return true;
627740
},
628741

629742
// Lazy by-type index over the panel's entries, used by dualBrowser.matchClass

src/main/resources/web/index.html

Lines changed: 58 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -87,16 +87,41 @@
8787
</span>
8888
</template>
8989
</div>
90+
<!--
91+
Inline filter (v0.4.1+). Substring match against the current view
92+
only — does not recurse. Esc clears, × button clears. Filter text
93+
resets on directory navigation; sort prefs persist.
94+
-->
95+
<div class="panel-filter">
96+
<input type="text"
97+
class="text-input grow"
98+
placeholder="Filter (substring match)…"
99+
x-model.debounce.150ms="local.filterText"
100+
@keyup.escape="local.clearFilter()">
101+
<button class="btn ghost small"
102+
x-show="local.filterText"
103+
@click="local.clearFilter()"
104+
title="Clear filter (Esc)">×</button>
105+
</div>
90106
<div class="filelist" :class="{ 'is-loading': local.loading }">
91107
<div class="filelist-head">
92108
<span class="col-check">
93109
<input type="checkbox"
94110
:checked="local.allSelected()"
95111
@change="local.toggleAll($event.target.checked)">
96112
</span>
97-
<span class="col-name">Name</span>
98-
<span class="col-size">Size</span>
99-
<span class="col-mtime">Modified</span>
113+
<span class="col-name col-sortable"
114+
:class="{ 'col-sorted': local.sortBy === 'name' }"
115+
@click="local.setSort('name')"
116+
title="Sort by name">Name<span x-text="local.sortIndicator('name')"></span></span>
117+
<span class="col-size col-sortable"
118+
:class="{ 'col-sorted': local.sortBy === 'size' }"
119+
@click="local.setSort('size')"
120+
title="Sort by size">Size<span x-text="local.sortIndicator('size')"></span></span>
121+
<span class="col-mtime col-sortable"
122+
:class="{ 'col-sorted': local.sortBy === 'mtime' }"
123+
@click="local.setSort('mtime')"
124+
title="Sort by modification time">Modified<span x-text="local.sortIndicator('mtime')"></span></span>
100125
</div>
101126
<div class="filelist-body">
102127
<div class="filerow"
@@ -107,7 +132,7 @@
107132
<span class="col-size"></span>
108133
<span class="col-mtime"></span>
109134
</div>
110-
<template x-for="e in local.entries" :key="e.name">
135+
<template x-for="e in local.displayEntries()" :key="e.type + ':' + e.name">
111136
<div class="filerow"
112137
:class="{
113138
'is-dir': e.type === 'dir',
@@ -129,8 +154,8 @@
129154
</div>
130155
</template>
131156
<div class="filelist-empty"
132-
x-show="!local.loading && local.entries.length === 0">
133-
<span x-text="local.error || 'empty'"></span>
157+
x-show="!local.loading && local.displayEntries().length === 0">
158+
<span x-text="local.error || (local.filterText ? 'no matches' : 'empty')"></span>
134159
</div>
135160
</div>
136161
</div>
@@ -195,16 +220,37 @@
195220
</span>
196221
</template>
197222
</div>
223+
<!-- Mirrors the local-panel filter row. -->
224+
<div class="panel-filter">
225+
<input type="text"
226+
class="text-input grow"
227+
placeholder="Filter (substring match)…"
228+
x-model.debounce.150ms="peer.filterText"
229+
@keyup.escape="peer.clearFilter()">
230+
<button class="btn ghost small"
231+
x-show="peer.filterText"
232+
@click="peer.clearFilter()"
233+
title="Clear filter (Esc)">×</button>
234+
</div>
198235
<div class="filelist" :class="{ 'is-loading': peer.loading }">
199236
<div class="filelist-head">
200237
<span class="col-check">
201238
<input type="checkbox"
202239
:checked="peer.allSelected()"
203240
@change="peer.toggleAll($event.target.checked)">
204241
</span>
205-
<span class="col-name">Name</span>
206-
<span class="col-size">Size</span>
207-
<span class="col-mtime">Modified</span>
242+
<span class="col-name col-sortable"
243+
:class="{ 'col-sorted': peer.sortBy === 'name' }"
244+
@click="peer.setSort('name')"
245+
title="Sort by name">Name<span x-text="peer.sortIndicator('name')"></span></span>
246+
<span class="col-size col-sortable"
247+
:class="{ 'col-sorted': peer.sortBy === 'size' }"
248+
@click="peer.setSort('size')"
249+
title="Sort by size">Size<span x-text="peer.sortIndicator('size')"></span></span>
250+
<span class="col-mtime col-sortable"
251+
:class="{ 'col-sorted': peer.sortBy === 'mtime' }"
252+
@click="peer.setSort('mtime')"
253+
title="Sort by modification time">Modified<span x-text="peer.sortIndicator('mtime')"></span></span>
208254
</div>
209255
<div class="filelist-body">
210256
<div class="filerow"
@@ -215,7 +261,7 @@
215261
<span class="col-size"></span>
216262
<span class="col-mtime"></span>
217263
</div>
218-
<template x-for="e in peer.entries" :key="e.name">
264+
<template x-for="e in peer.displayEntries()" :key="e.type + ':' + e.name">
219265
<div class="filerow"
220266
:class="{
221267
'is-dir': e.type === 'dir',
@@ -237,8 +283,8 @@
237283
</div>
238284
</template>
239285
<div class="filelist-empty"
240-
x-show="!peer.loading && peer.entries.length === 0">
241-
<span x-text="peer.error || 'no peer / empty'"></span>
286+
x-show="!peer.loading && peer.displayEntries().length === 0">
287+
<span x-text="peer.error || (peer.filterText ? 'no matches' : 'no peer / empty')"></span>
242288
</div>
243289
</div>
244290
</div>

src/main/resources/web/style.css

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,33 @@ body {
217217
letter-spacing: 0.05em;
218218
z-index: 1;
219219
}
220+
221+
/* Sortable column header (v0.4.1+). Click to sort by that column; click again
222+
to reverse. The active column gets a stronger colour so the indicator arrow
223+
reads as "this is sorting now". */
224+
.filelist-head .col-sortable {
225+
cursor: pointer;
226+
user-select: none;
227+
}
228+
.filelist-head .col-sortable:hover { color: var(--fg); }
229+
.filelist-head .col-sorted { color: var(--accent-2); }
230+
231+
/* Per-panel substring filter (v0.4.1+). Sits between the breadcrumb and the
232+
filelist. Width matches the panel's content column. */
233+
.panel-filter {
234+
display: flex;
235+
align-items: center;
236+
gap: 4px;
237+
padding: 4px 12px;
238+
background: var(--bg-2);
239+
border-bottom: 1px solid var(--border);
240+
}
241+
.panel-filter .text-input {
242+
width: auto;
243+
min-width: 0;
244+
flex: 1;
245+
font-family: var(--font-ui);
246+
}
220247
.filerow {
221248
cursor: default;
222249
user-select: none;

0 commit comments

Comments
 (0)