Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 118 additions & 5 deletions src/main/resources/web/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
70 changes: 58 additions & 12 deletions src/main/resources/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,41 @@
</span>
</template>
</div>
<!--
Inline filter (v0.4.1+). Substring match against the current view
only — does not recurse. Esc clears, × button clears. Filter text
resets on directory navigation; sort prefs persist.
-->
<div class="panel-filter">
<input type="text"
class="text-input grow"
placeholder="Filter (substring match)…"
x-model.debounce.150ms="local.filterText"
@keyup.escape="local.clearFilter()">
<button class="btn ghost small"
x-show="local.filterText"
@click="local.clearFilter()"
title="Clear filter (Esc)">×</button>
</div>
<div class="filelist" :class="{ 'is-loading': local.loading }">
<div class="filelist-head">
<span class="col-check">
<input type="checkbox"
:checked="local.allSelected()"
@change="local.toggleAll($event.target.checked)">
</span>
<span class="col-name">Name</span>
<span class="col-size">Size</span>
<span class="col-mtime">Modified</span>
<span class="col-name col-sortable"
:class="{ 'col-sorted': local.sortBy === 'name' }"
@click="local.setSort('name')"
title="Sort by name">Name<span x-text="local.sortIndicator('name')"></span></span>
<span class="col-size col-sortable"
:class="{ 'col-sorted': local.sortBy === 'size' }"
@click="local.setSort('size')"
title="Sort by size">Size<span x-text="local.sortIndicator('size')"></span></span>
<span class="col-mtime col-sortable"
:class="{ 'col-sorted': local.sortBy === 'mtime' }"
@click="local.setSort('mtime')"
title="Sort by modification time">Modified<span x-text="local.sortIndicator('mtime')"></span></span>
</div>
<div class="filelist-body">
<div class="filerow"
Expand All @@ -107,7 +132,7 @@
<span class="col-size"></span>
<span class="col-mtime"></span>
</div>
<template x-for="e in local.entries" :key="e.name">
<template x-for="e in local.displayEntries()" :key="e.type + ':' + e.name">
<div class="filerow"
:class="{
'is-dir': e.type === 'dir',
Expand All @@ -129,8 +154,8 @@
</div>
</template>
<div class="filelist-empty"
x-show="!local.loading && local.entries.length === 0">
<span x-text="local.error || 'empty'"></span>
x-show="!local.loading && local.displayEntries().length === 0">
<span x-text="local.error || (local.filterText ? 'no matches' : 'empty')"></span>
</div>
</div>
</div>
Expand Down Expand Up @@ -195,16 +220,37 @@
</span>
</template>
</div>
<!-- Mirrors the local-panel filter row. -->
<div class="panel-filter">
<input type="text"
class="text-input grow"
placeholder="Filter (substring match)…"
x-model.debounce.150ms="peer.filterText"
@keyup.escape="peer.clearFilter()">
<button class="btn ghost small"
x-show="peer.filterText"
@click="peer.clearFilter()"
title="Clear filter (Esc)">×</button>
</div>
<div class="filelist" :class="{ 'is-loading': peer.loading }">
<div class="filelist-head">
<span class="col-check">
<input type="checkbox"
:checked="peer.allSelected()"
@change="peer.toggleAll($event.target.checked)">
</span>
<span class="col-name">Name</span>
<span class="col-size">Size</span>
<span class="col-mtime">Modified</span>
<span class="col-name col-sortable"
:class="{ 'col-sorted': peer.sortBy === 'name' }"
@click="peer.setSort('name')"
title="Sort by name">Name<span x-text="peer.sortIndicator('name')"></span></span>
<span class="col-size col-sortable"
:class="{ 'col-sorted': peer.sortBy === 'size' }"
@click="peer.setSort('size')"
title="Sort by size">Size<span x-text="peer.sortIndicator('size')"></span></span>
<span class="col-mtime col-sortable"
:class="{ 'col-sorted': peer.sortBy === 'mtime' }"
@click="peer.setSort('mtime')"
title="Sort by modification time">Modified<span x-text="peer.sortIndicator('mtime')"></span></span>
</div>
<div class="filelist-body">
<div class="filerow"
Expand All @@ -215,7 +261,7 @@
<span class="col-size"></span>
<span class="col-mtime"></span>
</div>
<template x-for="e in peer.entries" :key="e.name">
<template x-for="e in peer.displayEntries()" :key="e.type + ':' + e.name">
<div class="filerow"
:class="{
'is-dir': e.type === 'dir',
Expand All @@ -237,8 +283,8 @@
</div>
</template>
<div class="filelist-empty"
x-show="!peer.loading && peer.entries.length === 0">
<span x-text="peer.error || 'no peer / empty'"></span>
x-show="!peer.loading && peer.displayEntries().length === 0">
<span x-text="peer.error || (peer.filterText ? 'no matches' : 'no peer / empty')"></span>
</div>
</div>
</div>
Expand Down
27 changes: 27 additions & 0 deletions src/main/resources/web/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,33 @@ body {
letter-spacing: 0.05em;
z-index: 1;
}

/* Sortable column header (v0.4.1+). Click to sort by that column; click again
to reverse. The active column gets a stronger colour so the indicator arrow
reads as "this is sorting now". */
.filelist-head .col-sortable {
cursor: pointer;
user-select: none;
}
.filelist-head .col-sortable:hover { color: var(--fg); }
.filelist-head .col-sorted { color: var(--accent-2); }

/* Per-panel substring filter (v0.4.1+). Sits between the breadcrumb and the
filelist. Width matches the panel's content column. */
.panel-filter {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
background: var(--bg-2);
border-bottom: 1px solid var(--border);
}
.panel-filter .text-input {
width: auto;
min-width: 0;
flex: 1;
font-family: var(--font-ui);
}
.filerow {
cursor: default;
user-select: none;
Expand Down
Loading