From 26ce847c6e5efb0d282c3cba4bdfd4b74bcb40fa Mon Sep 17 00:00:00 2001 From: VirusAlex Date: Fri, 1 May 2026 11:36:01 +0300 Subject: [PATCH] feat(ui): clickable sort columns + per-panel filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: Claude Opus 4.7 (1M context) --- src/main/resources/web/browser.js | 123 ++++++++++++++++++++++++++++-- src/main/resources/web/index.html | 70 ++++++++++++++--- src/main/resources/web/style.css | 27 +++++++ 3 files changed, 203 insertions(+), 17 deletions(-) diff --git a/src/main/resources/web/browser.js b/src/main/resources/web/browser.js index bd80009..0ebb538 100644 --- a/src/main/resources/web/browser.js +++ b/src/main/resources/web/browser.js @@ -360,6 +360,15 @@ function dualBrowser() { }; } +/** Deterministic type bucket: dirs first, files second, symlinks (and anything + * unknown) last. Mirrors the server's BrowseRoutes.typeOrder so the client view + * starts in the same grouping the server returned. */ +function _typeOrder(t) { + if (t === 'dir') return 0; + if (t === 'file') return 1; + return 2; +} + function makePanel(side) { return { side, @@ -376,10 +385,89 @@ function makePanel(side) { loading: false, error: null, + // ----- Sort + filter state (v0.4.1+) ----- + // sortBy chooses the in-group ordering (within "dirs first, files second, + // symlinks last" — that grouping is non-negotiable for the file-manager + // UX). sortDir flips it. filterText is a substring match on entry name. + // All three persist while the panel stays on the same path; filterText + // resets on navigation, sort* persists across navigations within the + // panel session. + sortBy: 'name', // 'name' | 'type' | 'size' | 'mtime' + sortDir: 'asc', // 'asc' | 'desc' + filterText: '', + get crumbsRecomputed() { return this.path ? this.path.split('/').filter(Boolean) : []; }, + /** + * Computed view: entries filtered by `filterText` (case-insensitive + * substring match on `name`) and ordered by `sortBy`/`sortDir` within + * the type-grouping (dirs → files → symlinks). Recomputed every render + * — fine at typical N=100-200 entries; would need memoisation only at + * 10k+. Selection / select-all / match-highlight all key off entry + * identity (name+type), so filtering doesn't lose them. + */ + displayEntries() { + let arr = this.entries; + const q = (this.filterText || '').toLowerCase(); + if (q) { + arr = arr.filter(e => e && e.name && e.name.toLowerCase().includes(q)); + } + arr = arr.slice().sort((a, b) => this._compareEntries(a, b)); + return arr; + }, + + _compareEntries(a, b) { + const aT = _typeOrder(a && a.type); + const bT = _typeOrder(b && b.type); + if (aT !== bT) return aT - bT; + let cmp; + switch (this.sortBy) { + case 'type': + cmp = (a.type || '').localeCompare(b.type || ''); + break; + case 'size': + // Dirs / symlinks have no meaningful size; treat as 0 so + // they rank below the smallest real file in size-asc. + cmp = (a.size || 0) - (b.size || 0); + break; + case 'mtime': + cmp = (a.mtime || 0) - (b.mtime || 0); + break; + default: + cmp = (a.name || '').localeCompare(b.name || ''); + } + // Stable tie-break by name so two entries with equal size/mtime + // don't shuffle on every sort flip. + if (cmp === 0 && this.sortBy !== 'name') { + cmp = (a.name || '').localeCompare(b.name || ''); + } + return this.sortDir === 'desc' ? -cmp : cmp; + }, + + /** Click on a column header. Same column → flip direction; different + * column → switch to that column ascending. */ + setSort(col) { + if (this.sortBy === col) { + this.sortDir = this.sortDir === 'asc' ? 'desc' : 'asc'; + } else { + this.sortBy = col; + this.sortDir = 'asc'; + } + }, + + /** Glyph for the column header. Empty string for inactive columns. */ + sortIndicator(col) { + if (this.sortBy !== col) return ''; + return this.sortDir === 'asc' ? ' ▲' : ' ▼'; + }, + + /** Clear the filter — wired to the × button and the Esc key. */ + clearFilter() { + this.filterText = ''; + }, + async discoverRoots() { // We don't have a /api/peer/info contract yet, so probe // rootIdx=0..7 against both /api/browse (shared) and @@ -561,12 +649,19 @@ function makePanel(side) { this.selectedFiles = 0; this.selectedBytes = 0; this.rootFree = null; + // Filter is per-directory ergonomically; clear it on root change. + // Sort preference persists — user's chosen ordering applies to the + // new root too. + this.filterText = ''; this.refresh(); }, goPath(p) { this.path = p || ''; this.selection = []; + // Stale filter from a different folder is rarely what the user wants + // when they navigate; reset it. + this.filterText = ''; this.refresh(); }, goCrumb(idx) { @@ -613,17 +708,35 @@ function makePanel(side) { this.recomputeSelectionStats(); }, toggleAll(checked) { + // Operate on the FILTERED view: "select all" with an active filter + // should select what's visible, not silently include hidden entries. + // Likewise unchecking removes only the visible ones — selections + // made while the filter was off survive a temporary filter session. + const visible = this.displayEntries(); if (checked) { - this.selection = this.entries.map(e => ({ - name: e.name, type: e.type, size: e.size, mtime: e.mtime, - })); + for (const e of visible) { + if (!this.isSelected(e)) { + this.selection.push({ + name: e.name, type: e.type, size: e.size, mtime: e.mtime, + }); + } + } } else { - this.selection = []; + const visKeys = new Set(visible.map(e => e.type + ':' + e.name)); + this.selection = this.selection.filter( + s => !visKeys.has(s.type + ':' + s.name)); } this.recomputeSelectionStats(); }, allSelected() { - return this.entries.length > 0 && this.selection.length === this.entries.length; + // True iff every currently-visible entry is in the selection. Empty + // view ⇒ checkbox is unchecked (nothing to select). + const visible = this.displayEntries(); + if (visible.length === 0) return false; + for (const e of visible) { + if (!this.isSelected(e)) return false; + } + return true; }, // Lazy by-type index over the panel's entries, used by dualBrowser.matchClass diff --git a/src/main/resources/web/index.html b/src/main/resources/web/index.html index b5c58a3..dcf8018 100644 --- a/src/main/resources/web/index.html +++ b/src/main/resources/web/index.html @@ -87,6 +87,22 @@ + +
+ + +
@@ -94,9 +110,18 @@ :checked="local.allSelected()" @change="local.toggleAll($event.target.checked)"> - Name - Size - Modified + Name + Size + Modified
-