detached 28d17a7

fix(web): keep the tree and finder state consistent

commit
28d17a7eec974a401b3940ea983ef3f48ef0650c
author
brice · · 2026-10-11 19:31 UTC
parent
515aa1e
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +369 -13
M internal/adapters/web/jstest/files.test.mjs +117 -1
@@ -1,5 +1,5 @@
11 // Tests of the pure core of the file finder (static/js/files.js). Run with
2-// node --test internal/adapters/web/jstest/
2+// node --test 'internal/adapters/web/jstest/*.test.mjs'
33 import assert from 'node:assert/strict';
44 import { test } from 'node:test';
55
@@ -233,3 +233,119 @@ test('performance on 100,000 paths (AC6)', () => {
233233 assert.ok(build < 1500, 'index ' + build);
234234 assert.ok(Math.max(worst, cold, cold2, withPos) < 300, 'query ' + Math.max(worst, cold, cold2, withPos));
235235 });
236+
237+// ---- a brute-force reference of the scoring --------------------------------
238+
239+// The reference tries every placement of the term in the path and scores it by
240+// the documented rules; the DP of files.js must find the same best score.
241+function refBonus(orig, j) {
242+ if (j === 0) return 10;
243+ const p = orig[j - 1];
244+ const c = orig[j];
245+ if (p === '/') return 10;
246+ if ('_-. '.includes(p)) return 8;
247+ const upper = c >= 'A' && c <= 'Z';
248+ const lower = c >= 'a' && c <= 'z';
249+ if (p >= 'a' && p <= 'z' && upper) return 7;
250+ if (p >= '0' && p <= '9' && (upper || lower)) return 7;
251+ return 0;
252+}
253+
254+function refPlacement(orig, pos) {
255+ let score = 0;
256+ pos.forEach((j, i) => {
257+ const b = refBonus(orig, j);
258+ score += 16 + (i === 0 ? 2 * b : b);
259+ if (i > 0) {
260+ const gap = j - pos[i - 1] - 1;
261+ score += gap === 0 ? Math.max(b, 4) : -(gap + 2);
262+ }
263+ });
264+ return score;
265+}
266+
267+// refBest is the best score of the placements of t at or after from (null: none).
268+function refBest(orig, lower, t, from) {
269+ let best = null;
270+ const go = (i, start, pos) => {
271+ if (i === t.length) {
272+ const sc = refPlacement(orig, pos);
273+ if (best === null || sc > best) best = sc;
274+ return;
275+ }
276+ for (let j = start; j < lower.length; j++) if (lower[j] === t[i]) go(i + 1, j + 1, [...pos, j]);
277+ };
278+ go(0, from, []);
279+ return best;
280+}
281+
282+function refTerm(orig, t, isLast) {
283+ const lower = orig.replace(/[A-Z]/g, (c) => c.toLowerCase());
284+ const all = refBest(orig, lower, t, 0);
285+ if (all === null) return null;
286+ if (!isLast) return all;
287+ const base = orig.lastIndexOf('/') + 1;
288+ let best = all;
289+ if (base === 0) best += 24;
290+ else {
291+ const r = refBest(orig, lower, t, base);
292+ if (r !== null && r + 24 > best) best = r + 24;
293+ }
294+ if (lower.startsWith(t, base)) best += 16;
295+ return best;
296+}
297+
298+test('search and positions agree with a brute-force reference', () => {
299+ let seed = 12345;
300+ const rnd = (n) => {
301+ seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff;
302+ return Math.floor((seed / 0x80000000) * n);
303+ };
304+ const alphabet = 'abcabcABC//..__-1';
305+ const paths = new Set();
306+ while (paths.size < 3000) {
307+ let p = '';
308+ for (let n = 1 + rnd(14); n > 0; n--) p += alphabet[rnd(alphabet.length)];
309+ paths.add(p);
310+ }
311+ const list = Array.from(paths);
312+ const x = F.index(list);
313+ const queries = [];
314+ for (let n = 0; n < 40; n++) {
315+ let q = '';
316+ for (let k = 1 + rnd(3); k > 0; k--) q += 'abc/._-1'[rnd(8)];
317+ queries.push(q);
318+ }
319+ for (let n = 0; n < 20; n++) queries.push('ab'[rnd(2)] + 'c/.'[rnd(3)] + ' ' + 'abc'[rnd(3)] + 'a_'[rnd(2)]);
320+ let compared = 0;
321+ for (const q of queries) {
322+ const terms = q.split(/\s+/).filter(Boolean);
323+ const res = F.search(x, q, list.length);
324+ const got = new Map(res.hits.map((h) => [h.i, h.score]));
325+ for (let i = 0; i < list.length; i++) {
326+ let total = 0;
327+ for (let n = 0; n < terms.length && total !== null; n++) {
328+ const sc = refTerm(list[i], terms[n], n === terms.length - 1);
329+ total = sc === null ? null : total + sc;
330+ }
331+ if (total === null) {
332+ assert.ok(!got.has(i), `${q}: ${list[i]} cannot match`);
333+ continue;
334+ }
335+ assert.equal(got.get(i), total, `${q}: score of ${list[i]}`);
336+ compared++;
337+ if (terms.length === 1) {
338+ // The positions are a placement of the term that scores the best.
339+ const pos = F.positions(x, i, q);
340+ assert.equal(pos.map((j) => list[i][j]).join('').toLowerCase(), terms[0], `${q}: characters of ${list[i]}`);
341+ const lower = list[i].toLowerCase();
342+ const base = list[i].lastIndexOf('/') + 1;
343+ const all = refBest(list[i], lower, terms[0], 0);
344+ const inBase = base === 0 ? null : refBest(list[i], lower, terms[0], base);
345+ const want = inBase !== null && inBase + 24 > all ? inBase : all;
346+ assert.equal(refPlacement(list[i], pos), want, `${q}: placement of ${list[i]}`);
347+ }
348+ }
349+ }
350+ assert.ok(compared > 2000, 'compared ' + compared);
351+});
@@ -1,5 +1,5 @@
1 // Tests of the pure core of the file finder (static/js/files.js). Run with1 // Tests of the pure core of the file finder (static/js/files.js). Run with
2-// node --test internal/adapters/web/jstest/2+// node --test 'internal/adapters/web/jstest/*.test.mjs'
3 import assert from 'node:assert/strict';3 import assert from 'node:assert/strict';
4 import { test } from 'node:test';4 import { test } from 'node:test';
5 5
@@ -233,3 +233,119 @@ test('performance on 100,000 paths (AC6)', () => {
233 assert.ok(build < 1500, 'index ' + build);233 assert.ok(build < 1500, 'index ' + build);
234 assert.ok(Math.max(worst, cold, cold2, withPos) < 300, 'query ' + Math.max(worst, cold, cold2, withPos));234 assert.ok(Math.max(worst, cold, cold2, withPos) < 300, 'query ' + Math.max(worst, cold, cold2, withPos));
235 });235 });
236+
237+// ---- a brute-force reference of the scoring --------------------------------
238+
239+// The reference tries every placement of the term in the path and scores it by
240+// the documented rules; the DP of files.js must find the same best score.
241+function refBonus(orig, j) {
242+ if (j === 0) return 10;
243+ const p = orig[j - 1];
244+ const c = orig[j];
245+ if (p === '/') return 10;
246+ if ('_-. '.includes(p)) return 8;
247+ const upper = c >= 'A' && c <= 'Z';
248+ const lower = c >= 'a' && c <= 'z';
249+ if (p >= 'a' && p <= 'z' && upper) return 7;
250+ if (p >= '0' && p <= '9' && (upper || lower)) return 7;
251+ return 0;
252+}
253+
254+function refPlacement(orig, pos) {
255+ let score = 0;
256+ pos.forEach((j, i) => {
257+ const b = refBonus(orig, j);
258+ score += 16 + (i === 0 ? 2 * b : b);
259+ if (i > 0) {
260+ const gap = j - pos[i - 1] - 1;
261+ score += gap === 0 ? Math.max(b, 4) : -(gap + 2);
262+ }
263+ });
264+ return score;
265+}
266+
267+// refBest is the best score of the placements of t at or after from (null: none).
268+function refBest(orig, lower, t, from) {
269+ let best = null;
270+ const go = (i, start, pos) => {
271+ if (i === t.length) {
272+ const sc = refPlacement(orig, pos);
273+ if (best === null || sc > best) best = sc;
274+ return;
275+ }
276+ for (let j = start; j < lower.length; j++) if (lower[j] === t[i]) go(i + 1, j + 1, [...pos, j]);
277+ };
278+ go(0, from, []);
279+ return best;
280+}
281+
282+function refTerm(orig, t, isLast) {
283+ const lower = orig.replace(/[A-Z]/g, (c) => c.toLowerCase());
284+ const all = refBest(orig, lower, t, 0);
285+ if (all === null) return null;
286+ if (!isLast) return all;
287+ const base = orig.lastIndexOf('/') + 1;
288+ let best = all;
289+ if (base === 0) best += 24;
290+ else {
291+ const r = refBest(orig, lower, t, base);
292+ if (r !== null && r + 24 > best) best = r + 24;
293+ }
294+ if (lower.startsWith(t, base)) best += 16;
295+ return best;
296+}
297+
298+test('search and positions agree with a brute-force reference', () => {
299+ let seed = 12345;
300+ const rnd = (n) => {
301+ seed = (Math.imul(seed, 1103515245) + 12345) & 0x7fffffff;
302+ return Math.floor((seed / 0x80000000) * n);
303+ };
304+ const alphabet = 'abcabcABC//..__-1';
305+ const paths = new Set();
306+ while (paths.size < 3000) {
307+ let p = '';
308+ for (let n = 1 + rnd(14); n > 0; n--) p += alphabet[rnd(alphabet.length)];
309+ paths.add(p);
310+ }
311+ const list = Array.from(paths);
312+ const x = F.index(list);
313+ const queries = [];
314+ for (let n = 0; n < 40; n++) {
315+ let q = '';
316+ for (let k = 1 + rnd(3); k > 0; k--) q += 'abc/._-1'[rnd(8)];
317+ queries.push(q);
318+ }
319+ for (let n = 0; n < 20; n++) queries.push('ab'[rnd(2)] + 'c/.'[rnd(3)] + ' ' + 'abc'[rnd(3)] + 'a_'[rnd(2)]);
320+ let compared = 0;
321+ for (const q of queries) {
322+ const terms = q.split(/\s+/).filter(Boolean);
323+ const res = F.search(x, q, list.length);
324+ const got = new Map(res.hits.map((h) => [h.i, h.score]));
325+ for (let i = 0; i < list.length; i++) {
326+ let total = 0;
327+ for (let n = 0; n < terms.length && total !== null; n++) {
328+ const sc = refTerm(list[i], terms[n], n === terms.length - 1);
329+ total = sc === null ? null : total + sc;
330+ }
331+ if (total === null) {
332+ assert.ok(!got.has(i), `${q}: ${list[i]} cannot match`);
333+ continue;
334+ }
335+ assert.equal(got.get(i), total, `${q}: score of ${list[i]}`);
336+ compared++;
337+ if (terms.length === 1) {
338+ // The positions are a placement of the term that scores the best.
339+ const pos = F.positions(x, i, q);
340+ assert.equal(pos.map((j) => list[i][j]).join('').toLowerCase(), terms[0], `${q}: characters of ${list[i]}`);
341+ const lower = list[i].toLowerCase();
342+ const base = list[i].lastIndexOf('/') + 1;
343+ const all = refBest(list[i], lower, terms[0], 0);
344+ const inBase = base === 0 ? null : refBest(list[i], lower, terms[0], base);
345+ const want = inBase !== null && inBase + 24 > all ? inBase : all;
346+ assert.equal(refPlacement(list[i], pos), want, `${q}: placement of ${list[i]}`);
347+ }
348+ }
349+ }
350+ assert.ok(compared > 2000, 'compared ' + compared);
351+});
A internal/adapters/web/jstest/tree.test.mjs +185 -0
@@ -0,0 +1,185 @@
1+// Tests of the tree rows of static/js/files.js (window.ermiteTree) on a tiny
2+// hand-rolled DOM. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { test } from 'node:test';
6+
7+// ---- a DOM just big enough for ermiteTree ----------------------------------
8+
9+class El {
10+ constructor(tag) {
11+ this.tagName = tag.toUpperCase();
12+ this.attrs = new Map();
13+ this.dataset = {};
14+ this.children = [];
15+ this.parent = null;
16+ this.hidden = false;
17+ this.style = { setProperty() {} };
18+ }
19+ setAttribute(k, v) { this.attrs.set(k, String(v)); }
20+ getAttribute(k) { return this.attrs.has(k) ? this.attrs.get(k) : null; }
21+ hasAttribute(k) { return this.attrs.has(k); }
22+ removeAttribute(k) { this.attrs.delete(k); }
23+ append(c) { c.parent = this; this.children.push(c); }
24+ get nextElementSibling() {
25+ if (!this.parent) return null;
26+ return this.parent.children[this.parent.children.indexOf(this) + 1] || null;
27+ }
28+ after(...rows) {
29+ const sib = this.parent.children;
30+ for (const r of rows) r.parent = this.parent;
31+ sib.splice(sib.indexOf(this) + 1, 0, ...rows);
32+ }
33+ closest(sel) {
34+ for (let e = this; e; e = e.parent) if (e.matches(sel)) return e;
35+ return null;
36+ }
37+ matches(sel) {
38+ if (sel === 'tr') return this.tagName === 'TR';
39+ if (sel === 'a[data-expand]') return this.tagName === 'A' && 'expand' in this.dataset;
40+ return false;
41+ }
42+ querySelector(sel) {
43+ for (const c of this.children) {
44+ if (c.matches(sel) || (sel === '.c-kind' && c.cls === 'c-kind')) return c;
45+ const d = c.querySelector(sel);
46+ if (d) return d;
47+ }
48+ return null;
49+ }
50+}
51+
52+// row makes a table row; a name ending in "/" is a directory with a link.
53+function row(name, depth) {
54+ const tr = new El('tr');
55+ tr.dataset.depth = String(depth);
56+ tr.name = name;
57+ const kind = new El('span');
58+ kind.cls = 'c-kind';
59+ kind.textContent = '▸';
60+ tr.append(kind);
61+ if (name.endsWith('/')) {
62+ const a = new El('a');
63+ a.dataset.expand = '/rows/' + name;
64+ a.href = '/tree/' + name;
65+ a.setAttribute('aria-expanded', 'false');
66+ tr.append(a);
67+ }
68+ return tr;
69+}
70+
71+// A fragment is "x,y/,!z": ! makes a div, not a row.
72+globalThis.document = {
73+ createElement(tag) {
74+ assert.equal(tag, 'template');
75+ const t = { content: { children: [] } };
76+ Object.defineProperty(t, 'innerHTML', {
77+ set(text) {
78+ t.content.children = text.split(',').filter(Boolean).map((n) => (n[0] === '!' ? new El('div') : row(n, 0)));
79+ },
80+ });
81+ return t;
82+ },
83+};
84+globalThis.location = { href: '' };
85+globalThis.window = globalThis;
86+await import('../static/js/files.js');
87+const T = window.ermiteTree;
88+
89+// fetches: URL -> resolve function, to answer a request when the test says so.
90+const pending = new Map();
91+globalThis.fetch = (url) => new Promise((resolve) => {
92+ pending.set(url, (text, ok = true) => resolve({ ok, status: ok ? 200 : 500, text: async () => text }));
93+});
94+const answer = async (url, text, ok) => {
95+ pending.get(url)(text, ok);
96+ await new Promise((r) => setTimeout(r, 0));
97+};
98+
99+const click = (tr) => {
100+ const a = tr.querySelector('a[data-expand]');
101+ T.click({ target: a, button: 0, preventDefault() {} });
102+};
103+const kindOf = (tr) => tr.querySelector('.c-kind').textContent;
104+
105+function table(...names) {
106+ const body = new El('tbody');
107+ for (const n of names) body.append(row(n, 0));
108+ return body;
109+}
110+const byName = (body, n) => body.children.find((r) => r.name === n);
111+const shown = (body) => body.children.filter((r) => !r.hidden).map((r) => r.name);
112+
113+test('a directory opens in place and closes again without a request', async () => {
114+ const body = table('P/', 'f');
115+ click(body.children[0]);
116+ assert.equal(body.children[0].getAttribute('aria-busy'), 'true');
117+ await answer('/rows/P/', 'a,b');
118+ assert.deepEqual(shown(body), ['P/', 'a', 'b', 'f']);
119+ assert.equal(byName(body, 'a').dataset.depth, '1');
120+ assert.equal(kindOf(body.children[0]), '▾');
121+ click(body.children[0]);
122+ assert.deepEqual(shown(body), ['P/', 'f']);
123+ pending.clear();
124+ click(body.children[0]);
125+ assert.deepEqual(shown(body), ['P/', 'a', 'b', 'f']);
126+ assert.equal(pending.size, 0, 'no new request');
127+});
128+
129+test('TS-001/TS-002: a directory that loads under a closed ancestor comes in hidden', async () => {
130+ pending.clear();
131+ const body = table('P/', 'f');
132+ const P = body.children[0];
133+ click(P);
134+ await answer('/rows/P/', 'C/,x');
135+ const C = byName(body, 'C/');
136+ click(C); // C starts loading
137+ assert.equal(C.getAttribute('aria-busy'), 'true');
138+ C.setAttribute('data-cursor', '');
139+ click(P); // P closes meanwhile
140+ assert.deepEqual(shown(body), ['P/', 'f']);
141+ assert.ok(P.hasAttribute('data-cursor'), 'the cursor of a hidden row moves to the closed row');
142+ assert.ok(!C.hasAttribute('data-cursor'));
143+
144+ await answer('/rows/C/', 'c1,c2');
145+ assert.deepEqual(shown(body), ['P/', 'f'], 'no visible row under a closed directory');
146+ assert.deepEqual(body.children.map((r) => r.name), ['P/', 'C/', 'c1', 'c2', 'x', 'f']);
147+ assert.equal(byName(body, 'c1').hidden, true);
148+ assert.equal(C.getAttribute('aria-busy'), null);
149+ assert.equal(C.querySelector('a[data-expand]').getAttribute('aria-expanded'), 'true', 'C is open and loaded');
150+ assert.equal(kindOf(C), '▾');
151+
152+ pending.clear();
153+ click(P); // P opens: C's rows show with it
154+ assert.deepEqual(shown(body), ['P/', 'C/', 'c1', 'c2', 'x', 'f']);
155+ assert.equal(pending.size, 0, 'C was not fetched again');
156+});
157+
158+test('a closed directory under a closed ancestor stays closed when the ancestor opens', async () => {
159+ pending.clear();
160+ const body = table('P/');
161+ click(body.children[0]);
162+ await answer('/rows/P/', 'C/');
163+ const C = byName(body, 'C/');
164+ click(C);
165+ await answer('/rows/C/', 'c1');
166+ click(C); // C closed
167+ click(body.children[0]);
168+ click(body.children[0]);
169+ assert.deepEqual(shown(body), ['P/', 'C/']);
170+});
171+
172+test('TS-006: only rows are kept; no row falls back to navigating', async () => {
173+ pending.clear();
174+ const body = table('P/');
175+ click(body.children[0]);
176+ await answer('/rows/P/', '!junk,a');
177+ assert.deepEqual(body.children.map((r) => r.name), ['P/', 'a']);
178+
179+ const other = table('Q/');
180+ globalThis.location.href = '';
181+ click(other.children[0]);
182+ await answer('/rows/Q/', '!junk');
183+ assert.equal(globalThis.location.href, '/tree/Q/');
184+ assert.deepEqual(other.children.map((r) => r.name), ['Q/']);
185+});
@@ -0,0 +1,185 @@
1+// Tests of the tree rows of static/js/files.js (window.ermiteTree) on a tiny
2+// hand-rolled DOM. Run with
3+// node --test 'internal/adapters/web/jstest/*.test.mjs'
4+import assert from 'node:assert/strict';
5+import { test } from 'node:test';
6+
7+// ---- a DOM just big enough for ermiteTree ----------------------------------
8+
9+class El {
10+ constructor(tag) {
11+ this.tagName = tag.toUpperCase();
12+ this.attrs = new Map();
13+ this.dataset = {};
14+ this.children = [];
15+ this.parent = null;
16+ this.hidden = false;
17+ this.style = { setProperty() {} };
18+ }
19+ setAttribute(k, v) { this.attrs.set(k, String(v)); }
20+ getAttribute(k) { return this.attrs.has(k) ? this.attrs.get(k) : null; }
21+ hasAttribute(k) { return this.attrs.has(k); }
22+ removeAttribute(k) { this.attrs.delete(k); }
23+ append(c) { c.parent = this; this.children.push(c); }
24+ get nextElementSibling() {
25+ if (!this.parent) return null;
26+ return this.parent.children[this.parent.children.indexOf(this) + 1] || null;
27+ }
28+ after(...rows) {
29+ const sib = this.parent.children;
30+ for (const r of rows) r.parent = this.parent;
31+ sib.splice(sib.indexOf(this) + 1, 0, ...rows);
32+ }
33+ closest(sel) {
34+ for (let e = this; e; e = e.parent) if (e.matches(sel)) return e;
35+ return null;
36+ }
37+ matches(sel) {
38+ if (sel === 'tr') return this.tagName === 'TR';
39+ if (sel === 'a[data-expand]') return this.tagName === 'A' && 'expand' in this.dataset;
40+ return false;
41+ }
42+ querySelector(sel) {
43+ for (const c of this.children) {
44+ if (c.matches(sel) || (sel === '.c-kind' && c.cls === 'c-kind')) return c;
45+ const d = c.querySelector(sel);
46+ if (d) return d;
47+ }
48+ return null;
49+ }
50+}
51+
52+// row makes a table row; a name ending in "/" is a directory with a link.
53+function row(name, depth) {
54+ const tr = new El('tr');
55+ tr.dataset.depth = String(depth);
56+ tr.name = name;
57+ const kind = new El('span');
58+ kind.cls = 'c-kind';
59+ kind.textContent = '▸';
60+ tr.append(kind);
61+ if (name.endsWith('/')) {
62+ const a = new El('a');
63+ a.dataset.expand = '/rows/' + name;
64+ a.href = '/tree/' + name;
65+ a.setAttribute('aria-expanded', 'false');
66+ tr.append(a);
67+ }
68+ return tr;
69+}
70+
71+// A fragment is "x,y/,!z": ! makes a div, not a row.
72+globalThis.document = {
73+ createElement(tag) {
74+ assert.equal(tag, 'template');
75+ const t = { content: { children: [] } };
76+ Object.defineProperty(t, 'innerHTML', {
77+ set(text) {
78+ t.content.children = text.split(',').filter(Boolean).map((n) => (n[0] === '!' ? new El('div') : row(n, 0)));
79+ },
80+ });
81+ return t;
82+ },
83+};
84+globalThis.location = { href: '' };
85+globalThis.window = globalThis;
86+await import('../static/js/files.js');
87+const T = window.ermiteTree;
88+
89+// fetches: URL -> resolve function, to answer a request when the test says so.
90+const pending = new Map();
91+globalThis.fetch = (url) => new Promise((resolve) => {
92+ pending.set(url, (text, ok = true) => resolve({ ok, status: ok ? 200 : 500, text: async () => text }));
93+});
94+const answer = async (url, text, ok) => {
95+ pending.get(url)(text, ok);
96+ await new Promise((r) => setTimeout(r, 0));
97+};
98+
99+const click = (tr) => {
100+ const a = tr.querySelector('a[data-expand]');
101+ T.click({ target: a, button: 0, preventDefault() {} });
102+};
103+const kindOf = (tr) => tr.querySelector('.c-kind').textContent;
104+
105+function table(...names) {
106+ const body = new El('tbody');
107+ for (const n of names) body.append(row(n, 0));
108+ return body;
109+}
110+const byName = (body, n) => body.children.find((r) => r.name === n);
111+const shown = (body) => body.children.filter((r) => !r.hidden).map((r) => r.name);
112+
113+test('a directory opens in place and closes again without a request', async () => {
114+ const body = table('P/', 'f');
115+ click(body.children[0]);
116+ assert.equal(body.children[0].getAttribute('aria-busy'), 'true');
117+ await answer('/rows/P/', 'a,b');
118+ assert.deepEqual(shown(body), ['P/', 'a', 'b', 'f']);
119+ assert.equal(byName(body, 'a').dataset.depth, '1');
120+ assert.equal(kindOf(body.children[0]), '▾');
121+ click(body.children[0]);
122+ assert.deepEqual(shown(body), ['P/', 'f']);
123+ pending.clear();
124+ click(body.children[0]);
125+ assert.deepEqual(shown(body), ['P/', 'a', 'b', 'f']);
126+ assert.equal(pending.size, 0, 'no new request');
127+});
128+
129+test('TS-001/TS-002: a directory that loads under a closed ancestor comes in hidden', async () => {
130+ pending.clear();
131+ const body = table('P/', 'f');
132+ const P = body.children[0];
133+ click(P);
134+ await answer('/rows/P/', 'C/,x');
135+ const C = byName(body, 'C/');
136+ click(C); // C starts loading
137+ assert.equal(C.getAttribute('aria-busy'), 'true');
138+ C.setAttribute('data-cursor', '');
139+ click(P); // P closes meanwhile
140+ assert.deepEqual(shown(body), ['P/', 'f']);
141+ assert.ok(P.hasAttribute('data-cursor'), 'the cursor of a hidden row moves to the closed row');
142+ assert.ok(!C.hasAttribute('data-cursor'));
143+
144+ await answer('/rows/C/', 'c1,c2');
145+ assert.deepEqual(shown(body), ['P/', 'f'], 'no visible row under a closed directory');
146+ assert.deepEqual(body.children.map((r) => r.name), ['P/', 'C/', 'c1', 'c2', 'x', 'f']);
147+ assert.equal(byName(body, 'c1').hidden, true);
148+ assert.equal(C.getAttribute('aria-busy'), null);
149+ assert.equal(C.querySelector('a[data-expand]').getAttribute('aria-expanded'), 'true', 'C is open and loaded');
150+ assert.equal(kindOf(C), '▾');
151+
152+ pending.clear();
153+ click(P); // P opens: C's rows show with it
154+ assert.deepEqual(shown(body), ['P/', 'C/', 'c1', 'c2', 'x', 'f']);
155+ assert.equal(pending.size, 0, 'C was not fetched again');
156+});
157+
158+test('a closed directory under a closed ancestor stays closed when the ancestor opens', async () => {
159+ pending.clear();
160+ const body = table('P/');
161+ click(body.children[0]);
162+ await answer('/rows/P/', 'C/');
163+ const C = byName(body, 'C/');
164+ click(C);
165+ await answer('/rows/C/', 'c1');
166+ click(C); // C closed
167+ click(body.children[0]);
168+ click(body.children[0]);
169+ assert.deepEqual(shown(body), ['P/', 'C/']);
170+});
171+
172+test('TS-006: only rows are kept; no row falls back to navigating', async () => {
173+ pending.clear();
174+ const body = table('P/');
175+ click(body.children[0]);
176+ await answer('/rows/P/', '!junk,a');
177+ assert.deepEqual(body.children.map((r) => r.name), ['P/', 'a']);
178+
179+ const other = table('Q/');
180+ globalThis.location.href = '';
181+ click(other.children[0]);
182+ await answer('/rows/Q/', '!junk');
183+ assert.equal(globalThis.location.href, '/tree/Q/');
184+ assert.deepEqual(other.children.map((r) => r.name), ['Q/']);
185+});
M internal/adapters/web/static/js/files.js +67 -12
@@ -17,11 +17,18 @@ window.ermiteTree = (() => {
1717 if (kind) kind.textContent = open ? '▾' : '▸';
1818 }
1919
20- // collapse hides every row under tr.
20+ // collapse hides every row under tr. The cursor of a row it hides moves to
21+ // tr, so it never rests on a row that cannot be seen.
2122 function collapse(tr, a) {
2223 setOpen(tr, a, false);
2324 const d = depthOf(tr);
24- for (let r = tr.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) r.hidden = true;
25+ for (let r = tr.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {
26+ r.hidden = true;
27+ if (r.hasAttribute('data-cursor')) {
28+ r.removeAttribute('data-cursor');
29+ tr.setAttribute('data-cursor', '');
30+ }
31+ }
2532 }
2633
2734 // expand shows the rows under tr that no collapsed directory between hides.
@@ -46,15 +53,21 @@ window.ermiteTree = (() => {
4653 const t = document.createElement('template');
4754 t.innerHTML = await res.text();
4855 const d = depthOf(tr) + 1;
49- const rows = Array.from(t.content.children);
56+ const rows = Array.from(t.content.children).filter((r) => r.tagName === 'TR');
57+ if (!rows.length) throw new Error('no rows');
58+ // An ancestor closed while the request was out: tr is hidden, and its
59+ // rows come in hidden, for the expand of that ancestor to show.
60+ const covered = tr.hidden;
5061 for (const r of rows) {
5162 r.dataset.depth = String(d);
5263 r.style.setProperty('--depth', String(d));
64+ if (covered) r.hidden = true;
5365 }
5466 tr.after(...rows);
5567 tr.dataset.loaded = '1';
5668 tr.removeAttribute('aria-busy');
57- expand(tr, a);
69+ if (covered) setOpen(tr, a, true);
70+ else expand(tr, a);
5871 } catch (e) {
5972 location.href = a.href;
6073 }
@@ -375,6 +388,8 @@ window.ermiteFinder = (() => {
375388 const loads = new Map(); // URL -> promise of {idx, truncated}
376389 let rendered = null; // the result the finder list shows
377390 let frame = 0;
391+ let treeCursor = null; // the row of the tree that had the cursor when the results came
392+ let pendingEnter = null; // the value of the field at an Enter pressed before its results
378393
379394 // load fetches the path list of the page once and indexes it.
380395 function load() {
@@ -402,11 +417,23 @@ window.ermiteFinder = (() => {
402417 }
403418 }
404419
420+ const isHigh = (c) => c >= 0xd800 && c <= 0xdbff;
421+ const isLow = (c) => c >= 0xdc00 && c <= 0xdfff;
422+
423+ // runs appends text[from, to) to parent, the marked characters in <mark>. A
424+ // surrogate pair is marked whole or not at all, so it is never split.
405425 function runs(parent, text, from, to, marks) {
426+ const marked = (j) => {
427+ if (marks.has(j)) return true;
428+ const c = text.charCodeAt(j);
429+ if (isLow(c) && isHigh(text.charCodeAt(j - 1))) return marks.has(j - 1);
430+ if (isHigh(c) && isLow(text.charCodeAt(j + 1))) return marks.has(j + 1);
431+ return false;
432+ };
406433 for (let j = from; j < to;) {
407- const m = marks.has(j);
434+ const m = marked(j);
408435 let e = j;
409- while (e < to && marks.has(e) === m) e++;
436+ while (e < to && marked(e) === m) e++;
410437 const part = text.slice(j, e);
411438 if (m) {
412439 const mk = document.createElement('mark');
@@ -448,6 +475,7 @@ window.ermiteFinder = (() => {
448475 const path = res.idx.paths[hit.i];
449476 const li = document.createElement('li');
450477 li.setAttribute('role', 'option');
478+ li.setAttribute('aria-selected', 'false');
451479 li.id = 'finder-o' + n;
452480 const a = document.createElement('a');
453481 a.href = blobHref(path);
@@ -459,14 +487,21 @@ window.ermiteFinder = (() => {
459487
460488 const status = (text) => { $('finder-status').textContent = text; };
461489
490+ // setActive makes li the active option (null: none) of the listbox.
462491 function setActive(el, li) {
463- if (li) el.setAttribute('aria-activedescendant', li.id);
464- else el.removeAttribute('aria-activedescendant');
492+ $('finder-list').querySelectorAll(':scope > li[aria-selected="true"]').forEach((o) => o.setAttribute('aria-selected', 'false'));
493+ if (li) {
494+ li.setAttribute('aria-selected', 'true');
495+ el.setAttribute('aria-activedescendant', li.id);
496+ } else {
497+ el.removeAttribute('aria-activedescendant');
498+ }
465499 }
466500
467501 // showTree: no query, the directory again, with j/k and Enter on its rows.
468502 function showTree(el) {
469503 rendered = null;
504+ pendingEnter = null;
470505 $('finder').hidden = true;
471506 $('tree-view').hidden = false;
472507 el.setAttribute('aria-expanded', 'false');
@@ -476,8 +511,11 @@ window.ermiteFinder = (() => {
476511 list.replaceChildren();
477512 const rows = $('tree-rows');
478513 rows.setAttribute('data-rows', '');
514+ const keep = treeCursor;
515+ treeCursor = null;
479516 if (!rows.querySelector(':scope > tr[data-cursor]')) {
480- rows.querySelector(':scope > tr:not([hidden])')?.setAttribute('data-cursor', '');
517+ const row = keep && keep.parentElement === rows && !keep.hidden ? keep : rows.querySelector(':scope > tr:not([hidden])');
518+ row?.setAttribute('data-cursor', '');
481519 }
482520 }
483521
@@ -488,12 +526,16 @@ window.ermiteFinder = (() => {
488526 el.setAttribute('aria-expanded', 'true');
489527 const rows = $('tree-rows');
490528 rows.removeAttribute('data-rows');
491- rows.querySelectorAll(':scope > tr[data-cursor]').forEach((r) => r.removeAttribute('data-cursor'));
529+ rows.querySelectorAll(':scope > tr[data-cursor]').forEach((r) => {
530+ treeCursor = r;
531+ r.removeAttribute('data-cursor');
532+ });
492533 $('finder-list').setAttribute('data-rows', '');
493534 }
494535
495536 async function run(el) {
496537 const q = el.value;
538+ if (pendingEnter !== null && pendingEnter !== q) pendingEnter = null; // the field moved on
497539 if (!q.trim()) return showTree(el);
498540 showResults(el);
499541 if (!rendered) status('loading the file list…');
@@ -501,10 +543,18 @@ window.ermiteFinder = (() => {
501543 try {
502544 loaded = await load();
503545 } catch (e) {
504- if (el.value === q) status('could not load the file list');
546+ if (el.value === q) {
547+ pendingEnter = null;
548+ status('could not load the file list');
549+ }
550+ return;
551+ }
552+ if (el.value !== q) return; // the field changed during the wait
553+ if (!loaded) {
554+ pendingEnter = null;
555+ status('file search is not available here');
505556 return;
506557 }
507- if (el.value !== q || !loaded) return; // the field changed during the wait
508558 const res = search(loaded.idx, q, 50, rendered);
509559 const items = res.hits.map((h, n) => option(res, h, n));
510560 const list = $('finder-list');
@@ -515,6 +565,9 @@ window.ermiteFinder = (() => {
515565 let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match');
516566 if (loaded.truncated) msg += ' · only the first 100,000 files are searched';
517567 status(msg);
568+ const open = pendingEnter === q;
569+ pendingEnter = null;
570+ if (open) items[0]?.querySelector('a').click();
518571 }
519572
520573 function palette(el) {
@@ -565,6 +618,7 @@ window.ermiteFinder = (() => {
565618 index, search, positions, visible, load, palette, input,
566619 // key moves the cursor of the results, opens one, or clears the field.
567620 key(el, e) {
621+ if (e.isComposing) return; // the Enter or Escape of an input method is its own
568622 const results = !$('finder').hidden;
569623 if (e.key === 'Escape') {
570624 el.value = '';
@@ -585,6 +639,7 @@ window.ermiteFinder = (() => {
585639 } else if (e.key === 'Enter') {
586640 e.preventDefault();
587641 if (rendered && rendered.q === el.value) cur?.querySelector('a')?.click();
642+ else pendingEnter = el.value; // the results are stale: run opens the top hit
588643 }
589644 },
590645 };
@@ -17,11 +17,18 @@ window.ermiteTree = (() => {
17 if (kind) kind.textContent = open ? '▾' : '▸';17 if (kind) kind.textContent = open ? '▾' : '▸';
18 }18 }
19 19
20- // collapse hides every row under tr.20+ // collapse hides every row under tr. The cursor of a row it hides moves to
21+ // tr, so it never rests on a row that cannot be seen.
21 function collapse(tr, a) {22 function collapse(tr, a) {
22 setOpen(tr, a, false);23 setOpen(tr, a, false);
23 const d = depthOf(tr);24 const d = depthOf(tr);
24- for (let r = tr.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) r.hidden = true;25+ for (let r = tr.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {
26+ r.hidden = true;
27+ if (r.hasAttribute('data-cursor')) {
28+ r.removeAttribute('data-cursor');
29+ tr.setAttribute('data-cursor', '');
30+ }
31+ }
25 }32 }
26 33
27 // expand shows the rows under tr that no collapsed directory between hides.34 // expand shows the rows under tr that no collapsed directory between hides.
@@ -46,15 +53,21 @@ window.ermiteTree = (() => {
46 const t = document.createElement('template');53 const t = document.createElement('template');
47 t.innerHTML = await res.text();54 t.innerHTML = await res.text();
48 const d = depthOf(tr) + 1;55 const d = depthOf(tr) + 1;
49- const rows = Array.from(t.content.children);56+ const rows = Array.from(t.content.children).filter((r) => r.tagName === 'TR');
57+ if (!rows.length) throw new Error('no rows');
58+ // An ancestor closed while the request was out: tr is hidden, and its
59+ // rows come in hidden, for the expand of that ancestor to show.
60+ const covered = tr.hidden;
50 for (const r of rows) {61 for (const r of rows) {
51 r.dataset.depth = String(d);62 r.dataset.depth = String(d);
52 r.style.setProperty('--depth', String(d));63 r.style.setProperty('--depth', String(d));
64+ if (covered) r.hidden = true;
53 }65 }
54 tr.after(...rows);66 tr.after(...rows);
55 tr.dataset.loaded = '1';67 tr.dataset.loaded = '1';
56 tr.removeAttribute('aria-busy');68 tr.removeAttribute('aria-busy');
57- expand(tr, a);69+ if (covered) setOpen(tr, a, true);
70+ else expand(tr, a);
58 } catch (e) {71 } catch (e) {
59 location.href = a.href;72 location.href = a.href;
60 }73 }
@@ -375,6 +388,8 @@ window.ermiteFinder = (() => {
375 const loads = new Map(); // URL -> promise of {idx, truncated}388 const loads = new Map(); // URL -> promise of {idx, truncated}
376 let rendered = null; // the result the finder list shows389 let rendered = null; // the result the finder list shows
377 let frame = 0;390 let frame = 0;
391+ let treeCursor = null; // the row of the tree that had the cursor when the results came
392+ let pendingEnter = null; // the value of the field at an Enter pressed before its results
378 393
379 // load fetches the path list of the page once and indexes it.394 // load fetches the path list of the page once and indexes it.
380 function load() {395 function load() {
@@ -402,11 +417,23 @@ window.ermiteFinder = (() => {
402 }417 }
403 }418 }
404 419
420+ const isHigh = (c) => c >= 0xd800 && c <= 0xdbff;
421+ const isLow = (c) => c >= 0xdc00 && c <= 0xdfff;
422+
423+ // runs appends text[from, to) to parent, the marked characters in <mark>. A
424+ // surrogate pair is marked whole or not at all, so it is never split.
405 function runs(parent, text, from, to, marks) {425 function runs(parent, text, from, to, marks) {
426+ const marked = (j) => {
427+ if (marks.has(j)) return true;
428+ const c = text.charCodeAt(j);
429+ if (isLow(c) && isHigh(text.charCodeAt(j - 1))) return marks.has(j - 1);
430+ if (isHigh(c) && isLow(text.charCodeAt(j + 1))) return marks.has(j + 1);
431+ return false;
432+ };
406 for (let j = from; j < to;) {433 for (let j = from; j < to;) {
407- const m = marks.has(j);434+ const m = marked(j);
408 let e = j;435 let e = j;
409- while (e < to && marks.has(e) === m) e++;436+ while (e < to && marked(e) === m) e++;
410 const part = text.slice(j, e);437 const part = text.slice(j, e);
411 if (m) {438 if (m) {
412 const mk = document.createElement('mark');439 const mk = document.createElement('mark');
@@ -448,6 +475,7 @@ window.ermiteFinder = (() => {
448 const path = res.idx.paths[hit.i];475 const path = res.idx.paths[hit.i];
449 const li = document.createElement('li');476 const li = document.createElement('li');
450 li.setAttribute('role', 'option');477 li.setAttribute('role', 'option');
478+ li.setAttribute('aria-selected', 'false');
451 li.id = 'finder-o' + n;479 li.id = 'finder-o' + n;
452 const a = document.createElement('a');480 const a = document.createElement('a');
453 a.href = blobHref(path);481 a.href = blobHref(path);
@@ -459,14 +487,21 @@ window.ermiteFinder = (() => {
459 487
460 const status = (text) => { $('finder-status').textContent = text; };488 const status = (text) => { $('finder-status').textContent = text; };
461 489
490+ // setActive makes li the active option (null: none) of the listbox.
462 function setActive(el, li) {491 function setActive(el, li) {
463- if (li) el.setAttribute('aria-activedescendant', li.id);492+ $('finder-list').querySelectorAll(':scope > li[aria-selected="true"]').forEach((o) => o.setAttribute('aria-selected', 'false'));
464- else el.removeAttribute('aria-activedescendant');493+ if (li) {
494+ li.setAttribute('aria-selected', 'true');
495+ el.setAttribute('aria-activedescendant', li.id);
496+ } else {
497+ el.removeAttribute('aria-activedescendant');
498+ }
465 }499 }
466 500
467 // showTree: no query, the directory again, with j/k and Enter on its rows.501 // showTree: no query, the directory again, with j/k and Enter on its rows.
468 function showTree(el) {502 function showTree(el) {
469 rendered = null;503 rendered = null;
504+ pendingEnter = null;
470 $('finder').hidden = true;505 $('finder').hidden = true;
471 $('tree-view').hidden = false;506 $('tree-view').hidden = false;
472 el.setAttribute('aria-expanded', 'false');507 el.setAttribute('aria-expanded', 'false');
@@ -476,8 +511,11 @@ window.ermiteFinder = (() => {
476 list.replaceChildren();511 list.replaceChildren();
477 const rows = $('tree-rows');512 const rows = $('tree-rows');
478 rows.setAttribute('data-rows', '');513 rows.setAttribute('data-rows', '');
514+ const keep = treeCursor;
515+ treeCursor = null;
479 if (!rows.querySelector(':scope > tr[data-cursor]')) {516 if (!rows.querySelector(':scope > tr[data-cursor]')) {
480- rows.querySelector(':scope > tr:not([hidden])')?.setAttribute('data-cursor', '');517+ const row = keep && keep.parentElement === rows && !keep.hidden ? keep : rows.querySelector(':scope > tr:not([hidden])');
518+ row?.setAttribute('data-cursor', '');
481 }519 }
482 }520 }
483 521
@@ -488,12 +526,16 @@ window.ermiteFinder = (() => {
488 el.setAttribute('aria-expanded', 'true');526 el.setAttribute('aria-expanded', 'true');
489 const rows = $('tree-rows');527 const rows = $('tree-rows');
490 rows.removeAttribute('data-rows');528 rows.removeAttribute('data-rows');
491- rows.querySelectorAll(':scope > tr[data-cursor]').forEach((r) => r.removeAttribute('data-cursor'));529+ rows.querySelectorAll(':scope > tr[data-cursor]').forEach((r) => {
530+ treeCursor = r;
531+ r.removeAttribute('data-cursor');
532+ });
492 $('finder-list').setAttribute('data-rows', '');533 $('finder-list').setAttribute('data-rows', '');
493 }534 }
494 535
495 async function run(el) {536 async function run(el) {
496 const q = el.value;537 const q = el.value;
538+ if (pendingEnter !== null && pendingEnter !== q) pendingEnter = null; // the field moved on
497 if (!q.trim()) return showTree(el);539 if (!q.trim()) return showTree(el);
498 showResults(el);540 showResults(el);
499 if (!rendered) status('loading the file list…');541 if (!rendered) status('loading the file list…');
@@ -501,10 +543,18 @@ window.ermiteFinder = (() => {
501 try {543 try {
502 loaded = await load();544 loaded = await load();
503 } catch (e) {545 } catch (e) {
504- if (el.value === q) status('could not load the file list');546+ if (el.value === q) {
547+ pendingEnter = null;
548+ status('could not load the file list');
549+ }
550+ return;
551+ }
552+ if (el.value !== q) return; // the field changed during the wait
553+ if (!loaded) {
554+ pendingEnter = null;
555+ status('file search is not available here');
505 return;556 return;
506 }557 }
507- if (el.value !== q || !loaded) return; // the field changed during the wait
508 const res = search(loaded.idx, q, 50, rendered);558 const res = search(loaded.idx, q, 50, rendered);
509 const items = res.hits.map((h, n) => option(res, h, n));559 const items = res.hits.map((h, n) => option(res, h, n));
510 const list = $('finder-list');560 const list = $('finder-list');
@@ -515,6 +565,9 @@ window.ermiteFinder = (() => {
515 let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match');565 let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match');
516 if (loaded.truncated) msg += ' · only the first 100,000 files are searched';566 if (loaded.truncated) msg += ' · only the first 100,000 files are searched';
517 status(msg);567 status(msg);
568+ const open = pendingEnter === q;
569+ pendingEnter = null;
570+ if (open) items[0]?.querySelector('a').click();
518 }571 }
519 572
520 function palette(el) {573 function palette(el) {
@@ -565,6 +618,7 @@ window.ermiteFinder = (() => {
565 index, search, positions, visible, load, palette, input,618 index, search, positions, visible, load, palette, input,
566 // key moves the cursor of the results, opens one, or clears the field.619 // key moves the cursor of the results, opens one, or clears the field.
567 key(el, e) {620 key(el, e) {
621+ if (e.isComposing) return; // the Enter or Escape of an input method is its own
568 const results = !$('finder').hidden;622 const results = !$('finder').hidden;
569 if (e.key === 'Escape') {623 if (e.key === 'Escape') {
570 el.value = '';624 el.value = '';
@@ -585,6 +639,7 @@ window.ermiteFinder = (() => {
585 } else if (e.key === 'Enter') {639 } else if (e.key === 'Enter') {
586 e.preventDefault();640 e.preventDefault();
587 if (rendered && rendered.q === el.value) cur?.querySelector('a')?.click();641 if (rendered && rendered.q === el.value) cur?.querySelector('a')?.click();
642+ else pendingEnter = el.value; // the results are stale: run opens the top hit
588 }643 }
589 },644 },
590 };645 };

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close