summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-09-16 01:06:03 +0200
committerChristophe Besson <cbesson@gmail.com>2026-09-16 01:06:03 +0200
commitd5933448cf7c8186afbcf4d52fccd16d1b827a5c (patch)
tree8dcda6dedf7654f316b968bcc33552b8b05e0dcc
parent1f84c047914cf21df1a1de196d990d193539523d (diff)
downloadmeshbay-d5933448cf7c8186afbcf4d52fccd16d1b827a5c.tar.gz
fix(hub): the Files listing sorts its folders by the chosen column too
Folders were always ordered by name, whatever the column and direction, so reversing the name sort or sorting by size moved only the files. A folder's size is what it holds and its date its newest file; ties go to the name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
-rw-r--r--packages/meshbay-hub/src/meshbay_hub/static/files-app.js50
-rw-r--r--packages/meshbay-hub/tests/test_files_sorting.py84
2 files changed, 123 insertions, 11 deletions
diff --git a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
index 518c917..7629459 100644
--- a/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
+++ b/packages/meshbay-hub/src/meshbay_hub/static/files-app.js
@@ -112,6 +112,28 @@ async function walkEntries(roots) {
return out;
}
+// One ordering for the whole listing. Folders stay above files and are ordered
+// among themselves by the same column: a folder's size is everything under it,
+// its date the newest file in it. It has no type, so sorted by type folders
+// fall back to their names. Rows are `{ name, size, date, type }`; a tie goes
+// to the name, ascending, so equal sizes do not shuffle between renders.
+//
+// Folders used to be `[...dirs].sort()` whatever the column — so reversing the
+// name sort or sorting by size moved the files and left every folder where it
+// was, which in a folder of folders is nothing moving at all.
+function sortRows(rows, key, asc) {
+ const byName = (a, b) => (a.name || '').localeCompare(b.name || '');
+ return [...rows].sort((a, b) => {
+ let cmp;
+ if (key === 'size') cmp = (a.size || 0) - (b.size || 0);
+ else if (key === 'date') cmp = (a.date || 0) - (b.date || 0);
+ else if (key === 'type') cmp = (a.type || '').localeCompare(b.type || '');
+ else cmp = byName(a, b);
+ if (!asc) cmp = -cmp;
+ return cmp || byName(a, b);
+ });
+}
+
function FilesPanel({
groupId, transportRef, gekRef, status,
entries, nodeDirs, nodeRoots, setEntries, setNodeDirs, setNodeRoots, applyIndex,
@@ -364,14 +386,9 @@ function FilesPanel({
return false;
});
- const sorted = [...filteredEntries].sort((a, b) => {
- let cmp = 0;
- if (sortKey === 'name') cmp = (a.name || '').localeCompare(b.name || '');
- else if (sortKey === 'size') cmp = a.size - b.size;
- else if (sortKey === 'type') cmp = (a.type || '').localeCompare(b.type || '');
- else if (sortKey === 'date') cmp = a.added_at - b.added_at;
- return sortAsc ? cmp : -cmp;
- });
+ const sorted = sortRows(
+ filteredEntries.map((e) => ({ name: e.name, size: e.size, date: e.added_at, type: e.type, entry: e })),
+ sortKey, sortAsc).map((r) => r.entry);
// The node's own listing, so an empty folder is visible, plus anything implied
// by a file path in case the two ever disagree. Skipped while searching — the
@@ -383,7 +400,19 @@ function FilesPanel({
if (!rest.includes('/')) dirs.add(rest);
}
}
- const subdirs = [...dirs].sort();
+ // What each folder holds, once — its size and date are sort keys now, not
+ // only the figure in its row.
+ const dirInfo = new Map([...dirs].map((d) => {
+ const inside = entriesUnder(entries, currentPath ? currentPath + '/' + d : d);
+ return [d, {
+ inside,
+ bytes: inside.reduce((n, f) => n + (f.entry.size || 0), 0),
+ newest: inside.reduce((n, f) => Math.max(n, f.entry.added_at || 0), 0),
+ }];
+ }));
+ const subdirs = sortRows(
+ [...dirs].map((d) => ({ name: d, size: dirInfo.get(d).bytes, date: dirInfo.get(d).newest })),
+ sortKey, sortAsc).map((r) => r.name);
// At the top of a group the folders on screen ARE the roots, so their state
// belongs there. Deeper in, everything shown lives inside one readable root
@@ -744,8 +773,7 @@ function FilesPanel({
`}
${subdirs.map(d => {
const full = currentPath ? currentPath + '/' + d : d;
- const inside = entriesUnder(entries, full);
- const bytes = inside.reduce((n, f) => n + (f.entry.size || 0), 0);
+ const { inside, bytes } = dirInfo.get(d);
const rs = rootState.get(d);
const isEjected = rs && rs.ejected;
const isUnavail = unavailableHere.includes(d);
diff --git a/packages/meshbay-hub/tests/test_files_sorting.py b/packages/meshbay-hub/tests/test_files_sorting.py
new file mode 100644
index 0000000..730d1e8
--- /dev/null
+++ b/packages/meshbay-hub/tests/test_files_sorting.py
@@ -0,0 +1,84 @@
+"""
+Sorting the Files listing (`files-app.js`).
+
+Reported: reversing the name sort, or sorting by size, did nothing — above all
+with folders. Folders were `[...dirs].sort()` whatever the column, so only the
+files below them moved, and a folder full of folders did not move at all.
+
+Both halves now go through one `sortRows`, run here out of the source.
+"""
+
+import json
+import re
+import shutil
+import subprocess
+from pathlib import Path
+
+import pytest
+
+STATIC = Path(__file__).resolve().parents[1] / "src" / "meshbay_hub" / "static"
+FILES_APP = STATIC / "files-app.js"
+
+pytestmark = pytest.mark.skipif(
+ shutil.which("node") is None or not FILES_APP.exists(),
+ reason="node or the SPA sources are not available")
+
+
+@pytest.fixture(scope="module")
+def source():
+ m = re.search(r"^function sortRows\(.*?^\}", FILES_APP.read_text(encoding="utf-8"),
+ re.M | re.S)
+ assert m, "sortRows is no longer where this test reads it from"
+ return m.group(0)
+
+
+def _names(tmp_path, source, rows, key, asc):
+ script = tmp_path / "case.js"
+ script.write_text(
+ f"{source}\nconsole.log(JSON.stringify("
+ f"sortRows({json.dumps(rows)}, {json.dumps(key)}, {json.dumps(asc)}).map((r) => r.name)));",
+ encoding="utf-8")
+ out = subprocess.run(["node", str(script)], capture_output=True, text=True, check=True)
+ return json.loads(out.stdout)
+
+
+FOLDERS = [
+ {"name": "Beta", "size": 10, "date": 300},
+ {"name": "alpha", "size": 500, "date": 100},
+ {"name": "Gamma", "size": 0, "date": 0},
+ {"name": "delta", "size": 500, "date": 200},
+]
+
+
+def test_name_both_ways(tmp_path, source):
+ up = _names(tmp_path, source, FOLDERS, "name", True)
+ assert up == ["alpha", "Beta", "delta", "Gamma"]
+ assert _names(tmp_path, source, FOLDERS, "name", False) == up[::-1]
+
+
+def test_size_both_ways_with_ties_broken_by_name(tmp_path, source):
+ assert _names(tmp_path, source, FOLDERS, "size", True) == ["Gamma", "Beta", "alpha", "delta"]
+ assert _names(tmp_path, source, FOLDERS, "size", False) == ["alpha", "delta", "Beta", "Gamma"]
+
+
+def test_date(tmp_path, source):
+ assert _names(tmp_path, source, FOLDERS, "date", False) == ["Beta", "delta", "alpha", "Gamma"]
+
+
+def test_folders_have_no_type_and_fall_back_to_their_names(tmp_path, source):
+ assert _names(tmp_path, source, FOLDERS, "type", False) == ["alpha", "Beta", "delta", "Gamma"]
+
+
+def test_a_missing_size_sorts_as_zero_rather_than_breaking_the_order(tmp_path, source):
+ rows = [{"name": "b", "size": 5}, {"name": "a"}, {"name": "c", "size": 1}]
+ assert _names(tmp_path, source, rows, "size", True) == ["a", "c", "b"]
+
+
+def test_folders_and_files_share_one_ordering():
+ """Both lists in the listing are ordered by the same function and column."""
+ text = FILES_APP.read_text(encoding="utf-8")
+ # Code lines only: the comment above sortRows quotes the old line on purpose.
+ code = "\n".join(line for line in text.splitlines() if not line.lstrip().startswith("//"))
+ assert "[...dirs].sort()" not in code
+ assert re.search(r"const sorted = sortRows\(", text)
+ assert re.search(r"const subdirs = sortRows\(", text)