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
-