aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-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)