fix(web): keep the tree and finder state consistent
- commit
- 28d17a7eec974a401b3940ea983ef3f48ef0650c
- author
- brice · · 2026-10-11 19:31 UTC
- parent
- 515aa1e
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 @@ | |||
| 1 | 1 | // 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 | 3 | import assert from 'node:assert/strict'; | |
| 4 | 4 | import { test } from 'node:test'; | |
| 5 | 5 | ||
| @@ -233,3 +233,119 @@ test('performance on 100,000 paths (AC6)', () => { | |||
| 233 | 233 | assert.ok(build < 1500, 'index ' + build); | |
| 234 | 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 | + | }); | |
| @@ -1,5 +1,5 @@ | |||||
| 1 | // Tests of the pure core of the file finder (static/js/files.js). Run with | 1 | // 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 = (() => { | |||
| 17 | 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 | 22 | function collapse(tr, a) { | |
| 22 | 23 | setOpen(tr, a, false); | |
| 23 | 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 | 34 | // expand shows the rows under tr that no collapsed directory between hides. | |
| @@ -46,15 +53,21 @@ window.ermiteTree = (() => { | |||
| 46 | 53 | const t = document.createElement('template'); | |
| 47 | 54 | t.innerHTML = await res.text(); | |
| 48 | 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 | 61 | for (const r of rows) { | |
| 51 | 62 | r.dataset.depth = String(d); | |
| 52 | 63 | r.style.setProperty('--depth', String(d)); | |
| 64 | + | if (covered) r.hidden = true; | |
| 53 | 65 | } | |
| 54 | 66 | tr.after(...rows); | |
| 55 | 67 | tr.dataset.loaded = '1'; | |
| 56 | 68 | tr.removeAttribute('aria-busy'); | |
| 57 | - | expand(tr, a); | |
| 69 | + | if (covered) setOpen(tr, a, true); | |
| 70 | + | else expand(tr, a); | |
| 58 | 71 | } catch (e) { | |
| 59 | 72 | location.href = a.href; | |
| 60 | 73 | } | |
| @@ -375,6 +388,8 @@ window.ermiteFinder = (() => { | |||
| 375 | 388 | const loads = new Map(); // URL -> promise of {idx, truncated} | |
| 376 | 389 | let rendered = null; // the result the finder list shows | |
| 377 | 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 | 394 | // load fetches the path list of the page once and indexes it. | |
| 380 | 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 | 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 | 433 | for (let j = from; j < to;) { | |
| 407 | - | const m = marks.has(j); | |
| 434 | + | const m = marked(j); | |
| 408 | 435 | let e = j; | |
| 409 | - | while (e < to && marks.has(e) === m) e++; | |
| 436 | + | while (e < to && marked(e) === m) e++; | |
| 410 | 437 | const part = text.slice(j, e); | |
| 411 | 438 | if (m) { | |
| 412 | 439 | const mk = document.createElement('mark'); | |
| @@ -448,6 +475,7 @@ window.ermiteFinder = (() => { | |||
| 448 | 475 | const path = res.idx.paths[hit.i]; | |
| 449 | 476 | const li = document.createElement('li'); | |
| 450 | 477 | li.setAttribute('role', 'option'); | |
| 478 | + | li.setAttribute('aria-selected', 'false'); | |
| 451 | 479 | li.id = 'finder-o' + n; | |
| 452 | 480 | const a = document.createElement('a'); | |
| 453 | 481 | a.href = blobHref(path); | |
| @@ -459,14 +487,21 @@ window.ermiteFinder = (() => { | |||
| 459 | 487 | ||
| 460 | 488 | const status = (text) => { $('finder-status').textContent = text; }; | |
| 461 | 489 | ||
| 490 | + | // setActive makes li the active option (null: none) of the listbox. | |
| 462 | 491 | 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 | + | } | |
| 465 | 499 | } | |
| 466 | 500 | ||
| 467 | 501 | // showTree: no query, the directory again, with j/k and Enter on its rows. | |
| 468 | 502 | function showTree(el) { | |
| 469 | 503 | rendered = null; | |
| 504 | + | pendingEnter = null; | |
| 470 | 505 | $('finder').hidden = true; | |
| 471 | 506 | $('tree-view').hidden = false; | |
| 472 | 507 | el.setAttribute('aria-expanded', 'false'); | |
| @@ -476,8 +511,11 @@ window.ermiteFinder = (() => { | |||
| 476 | 511 | list.replaceChildren(); | |
| 477 | 512 | const rows = $('tree-rows'); | |
| 478 | 513 | rows.setAttribute('data-rows', ''); | |
| 514 | + | const keep = treeCursor; | |
| 515 | + | treeCursor = null; | |
| 479 | 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 | 526 | el.setAttribute('aria-expanded', 'true'); | |
| 489 | 527 | const rows = $('tree-rows'); | |
| 490 | 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 | 533 | $('finder-list').setAttribute('data-rows', ''); | |
| 493 | 534 | } | |
| 494 | 535 | ||
| 495 | 536 | async function run(el) { | |
| 496 | 537 | const q = el.value; | |
| 538 | + | if (pendingEnter !== null && pendingEnter !== q) pendingEnter = null; // the field moved on | |
| 497 | 539 | if (!q.trim()) return showTree(el); | |
| 498 | 540 | showResults(el); | |
| 499 | 541 | if (!rendered) status('loading the file list…'); | |
| @@ -501,10 +543,18 @@ window.ermiteFinder = (() => { | |||
| 501 | 543 | try { | |
| 502 | 544 | loaded = await load(); | |
| 503 | 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 | 556 | return; | |
| 506 | 557 | } | |
| 507 | - | if (el.value !== q || !loaded) return; // the field changed during the wait | |
| 508 | 558 | const res = search(loaded.idx, q, 50, rendered); | |
| 509 | 559 | const items = res.hits.map((h, n) => option(res, h, n)); | |
| 510 | 560 | const list = $('finder-list'); | |
| @@ -515,6 +565,9 @@ window.ermiteFinder = (() => { | |||
| 515 | 565 | let msg = res.all.length === 0 ? 'no file matches' : res.all.length + (res.all.length === 1 ? ' file matches' : ' files match'); | |
| 516 | 566 | if (loaded.truncated) msg += ' · only the first 100,000 files are searched'; | |
| 517 | 567 | status(msg); | |
| 568 | + | const open = pendingEnter === q; | |
| 569 | + | pendingEnter = null; | |
| 570 | + | if (open) items[0]?.querySelector('a').click(); | |
| 518 | 571 | } | |
| 519 | 572 | ||
| 520 | 573 | function palette(el) { | |
| @@ -565,6 +618,7 @@ window.ermiteFinder = (() => { | |||
| 565 | 618 | index, search, positions, visible, load, palette, input, | |
| 566 | 619 | // key moves the cursor of the results, opens one, or clears the field. | |
| 567 | 620 | key(el, e) { | |
| 621 | + | if (e.isComposing) return; // the Enter or Escape of an input method is its own | |
| 568 | 622 | const results = !$('finder').hidden; | |
| 569 | 623 | if (e.key === 'Escape') { | |
| 570 | 624 | el.value = ''; | |
| @@ -585,6 +639,7 @@ window.ermiteFinder = (() => { | |||
| 585 | 639 | } else if (e.key === 'Enter') { | |
| 586 | 640 | e.preventDefault(); | |
| 587 | 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 | }; | |
| @@ -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 shows | 389 | 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 | }; | ||