detached 2bf61ae

feat(web): browse files as a directory tree with keyboard navigation (#58)

commit
2bf61ae1b3afdfca9ad52064044ce67f22d1303d
author
brice · · 2026-10-18 06:27 UTC
committer
bnema · · 2026-10-18 06:27 UTC
parent
e95eebd
signature
SSH UnverifiedSHA256:VOCA5lYARHcbKDZ7GhTV45VmcjWnJ6ZQIBazj17LivA The key is unknown
45 files · +2002 -792
M internal/adapters/web/ASSETS.md +1 -1
@@ -10,7 +10,7 @@ content-hashed names. Nothing is fetched at runtime.
1010 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
1111 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
1212 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
13-| `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
13+| `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
1414 | `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |
1515 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
1616 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
@@ -10,7 +10,7 @@ content-hashed names. Nothing is fetched at runtime.
10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |10 | `static/js/hx-sse.min.js` | htmx 4 `hx-sse` extension | `mockups/assets/htmx/ext/hx-sse.min.js` | `8a834680c4000a9034d79228872372a92e140c810a075cb6d4a76690dfc13085` |
11 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |11 | `static/js/ermite.js` | key sequences to `k-*` events, the functions the keys and inputs call (`window.ermite`), the live fragments (`ermiteLive`), the diff table of contents observer | `mockups/d/js/ermite.js`, extended here | |
12 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |12 | `static/js/log.js` | the live log of a running job (`window.ermiteLog`, tested in Node by `jstest/log.test.mjs`) | written here | |
13-| `static/js/files.js` | the Files view: directories open in place (`window.ermiteTree`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |13+| `static/js/files.js` | the Files view: a tree whose directories open in place and walk by keyboard (`window.ermiteTree`, tested in Node by `jstest/tree.test.mjs`), the fuzzy file finder and the palette's file hits (`window.ermiteFinder`; its core is tested in Node by `jstest/files.test.mjs`) | written here | |
14 | `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |14 | `static/js/passkey.js` | passkey sign-in on `/-/login` and passkey registration on `/-/passkeys`: a delegated click handler on `[data-passkey]` that runs the WebAuthn JSON API flow (`window.ermitePasskey`; tested in Node by `jstest/passkey.test.mjs`) | written here | |
15 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |15 | `static/css/56-chroma.css` | syntax classes of the code view, mapped to the `--syn-*` tokens (no colour of its own) | generated by `go run scripts/chroma-css.go` from chroma's token classes | |
16 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |16 | `static/css/*.css` | native CSS, served as one stylesheet in name order; relative `url(fonts/…)` and `url(icons/…)` become hashed URLs | written here | |
M internal/adapters/web/jstest/dom.mjs +8 -2
@@ -143,11 +143,15 @@ export class El {
143143 return null;
144144 }
145145 scrollIntoView() {}
146- focus() { dom.focused.push(this); }
146+ focus() { dom.focused.push(this); dom.active = this; }
147+ contains(n) {
148+ for (let e = n; e; e = e.parent) if (e === this) return true;
149+ return false;
150+ }
147151 click() { dom.clicked.push(this); }
148152 }
149153
150-export const dom = { clicked: [], focused: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 };
154+export const dom = { clicked: [], focused: [], active: null, body: null, holdFrames: false, frames: new Map(), frameID: 0 };
151155
152156 // flushFrames runs the frames held since the last flush.
153157 export function flushFrames() {
@@ -162,8 +166,10 @@ export function installDom({ parseHTML } = {}) {
162166 dom.body = new El('body');
163167 dom.clicked = [];
164168 dom.focused = [];
169+ dom.active = null;
165170 const doc = {
166171 body: dom.body,
172+ get activeElement() { return dom.active || dom.body; },
167173 createElement(tag) {
168174 const e = new El(tag);
169175 if (tag === 'template') {
@@ -143,11 +143,15 @@ export class El {
143 return null;143 return null;
144 }144 }
145 scrollIntoView() {}145 scrollIntoView() {}
146- focus() { dom.focused.push(this); }146+ focus() { dom.focused.push(this); dom.active = this; }
147+ contains(n) {
148+ for (let e = n; e; e = e.parent) if (e === this) return true;
149+ return false;
150+ }
147 click() { dom.clicked.push(this); }151 click() { dom.clicked.push(this); }
148 }152 }
149 153
150-export const dom = { clicked: [], focused: [], body: null, holdFrames: false, frames: new Map(), frameID: 0 };154+export const dom = { clicked: [], focused: [], active: null, body: null, holdFrames: false, frames: new Map(), frameID: 0 };
151 155
152 // flushFrames runs the frames held since the last flush.156 // flushFrames runs the frames held since the last flush.
153 export function flushFrames() {157 export function flushFrames() {
@@ -162,8 +166,10 @@ export function installDom({ parseHTML } = {}) {
162 dom.body = new El('body');166 dom.body = new El('body');
163 dom.clicked = [];167 dom.clicked = [];
164 dom.focused = [];168 dom.focused = [];
169+ dom.active = null;
165 const doc = {170 const doc = {
166 body: dom.body,171 body: dom.body,
172+ get activeElement() { return dom.active || dom.body; },
167 createElement(tag) {173 createElement(tag) {
168 const e = new El(tag);174 const e = new El(tag);
169 if (tag === 'template') {175 if (tag === 'template') {
M internal/adapters/web/jstest/finder.test.mjs +3 -3
@@ -30,13 +30,13 @@ globalThis.fetch = async () => {
3030 };
3131
3232 // page builds the parts of the Files view and of the palette that files.js
33-// reaches, around a query field and a tree of three rows.
33+// reaches, around a query field and a tree of three rows (li).
3434 function page() {
35- const rows = ['r1', 'r2', 'r3'].map((n) => el('tr', { attrs: { 'data-nm': n } }));
35+ const rows = ['r1', 'r2', 'r3'].map((n) => el('li', { attrs: { 'data-nm': n } }));
3636 rows[1].setAttribute('data-cursor', '');
3737 const q = el('input', { id: 'finder-q' });
3838 const cmdQ = el('input', { id: 'cmd-q' });
39- const treeRows = el('tbody', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
39+ const treeRows = el('ul', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
4040 const finderList = el('ul', { id: 'finder-list' });
4141 const cmdList = el('ul', { id: 'cmd-list' }, el('li', { attrs: { 'data-k': 'section' }, cls: 'entry' }));
4242 const finder = el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), finderList);
@@ -30,13 +30,13 @@ globalThis.fetch = async () => {
30 };30 };
31 31
32 // page builds the parts of the Files view and of the palette that files.js32 // page builds the parts of the Files view and of the palette that files.js
33-// reaches, around a query field and a tree of three rows.33+// reaches, around a query field and a tree of three rows (li).
34 function page() {34 function page() {
35- const rows = ['r1', 'r2', 'r3'].map((n) => el('tr', { attrs: { 'data-nm': n } }));35+ const rows = ['r1', 'r2', 'r3'].map((n) => el('li', { attrs: { 'data-nm': n } }));
36 rows[1].setAttribute('data-cursor', '');36 rows[1].setAttribute('data-cursor', '');
37 const q = el('input', { id: 'finder-q' });37 const q = el('input', { id: 'finder-q' });
38 const cmdQ = el('input', { id: 'cmd-q' });38 const cmdQ = el('input', { id: 'cmd-q' });
39- const treeRows = el('tbody', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);39+ const treeRows = el('ul', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
40 const finderList = el('ul', { id: 'finder-list' });40 const finderList = el('ul', { id: 'finder-list' });
41 const cmdList = el('ul', { id: 'cmd-list' }, el('li', { attrs: { 'data-k': 'section' }, cls: 'entry' }));41 const cmdList = el('ul', { id: 'cmd-list' }, el('li', { attrs: { 'data-k': 'section' }, cls: 'entry' }));
42 const finder = el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), finderList);42 const finder = el('div', { id: 'finder', hidden: true }, el('p', { id: 'finder-status' }), finderList);
M internal/adapters/web/jstest/tree.test.mjs +308 -17
@@ -3,18 +3,26 @@
33 // node --test 'internal/adapters/web/jstest/*.test.mjs'
44 import assert from 'node:assert/strict';
55 import { test } from 'node:test';
6-import { El, el, installDom } from './dom.mjs';
6+import { dom, El, el, installDom } from './dom.mjs';
77
8-// A row is a name: a trailing "/" makes a directory with a link, "!x" a div
9-// that is not a row, "more" the tree-more row of a truncated directory.
8+// A row is a name: a trailing "/" makes a directory, "!x" a div that is not a
9+// row, "more" the tree-more row of a truncated directory.
1010 function row(name, depth) {
11- const tr = el('tr', { cls: name === 'more' ? 'tree-more' : '', attrs: { 'data-depth': depth } });
12- tr.name = name;
13- tr.append(el('td', { cls: 'c-kind', text: '▸' }));
14- if (name.endsWith('/')) {
15- tr.append(el('a', { href: '/tree/' + name, attrs: { 'data-expand': '/rows/' + name, 'aria-expanded': 'false' } }));
11+ const dir = name.endsWith('/');
12+ const li = el('li', { cls: name === 'more' ? 'tree-more' : '', attrs: { 'data-depth': depth } });
13+ li.name = name;
14+ if (dir) {
15+ li.setAttribute('data-expand', '/rows/' + name);
16+ li.setAttribute('aria-expanded', 'false');
1617 }
17- return tr;
18+ li.setAttribute('role', 'treeitem');
19+ const main = el('span', { cls: 't-main' }, el('span', { cls: 't-node' }));
20+ const a = el('a', { cls: 't-name', href: (dir ? '/tree/' : '/blob/') + name });
21+ a.click = () => { globalThis.location.href = a.href; };
22+ main.append(a);
23+ li.append(main);
24+ li.append(el('span', { cls: 't-sub' }, el('a', { cls: 't-commit', href: '/commit/' + name })));
25+ return li;
1826 }
1927
2028 installDom({
@@ -35,16 +43,21 @@ const answer = async (url, text, ok) => {
3543
3644 // click sends a click on the link of tr; it answers whether the page's own
3745 // navigation was kept (no preventDefault).
38-const click = (tr, mods = {}) => {
39- const a = tr.querySelector('a[data-expand]');
46+const click = (tr, mods = {}, target = tr.querySelector('a.t-name')) => {
4047 let prevented = false;
41- T.click({ target: a, button: 0, preventDefault() { prevented = true; }, ...mods });
48+ T.click({ target, button: 0, preventDefault() { prevented = true; }, ...mods });
4249 return !prevented;
4350 };
44-const kindOf = (tr) => tr.querySelector('.c-kind').textContent;
51+const isOpen = (tr) => tr.getAttribute('aria-expanded') === 'true';
4552
53+// table builds the page: the list of rows, with the cursor on the first.
4654 function table(...names) {
47- return el('tbody', {}, ...names.map((n) => row(n, 0)));
55+ const ul = el('ul', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...names.map((n) => row(n, 0)));
56+ dom.body.replaceChildren(ul);
57+ ul.children[0]?.setAttribute('data-cursor', '');
58+ T.init();
59+ dom.active = null;
60+ return ul;
4861 }
4962 const byName = (body, n) => body.children.find((r) => r.name === n);
5063 const shown = (body) => body.children.filter((r) => !r.hidden).map((r) => r.name);
@@ -56,7 +69,7 @@ test('a directory opens in place and closes again without a request', async () =
5669 await answer('/rows/P/', 'a,b');
5770 assert.deepEqual(shown(body), ['P/', 'a', 'b', 'f']);
5871 assert.equal(byName(body, 'a').dataset.depth, '1');
59- assert.equal(kindOf(body.children[0]), '▾');
72+ assert.ok(isOpen(body.children[0]));
6073 click(body.children[0]);
6174 assert.deepEqual(shown(body), ['P/', 'f']);
6275 pending.clear();
@@ -85,8 +98,8 @@ test('TS-001/TS-002: a directory that loads under a closed ancestor comes in hid
8598 assert.deepEqual(body.children.map((r) => r.name), ['P/', 'C/', 'c1', 'c2', 'x', 'f']);
8699 assert.equal(byName(body, 'c1').hidden, true);
87100 assert.equal(C.getAttribute('aria-busy'), null);
88- assert.equal(C.querySelector('a[data-expand]').getAttribute('aria-expanded'), 'true', 'C is open and loaded');
89- assert.equal(kindOf(C), '▾');
101+ assert.ok(isOpen(C), 'C is open and loaded');
102+ assert.ok(isOpen(C));
90103
91104 pending.clear();
92105 click(P); // P opens: C's rows show with it
@@ -175,3 +188,281 @@ test('the tree-more row has a depth and follows its directory', async () => {
175188 click(body.children[0]);
176189 assert.equal(more.hidden, false, 'shown with it again');
177190 });
191+
192+// ---- the keyboard: l, h and Enter on the row under the cursor ----------------
193+
194+const cursorName = (body) => body.children.find((r) => r.hasAttribute('data-cursor'))?.name;
195+const nav = () => globalThis.location.href;
196+
197+test('l opens a closed directory, then enters it; h closes it, or goes to its directory', async () => {
198+ pending.clear();
199+ const body = table('P/', 'f');
200+ assert.equal(T.key(true), true);
201+ await answer('/rows/P/', 'C/,a');
202+ assert.deepEqual(shown(body), ['P/', 'C/', 'a', 'f']);
203+ assert.equal(cursorName(body), 'P/', 'the cursor stays on the directory it opened');
204+ T.key(true); // open: into its first entry
205+ assert.equal(cursorName(body), 'C/');
206+ T.key(false); // C/ is closed: go to its directory
207+ assert.equal(cursorName(body), 'P/');
208+ T.key(false); // P/ is open: close it
209+ assert.deepEqual(shown(body), ['P/', 'f']);
210+ assert.ok(!isOpen(body.children[0]));
211+ assert.equal(cursorName(body), 'P/');
212+});
213+
214+test('l on a file opens it; Enter opens a file and toggles a directory', async () => {
215+ pending.clear();
216+ const body = table('f', 'P/');
217+ globalThis.location.href = '';
218+ assert.equal(T.key(true), true);
219+ assert.equal(nav(), '/blob/f');
220+ body.children[0].removeAttribute('data-cursor');
221+ body.children[1].setAttribute('data-cursor', '');
222+ assert.equal(T.open(), true);
223+ await answer('/rows/P/', 'x');
224+ assert.deepEqual(shown(body), ['f', 'P/', 'x']);
225+ assert.equal(T.open(), true);
226+ assert.deepEqual(shown(body), ['f', 'P/']);
227+});
228+
229+test('h on a top-level row goes to the parent page, when there is one', async () => {
230+ pending.clear();
231+ const body = table('f');
232+ globalThis.location.href = '';
233+ T.key(false);
234+ assert.equal(nav(), '', 'at the root there is nowhere to go');
235+ body.setAttribute('data-up', '/tree/main');
236+ T.key(false);
237+ assert.equal(nav(), '/tree/main');
238+});
239+
240+test('the keys are not the tree\'s while the finder shows its results', async () => {
241+ pending.clear();
242+ const body = table('P/', 'f');
243+ body.removeAttribute('data-rows');
244+ assert.equal(T.active(), false);
245+ assert.equal(T.key(true), false);
246+ assert.equal(T.open(), false);
247+ assert.deepEqual(pending.size, 0);
248+});
249+
250+test('a click on the name puts the cursor on the row', async () => {
251+ pending.clear();
252+ const body = table('a', 'b');
253+ click(body.children[1], {}, body.children[1].querySelector('a.t-name'));
254+ assert.equal(cursorName(body), 'b');
255+ assert.equal(body.children[0].hasAttribute('data-cursor'), false);
256+ // the click on the last commit is the link's own, and the row takes the cursor
257+ let prevented = false;
258+ const sub = body.children[0].querySelector('a.t-commit');
259+ T.click({ target: sub, button: 0, preventDefault() { prevented = true; } });
260+ assert.equal(prevented, false);
261+ assert.equal(cursorName(body), 'a', 'the cursor follows the click on the row, whatever the target');
262+});
263+
264+// ---- focus: one model, a roving tabindex on the rows ---------------------------
265+
266+const stops = (body) => body.children.filter((r) => r.getAttribute('tabindex') === '0').map((r) => r.name);
267+const subTab = (r) => r.querySelector('a.t-commit').getAttribute('tabindex');
268+
269+test('init: one tab stop, the row under the cursor; name links are out of the tab order', () => {
270+ const body = table('a', 'P/', 'b');
271+ assert.deepEqual(stops(body), ['a']);
272+ for (const r of body.children) {
273+ assert.equal(r.querySelector('a.t-name').getAttribute('tabindex'), '-1', 'the row stands for its name');
274+ assert.equal(subTab(r), r.name === 'a' ? '0' : '-1', 'the commit link follows its row');
275+ }
276+ assert.equal(body.children[1].getAttribute('tabindex'), '-1');
277+});
278+
279+test('l and h move the focus with the cursor, and the tab stop with them', async () => {
280+ pending.clear();
281+ const body = table('P/', 'f');
282+ T.key(true);
283+ await answer('/rows/P/', 'C/,a');
284+ const C = byName(body, 'C/');
285+ assert.equal(C.getAttribute('tabindex'), '-1', 'rows that come in are out of the tab order');
286+ assert.equal(C.querySelector('a.t-name').getAttribute('tabindex'), '-1');
287+ assert.equal(subTab(C), '-1');
288+ T.key(true); // into the first entry
289+ assert.equal(dom.active, C);
290+ assert.deepEqual(stops(body), ['C/']);
291+ assert.equal(subTab(C), '0');
292+ assert.equal(subTab(body.children[0]), '-1');
293+ T.key(false); // up to P/
294+ assert.equal(dom.active, body.children[0]);
295+ assert.deepEqual(stops(body), ['P/']);
296+});
297+
298+test('sync (j k gg G of ermite.js) puts the focus and the tab stop on the row', () => {
299+ const body = table('a', 'b', 'c');
300+ body.children[0].removeAttribute('data-cursor');
301+ body.children[2].setAttribute('data-cursor', ''); // ermite.js moved the cursor
302+ T.sync(body.children[2]);
303+ assert.equal(dom.active, body.children[2]);
304+ assert.deepEqual(stops(body), ['c']);
305+ assert.equal(body.children.filter((r) => r.hasAttribute('data-cursor')).length, 1);
306+ T.sync(el('li')); // a row of another list: not the tree's
307+ assert.equal(dom.active, body.children[2]);
308+});
309+
310+test('a click puts the focus and the tab stop on its row, directory or not', async () => {
311+ pending.clear();
312+ const body = table('a', 'b/');
313+ click(body.children[1]);
314+ assert.equal(dom.active, body.children[1]);
315+ assert.deepEqual(stops(body), ['b/']);
316+ await answer('/rows/b/', 'x');
317+ // a click on the commit link: the row takes the focus, the link keeps its own navigation
318+ let prevented = false;
319+ T.click({ target: body.children[0].querySelector('a.t-commit'), button: 0, preventDefault() { prevented = true; } });
320+ assert.equal(prevented, false);
321+ assert.equal(dom.active, body.children[0]);
322+ assert.deepEqual(stops(body), ['a']);
323+ // a modified click on the name too
324+ click(body.children[1], { ctrlKey: true });
325+ assert.equal(dom.active, body.children[1]);
326+});
327+
328+test('collapsing a branch with the focus or the cursor in it moves both to the directory', async () => {
329+ pending.clear();
330+ const body = table('P/', 'f');
331+ T.key(true);
332+ await answer('/rows/P/', 'a,b');
333+ const a = byName(body, 'a');
334+ click(a); // the cursor and the focus on a
335+ assert.equal(dom.active, a);
336+ click(body.children[0]); // the directory closes
337+ assert.deepEqual(shown(body), ['P/', 'f']);
338+ assert.equal(dom.active, body.children[0], 'the focus leaves the hidden row');
339+ assert.deepEqual(stops(body), ['P/']);
340+ assert.ok(body.children[0].hasAttribute('data-cursor'));
341+ assert.equal(a.hasAttribute('data-cursor'), false);
342+ assert.equal(a.getAttribute('tabindex'), '-1');
343+});
344+
345+test('collapsing a directory the focus is not in leaves the focus alone', async () => {
346+ pending.clear();
347+ const body = table('P/', 'f');
348+ T.key(true);
349+ await answer('/rows/P/', 'a');
350+ click(body.children[0]); // P/ has the cursor and the focus; closing it keeps them
351+ assert.equal(dom.active, body.children[0]);
352+ click(body.children[3] ?? body.children[2]); // f
353+ const f = byName(body, 'f');
354+ assert.equal(dom.active, f);
355+ T.key(false); // f: no parent in the page, no up: nothing
356+ assert.equal(dom.active, f);
357+});
358+
359+test('Enter acts on the row that has the cursor, which is the focused row', async () => {
360+ pending.clear();
361+ const body = table('a', 'P/');
362+ T.sync(body.children[1]); // ermite.js moved the cursor and the focus to P/
363+ assert.equal(dom.active, body.children[1]);
364+ T.open();
365+ await answer('/rows/P/', 'x');
366+ assert.ok(isOpen(body.children[1]));
367+ assert.equal(body.children.filter((r) => r.hasAttribute('data-cursor')).length, 1);
368+});
369+
370+// ---- ARIA: the level is set on the rows inserted; position and size come with them ----
371+
372+test('inserted rows get their level; the server gave them posinset and setsize', async () => {
373+ pending.clear();
374+ const body = table('P/', 'f');
375+ T.key(true);
376+ await answer('/rows/P/', 'C/,a');
377+ assert.equal(byName(body, 'C/').getAttribute('aria-level'), '2');
378+ assert.equal(byName(body, 'C/').getAttribute('role'), 'treeitem');
379+ // a directory inside it: level 3
380+ click(byName(body, 'C/'));
381+ await answer('/rows/C/', 'z');
382+ assert.equal(byName(body, 'z').getAttribute('aria-level'), '3');
383+});
384+
385+// ---- the arrow keys: the APG tree pattern, apart from the Vim keys ---------------
386+
387+test('Down, Up, Home and End walk the visible rows, with the focus', async () => {
388+ pending.clear();
389+ const body = table('P/', 'f', 'g');
390+ T.arrow('ArrowRight');
391+ await answer('/rows/P/', 'a,b');
392+ T.arrow('ArrowRight'); // open: into the first entry
393+ assert.equal(cursorName(body), 'a');
394+ T.arrow('ArrowDown');
395+ assert.equal(cursorName(body), 'b');
396+ assert.equal(dom.active, byName(body, 'b'));
397+ T.arrow('ArrowUp');
398+ assert.equal(cursorName(body), 'a');
399+ T.arrow('End');
400+ assert.equal(cursorName(body), 'g');
401+ T.arrow('ArrowDown'); // the last row: stays
402+ assert.equal(cursorName(body), 'g');
403+ T.arrow('Home');
404+ assert.equal(cursorName(body), 'P/');
405+ T.arrow('ArrowUp');
406+ assert.equal(cursorName(body), 'P/');
407+ // a collapsed branch is skipped
408+ T.arrow('ArrowLeft');
409+ T.arrow('ArrowDown');
410+ assert.equal(cursorName(body), 'f');
411+ assert.deepEqual(stops(body), ['f']);
412+});
413+
414+test('Right opens a closed directory, enters an open one, and does nothing on a file', async () => {
415+ pending.clear();
416+ globalThis.location.href = '';
417+ const body = table('P/', 'f');
418+ assert.equal(T.arrow('ArrowRight'), true);
419+ await answer('/rows/P/', 'a');
420+ assert.ok(isOpen(body.children[0]));
421+ assert.equal(cursorName(body), 'P/', 'opening keeps the cursor on the directory');
422+ T.arrow('ArrowRight');
423+ assert.equal(cursorName(body), 'a', 'on an open directory: the first entry');
424+ T.arrow('ArrowRight'); // a file: nothing, in particular it is not opened
425+ assert.equal(cursorName(body), 'a');
426+ assert.equal(nav(), '', 'a file is not opened by the arrow');
427+ assert.equal(pending.size, 1, 'no further request');
428+});
429+
430+test('Left closes an open directory, goes to the parent, and does nothing at the top', async () => {
431+ pending.clear();
432+ globalThis.location.href = '';
433+ const body = table('P/', 'f');
434+ body.setAttribute('data-up', '/tree/main'); // a page to go up to: not for an arrow
435+ T.arrow('ArrowRight');
436+ await answer('/rows/P/', 'a');
437+ T.arrow('ArrowRight');
438+ T.arrow('ArrowLeft'); // a: to its directory
439+ assert.equal(cursorName(body), 'P/');
440+ T.arrow('ArrowLeft'); // P/ is open: closes
441+ assert.deepEqual(shown(body), ['P/', 'f']);
442+ T.arrow('ArrowLeft'); // closed and top level: nothing
443+ assert.equal(cursorName(body), 'P/');
444+ assert.equal(nav(), '', 'the arrow does not leave the page; h does');
445+ T.key(false);
446+ assert.equal(nav(), '/tree/main');
447+});
448+
449+test('l opens a file where Right does not', () => {
450+ pending.clear();
451+ globalThis.location.href = '';
452+ const body = table('f');
453+ T.arrow('ArrowRight');
454+ assert.equal(nav(), '');
455+ T.key(true);
456+ assert.equal(nav(), '/blob/f');
457+ assert.ok(body);
458+});
459+
460+test('the tree takes no arrow key while the finder shows its results, nor any other key', () => {
461+ const body = table('a', 'b');
462+ body.removeAttribute('data-rows');
463+ assert.equal(T.arrow('ArrowDown'), false);
464+ assert.equal(cursorName(body), 'a');
465+ body.setAttribute('data-rows', '');
466+ assert.equal(T.arrow('x'), false);
467+ assert.equal(T.arrow('PageDown'), false);
468+});
@@ -3,18 +3,26 @@
3 // node --test 'internal/adapters/web/jstest/*.test.mjs'3 // node --test 'internal/adapters/web/jstest/*.test.mjs'
4 import assert from 'node:assert/strict';4 import assert from 'node:assert/strict';
5 import { test } from 'node:test';5 import { test } from 'node:test';
6-import { El, el, installDom } from './dom.mjs';6+import { dom, El, el, installDom } from './dom.mjs';
7 7
8-// A row is a name: a trailing "/" makes a directory with a link, "!x" a div8+// A row is a name: a trailing "/" makes a directory, "!x" a div that is not a
9-// that is not a row, "more" the tree-more row of a truncated directory.9+// row, "more" the tree-more row of a truncated directory.
10 function row(name, depth) {10 function row(name, depth) {
11- const tr = el('tr', { cls: name === 'more' ? 'tree-more' : '', attrs: { 'data-depth': depth } });11+ const dir = name.endsWith('/');
12- tr.name = name;12+ const li = el('li', { cls: name === 'more' ? 'tree-more' : '', attrs: { 'data-depth': depth } });
13- tr.append(el('td', { cls: 'c-kind', text: '▸' }));13+ li.name = name;
14- if (name.endsWith('/')) {14+ if (dir) {
15- tr.append(el('a', { href: '/tree/' + name, attrs: { 'data-expand': '/rows/' + name, 'aria-expanded': 'false' } }));15+ li.setAttribute('data-expand', '/rows/' + name);
16+ li.setAttribute('aria-expanded', 'false');
16 }17 }
17- return tr;18+ li.setAttribute('role', 'treeitem');
19+ const main = el('span', { cls: 't-main' }, el('span', { cls: 't-node' }));
20+ const a = el('a', { cls: 't-name', href: (dir ? '/tree/' : '/blob/') + name });
21+ a.click = () => { globalThis.location.href = a.href; };
22+ main.append(a);
23+ li.append(main);
24+ li.append(el('span', { cls: 't-sub' }, el('a', { cls: 't-commit', href: '/commit/' + name })));
25+ return li;
18 }26 }
19 27
20 installDom({28 installDom({
@@ -35,16 +43,21 @@ const answer = async (url, text, ok) => {
35 43
36 // click sends a click on the link of tr; it answers whether the page's own44 // click sends a click on the link of tr; it answers whether the page's own
37 // navigation was kept (no preventDefault).45 // navigation was kept (no preventDefault).
38-const click = (tr, mods = {}) => {46+const click = (tr, mods = {}, target = tr.querySelector('a.t-name')) => {
39- const a = tr.querySelector('a[data-expand]');
40 let prevented = false;47 let prevented = false;
41- T.click({ target: a, button: 0, preventDefault() { prevented = true; }, ...mods });48+ T.click({ target, button: 0, preventDefault() { prevented = true; }, ...mods });
42 return !prevented;49 return !prevented;
43 };50 };
44-const kindOf = (tr) => tr.querySelector('.c-kind').textContent;51+const isOpen = (tr) => tr.getAttribute('aria-expanded') === 'true';
45 52
53+// table builds the page: the list of rows, with the cursor on the first.
46 function table(...names) {54 function table(...names) {
47- return el('tbody', {}, ...names.map((n) => row(n, 0)));55+ const ul = el('ul', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...names.map((n) => row(n, 0)));
56+ dom.body.replaceChildren(ul);
57+ ul.children[0]?.setAttribute('data-cursor', '');
58+ T.init();
59+ dom.active = null;
60+ return ul;
48 }61 }
49 const byName = (body, n) => body.children.find((r) => r.name === n);62 const byName = (body, n) => body.children.find((r) => r.name === n);
50 const shown = (body) => body.children.filter((r) => !r.hidden).map((r) => r.name);63 const shown = (body) => body.children.filter((r) => !r.hidden).map((r) => r.name);
@@ -56,7 +69,7 @@ test('a directory opens in place and closes again without a request', async () =
56 await answer('/rows/P/', 'a,b');69 await answer('/rows/P/', 'a,b');
57 assert.deepEqual(shown(body), ['P/', 'a', 'b', 'f']);70 assert.deepEqual(shown(body), ['P/', 'a', 'b', 'f']);
58 assert.equal(byName(body, 'a').dataset.depth, '1');71 assert.equal(byName(body, 'a').dataset.depth, '1');
59- assert.equal(kindOf(body.children[0]), '▾');72+ assert.ok(isOpen(body.children[0]));
60 click(body.children[0]);73 click(body.children[0]);
61 assert.deepEqual(shown(body), ['P/', 'f']);74 assert.deepEqual(shown(body), ['P/', 'f']);
62 pending.clear();75 pending.clear();
@@ -85,8 +98,8 @@ test('TS-001/TS-002: a directory that loads under a closed ancestor comes in hid
85 assert.deepEqual(body.children.map((r) => r.name), ['P/', 'C/', 'c1', 'c2', 'x', 'f']);98 assert.deepEqual(body.children.map((r) => r.name), ['P/', 'C/', 'c1', 'c2', 'x', 'f']);
86 assert.equal(byName(body, 'c1').hidden, true);99 assert.equal(byName(body, 'c1').hidden, true);
87 assert.equal(C.getAttribute('aria-busy'), null);100 assert.equal(C.getAttribute('aria-busy'), null);
88- assert.equal(C.querySelector('a[data-expand]').getAttribute('aria-expanded'), 'true', 'C is open and loaded');101+ assert.ok(isOpen(C), 'C is open and loaded');
89- assert.equal(kindOf(C), '▾');102+ assert.ok(isOpen(C));
90 103
91 pending.clear();104 pending.clear();
92 click(P); // P opens: C's rows show with it105 click(P); // P opens: C's rows show with it
@@ -175,3 +188,281 @@ test('the tree-more row has a depth and follows its directory', async () => {
175 click(body.children[0]);188 click(body.children[0]);
176 assert.equal(more.hidden, false, 'shown with it again');189 assert.equal(more.hidden, false, 'shown with it again');
177 });190 });
191+
192+// ---- the keyboard: l, h and Enter on the row under the cursor ----------------
193+
194+const cursorName = (body) => body.children.find((r) => r.hasAttribute('data-cursor'))?.name;
195+const nav = () => globalThis.location.href;
196+
197+test('l opens a closed directory, then enters it; h closes it, or goes to its directory', async () => {
198+ pending.clear();
199+ const body = table('P/', 'f');
200+ assert.equal(T.key(true), true);
201+ await answer('/rows/P/', 'C/,a');
202+ assert.deepEqual(shown(body), ['P/', 'C/', 'a', 'f']);
203+ assert.equal(cursorName(body), 'P/', 'the cursor stays on the directory it opened');
204+ T.key(true); // open: into its first entry
205+ assert.equal(cursorName(body), 'C/');
206+ T.key(false); // C/ is closed: go to its directory
207+ assert.equal(cursorName(body), 'P/');
208+ T.key(false); // P/ is open: close it
209+ assert.deepEqual(shown(body), ['P/', 'f']);
210+ assert.ok(!isOpen(body.children[0]));
211+ assert.equal(cursorName(body), 'P/');
212+});
213+
214+test('l on a file opens it; Enter opens a file and toggles a directory', async () => {
215+ pending.clear();
216+ const body = table('f', 'P/');
217+ globalThis.location.href = '';
218+ assert.equal(T.key(true), true);
219+ assert.equal(nav(), '/blob/f');
220+ body.children[0].removeAttribute('data-cursor');
221+ body.children[1].setAttribute('data-cursor', '');
222+ assert.equal(T.open(), true);
223+ await answer('/rows/P/', 'x');
224+ assert.deepEqual(shown(body), ['f', 'P/', 'x']);
225+ assert.equal(T.open(), true);
226+ assert.deepEqual(shown(body), ['f', 'P/']);
227+});
228+
229+test('h on a top-level row goes to the parent page, when there is one', async () => {
230+ pending.clear();
231+ const body = table('f');
232+ globalThis.location.href = '';
233+ T.key(false);
234+ assert.equal(nav(), '', 'at the root there is nowhere to go');
235+ body.setAttribute('data-up', '/tree/main');
236+ T.key(false);
237+ assert.equal(nav(), '/tree/main');
238+});
239+
240+test('the keys are not the tree\'s while the finder shows its results', async () => {
241+ pending.clear();
242+ const body = table('P/', 'f');
243+ body.removeAttribute('data-rows');
244+ assert.equal(T.active(), false);
245+ assert.equal(T.key(true), false);
246+ assert.equal(T.open(), false);
247+ assert.deepEqual(pending.size, 0);
248+});
249+
250+test('a click on the name puts the cursor on the row', async () => {
251+ pending.clear();
252+ const body = table('a', 'b');
253+ click(body.children[1], {}, body.children[1].querySelector('a.t-name'));
254+ assert.equal(cursorName(body), 'b');
255+ assert.equal(body.children[0].hasAttribute('data-cursor'), false);
256+ // the click on the last commit is the link's own, and the row takes the cursor
257+ let prevented = false;
258+ const sub = body.children[0].querySelector('a.t-commit');
259+ T.click({ target: sub, button: 0, preventDefault() { prevented = true; } });
260+ assert.equal(prevented, false);
261+ assert.equal(cursorName(body), 'a', 'the cursor follows the click on the row, whatever the target');
262+});
263+
264+// ---- focus: one model, a roving tabindex on the rows ---------------------------
265+
266+const stops = (body) => body.children.filter((r) => r.getAttribute('tabindex') === '0').map((r) => r.name);
267+const subTab = (r) => r.querySelector('a.t-commit').getAttribute('tabindex');
268+
269+test('init: one tab stop, the row under the cursor; name links are out of the tab order', () => {
270+ const body = table('a', 'P/', 'b');
271+ assert.deepEqual(stops(body), ['a']);
272+ for (const r of body.children) {
273+ assert.equal(r.querySelector('a.t-name').getAttribute('tabindex'), '-1', 'the row stands for its name');
274+ assert.equal(subTab(r), r.name === 'a' ? '0' : '-1', 'the commit link follows its row');
275+ }
276+ assert.equal(body.children[1].getAttribute('tabindex'), '-1');
277+});
278+
279+test('l and h move the focus with the cursor, and the tab stop with them', async () => {
280+ pending.clear();
281+ const body = table('P/', 'f');
282+ T.key(true);
283+ await answer('/rows/P/', 'C/,a');
284+ const C = byName(body, 'C/');
285+ assert.equal(C.getAttribute('tabindex'), '-1', 'rows that come in are out of the tab order');
286+ assert.equal(C.querySelector('a.t-name').getAttribute('tabindex'), '-1');
287+ assert.equal(subTab(C), '-1');
288+ T.key(true); // into the first entry
289+ assert.equal(dom.active, C);
290+ assert.deepEqual(stops(body), ['C/']);
291+ assert.equal(subTab(C), '0');
292+ assert.equal(subTab(body.children[0]), '-1');
293+ T.key(false); // up to P/
294+ assert.equal(dom.active, body.children[0]);
295+ assert.deepEqual(stops(body), ['P/']);
296+});
297+
298+test('sync (j k gg G of ermite.js) puts the focus and the tab stop on the row', () => {
299+ const body = table('a', 'b', 'c');
300+ body.children[0].removeAttribute('data-cursor');
301+ body.children[2].setAttribute('data-cursor', ''); // ermite.js moved the cursor
302+ T.sync(body.children[2]);
303+ assert.equal(dom.active, body.children[2]);
304+ assert.deepEqual(stops(body), ['c']);
305+ assert.equal(body.children.filter((r) => r.hasAttribute('data-cursor')).length, 1);
306+ T.sync(el('li')); // a row of another list: not the tree's
307+ assert.equal(dom.active, body.children[2]);
308+});
309+
310+test('a click puts the focus and the tab stop on its row, directory or not', async () => {
311+ pending.clear();
312+ const body = table('a', 'b/');
313+ click(body.children[1]);
314+ assert.equal(dom.active, body.children[1]);
315+ assert.deepEqual(stops(body), ['b/']);
316+ await answer('/rows/b/', 'x');
317+ // a click on the commit link: the row takes the focus, the link keeps its own navigation
318+ let prevented = false;
319+ T.click({ target: body.children[0].querySelector('a.t-commit'), button: 0, preventDefault() { prevented = true; } });
320+ assert.equal(prevented, false);
321+ assert.equal(dom.active, body.children[0]);
322+ assert.deepEqual(stops(body), ['a']);
323+ // a modified click on the name too
324+ click(body.children[1], { ctrlKey: true });
325+ assert.equal(dom.active, body.children[1]);
326+});
327+
328+test('collapsing a branch with the focus or the cursor in it moves both to the directory', async () => {
329+ pending.clear();
330+ const body = table('P/', 'f');
331+ T.key(true);
332+ await answer('/rows/P/', 'a,b');
333+ const a = byName(body, 'a');
334+ click(a); // the cursor and the focus on a
335+ assert.equal(dom.active, a);
336+ click(body.children[0]); // the directory closes
337+ assert.deepEqual(shown(body), ['P/', 'f']);
338+ assert.equal(dom.active, body.children[0], 'the focus leaves the hidden row');
339+ assert.deepEqual(stops(body), ['P/']);
340+ assert.ok(body.children[0].hasAttribute('data-cursor'));
341+ assert.equal(a.hasAttribute('data-cursor'), false);
342+ assert.equal(a.getAttribute('tabindex'), '-1');
343+});
344+
345+test('collapsing a directory the focus is not in leaves the focus alone', async () => {
346+ pending.clear();
347+ const body = table('P/', 'f');
348+ T.key(true);
349+ await answer('/rows/P/', 'a');
350+ click(body.children[0]); // P/ has the cursor and the focus; closing it keeps them
351+ assert.equal(dom.active, body.children[0]);
352+ click(body.children[3] ?? body.children[2]); // f
353+ const f = byName(body, 'f');
354+ assert.equal(dom.active, f);
355+ T.key(false); // f: no parent in the page, no up: nothing
356+ assert.equal(dom.active, f);
357+});
358+
359+test('Enter acts on the row that has the cursor, which is the focused row', async () => {
360+ pending.clear();
361+ const body = table('a', 'P/');
362+ T.sync(body.children[1]); // ermite.js moved the cursor and the focus to P/
363+ assert.equal(dom.active, body.children[1]);
364+ T.open();
365+ await answer('/rows/P/', 'x');
366+ assert.ok(isOpen(body.children[1]));
367+ assert.equal(body.children.filter((r) => r.hasAttribute('data-cursor')).length, 1);
368+});
369+
370+// ---- ARIA: the level is set on the rows inserted; position and size come with them ----
371+
372+test('inserted rows get their level; the server gave them posinset and setsize', async () => {
373+ pending.clear();
374+ const body = table('P/', 'f');
375+ T.key(true);
376+ await answer('/rows/P/', 'C/,a');
377+ assert.equal(byName(body, 'C/').getAttribute('aria-level'), '2');
378+ assert.equal(byName(body, 'C/').getAttribute('role'), 'treeitem');
379+ // a directory inside it: level 3
380+ click(byName(body, 'C/'));
381+ await answer('/rows/C/', 'z');
382+ assert.equal(byName(body, 'z').getAttribute('aria-level'), '3');
383+});
384+
385+// ---- the arrow keys: the APG tree pattern, apart from the Vim keys ---------------
386+
387+test('Down, Up, Home and End walk the visible rows, with the focus', async () => {
388+ pending.clear();
389+ const body = table('P/', 'f', 'g');
390+ T.arrow('ArrowRight');
391+ await answer('/rows/P/', 'a,b');
392+ T.arrow('ArrowRight'); // open: into the first entry
393+ assert.equal(cursorName(body), 'a');
394+ T.arrow('ArrowDown');
395+ assert.equal(cursorName(body), 'b');
396+ assert.equal(dom.active, byName(body, 'b'));
397+ T.arrow('ArrowUp');
398+ assert.equal(cursorName(body), 'a');
399+ T.arrow('End');
400+ assert.equal(cursorName(body), 'g');
401+ T.arrow('ArrowDown'); // the last row: stays
402+ assert.equal(cursorName(body), 'g');
403+ T.arrow('Home');
404+ assert.equal(cursorName(body), 'P/');
405+ T.arrow('ArrowUp');
406+ assert.equal(cursorName(body), 'P/');
407+ // a collapsed branch is skipped
408+ T.arrow('ArrowLeft');
409+ T.arrow('ArrowDown');
410+ assert.equal(cursorName(body), 'f');
411+ assert.deepEqual(stops(body), ['f']);
412+});
413+
414+test('Right opens a closed directory, enters an open one, and does nothing on a file', async () => {
415+ pending.clear();
416+ globalThis.location.href = '';
417+ const body = table('P/', 'f');
418+ assert.equal(T.arrow('ArrowRight'), true);
419+ await answer('/rows/P/', 'a');
420+ assert.ok(isOpen(body.children[0]));
421+ assert.equal(cursorName(body), 'P/', 'opening keeps the cursor on the directory');
422+ T.arrow('ArrowRight');
423+ assert.equal(cursorName(body), 'a', 'on an open directory: the first entry');
424+ T.arrow('ArrowRight'); // a file: nothing, in particular it is not opened
425+ assert.equal(cursorName(body), 'a');
426+ assert.equal(nav(), '', 'a file is not opened by the arrow');
427+ assert.equal(pending.size, 1, 'no further request');
428+});
429+
430+test('Left closes an open directory, goes to the parent, and does nothing at the top', async () => {
431+ pending.clear();
432+ globalThis.location.href = '';
433+ const body = table('P/', 'f');
434+ body.setAttribute('data-up', '/tree/main'); // a page to go up to: not for an arrow
435+ T.arrow('ArrowRight');
436+ await answer('/rows/P/', 'a');
437+ T.arrow('ArrowRight');
438+ T.arrow('ArrowLeft'); // a: to its directory
439+ assert.equal(cursorName(body), 'P/');
440+ T.arrow('ArrowLeft'); // P/ is open: closes
441+ assert.deepEqual(shown(body), ['P/', 'f']);
442+ T.arrow('ArrowLeft'); // closed and top level: nothing
443+ assert.equal(cursorName(body), 'P/');
444+ assert.equal(nav(), '', 'the arrow does not leave the page; h does');
445+ T.key(false);
446+ assert.equal(nav(), '/tree/main');
447+});
448+
449+test('l opens a file where Right does not', () => {
450+ pending.clear();
451+ globalThis.location.href = '';
452+ const body = table('f');
453+ T.arrow('ArrowRight');
454+ assert.equal(nav(), '');
455+ T.key(true);
456+ assert.equal(nav(), '/blob/f');
457+ assert.ok(body);
458+});
459+
460+test('the tree takes no arrow key while the finder shows its results, nor any other key', () => {
461+ const body = table('a', 'b');
462+ body.removeAttribute('data-rows');
463+ assert.equal(T.arrow('ArrowDown'), false);
464+ assert.equal(cursorName(body), 'a');
465+ body.setAttribute('data-rows', '');
466+ assert.equal(T.arrow('x'), false);
467+ assert.equal(T.arrow('PageDown'), false);
468+});
M internal/adapters/web/overlays.templ +4 -3
@@ -13,10 +13,11 @@ templ OverlayHelp(mr bool) {
1313 <div><dt><kbd>ctrl k</kbd> <kbd>:</kbd></dt><dd>go to a section, branch, tag, file or repository</dd></div>
1414 <div><dt><kbd>?</kbd></dt><dd>this list</dd></div>
1515 <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div>
16- <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div>
17- <div><dt><kbd>l</kbd> <kbd>h</kbd></dt><dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd></div>
16+ <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor; on Files, a directory opens or closes in place</dd></div>
17+ <div><dt><kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> <kbd>home</kbd> <kbd>end</kbd></dt><dd>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</dd></div>
18+ <div><dt><kbd>l</kbd> <kbd>h</kbd></dt><dd>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</dd></div>
1819 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
19- <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file in the repository</dd></div>
20+ <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
2021 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
2122 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>
2223 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
@@ -13,10 +13,11 @@ templ OverlayHelp(mr bool) {
13 <div><dt><kbd>ctrl k</kbd> <kbd>:</kbd></dt><dd>go to a section, branch, tag, file or repository</dd></div>13 <div><dt><kbd>ctrl k</kbd> <kbd>:</kbd></dt><dd>go to a section, branch, tag, file or repository</dd></div>
14 <div><dt><kbd>?</kbd></dt><dd>this list</dd></div>14 <div><dt><kbd>?</kbd></dt><dd>this list</dd></div>
15 <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div>15 <div><dt><kbd>j</kbd> <kbd>k</kbd></dt><dd>move the row cursor down / up</dd></div>
16- <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor</dd></div>16+ <div><dt><kbd>enter</kbd></dt><dd>open the row under the cursor; on Files, a directory opens or closes in place</dd></div>
17- <div><dt><kbd>l</kbd> <kbd>h</kbd></dt><dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd></div>17+ <div><dt><kbd>↑</kbd> <kbd>↓</kbd> <kbd>←</kbd> <kbd>→</kbd> <kbd>home</kbd> <kbd>end</kbd></dt><dd>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</dd></div>
18+ <div><dt><kbd>l</kbd> <kbd>h</kbd></dt><dd>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</dd></div>
18 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>19 <div><dt><kbd>g g</kbd> <kbd>G</kbd></dt><dd>first / last row</dd></div>
19- <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file in the repository</dd></div>20+ <div><dt><kbd>/</kbd></dt><dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd></div>
20 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>21 <div><dt><kbd>b</kbd></dt><dd>{ "switch" } branch or tag</dd></div>
21 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>22 <div><dt><kbd>s</kbd> <kbd>t</kbd> <kbd>c</kbd> <kbd>r</kbd> <kbd>m</kbd></dt><dd>tabs: summary, files, commits, branches &amp; tags, merge requests</dd></div>
22 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>23 <div><dt><kbd>g h</kbd></dt><dd>home</dd></div>
M internal/adapters/web/overlays_templ.go +10 -10 generated file: collapsed
M internal/adapters/web/static/css/30-list.css +95 -13
@@ -70,17 +70,6 @@
7070 & .repo-meta time { color: var(--ink-dim); }
7171 & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); }
7272 }
73- .list.tree {
74- & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; }
75- /* --depth is set by files.js on the rows opened in place */
76- & .c-name { width: 32%; font-family: var(--mono); font-size: var(--fs-s); padding-inline-start: calc(var(--s3) + var(--depth, 0) * 1.25ch); }
77- & tr[aria-busy=true] .c-kind { opacity: .4; }
78- & .tree-more td { padding-inline-start: calc(var(--s3) + 36px + var(--depth, 0) * 1.25ch); color: var(--ink-dim); font-size: var(--fs-s); }
79- & .c-size { width: 90px; text-align: right; }
80- & .c-when { width: 100px; }
81- & .c-sub { width: auto; }
82- & .c-who { width: 130px; }
83- }
8473 .list.passkeys {
8574 & .c-name { width: auto; font-weight: 600; }
8675 & .c-when { width: 120px; font-size: var(--fs-s); }
@@ -125,8 +114,101 @@
125114
126115 /* file finder: the results replace the tree while the field has a query */
127116 #finder {
128- margin-top: var(--s3);
129- & #finder-status { margin-bottom: var(--s2); font-size: var(--fs-s); }
117+ & #finder-status { padding: var(--s3) var(--s4) var(--s2); font-size: var(--fs-s); }
118+ & #finder-list { padding: 0 var(--s2) var(--s2); }
119+ }
120+
121+ /* the Files view: the finder on top, the tree in a card under it */
122+ .tree-find {
123+ display: flex; align-items: center; gap: var(--s4); margin-bottom: var(--s3);
124+ & .filter { flex: 1; max-width: none; }
125+ & .filter::before { top: 14px; left: 14px; }
126+ & .filter::after { right: 12px; }
127+ & .filter input { height: 44px; padding-left: 42px; font-size: var(--fs); background: var(--bg-surface); }
128+ }
129+ .tree-keys { flex: none; color: var(--ink-mute); font-size: var(--fs-xs); white-space: nowrap; }
130+ .tree-card {
131+ background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden;
132+ }
133+ .tree-head {
134+ display: flex; align-items: center; gap: var(--s3); min-height: 40px; padding: 0 var(--s4);
135+ background: var(--bg-raised); border-bottom: 1px solid var(--border); font-size: var(--fs-s);
136+ & .tree-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 var(--fs-s) / 1 var(--mono); color: var(--ink-hi); }
137+ & .tree-up { flex: none; font: 600 var(--fs-s) / 1 var(--mono); color: var(--ink-dim); text-decoration: none; }
138+ & .tree-up:hover { color: var(--ink-hi); text-decoration: underline; }
139+ & .tree-count { flex: none; color: var(--ink-mute); }
140+ }
141+ .tree-card > :is(.lede, .note) { margin: var(--s3) var(--s4); }
142+
143+ /* The tree: one flat list, a row per entry, each as deep as --depth says
144+ (files.js sets it on the rows it opens in place). A row draws, from the
145+ left: a vertical guide per level above it, the branch tick to its node,
146+ the node, its name; then size, last commit and date, which are secondary. */
147+ ul.tree {
148+ --indent: 22px; --rowh: 32px; --guide: var(--border-strong);
149+ & > li {
150+ display: grid; grid-template-columns: minmax(0, 1fr) 72px minmax(0, 1.2fr) 96px; align-items: center;
151+ min-height: var(--rowh); padding-right: var(--s4); font-size: var(--fs-s);
152+ }
153+ & > li:hover, & > li[data-cursor] { background: var(--bg-raised); }
154+ & > li[data-cursor] { box-shadow: inset 3px 0 0 var(--ink-hi); }
155+ & > li:focus-visible { outline-offset: -2px; border-radius: 0; }
156+ & .t-main { display: flex; align-items: stretch; min-width: 0; min-height: var(--rowh); padding-left: var(--s3); }
157+ & .t-twig {
158+ flex: none; width: calc(var(--depth, 0) * var(--indent));
159+ background: repeating-linear-gradient(90deg,
160+ transparent 0 calc(var(--indent) / 2), var(--guide) calc(var(--indent) / 2) calc(var(--indent) / 2 + 1px),
161+ transparent calc(var(--indent) / 2 + 1px) var(--indent));
162+ }
163+ & .t-node { position: relative; flex: none; width: var(--indent); }
164+ & .t-node::before, & .t-node::after { content: ""; position: absolute; }
165+ /* the branch tick, from the guide of the directory to this node */
166+ & .t-node::before { top: 50%; left: calc(var(--indent) / -2); right: calc(50% + 6px); height: 1px; background: var(--guide); }
167+ & > li[data-depth="0"] .t-node::before { display: none; }
168+ /* an open directory continues its guide down, to its first entry */
169+ & > li[aria-expanded="true"] .t-node {
170+ background: linear-gradient(var(--guide), var(--guide)) center bottom / 1px calc(50% - 8px) no-repeat;
171+ }
172+ /* the node: a ring for a file, a chevron for a directory (it turns down
173+ once open), a diamond for a link, a square for a submodule */
174+ & .t-node::after {
175+ top: 50%; left: 50%; width: 7px; height: 7px; margin: -4px 0 0 -4px;
176+ background: var(--bg-surface); border: 1px solid var(--ink-mute); border-radius: 50%;
177+ }
178+ & > li:hover .t-node::after, & > li[data-cursor] .t-node::after { background: var(--bg-raised); border-color: var(--ink-hi); }
179+ & > li[data-kind="dir"] .t-node::after {
180+ width: 16px; height: 16px; margin: -8px 0 0 -8px; border: 0; border-radius: 0; background: var(--ink-dim);
181+ mask: url(icons/chevron.svg) center / contain no-repeat; transform: rotate(-90deg);
182+ }
183+ & > li[data-kind="dir"][aria-expanded="true"] .t-node::after { transform: none; background: var(--ink-hi); }
184+ & > li[data-kind="link"] .t-node::after { border-radius: 1px; transform: rotate(45deg); }
185+ & > li[data-kind="module"] .t-node::after { border-radius: 1px; }
186+ & > li[aria-busy="true"] .t-node { opacity: .4; }
187+ & .t-name {
188+ align-self: center; min-width: 0; padding: 4px var(--s1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
189+ font-family: var(--mono); font-size: var(--fs); color: var(--ink); text-decoration: none; cursor: pointer;
190+ }
191+ & > li[data-kind="dir"] .t-name { font-weight: 700; color: var(--ink-hi); }
192+ & > li[data-kind="link"] .t-name, & > li[data-kind="module"] .t-name { color: var(--ink-dim); cursor: default; }
193+ & > li:hover .t-name, & > li[data-cursor] .t-name { text-decoration: underline; }
194+ & .t-size { padding-right: var(--s4); text-align: right; font: var(--fs-xs) / 1 var(--mono); color: var(--ink-mute); font-variant-numeric: tabular-nums; }
195+ & .t-sub { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-dim); }
196+ & .t-sub a { color: inherit; text-decoration: none; }
197+ & .t-sub a:hover { color: var(--ink-hi); text-decoration: underline; }
198+ & .t-when { text-align: right; white-space: nowrap; color: var(--ink-mute); font-size: var(--fs-xs); }
199+ & > li.tree-more { grid-template-columns: minmax(0, 1fr); }
200+ & > li.tree-more .t-name { font-size: var(--fs-s); font-weight: 400; color: var(--ink-dim); text-decoration: underline; }
201+ & > li.tree-more .t-node::after { display: none; }
202+ }
203+ @media (max-width: 900px) {
204+ ul.tree > li { grid-template-columns: minmax(0, 1fr) 64px 80px; }
205+ ul.tree .t-sub { display: none; }
206+ }
207+ @media (max-width: 720px) {
208+ .tree-keys { display: none; }
209+ ul.tree { --indent: 18px; }
210+ ul.tree > li { grid-template-columns: minmax(0, 1fr) 72px; }
211+ ul.tree .t-size { display: none; }
130212 }
131213
132214 /* section headings inside a page */
@@ -70,17 +70,6 @@
70 & .repo-meta time { color: var(--ink-dim); }70 & .repo-meta time { color: var(--ink-dim); }
71 & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); }71 & .repo-branch { display: inline-flex; align-items: center; gap: 6px; font-family: var(--mono); color: var(--ink-dim); }
72 }72 }
73- .list.tree {
74- & .c-kind { width: 36px; color: var(--ink-mute); padding-right: 0; text-align: center; }
75- /* --depth is set by files.js on the rows opened in place */
76- & .c-name { width: 32%; font-family: var(--mono); font-size: var(--fs-s); padding-inline-start: calc(var(--s3) + var(--depth, 0) * 1.25ch); }
77- & tr[aria-busy=true] .c-kind { opacity: .4; }
78- & .tree-more td { padding-inline-start: calc(var(--s3) + 36px + var(--depth, 0) * 1.25ch); color: var(--ink-dim); font-size: var(--fs-s); }
79- & .c-size { width: 90px; text-align: right; }
80- & .c-when { width: 100px; }
81- & .c-sub { width: auto; }
82- & .c-who { width: 130px; }
83- }
84 .list.passkeys {73 .list.passkeys {
85 & .c-name { width: auto; font-weight: 600; }74 & .c-name { width: auto; font-weight: 600; }
86 & .c-when { width: 120px; font-size: var(--fs-s); }75 & .c-when { width: 120px; font-size: var(--fs-s); }
@@ -125,8 +114,101 @@
125 114
126 /* file finder: the results replace the tree while the field has a query */115 /* file finder: the results replace the tree while the field has a query */
127 #finder {116 #finder {
128- margin-top: var(--s3);117+ & #finder-status { padding: var(--s3) var(--s4) var(--s2); font-size: var(--fs-s); }
129- & #finder-status { margin-bottom: var(--s2); font-size: var(--fs-s); }118+ & #finder-list { padding: 0 var(--s2) var(--s2); }
119+ }
120+
121+ /* the Files view: the finder on top, the tree in a card under it */
122+ .tree-find {
123+ display: flex; align-items: center; gap: var(--s4); margin-bottom: var(--s3);
124+ & .filter { flex: 1; max-width: none; }
125+ & .filter::before { top: 14px; left: 14px; }
126+ & .filter::after { right: 12px; }
127+ & .filter input { height: 44px; padding-left: 42px; font-size: var(--fs); background: var(--bg-surface); }
128+ }
129+ .tree-keys { flex: none; color: var(--ink-mute); font-size: var(--fs-xs); white-space: nowrap; }
130+ .tree-card {
131+ background: var(--bg-surface); border: 1px solid var(--border); border-radius: var(--r); overflow: hidden;
132+ }
133+ .tree-head {
134+ display: flex; align-items: center; gap: var(--s3); min-height: 40px; padding: 0 var(--s4);
135+ background: var(--bg-raised); border-bottom: 1px solid var(--border); font-size: var(--fs-s);
136+ & .tree-path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: 700 var(--fs-s) / 1 var(--mono); color: var(--ink-hi); }
137+ & .tree-up { flex: none; font: 600 var(--fs-s) / 1 var(--mono); color: var(--ink-dim); text-decoration: none; }
138+ & .tree-up:hover { color: var(--ink-hi); text-decoration: underline; }
139+ & .tree-count { flex: none; color: var(--ink-mute); }
140+ }
141+ .tree-card > :is(.lede, .note) { margin: var(--s3) var(--s4); }
142+
143+ /* The tree: one flat list, a row per entry, each as deep as --depth says
144+ (files.js sets it on the rows it opens in place). A row draws, from the
145+ left: a vertical guide per level above it, the branch tick to its node,
146+ the node, its name; then size, last commit and date, which are secondary. */
147+ ul.tree {
148+ --indent: 22px; --rowh: 32px; --guide: var(--border-strong);
149+ & > li {
150+ display: grid; grid-template-columns: minmax(0, 1fr) 72px minmax(0, 1.2fr) 96px; align-items: center;
151+ min-height: var(--rowh); padding-right: var(--s4); font-size: var(--fs-s);
152+ }
153+ & > li:hover, & > li[data-cursor] { background: var(--bg-raised); }
154+ & > li[data-cursor] { box-shadow: inset 3px 0 0 var(--ink-hi); }
155+ & > li:focus-visible { outline-offset: -2px; border-radius: 0; }
156+ & .t-main { display: flex; align-items: stretch; min-width: 0; min-height: var(--rowh); padding-left: var(--s3); }
157+ & .t-twig {
158+ flex: none; width: calc(var(--depth, 0) * var(--indent));
159+ background: repeating-linear-gradient(90deg,
160+ transparent 0 calc(var(--indent) / 2), var(--guide) calc(var(--indent) / 2) calc(var(--indent) / 2 + 1px),
161+ transparent calc(var(--indent) / 2 + 1px) var(--indent));
162+ }
163+ & .t-node { position: relative; flex: none; width: var(--indent); }
164+ & .t-node::before, & .t-node::after { content: ""; position: absolute; }
165+ /* the branch tick, from the guide of the directory to this node */
166+ & .t-node::before { top: 50%; left: calc(var(--indent) / -2); right: calc(50% + 6px); height: 1px; background: var(--guide); }
167+ & > li[data-depth="0"] .t-node::before { display: none; }
168+ /* an open directory continues its guide down, to its first entry */
169+ & > li[aria-expanded="true"] .t-node {
170+ background: linear-gradient(var(--guide), var(--guide)) center bottom / 1px calc(50% - 8px) no-repeat;
171+ }
172+ /* the node: a ring for a file, a chevron for a directory (it turns down
173+ once open), a diamond for a link, a square for a submodule */
174+ & .t-node::after {
175+ top: 50%; left: 50%; width: 7px; height: 7px; margin: -4px 0 0 -4px;
176+ background: var(--bg-surface); border: 1px solid var(--ink-mute); border-radius: 50%;
177+ }
178+ & > li:hover .t-node::after, & > li[data-cursor] .t-node::after { background: var(--bg-raised); border-color: var(--ink-hi); }
179+ & > li[data-kind="dir"] .t-node::after {
180+ width: 16px; height: 16px; margin: -8px 0 0 -8px; border: 0; border-radius: 0; background: var(--ink-dim);
181+ mask: url(icons/chevron.svg) center / contain no-repeat; transform: rotate(-90deg);
182+ }
183+ & > li[data-kind="dir"][aria-expanded="true"] .t-node::after { transform: none; background: var(--ink-hi); }
184+ & > li[data-kind="link"] .t-node::after { border-radius: 1px; transform: rotate(45deg); }
185+ & > li[data-kind="module"] .t-node::after { border-radius: 1px; }
186+ & > li[aria-busy="true"] .t-node { opacity: .4; }
187+ & .t-name {
188+ align-self: center; min-width: 0; padding: 4px var(--s1); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
189+ font-family: var(--mono); font-size: var(--fs); color: var(--ink); text-decoration: none; cursor: pointer;
190+ }
191+ & > li[data-kind="dir"] .t-name { font-weight: 700; color: var(--ink-hi); }
192+ & > li[data-kind="link"] .t-name, & > li[data-kind="module"] .t-name { color: var(--ink-dim); cursor: default; }
193+ & > li:hover .t-name, & > li[data-cursor] .t-name { text-decoration: underline; }
194+ & .t-size { padding-right: var(--s4); text-align: right; font: var(--fs-xs) / 1 var(--mono); color: var(--ink-mute); font-variant-numeric: tabular-nums; }
195+ & .t-sub { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink-dim); }
196+ & .t-sub a { color: inherit; text-decoration: none; }
197+ & .t-sub a:hover { color: var(--ink-hi); text-decoration: underline; }
198+ & .t-when { text-align: right; white-space: nowrap; color: var(--ink-mute); font-size: var(--fs-xs); }
199+ & > li.tree-more { grid-template-columns: minmax(0, 1fr); }
200+ & > li.tree-more .t-name { font-size: var(--fs-s); font-weight: 400; color: var(--ink-dim); text-decoration: underline; }
201+ & > li.tree-more .t-node::after { display: none; }
202+ }
203+ @media (max-width: 900px) {
204+ ul.tree > li { grid-template-columns: minmax(0, 1fr) 64px 80px; }
205+ ul.tree .t-sub { display: none; }
206+ }
207+ @media (max-width: 720px) {
208+ .tree-keys { display: none; }
209+ ul.tree { --indent: 18px; }
210+ ul.tree > li { grid-template-columns: minmax(0, 1fr) 72px; }
211+ ul.tree .t-size { display: none; }
130 }212 }
131 213
132 /* section headings inside a page */214 /* section headings inside a page */
M internal/adapters/web/static/css/50-code.css +1 -1
@@ -170,7 +170,7 @@
170170 .rail section > p { overflow-wrap: anywhere; }
171171 @media (max-width: 700px) {
172172 /* the name takes what the fixed columns leave */
173- .list.tree .c-name, .list.refs .c-name { width: auto; }
173+ .list.refs .c-name { width: auto; }
174174 }
175175 }
176176
@@ -170,7 +170,7 @@
170 .rail section > p { overflow-wrap: anywhere; }170 .rail section > p { overflow-wrap: anywhere; }
171 @media (max-width: 700px) {171 @media (max-width: 700px) {
172 /* the name takes what the fixed columns leave */172 /* the name takes what the fixed columns leave */
173- .list.tree .c-name, .list.refs .c-name { width: auto; }173+ .list.refs .c-name { width: auto; }
174 }174 }
175 }175 }
176 176
M internal/adapters/web/static/js/ermite.js +13 -4
@@ -62,6 +62,7 @@
6262 const j = Math.max(0, Math.min(rows.length - 1, i + d));
6363 setCursor(rows[j], $$('[data-rows] > [data-cursor]'));
6464 rows[j].scrollIntoView({ block: 'nearest' });
65+ window.ermiteTree?.sync(rows[j]);
6566 const mark = j + 1 + '/' + rows.length;
6667 body().dataset.nav = mark;
6768 setTimeout(() => body().dataset.nav === mark && (body().dataset.nav = ''), 1500);
@@ -73,15 +74,19 @@
7374 if (!row) return scrollPage(last ? 1e9 : 0);
7475 setCursor(row, $$('[data-rows] > [data-cursor]'));
7576 row.scrollIntoView({ block: 'nearest' });
77+ window.ermiteTree?.sync(row);
7678 },
7779 open() {
78- if (!$('dialog[open]')) $('[data-cursor] a')?.click();
80+ if ($('dialog[open]') || window.ermiteTree?.open()) return;
81+ $('[data-cursor] a')?.click();
7982 },
80- // l / h: into the side panel (the current file of a merge request, else
81- // its main action, else its first control) and back to the content.
82- // Enter then activates the control.
83+ // l / h: in the Files view, open a directory or a file / close a directory
84+ // or go up. Elsewhere: into the side panel (the current file of a merge
85+ // request, else its main action, else its first control) and back to the
86+ // content. Enter then activates the control.
8387 rail(into) {
8488 if ($('dialog[open]')) return;
89+ if (window.ermiteTree?.key(into)) return;
8590 if (into && window.ermiteMR?.focusFiles()) return flash('files · j k move · enter open · h back');
8691 if (!into && window.ermiteMR?.leaveFiles()) return;
8792 const msg = into ? window.ermiteRail.enter() : window.ermiteRail.leave();
@@ -425,6 +430,10 @@
425430 return;
426431 }
427432 if (e.key === 'g') { pending = 1; clearTimeout(pendingTimer); pendingTimer = setTimeout(() => (pending = 0), 1200); return; }
433+ // The arrows and Home / End are the tree's only while the focus is on one of
434+ // its rows (they scroll, or move the focus, anywhere else); j k l h are
435+ // page-level keys, as on the other lists.
436+ if (!e.shiftKey && e.target.closest?.('#tree-rows') && window.ermiteTree?.arrow(e.key)) { e.preventDefault(); return; }
428437 const name = single[e.key];
429438 if (!name) return;
430439 if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault();
@@ -62,6 +62,7 @@
62 const j = Math.max(0, Math.min(rows.length - 1, i + d));62 const j = Math.max(0, Math.min(rows.length - 1, i + d));
63 setCursor(rows[j], $$('[data-rows] > [data-cursor]'));63 setCursor(rows[j], $$('[data-rows] > [data-cursor]'));
64 rows[j].scrollIntoView({ block: 'nearest' });64 rows[j].scrollIntoView({ block: 'nearest' });
65+ window.ermiteTree?.sync(rows[j]);
65 const mark = j + 1 + '/' + rows.length;66 const mark = j + 1 + '/' + rows.length;
66 body().dataset.nav = mark;67 body().dataset.nav = mark;
67 setTimeout(() => body().dataset.nav === mark && (body().dataset.nav = ''), 1500);68 setTimeout(() => body().dataset.nav === mark && (body().dataset.nav = ''), 1500);
@@ -73,15 +74,19 @@
73 if (!row) return scrollPage(last ? 1e9 : 0);74 if (!row) return scrollPage(last ? 1e9 : 0);
74 setCursor(row, $$('[data-rows] > [data-cursor]'));75 setCursor(row, $$('[data-rows] > [data-cursor]'));
75 row.scrollIntoView({ block: 'nearest' });76 row.scrollIntoView({ block: 'nearest' });
77+ window.ermiteTree?.sync(row);
76 },78 },
77 open() {79 open() {
78- if (!$('dialog[open]')) $('[data-cursor] a')?.click();80+ if ($('dialog[open]') || window.ermiteTree?.open()) return;
81+ $('[data-cursor] a')?.click();
79 },82 },
80- // l / h: into the side panel (the current file of a merge request, else83+ // l / h: in the Files view, open a directory or a file / close a directory
81- // its main action, else its first control) and back to the content.84+ // or go up. Elsewhere: into the side panel (the current file of a merge
82- // Enter then activates the control.85+ // request, else its main action, else its first control) and back to the
86+ // content. Enter then activates the control.
83 rail(into) {87 rail(into) {
84 if ($('dialog[open]')) return;88 if ($('dialog[open]')) return;
89+ if (window.ermiteTree?.key(into)) return;
85 if (into && window.ermiteMR?.focusFiles()) return flash('files · j k move · enter open · h back');90 if (into && window.ermiteMR?.focusFiles()) return flash('files · j k move · enter open · h back');
86 if (!into && window.ermiteMR?.leaveFiles()) return;91 if (!into && window.ermiteMR?.leaveFiles()) return;
87 const msg = into ? window.ermiteRail.enter() : window.ermiteRail.leave();92 const msg = into ? window.ermiteRail.enter() : window.ermiteRail.leave();
@@ -425,6 +430,10 @@
425 return;430 return;
426 }431 }
427 if (e.key === 'g') { pending = 1; clearTimeout(pendingTimer); pendingTimer = setTimeout(() => (pending = 0), 1200); return; }432 if (e.key === 'g') { pending = 1; clearTimeout(pendingTimer); pendingTimer = setTimeout(() => (pending = 0), 1200); return; }
433+ // The arrows and Home / End are the tree's only while the focus is on one of
434+ // its rows (they scroll, or move the focus, anywhere else); j k l h are
435+ // page-level keys, as on the other lists.
436+ if (!e.shiftKey && e.target.closest?.('#tree-rows') && window.ermiteTree?.arrow(e.key)) { e.preventDefault(); return; }
428 const name = single[e.key];437 const name = single[e.key];
429 if (!name) return;438 if (!name) return;
430 if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault();439 if (e.key !== 'Enter' || !$('dialog[open]')) e.preventDefault();
M internal/adapters/web/static/js/files.js +224 -59
@@ -1,99 +1,264 @@
1-// files.js: the Files view. A directory opens in place: its entries are
2-// fetched once as table rows (the tree page with ?rows), inserted under it
3-// and kept in the DOM, so closing and opening it again needs no request.
1+// files.js: the Files view. The entries of a directory are the rows of a tree
2+// (li, flat in one ul, each with its depth). A directory opens in place: its
3+// entries are fetched once as rows (the tree page with ?rows), inserted under
4+// it and kept in the DOM, so closing and opening it again needs no request.
45 //
5-// Rows opened in place carry data-depth and the --depth custom property
6-// (set through the CSSOM: the CSP forbids style attributes). Rows of the
7-// page itself have depth 0. Nothing here touches the document at load; the
8-// handlers are declared in the templates (hx-on:click on #tree-rows).
6+// Rows opened in place carry data-depth, aria-level and the --depth custom
7+// property (set through the CSSOM: the CSP forbids style attributes), which
8+// the stylesheet turns into indentation guides. Rows of the page itself have
9+// depth 0.
10+//
11+// Focus model: a roving tabindex on the rows. The row under the cursor
12+// (data-cursor) is the only one in the tab order (tabindex 0) and it has the
13+// DOM focus whenever the keyboard or a click moved the cursor, so j k l h, the
14+// arrows, Enter and Tab all act on the row that is highlighted. The name link
15+// of a row is out of the tab order (the row stands for it: Enter opens it);
16+// the link to the last commit is tabbable only in the row under the cursor
17+// (Tab from the row reaches it, the next Tab leaves the tree). Without
18+// scripts nothing is taken out of the tab order. The handlers are declared in
19+// the templates (hx-on:click on #tree-rows); init() runs once at load (a page
20+// without a tree: nothing); ermite.js calls ermiteTree.key and open for h, l
21+// and Enter, and ermiteTree.sync for the cursor moves of j k gg G.
922 window.ermiteTree = (() => {
10- const depthOf = (tr) => Number(tr.dataset.depth || 0);
11- const linkOf = (tr) => tr.querySelector('a[data-expand]');
12- const isOpen = (a) => a.getAttribute('aria-expanded') === 'true';
23+ const depthOf = (li) => Number(li.dataset.depth || 0);
24+ const isDir = (li) => li.hasAttribute('data-expand');
25+ const isOpen = (li) => li.getAttribute('aria-expanded') === 'true';
1326 // A directory is loaded when rows deeper than it follow it.
14- const isLoaded = (tr) => { const n = tr.nextElementSibling; return !!n && depthOf(n) > depthOf(tr); };
27+ const isLoaded = (li) => { const n = li.nextElementSibling; return !!n && depthOf(n) > depthOf(li); };
28+ const nameLink = (li) => li.querySelector('a.t-name');
29+
30+ // tab puts a row in or out of the tab order, with its link to the last commit.
31+ function tab(li, on) {
32+ li.setAttribute('tabindex', on ? '0' : '-1');
33+ for (const a of li.querySelectorAll('a.t-commit')) a.setAttribute('tabindex', on ? '0' : '-1');
34+ }
35+
36+ // prep takes a row out of the tab order: the one under the cursor comes back.
37+ function prep(li) {
38+ tab(li, false);
39+ nameLink(li)?.setAttribute('tabindex', '-1');
40+ }
41+
42+ // mark makes li the row under the cursor, the one stop of the tab order,
43+ // and with focus gives it the DOM focus (without scrolling: the caller does).
44+ function mark(li, focus) {
45+ for (const r of Array.from(li.parentElement.children)) {
46+ if (r === li || !(r.hasAttribute('data-cursor') || r.getAttribute('tabindex') === '0')) continue;
47+ r.removeAttribute('data-cursor');
48+ tab(r, false);
49+ }
50+ li.setAttribute('data-cursor', '');
51+ tab(li, true);
52+ if (focus) li.focus({ preventScroll: true });
53+ }
1554
16- function setOpen(tr, a, open) {
17- a.setAttribute('aria-expanded', open ? 'true' : 'false');
18- const kind = tr.querySelector('.c-kind');
19- if (kind) kind.textContent = open ? '▾' : '▸';
55+ // setCursor moves the cursor, and so the focus, to li, and shows it.
56+ function setCursor(li) {
57+ mark(li, true);
58+ li.scrollIntoView({ block: 'nearest' });
2059 }
2160
22- // collapse hides every row under tr. The cursor of a row it hides moves to
23- // tr, so it never rests on a row that cannot be seen.
24- function collapse(tr, a) {
25- setOpen(tr, a, false);
26- const d = depthOf(tr);
27- for (let r = tr.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {
61+ // collapse hides every row under li. The cursor, and the focus, of a row it
62+ // hides move to li, so they never rest on a row that cannot be seen.
63+ function collapse(li) {
64+ li.setAttribute('aria-expanded', 'false');
65+ const d = depthOf(li);
66+ let lostCursor = false;
67+ let lostFocus = false;
68+ for (let r = li.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {
2869 r.hidden = true;
29- if (r.hasAttribute('data-cursor')) {
30- r.removeAttribute('data-cursor');
31- tr.setAttribute('data-cursor', '');
32- }
70+ if (r.hasAttribute('data-cursor')) lostCursor = true;
71+ if (r.contains(document.activeElement)) lostFocus = true;
3372 }
73+ if (lostCursor || lostFocus) mark(li, lostFocus);
3474 }
3575
36- // expand shows the rows under tr that no collapsed directory between hides.
37- function expand(tr, a) {
38- setOpen(tr, a, true);
39- const d = depthOf(tr);
76+ // expand shows the rows under li that no collapsed directory between hides.
77+ function expand(li) {
78+ li.setAttribute('aria-expanded', 'true');
79+ const d = depthOf(li);
4080 let closedAt = Infinity; // depth of the collapsed directory whose rows are skipped
41- for (let r = tr.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {
81+ for (let r = li.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {
4282 const rd = depthOf(r);
4383 if (rd <= closedAt) closedAt = Infinity;
4484 r.hidden = rd > closedAt;
45- const la = linkOf(r);
46- if (closedAt === Infinity && la && !isOpen(la)) closedAt = rd;
85+ if (closedAt === Infinity && isDir(r) && !isOpen(r)) closedAt = rd;
4786 }
4887 }
4988
50- async function load(tr, a) {
51- tr.setAttribute('aria-busy', 'true');
89+ async function load(li) {
90+ li.setAttribute('aria-busy', 'true');
5291 try {
53- const res = await fetch(a.dataset.expand, { headers: { Accept: 'text/html' } });
92+ const res = await fetch(li.dataset.expand, { headers: { Accept: 'text/html' } });
5493 if (!res.ok) throw new Error('HTTP ' + res.status);
5594 const t = document.createElement('template');
5695 t.innerHTML = await res.text();
57- const d = depthOf(tr) + 1;
58- const rows = Array.from(t.content.children).filter((r) => r.tagName === 'TR');
96+ const d = depthOf(li) + 1;
97+ const rows = Array.from(t.content.children).filter((r) => r.tagName === 'LI');
5998 if (!rows.length) throw new Error('no rows');
60- // An ancestor closed while the request was out: tr is hidden, and its
99+ // An ancestor closed while the request was out: li is hidden, and its
61100 // rows come in hidden, for the expand of that ancestor to show.
62- const covered = tr.hidden;
101+ const covered = li.hidden;
63102 for (const r of rows) {
64103 r.dataset.depth = String(d);
104+ r.setAttribute('aria-level', String(d + 1));
65105 r.style.setProperty('--depth', String(d));
106+ prep(r);
66107 if (covered) r.hidden = true;
67108 }
68- tr.after(...rows);
69- tr.removeAttribute('aria-busy');
70- if (covered) setOpen(tr, a, true);
71- else expand(tr, a);
109+ li.after(...rows);
110+ li.removeAttribute('aria-busy');
111+ if (covered) li.setAttribute('aria-expanded', 'true');
112+ else expand(li);
72113 } catch (e) {
73- location.href = a.href;
114+ location.href = nameLink(li).href;
74115 }
75116 }
76117
77- function toggle(tr) {
78- const a = linkOf(tr);
79- if (!a || tr.getAttribute('aria-busy') === 'true') return;
80- if (isOpen(a)) collapse(tr, a);
81- else if (isLoaded(tr)) expand(tr, a);
82- else load(tr, a);
118+ function toggle(li) {
119+ if (!isDir(li) || li.getAttribute('aria-busy') === 'true') return;
120+ if (isOpen(li)) collapse(li);
121+ else if (isLoaded(li)) expand(li);
122+ else load(li);
123+ }
124+
125+ const rowsEl = () => document.getElementById('tree-rows');
126+ const cursorRow = () => rowsEl()?.querySelector(':scope > [data-cursor]') ?? null;
127+ const flash = (m) => window.ermite?.flash?.(m);
128+
129+ // parentOf is the visible row of the directory that holds li: the closest
130+ // row above it that is less deep.
131+ function parentOf(li) {
132+ const d = depthOf(li);
133+ for (let r = li.previousElementSibling; r; r = r.previousElementSibling) if (depthOf(r) < d) return r;
134+ return null;
83135 }
84136
85137 return {
86- // click opens or closes the directory under a plain left click; any
87- // modified or other click keeps the link's own navigation.
138+ // click puts the cursor and the focus on the row under any click, and
139+ // opens or closes the directory under a plain left click on its name or its
140+ // node; any modified or other click, and a click on the last commit, keep
141+ // their own navigation.
88142 click(event) {
89- const a = event.target.closest('a[data-expand]');
90- if (!a || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
143+ const li = event.target.closest('li');
144+ if (!li || !li.parentElement) return;
145+ mark(li, true);
146+ if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
147+ if (!event.target.closest('.t-main') || !isDir(li)) return;
91148 event.preventDefault();
92- toggle(a.closest('tr'));
149+ toggle(li);
150+ },
151+
152+ // init takes the rows out of the tab order but the one under the cursor
153+ // (the first when none has it). It runs once the page has loaded, and
154+ // not at all without a tree.
155+ init() {
156+ const rows = typeof document === 'undefined' ? null : rowsEl();
157+ if (!rows) return;
158+ const lis = Array.from(rows.children);
159+ for (const li of lis) prep(li);
160+ const cur = cursorRow() ?? lis[0];
161+ if (cur) mark(cur, false);
162+ },
163+
164+ // sync follows a cursor that ermite.js moved (j k gg G): the focus goes
165+ // with it, and the tab stop.
166+ sync(li) {
167+ if (li.parentElement && li.parentElement.id === 'tree-rows') mark(li, true);
168+ },
169+
170+ // mark is for the finder, which takes the cursor away and gives it back.
171+ mark(li) { mark(li, false); },
172+
173+ // active says that h and l (and the arrows) walk the tree: it is shown,
174+ // not replaced by the results of the finder.
175+ active() {
176+ const rows = rowsEl();
177+ return !!rows && rows.hasAttribute('data-rows');
178+ },
179+
180+ // open is Enter on the row under the cursor: a directory opens or closes
181+ // in place, a file opens. It answers whether the tree took the key.
182+ open() {
183+ const li = this.active() && cursorRow();
184+ if (!li) return false;
185+ if (isDir(li)) toggle(li);
186+ else if (nameLink(li)) nameLink(li).click();
187+ else flash('a submodule has no page here');
188+ return true;
189+ },
190+
191+ // arrow is a key of the tree pattern (WAI-ARIA APG Tree View) pressed with
192+ // the focus on a row: it only moves the focus or the expansion, and never
193+ // opens a file or leaves the page. It answers whether the key is one the
194+ // tree handles (the caller then keeps the browser from scrolling).
195+ // Down, Up: the next, the previous visible row. Home, End: the first, the last.
196+ // Right: a closed directory opens, an open one moves to its first entry, a
197+ // file does nothing.
198+ // Left: an open directory closes, any other row moves to its directory,
199+ // and a top-level row does nothing.
200+ arrow(key) {
201+ if (!this.active()) return false;
202+ const li = cursorRow();
203+ if (!li) return false;
204+ const rows = Array.from(li.parentElement.children).filter((r) => !r.hidden);
205+ const i = rows.indexOf(li);
206+ switch (key) {
207+ case 'ArrowDown': if (rows[i + 1]) setCursor(rows[i + 1]); break;
208+ case 'ArrowUp': if (i > 0) setCursor(rows[i - 1]); break;
209+ case 'Home': if (rows[0]) setCursor(rows[0]); break;
210+ case 'End': if (rows.length) setCursor(rows[rows.length - 1]); break;
211+ case 'ArrowRight':
212+ if (!isDir(li)) break;
213+ if (!isOpen(li)) toggle(li);
214+ else if (rows[i + 1] && depthOf(rows[i + 1]) > depthOf(li)) setCursor(rows[i + 1]);
215+ break;
216+ case 'ArrowLeft':
217+ if (isDir(li) && isOpen(li)) collapse(li);
218+ else if (parentOf(li)) setCursor(parentOf(li));
219+ break;
220+ default: return false;
221+ }
222+ return true;
223+ },
224+
225+ // key is l (into = true) or h on the row under the cursor, the Vim keys
226+ // with their richer behaviour; it answers whether the tree took the key.
227+ // l: a closed directory opens, an open one moves to its first entry, a
228+ // file is opened.
229+ // h: an open directory closes, any other row goes to its directory, and a
230+ // top-level row to the parent directory's page.
231+ key(into) {
232+ if (!this.active()) return false;
233+ const li = cursorRow();
234+ if (!li) return false;
235+ if (into) {
236+ if (isDir(li)) {
237+ if (!isOpen(li)) return toggle(li), true;
238+ const n = li.nextElementSibling;
239+ if (n && !n.hidden && depthOf(n) > depthOf(li)) setCursor(n);
240+ return true;
241+ }
242+ const a = nameLink(li);
243+ if (a) a.click();
244+ else flash('a submodule has no page here');
245+ return true;
246+ }
247+ if (isDir(li) && isOpen(li)) {
248+ collapse(li);
249+ return true;
250+ }
251+ const up = parentOf(li);
252+ if (up) setCursor(up);
253+ else if (rowsEl().dataset.up) location.href = rowsEl().dataset.up;
254+ else flash('top of the tree');
255+ return true;
93256 },
94257 };
95258 })();
96259
260+window.ermiteTree.init();
261+
97262 // ermiteFinder: the fuzzy finder of the Files view and the file hits of the
98263 // ":" palette. The core (index, search, positions, visible) is pure and runs
99264 // in Node; the DOM glue below it runs only from the handlers declared in the
@@ -522,9 +687,9 @@ window.ermiteFinder = (() => {
522687 rows.setAttribute('data-rows', '');
523688 const keep = treeCursor;
524689 treeCursor = null;
525- if (!rows.querySelector(':scope > tr[data-cursor]')) {
526- const row = keep && keep.parentElement === rows && !keep.hidden ? keep : rows.querySelector(':scope > tr:not([hidden])');
527- row?.setAttribute('data-cursor', '');
690+ if (!rows.querySelector(':scope > [data-cursor]')) {
691+ const row = keep && keep.parentElement === rows && !keep.hidden ? keep : rows.querySelector(':scope > li:not([hidden])');
692+ if (row) window.ermiteTree.mark(row);
528693 }
529694 }
530695
@@ -535,7 +700,7 @@ window.ermiteFinder = (() => {
535700 el.setAttribute('aria-expanded', 'true');
536701 const rows = $('tree-rows');
537702 rows.removeAttribute('data-rows');
538- rows.querySelectorAll(':scope > tr[data-cursor]').forEach((r) => {
703+ rows.querySelectorAll(':scope > [data-cursor]').forEach((r) => {
539704 treeCursor = r;
540705 r.removeAttribute('data-cursor');
541706 });
@@ -1,99 +1,264 @@
1-// files.js: the Files view. A directory opens in place: its entries are1+// files.js: the Files view. The entries of a directory are the rows of a tree
2-// fetched once as table rows (the tree page with ?rows), inserted under it2+// (li, flat in one ul, each with its depth). A directory opens in place: its
3-// and kept in the DOM, so closing and opening it again needs no request.3+// entries are fetched once as rows (the tree page with ?rows), inserted under
4+// it and kept in the DOM, so closing and opening it again needs no request.
4 //5 //
5-// Rows opened in place carry data-depth and the --depth custom property6+// Rows opened in place carry data-depth, aria-level and the --depth custom
6-// (set through the CSSOM: the CSP forbids style attributes). Rows of the7+// property (set through the CSSOM: the CSP forbids style attributes), which
7-// page itself have depth 0. Nothing here touches the document at load; the8+// the stylesheet turns into indentation guides. Rows of the page itself have
8-// handlers are declared in the templates (hx-on:click on #tree-rows).9+// depth 0.
10+//
11+// Focus model: a roving tabindex on the rows. The row under the cursor
12+// (data-cursor) is the only one in the tab order (tabindex 0) and it has the
13+// DOM focus whenever the keyboard or a click moved the cursor, so j k l h, the
14+// arrows, Enter and Tab all act on the row that is highlighted. The name link
15+// of a row is out of the tab order (the row stands for it: Enter opens it);
16+// the link to the last commit is tabbable only in the row under the cursor
17+// (Tab from the row reaches it, the next Tab leaves the tree). Without
18+// scripts nothing is taken out of the tab order. The handlers are declared in
19+// the templates (hx-on:click on #tree-rows); init() runs once at load (a page
20+// without a tree: nothing); ermite.js calls ermiteTree.key and open for h, l
21+// and Enter, and ermiteTree.sync for the cursor moves of j k gg G.
9 window.ermiteTree = (() => {22 window.ermiteTree = (() => {
10- const depthOf = (tr) => Number(tr.dataset.depth || 0);23+ const depthOf = (li) => Number(li.dataset.depth || 0);
11- const linkOf = (tr) => tr.querySelector('a[data-expand]');24+ const isDir = (li) => li.hasAttribute('data-expand');
12- const isOpen = (a) => a.getAttribute('aria-expanded') === 'true';25+ const isOpen = (li) => li.getAttribute('aria-expanded') === 'true';
13 // A directory is loaded when rows deeper than it follow it.26 // A directory is loaded when rows deeper than it follow it.
14- const isLoaded = (tr) => { const n = tr.nextElementSibling; return !!n && depthOf(n) > depthOf(tr); };27+ const isLoaded = (li) => { const n = li.nextElementSibling; return !!n && depthOf(n) > depthOf(li); };
28+ const nameLink = (li) => li.querySelector('a.t-name');
29+
30+ // tab puts a row in or out of the tab order, with its link to the last commit.
31+ function tab(li, on) {
32+ li.setAttribute('tabindex', on ? '0' : '-1');
33+ for (const a of li.querySelectorAll('a.t-commit')) a.setAttribute('tabindex', on ? '0' : '-1');
34+ }
35+
36+ // prep takes a row out of the tab order: the one under the cursor comes back.
37+ function prep(li) {
38+ tab(li, false);
39+ nameLink(li)?.setAttribute('tabindex', '-1');
40+ }
41+
42+ // mark makes li the row under the cursor, the one stop of the tab order,
43+ // and with focus gives it the DOM focus (without scrolling: the caller does).
44+ function mark(li, focus) {
45+ for (const r of Array.from(li.parentElement.children)) {
46+ if (r === li || !(r.hasAttribute('data-cursor') || r.getAttribute('tabindex') === '0')) continue;
47+ r.removeAttribute('data-cursor');
48+ tab(r, false);
49+ }
50+ li.setAttribute('data-cursor', '');
51+ tab(li, true);
52+ if (focus) li.focus({ preventScroll: true });
53+ }
15 54
16- function setOpen(tr, a, open) {55+ // setCursor moves the cursor, and so the focus, to li, and shows it.
17- a.setAttribute('aria-expanded', open ? 'true' : 'false');56+ function setCursor(li) {
18- const kind = tr.querySelector('.c-kind');57+ mark(li, true);
19- if (kind) kind.textContent = open ? '▾' : '▸';58+ li.scrollIntoView({ block: 'nearest' });
20 }59 }
21 60
22- // collapse hides every row under tr. The cursor of a row it hides moves to61+ // collapse hides every row under li. The cursor, and the focus, of a row it
23- // tr, so it never rests on a row that cannot be seen.62+ // hides move to li, so they never rest on a row that cannot be seen.
24- function collapse(tr, a) {63+ function collapse(li) {
25- setOpen(tr, a, false);64+ li.setAttribute('aria-expanded', 'false');
26- const d = depthOf(tr);65+ const d = depthOf(li);
27- for (let r = tr.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {66+ let lostCursor = false;
67+ let lostFocus = false;
68+ for (let r = li.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {
28 r.hidden = true;69 r.hidden = true;
29- if (r.hasAttribute('data-cursor')) {70+ if (r.hasAttribute('data-cursor')) lostCursor = true;
30- r.removeAttribute('data-cursor');71+ if (r.contains(document.activeElement)) lostFocus = true;
31- tr.setAttribute('data-cursor', '');
32- }
33 }72 }
73+ if (lostCursor || lostFocus) mark(li, lostFocus);
34 }74 }
35 75
36- // expand shows the rows under tr that no collapsed directory between hides.76+ // expand shows the rows under li that no collapsed directory between hides.
37- function expand(tr, a) {77+ function expand(li) {
38- setOpen(tr, a, true);78+ li.setAttribute('aria-expanded', 'true');
39- const d = depthOf(tr);79+ const d = depthOf(li);
40 let closedAt = Infinity; // depth of the collapsed directory whose rows are skipped80 let closedAt = Infinity; // depth of the collapsed directory whose rows are skipped
41- for (let r = tr.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {81+ for (let r = li.nextElementSibling; r && depthOf(r) > d; r = r.nextElementSibling) {
42 const rd = depthOf(r);82 const rd = depthOf(r);
43 if (rd <= closedAt) closedAt = Infinity;83 if (rd <= closedAt) closedAt = Infinity;
44 r.hidden = rd > closedAt;84 r.hidden = rd > closedAt;
45- const la = linkOf(r);85+ if (closedAt === Infinity && isDir(r) && !isOpen(r)) closedAt = rd;
46- if (closedAt === Infinity && la && !isOpen(la)) closedAt = rd;
47 }86 }
48 }87 }
49 88
50- async function load(tr, a) {89+ async function load(li) {
51- tr.setAttribute('aria-busy', 'true');90+ li.setAttribute('aria-busy', 'true');
52 try {91 try {
53- const res = await fetch(a.dataset.expand, { headers: { Accept: 'text/html' } });92+ const res = await fetch(li.dataset.expand, { headers: { Accept: 'text/html' } });
54 if (!res.ok) throw new Error('HTTP ' + res.status);93 if (!res.ok) throw new Error('HTTP ' + res.status);
55 const t = document.createElement('template');94 const t = document.createElement('template');
56 t.innerHTML = await res.text();95 t.innerHTML = await res.text();
57- const d = depthOf(tr) + 1;96+ const d = depthOf(li) + 1;
58- const rows = Array.from(t.content.children).filter((r) => r.tagName === 'TR');97+ const rows = Array.from(t.content.children).filter((r) => r.tagName === 'LI');
59 if (!rows.length) throw new Error('no rows');98 if (!rows.length) throw new Error('no rows');
60- // An ancestor closed while the request was out: tr is hidden, and its99+ // An ancestor closed while the request was out: li is hidden, and its
61 // rows come in hidden, for the expand of that ancestor to show.100 // rows come in hidden, for the expand of that ancestor to show.
62- const covered = tr.hidden;101+ const covered = li.hidden;
63 for (const r of rows) {102 for (const r of rows) {
64 r.dataset.depth = String(d);103 r.dataset.depth = String(d);
104+ r.setAttribute('aria-level', String(d + 1));
65 r.style.setProperty('--depth', String(d));105 r.style.setProperty('--depth', String(d));
106+ prep(r);
66 if (covered) r.hidden = true;107 if (covered) r.hidden = true;
67 }108 }
68- tr.after(...rows);109+ li.after(...rows);
69- tr.removeAttribute('aria-busy');110+ li.removeAttribute('aria-busy');
70- if (covered) setOpen(tr, a, true);111+ if (covered) li.setAttribute('aria-expanded', 'true');
71- else expand(tr, a);112+ else expand(li);
72 } catch (e) {113 } catch (e) {
73- location.href = a.href;114+ location.href = nameLink(li).href;
74 }115 }
75 }116 }
76 117
77- function toggle(tr) {118+ function toggle(li) {
78- const a = linkOf(tr);119+ if (!isDir(li) || li.getAttribute('aria-busy') === 'true') return;
79- if (!a || tr.getAttribute('aria-busy') === 'true') return;120+ if (isOpen(li)) collapse(li);
80- if (isOpen(a)) collapse(tr, a);121+ else if (isLoaded(li)) expand(li);
81- else if (isLoaded(tr)) expand(tr, a);122+ else load(li);
82- else load(tr, a);123+ }
124+
125+ const rowsEl = () => document.getElementById('tree-rows');
126+ const cursorRow = () => rowsEl()?.querySelector(':scope > [data-cursor]') ?? null;
127+ const flash = (m) => window.ermite?.flash?.(m);
128+
129+ // parentOf is the visible row of the directory that holds li: the closest
130+ // row above it that is less deep.
131+ function parentOf(li) {
132+ const d = depthOf(li);
133+ for (let r = li.previousElementSibling; r; r = r.previousElementSibling) if (depthOf(r) < d) return r;
134+ return null;
83 }135 }
84 136
85 return {137 return {
86- // click opens or closes the directory under a plain left click; any138+ // click puts the cursor and the focus on the row under any click, and
87- // modified or other click keeps the link's own navigation.139+ // opens or closes the directory under a plain left click on its name or its
140+ // node; any modified or other click, and a click on the last commit, keep
141+ // their own navigation.
88 click(event) {142 click(event) {
89- const a = event.target.closest('a[data-expand]');143+ const li = event.target.closest('li');
90- if (!a || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;144+ if (!li || !li.parentElement) return;
145+ mark(li, true);
146+ if (event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
147+ if (!event.target.closest('.t-main') || !isDir(li)) return;
91 event.preventDefault();148 event.preventDefault();
92- toggle(a.closest('tr'));149+ toggle(li);
150+ },
151+
152+ // init takes the rows out of the tab order but the one under the cursor
153+ // (the first when none has it). It runs once the page has loaded, and
154+ // not at all without a tree.
155+ init() {
156+ const rows = typeof document === 'undefined' ? null : rowsEl();
157+ if (!rows) return;
158+ const lis = Array.from(rows.children);
159+ for (const li of lis) prep(li);
160+ const cur = cursorRow() ?? lis[0];
161+ if (cur) mark(cur, false);
162+ },
163+
164+ // sync follows a cursor that ermite.js moved (j k gg G): the focus goes
165+ // with it, and the tab stop.
166+ sync(li) {
167+ if (li.parentElement && li.parentElement.id === 'tree-rows') mark(li, true);
168+ },
169+
170+ // mark is for the finder, which takes the cursor away and gives it back.
171+ mark(li) { mark(li, false); },
172+
173+ // active says that h and l (and the arrows) walk the tree: it is shown,
174+ // not replaced by the results of the finder.
175+ active() {
176+ const rows = rowsEl();
177+ return !!rows && rows.hasAttribute('data-rows');
178+ },
179+
180+ // open is Enter on the row under the cursor: a directory opens or closes
181+ // in place, a file opens. It answers whether the tree took the key.
182+ open() {
183+ const li = this.active() && cursorRow();
184+ if (!li) return false;
185+ if (isDir(li)) toggle(li);
186+ else if (nameLink(li)) nameLink(li).click();
187+ else flash('a submodule has no page here');
188+ return true;
189+ },
190+
191+ // arrow is a key of the tree pattern (WAI-ARIA APG Tree View) pressed with
192+ // the focus on a row: it only moves the focus or the expansion, and never
193+ // opens a file or leaves the page. It answers whether the key is one the
194+ // tree handles (the caller then keeps the browser from scrolling).
195+ // Down, Up: the next, the previous visible row. Home, End: the first, the last.
196+ // Right: a closed directory opens, an open one moves to its first entry, a
197+ // file does nothing.
198+ // Left: an open directory closes, any other row moves to its directory,
199+ // and a top-level row does nothing.
200+ arrow(key) {
201+ if (!this.active()) return false;
202+ const li = cursorRow();
203+ if (!li) return false;
204+ const rows = Array.from(li.parentElement.children).filter((r) => !r.hidden);
205+ const i = rows.indexOf(li);
206+ switch (key) {
207+ case 'ArrowDown': if (rows[i + 1]) setCursor(rows[i + 1]); break;
208+ case 'ArrowUp': if (i > 0) setCursor(rows[i - 1]); break;
209+ case 'Home': if (rows[0]) setCursor(rows[0]); break;
210+ case 'End': if (rows.length) setCursor(rows[rows.length - 1]); break;
211+ case 'ArrowRight':
212+ if (!isDir(li)) break;
213+ if (!isOpen(li)) toggle(li);
214+ else if (rows[i + 1] && depthOf(rows[i + 1]) > depthOf(li)) setCursor(rows[i + 1]);
215+ break;
216+ case 'ArrowLeft':
217+ if (isDir(li) && isOpen(li)) collapse(li);
218+ else if (parentOf(li)) setCursor(parentOf(li));
219+ break;
220+ default: return false;
221+ }
222+ return true;
223+ },
224+
225+ // key is l (into = true) or h on the row under the cursor, the Vim keys
226+ // with their richer behaviour; it answers whether the tree took the key.
227+ // l: a closed directory opens, an open one moves to its first entry, a
228+ // file is opened.
229+ // h: an open directory closes, any other row goes to its directory, and a
230+ // top-level row to the parent directory's page.
231+ key(into) {
232+ if (!this.active()) return false;
233+ const li = cursorRow();
234+ if (!li) return false;
235+ if (into) {
236+ if (isDir(li)) {
237+ if (!isOpen(li)) return toggle(li), true;
238+ const n = li.nextElementSibling;
239+ if (n && !n.hidden && depthOf(n) > depthOf(li)) setCursor(n);
240+ return true;
241+ }
242+ const a = nameLink(li);
243+ if (a) a.click();
244+ else flash('a submodule has no page here');
245+ return true;
246+ }
247+ if (isDir(li) && isOpen(li)) {
248+ collapse(li);
249+ return true;
250+ }
251+ const up = parentOf(li);
252+ if (up) setCursor(up);
253+ else if (rowsEl().dataset.up) location.href = rowsEl().dataset.up;
254+ else flash('top of the tree');
255+ return true;
93 },256 },
94 };257 };
95 })();258 })();
96 259
260+window.ermiteTree.init();
261+
97 // ermiteFinder: the fuzzy finder of the Files view and the file hits of the262 // ermiteFinder: the fuzzy finder of the Files view and the file hits of the
98 // ":" palette. The core (index, search, positions, visible) is pure and runs263 // ":" palette. The core (index, search, positions, visible) is pure and runs
99 // in Node; the DOM glue below it runs only from the handlers declared in the264 // in Node; the DOM glue below it runs only from the handlers declared in the
@@ -522,9 +687,9 @@ window.ermiteFinder = (() => {
522 rows.setAttribute('data-rows', '');687 rows.setAttribute('data-rows', '');
523 const keep = treeCursor;688 const keep = treeCursor;
524 treeCursor = null;689 treeCursor = null;
525- if (!rows.querySelector(':scope > tr[data-cursor]')) {690+ if (!rows.querySelector(':scope > [data-cursor]')) {
526- const row = keep && keep.parentElement === rows && !keep.hidden ? keep : rows.querySelector(':scope > tr:not([hidden])');691+ const row = keep && keep.parentElement === rows && !keep.hidden ? keep : rows.querySelector(':scope > li:not([hidden])');
527- row?.setAttribute('data-cursor', '');692+ if (row) window.ermiteTree.mark(row);
528 }693 }
529 }694 }
530 695
@@ -535,7 +700,7 @@ window.ermiteFinder = (() => {
535 el.setAttribute('aria-expanded', 'true');700 el.setAttribute('aria-expanded', 'true');
536 const rows = $('tree-rows');701 const rows = $('tree-rows');
537 rows.removeAttribute('data-rows');702 rows.removeAttribute('data-rows');
538- rows.querySelectorAll(':scope > tr[data-cursor]').forEach((r) => {703+ rows.querySelectorAll(':scope > [data-cursor]').forEach((r) => {
539 treeCursor = r;704 treeCursor = r;
540 r.removeAttribute('data-cursor');705 r.removeAttribute('data-cursor');
541 });706 });
M internal/adapters/web/tree.go +60 -20
@@ -5,20 +5,33 @@ package web
55 import (
66 "context"
77 "net/http"
8+ "path"
89
910 "github.com/a-h/templ"
1011
1112 "bnema.dev/ermite/internal/domain"
1213 )
1314
15+// Entry kinds of a TreeRow; the stylesheet draws the node of a row by its kind.
16+const (
17+ kindDir = "dir"
18+ kindFile = "file"
19+ kindLink = "link"
20+ kindModule = "module"
21+)
22+
1423 // TreeRow is one entry of a directory.
1524 type TreeRow struct {
16- Glyph string // ▸ directory, @ symbolic link, + submodule, · file
17- Name string
18- Href string // "" for a submodule
25+ Kind string // kindDir, kindFile, kindLink or kindModule
26+ Name string
27+ Href string // "" for a submodule
1928 // Expand is the URL of the rows of a directory (Href?rows); "" for any
2029 // other entry.
21- Expand string
30+ Expand string
31+ // Hint says what a directory, a link or a submodule is, in words, for the
32+ // screen reader (the node drawn for it is aria-hidden) and the tooltip; ""
33+ // for a file.
34+ Hint string
2235 Size string
2336 Subject string
2437 CHref string
@@ -26,21 +39,39 @@ type TreeRow struct {
2639 When string
2740 WhenAt string
2841 Cursor bool
42+ // Pos and Set are the aria-posinset and aria-setsize of the row among the
43+ // entries of its directory; Set is -1 when the listing is cut, as the
44+ // directory has more entries than are listed.
45+ Pos, Set int
2946 }
3047
3148 // TreePage is a directory.
3249 type TreePage struct {
33- Rows []TreeRow
34- Readme *Readme
50+ Rows []TreeRow
51+ // Path is the directory, "/" for the root.
52+ Path string
53+ // Up is the page of the parent directory, "" at the root: the h key of the
54+ // tree goes there from a top-level entry.
55+ Up string
3556 // Notes say what the listing leaves out.
3657 Notes []string
3758 // More, for the rows of a directory expanded in place, is the page of the
3859 // directory when it has more entries than are listed; "" otherwise.
3960 More string
61+ // MorePos is the position of the row that says there are more.
62+ MorePos int
4063 }
4164
42-// tree lists a directory of a ref, with the last commit of each entry, and
43-// renders the README of the directory below.
65+// Unit is the noun of the entry count in the head of the tree.
66+func (p TreePage) Unit() string {
67+ if len(p.Rows) == 1 {
68+ return "entry"
69+ }
70+ return "entries"
71+}
72+
73+// tree lists a directory of a ref, with the last commit of each entry. With
74+// ?rows it answers the entries alone, for a directory opened in place.
4475 //
4576 //hexcheck:ignore suspicious-business-logic-in-adapter builds the directory page from the use case results; no rule is decided here
4677 func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) {
@@ -60,19 +91,22 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
6091 now := s.cfg.Now()
6192
6293 rowsOnly := r.URL.Query().Has("rows")
63- p := TreePage{}
94+ p := TreePage{Path: "/"}
95+ if t.path != "" {
96+ p.Path = visibleText(t.path) + "/"
97+ }
6498 for i, e := range entries {
65- row := TreeRow{Glyph: "·", Name: visibleText(e.Name), Size: humanSize(e.Size), Cursor: i == 0 && !rowsOnly}
99+ row := TreeRow{Kind: kindFile, Name: visibleText(e.Name), Size: humanSize(e.Size), Cursor: i == 0 && !rowsOnly}
66100 switch e.Kind {
67101 case domain.EntryDirectory:
68- row.Glyph, row.Name, row.Size = "▸", row.Name+"/", "-"
102+ row.Kind, row.Hint, row.Name, row.Size = kindDir, "directory", row.Name+"/", ""
69103 row.Href = repoHref(d.name(), "tree", t.name, e.Path)
70104 row.Expand = row.Href + "?rows"
71105 case domain.EntrySymlink:
72- row.Glyph = "@"
106+ row.Kind, row.Hint = kindLink, "symbolic link"
73107 row.Href = repoHref(d.name(), "blob", t.name, e.Path)
74108 case domain.EntrySubmodule:
75- row.Glyph, row.Size = "+", "-"
109+ row.Kind, row.Hint, row.Size = kindModule, "submodule", ""
76110 default:
77111 row.Href = repoHref(d.name(), "blob", t.name, e.Path)
78112 }
@@ -82,10 +116,16 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
82116 }
83117 p.Rows = append(p.Rows, row)
84118 }
119+ for i := range p.Rows {
120+ p.Rows[i].Pos, p.Rows[i].Set = i+1, len(p.Rows)
121+ if res.Truncated {
122+ p.Rows[i].Set = -1
123+ }
124+ }
85125 if rowsOnly {
86126 // The rows alone, for a directory opened in place (files.js).
87127 if res.Truncated {
88- p.More = repoHref(d.name(), "tree", t.name, t.path)
128+ p.More, p.MorePos = repoHref(d.name(), "tree", t.name, t.path), len(p.Rows)+1
89129 }
90130 return TreeRows(p), http.StatusOK, nil
91131 }
@@ -95,12 +135,12 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
95135 if res.LastCommitsOmitted {
96136 p.Notes = append(p.Notes, "the last commit is shown for the first entries only.")
97137 }
98- rd, err := s.readmeIn(ctx, d.repo.Name, res.CommitID.String(), t.name, t.path, entries)
99- if err != nil {
100- return nil, 0, err
101- }
102- if rd != nil {
103- p.Readme = rd.readme
138+ if t.path != "" {
139+ up := path.Dir(t.path)
140+ if up == "." {
141+ up = ""
142+ }
143+ p.Up = repoHref(d.name(), "tree", t.name, up)
104144 }
105145
106146 l := s.repoLayout(ctx, d, "tree")
@@ -5,20 +5,33 @@ package web
5 import (5 import (
6 "context"6 "context"
7 "net/http"7 "net/http"
8+ "path"
8 9
9 "github.com/a-h/templ"10 "github.com/a-h/templ"
10 11
11 "bnema.dev/ermite/internal/domain"12 "bnema.dev/ermite/internal/domain"
12 )13 )
13 14
15+// Entry kinds of a TreeRow; the stylesheet draws the node of a row by its kind.
16+const (
17+ kindDir = "dir"
18+ kindFile = "file"
19+ kindLink = "link"
20+ kindModule = "module"
21+)
22+
14 // TreeRow is one entry of a directory.23 // TreeRow is one entry of a directory.
15 type TreeRow struct {24 type TreeRow struct {
16- Glyph string // ▸ directory, @ symbolic link, + submodule, · file25+ Kind string // kindDir, kindFile, kindLink or kindModule
17- Name string26+ Name string
18- Href string // "" for a submodule27+ Href string // "" for a submodule
19 // Expand is the URL of the rows of a directory (Href?rows); "" for any28 // Expand is the URL of the rows of a directory (Href?rows); "" for any
20 // other entry.29 // other entry.
21- Expand string30+ Expand string
31+ // Hint says what a directory, a link or a submodule is, in words, for the
32+ // screen reader (the node drawn for it is aria-hidden) and the tooltip; ""
33+ // for a file.
34+ Hint string
22 Size string35 Size string
23 Subject string36 Subject string
24 CHref string37 CHref string
@@ -26,21 +39,39 @@ type TreeRow struct {
26 When string39 When string
27 WhenAt string40 WhenAt string
28 Cursor bool41 Cursor bool
42+ // Pos and Set are the aria-posinset and aria-setsize of the row among the
43+ // entries of its directory; Set is -1 when the listing is cut, as the
44+ // directory has more entries than are listed.
45+ Pos, Set int
29 }46 }
30 47
31 // TreePage is a directory.48 // TreePage is a directory.
32 type TreePage struct {49 type TreePage struct {
33- Rows []TreeRow50+ Rows []TreeRow
34- Readme *Readme51+ // Path is the directory, "/" for the root.
52+ Path string
53+ // Up is the page of the parent directory, "" at the root: the h key of the
54+ // tree goes there from a top-level entry.
55+ Up string
35 // Notes say what the listing leaves out.56 // Notes say what the listing leaves out.
36 Notes []string57 Notes []string
37 // More, for the rows of a directory expanded in place, is the page of the58 // More, for the rows of a directory expanded in place, is the page of the
38 // directory when it has more entries than are listed; "" otherwise.59 // directory when it has more entries than are listed; "" otherwise.
39 More string60 More string
61+ // MorePos is the position of the row that says there are more.
62+ MorePos int
40 }63 }
41 64
42-// tree lists a directory of a ref, with the last commit of each entry, and65+// Unit is the noun of the entry count in the head of the tree.
43-// renders the README of the directory below.66+func (p TreePage) Unit() string {
67+ if len(p.Rows) == 1 {
68+ return "entry"
69+ }
70+ return "entries"
71+}
72+
73+// tree lists a directory of a ref, with the last commit of each entry. With
74+// ?rows it answers the entries alone, for a directory opened in place.
44 //75 //
45 //hexcheck:ignore suspicious-business-logic-in-adapter builds the directory page from the use case results; no rule is decided here76 //hexcheck:ignore suspicious-business-logic-in-adapter builds the directory page from the use case results; no rule is decided here
46 func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) {77 func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, int, error) {
@@ -60,19 +91,22 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
60 now := s.cfg.Now()91 now := s.cfg.Now()
61 92
62 rowsOnly := r.URL.Query().Has("rows")93 rowsOnly := r.URL.Query().Has("rows")
63- p := TreePage{}94+ p := TreePage{Path: "/"}
95+ if t.path != "" {
96+ p.Path = visibleText(t.path) + "/"
97+ }
64 for i, e := range entries {98 for i, e := range entries {
65- row := TreeRow{Glyph: "·", Name: visibleText(e.Name), Size: humanSize(e.Size), Cursor: i == 0 && !rowsOnly}99+ row := TreeRow{Kind: kindFile, Name: visibleText(e.Name), Size: humanSize(e.Size), Cursor: i == 0 && !rowsOnly}
66 switch e.Kind {100 switch e.Kind {
67 case domain.EntryDirectory:101 case domain.EntryDirectory:
68- row.Glyph, row.Name, row.Size = "▸", row.Name+"/", "-"102+ row.Kind, row.Hint, row.Name, row.Size = kindDir, "directory", row.Name+"/", ""
69 row.Href = repoHref(d.name(), "tree", t.name, e.Path)103 row.Href = repoHref(d.name(), "tree", t.name, e.Path)
70 row.Expand = row.Href + "?rows"104 row.Expand = row.Href + "?rows"
71 case domain.EntrySymlink:105 case domain.EntrySymlink:
72- row.Glyph = "@"106+ row.Kind, row.Hint = kindLink, "symbolic link"
73 row.Href = repoHref(d.name(), "blob", t.name, e.Path)107 row.Href = repoHref(d.name(), "blob", t.name, e.Path)
74 case domain.EntrySubmodule:108 case domain.EntrySubmodule:
75- row.Glyph, row.Size = "+", "-"109+ row.Kind, row.Hint, row.Size = kindModule, "submodule", ""
76 default:110 default:
77 row.Href = repoHref(d.name(), "blob", t.name, e.Path)111 row.Href = repoHref(d.name(), "blob", t.name, e.Path)
78 }112 }
@@ -82,10 +116,16 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
82 }116 }
83 p.Rows = append(p.Rows, row)117 p.Rows = append(p.Rows, row)
84 }118 }
119+ for i := range p.Rows {
120+ p.Rows[i].Pos, p.Rows[i].Set = i+1, len(p.Rows)
121+ if res.Truncated {
122+ p.Rows[i].Set = -1
123+ }
124+ }
85 if rowsOnly {125 if rowsOnly {
86 // The rows alone, for a directory opened in place (files.js).126 // The rows alone, for a directory opened in place (files.js).
87 if res.Truncated {127 if res.Truncated {
88- p.More = repoHref(d.name(), "tree", t.name, t.path)128+ p.More, p.MorePos = repoHref(d.name(), "tree", t.name, t.path), len(p.Rows)+1
89 }129 }
90 return TreeRows(p), http.StatusOK, nil130 return TreeRows(p), http.StatusOK, nil
91 }131 }
@@ -95,12 +135,12 @@ func (s *Server) tree(ctx context.Context, r *http.Request) (templ.Component, in
95 if res.LastCommitsOmitted {135 if res.LastCommitsOmitted {
96 p.Notes = append(p.Notes, "the last commit is shown for the first entries only.")136 p.Notes = append(p.Notes, "the last commit is shown for the first entries only.")
97 }137 }
98- rd, err := s.readmeIn(ctx, d.repo.Name, res.CommitID.String(), t.name, t.path, entries)138+ if t.path != "" {
99- if err != nil {139+ up := path.Dir(t.path)
100- return nil, 0, err140+ if up == "." {
101- }141+ up = ""
102- if rd != nil {142+ }
103- p.Readme = rd.readme143+ p.Up = repoHref(d.name(), "tree", t.name, up)
104 }144 }
105 145
106 l := s.repoLayout(ctx, d, "tree")146 l := s.repoLayout(ctx, d, "tree")
M internal/adapters/web/tree.templ +99 -53
@@ -2,50 +2,64 @@
22
33 package web
44
5-// Tree is a directory: the file finder, the entries with their last commit, the
6-// notes on what is left out, and the README of the directory below.
5+// Tree is a directory as a tree: the file finder on top, then the entries
6+// with their last commit, and the notes on what is left out. A directory opens
7+// in place (files.js); the keyboard walks the rows (j k l h, the arrows).
78 templ Tree(l Layout, p TreePage) {
89 @Shell(l, nil) {
9- <div class="filter">
10- <label for="finder-q" class="sr">find a file</label>
11- <input
12- id="finder-q"
13- type="search"
14- data-filter
15- role="combobox"
16- aria-autocomplete="list"
17- aria-controls="finder-list"
18- aria-expanded="false"
19- placeholder="Find a file…"
20- autocomplete="off"
21- spellcheck="false"
22- hx-on:focus="ermiteFinder.load()"
23- hx-on:input="ermiteFinder.input(this)"
24- hx-on:keydown="ermiteFinder.key(this, event)"
25- />
10+ <div class="tree-find">
11+ <div class="filter wide">
12+ <label for="finder-q" class="sr">find a file</label>
13+ <input
14+ id="finder-q"
15+ type="search"
16+ data-filter
17+ role="combobox"
18+ aria-autocomplete="list"
19+ aria-controls="finder-list"
20+ aria-expanded="false"
21+ placeholder="Find a file in this repository…"
22+ autocomplete="off"
23+ spellcheck="false"
24+ hx-on:focus="ermiteFinder.load()"
25+ hx-on:input="ermiteFinder.input(this)"
26+ hx-on:keydown="ermiteFinder.key(this, event)"
27+ />
28+ </div>
29+ <p class="tree-keys" data-js aria-hidden="true"><kbd>j</kbd> <kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
2630 </div>
27- <div id="finder" hidden>
31+ <div id="finder" class="tree-card" hidden>
2832 <p id="finder-status" class="dim" aria-live="polite"></p>
2933 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>
3034 </div>
31- <div id="tree-view">
32- <table class="list tree" aria-label="Entries">
33- <thead>
34- <tr><th class="cur"></th><th class="c-kind"></th><th class="c-name">name</th><th class="c-size">size</th><th class="c-sub">last commit</th><th class="c-who">author</th><th class="c-when">date</th></tr>
35- </thead>
36- <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
37- @TreeRows(p)
38- </tbody>
39- </table>
35+ <div id="tree-view" class="tree-card">
36+ <div class="tree-head">
37+ <span class="tree-path">{ p.Path }</span>
38+ if p.Up != "" {
39+ <a class="tree-up" href={ templ.SafeURL(p.Up) } title="parent directory (h)">..</a>
40+ }
41+ <span class="tree-count">{ itoa(len(p.Rows)) } { p.Unit() }</span>
42+ </div>
43+ <ul
44+ id="tree-rows"
45+ class="tree"
46+ role="tree"
47+ aria-label="Entries"
48+ data-rows
49+ data-tree
50+ if p.Up != "" {
51+ data-up={ p.Up }
52+ }
53+ hx-on:click="ermiteTree.click(event)"
54+ >
55+ @TreeRows(p)
56+ </ul>
4057 if len(p.Rows) == 0 {
4158 <p class="lede dim">this directory is empty.</p>
4259 }
4360 for _, n := range p.Notes {
4461 <p class="note">{ n }</p>
4562 }
46- if p.Readme != nil {
47- @ReadmeCard(*p.Readme)
48- }
4963 </div>
5064 }
5165 }
@@ -58,41 +72,73 @@ templ TreeRows(p TreePage) {
5872 @TreeRowView(r)
5973 }
6074 if p.More != "" {
61- <tr class="tree-more"><td colspan="7"><a href={ templ.SafeURL(p.More) }>more entries: open the directory</a></td></tr>
75+ <li class="tree-more" role="treeitem" aria-level="1" aria-posinset={ itoa(p.MorePos) } aria-setsize="-1" data-depth="0">
76+ <span class="t-main">
77+ <span class="t-twig" aria-hidden="true"></span>
78+ <span class="t-node" aria-hidden="true"></span>
79+ <a class="t-name" href={ templ.SafeURL(p.More) }>more entries: open the directory</a>
80+ </span>
81+ </li>
6282 }
6383 }
6484
65-// TreeRowView is one entry of a directory. Glyph: ▸ directory (▾ once opened
66-// in place), @ symbolic link, + submodule, · file. A directory link carries
67-// data-expand, the URL of its rows.
85+// TreeRowView is one entry of a directory, a row of the tree: the guides of
86+// its depth (t-twig), its node, its name, then its last commit. A directory
87+// carries data-expand, the URL of its rows, and aria-expanded; files.js sets
88+// the depth of the rows it inserts (data-depth, --depth, aria-level).
89+// aria-posinset and aria-setsize count the entries of the row's own directory
90+// (setsize -1: the listing is cut and the total unknown), so the positions
91+// stay right once the rows of a directory are inserted into the flat list. The
92+// node is drawn by the stylesheet from data-kind, which is aria-hidden: the
93+// type of a directory, a link or a submodule is said in text for screen readers
94+// (the .sr span, part of the name of the treeitem) and in a tooltip.
6895 templ TreeRowView(r TreeRow) {
69- <tr data-cursor?={ r.Cursor }>
70- <td class="cur" aria-hidden="true"></td>
71- <td class="c-kind" aria-hidden="true">{ r.Glyph }</td>
72- <td class="c-name">
96+ <li
97+ role="treeitem"
98+ aria-level="1"
99+ aria-posinset={ itoa(r.Pos) }
100+ aria-setsize={ itoa(r.Set) }
101+ data-kind={ r.Kind }
102+ data-depth="0"
103+ data-cursor?={ r.Cursor }
104+ if r.Expand != "" {
105+ data-expand={ r.Expand }
106+ aria-expanded="false"
107+ }
108+ >
109+ <span class="t-main">
110+ <span class="t-twig" aria-hidden="true"></span>
111+ <span class="t-node" aria-hidden="true"></span>
73112 if r.Href != "" {
74113 <a
114+ class="t-name"
75115 href={ templ.SafeURL(r.Href) }
76- if r.Expand != "" {
77- data-expand={ r.Expand }
78- aria-expanded="false"
116+ if r.Hint != "" {
117+ title={ r.Hint }
79118 }
80119 >{ r.Name }</a>
81120 } else {
82- { r.Name }
121+ <span
122+ class="t-name"
123+ if r.Hint != "" {
124+ title={ r.Hint }
125+ }
126+ >{ r.Name }</span>
127+ }
128+ if r.Hint != "" {
129+ <span class="sr">{ " (" + r.Hint + ")" }</span>
83130 }
84- </td>
85- <td class="c-size num dim">{ r.Size }</td>
86- <td class="c-sub dim">
131+ </span>
132+ <span class="t-size">{ r.Size }</span>
133+ <span class="t-sub">
87134 if r.CHref != "" {
88- <a href={ templ.SafeURL(r.CHref) }>{ r.Subject }</a>
135+ <a class="t-commit" href={ templ.SafeURL(r.CHref) } title={ r.Author }>{ r.Subject }</a>
89136 }
90- </td>
91- <td class="c-who dim">{ r.Author }</td>
92- <td class="c-when nowrap dim">
137+ </span>
138+ <span class="t-when">
93139 if r.WhenAt != "" {
94140 <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
95141 }
96- </td>
97- </tr>
142+ </span>
143+ </li>
98144 }
@@ -2,50 +2,64 @@
2 2
3 package web3 package web
4 4
5-// Tree is a directory: the file finder, the entries with their last commit, the5+// Tree is a directory as a tree: the file finder on top, then the entries
6-// notes on what is left out, and the README of the directory below.6+// with their last commit, and the notes on what is left out. A directory opens
7+// in place (files.js); the keyboard walks the rows (j k l h, the arrows).
7 templ Tree(l Layout, p TreePage) {8 templ Tree(l Layout, p TreePage) {
8 @Shell(l, nil) {9 @Shell(l, nil) {
9- <div class="filter">10+ <div class="tree-find">
10- <label for="finder-q" class="sr">find a file</label>11+ <div class="filter wide">
11- <input12+ <label for="finder-q" class="sr">find a file</label>
12- id="finder-q"13+ <input
13- type="search"14+ id="finder-q"
14- data-filter15+ type="search"
15- role="combobox"16+ data-filter
16- aria-autocomplete="list"17+ role="combobox"
17- aria-controls="finder-list"18+ aria-autocomplete="list"
18- aria-expanded="false"19+ aria-controls="finder-list"
19- placeholder="Find a file…"20+ aria-expanded="false"
20- autocomplete="off"21+ placeholder="Find a file in this repository…"
21- spellcheck="false"22+ autocomplete="off"
22- hx-on:focus="ermiteFinder.load()"23+ spellcheck="false"
23- hx-on:input="ermiteFinder.input(this)"24+ hx-on:focus="ermiteFinder.load()"
24- hx-on:keydown="ermiteFinder.key(this, event)"25+ hx-on:input="ermiteFinder.input(this)"
25- />26+ hx-on:keydown="ermiteFinder.key(this, event)"
27+ />
28+ </div>
29+ <p class="tree-keys" data-js aria-hidden="true"><kbd>j</kbd> <kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
26 </div>30 </div>
27- <div id="finder" hidden>31+ <div id="finder" class="tree-card" hidden>
28 <p id="finder-status" class="dim" aria-live="polite"></p>32 <p id="finder-status" class="dim" aria-live="polite"></p>
29 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>33 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files"></ul>
30 </div>34 </div>
31- <div id="tree-view">35+ <div id="tree-view" class="tree-card">
32- <table class="list tree" aria-label="Entries">36+ <div class="tree-head">
33- <thead>37+ <span class="tree-path">{ p.Path }</span>
34- <tr><th class="cur"></th><th class="c-kind"></th><th class="c-name">name</th><th class="c-size">size</th><th class="c-sub">last commit</th><th class="c-who">author</th><th class="c-when">date</th></tr>38+ if p.Up != "" {
35- </thead>39+ <a class="tree-up" href={ templ.SafeURL(p.Up) } title="parent directory (h)">..</a>
36- <tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">40+ }
37- @TreeRows(p)41+ <span class="tree-count">{ itoa(len(p.Rows)) } { p.Unit() }</span>
38- </tbody>42+ </div>
39- </table>43+ <ul
44+ id="tree-rows"
45+ class="tree"
46+ role="tree"
47+ aria-label="Entries"
48+ data-rows
49+ data-tree
50+ if p.Up != "" {
51+ data-up={ p.Up }
52+ }
53+ hx-on:click="ermiteTree.click(event)"
54+ >
55+ @TreeRows(p)
56+ </ul>
40 if len(p.Rows) == 0 {57 if len(p.Rows) == 0 {
41 <p class="lede dim">this directory is empty.</p>58 <p class="lede dim">this directory is empty.</p>
42 }59 }
43 for _, n := range p.Notes {60 for _, n := range p.Notes {
44 <p class="note">{ n }</p>61 <p class="note">{ n }</p>
45 }62 }
46- if p.Readme != nil {
47- @ReadmeCard(*p.Readme)
48- }
49 </div>63 </div>
50 }64 }
51 }65 }
@@ -58,41 +72,73 @@ templ TreeRows(p TreePage) {
58 @TreeRowView(r)72 @TreeRowView(r)
59 }73 }
60 if p.More != "" {74 if p.More != "" {
61- <tr class="tree-more"><td colspan="7"><a href={ templ.SafeURL(p.More) }>more entries: open the directory</a></td></tr>75+ <li class="tree-more" role="treeitem" aria-level="1" aria-posinset={ itoa(p.MorePos) } aria-setsize="-1" data-depth="0">
76+ <span class="t-main">
77+ <span class="t-twig" aria-hidden="true"></span>
78+ <span class="t-node" aria-hidden="true"></span>
79+ <a class="t-name" href={ templ.SafeURL(p.More) }>more entries: open the directory</a>
80+ </span>
81+ </li>
62 }82 }
63 }83 }
64 84
65-// TreeRowView is one entry of a directory. Glyph: ▸ directory (▾ once opened85+// TreeRowView is one entry of a directory, a row of the tree: the guides of
66-// in place), @ symbolic link, + submodule, · file. A directory link carries86+// its depth (t-twig), its node, its name, then its last commit. A directory
67-// data-expand, the URL of its rows.87+// carries data-expand, the URL of its rows, and aria-expanded; files.js sets
88+// the depth of the rows it inserts (data-depth, --depth, aria-level).
89+// aria-posinset and aria-setsize count the entries of the row's own directory
90+// (setsize -1: the listing is cut and the total unknown), so the positions
91+// stay right once the rows of a directory are inserted into the flat list. The
92+// node is drawn by the stylesheet from data-kind, which is aria-hidden: the
93+// type of a directory, a link or a submodule is said in text for screen readers
94+// (the .sr span, part of the name of the treeitem) and in a tooltip.
68 templ TreeRowView(r TreeRow) {95 templ TreeRowView(r TreeRow) {
69- <tr data-cursor?={ r.Cursor }>96+ <li
70- <td class="cur" aria-hidden="true"></td>97+ role="treeitem"
71- <td class="c-kind" aria-hidden="true">{ r.Glyph }</td>98+ aria-level="1"
72- <td class="c-name">99+ aria-posinset={ itoa(r.Pos) }
100+ aria-setsize={ itoa(r.Set) }
101+ data-kind={ r.Kind }
102+ data-depth="0"
103+ data-cursor?={ r.Cursor }
104+ if r.Expand != "" {
105+ data-expand={ r.Expand }
106+ aria-expanded="false"
107+ }
108+ >
109+ <span class="t-main">
110+ <span class="t-twig" aria-hidden="true"></span>
111+ <span class="t-node" aria-hidden="true"></span>
73 if r.Href != "" {112 if r.Href != "" {
74 <a113 <a
114+ class="t-name"
75 href={ templ.SafeURL(r.Href) }115 href={ templ.SafeURL(r.Href) }
76- if r.Expand != "" {116+ if r.Hint != "" {
77- data-expand={ r.Expand }117+ title={ r.Hint }
78- aria-expanded="false"
79 }118 }
80 >{ r.Name }</a>119 >{ r.Name }</a>
81 } else {120 } else {
82- { r.Name }121+ <span
122+ class="t-name"
123+ if r.Hint != "" {
124+ title={ r.Hint }
125+ }
126+ >{ r.Name }</span>
127+ }
128+ if r.Hint != "" {
129+ <span class="sr">{ " (" + r.Hint + ")" }</span>
83 }130 }
84- </td>131+ </span>
85- <td class="c-size num dim">{ r.Size }</td>132+ <span class="t-size">{ r.Size }</span>
86- <td class="c-sub dim">133+ <span class="t-sub">
87 if r.CHref != "" {134 if r.CHref != "" {
88- <a href={ templ.SafeURL(r.CHref) }>{ r.Subject }</a>135+ <a class="t-commit" href={ templ.SafeURL(r.CHref) } title={ r.Author }>{ r.Subject }</a>
89 }136 }
90- </td>137+ </span>
91- <td class="c-who dim">{ r.Author }</td>138+ <span class="t-when">
92- <td class="c-when nowrap dim">
93 if r.WhenAt != "" {139 if r.WhenAt != "" {
94 <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>140 <time datetime={ r.WhenAt } title={ r.WhenAt }>{ r.When }</time>
95 }141 }
96- </td>142+ </span>
97- </tr>143+ </li>
98 }144 }
M internal/adapters/web/tree_templ.go +310 -115 generated file: collapsed
M internal/app/testdata/web/blob-binary.html +14 -3
@@ -174,14 +174,25 @@
174174 <dt>
175175 <kbd>enter</kbd>
176176 </dt>
177-<dd>open the row under the cursor</dd>
177+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
178+</div>
179+<div>
180+<dt>
181+<kbd>↑</kbd>
182+<kbd>↓</kbd>
183+<kbd>←</kbd>
184+<kbd>→</kbd>
185+<kbd>home</kbd>
186+<kbd>end</kbd>
187+</dt>
188+<dd>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</dd>
178189 </div>
179190 <div>
180191 <dt>
181192 <kbd>l</kbd>
182193 <kbd>h</kbd>
183194 </dt>
184-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
195+<dd>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</dd>
185196 </div>
186197 <div>
187198 <dt>
@@ -194,7 +205,7 @@
194205 <dt>
195206 <kbd>/</kbd>
196207 </dt>
197-<dd>filter the list; on Files, find a file in the repository</dd>
208+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
198209 </div>
199210 <div>
200211 <dt>
@@ -174,14 +174,25 @@
174 <dt>174 <dt>
175 <kbd>enter</kbd>175 <kbd>enter</kbd>
176 </dt>176 </dt>
177-<dd>open the row under the cursor</dd>177+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
178+</div>
179+<div>
180+<dt>
181+<kbd>↑</kbd>
182+<kbd>↓</kbd>
183+<kbd>←</kbd>
184+<kbd>→</kbd>
185+<kbd>home</kbd>
186+<kbd>end</kbd>
187+</dt>
188+<dd>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</dd>
178 </div>189 </div>
179 <div>190 <div>
180 <dt>191 <dt>
181 <kbd>l</kbd>192 <kbd>l</kbd>
182 <kbd>h</kbd>193 <kbd>h</kbd>
183 </dt>194 </dt>
184-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>195+<dd>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</dd>
185 </div>196 </div>
186 <div>197 <div>
187 <dt>198 <dt>
@@ -194,7 +205,7 @@
194 <dt>205 <dt>
195 <kbd>/</kbd>206 <kbd>/</kbd>
196 </dt>207 </dt>
197-<dd>filter the list; on Files, find a file in the repository</dd>208+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
198 </div>209 </div>
199 <div>210 <div>
200 <dt>211 <dt>
M internal/app/testdata/web/blob-code.html +14 -3
@@ -244,14 +244,25 @@
244244 <dt>
245245 <kbd>enter</kbd>
246246 </dt>
247-<dd>open the row under the cursor</dd>
247+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
248+</div>
249+<div>
250+<dt>
251+<kbd>↑</kbd>
252+<kbd>↓</kbd>
253+<kbd>←</kbd>
254+<kbd>→</kbd>
255+<kbd>home</kbd>
256+<kbd>end</kbd>
257+</dt>
258+<dd>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</dd>
248259 </div>
249260 <div>
250261 <dt>
251262 <kbd>l</kbd>
252263 <kbd>h</kbd>
253264 </dt>
254-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
265+<dd>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</dd>
255266 </div>
256267 <div>
257268 <dt>
@@ -264,7 +275,7 @@
264275 <dt>
265276 <kbd>/</kbd>
266277 </dt>
267-<dd>filter the list; on Files, find a file in the repository</dd>
278+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
268279 </div>
269280 <div>
270281 <dt>
@@ -244,14 +244,25 @@
244 <dt>244 <dt>
245 <kbd>enter</kbd>245 <kbd>enter</kbd>
246 </dt>246 </dt>
247-<dd>open the row under the cursor</dd>247+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
248+</div>
249+<div>
250+<dt>
251+<kbd>↑</kbd>
252+<kbd>↓</kbd>
253+<kbd>←</kbd>
254+<kbd>→</kbd>
255+<kbd>home</kbd>
256+<kbd>end</kbd>
257+</dt>
258+<dd>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</dd>
248 </div>259 </div>
249 <div>260 <div>
250 <dt>261 <dt>
251 <kbd>l</kbd>262 <kbd>l</kbd>
252 <kbd>h</kbd>263 <kbd>h</kbd>
253 </dt>264 </dt>
254-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>265+<dd>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</dd>
255 </div>266 </div>
256 <div>267 <div>
257 <dt>268 <dt>
@@ -264,7 +275,7 @@
264 <dt>275 <dt>
265 <kbd>/</kbd>276 <kbd>/</kbd>
266 </dt>277 </dt>
267-<dd>filter the list; on Files, find a file in the repository</dd>278+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
268 </div>279 </div>
269 <div>280 <div>
270 <dt>281 <dt>
M internal/app/testdata/web/blob-empty.html +14 -3
@@ -174,14 +174,25 @@
174174 <dt>
175175 <kbd>enter</kbd>
176176 </dt>
177-<dd>open the row under the cursor</dd>
177+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
178+</div>
179+<div>
180+<dt>
181+<kbd>↑</kbd>
182+<kbd>↓</kbd>
183+<kbd>←</kbd>
184+<kbd>→</kbd>
185+<kbd>home</kbd>
186+<kbd>end</kbd>
187+</dt>
188+<dd>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</dd>
178189 </div>
179190 <div>
180191 <dt>
181192 <kbd>l</kbd>
182193 <kbd>h</kbd>
183194 </dt>
184-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
195+<dd>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</dd>
185196 </div>
186197 <div>
187198 <dt>
@@ -194,7 +205,7 @@
194205 <dt>
195206 <kbd>/</kbd>
196207 </dt>
197-<dd>filter the list; on Files, find a file in the repository</dd>
208+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
198209 </div>
199210 <div>
200211 <dt>
@@ -174,14 +174,25 @@
174 <dt>174 <dt>
175 <kbd>enter</kbd>175 <kbd>enter</kbd>
176 </dt>176 </dt>
177-<dd>open the row under the cursor</dd>177+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
178+</div>
179+<div>
180+<dt>
181+<kbd>↑</kbd>
182+<kbd>↓</kbd>
183+<kbd>←</kbd>
184+<kbd>→</kbd>
185+<kbd>home</kbd>
186+<kbd>end</kbd>
187+</dt>
188+<dd>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</dd>
178 </div>189 </div>
179 <div>190 <div>
180 <dt>191 <dt>
181 <kbd>l</kbd>192 <kbd>l</kbd>
182 <kbd>h</kbd>193 <kbd>h</kbd>
183 </dt>194 </dt>
184-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>195+<dd>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</dd>
185 </div>196 </div>
186 <div>197 <div>
187 <dt>198 <dt>
@@ -194,7 +205,7 @@
194 <dt>205 <dt>
195 <kbd>/</kbd>206 <kbd>/</kbd>
196 </dt>207 </dt>
197-<dd>filter the list; on Files, find a file in the repository</dd>208+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
198 </div>209 </div>
199 <div>210 <div>
200 <dt>211 <dt>
M internal/app/testdata/web/blob-image.html +14 -3
@@ -176,14 +176,25 @@
176176 <dt>
177177 <kbd>enter</kbd>
178178 </dt>
179-<dd>open the row under the cursor</dd>
179+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
180+</div>
181+<div>
182+<dt>
183+<kbd>↑</kbd>
184+<kbd>↓</kbd>
185+<kbd>←</kbd>
186+<kbd>→</kbd>
187+<kbd>home</kbd>
188+<kbd>end</kbd>
189+</dt>
190+<dd>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</dd>
180191 </div>
181192 <div>
182193 <dt>
183194 <kbd>l</kbd>
184195 <kbd>h</kbd>
185196 </dt>
186-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
197+<dd>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</dd>
187198 </div>
188199 <div>
189200 <dt>
@@ -196,7 +207,7 @@
196207 <dt>
197208 <kbd>/</kbd>
198209 </dt>
199-<dd>filter the list; on Files, find a file in the repository</dd>
210+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
200211 </div>
201212 <div>
202213 <dt>
@@ -176,14 +176,25 @@
176 <dt>176 <dt>
177 <kbd>enter</kbd>177 <kbd>enter</kbd>
178 </dt>178 </dt>
179-<dd>open the row under the cursor</dd>179+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
180+</div>
181+<div>
182+<dt>
183+<kbd>↑</kbd>
184+<kbd>↓</kbd>
185+<kbd>←</kbd>
186+<kbd>→</kbd>
187+<kbd>home</kbd>
188+<kbd>end</kbd>
189+</dt>
190+<dd>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</dd>
180 </div>191 </div>
181 <div>192 <div>
182 <dt>193 <dt>
183 <kbd>l</kbd>194 <kbd>l</kbd>
184 <kbd>h</kbd>195 <kbd>h</kbd>
185 </dt>196 </dt>
186-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>197+<dd>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</dd>
187 </div>198 </div>
188 <div>199 <div>
189 <dt>200 <dt>
@@ -196,7 +207,7 @@
196 <dt>207 <dt>
197 <kbd>/</kbd>208 <kbd>/</kbd>
198 </dt>209 </dt>
199-<dd>filter the list; on Files, find a file in the repository</dd>210+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
200 </div>211 </div>
201 <div>212 <div>
202 <dt>213 <dt>
M internal/app/testdata/web/blob-markdown.html +14 -3
@@ -241,14 +241,25 @@
241241 <dt>
242242 <kbd>enter</kbd>
243243 </dt>
244-<dd>open the row under the cursor</dd>
244+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
245+</div>
246+<div>
247+<dt>
248+<kbd>↑</kbd>
249+<kbd>↓</kbd>
250+<kbd>←</kbd>
251+<kbd>→</kbd>
252+<kbd>home</kbd>
253+<kbd>end</kbd>
254+</dt>
255+<dd>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</dd>
245256 </div>
246257 <div>
247258 <dt>
248259 <kbd>l</kbd>
249260 <kbd>h</kbd>
250261 </dt>
251-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
262+<dd>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</dd>
252263 </div>
253264 <div>
254265 <dt>
@@ -261,7 +272,7 @@
261272 <dt>
262273 <kbd>/</kbd>
263274 </dt>
264-<dd>filter the list; on Files, find a file in the repository</dd>
275+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
265276 </div>
266277 <div>
267278 <dt>
@@ -241,14 +241,25 @@
241 <dt>241 <dt>
242 <kbd>enter</kbd>242 <kbd>enter</kbd>
243 </dt>243 </dt>
244-<dd>open the row under the cursor</dd>244+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
245+</div>
246+<div>
247+<dt>
248+<kbd>↑</kbd>
249+<kbd>↓</kbd>
250+<kbd>←</kbd>
251+<kbd>→</kbd>
252+<kbd>home</kbd>
253+<kbd>end</kbd>
254+</dt>
255+<dd>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</dd>
245 </div>256 </div>
246 <div>257 <div>
247 <dt>258 <dt>
248 <kbd>l</kbd>259 <kbd>l</kbd>
249 <kbd>h</kbd>260 <kbd>h</kbd>
250 </dt>261 </dt>
251-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>262+<dd>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</dd>
252 </div>263 </div>
253 <div>264 <div>
254 <dt>265 <dt>
@@ -261,7 +272,7 @@
261 <dt>272 <dt>
262 <kbd>/</kbd>273 <kbd>/</kbd>
263 </dt>274 </dt>
264-<dd>filter the list; on Files, find a file in the repository</dd>275+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
265 </div>276 </div>
266 <div>277 <div>
267 <dt>278 <dt>
M internal/app/testdata/web/blob-svg.html +14 -3
@@ -199,14 +199,25 @@
199199 <dt>
200200 <kbd>enter</kbd>
201201 </dt>
202-<dd>open the row under the cursor</dd>
202+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
203+</div>
204+<div>
205+<dt>
206+<kbd>↑</kbd>
207+<kbd>↓</kbd>
208+<kbd>←</kbd>
209+<kbd>→</kbd>
210+<kbd>home</kbd>
211+<kbd>end</kbd>
212+</dt>
213+<dd>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</dd>
203214 </div>
204215 <div>
205216 <dt>
206217 <kbd>l</kbd>
207218 <kbd>h</kbd>
208219 </dt>
209-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
220+<dd>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</dd>
210221 </div>
211222 <div>
212223 <dt>
@@ -219,7 +230,7 @@
219230 <dt>
220231 <kbd>/</kbd>
221232 </dt>
222-<dd>filter the list; on Files, find a file in the repository</dd>
233+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
223234 </div>
224235 <div>
225236 <dt>
@@ -199,14 +199,25 @@
199 <dt>199 <dt>
200 <kbd>enter</kbd>200 <kbd>enter</kbd>
201 </dt>201 </dt>
202-<dd>open the row under the cursor</dd>202+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
203+</div>
204+<div>
205+<dt>
206+<kbd>↑</kbd>
207+<kbd>↓</kbd>
208+<kbd>←</kbd>
209+<kbd>→</kbd>
210+<kbd>home</kbd>
211+<kbd>end</kbd>
212+</dt>
213+<dd>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</dd>
203 </div>214 </div>
204 <div>215 <div>
205 <dt>216 <dt>
206 <kbd>l</kbd>217 <kbd>l</kbd>
207 <kbd>h</kbd>218 <kbd>h</kbd>
208 </dt>219 </dt>
209-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>220+<dd>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</dd>
210 </div>221 </div>
211 <div>222 <div>
212 <dt>223 <dt>
@@ -219,7 +230,7 @@
219 <dt>230 <dt>
220 <kbd>/</kbd>231 <kbd>/</kbd>
221 </dt>232 </dt>
222-<dd>filter the list; on Files, find a file in the repository</dd>233+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
223 </div>234 </div>
224 <div>235 <div>
225 <dt>236 <dt>
M internal/app/testdata/web/blob-text.html +14 -3
@@ -194,14 +194,25 @@
194194 <dt>
195195 <kbd>enter</kbd>
196196 </dt>
197-<dd>open the row under the cursor</dd>
197+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
198+</div>
199+<div>
200+<dt>
201+<kbd>↑</kbd>
202+<kbd>↓</kbd>
203+<kbd>←</kbd>
204+<kbd>→</kbd>
205+<kbd>home</kbd>
206+<kbd>end</kbd>
207+</dt>
208+<dd>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</dd>
198209 </div>
199210 <div>
200211 <dt>
201212 <kbd>l</kbd>
202213 <kbd>h</kbd>
203214 </dt>
204-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
215+<dd>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</dd>
205216 </div>
206217 <div>
207218 <dt>
@@ -214,7 +225,7 @@
214225 <dt>
215226 <kbd>/</kbd>
216227 </dt>
217-<dd>filter the list; on Files, find a file in the repository</dd>
228+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
218229 </div>
219230 <div>
220231 <dt>
@@ -194,14 +194,25 @@
194 <dt>194 <dt>
195 <kbd>enter</kbd>195 <kbd>enter</kbd>
196 </dt>196 </dt>
197-<dd>open the row under the cursor</dd>197+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
198+</div>
199+<div>
200+<dt>
201+<kbd>↑</kbd>
202+<kbd>↓</kbd>
203+<kbd>←</kbd>
204+<kbd>→</kbd>
205+<kbd>home</kbd>
206+<kbd>end</kbd>
207+</dt>
208+<dd>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</dd>
198 </div>209 </div>
199 <div>210 <div>
200 <dt>211 <dt>
201 <kbd>l</kbd>212 <kbd>l</kbd>
202 <kbd>h</kbd>213 <kbd>h</kbd>
203 </dt>214 </dt>
204-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>215+<dd>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</dd>
205 </div>216 </div>
206 <div>217 <div>
207 <dt>218 <dt>
@@ -214,7 +225,7 @@
214 <dt>225 <dt>
215 <kbd>/</kbd>226 <kbd>/</kbd>
216 </dt>227 </dt>
217-<dd>filter the list; on Files, find a file in the repository</dd>228+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
218 </div>229 </div>
219 <div>230 <div>
220 <dt>231 <dt>
M internal/app/testdata/web/blob-too-large.html +14 -3
@@ -174,14 +174,25 @@
174174 <dt>
175175 <kbd>enter</kbd>
176176 </dt>
177-<dd>open the row under the cursor</dd>
177+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
178+</div>
179+<div>
180+<dt>
181+<kbd>↑</kbd>
182+<kbd>↓</kbd>
183+<kbd>←</kbd>
184+<kbd>→</kbd>
185+<kbd>home</kbd>
186+<kbd>end</kbd>
187+</dt>
188+<dd>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</dd>
178189 </div>
179190 <div>
180191 <dt>
181192 <kbd>l</kbd>
182193 <kbd>h</kbd>
183194 </dt>
184-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
195+<dd>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</dd>
185196 </div>
186197 <div>
187198 <dt>
@@ -194,7 +205,7 @@
194205 <dt>
195206 <kbd>/</kbd>
196207 </dt>
197-<dd>filter the list; on Files, find a file in the repository</dd>
208+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
198209 </div>
199210 <div>
200211 <dt>
@@ -174,14 +174,25 @@
174 <dt>174 <dt>
175 <kbd>enter</kbd>175 <kbd>enter</kbd>
176 </dt>176 </dt>
177-<dd>open the row under the cursor</dd>177+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
178+</div>
179+<div>
180+<dt>
181+<kbd>↑</kbd>
182+<kbd>↓</kbd>
183+<kbd>←</kbd>
184+<kbd>→</kbd>
185+<kbd>home</kbd>
186+<kbd>end</kbd>
187+</dt>
188+<dd>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</dd>
178 </div>189 </div>
179 <div>190 <div>
180 <dt>191 <dt>
181 <kbd>l</kbd>192 <kbd>l</kbd>
182 <kbd>h</kbd>193 <kbd>h</kbd>
183 </dt>194 </dt>
184-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>195+<dd>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</dd>
185 </div>196 </div>
186 <div>197 <div>
187 <dt>198 <dt>
@@ -194,7 +205,7 @@
194 <dt>205 <dt>
195 <kbd>/</kbd>206 <kbd>/</kbd>
196 </dt>207 </dt>
197-<dd>filter the list; on Files, find a file in the repository</dd>208+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
198 </div>209 </div>
199 <div>210 <div>
200 <dt>211 <dt>
M internal/app/testdata/web/blob-trojan.html +14 -3
@@ -241,14 +241,25 @@
241241 <dt>
242242 <kbd>enter</kbd>
243243 </dt>
244-<dd>open the row under the cursor</dd>
244+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
245+</div>
246+<div>
247+<dt>
248+<kbd>↑</kbd>
249+<kbd>↓</kbd>
250+<kbd>←</kbd>
251+<kbd>→</kbd>
252+<kbd>home</kbd>
253+<kbd>end</kbd>
254+</dt>
255+<dd>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</dd>
245256 </div>
246257 <div>
247258 <dt>
248259 <kbd>l</kbd>
249260 <kbd>h</kbd>
250261 </dt>
251-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
262+<dd>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</dd>
252263 </div>
253264 <div>
254265 <dt>
@@ -261,7 +272,7 @@
261272 <dt>
262273 <kbd>/</kbd>
263274 </dt>
264-<dd>filter the list; on Files, find a file in the repository</dd>
275+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
265276 </div>
266277 <div>
267278 <dt>
@@ -241,14 +241,25 @@
241 <dt>241 <dt>
242 <kbd>enter</kbd>242 <kbd>enter</kbd>
243 </dt>243 </dt>
244-<dd>open the row under the cursor</dd>244+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
245+</div>
246+<div>
247+<dt>
248+<kbd>↑</kbd>
249+<kbd>↓</kbd>
250+<kbd>←</kbd>
251+<kbd>→</kbd>
252+<kbd>home</kbd>
253+<kbd>end</kbd>
254+</dt>
255+<dd>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</dd>
245 </div>256 </div>
246 <div>257 <div>
247 <dt>258 <dt>
248 <kbd>l</kbd>259 <kbd>l</kbd>
249 <kbd>h</kbd>260 <kbd>h</kbd>
250 </dt>261 </dt>
251-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>262+<dd>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</dd>
252 </div>263 </div>
253 <div>264 <div>
254 <dt>265 <dt>
@@ -261,7 +272,7 @@
261 <dt>272 <dt>
262 <kbd>/</kbd>273 <kbd>/</kbd>
263 </dt>274 </dt>
264-<dd>filter the list; on Files, find a file in the repository</dd>275+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
265 </div>276 </div>
266 <div>277 <div>
267 <dt>278 <dt>
M internal/app/testdata/web/commit-big.html +14 -3
@@ -212,14 +212,25 @@
212212 <dt>
213213 <kbd>enter</kbd>
214214 </dt>
215-<dd>open the row under the cursor</dd>
215+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
216+</div>
217+<div>
218+<dt>
219+<kbd>↑</kbd>
220+<kbd>↓</kbd>
221+<kbd>←</kbd>
222+<kbd>→</kbd>
223+<kbd>home</kbd>
224+<kbd>end</kbd>
225+</dt>
226+<dd>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</dd>
216227 </div>
217228 <div>
218229 <dt>
219230 <kbd>l</kbd>
220231 <kbd>h</kbd>
221232 </dt>
222-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
233+<dd>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</dd>
223234 </div>
224235 <div>
225236 <dt>
@@ -232,7 +243,7 @@
232243 <dt>
233244 <kbd>/</kbd>
234245 </dt>
235-<dd>filter the list; on Files, find a file in the repository</dd>
246+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
236247 </div>
237248 <div>
238249 <dt>
@@ -212,14 +212,25 @@
212 <dt>212 <dt>
213 <kbd>enter</kbd>213 <kbd>enter</kbd>
214 </dt>214 </dt>
215-<dd>open the row under the cursor</dd>215+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
216+</div>
217+<div>
218+<dt>
219+<kbd>↑</kbd>
220+<kbd>↓</kbd>
221+<kbd>←</kbd>
222+<kbd>→</kbd>
223+<kbd>home</kbd>
224+<kbd>end</kbd>
225+</dt>
226+<dd>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</dd>
216 </div>227 </div>
217 <div>228 <div>
218 <dt>229 <dt>
219 <kbd>l</kbd>230 <kbd>l</kbd>
220 <kbd>h</kbd>231 <kbd>h</kbd>
221 </dt>232 </dt>
222-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>233+<dd>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</dd>
223 </div>234 </div>
224 <div>235 <div>
225 <dt>236 <dt>
@@ -232,7 +243,7 @@
232 <dt>243 <dt>
233 <kbd>/</kbd>244 <kbd>/</kbd>
234 </dt>245 </dt>
235-<dd>filter the list; on Files, find a file in the repository</dd>246+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
236 </div>247 </div>
237 <div>248 <div>
238 <dt>249 <dt>
M internal/app/testdata/web/commit-binary.html +14 -3
@@ -204,14 +204,25 @@
204204 <dt>
205205 <kbd>enter</kbd>
206206 </dt>
207-<dd>open the row under the cursor</dd>
207+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
208+</div>
209+<div>
210+<dt>
211+<kbd>↑</kbd>
212+<kbd>↓</kbd>
213+<kbd>←</kbd>
214+<kbd>→</kbd>
215+<kbd>home</kbd>
216+<kbd>end</kbd>
217+</dt>
218+<dd>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</dd>
208219 </div>
209220 <div>
210221 <dt>
211222 <kbd>l</kbd>
212223 <kbd>h</kbd>
213224 </dt>
214-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
225+<dd>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</dd>
215226 </div>
216227 <div>
217228 <dt>
@@ -224,7 +235,7 @@
224235 <dt>
225236 <kbd>/</kbd>
226237 </dt>
227-<dd>filter the list; on Files, find a file in the repository</dd>
238+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
228239 </div>
229240 <div>
230241 <dt>
@@ -204,14 +204,25 @@
204 <dt>204 <dt>
205 <kbd>enter</kbd>205 <kbd>enter</kbd>
206 </dt>206 </dt>
207-<dd>open the row under the cursor</dd>207+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
208+</div>
209+<div>
210+<dt>
211+<kbd>↑</kbd>
212+<kbd>↓</kbd>
213+<kbd>←</kbd>
214+<kbd>→</kbd>
215+<kbd>home</kbd>
216+<kbd>end</kbd>
217+</dt>
218+<dd>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</dd>
208 </div>219 </div>
209 <div>220 <div>
210 <dt>221 <dt>
211 <kbd>l</kbd>222 <kbd>l</kbd>
212 <kbd>h</kbd>223 <kbd>h</kbd>
213 </dt>224 </dt>
214-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>225+<dd>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</dd>
215 </div>226 </div>
216 <div>227 <div>
217 <dt>228 <dt>
@@ -224,7 +235,7 @@
224 <dt>235 <dt>
225 <kbd>/</kbd>236 <kbd>/</kbd>
226 </dt>237 </dt>
227-<dd>filter the list; on Files, find a file in the repository</dd>238+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
228 </div>239 </div>
229 <div>240 <div>
230 <dt>241 <dt>
M internal/app/testdata/web/commit-merge.html +14 -3
@@ -246,14 +246,25 @@
246246 <dt>
247247 <kbd>enter</kbd>
248248 </dt>
249-<dd>open the row under the cursor</dd>
249+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
250+</div>
251+<div>
252+<dt>
253+<kbd>↑</kbd>
254+<kbd>↓</kbd>
255+<kbd>←</kbd>
256+<kbd>→</kbd>
257+<kbd>home</kbd>
258+<kbd>end</kbd>
259+</dt>
260+<dd>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</dd>
250261 </div>
251262 <div>
252263 <dt>
253264 <kbd>l</kbd>
254265 <kbd>h</kbd>
255266 </dt>
256-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
267+<dd>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</dd>
257268 </div>
258269 <div>
259270 <dt>
@@ -266,7 +277,7 @@
266277 <dt>
267278 <kbd>/</kbd>
268279 </dt>
269-<dd>filter the list; on Files, find a file in the repository</dd>
280+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
270281 </div>
271282 <div>
272283 <dt>
@@ -246,14 +246,25 @@
246 <dt>246 <dt>
247 <kbd>enter</kbd>247 <kbd>enter</kbd>
248 </dt>248 </dt>
249-<dd>open the row under the cursor</dd>249+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
250+</div>
251+<div>
252+<dt>
253+<kbd>↑</kbd>
254+<kbd>↓</kbd>
255+<kbd>←</kbd>
256+<kbd>→</kbd>
257+<kbd>home</kbd>
258+<kbd>end</kbd>
259+</dt>
260+<dd>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</dd>
250 </div>261 </div>
251 <div>262 <div>
252 <dt>263 <dt>
253 <kbd>l</kbd>264 <kbd>l</kbd>
254 <kbd>h</kbd>265 <kbd>h</kbd>
255 </dt>266 </dt>
256-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>267+<dd>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</dd>
257 </div>268 </div>
258 <div>269 <div>
259 <dt>270 <dt>
@@ -266,7 +277,7 @@
266 <dt>277 <dt>
267 <kbd>/</kbd>278 <kbd>/</kbd>
268 </dt>279 </dt>
269-<dd>filter the list; on Files, find a file in the repository</dd>280+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
270 </div>281 </div>
271 <div>282 <div>
272 <dt>283 <dt>
M internal/app/testdata/web/commit-modify-split.html +14 -3
@@ -369,14 +369,25 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
369369 <dt>
370370 <kbd>enter</kbd>
371371 </dt>
372-<dd>open the row under the cursor</dd>
372+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
373+</div>
374+<div>
375+<dt>
376+<kbd>↑</kbd>
377+<kbd>↓</kbd>
378+<kbd>←</kbd>
379+<kbd>→</kbd>
380+<kbd>home</kbd>
381+<kbd>end</kbd>
382+</dt>
383+<dd>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</dd>
373384 </div>
374385 <div>
375386 <dt>
376387 <kbd>l</kbd>
377388 <kbd>h</kbd>
378389 </dt>
379-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
390+<dd>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</dd>
380391 </div>
381392 <div>
382393 <dt>
@@ -389,7 +400,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
389400 <dt>
390401 <kbd>/</kbd>
391402 </dt>
392-<dd>filter the list; on Files, find a file in the repository</dd>
403+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
393404 </div>
394405 <div>
395406 <dt>
@@ -369,14 +369,25 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
369 <dt>369 <dt>
370 <kbd>enter</kbd>370 <kbd>enter</kbd>
371 </dt>371 </dt>
372-<dd>open the row under the cursor</dd>372+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
373+</div>
374+<div>
375+<dt>
376+<kbd>↑</kbd>
377+<kbd>↓</kbd>
378+<kbd>←</kbd>
379+<kbd>→</kbd>
380+<kbd>home</kbd>
381+<kbd>end</kbd>
382+</dt>
383+<dd>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</dd>
373 </div>384 </div>
374 <div>385 <div>
375 <dt>386 <dt>
376 <kbd>l</kbd>387 <kbd>l</kbd>
377 <kbd>h</kbd>388 <kbd>h</kbd>
378 </dt>389 </dt>
379-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>390+<dd>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</dd>
380 </div>391 </div>
381 <div>392 <div>
382 <dt>393 <dt>
@@ -389,7 +400,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
389 <dt>400 <dt>
390 <kbd>/</kbd>401 <kbd>/</kbd>
391 </dt>402 </dt>
392-<dd>filter the list; on Files, find a file in the repository</dd>403+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
393 </div>404 </div>
394 <div>405 <div>
395 <dt>406 <dt>
M internal/app/testdata/web/commit-modify.html +14 -3
@@ -369,14 +369,25 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
369369 <dt>
370370 <kbd>enter</kbd>
371371 </dt>
372-<dd>open the row under the cursor</dd>
372+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
373+</div>
374+<div>
375+<dt>
376+<kbd>↑</kbd>
377+<kbd>↓</kbd>
378+<kbd>←</kbd>
379+<kbd>→</kbd>
380+<kbd>home</kbd>
381+<kbd>end</kbd>
382+</dt>
383+<dd>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</dd>
373384 </div>
374385 <div>
375386 <dt>
376387 <kbd>l</kbd>
377388 <kbd>h</kbd>
378389 </dt>
379-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
390+<dd>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</dd>
380391 </div>
381392 <div>
382393 <dt>
@@ -389,7 +400,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
389400 <dt>
390401 <kbd>/</kbd>
391402 </dt>
392-<dd>filter the list; on Files, find a file in the repository</dd>
403+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
393404 </div>
394405 <div>
395406 <dt>
@@ -369,14 +369,25 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
369 <dt>369 <dt>
370 <kbd>enter</kbd>370 <kbd>enter</kbd>
371 </dt>371 </dt>
372-<dd>open the row under the cursor</dd>372+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
373+</div>
374+<div>
375+<dt>
376+<kbd>↑</kbd>
377+<kbd>↓</kbd>
378+<kbd>←</kbd>
379+<kbd>→</kbd>
380+<kbd>home</kbd>
381+<kbd>end</kbd>
382+</dt>
383+<dd>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</dd>
373 </div>384 </div>
374 <div>385 <div>
375 <dt>386 <dt>
376 <kbd>l</kbd>387 <kbd>l</kbd>
377 <kbd>h</kbd>388 <kbd>h</kbd>
378 </dt>389 </dt>
379-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>390+<dd>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</dd>
380 </div>391 </div>
381 <div>392 <div>
382 <dt>393 <dt>
@@ -389,7 +400,7 @@ And &lt;b&gt;html&lt;/b&gt; &amp; &#34;quotes&#34;.</pre>
389 <dt>400 <dt>
390 <kbd>/</kbd>401 <kbd>/</kbd>
391 </dt>402 </dt>
392-<dd>filter the list; on Files, find a file in the repository</dd>403+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
393 </div>404 </div>
394 <div>405 <div>
395 <dt>406 <dt>
M internal/app/testdata/web/commit-rename.html +14 -3
@@ -273,14 +273,25 @@
273273 <dt>
274274 <kbd>enter</kbd>
275275 </dt>
276-<dd>open the row under the cursor</dd>
276+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
277+</div>
278+<div>
279+<dt>
280+<kbd>↑</kbd>
281+<kbd>↓</kbd>
282+<kbd>←</kbd>
283+<kbd>→</kbd>
284+<kbd>home</kbd>
285+<kbd>end</kbd>
286+</dt>
287+<dd>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</dd>
277288 </div>
278289 <div>
279290 <dt>
280291 <kbd>l</kbd>
281292 <kbd>h</kbd>
282293 </dt>
283-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
294+<dd>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</dd>
284295 </div>
285296 <div>
286297 <dt>
@@ -293,7 +304,7 @@
293304 <dt>
294305 <kbd>/</kbd>
295306 </dt>
296-<dd>filter the list; on Files, find a file in the repository</dd>
307+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
297308 </div>
298309 <div>
299310 <dt>
@@ -273,14 +273,25 @@
273 <dt>273 <dt>
274 <kbd>enter</kbd>274 <kbd>enter</kbd>
275 </dt>275 </dt>
276-<dd>open the row under the cursor</dd>276+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
277+</div>
278+<div>
279+<dt>
280+<kbd>↑</kbd>
281+<kbd>↓</kbd>
282+<kbd>←</kbd>
283+<kbd>→</kbd>
284+<kbd>home</kbd>
285+<kbd>end</kbd>
286+</dt>
287+<dd>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</dd>
277 </div>288 </div>
278 <div>289 <div>
279 <dt>290 <dt>
280 <kbd>l</kbd>291 <kbd>l</kbd>
281 <kbd>h</kbd>292 <kbd>h</kbd>
282 </dt>293 </dt>
283-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>294+<dd>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</dd>
284 </div>295 </div>
285 <div>296 <div>
286 <dt>297 <dt>
@@ -293,7 +304,7 @@
293 <dt>304 <dt>
294 <kbd>/</kbd>305 <kbd>/</kbd>
295 </dt>306 </dt>
296-<dd>filter the list; on Files, find a file in the repository</dd>307+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
297 </div>308 </div>
298 <div>309 <div>
299 <dt>310 <dt>
M internal/app/testdata/web/commit-root.html +14 -3
@@ -592,14 +592,25 @@
592592 <dt>
593593 <kbd>enter</kbd>
594594 </dt>
595-<dd>open the row under the cursor</dd>
595+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
596+</div>
597+<div>
598+<dt>
599+<kbd>↑</kbd>
600+<kbd>↓</kbd>
601+<kbd>←</kbd>
602+<kbd>→</kbd>
603+<kbd>home</kbd>
604+<kbd>end</kbd>
605+</dt>
606+<dd>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</dd>
596607 </div>
597608 <div>
598609 <dt>
599610 <kbd>l</kbd>
600611 <kbd>h</kbd>
601612 </dt>
602-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
613+<dd>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</dd>
603614 </div>
604615 <div>
605616 <dt>
@@ -612,7 +623,7 @@
612623 <dt>
613624 <kbd>/</kbd>
614625 </dt>
615-<dd>filter the list; on Files, find a file in the repository</dd>
626+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
616627 </div>
617628 <div>
618629 <dt>
@@ -592,14 +592,25 @@
592 <dt>592 <dt>
593 <kbd>enter</kbd>593 <kbd>enter</kbd>
594 </dt>594 </dt>
595-<dd>open the row under the cursor</dd>595+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
596+</div>
597+<div>
598+<dt>
599+<kbd>↑</kbd>
600+<kbd>↓</kbd>
601+<kbd>←</kbd>
602+<kbd>→</kbd>
603+<kbd>home</kbd>
604+<kbd>end</kbd>
605+</dt>
606+<dd>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</dd>
596 </div>607 </div>
597 <div>608 <div>
598 <dt>609 <dt>
599 <kbd>l</kbd>610 <kbd>l</kbd>
600 <kbd>h</kbd>611 <kbd>h</kbd>
601 </dt>612 </dt>
602-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>613+<dd>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</dd>
603 </div>614 </div>
604 <div>615 <div>
605 <dt>616 <dt>
@@ -612,7 +623,7 @@
612 <dt>623 <dt>
613 <kbd>/</kbd>624 <kbd>/</kbd>
614 </dt>625 </dt>
615-<dd>filter the list; on Files, find a file in the repository</dd>626+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
616 </div>627 </div>
617 <div>628 <div>
618 <dt>629 <dt>
M internal/app/testdata/web/commit-trojan.html +14 -3
@@ -381,14 +381,25 @@
381381 <dt>
382382 <kbd>enter</kbd>
383383 </dt>
384-<dd>open the row under the cursor</dd>
384+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
385+</div>
386+<div>
387+<dt>
388+<kbd>↑</kbd>
389+<kbd>↓</kbd>
390+<kbd>←</kbd>
391+<kbd>→</kbd>
392+<kbd>home</kbd>
393+<kbd>end</kbd>
394+</dt>
395+<dd>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</dd>
385396 </div>
386397 <div>
387398 <dt>
388399 <kbd>l</kbd>
389400 <kbd>h</kbd>
390401 </dt>
391-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
402+<dd>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</dd>
392403 </div>
393404 <div>
394405 <dt>
@@ -401,7 +412,7 @@
401412 <dt>
402413 <kbd>/</kbd>
403414 </dt>
404-<dd>filter the list; on Files, find a file in the repository</dd>
415+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
405416 </div>
406417 <div>
407418 <dt>
@@ -381,14 +381,25 @@
381 <dt>381 <dt>
382 <kbd>enter</kbd>382 <kbd>enter</kbd>
383 </dt>383 </dt>
384-<dd>open the row under the cursor</dd>384+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
385+</div>
386+<div>
387+<dt>
388+<kbd>↑</kbd>
389+<kbd>↓</kbd>
390+<kbd>←</kbd>
391+<kbd>→</kbd>
392+<kbd>home</kbd>
393+<kbd>end</kbd>
394+</dt>
395+<dd>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</dd>
385 </div>396 </div>
386 <div>397 <div>
387 <dt>398 <dt>
388 <kbd>l</kbd>399 <kbd>l</kbd>
389 <kbd>h</kbd>400 <kbd>h</kbd>
390 </dt>401 </dt>
391-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>402+<dd>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</dd>
392 </div>403 </div>
393 <div>404 <div>
394 <dt>405 <dt>
@@ -401,7 +412,7 @@
401 <dt>412 <dt>
402 <kbd>/</kbd>413 <kbd>/</kbd>
403 </dt>414 </dt>
404-<dd>filter the list; on Files, find a file in the repository</dd>415+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
405 </div>416 </div>
406 <div>417 <div>
407 <dt>418 <dt>
M internal/app/testdata/web/commits-page2.html +14 -3
@@ -205,14 +205,25 @@
205205 <dt>
206206 <kbd>enter</kbd>
207207 </dt>
208-<dd>open the row under the cursor</dd>
208+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
209+</div>
210+<div>
211+<dt>
212+<kbd>↑</kbd>
213+<kbd>↓</kbd>
214+<kbd>←</kbd>
215+<kbd>→</kbd>
216+<kbd>home</kbd>
217+<kbd>end</kbd>
218+</dt>
219+<dd>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</dd>
209220 </div>
210221 <div>
211222 <dt>
212223 <kbd>l</kbd>
213224 <kbd>h</kbd>
214225 </dt>
215-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
226+<dd>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</dd>
216227 </div>
217228 <div>
218229 <dt>
@@ -225,7 +236,7 @@
225236 <dt>
226237 <kbd>/</kbd>
227238 </dt>
228-<dd>filter the list; on Files, find a file in the repository</dd>
239+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
229240 </div>
230241 <div>
231242 <dt>
@@ -205,14 +205,25 @@
205 <dt>205 <dt>
206 <kbd>enter</kbd>206 <kbd>enter</kbd>
207 </dt>207 </dt>
208-<dd>open the row under the cursor</dd>208+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
209+</div>
210+<div>
211+<dt>
212+<kbd>↑</kbd>
213+<kbd>↓</kbd>
214+<kbd>←</kbd>
215+<kbd>→</kbd>
216+<kbd>home</kbd>
217+<kbd>end</kbd>
218+</dt>
219+<dd>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</dd>
209 </div>220 </div>
210 <div>221 <div>
211 <dt>222 <dt>
212 <kbd>l</kbd>223 <kbd>l</kbd>
213 <kbd>h</kbd>224 <kbd>h</kbd>
214 </dt>225 </dt>
215-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>226+<dd>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</dd>
216 </div>227 </div>
217 <div>228 <div>
218 <dt>229 <dt>
@@ -225,7 +236,7 @@
225 <dt>236 <dt>
226 <kbd>/</kbd>237 <kbd>/</kbd>
227 </dt>238 </dt>
228-<dd>filter the list; on Files, find a file in the repository</dd>239+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
229 </div>240 </div>
230 <div>241 <div>
231 <dt>242 <dt>
M internal/app/testdata/web/commits-path.html +14 -3
@@ -220,14 +220,25 @@
220220 <dt>
221221 <kbd>enter</kbd>
222222 </dt>
223-<dd>open the row under the cursor</dd>
223+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
224+</div>
225+<div>
226+<dt>
227+<kbd>↑</kbd>
228+<kbd>↓</kbd>
229+<kbd>←</kbd>
230+<kbd>→</kbd>
231+<kbd>home</kbd>
232+<kbd>end</kbd>
233+</dt>
234+<dd>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</dd>
224235 </div>
225236 <div>
226237 <dt>
227238 <kbd>l</kbd>
228239 <kbd>h</kbd>
229240 </dt>
230-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
241+<dd>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</dd>
231242 </div>
232243 <div>
233244 <dt>
@@ -240,7 +251,7 @@
240251 <dt>
241252 <kbd>/</kbd>
242253 </dt>
243-<dd>filter the list; on Files, find a file in the repository</dd>
254+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
244255 </div>
245256 <div>
246257 <dt>
@@ -220,14 +220,25 @@
220 <dt>220 <dt>
221 <kbd>enter</kbd>221 <kbd>enter</kbd>
222 </dt>222 </dt>
223-<dd>open the row under the cursor</dd>223+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
224+</div>
225+<div>
226+<dt>
227+<kbd>↑</kbd>
228+<kbd>↓</kbd>
229+<kbd>←</kbd>
230+<kbd>→</kbd>
231+<kbd>home</kbd>
232+<kbd>end</kbd>
233+</dt>
234+<dd>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</dd>
224 </div>235 </div>
225 <div>236 <div>
226 <dt>237 <dt>
227 <kbd>l</kbd>238 <kbd>l</kbd>
228 <kbd>h</kbd>239 <kbd>h</kbd>
229 </dt>240 </dt>
230-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>241+<dd>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</dd>
231 </div>242 </div>
232 <div>243 <div>
233 <dt>244 <dt>
@@ -240,7 +251,7 @@
240 <dt>251 <dt>
241 <kbd>/</kbd>252 <kbd>/</kbd>
242 </dt>253 </dt>
243-<dd>filter the list; on Files, find a file in the repository</dd>254+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
244 </div>255 </div>
245 <div>256 <div>
246 <dt>257 <dt>
M internal/app/testdata/web/commits-root.html +14 -3
@@ -1105,14 +1105,25 @@
11051105 <dt>
11061106 <kbd>enter</kbd>
11071107 </dt>
1108-<dd>open the row under the cursor</dd>
1108+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
1109+</div>
1110+<div>
1111+<dt>
1112+<kbd>↑</kbd>
1113+<kbd>↓</kbd>
1114+<kbd>←</kbd>
1115+<kbd>→</kbd>
1116+<kbd>home</kbd>
1117+<kbd>end</kbd>
1118+</dt>
1119+<dd>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</dd>
11091120 </div>
11101121 <div>
11111122 <dt>
11121123 <kbd>l</kbd>
11131124 <kbd>h</kbd>
11141125 </dt>
1115-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
1126+<dd>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</dd>
11161127 </div>
11171128 <div>
11181129 <dt>
@@ -1125,7 +1136,7 @@
11251136 <dt>
11261137 <kbd>/</kbd>
11271138 </dt>
1128-<dd>filter the list; on Files, find a file in the repository</dd>
1139+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
11291140 </div>
11301141 <div>
11311142 <dt>
@@ -1105,14 +1105,25 @@
1105 <dt>1105 <dt>
1106 <kbd>enter</kbd>1106 <kbd>enter</kbd>
1107 </dt>1107 </dt>
1108-<dd>open the row under the cursor</dd>1108+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
1109+</div>
1110+<div>
1111+<dt>
1112+<kbd>↑</kbd>
1113+<kbd>↓</kbd>
1114+<kbd>←</kbd>
1115+<kbd>→</kbd>
1116+<kbd>home</kbd>
1117+<kbd>end</kbd>
1118+</dt>
1119+<dd>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</dd>
1109 </div>1120 </div>
1110 <div>1121 <div>
1111 <dt>1122 <dt>
1112 <kbd>l</kbd>1123 <kbd>l</kbd>
1113 <kbd>h</kbd>1124 <kbd>h</kbd>
1114 </dt>1125 </dt>
1115-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>1126+<dd>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</dd>
1116 </div>1127 </div>
1117 <div>1128 <div>
1118 <dt>1129 <dt>
@@ -1125,7 +1136,7 @@
1125 <dt>1136 <dt>
1126 <kbd>/</kbd>1137 <kbd>/</kbd>
1127 </dt>1138 </dt>
1128-<dd>filter the list; on Files, find a file in the repository</dd>1139+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
1129 </div>1140 </div>
1130 <div>1141 <div>
1131 <dt>1142 <dt>
M internal/app/testdata/web/commits-small.html +14 -3
@@ -366,14 +366,25 @@
366366 <dt>
367367 <kbd>enter</kbd>
368368 </dt>
369-<dd>open the row under the cursor</dd>
369+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
370+</div>
371+<div>
372+<dt>
373+<kbd>↑</kbd>
374+<kbd>↓</kbd>
375+<kbd>←</kbd>
376+<kbd>→</kbd>
377+<kbd>home</kbd>
378+<kbd>end</kbd>
379+</dt>
380+<dd>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</dd>
370381 </div>
371382 <div>
372383 <dt>
373384 <kbd>l</kbd>
374385 <kbd>h</kbd>
375386 </dt>
376-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
387+<dd>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</dd>
377388 </div>
378389 <div>
379390 <dt>
@@ -386,7 +397,7 @@
386397 <dt>
387398 <kbd>/</kbd>
388399 </dt>
389-<dd>filter the list; on Files, find a file in the repository</dd>
400+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
390401 </div>
391402 <div>
392403 <dt>
@@ -366,14 +366,25 @@
366 <dt>366 <dt>
367 <kbd>enter</kbd>367 <kbd>enter</kbd>
368 </dt>368 </dt>
369-<dd>open the row under the cursor</dd>369+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
370+</div>
371+<div>
372+<dt>
373+<kbd>↑</kbd>
374+<kbd>↓</kbd>
375+<kbd>←</kbd>
376+<kbd>→</kbd>
377+<kbd>home</kbd>
378+<kbd>end</kbd>
379+</dt>
380+<dd>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</dd>
370 </div>381 </div>
371 <div>382 <div>
372 <dt>383 <dt>
373 <kbd>l</kbd>384 <kbd>l</kbd>
374 <kbd>h</kbd>385 <kbd>h</kbd>
375 </dt>386 </dt>
376-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>387+<dd>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</dd>
377 </div>388 </div>
378 <div>389 <div>
379 <dt>390 <dt>
@@ -386,7 +397,7 @@
386 <dt>397 <dt>
387 <kbd>/</kbd>398 <kbd>/</kbd>
388 </dt>399 </dt>
389-<dd>filter the list; on Files, find a file in the repository</dd>400+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
390 </div>401 </div>
391 <div>402 <div>
392 <dt>403 <dt>
M internal/app/testdata/web/empty.html +14 -3
@@ -123,14 +123,25 @@
123123 <dt>
124124 <kbd>enter</kbd>
125125 </dt>
126-<dd>open the row under the cursor</dd>
126+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
127+</div>
128+<div>
129+<dt>
130+<kbd>↑</kbd>
131+<kbd>↓</kbd>
132+<kbd>←</kbd>
133+<kbd>→</kbd>
134+<kbd>home</kbd>
135+<kbd>end</kbd>
136+</dt>
137+<dd>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</dd>
127138 </div>
128139 <div>
129140 <dt>
130141 <kbd>l</kbd>
131142 <kbd>h</kbd>
132143 </dt>
133-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
144+<dd>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</dd>
134145 </div>
135146 <div>
136147 <dt>
@@ -143,7 +154,7 @@
143154 <dt>
144155 <kbd>/</kbd>
145156 </dt>
146-<dd>filter the list; on Files, find a file in the repository</dd>
157+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
147158 </div>
148159 <div>
149160 <dt>
@@ -123,14 +123,25 @@
123 <dt>123 <dt>
124 <kbd>enter</kbd>124 <kbd>enter</kbd>
125 </dt>125 </dt>
126-<dd>open the row under the cursor</dd>126+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
127+</div>
128+<div>
129+<dt>
130+<kbd>↑</kbd>
131+<kbd>↓</kbd>
132+<kbd>←</kbd>
133+<kbd>→</kbd>
134+<kbd>home</kbd>
135+<kbd>end</kbd>
136+</dt>
137+<dd>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</dd>
127 </div>138 </div>
128 <div>139 <div>
129 <dt>140 <dt>
130 <kbd>l</kbd>141 <kbd>l</kbd>
131 <kbd>h</kbd>142 <kbd>h</kbd>
132 </dt>143 </dt>
133-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>144+<dd>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</dd>
134 </div>145 </div>
135 <div>146 <div>
136 <dt>147 <dt>
@@ -143,7 +154,7 @@
143 <dt>154 <dt>
144 <kbd>/</kbd>155 <kbd>/</kbd>
145 </dt>156 </dt>
146-<dd>filter the list; on Files, find a file in the repository</dd>157+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
147 </div>158 </div>
148 <div>159 <div>
149 <dt>160 <dt>
M internal/app/testdata/web/home.html +14 -3
@@ -148,14 +148,25 @@
148148 <dt>
149149 <kbd>enter</kbd>
150150 </dt>
151-<dd>open the row under the cursor</dd>
151+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
152+</div>
153+<div>
154+<dt>
155+<kbd>↑</kbd>
156+<kbd>↓</kbd>
157+<kbd>←</kbd>
158+<kbd>→</kbd>
159+<kbd>home</kbd>
160+<kbd>end</kbd>
161+</dt>
162+<dd>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</dd>
152163 </div>
153164 <div>
154165 <dt>
155166 <kbd>l</kbd>
156167 <kbd>h</kbd>
157168 </dt>
158-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
169+<dd>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</dd>
159170 </div>
160171 <div>
161172 <dt>
@@ -168,7 +179,7 @@
168179 <dt>
169180 <kbd>/</kbd>
170181 </dt>
171-<dd>filter the list; on Files, find a file in the repository</dd>
182+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
172183 </div>
173184 <div>
174185 <dt>
@@ -148,14 +148,25 @@
148 <dt>148 <dt>
149 <kbd>enter</kbd>149 <kbd>enter</kbd>
150 </dt>150 </dt>
151-<dd>open the row under the cursor</dd>151+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
152+</div>
153+<div>
154+<dt>
155+<kbd>↑</kbd>
156+<kbd>↓</kbd>
157+<kbd>←</kbd>
158+<kbd>→</kbd>
159+<kbd>home</kbd>
160+<kbd>end</kbd>
161+</dt>
162+<dd>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</dd>
152 </div>163 </div>
153 <div>164 <div>
154 <dt>165 <dt>
155 <kbd>l</kbd>166 <kbd>l</kbd>
156 <kbd>h</kbd>167 <kbd>h</kbd>
157 </dt>168 </dt>
158-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>169+<dd>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</dd>
159 </div>170 </div>
160 <div>171 <div>
161 <dt>172 <dt>
@@ -168,7 +179,7 @@
168 <dt>179 <dt>
169 <kbd>/</kbd>180 <kbd>/</kbd>
170 </dt>181 </dt>
171-<dd>filter the list; on Files, find a file in the repository</dd>182+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
172 </div>183 </div>
173 <div>184 <div>
174 <dt>185 <dt>
M internal/app/testdata/web/not-found-page.html +14 -3
@@ -112,14 +112,25 @@
112112 <dt>
113113 <kbd>enter</kbd>
114114 </dt>
115-<dd>open the row under the cursor</dd>
115+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
116+</div>
117+<div>
118+<dt>
119+<kbd>↑</kbd>
120+<kbd>↓</kbd>
121+<kbd>←</kbd>
122+<kbd>→</kbd>
123+<kbd>home</kbd>
124+<kbd>end</kbd>
125+</dt>
126+<dd>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</dd>
116127 </div>
117128 <div>
118129 <dt>
119130 <kbd>l</kbd>
120131 <kbd>h</kbd>
121132 </dt>
122-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
133+<dd>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</dd>
123134 </div>
124135 <div>
125136 <dt>
@@ -132,7 +143,7 @@
132143 <dt>
133144 <kbd>/</kbd>
134145 </dt>
135-<dd>filter the list; on Files, find a file in the repository</dd>
146+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
136147 </div>
137148 <div>
138149 <dt>
@@ -112,14 +112,25 @@
112 <dt>112 <dt>
113 <kbd>enter</kbd>113 <kbd>enter</kbd>
114 </dt>114 </dt>
115-<dd>open the row under the cursor</dd>115+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
116+</div>
117+<div>
118+<dt>
119+<kbd>↑</kbd>
120+<kbd>↓</kbd>
121+<kbd>←</kbd>
122+<kbd>→</kbd>
123+<kbd>home</kbd>
124+<kbd>end</kbd>
125+</dt>
126+<dd>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</dd>
116 </div>127 </div>
117 <div>128 <div>
118 <dt>129 <dt>
119 <kbd>l</kbd>130 <kbd>l</kbd>
120 <kbd>h</kbd>131 <kbd>h</kbd>
121 </dt>132 </dt>
122-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>133+<dd>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</dd>
123 </div>134 </div>
124 <div>135 <div>
125 <dt>136 <dt>
@@ -132,7 +143,7 @@
132 <dt>143 <dt>
133 <kbd>/</kbd>144 <kbd>/</kbd>
134 </dt>145 </dt>
135-<dd>filter the list; on Files, find a file in the repository</dd>146+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
136 </div>147 </div>
137 <div>148 <div>
138 <dt>149 <dt>
M internal/app/testdata/web/not-found.html +14 -3
@@ -112,14 +112,25 @@
112112 <dt>
113113 <kbd>enter</kbd>
114114 </dt>
115-<dd>open the row under the cursor</dd>
115+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
116+</div>
117+<div>
118+<dt>
119+<kbd>↑</kbd>
120+<kbd>↓</kbd>
121+<kbd>←</kbd>
122+<kbd>→</kbd>
123+<kbd>home</kbd>
124+<kbd>end</kbd>
125+</dt>
126+<dd>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</dd>
116127 </div>
117128 <div>
118129 <dt>
119130 <kbd>l</kbd>
120131 <kbd>h</kbd>
121132 </dt>
122-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
133+<dd>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</dd>
123134 </div>
124135 <div>
125136 <dt>
@@ -132,7 +143,7 @@
132143 <dt>
133144 <kbd>/</kbd>
134145 </dt>
135-<dd>filter the list; on Files, find a file in the repository</dd>
146+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
136147 </div>
137148 <div>
138149 <dt>
@@ -112,14 +112,25 @@
112 <dt>112 <dt>
113 <kbd>enter</kbd>113 <kbd>enter</kbd>
114 </dt>114 </dt>
115-<dd>open the row under the cursor</dd>115+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
116+</div>
117+<div>
118+<dt>
119+<kbd>↑</kbd>
120+<kbd>↓</kbd>
121+<kbd>←</kbd>
122+<kbd>→</kbd>
123+<kbd>home</kbd>
124+<kbd>end</kbd>
125+</dt>
126+<dd>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</dd>
116 </div>127 </div>
117 <div>128 <div>
118 <dt>129 <dt>
119 <kbd>l</kbd>130 <kbd>l</kbd>
120 <kbd>h</kbd>131 <kbd>h</kbd>
121 </dt>132 </dt>
122-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>133+<dd>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</dd>
123 </div>134 </div>
124 <div>135 <div>
125 <dt>136 <dt>
@@ -132,7 +143,7 @@
132 <dt>143 <dt>
133 <kbd>/</kbd>144 <kbd>/</kbd>
134 </dt>145 </dt>
135-<dd>filter the list; on Files, find a file in the repository</dd>146+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
136 </div>147 </div>
137 <div>148 <div>
138 <dt>149 <dt>
M internal/app/testdata/web/refs.html +14 -3
@@ -242,14 +242,25 @@
242242 <dt>
243243 <kbd>enter</kbd>
244244 </dt>
245-<dd>open the row under the cursor</dd>
245+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
246+</div>
247+<div>
248+<dt>
249+<kbd>↑</kbd>
250+<kbd>↓</kbd>
251+<kbd>←</kbd>
252+<kbd>→</kbd>
253+<kbd>home</kbd>
254+<kbd>end</kbd>
255+</dt>
256+<dd>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</dd>
246257 </div>
247258 <div>
248259 <dt>
249260 <kbd>l</kbd>
250261 <kbd>h</kbd>
251262 </dt>
252-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
263+<dd>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</dd>
253264 </div>
254265 <div>
255266 <dt>
@@ -262,7 +273,7 @@
262273 <dt>
263274 <kbd>/</kbd>
264275 </dt>
265-<dd>filter the list; on Files, find a file in the repository</dd>
276+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
266277 </div>
267278 <div>
268279 <dt>
@@ -242,14 +242,25 @@
242 <dt>242 <dt>
243 <kbd>enter</kbd>243 <kbd>enter</kbd>
244 </dt>244 </dt>
245-<dd>open the row under the cursor</dd>245+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
246+</div>
247+<div>
248+<dt>
249+<kbd>↑</kbd>
250+<kbd>↓</kbd>
251+<kbd>←</kbd>
252+<kbd>→</kbd>
253+<kbd>home</kbd>
254+<kbd>end</kbd>
255+</dt>
256+<dd>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</dd>
246 </div>257 </div>
247 <div>258 <div>
248 <dt>259 <dt>
249 <kbd>l</kbd>260 <kbd>l</kbd>
250 <kbd>h</kbd>261 <kbd>h</kbd>
251 </dt>262 </dt>
252-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>263+<dd>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</dd>
253 </div>264 </div>
254 <div>265 <div>
255 <dt>266 <dt>
@@ -262,7 +273,7 @@
262 <dt>273 <dt>
263 <kbd>/</kbd>274 <kbd>/</kbd>
264 </dt>275 </dt>
265-<dd>filter the list; on Files, find a file in the repository</dd>276+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
266 </div>277 </div>
267 <div>278 <div>
268 <dt>279 <dt>
M internal/app/testdata/web/summary.html +14 -3
@@ -266,14 +266,25 @@
266266 <dt>
267267 <kbd>enter</kbd>
268268 </dt>
269-<dd>open the row under the cursor</dd>
269+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
270+</div>
271+<div>
272+<dt>
273+<kbd>↑</kbd>
274+<kbd>↓</kbd>
275+<kbd>←</kbd>
276+<kbd>→</kbd>
277+<kbd>home</kbd>
278+<kbd>end</kbd>
279+</dt>
280+<dd>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</dd>
270281 </div>
271282 <div>
272283 <dt>
273284 <kbd>l</kbd>
274285 <kbd>h</kbd>
275286 </dt>
276-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
287+<dd>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</dd>
277288 </div>
278289 <div>
279290 <dt>
@@ -286,7 +297,7 @@
286297 <dt>
287298 <kbd>/</kbd>
288299 </dt>
289-<dd>filter the list; on Files, find a file in the repository</dd>
300+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
290301 </div>
291302 <div>
292303 <dt>
@@ -266,14 +266,25 @@
266 <dt>266 <dt>
267 <kbd>enter</kbd>267 <kbd>enter</kbd>
268 </dt>268 </dt>
269-<dd>open the row under the cursor</dd>269+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
270+</div>
271+<div>
272+<dt>
273+<kbd>↑</kbd>
274+<kbd>↓</kbd>
275+<kbd>←</kbd>
276+<kbd>→</kbd>
277+<kbd>home</kbd>
278+<kbd>end</kbd>
279+</dt>
280+<dd>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</dd>
270 </div>281 </div>
271 <div>282 <div>
272 <dt>283 <dt>
273 <kbd>l</kbd>284 <kbd>l</kbd>
274 <kbd>h</kbd>285 <kbd>h</kbd>
275 </dt>286 </dt>
276-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>287+<dd>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</dd>
277 </div>288 </div>
278 <div>289 <div>
279 <dt>290 <dt>
@@ -286,7 +297,7 @@
286 <dt>297 <dt>
287 <kbd>/</kbd>298 <kbd>/</kbd>
288 </dt>299 </dt>
289-<dd>filter the list; on Files, find a file in the repository</dd>300+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
290 </div>301 </div>
291 <div>302 <div>
292 <dt>303 <dt>
M internal/app/testdata/web/tree-nested.html +45 -39
@@ -102,50 +102,45 @@
102102 </header>
103103 <div class="work">
104104 <main id="main" tabindex="-1">
105-<div class="filter">
105+<div class="tree-find">
106+<div class="filter wide">
106107 <label for="finder-q" class="sr">find a file</label>
107-<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
108+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file in this repository…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
108109 </div>
109-<div id="finder" hidden>
110+<p class="tree-keys" data-js aria-hidden="true">
111+<kbd>j</kbd>
112+<kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
113+</div>
114+<div id="finder" class="tree-card" hidden>
110115 <p id="finder-status" class="dim" aria-live="polite">
111116 </p>
112117 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
113118 </ul>
114119 </div>
115-<div id="tree-view">
116-<table class="list tree" aria-label="Entries">
117-<thead>
118-<tr>
119-<th class="cur">
120-</th>
121-<th class="c-kind">
122-</th>
123-<th class="c-name">name</th>
124-<th class="c-size">size</th>
125-<th class="c-sub">last commit</th>
126-<th class="c-who">author</th>
127-<th class="c-when">date</th>
128-</tr>
129-</thead>
130-<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
131-<tr data-cursor>
132-<td class="cur" aria-hidden="true">
133-</td>
134-<td class="c-kind" aria-hidden="true">·</td>
135-<td class="c-name">
136-<a href="/files/blob/main/src/lib/util.go">util.go</a>
137-</td>
138-<td class="c-size num dim">12 B</td>
139-<td class="c-sub dim">
140-<a href="/files/commit/SHA40">add src/lib/util.go</a>
141-</td>
142-<td class="c-who dim">T</td>
143-<td class="c-when nowrap dim">
120+<div id="tree-view" class="tree-card">
121+<div class="tree-head">
122+<span class="tree-path">src/lib/</span>
123+<a class="tree-up" href="/files/tree/main/src" title="parent directory (h)">..</a>
124+<span class="tree-count">1 entry</span>
125+</div>
126+<ul id="tree-rows" class="tree" role="tree" aria-label="Entries" data-rows data-tree data-up="/files/tree/main/src" hx-on:click="ermiteTree.click(event)">
127+<li role="treeitem" aria-level="1" aria-posinset="1" aria-setsize="1" data-kind="file" data-depth="0" data-cursor>
128+<span class="t-main">
129+<span class="t-twig" aria-hidden="true">
130+</span>
131+<span class="t-node" aria-hidden="true">
132+</span>
133+<a class="t-name" href="/files/blob/main/src/lib/util.go">util.go</a>
134+</span>
135+<span class="t-size">12 B</span>
136+<span class="t-sub">
137+<a class="t-commit" href="/files/commit/SHA40" title="T">add src/lib/util.go</a>
138+</span>
139+<span class="t-when">
144140 <time datetime="STAMP" title="STAMP">AGO</time>
145-</td>
146-</tr>
147-</tbody>
148-</table>
141+</span>
142+</li>
143+</ul>
149144 </div>
150145 </main>
151146 </div>
@@ -194,14 +189,25 @@
194189 <dt>
195190 <kbd>enter</kbd>
196191 </dt>
197-<dd>open the row under the cursor</dd>
192+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
193+</div>
194+<div>
195+<dt>
196+<kbd>↑</kbd>
197+<kbd>↓</kbd>
198+<kbd>←</kbd>
199+<kbd>→</kbd>
200+<kbd>home</kbd>
201+<kbd>end</kbd>
202+</dt>
203+<dd>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</dd>
198204 </div>
199205 <div>
200206 <dt>
201207 <kbd>l</kbd>
202208 <kbd>h</kbd>
203209 </dt>
204-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
210+<dd>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</dd>
205211 </div>
206212 <div>
207213 <dt>
@@ -214,7 +220,7 @@
214220 <dt>
215221 <kbd>/</kbd>
216222 </dt>
217-<dd>filter the list; on Files, find a file in the repository</dd>
223+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
218224 </div>
219225 <div>
220226 <dt>
@@ -102,50 +102,45 @@
102 </header>102 </header>
103 <div class="work">103 <div class="work">
104 <main id="main" tabindex="-1">104 <main id="main" tabindex="-1">
105-<div class="filter">105+<div class="tree-find">
106+<div class="filter wide">
106 <label for="finder-q" class="sr">find a file</label>107 <label for="finder-q" class="sr">find a file</label>
107-<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">108+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file in this repository…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
108 </div>109 </div>
109-<div id="finder" hidden>110+<p class="tree-keys" data-js aria-hidden="true">
111+<kbd>j</kbd>
112+<kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
113+</div>
114+<div id="finder" class="tree-card" hidden>
110 <p id="finder-status" class="dim" aria-live="polite">115 <p id="finder-status" class="dim" aria-live="polite">
111 </p>116 </p>
112 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">117 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
113 </ul>118 </ul>
114 </div>119 </div>
115-<div id="tree-view">120+<div id="tree-view" class="tree-card">
116-<table class="list tree" aria-label="Entries">121+<div class="tree-head">
117-<thead>122+<span class="tree-path">src/lib/</span>
118-<tr>123+<a class="tree-up" href="/files/tree/main/src" title="parent directory (h)">..</a>
119-<th class="cur">124+<span class="tree-count">1 entry</span>
120-</th>125+</div>
121-<th class="c-kind">126+<ul id="tree-rows" class="tree" role="tree" aria-label="Entries" data-rows data-tree data-up="/files/tree/main/src" hx-on:click="ermiteTree.click(event)">
122-</th>127+<li role="treeitem" aria-level="1" aria-posinset="1" aria-setsize="1" data-kind="file" data-depth="0" data-cursor>
123-<th class="c-name">name</th>128+<span class="t-main">
124-<th class="c-size">size</th>129+<span class="t-twig" aria-hidden="true">
125-<th class="c-sub">last commit</th>130+</span>
126-<th class="c-who">author</th>131+<span class="t-node" aria-hidden="true">
127-<th class="c-when">date</th>132+</span>
128-</tr>133+<a class="t-name" href="/files/blob/main/src/lib/util.go">util.go</a>
129-</thead>134+</span>
130-<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">135+<span class="t-size">12 B</span>
131-<tr data-cursor>136+<span class="t-sub">
132-<td class="cur" aria-hidden="true">137+<a class="t-commit" href="/files/commit/SHA40" title="T">add src/lib/util.go</a>
133-</td>138+</span>
134-<td class="c-kind" aria-hidden="true">·</td>139+<span class="t-when">
135-<td class="c-name">
136-<a href="/files/blob/main/src/lib/util.go">util.go</a>
137-</td>
138-<td class="c-size num dim">12 B</td>
139-<td class="c-sub dim">
140-<a href="/files/commit/SHA40">add src/lib/util.go</a>
141-</td>
142-<td class="c-who dim">T</td>
143-<td class="c-when nowrap dim">
144 <time datetime="STAMP" title="STAMP">AGO</time>140 <time datetime="STAMP" title="STAMP">AGO</time>
145-</td>141+</span>
146-</tr>142+</li>
147-</tbody>143+</ul>
148-</table>
149 </div>144 </div>
150 </main>145 </main>
151 </div>146 </div>
@@ -194,14 +189,25 @@
194 <dt>189 <dt>
195 <kbd>enter</kbd>190 <kbd>enter</kbd>
196 </dt>191 </dt>
197-<dd>open the row under the cursor</dd>192+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
193+</div>
194+<div>
195+<dt>
196+<kbd>↑</kbd>
197+<kbd>↓</kbd>
198+<kbd>←</kbd>
199+<kbd>→</kbd>
200+<kbd>home</kbd>
201+<kbd>end</kbd>
202+</dt>
203+<dd>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</dd>
198 </div>204 </div>
199 <div>205 <div>
200 <dt>206 <dt>
201 <kbd>l</kbd>207 <kbd>l</kbd>
202 <kbd>h</kbd>208 <kbd>h</kbd>
203 </dt>209 </dt>
204-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>210+<dd>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</dd>
205 </div>211 </div>
206 <div>212 <div>
207 <dt>213 <dt>
@@ -214,7 +220,7 @@
214 <dt>220 <dt>
215 <kbd>/</kbd>221 <kbd>/</kbd>
216 </dt>222 </dt>
217-<dd>filter the list; on Files, find a file in the repository</dd>223+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
218 </div>224 </div>
219 <div>225 <div>
220 <dt>226 <dt>
M internal/app/testdata/web/tree-root.html +258 -258
@@ -96,283 +96,272 @@
9696 </header>
9797 <div class="work">
9898 <main id="main" tabindex="-1">
99-<div class="filter">
99+<div class="tree-find">
100+<div class="filter wide">
100101 <label for="finder-q" class="sr">find a file</label>
101-<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
102+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file in this repository…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
102103 </div>
103-<div id="finder" hidden>
104+<p class="tree-keys" data-js aria-hidden="true">
105+<kbd>j</kbd>
106+<kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
107+</div>
108+<div id="finder" class="tree-card" hidden>
104109 <p id="finder-status" class="dim" aria-live="polite">
105110 </p>
106111 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
107112 </ul>
108113 </div>
109-<div id="tree-view">
110-<table class="list tree" aria-label="Entries">
111-<thead>
112-<tr>
113-<th class="cur">
114-</th>
115-<th class="c-kind">
116-</th>
117-<th class="c-name">name</th>
118-<th class="c-size">size</th>
119-<th class="c-sub">last commit</th>
120-<th class="c-who">author</th>
121-<th class="c-when">date</th>
122-</tr>
123-</thead>
124-<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
125-<tr data-cursor>
126-<td class="cur" aria-hidden="true">
127-</td>
128-<td class="c-kind" aria-hidden="true">▸</td>
129-<td class="c-name">
130-<a href="/files/tree/main/docs" data-expand="/files/tree/main/docs?rows" aria-expanded="false">docs/</a>
131-</td>
132-<td class="c-size num dim">-</td>
133-<td class="c-sub dim">
134-<a href="/files/commit/SHA40">add docs/guide.md</a>
135-</td>
136-<td class="c-who dim">T</td>
137-<td class="c-when nowrap dim">
114+<div id="tree-view" class="tree-card">
115+<div class="tree-head">
116+<span class="tree-path">/</span>
117+<span class="tree-count">15 entries</span>
118+</div>
119+<ul id="tree-rows" class="tree" role="tree" aria-label="Entries" data-rows data-tree hx-on:click="ermiteTree.click(event)">
120+<li role="treeitem" aria-level="1" aria-posinset="1" aria-setsize="15" data-kind="dir" data-depth="0" data-cursor data-expand="/files/tree/main/docs?rows" aria-expanded="false">
121+<span class="t-main">
122+<span class="t-twig" aria-hidden="true">
123+</span>
124+<span class="t-node" aria-hidden="true">
125+</span>
126+<a class="t-name" href="/files/tree/main/docs" title="directory">docs/</a>
127+<span class="sr"> (directory)</span>
128+</span>
129+<span class="t-size">
130+</span>
131+<span class="t-sub">
132+<a class="t-commit" href="/files/commit/SHA40" title="T">add docs/guide.md</a>
133+</span>
134+<span class="t-when">
138135 <time datetime="STAMP" title="STAMP">AGO</time>
139-</td>
140-</tr>
141-<tr>
142-<td class="cur" aria-hidden="true">
143-</td>
144-<td class="c-kind" aria-hidden="true">▸</td>
145-<td class="c-name">
146-<a href="/files/tree/main/src" data-expand="/files/tree/main/src?rows" aria-expanded="false">src/</a>
147-</td>
148-<td class="c-size num dim">-</td>
149-<td class="c-sub dim">
150-<a href="/files/commit/SHA40">add src/lib/util.go</a>
151-</td>
152-<td class="c-who dim">T</td>
153-<td class="c-when nowrap dim">
136+</span>
137+</li>
138+<li role="treeitem" aria-level="1" aria-posinset="2" aria-setsize="15" data-kind="dir" data-depth="0" data-expand="/files/tree/main/src?rows" aria-expanded="false">
139+<span class="t-main">
140+<span class="t-twig" aria-hidden="true">
141+</span>
142+<span class="t-node" aria-hidden="true">
143+</span>
144+<a class="t-name" href="/files/tree/main/src" title="directory">src/</a>
145+<span class="sr"> (directory)</span>
146+</span>
147+<span class="t-size">
148+</span>
149+<span class="t-sub">
150+<a class="t-commit" href="/files/commit/SHA40" title="T">add src/lib/util.go</a>
151+</span>
152+<span class="t-when">
154153 <time datetime="STAMP" title="STAMP">AGO</time>
155-</td>
156-</tr>
157-<tr>
158-<td class="cur" aria-hidden="true">
159-</td>
160-<td class="c-kind" aria-hidden="true">·</td>
161-<td class="c-name">
162-<a href="/files/blob/main/README.md">README.md</a>
163-</td>
164-<td class="c-size num dim">41 B</td>
165-<td class="c-sub dim">
166-<a href="/files/commit/SHA40">add README.md</a>
167-</td>
168-<td class="c-who dim">T</td>
169-<td class="c-when nowrap dim">
154+</span>
155+</li>
156+<li role="treeitem" aria-level="1" aria-posinset="3" aria-setsize="15" data-kind="file" data-depth="0">
157+<span class="t-main">
158+<span class="t-twig" aria-hidden="true">
159+</span>
160+<span class="t-node" aria-hidden="true">
161+</span>
162+<a class="t-name" href="/files/blob/main/README.md">README.md</a>
163+</span>
164+<span class="t-size">41 B</span>
165+<span class="t-sub">
166+<a class="t-commit" href="/files/commit/SHA40" title="T">add README.md</a>
167+</span>
168+<span class="t-when">
170169 <time datetime="STAMP" title="STAMP">AGO</time>
171-</td>
172-</tr>
173-<tr>
174-<td class="cur" aria-hidden="true">
175-</td>
176-<td class="c-kind" aria-hidden="true">·</td>
177-<td class="c-name">
178-<a href="/files/blob/main/big.txt">big.txt</a>
179-</td>
180-<td class="c-size num dim">1.1 MiB</td>
181-<td class="c-sub dim">
182-<a href="/files/commit/SHA40">add big.txt</a>
183-</td>
184-<td class="c-who dim">T</td>
185-<td class="c-when nowrap dim">
170+</span>
171+</li>
172+<li role="treeitem" aria-level="1" aria-posinset="4" aria-setsize="15" data-kind="file" data-depth="0">
173+<span class="t-main">
174+<span class="t-twig" aria-hidden="true">
175+</span>
176+<span class="t-node" aria-hidden="true">
177+</span>
178+<a class="t-name" href="/files/blob/main/big.txt">big.txt</a>
179+</span>
180+<span class="t-size">1.1 MiB</span>
181+<span class="t-sub">
182+<a class="t-commit" href="/files/commit/SHA40" title="T">add big.txt</a>
183+</span>
184+<span class="t-when">
186185 <time datetime="STAMP" title="STAMP">AGO</time>
187-</td>
188-</tr>
189-<tr>
190-<td class="cur" aria-hidden="true">
191-</td>
192-<td class="c-kind" aria-hidden="true">·</td>
193-<td class="c-name">
194-<a href="/files/blob/main/data.bin">data.bin</a>
195-</td>
196-<td class="c-size num dim">10 B</td>
197-<td class="c-sub dim">
198-<a href="/files/commit/SHA40">add data.bin</a>
199-</td>
200-<td class="c-who dim">T</td>
201-<td class="c-when nowrap dim">
186+</span>
187+</li>
188+<li role="treeitem" aria-level="1" aria-posinset="5" aria-setsize="15" data-kind="file" data-depth="0">
189+<span class="t-main">
190+<span class="t-twig" aria-hidden="true">
191+</span>
192+<span class="t-node" aria-hidden="true">
193+</span>
194+<a class="t-name" href="/files/blob/main/data.bin">data.bin</a>
195+</span>
196+<span class="t-size">10 B</span>
197+<span class="t-sub">
198+<a class="t-commit" href="/files/commit/SHA40" title="T">add data.bin</a>
199+</span>
200+<span class="t-when">
202201 <time datetime="STAMP" title="STAMP">AGO</time>
203-</td>
204-</tr>
205-<tr>
206-<td class="cur" aria-hidden="true">
207-</td>
208-<td class="c-kind" aria-hidden="true">·</td>
209-<td class="c-name">
210-<a href="/files/blob/main/empty.txt">empty.txt</a>
211-</td>
212-<td class="c-size num dim">0 B</td>
213-<td class="c-sub dim">
214-<a href="/files/commit/SHA40">add empty.txt</a>
215-</td>
216-<td class="c-who dim">T</td>
217-<td class="c-when nowrap dim">
202+</span>
203+</li>
204+<li role="treeitem" aria-level="1" aria-posinset="6" aria-setsize="15" data-kind="file" data-depth="0">
205+<span class="t-main">
206+<span class="t-twig" aria-hidden="true">
207+</span>
208+<span class="t-node" aria-hidden="true">
209+</span>
210+<a class="t-name" href="/files/blob/main/empty.txt">empty.txt</a>
211+</span>
212+<span class="t-size">0 B</span>
213+<span class="t-sub">
214+<a class="t-commit" href="/files/commit/SHA40" title="T">add empty.txt</a>
215+</span>
216+<span class="t-when">
218217 <time datetime="STAMP" title="STAMP">AGO</time>
219-</td>
220-</tr>
221-<tr>
222-<td class="cur" aria-hidden="true">
223-</td>
224-<td class="c-kind" aria-hidden="true">·</td>
225-<td class="c-name">
226-<a href="/files/blob/main/evil.go">evil.go</a>
227-</td>
228-<td class="c-size num dim">87 B</td>
229-<td class="c-sub dim">
230-<a href="/files/commit/SHA40">add evil.go</a>
231-</td>
232-<td class="c-who dim">T</td>
233-<td class="c-when nowrap dim">
218+</span>
219+</li>
220+<li role="treeitem" aria-level="1" aria-posinset="7" aria-setsize="15" data-kind="file" data-depth="0">
221+<span class="t-main">
222+<span class="t-twig" aria-hidden="true">
223+</span>
224+<span class="t-node" aria-hidden="true">
225+</span>
226+<a class="t-name" href="/files/blob/main/evil.go">evil.go</a>
227+</span>
228+<span class="t-size">87 B</span>
229+<span class="t-sub">
230+<a class="t-commit" href="/files/commit/SHA40" title="T">add evil.go</a>
231+</span>
232+<span class="t-when">
234233 <time datetime="STAMP" title="STAMP">AGO</time>
235-</td>
236-</tr>
237-<tr>
238-<td class="cur" aria-hidden="true">
239-</td>
240-<td class="c-kind" aria-hidden="true">·</td>
241-<td class="c-name">
242-<a href="/files/blob/main/fake.jpg">fake.jpg</a>
243-</td>
244-<td class="c-size num dim">15 B</td>
245-<td class="c-sub dim">
246-<a href="/files/commit/SHA40">add fake.jpg</a>
247-</td>
248-<td class="c-who dim">T</td>
249-<td class="c-when nowrap dim">
234+</span>
235+</li>
236+<li role="treeitem" aria-level="1" aria-posinset="8" aria-setsize="15" data-kind="file" data-depth="0">
237+<span class="t-main">
238+<span class="t-twig" aria-hidden="true">
239+</span>
240+<span class="t-node" aria-hidden="true">
241+</span>
242+<a class="t-name" href="/files/blob/main/fake.jpg">fake.jpg</a>
243+</span>
244+<span class="t-size">15 B</span>
245+<span class="t-sub">
246+<a class="t-commit" href="/files/commit/SHA40" title="T">add fake.jpg</a>
247+</span>
248+<span class="t-when">
250249 <time datetime="STAMP" title="STAMP">AGO</time>
251-</td>
252-</tr>
253-<tr>
254-<td class="cur" aria-hidden="true">
255-</td>
256-<td class="c-kind" aria-hidden="true">·</td>
257-<td class="c-name">
258-<a href="/files/blob/main/fake.png">fake.png</a>
259-</td>
260-<td class="c-size num dim">39 B</td>
261-<td class="c-sub dim">
262-<a href="/files/commit/SHA40">add fake.png</a>
263-</td>
264-<td class="c-who dim">T</td>
265-<td class="c-when nowrap dim">
250+</span>
251+</li>
252+<li role="treeitem" aria-level="1" aria-posinset="9" aria-setsize="15" data-kind="file" data-depth="0">
253+<span class="t-main">
254+<span class="t-twig" aria-hidden="true">
255+</span>
256+<span class="t-node" aria-hidden="true">
257+</span>
258+<a class="t-name" href="/files/blob/main/fake.png">fake.png</a>
259+</span>
260+<span class="t-size">39 B</span>
261+<span class="t-sub">
262+<a class="t-commit" href="/files/commit/SHA40" title="T">add fake.png</a>
263+</span>
264+<span class="t-when">
266265 <time datetime="STAMP" title="STAMP">AGO</time>
267-</td>
268-</tr>
269-<tr>
270-<td class="cur" aria-hidden="true">
271-</td>
272-<td class="c-kind" aria-hidden="true">·</td>
273-<td class="c-name">
274-<a href="/files/blob/main/lines.txt">lines.txt</a>
275-</td>
276-<td class="c-size num dim">39.1 KiB</td>
277-<td class="c-sub dim">
278-<a href="/files/commit/SHA40">add lines.txt</a>
279-</td>
280-<td class="c-who dim">T</td>
281-<td class="c-when nowrap dim">
266+</span>
267+</li>
268+<li role="treeitem" aria-level="1" aria-posinset="10" aria-setsize="15" data-kind="file" data-depth="0">
269+<span class="t-main">
270+<span class="t-twig" aria-hidden="true">
271+</span>
272+<span class="t-node" aria-hidden="true">
273+</span>
274+<a class="t-name" href="/files/blob/main/lines.txt">lines.txt</a>
275+</span>
276+<span class="t-size">39.1 KiB</span>
277+<span class="t-sub">
278+<a class="t-commit" href="/files/commit/SHA40" title="T">add lines.txt</a>
279+</span>
280+<span class="t-when">
282281 <time datetime="STAMP" title="STAMP">AGO</time>
283-</td>
284-</tr>
285-<tr>
286-<td class="cur" aria-hidden="true">
287-</td>
288-<td class="c-kind" aria-hidden="true">·</td>
289-<td class="c-name">
290-<a href="/files/blob/main/logo.svg">logo.svg</a>
291-</td>
292-<td class="c-size num dim">89 B</td>
293-<td class="c-sub dim">
294-<a href="/files/commit/SHA40">add logo.svg</a>
295-</td>
296-<td class="c-who dim">T</td>
297-<td class="c-when nowrap dim">
282+</span>
283+</li>
284+<li role="treeitem" aria-level="1" aria-posinset="11" aria-setsize="15" data-kind="file" data-depth="0">
285+<span class="t-main">
286+<span class="t-twig" aria-hidden="true">
287+</span>
288+<span class="t-node" aria-hidden="true">
289+</span>
290+<a class="t-name" href="/files/blob/main/logo.svg">logo.svg</a>
291+</span>
292+<span class="t-size">89 B</span>
293+<span class="t-sub">
294+<a class="t-commit" href="/files/commit/SHA40" title="T">add logo.svg</a>
295+</span>
296+<span class="t-when">
298297 <time datetime="STAMP" title="STAMP">AGO</time>
299-</td>
300-</tr>
301-<tr>
302-<td class="cur" aria-hidden="true">
303-</td>
304-<td class="c-kind" aria-hidden="true">·</td>
305-<td class="c-name">
306-<a href="/files/blob/main/main.go">main.go</a>
307-</td>
308-<td class="c-size num dim">87 B</td>
309-<td class="c-sub dim">
310-<a href="/files/commit/SHA40">add main.go</a>
311-</td>
312-<td class="c-who dim">T</td>
313-<td class="c-when nowrap dim">
298+</span>
299+</li>
300+<li role="treeitem" aria-level="1" aria-posinset="12" aria-setsize="15" data-kind="file" data-depth="0">
301+<span class="t-main">
302+<span class="t-twig" aria-hidden="true">
303+</span>
304+<span class="t-node" aria-hidden="true">
305+</span>
306+<a class="t-name" href="/files/blob/main/main.go">main.go</a>
307+</span>
308+<span class="t-size">87 B</span>
309+<span class="t-sub">
310+<a class="t-commit" href="/files/commit/SHA40" title="T">add main.go</a>
311+</span>
312+<span class="t-when">
314313 <time datetime="STAMP" title="STAMP">AGO</time>
315-</td>
316-</tr>
317-<tr>
318-<td class="cur" aria-hidden="true">
319-</td>
320-<td class="c-kind" aria-hidden="true">·</td>
321-<td class="c-name">
322-<a href="/files/blob/main/notes.txt">notes.txt</a>
323-</td>
324-<td class="c-size num dim">24 B</td>
325-<td class="c-sub dim">
326-<a href="/files/commit/SHA40">add notes.txt</a>
327-</td>
328-<td class="c-who dim">T</td>
329-<td class="c-when nowrap dim">
314+</span>
315+</li>
316+<li role="treeitem" aria-level="1" aria-posinset="13" aria-setsize="15" data-kind="file" data-depth="0">
317+<span class="t-main">
318+<span class="t-twig" aria-hidden="true">
319+</span>
320+<span class="t-node" aria-hidden="true">
321+</span>
322+<a class="t-name" href="/files/blob/main/notes.txt">notes.txt</a>
323+</span>
324+<span class="t-size">24 B</span>
325+<span class="t-sub">
326+<a class="t-commit" href="/files/commit/SHA40" title="T">add notes.txt</a>
327+</span>
328+<span class="t-when">
330329 <time datetime="STAMP" title="STAMP">AGO</time>
331-</td>
332-</tr>
333-<tr>
334-<td class="cur" aria-hidden="true">
335-</td>
336-<td class="c-kind" aria-hidden="true">·</td>
337-<td class="c-name">
338-<a href="/files/blob/main/page.html">page.html</a>
339-</td>
340-<td class="c-size num dim">26 B</td>
341-<td class="c-sub dim">
342-<a href="/files/commit/SHA40">add page.html</a>
343-</td>
344-<td class="c-who dim">T</td>
345-<td class="c-when nowrap dim">
330+</span>
331+</li>
332+<li role="treeitem" aria-level="1" aria-posinset="14" aria-setsize="15" data-kind="file" data-depth="0">
333+<span class="t-main">
334+<span class="t-twig" aria-hidden="true">
335+</span>
336+<span class="t-node" aria-hidden="true">
337+</span>
338+<a class="t-name" href="/files/blob/main/page.html">page.html</a>
339+</span>
340+<span class="t-size">26 B</span>
341+<span class="t-sub">
342+<a class="t-commit" href="/files/commit/SHA40" title="T">add page.html</a>
343+</span>
344+<span class="t-when">
346345 <time datetime="STAMP" title="STAMP">AGO</time>
347-</td>
348-</tr>
349-<tr>
350-<td class="cur" aria-hidden="true">
351-</td>
352-<td class="c-kind" aria-hidden="true">·</td>
353-<td class="c-name">
354-<a href="/files/blob/main/pic.png">pic.png</a>
355-</td>
356-<td class="c-size num dim">88 B</td>
357-<td class="c-sub dim">
358-<a href="/files/commit/SHA40">add pic.png</a>
359-</td>
360-<td class="c-who dim">T</td>
361-<td class="c-when nowrap dim">
346+</span>
347+</li>
348+<li role="treeitem" aria-level="1" aria-posinset="15" aria-setsize="15" data-kind="file" data-depth="0">
349+<span class="t-main">
350+<span class="t-twig" aria-hidden="true">
351+</span>
352+<span class="t-node" aria-hidden="true">
353+</span>
354+<a class="t-name" href="/files/blob/main/pic.png">pic.png</a>
355+</span>
356+<span class="t-size">88 B</span>
357+<span class="t-sub">
358+<a class="t-commit" href="/files/commit/SHA40" title="T">add pic.png</a>
359+</span>
360+<span class="t-when">
362361 <time datetime="STAMP" title="STAMP">AGO</time>
363-</td>
364-</tr>
365-</tbody>
366-</table>
367-<section class="card readme" aria-labelledby="readme-h">
368-<h2 class="card-h" id="readme-h">
369-<span class="ico i-file" aria-hidden="true">
370-</span> README.md</h2>
371-<article class="prose" aria-label="README.md" hx-ignore>
372-<h1 id="md-files">Files</h1>
373-<p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
374-</article>
375-</section>
362+</span>
363+</li>
364+</ul>
376365 </div>
377366 </main>
378367 </div>
@@ -421,14 +410,25 @@
421410 <dt>
422411 <kbd>enter</kbd>
423412 </dt>
424-<dd>open the row under the cursor</dd>
413+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
414+</div>
415+<div>
416+<dt>
417+<kbd>↑</kbd>
418+<kbd>↓</kbd>
419+<kbd>←</kbd>
420+<kbd>→</kbd>
421+<kbd>home</kbd>
422+<kbd>end</kbd>
423+</dt>
424+<dd>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</dd>
425425 </div>
426426 <div>
427427 <dt>
428428 <kbd>l</kbd>
429429 <kbd>h</kbd>
430430 </dt>
431-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
431+<dd>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</dd>
432432 </div>
433433 <div>
434434 <dt>
@@ -441,7 +441,7 @@
441441 <dt>
442442 <kbd>/</kbd>
443443 </dt>
444-<dd>filter the list; on Files, find a file in the repository</dd>
444+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
445445 </div>
446446 <div>
447447 <dt>
@@ -96,283 +96,272 @@
96 </header>96 </header>
97 <div class="work">97 <div class="work">
98 <main id="main" tabindex="-1">98 <main id="main" tabindex="-1">
99-<div class="filter">99+<div class="tree-find">
100+<div class="filter wide">
100 <label for="finder-q" class="sr">find a file</label>101 <label for="finder-q" class="sr">find a file</label>
101-<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">102+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file in this repository…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
102 </div>103 </div>
103-<div id="finder" hidden>104+<p class="tree-keys" data-js aria-hidden="true">
105+<kbd>j</kbd>
106+<kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
107+</div>
108+<div id="finder" class="tree-card" hidden>
104 <p id="finder-status" class="dim" aria-live="polite">109 <p id="finder-status" class="dim" aria-live="polite">
105 </p>110 </p>
106 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">111 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
107 </ul>112 </ul>
108 </div>113 </div>
109-<div id="tree-view">114+<div id="tree-view" class="tree-card">
110-<table class="list tree" aria-label="Entries">115+<div class="tree-head">
111-<thead>116+<span class="tree-path">/</span>
112-<tr>117+<span class="tree-count">15 entries</span>
113-<th class="cur">118+</div>
114-</th>119+<ul id="tree-rows" class="tree" role="tree" aria-label="Entries" data-rows data-tree hx-on:click="ermiteTree.click(event)">
115-<th class="c-kind">120+<li role="treeitem" aria-level="1" aria-posinset="1" aria-setsize="15" data-kind="dir" data-depth="0" data-cursor data-expand="/files/tree/main/docs?rows" aria-expanded="false">
116-</th>121+<span class="t-main">
117-<th class="c-name">name</th>122+<span class="t-twig" aria-hidden="true">
118-<th class="c-size">size</th>123+</span>
119-<th class="c-sub">last commit</th>124+<span class="t-node" aria-hidden="true">
120-<th class="c-who">author</th>125+</span>
121-<th class="c-when">date</th>126+<a class="t-name" href="/files/tree/main/docs" title="directory">docs/</a>
122-</tr>127+<span class="sr"> (directory)</span>
123-</thead>128+</span>
124-<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">129+<span class="t-size">
125-<tr data-cursor>130+</span>
126-<td class="cur" aria-hidden="true">131+<span class="t-sub">
127-</td>132+<a class="t-commit" href="/files/commit/SHA40" title="T">add docs/guide.md</a>
128-<td class="c-kind" aria-hidden="true">▸</td>133+</span>
129-<td class="c-name">134+<span class="t-when">
130-<a href="/files/tree/main/docs" data-expand="/files/tree/main/docs?rows" aria-expanded="false">docs/</a>
131-</td>
132-<td class="c-size num dim">-</td>
133-<td class="c-sub dim">
134-<a href="/files/commit/SHA40">add docs/guide.md</a>
135-</td>
136-<td class="c-who dim">T</td>
137-<td class="c-when nowrap dim">
138 <time datetime="STAMP" title="STAMP">AGO</time>135 <time datetime="STAMP" title="STAMP">AGO</time>
139-</td>136+</span>
140-</tr>137+</li>
141-<tr>138+<li role="treeitem" aria-level="1" aria-posinset="2" aria-setsize="15" data-kind="dir" data-depth="0" data-expand="/files/tree/main/src?rows" aria-expanded="false">
142-<td class="cur" aria-hidden="true">139+<span class="t-main">
143-</td>140+<span class="t-twig" aria-hidden="true">
144-<td class="c-kind" aria-hidden="true">▸</td>141+</span>
145-<td class="c-name">142+<span class="t-node" aria-hidden="true">
146-<a href="/files/tree/main/src" data-expand="/files/tree/main/src?rows" aria-expanded="false">src/</a>143+</span>
147-</td>144+<a class="t-name" href="/files/tree/main/src" title="directory">src/</a>
148-<td class="c-size num dim">-</td>145+<span class="sr"> (directory)</span>
149-<td class="c-sub dim">146+</span>
150-<a href="/files/commit/SHA40">add src/lib/util.go</a>147+<span class="t-size">
151-</td>148+</span>
152-<td class="c-who dim">T</td>149+<span class="t-sub">
153-<td class="c-when nowrap dim">150+<a class="t-commit" href="/files/commit/SHA40" title="T">add src/lib/util.go</a>
151+</span>
152+<span class="t-when">
154 <time datetime="STAMP" title="STAMP">AGO</time>153 <time datetime="STAMP" title="STAMP">AGO</time>
155-</td>154+</span>
156-</tr>155+</li>
157-<tr>156+<li role="treeitem" aria-level="1" aria-posinset="3" aria-setsize="15" data-kind="file" data-depth="0">
158-<td class="cur" aria-hidden="true">157+<span class="t-main">
159-</td>158+<span class="t-twig" aria-hidden="true">
160-<td class="c-kind" aria-hidden="true">·</td>159+</span>
161-<td class="c-name">160+<span class="t-node" aria-hidden="true">
162-<a href="/files/blob/main/README.md">README.md</a>161+</span>
163-</td>162+<a class="t-name" href="/files/blob/main/README.md">README.md</a>
164-<td class="c-size num dim">41 B</td>163+</span>
165-<td class="c-sub dim">164+<span class="t-size">41 B</span>
166-<a href="/files/commit/SHA40">add README.md</a>165+<span class="t-sub">
167-</td>166+<a class="t-commit" href="/files/commit/SHA40" title="T">add README.md</a>
168-<td class="c-who dim">T</td>167+</span>
169-<td class="c-when nowrap dim">168+<span class="t-when">
170 <time datetime="STAMP" title="STAMP">AGO</time>169 <time datetime="STAMP" title="STAMP">AGO</time>
171-</td>170+</span>
172-</tr>171+</li>
173-<tr>172+<li role="treeitem" aria-level="1" aria-posinset="4" aria-setsize="15" data-kind="file" data-depth="0">
174-<td class="cur" aria-hidden="true">173+<span class="t-main">
175-</td>174+<span class="t-twig" aria-hidden="true">
176-<td class="c-kind" aria-hidden="true">·</td>175+</span>
177-<td class="c-name">176+<span class="t-node" aria-hidden="true">
178-<a href="/files/blob/main/big.txt">big.txt</a>177+</span>
179-</td>178+<a class="t-name" href="/files/blob/main/big.txt">big.txt</a>
180-<td class="c-size num dim">1.1 MiB</td>179+</span>
181-<td class="c-sub dim">180+<span class="t-size">1.1 MiB</span>
182-<a href="/files/commit/SHA40">add big.txt</a>181+<span class="t-sub">
183-</td>182+<a class="t-commit" href="/files/commit/SHA40" title="T">add big.txt</a>
184-<td class="c-who dim">T</td>183+</span>
185-<td class="c-when nowrap dim">184+<span class="t-when">
186 <time datetime="STAMP" title="STAMP">AGO</time>185 <time datetime="STAMP" title="STAMP">AGO</time>
187-</td>186+</span>
188-</tr>187+</li>
189-<tr>188+<li role="treeitem" aria-level="1" aria-posinset="5" aria-setsize="15" data-kind="file" data-depth="0">
190-<td class="cur" aria-hidden="true">189+<span class="t-main">
191-</td>190+<span class="t-twig" aria-hidden="true">
192-<td class="c-kind" aria-hidden="true">·</td>191+</span>
193-<td class="c-name">192+<span class="t-node" aria-hidden="true">
194-<a href="/files/blob/main/data.bin">data.bin</a>193+</span>
195-</td>194+<a class="t-name" href="/files/blob/main/data.bin">data.bin</a>
196-<td class="c-size num dim">10 B</td>195+</span>
197-<td class="c-sub dim">196+<span class="t-size">10 B</span>
198-<a href="/files/commit/SHA40">add data.bin</a>197+<span class="t-sub">
199-</td>198+<a class="t-commit" href="/files/commit/SHA40" title="T">add data.bin</a>
200-<td class="c-who dim">T</td>199+</span>
201-<td class="c-when nowrap dim">200+<span class="t-when">
202 <time datetime="STAMP" title="STAMP">AGO</time>201 <time datetime="STAMP" title="STAMP">AGO</time>
203-</td>202+</span>
204-</tr>203+</li>
205-<tr>204+<li role="treeitem" aria-level="1" aria-posinset="6" aria-setsize="15" data-kind="file" data-depth="0">
206-<td class="cur" aria-hidden="true">205+<span class="t-main">
207-</td>206+<span class="t-twig" aria-hidden="true">
208-<td class="c-kind" aria-hidden="true">·</td>207+</span>
209-<td class="c-name">208+<span class="t-node" aria-hidden="true">
210-<a href="/files/blob/main/empty.txt">empty.txt</a>209+</span>
211-</td>210+<a class="t-name" href="/files/blob/main/empty.txt">empty.txt</a>
212-<td class="c-size num dim">0 B</td>211+</span>
213-<td class="c-sub dim">212+<span class="t-size">0 B</span>
214-<a href="/files/commit/SHA40">add empty.txt</a>213+<span class="t-sub">
215-</td>214+<a class="t-commit" href="/files/commit/SHA40" title="T">add empty.txt</a>
216-<td class="c-who dim">T</td>215+</span>
217-<td class="c-when nowrap dim">216+<span class="t-when">
218 <time datetime="STAMP" title="STAMP">AGO</time>217 <time datetime="STAMP" title="STAMP">AGO</time>
219-</td>218+</span>
220-</tr>219+</li>
221-<tr>220+<li role="treeitem" aria-level="1" aria-posinset="7" aria-setsize="15" data-kind="file" data-depth="0">
222-<td class="cur" aria-hidden="true">221+<span class="t-main">
223-</td>222+<span class="t-twig" aria-hidden="true">
224-<td class="c-kind" aria-hidden="true">·</td>223+</span>
225-<td class="c-name">224+<span class="t-node" aria-hidden="true">
226-<a href="/files/blob/main/evil.go">evil.go</a>225+</span>
227-</td>226+<a class="t-name" href="/files/blob/main/evil.go">evil.go</a>
228-<td class="c-size num dim">87 B</td>227+</span>
229-<td class="c-sub dim">228+<span class="t-size">87 B</span>
230-<a href="/files/commit/SHA40">add evil.go</a>229+<span class="t-sub">
231-</td>230+<a class="t-commit" href="/files/commit/SHA40" title="T">add evil.go</a>
232-<td class="c-who dim">T</td>231+</span>
233-<td class="c-when nowrap dim">232+<span class="t-when">
234 <time datetime="STAMP" title="STAMP">AGO</time>233 <time datetime="STAMP" title="STAMP">AGO</time>
235-</td>234+</span>
236-</tr>235+</li>
237-<tr>236+<li role="treeitem" aria-level="1" aria-posinset="8" aria-setsize="15" data-kind="file" data-depth="0">
238-<td class="cur" aria-hidden="true">237+<span class="t-main">
239-</td>238+<span class="t-twig" aria-hidden="true">
240-<td class="c-kind" aria-hidden="true">·</td>239+</span>
241-<td class="c-name">240+<span class="t-node" aria-hidden="true">
242-<a href="/files/blob/main/fake.jpg">fake.jpg</a>241+</span>
243-</td>242+<a class="t-name" href="/files/blob/main/fake.jpg">fake.jpg</a>
244-<td class="c-size num dim">15 B</td>243+</span>
245-<td class="c-sub dim">244+<span class="t-size">15 B</span>
246-<a href="/files/commit/SHA40">add fake.jpg</a>245+<span class="t-sub">
247-</td>246+<a class="t-commit" href="/files/commit/SHA40" title="T">add fake.jpg</a>
248-<td class="c-who dim">T</td>247+</span>
249-<td class="c-when nowrap dim">248+<span class="t-when">
250 <time datetime="STAMP" title="STAMP">AGO</time>249 <time datetime="STAMP" title="STAMP">AGO</time>
251-</td>250+</span>
252-</tr>251+</li>
253-<tr>252+<li role="treeitem" aria-level="1" aria-posinset="9" aria-setsize="15" data-kind="file" data-depth="0">
254-<td class="cur" aria-hidden="true">253+<span class="t-main">
255-</td>254+<span class="t-twig" aria-hidden="true">
256-<td class="c-kind" aria-hidden="true">·</td>255+</span>
257-<td class="c-name">256+<span class="t-node" aria-hidden="true">
258-<a href="/files/blob/main/fake.png">fake.png</a>257+</span>
259-</td>258+<a class="t-name" href="/files/blob/main/fake.png">fake.png</a>
260-<td class="c-size num dim">39 B</td>259+</span>
261-<td class="c-sub dim">260+<span class="t-size">39 B</span>
262-<a href="/files/commit/SHA40">add fake.png</a>261+<span class="t-sub">
263-</td>262+<a class="t-commit" href="/files/commit/SHA40" title="T">add fake.png</a>
264-<td class="c-who dim">T</td>263+</span>
265-<td class="c-when nowrap dim">264+<span class="t-when">
266 <time datetime="STAMP" title="STAMP">AGO</time>265 <time datetime="STAMP" title="STAMP">AGO</time>
267-</td>266+</span>
268-</tr>267+</li>
269-<tr>268+<li role="treeitem" aria-level="1" aria-posinset="10" aria-setsize="15" data-kind="file" data-depth="0">
270-<td class="cur" aria-hidden="true">269+<span class="t-main">
271-</td>270+<span class="t-twig" aria-hidden="true">
272-<td class="c-kind" aria-hidden="true">·</td>271+</span>
273-<td class="c-name">272+<span class="t-node" aria-hidden="true">
274-<a href="/files/blob/main/lines.txt">lines.txt</a>273+</span>
275-</td>274+<a class="t-name" href="/files/blob/main/lines.txt">lines.txt</a>
276-<td class="c-size num dim">39.1 KiB</td>275+</span>
277-<td class="c-sub dim">276+<span class="t-size">39.1 KiB</span>
278-<a href="/files/commit/SHA40">add lines.txt</a>277+<span class="t-sub">
279-</td>278+<a class="t-commit" href="/files/commit/SHA40" title="T">add lines.txt</a>
280-<td class="c-who dim">T</td>279+</span>
281-<td class="c-when nowrap dim">280+<span class="t-when">
282 <time datetime="STAMP" title="STAMP">AGO</time>281 <time datetime="STAMP" title="STAMP">AGO</time>
283-</td>282+</span>
284-</tr>283+</li>
285-<tr>284+<li role="treeitem" aria-level="1" aria-posinset="11" aria-setsize="15" data-kind="file" data-depth="0">
286-<td class="cur" aria-hidden="true">285+<span class="t-main">
287-</td>286+<span class="t-twig" aria-hidden="true">
288-<td class="c-kind" aria-hidden="true">·</td>287+</span>
289-<td class="c-name">288+<span class="t-node" aria-hidden="true">
290-<a href="/files/blob/main/logo.svg">logo.svg</a>289+</span>
291-</td>290+<a class="t-name" href="/files/blob/main/logo.svg">logo.svg</a>
292-<td class="c-size num dim">89 B</td>291+</span>
293-<td class="c-sub dim">292+<span class="t-size">89 B</span>
294-<a href="/files/commit/SHA40">add logo.svg</a>293+<span class="t-sub">
295-</td>294+<a class="t-commit" href="/files/commit/SHA40" title="T">add logo.svg</a>
296-<td class="c-who dim">T</td>295+</span>
297-<td class="c-when nowrap dim">296+<span class="t-when">
298 <time datetime="STAMP" title="STAMP">AGO</time>297 <time datetime="STAMP" title="STAMP">AGO</time>
299-</td>298+</span>
300-</tr>299+</li>
301-<tr>300+<li role="treeitem" aria-level="1" aria-posinset="12" aria-setsize="15" data-kind="file" data-depth="0">
302-<td class="cur" aria-hidden="true">301+<span class="t-main">
303-</td>302+<span class="t-twig" aria-hidden="true">
304-<td class="c-kind" aria-hidden="true">·</td>303+</span>
305-<td class="c-name">304+<span class="t-node" aria-hidden="true">
306-<a href="/files/blob/main/main.go">main.go</a>305+</span>
307-</td>306+<a class="t-name" href="/files/blob/main/main.go">main.go</a>
308-<td class="c-size num dim">87 B</td>307+</span>
309-<td class="c-sub dim">308+<span class="t-size">87 B</span>
310-<a href="/files/commit/SHA40">add main.go</a>309+<span class="t-sub">
311-</td>310+<a class="t-commit" href="/files/commit/SHA40" title="T">add main.go</a>
312-<td class="c-who dim">T</td>311+</span>
313-<td class="c-when nowrap dim">312+<span class="t-when">
314 <time datetime="STAMP" title="STAMP">AGO</time>313 <time datetime="STAMP" title="STAMP">AGO</time>
315-</td>314+</span>
316-</tr>315+</li>
317-<tr>316+<li role="treeitem" aria-level="1" aria-posinset="13" aria-setsize="15" data-kind="file" data-depth="0">
318-<td class="cur" aria-hidden="true">317+<span class="t-main">
319-</td>318+<span class="t-twig" aria-hidden="true">
320-<td class="c-kind" aria-hidden="true">·</td>319+</span>
321-<td class="c-name">320+<span class="t-node" aria-hidden="true">
322-<a href="/files/blob/main/notes.txt">notes.txt</a>321+</span>
323-</td>322+<a class="t-name" href="/files/blob/main/notes.txt">notes.txt</a>
324-<td class="c-size num dim">24 B</td>323+</span>
325-<td class="c-sub dim">324+<span class="t-size">24 B</span>
326-<a href="/files/commit/SHA40">add notes.txt</a>325+<span class="t-sub">
327-</td>326+<a class="t-commit" href="/files/commit/SHA40" title="T">add notes.txt</a>
328-<td class="c-who dim">T</td>327+</span>
329-<td class="c-when nowrap dim">328+<span class="t-when">
330 <time datetime="STAMP" title="STAMP">AGO</time>329 <time datetime="STAMP" title="STAMP">AGO</time>
331-</td>330+</span>
332-</tr>331+</li>
333-<tr>332+<li role="treeitem" aria-level="1" aria-posinset="14" aria-setsize="15" data-kind="file" data-depth="0">
334-<td class="cur" aria-hidden="true">333+<span class="t-main">
335-</td>334+<span class="t-twig" aria-hidden="true">
336-<td class="c-kind" aria-hidden="true">·</td>335+</span>
337-<td class="c-name">336+<span class="t-node" aria-hidden="true">
338-<a href="/files/blob/main/page.html">page.html</a>337+</span>
339-</td>338+<a class="t-name" href="/files/blob/main/page.html">page.html</a>
340-<td class="c-size num dim">26 B</td>339+</span>
341-<td class="c-sub dim">340+<span class="t-size">26 B</span>
342-<a href="/files/commit/SHA40">add page.html</a>341+<span class="t-sub">
343-</td>342+<a class="t-commit" href="/files/commit/SHA40" title="T">add page.html</a>
344-<td class="c-who dim">T</td>343+</span>
345-<td class="c-when nowrap dim">344+<span class="t-when">
346 <time datetime="STAMP" title="STAMP">AGO</time>345 <time datetime="STAMP" title="STAMP">AGO</time>
347-</td>346+</span>
348-</tr>347+</li>
349-<tr>348+<li role="treeitem" aria-level="1" aria-posinset="15" aria-setsize="15" data-kind="file" data-depth="0">
350-<td class="cur" aria-hidden="true">349+<span class="t-main">
351-</td>350+<span class="t-twig" aria-hidden="true">
352-<td class="c-kind" aria-hidden="true">·</td>351+</span>
353-<td class="c-name">352+<span class="t-node" aria-hidden="true">
354-<a href="/files/blob/main/pic.png">pic.png</a>353+</span>
355-</td>354+<a class="t-name" href="/files/blob/main/pic.png">pic.png</a>
356-<td class="c-size num dim">88 B</td>355+</span>
357-<td class="c-sub dim">356+<span class="t-size">88 B</span>
358-<a href="/files/commit/SHA40">add pic.png</a>357+<span class="t-sub">
359-</td>358+<a class="t-commit" href="/files/commit/SHA40" title="T">add pic.png</a>
360-<td class="c-who dim">T</td>359+</span>
361-<td class="c-when nowrap dim">360+<span class="t-when">
362 <time datetime="STAMP" title="STAMP">AGO</time>361 <time datetime="STAMP" title="STAMP">AGO</time>
363-</td>362+</span>
364-</tr>363+</li>
365-</tbody>364+</ul>
366-</table>
367-<section class="card readme" aria-labelledby="readme-h">
368-<h2 class="card-h" id="readme-h">
369-<span class="ico i-file" aria-hidden="true">
370-</span> README.md</h2>
371-<article class="prose" aria-label="README.md" hx-ignore>
372-<h1 id="md-files">Files</h1>
373-<p>See <a href="/files/blob/main/docs/guide.md">the guide</a>.</p>
374-</article>
375-</section>
376 </div>365 </div>
377 </main>366 </main>
378 </div>367 </div>
@@ -421,14 +410,25 @@
421 <dt>410 <dt>
422 <kbd>enter</kbd>411 <kbd>enter</kbd>
423 </dt>412 </dt>
424-<dd>open the row under the cursor</dd>413+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
414+</div>
415+<div>
416+<dt>
417+<kbd>↑</kbd>
418+<kbd>↓</kbd>
419+<kbd>←</kbd>
420+<kbd>→</kbd>
421+<kbd>home</kbd>
422+<kbd>end</kbd>
423+</dt>
424+<dd>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</dd>
425 </div>425 </div>
426 <div>426 <div>
427 <dt>427 <dt>
428 <kbd>l</kbd>428 <kbd>l</kbd>
429 <kbd>h</kbd>429 <kbd>h</kbd>
430 </dt>430 </dt>
431-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>431+<dd>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</dd>
432 </div>432 </div>
433 <div>433 <div>
434 <dt>434 <dt>
@@ -441,7 +441,7 @@
441 <dt>441 <dt>
442 <kbd>/</kbd>442 <kbd>/</kbd>
443 </dt>443 </dt>
444-<dd>filter the list; on Files, find a file in the repository</dd>444+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
445 </div>445 </div>
446 <div>446 <div>
447 <dt>447 <dt>
M internal/app/testdata/web/tree-slash-ref.html +45 -39
@@ -99,50 +99,45 @@
9999 </header>
100100 <div class="work">
101101 <main id="main" tabindex="-1">
102-<div class="filter">
102+<div class="tree-find">
103+<div class="filter wide">
103104 <label for="finder-q" class="sr">find a file</label>
104-<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
105+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file in this repository…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
105106 </div>
106-<div id="finder" hidden>
107+<p class="tree-keys" data-js aria-hidden="true">
108+<kbd>j</kbd>
109+<kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
110+</div>
111+<div id="finder" class="tree-card" hidden>
107112 <p id="finder-status" class="dim" aria-live="polite">
108113 </p>
109114 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
110115 </ul>
111116 </div>
112-<div id="tree-view">
113-<table class="list tree" aria-label="Entries">
114-<thead>
115-<tr>
116-<th class="cur">
117-</th>
118-<th class="c-kind">
119-</th>
120-<th class="c-name">name</th>
121-<th class="c-size">size</th>
122-<th class="c-sub">last commit</th>
123-<th class="c-who">author</th>
124-<th class="c-when">date</th>
125-</tr>
126-</thead>
127-<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
128-<tr data-cursor>
129-<td class="cur" aria-hidden="true">
130-</td>
131-<td class="c-kind" aria-hidden="true">·</td>
132-<td class="c-name">
133-<a href="/files/blob/feature/x/x/y.txt">y.txt</a>
134-</td>
135-<td class="c-size num dim">22 B</td>
136-<td class="c-sub dim">
137-<a href="/files/commit/SHA40">add x/y.txt</a>
138-</td>
139-<td class="c-who dim">T</td>
140-<td class="c-when nowrap dim">
117+<div id="tree-view" class="tree-card">
118+<div class="tree-head">
119+<span class="tree-path">x/</span>
120+<a class="tree-up" href="/files/tree/feature/x" title="parent directory (h)">..</a>
121+<span class="tree-count">1 entry</span>
122+</div>
123+<ul id="tree-rows" class="tree" role="tree" aria-label="Entries" data-rows data-tree data-up="/files/tree/feature/x" hx-on:click="ermiteTree.click(event)">
124+<li role="treeitem" aria-level="1" aria-posinset="1" aria-setsize="1" data-kind="file" data-depth="0" data-cursor>
125+<span class="t-main">
126+<span class="t-twig" aria-hidden="true">
127+</span>
128+<span class="t-node" aria-hidden="true">
129+</span>
130+<a class="t-name" href="/files/blob/feature/x/x/y.txt">y.txt</a>
131+</span>
132+<span class="t-size">22 B</span>
133+<span class="t-sub">
134+<a class="t-commit" href="/files/commit/SHA40" title="T">add x/y.txt</a>
135+</span>
136+<span class="t-when">
141137 <time datetime="STAMP" title="STAMP">AGO</time>
142-</td>
143-</tr>
144-</tbody>
145-</table>
138+</span>
139+</li>
140+</ul>
146141 </div>
147142 </main>
148143 </div>
@@ -191,14 +186,25 @@
191186 <dt>
192187 <kbd>enter</kbd>
193188 </dt>
194-<dd>open the row under the cursor</dd>
189+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
190+</div>
191+<div>
192+<dt>
193+<kbd>↑</kbd>
194+<kbd>↓</kbd>
195+<kbd>←</kbd>
196+<kbd>→</kbd>
197+<kbd>home</kbd>
198+<kbd>end</kbd>
199+</dt>
200+<dd>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</dd>
195201 </div>
196202 <div>
197203 <dt>
198204 <kbd>l</kbd>
199205 <kbd>h</kbd>
200206 </dt>
201-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
207+<dd>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</dd>
202208 </div>
203209 <div>
204210 <dt>
@@ -211,7 +217,7 @@
211217 <dt>
212218 <kbd>/</kbd>
213219 </dt>
214-<dd>filter the list; on Files, find a file in the repository</dd>
220+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
215221 </div>
216222 <div>
217223 <dt>
@@ -99,50 +99,45 @@
99 </header>99 </header>
100 <div class="work">100 <div class="work">
101 <main id="main" tabindex="-1">101 <main id="main" tabindex="-1">
102-<div class="filter">102+<div class="tree-find">
103+<div class="filter wide">
103 <label for="finder-q" class="sr">find a file</label>104 <label for="finder-q" class="sr">find a file</label>
104-<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">105+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file in this repository…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
105 </div>106 </div>
106-<div id="finder" hidden>107+<p class="tree-keys" data-js aria-hidden="true">
108+<kbd>j</kbd>
109+<kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
110+</div>
111+<div id="finder" class="tree-card" hidden>
107 <p id="finder-status" class="dim" aria-live="polite">112 <p id="finder-status" class="dim" aria-live="polite">
108 </p>113 </p>
109 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">114 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
110 </ul>115 </ul>
111 </div>116 </div>
112-<div id="tree-view">117+<div id="tree-view" class="tree-card">
113-<table class="list tree" aria-label="Entries">118+<div class="tree-head">
114-<thead>119+<span class="tree-path">x/</span>
115-<tr>120+<a class="tree-up" href="/files/tree/feature/x" title="parent directory (h)">..</a>
116-<th class="cur">121+<span class="tree-count">1 entry</span>
117-</th>122+</div>
118-<th class="c-kind">123+<ul id="tree-rows" class="tree" role="tree" aria-label="Entries" data-rows data-tree data-up="/files/tree/feature/x" hx-on:click="ermiteTree.click(event)">
119-</th>124+<li role="treeitem" aria-level="1" aria-posinset="1" aria-setsize="1" data-kind="file" data-depth="0" data-cursor>
120-<th class="c-name">name</th>125+<span class="t-main">
121-<th class="c-size">size</th>126+<span class="t-twig" aria-hidden="true">
122-<th class="c-sub">last commit</th>127+</span>
123-<th class="c-who">author</th>128+<span class="t-node" aria-hidden="true">
124-<th class="c-when">date</th>129+</span>
125-</tr>130+<a class="t-name" href="/files/blob/feature/x/x/y.txt">y.txt</a>
126-</thead>131+</span>
127-<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">132+<span class="t-size">22 B</span>
128-<tr data-cursor>133+<span class="t-sub">
129-<td class="cur" aria-hidden="true">134+<a class="t-commit" href="/files/commit/SHA40" title="T">add x/y.txt</a>
130-</td>135+</span>
131-<td class="c-kind" aria-hidden="true">·</td>136+<span class="t-when">
132-<td class="c-name">
133-<a href="/files/blob/feature/x/x/y.txt">y.txt</a>
134-</td>
135-<td class="c-size num dim">22 B</td>
136-<td class="c-sub dim">
137-<a href="/files/commit/SHA40">add x/y.txt</a>
138-</td>
139-<td class="c-who dim">T</td>
140-<td class="c-when nowrap dim">
141 <time datetime="STAMP" title="STAMP">AGO</time>137 <time datetime="STAMP" title="STAMP">AGO</time>
142-</td>138+</span>
143-</tr>139+</li>
144-</tbody>140+</ul>
145-</table>
146 </div>141 </div>
147 </main>142 </main>
148 </div>143 </div>
@@ -191,14 +186,25 @@
191 <dt>186 <dt>
192 <kbd>enter</kbd>187 <kbd>enter</kbd>
193 </dt>188 </dt>
194-<dd>open the row under the cursor</dd>189+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
190+</div>
191+<div>
192+<dt>
193+<kbd>↑</kbd>
194+<kbd>↓</kbd>
195+<kbd>←</kbd>
196+<kbd>→</kbd>
197+<kbd>home</kbd>
198+<kbd>end</kbd>
199+</dt>
200+<dd>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</dd>
195 </div>201 </div>
196 <div>202 <div>
197 <dt>203 <dt>
198 <kbd>l</kbd>204 <kbd>l</kbd>
199 <kbd>h</kbd>205 <kbd>h</kbd>
200 </dt>206 </dt>
201-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>207+<dd>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</dd>
202 </div>208 </div>
203 <div>209 <div>
204 <dt>210 <dt>
@@ -211,7 +217,7 @@
211 <dt>217 <dt>
212 <kbd>/</kbd>218 <kbd>/</kbd>
213 </dt>219 </dt>
214-<dd>filter the list; on Files, find a file in the repository</dd>220+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
215 </div>221 </div>
216 <div>222 <div>
217 <dt>223 <dt>
M internal/app/testdata/web/tree-subdir.html +60 -63
@@ -99,75 +99,61 @@
9999 </header>
100100 <div class="work">
101101 <main id="main" tabindex="-1">
102-<div class="filter">
102+<div class="tree-find">
103+<div class="filter wide">
103104 <label for="finder-q" class="sr">find a file</label>
104-<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
105+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file in this repository…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
105106 </div>
106-<div id="finder" hidden>
107+<p class="tree-keys" data-js aria-hidden="true">
108+<kbd>j</kbd>
109+<kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
110+</div>
111+<div id="finder" class="tree-card" hidden>
107112 <p id="finder-status" class="dim" aria-live="polite">
108113 </p>
109114 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
110115 </ul>
111116 </div>
112-<div id="tree-view">
113-<table class="list tree" aria-label="Entries">
114-<thead>
115-<tr>
116-<th class="cur">
117-</th>
118-<th class="c-kind">
119-</th>
120-<th class="c-name">name</th>
121-<th class="c-size">size</th>
122-<th class="c-sub">last commit</th>
123-<th class="c-who">author</th>
124-<th class="c-when">date</th>
125-</tr>
126-</thead>
127-<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">
128-<tr data-cursor>
129-<td class="cur" aria-hidden="true">
130-</td>
131-<td class="c-kind" aria-hidden="true">·</td>
132-<td class="c-name">
133-<a href="/files/blob/main/docs/README.md">README.md</a>
134-</td>
135-<td class="c-size num dim">62 B</td>
136-<td class="c-sub dim">
137-<a href="/files/commit/SHA40">add docs/README.md</a>
138-</td>
139-<td class="c-who dim">T</td>
140-<td class="c-when nowrap dim">
117+<div id="tree-view" class="tree-card">
118+<div class="tree-head">
119+<span class="tree-path">docs/</span>
120+<a class="tree-up" href="/files/tree/main" title="parent directory (h)">..</a>
121+<span class="tree-count">2 entries</span>
122+</div>
123+<ul id="tree-rows" class="tree" role="tree" aria-label="Entries" data-rows data-tree data-up="/files/tree/main" hx-on:click="ermiteTree.click(event)">
124+<li role="treeitem" aria-level="1" aria-posinset="1" aria-setsize="2" data-kind="file" data-depth="0" data-cursor>
125+<span class="t-main">
126+<span class="t-twig" aria-hidden="true">
127+</span>
128+<span class="t-node" aria-hidden="true">
129+</span>
130+<a class="t-name" href="/files/blob/main/docs/README.md">README.md</a>
131+</span>
132+<span class="t-size">62 B</span>
133+<span class="t-sub">
134+<a class="t-commit" href="/files/commit/SHA40" title="T">add docs/README.md</a>
135+</span>
136+<span class="t-when">
141137 <time datetime="STAMP" title="STAMP">AGO</time>
142-</td>
143-</tr>
144-<tr>
145-<td class="cur" aria-hidden="true">
146-</td>
147-<td class="c-kind" aria-hidden="true">·</td>
148-<td class="c-name">
149-<a href="/files/blob/main/docs/guide.md">guide.md</a>
150-</td>
151-<td class="c-size num dim">47 B</td>
152-<td class="c-sub dim">
153-<a href="/files/commit/SHA40">add docs/guide.md</a>
154-</td>
155-<td class="c-who dim">T</td>
156-<td class="c-when nowrap dim">
138+</span>
139+</li>
140+<li role="treeitem" aria-level="1" aria-posinset="2" aria-setsize="2" data-kind="file" data-depth="0">
141+<span class="t-main">
142+<span class="t-twig" aria-hidden="true">
143+</span>
144+<span class="t-node" aria-hidden="true">
145+</span>
146+<a class="t-name" href="/files/blob/main/docs/guide.md">guide.md</a>
147+</span>
148+<span class="t-size">47 B</span>
149+<span class="t-sub">
150+<a class="t-commit" href="/files/commit/SHA40" title="T">add docs/guide.md</a>
151+</span>
152+<span class="t-when">
157153 <time datetime="STAMP" title="STAMP">AGO</time>
158-</td>
159-</tr>
160-</tbody>
161-</table>
162-<section class="card readme" aria-labelledby="readme-h">
163-<h2 class="card-h" id="readme-h">
164-<span class="ico i-file" aria-hidden="true">
165-</span> README.md</h2>
166-<article class="prose" aria-label="README.md" hx-ignore>
167-<h1 id="md-docs">Docs</h1>
168-<p>Back to <a href="/files/blob/main/docs/guide.md">the guide</a> and <a href="/files/blob/main/main.go">root</a>.</p>
169-</article>
170-</section>
154+</span>
155+</li>
156+</ul>
171157 </div>
172158 </main>
173159 </div>
@@ -216,14 +202,25 @@
216202 <dt>
217203 <kbd>enter</kbd>
218204 </dt>
219-<dd>open the row under the cursor</dd>
205+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
206+</div>
207+<div>
208+<dt>
209+<kbd>↑</kbd>
210+<kbd>↓</kbd>
211+<kbd>←</kbd>
212+<kbd>→</kbd>
213+<kbd>home</kbd>
214+<kbd>end</kbd>
215+</dt>
216+<dd>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</dd>
220217 </div>
221218 <div>
222219 <dt>
223220 <kbd>l</kbd>
224221 <kbd>h</kbd>
225222 </dt>
226-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>
223+<dd>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</dd>
227224 </div>
228225 <div>
229226 <dt>
@@ -236,7 +233,7 @@
236233 <dt>
237234 <kbd>/</kbd>
238235 </dt>
239-<dd>filter the list; on Files, find a file in the repository</dd>
236+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
240237 </div>
241238 <div>
242239 <dt>
@@ -99,75 +99,61 @@
99 </header>99 </header>
100 <div class="work">100 <div class="work">
101 <main id="main" tabindex="-1">101 <main id="main" tabindex="-1">
102-<div class="filter">102+<div class="tree-find">
103+<div class="filter wide">
103 <label for="finder-q" class="sr">find a file</label>104 <label for="finder-q" class="sr">find a file</label>
104-<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">105+<input id="finder-q" type="search" data-filter role="combobox" aria-autocomplete="list" aria-controls="finder-list" aria-expanded="false" placeholder="Find a file in this repository…" autocomplete="off" spellcheck="false" hx-on:focus="ermiteFinder.load()" hx-on:input="ermiteFinder.input(this)" hx-on:keydown="ermiteFinder.key(this, event)">
105 </div>106 </div>
106-<div id="finder" hidden>107+<p class="tree-keys" data-js aria-hidden="true">
108+<kbd>j</kbd>
109+<kbd>k</kbd> move · <kbd>l</kbd> open · <kbd>h</kbd> back</p>
110+</div>
111+<div id="finder" class="tree-card" hidden>
107 <p id="finder-status" class="dim" aria-live="polite">112 <p id="finder-status" class="dim" aria-live="polite">
108 </p>113 </p>
109 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">114 <ul id="finder-list" class="cmd-list" role="listbox" aria-label="Files">
110 </ul>115 </ul>
111 </div>116 </div>
112-<div id="tree-view">117+<div id="tree-view" class="tree-card">
113-<table class="list tree" aria-label="Entries">118+<div class="tree-head">
114-<thead>119+<span class="tree-path">docs/</span>
115-<tr>120+<a class="tree-up" href="/files/tree/main" title="parent directory (h)">..</a>
116-<th class="cur">121+<span class="tree-count">2 entries</span>
117-</th>122+</div>
118-<th class="c-kind">123+<ul id="tree-rows" class="tree" role="tree" aria-label="Entries" data-rows data-tree data-up="/files/tree/main" hx-on:click="ermiteTree.click(event)">
119-</th>124+<li role="treeitem" aria-level="1" aria-posinset="1" aria-setsize="2" data-kind="file" data-depth="0" data-cursor>
120-<th class="c-name">name</th>125+<span class="t-main">
121-<th class="c-size">size</th>126+<span class="t-twig" aria-hidden="true">
122-<th class="c-sub">last commit</th>127+</span>
123-<th class="c-who">author</th>128+<span class="t-node" aria-hidden="true">
124-<th class="c-when">date</th>129+</span>
125-</tr>130+<a class="t-name" href="/files/blob/main/docs/README.md">README.md</a>
126-</thead>131+</span>
127-<tbody id="tree-rows" data-rows hx-on:click="ermiteTree.click(event)">132+<span class="t-size">62 B</span>
128-<tr data-cursor>133+<span class="t-sub">
129-<td class="cur" aria-hidden="true">134+<a class="t-commit" href="/files/commit/SHA40" title="T">add docs/README.md</a>
130-</td>135+</span>
131-<td class="c-kind" aria-hidden="true">·</td>136+<span class="t-when">
132-<td class="c-name">
133-<a href="/files/blob/main/docs/README.md">README.md</a>
134-</td>
135-<td class="c-size num dim">62 B</td>
136-<td class="c-sub dim">
137-<a href="/files/commit/SHA40">add docs/README.md</a>
138-</td>
139-<td class="c-who dim">T</td>
140-<td class="c-when nowrap dim">
141 <time datetime="STAMP" title="STAMP">AGO</time>137 <time datetime="STAMP" title="STAMP">AGO</time>
142-</td>138+</span>
143-</tr>139+</li>
144-<tr>140+<li role="treeitem" aria-level="1" aria-posinset="2" aria-setsize="2" data-kind="file" data-depth="0">
145-<td class="cur" aria-hidden="true">141+<span class="t-main">
146-</td>142+<span class="t-twig" aria-hidden="true">
147-<td class="c-kind" aria-hidden="true">·</td>143+</span>
148-<td class="c-name">144+<span class="t-node" aria-hidden="true">
149-<a href="/files/blob/main/docs/guide.md">guide.md</a>145+</span>
150-</td>146+<a class="t-name" href="/files/blob/main/docs/guide.md">guide.md</a>
151-<td class="c-size num dim">47 B</td>147+</span>
152-<td class="c-sub dim">148+<span class="t-size">47 B</span>
153-<a href="/files/commit/SHA40">add docs/guide.md</a>149+<span class="t-sub">
154-</td>150+<a class="t-commit" href="/files/commit/SHA40" title="T">add docs/guide.md</a>
155-<td class="c-who dim">T</td>151+</span>
156-<td class="c-when nowrap dim">152+<span class="t-when">
157 <time datetime="STAMP" title="STAMP">AGO</time>153 <time datetime="STAMP" title="STAMP">AGO</time>
158-</td>154+</span>
159-</tr>155+</li>
160-</tbody>156+</ul>
161-</table>
162-<section class="card readme" aria-labelledby="readme-h">
163-<h2 class="card-h" id="readme-h">
164-<span class="ico i-file" aria-hidden="true">
165-</span> README.md</h2>
166-<article class="prose" aria-label="README.md" hx-ignore>
167-<h1 id="md-docs">Docs</h1>
168-<p>Back to <a href="/files/blob/main/docs/guide.md">the guide</a> and <a href="/files/blob/main/main.go">root</a>.</p>
169-</article>
170-</section>
171 </div>157 </div>
172 </main>158 </main>
173 </div>159 </div>
@@ -216,14 +202,25 @@
216 <dt>202 <dt>
217 <kbd>enter</kbd>203 <kbd>enter</kbd>
218 </dt>204 </dt>
219-<dd>open the row under the cursor</dd>205+<dd>open the row under the cursor; on Files, a directory opens or closes in place</dd>
206+</div>
207+<div>
208+<dt>
209+<kbd>↑</kbd>
210+<kbd>↓</kbd>
211+<kbd>←</kbd>
212+<kbd>→</kbd>
213+<kbd>home</kbd>
214+<kbd>end</kbd>
215+</dt>
216+<dd>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</dd>
220 </div>217 </div>
221 <div>218 <div>
222 <dt>219 <dt>
223 <kbd>l</kbd>220 <kbd>l</kbd>
224 <kbd>h</kbd>221 <kbd>h</kbd>
225 </dt>222 </dt>
226-<dd>into the side panel (on a merge request, its files) / back to the content; there j k move between its controls</dd>223+<dd>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</dd>
227 </div>224 </div>
228 <div>225 <div>
229 <dt>226 <dt>
@@ -236,7 +233,7 @@
236 <dt>233 <dt>
237 <kbd>/</kbd>234 <kbd>/</kbd>
238 </dt>235 </dt>
239-<dd>filter the list; on Files, find a file in the repository</dd>236+<dd>filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)</dd>
240 </div>237 </div>
241 <div>238 <div>
242 <dt>239 <dt>
M internal/app/web_files_test.go +80 -11
@@ -116,7 +116,15 @@ func TestWebTreeRows(t *testing.T) {
116116
117117 // The page links its directories to their rows.
118118 _, _, page := h.get("/files/tree/main")
119- assert.Contains(t, page, `href="/files/tree/main/docs" data-expand="/files/tree/main/docs?rows" aria-expanded="false"`)
119+ assert.Contains(t, page, `data-expand="/files/tree/main/docs?rows" aria-expanded="false"`)
120+ assert.Contains(t, page, `<a class="t-name" href="/files/tree/main/docs" title="directory">docs/</a>`)
121+ // Every row says its place among the entries of its own directory.
122+ assert.Contains(t, page, `aria-level="1" aria-posinset="1" aria-setsize="15" data-kind="dir"`)
123+ assert.Contains(t, page, `aria-posinset="15" aria-setsize="15" data-kind="file"`)
124+ assert.Equal(t, 15, strings.Count(page, `role="treeitem"`))
125+ assert.Contains(t, page, `<a class="t-commit" href="/files/commit/`)
126+ assert.NotContains(t, page, "<h1", "no README below the tree: the summary shows it")
127+ assert.NotContains(t, page, "See the guide")
120128 assert.Contains(t, page, `<script src="/-/static/js/files.`)
121129 assert.NotContains(t, page, "main.go</span>", "the palette lists no file")
122130
@@ -126,16 +134,19 @@ func TestWebTreeRows(t *testing.T) {
126134 assert.Contains(t, hdr.Get("Content-Type"), "text/html")
127135 assert.NotContains(t, body, "<html")
128136 assert.NotContains(t, body, "<table")
129- assert.Equal(t, 2, strings.Count(body, "<tr"), "one row per entry")
130- assert.Contains(t, body, `<a href="/files/blob/main/docs/README.md">README.md</a>`)
131- assert.Contains(t, body, `<a href="/files/blob/main/docs/guide.md">guide.md</a>`)
137+ assert.NotContains(t, body, "<ul")
138+ assert.Equal(t, 2, strings.Count(body, "<li"), "one row per entry")
139+ assert.Contains(t, body, `aria-posinset="1" aria-setsize="2"`)
140+ assert.Contains(t, body, `aria-posinset="2" aria-setsize="2"`, "positions count the entries of the directory itself")
141+ assert.Contains(t, body, `<a class="t-name" href="/files/blob/main/docs/README.md">README.md</a>`)
142+ assert.Contains(t, body, `<a class="t-name" href="/files/blob/main/docs/guide.md">guide.md</a>`)
132143 assert.NotContains(t, body, "data-cursor", "the cursor stays on the page")
133144 assertNoInline(t, body)
134145
135146 // A subdirectory row can be opened in turn.
136147 _, _, body = h.get("/files/tree/main/src?rows")
137- assert.Contains(t, body, `href="/files/tree/main/src/lib" data-expand="/files/tree/main/src/lib?rows" aria-expanded="false"`)
138- assert.Contains(t, body, "▸")
148+ assert.Contains(t, body, `data-expand="/files/tree/main/src/lib?rows" aria-expanded="false"`)
149+ assert.Contains(t, body, `data-kind="dir"`)
139150 _, _, body = h.get("/files/tree/main/src/lib?rows")
140151 assert.Contains(t, body, `href="/files/blob/main/src/lib/util.go"`)
141152 assert.NotContains(t, body, "data-expand")
@@ -169,13 +180,68 @@ func TestWebTreeRowsTruncated(t *testing.T) {
169180
170181 status, _, body := h.get("/wide/tree/main/many?rows")
171182 require.Equal(t, 200, status)
172- assert.Equal(t, domain.MaxTreeEntries+1, strings.Count(body, "<tr"), "the listed entries and the row that says there are more")
173- assert.Contains(t, body, `<tr class="tree-more"><td colspan="7"><a href="/wide/tree/main/many">more entries: open the directory</a></td></tr>`)
183+ assert.Equal(t, domain.MaxTreeEntries+1, strings.Count(body, "<li"), "the listed entries and the row that says there are more")
184+ assert.Contains(t, body, `<a class="t-name" href="/wide/tree/main/many">more entries: open the directory</a>`)
174185 assert.Equal(t, 1, strings.Count(body, "tree-more"))
186+ // The listing is cut: the size is unknown (-1), and the row that says so is the next position.
187+ assert.Equal(t, domain.MaxTreeEntries, strings.Count(body, `aria-setsize="-1" data-kind=`))
188+ assert.Contains(t, body, `aria-posinset="1" aria-setsize="-1" data-kind="file"`)
189+ assert.Contains(t, body, fmt.Sprintf(`class="tree-more" role="treeitem" aria-level="1" aria-posinset="%d" aria-setsize="-1"`, domain.MaxTreeEntries+1))
175190
176191 // A directory that fits has no such row.
177192 _, _, body = h.get("/wide/tree?rows")
178193 assert.NotContains(t, body, "tree-more")
194+
195+ // The page of the directory says the listing is cut, in a note, and its
196+ // rows are of unknown number.
197+ status, _, page := h.get("/wide/tree/main/many")
198+ require.Equal(t, 200, status)
199+ assert.Contains(t, page, fmt.Sprintf(`<p class="note">this directory has more entries than are listed here: the first %d are shown.</p>`, domain.MaxTreeEntries))
200+ assert.Contains(t, page, `aria-posinset="1" aria-setsize="-1" data-kind="file"`)
201+ assert.NotContains(t, page, "tree-more", "the page of the directory has no row for the rest: its note says it")
202+ _, _, page = h.get("/wide/tree/main")
203+ assert.NotContains(t, page, `class="note"`, "a directory that fits has no note")
204+}
205+
206+// Entries that are not plain files or directories say what they are in words
207+// (the node drawn for them is aria-hidden), and a name is text: HTML in it is
208+// escaped and invisible characters show as markers.
209+func TestWebTreeEntryKinds(t *testing.T) {
210+ h := newHarness(t, harnessOpts{})
211+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("kinds"), Visibility: domain.VisibilityPublic})
212+ require.NoError(t, err)
213+ wt := h.t.TempDir()
214+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
215+ h.writeFile(wt, "target.txt", []byte("t\n"))
216+ h.writeFile(wt, `odd<b>&"name.txt`, []byte("x\n"))
217+ h.writeFile(wt, "bidi\u202eevil.txt", []byte("x\n"))
218+ h.writeFile(wt, "dir/inner.txt", []byte("x\n"))
219+ // A symbolic link and a submodule entry (a gitlink: no clone is needed).
220+ require.NoError(t, os.Symlink("target.txt", filepath.Join(wt, "link.txt")))
221+ h.mustGit(wt, "add", "link.txt")
222+ h.mustGit(wt, "update-index", "--add", "--cacheinfo", "160000,"+strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))+",vendor")
223+ h.mustGit(wt, "commit", "-q", "-m", "kinds")
224+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/kinds.git"), "main")
225+
226+ status, _, page := h.get("/kinds/tree/main")
227+ require.Equal(t, 200, status)
228+ assertNoInline(t, page)
229+
230+ // A symbolic link has a link to its page, and says what it is.
231+ assert.Contains(t, page, `data-kind="link"`)
232+ assert.Contains(t, page, `<a class="t-name" href="/kinds/blob/main/link.txt" title="symbolic link">link.txt</a> <span class="sr"> (symbolic link)</span>`)
233+ // A submodule has no page here: a name that is not a link.
234+ assert.Contains(t, page, `data-kind="module"`)
235+ assert.Contains(t, page, `<span class="t-name" title="submodule">vendor</span> <span class="sr"> (submodule)</span>`)
236+ // A directory says so too, and a file says nothing more.
237+ assert.Contains(t, page, `title="directory">dir/</a> <span class="sr"> (directory)</span>`)
238+ assert.Contains(t, page, `href="/kinds/blob/main/target.txt">target.txt</a> </span>`)
239+
240+ // HTML in a name is escaped; an invisible character shows as its marker.
241+ assert.NotContains(t, page, "<b>&")
242+ assert.Contains(t, page, `odd&lt;b&gt;&amp;&#34;name.txt`)
243+ assert.Contains(t, page, "bidi&lt;U+202E&gt;evil.txt")
244+ assert.NotContains(t, page, "\u202e")
179245 }
180246
181247 // Ref names with a slash are found by their longest prefix, and a commit id
@@ -201,10 +267,13 @@ func TestWebFilesRefs(t *testing.T) {
201267 status, _, _ = h.get("/files/tree/" + sha[:7])
202268 assert.Equal(t, 200, status, "an abbreviated commit id")
203269
204- // Links of a directory README stay under the directory and the ref.
270+ // The entries of a directory stay under the directory and the ref, and the
271+ // tree goes up to the parent.
205272 _, _, body = h.get("/files/tree/main/docs")
206273 assert.Contains(t, body, `href="/files/blob/main/docs/guide.md"`)
207- assert.Contains(t, body, `href="/files/blob/main/main.go"`)
274+ assert.Contains(t, body, `data-up="/files/tree/main"`)
275+ _, _, body = h.get("/files/tree/main/src/lib")
276+ assert.Contains(t, body, `data-up="/files/tree/main/src"`)
208277 }
209278
210279 // AC3: what does not exist is the 404 page, with no hint of why.
@@ -574,7 +643,7 @@ func TestWebFinderPage(t *testing.T) {
574643 if strings.Contains(tt.path, "/tree/") {
575644 assert.Contains(t, body, `id="finder-q"`)
576645 assert.Contains(t, body, `id="finder-list"`)
577- assert.Contains(t, body, `placeholder="Find a file…"`)
646+ assert.Contains(t, body, `placeholder="Find a file in this repository…"`)
578647 } else {
579648 assert.NotContains(t, body, `id="finder-q"`)
580649 }
@@ -116,7 +116,15 @@ func TestWebTreeRows(t *testing.T) {
116 116
117 // The page links its directories to their rows.117 // The page links its directories to their rows.
118 _, _, page := h.get("/files/tree/main")118 _, _, page := h.get("/files/tree/main")
119- assert.Contains(t, page, `href="/files/tree/main/docs" data-expand="/files/tree/main/docs?rows" aria-expanded="false"`)119+ assert.Contains(t, page, `data-expand="/files/tree/main/docs?rows" aria-expanded="false"`)
120+ assert.Contains(t, page, `<a class="t-name" href="/files/tree/main/docs" title="directory">docs/</a>`)
121+ // Every row says its place among the entries of its own directory.
122+ assert.Contains(t, page, `aria-level="1" aria-posinset="1" aria-setsize="15" data-kind="dir"`)
123+ assert.Contains(t, page, `aria-posinset="15" aria-setsize="15" data-kind="file"`)
124+ assert.Equal(t, 15, strings.Count(page, `role="treeitem"`))
125+ assert.Contains(t, page, `<a class="t-commit" href="/files/commit/`)
126+ assert.NotContains(t, page, "<h1", "no README below the tree: the summary shows it")
127+ assert.NotContains(t, page, "See the guide")
120 assert.Contains(t, page, `<script src="/-/static/js/files.`)128 assert.Contains(t, page, `<script src="/-/static/js/files.`)
121 assert.NotContains(t, page, "main.go</span>", "the palette lists no file")129 assert.NotContains(t, page, "main.go</span>", "the palette lists no file")
122 130
@@ -126,16 +134,19 @@ func TestWebTreeRows(t *testing.T) {
126 assert.Contains(t, hdr.Get("Content-Type"), "text/html")134 assert.Contains(t, hdr.Get("Content-Type"), "text/html")
127 assert.NotContains(t, body, "<html")135 assert.NotContains(t, body, "<html")
128 assert.NotContains(t, body, "<table")136 assert.NotContains(t, body, "<table")
129- assert.Equal(t, 2, strings.Count(body, "<tr"), "one row per entry")137+ assert.NotContains(t, body, "<ul")
130- assert.Contains(t, body, `<a href="/files/blob/main/docs/README.md">README.md</a>`)138+ assert.Equal(t, 2, strings.Count(body, "<li"), "one row per entry")
131- assert.Contains(t, body, `<a href="/files/blob/main/docs/guide.md">guide.md</a>`)139+ assert.Contains(t, body, `aria-posinset="1" aria-setsize="2"`)
140+ assert.Contains(t, body, `aria-posinset="2" aria-setsize="2"`, "positions count the entries of the directory itself")
141+ assert.Contains(t, body, `<a class="t-name" href="/files/blob/main/docs/README.md">README.md</a>`)
142+ assert.Contains(t, body, `<a class="t-name" href="/files/blob/main/docs/guide.md">guide.md</a>`)
132 assert.NotContains(t, body, "data-cursor", "the cursor stays on the page")143 assert.NotContains(t, body, "data-cursor", "the cursor stays on the page")
133 assertNoInline(t, body)144 assertNoInline(t, body)
134 145
135 // A subdirectory row can be opened in turn.146 // A subdirectory row can be opened in turn.
136 _, _, body = h.get("/files/tree/main/src?rows")147 _, _, body = h.get("/files/tree/main/src?rows")
137- assert.Contains(t, body, `href="/files/tree/main/src/lib" data-expand="/files/tree/main/src/lib?rows" aria-expanded="false"`)148+ assert.Contains(t, body, `data-expand="/files/tree/main/src/lib?rows" aria-expanded="false"`)
138- assert.Contains(t, body, "▸")149+ assert.Contains(t, body, `data-kind="dir"`)
139 _, _, body = h.get("/files/tree/main/src/lib?rows")150 _, _, body = h.get("/files/tree/main/src/lib?rows")
140 assert.Contains(t, body, `href="/files/blob/main/src/lib/util.go"`)151 assert.Contains(t, body, `href="/files/blob/main/src/lib/util.go"`)
141 assert.NotContains(t, body, "data-expand")152 assert.NotContains(t, body, "data-expand")
@@ -169,13 +180,68 @@ func TestWebTreeRowsTruncated(t *testing.T) {
169 180
170 status, _, body := h.get("/wide/tree/main/many?rows")181 status, _, body := h.get("/wide/tree/main/many?rows")
171 require.Equal(t, 200, status)182 require.Equal(t, 200, status)
172- assert.Equal(t, domain.MaxTreeEntries+1, strings.Count(body, "<tr"), "the listed entries and the row that says there are more")183+ assert.Equal(t, domain.MaxTreeEntries+1, strings.Count(body, "<li"), "the listed entries and the row that says there are more")
173- assert.Contains(t, body, `<tr class="tree-more"><td colspan="7"><a href="/wide/tree/main/many">more entries: open the directory</a></td></tr>`)184+ assert.Contains(t, body, `<a class="t-name" href="/wide/tree/main/many">more entries: open the directory</a>`)
174 assert.Equal(t, 1, strings.Count(body, "tree-more"))185 assert.Equal(t, 1, strings.Count(body, "tree-more"))
186+ // The listing is cut: the size is unknown (-1), and the row that says so is the next position.
187+ assert.Equal(t, domain.MaxTreeEntries, strings.Count(body, `aria-setsize="-1" data-kind=`))
188+ assert.Contains(t, body, `aria-posinset="1" aria-setsize="-1" data-kind="file"`)
189+ assert.Contains(t, body, fmt.Sprintf(`class="tree-more" role="treeitem" aria-level="1" aria-posinset="%d" aria-setsize="-1"`, domain.MaxTreeEntries+1))
175 190
176 // A directory that fits has no such row.191 // A directory that fits has no such row.
177 _, _, body = h.get("/wide/tree?rows")192 _, _, body = h.get("/wide/tree?rows")
178 assert.NotContains(t, body, "tree-more")193 assert.NotContains(t, body, "tree-more")
194+
195+ // The page of the directory says the listing is cut, in a note, and its
196+ // rows are of unknown number.
197+ status, _, page := h.get("/wide/tree/main/many")
198+ require.Equal(t, 200, status)
199+ assert.Contains(t, page, fmt.Sprintf(`<p class="note">this directory has more entries than are listed here: the first %d are shown.</p>`, domain.MaxTreeEntries))
200+ assert.Contains(t, page, `aria-posinset="1" aria-setsize="-1" data-kind="file"`)
201+ assert.NotContains(t, page, "tree-more", "the page of the directory has no row for the rest: its note says it")
202+ _, _, page = h.get("/wide/tree/main")
203+ assert.NotContains(t, page, `class="note"`, "a directory that fits has no note")
204+}
205+
206+// Entries that are not plain files or directories say what they are in words
207+// (the node drawn for them is aria-hidden), and a name is text: HTML in it is
208+// escaped and invisible characters show as markers.
209+func TestWebTreeEntryKinds(t *testing.T) {
210+ h := newHarness(t, harnessOpts{})
211+ _, err := h.forge.Repos.Create(h.ctx, domain.Root(), ports.CreateRepoParams{Name: h.name("kinds"), Visibility: domain.VisibilityPublic})
212+ require.NoError(t, err)
213+ wt := h.t.TempDir()
214+ h.mustGit(wt, "init", "-q", "--initial-branch=main")
215+ h.writeFile(wt, "target.txt", []byte("t\n"))
216+ h.writeFile(wt, `odd<b>&"name.txt`, []byte("x\n"))
217+ h.writeFile(wt, "bidi\u202eevil.txt", []byte("x\n"))
218+ h.writeFile(wt, "dir/inner.txt", []byte("x\n"))
219+ // A symbolic link and a submodule entry (a gitlink: no clone is needed).
220+ require.NoError(t, os.Symlink("target.txt", filepath.Join(wt, "link.txt")))
221+ h.mustGit(wt, "add", "link.txt")
222+ h.mustGit(wt, "update-index", "--add", "--cacheinfo", "160000,"+strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))+",vendor")
223+ h.mustGit(wt, "commit", "-q", "-m", "kinds")
224+ h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/kinds.git"), "main")
225+
226+ status, _, page := h.get("/kinds/tree/main")
227+ require.Equal(t, 200, status)
228+ assertNoInline(t, page)
229+
230+ // A symbolic link has a link to its page, and says what it is.
231+ assert.Contains(t, page, `data-kind="link"`)
232+ assert.Contains(t, page, `<a class="t-name" href="/kinds/blob/main/link.txt" title="symbolic link">link.txt</a> <span class="sr"> (symbolic link)</span>`)
233+ // A submodule has no page here: a name that is not a link.
234+ assert.Contains(t, page, `data-kind="module"`)
235+ assert.Contains(t, page, `<span class="t-name" title="submodule">vendor</span> <span class="sr"> (submodule)</span>`)
236+ // A directory says so too, and a file says nothing more.
237+ assert.Contains(t, page, `title="directory">dir/</a> <span class="sr"> (directory)</span>`)
238+ assert.Contains(t, page, `href="/kinds/blob/main/target.txt">target.txt</a> </span>`)
239+
240+ // HTML in a name is escaped; an invisible character shows as its marker.
241+ assert.NotContains(t, page, "<b>&")
242+ assert.Contains(t, page, `odd&lt;b&gt;&amp;&#34;name.txt`)
243+ assert.Contains(t, page, "bidi&lt;U+202E&gt;evil.txt")
244+ assert.NotContains(t, page, "\u202e")
179 }245 }
180 246
181 // Ref names with a slash are found by their longest prefix, and a commit id247 // Ref names with a slash are found by their longest prefix, and a commit id
@@ -201,10 +267,13 @@ func TestWebFilesRefs(t *testing.T) {
201 status, _, _ = h.get("/files/tree/" + sha[:7])267 status, _, _ = h.get("/files/tree/" + sha[:7])
202 assert.Equal(t, 200, status, "an abbreviated commit id")268 assert.Equal(t, 200, status, "an abbreviated commit id")
203 269
204- // Links of a directory README stay under the directory and the ref.270+ // The entries of a directory stay under the directory and the ref, and the
271+ // tree goes up to the parent.
205 _, _, body = h.get("/files/tree/main/docs")272 _, _, body = h.get("/files/tree/main/docs")
206 assert.Contains(t, body, `href="/files/blob/main/docs/guide.md"`)273 assert.Contains(t, body, `href="/files/blob/main/docs/guide.md"`)
207- assert.Contains(t, body, `href="/files/blob/main/main.go"`)274+ assert.Contains(t, body, `data-up="/files/tree/main"`)
275+ _, _, body = h.get("/files/tree/main/src/lib")
276+ assert.Contains(t, body, `data-up="/files/tree/main/src"`)
208 }277 }
209 278
210 // AC3: what does not exist is the 404 page, with no hint of why.279 // AC3: what does not exist is the 404 page, with no hint of why.
@@ -574,7 +643,7 @@ func TestWebFinderPage(t *testing.T) {
574 if strings.Contains(tt.path, "/tree/") {643 if strings.Contains(tt.path, "/tree/") {
575 assert.Contains(t, body, `id="finder-q"`)644 assert.Contains(t, body, `id="finder-q"`)
576 assert.Contains(t, body, `id="finder-list"`)645 assert.Contains(t, body, `id="finder-list"`)
577- assert.Contains(t, body, `placeholder="Find a file…"`)646+ assert.Contains(t, body, `placeholder="Find a file in this repository…"`)
578 } else {647 } else {
579 assert.NotContains(t, body, `id="finder-q"`)648 assert.NotContains(t, body, `id="finder-q"`)
580 }649 }

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 43

↑↓ moveenter openesc close