v0.18.1 e08f000

finder.test.mjs RawHistoryFolder

1// Tests of the DOM glue of the file finder and of the ":" palette
2// (static/js/files.js) on the small DOM of dom.mjs. Run with
3//   node --test 'internal/adapters/web/jstest/*.test.mjs'
4import assert from 'node:assert/strict';
5import { test } from 'node:test';
6import { dom, el, flushFrames, installDom, settle } from './dom.mjs';
7
8installDom();
9await import('../static/js/files.js');
10const F = window.ermiteFinder;
11
12const PATHS = [
13  'README.md',
14  'cmd/ermite/main.go',
15  'internal/adapters/web/tree.go',
16  'internal/adapters/web/tree.templ',
17  'docs/web/tree-view.md',
18  'src/treehouse.txt',
19];
20for (let i = 0; i < 1194; i++) PATHS.push('gen/f' + i + '.dat'); // 1,200 in all
21
22// The path list answers when the test says so (once: the finder keeps it).
23let release;
24const gate = new Promise((r) => { release = r; });
25let fetches = 0;
26globalThis.fetch = async () => {
27  fetches++;
28  await gate;
29  return { ok: true, json: async () => ({ paths: PATHS, truncated: true }) };
30};
31
32// 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.
34function page() {
35  const rows = ['r1', 'r2', 'r3'].map((n) => el('tr', { attrs: { 'data-nm': n } }));
36  rows[1].setAttribute('data-cursor', '');
37  const q = el('input', { id: 'finder-q' });
38  const cmdQ = el('input', { id: 'cmd-q' });
39  const treeRows = el('tbody', { id: 'tree-rows', attrs: { 'data-rows': '' } }, ...rows);
40  const finderList = el('ul', { id: 'finder-list' });
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);
43  const treeView = el('div', { id: 'tree-view' }, treeRows);
44  dom.body.replaceChildren(q, cmdQ, treeView, finder, cmdList);
45  dom.body.setAttribute('data-paths', '/paths.json');
46  dom.body.setAttribute('data-blob', '/blob/main');
47  dom.clicked = [];
48  return { rows, q, cmdQ, treeRows, finderList, cmdList, finder, treeView };
49}
50
51const key = (k, extra = {}) => {
52  const e = { key: k, isComposing: false, prevented: false, preventDefault() { this.prevented = true; }, ...extra };
53  return e;
54};
55const status = () => document.getElementById('finder-status').textContent;
56const hrefs = (list) => list.children.map((li) => li.querySelector('a').href);
57
58test('Enter before the results opens the top hit once they come', async () => {
59  const p = page();
60  p.q.value = 'tree';
61  F.input(p.q);
62  assert.equal(p.finder.hidden, false, 'the results replace the tree at once');
63  F.key(p.q, key('Enter'));
64  assert.equal(dom.clicked.length, 0, 'nothing to open yet');
65  release();
66  await settle();
67  assert.equal(p.finderList.children.length > 0, true);
68  assert.equal(dom.clicked.length, 1);
69  assert.equal(dom.clicked[0].href, hrefs(p.finderList)[0]);
70  assert.match(dom.clicked[0].href, /^\/blob\/main\/.*tree/);
71  assert.equal(fetches, 1);
72});
73
74test('the status counts the files and names the real size of a truncated list', async () => {
75  const p = page();
76  p.q.value = 'tree';
77  F.input(p.q);
78  await settle();
79  assert.equal(status(), '4 files match · only the first 1,200 files are searched');
80  p.q.value = 'zzzz';
81  F.input(p.q);
82  await settle();
83  assert.match(status(), /^no file matches · /);
84});
85
86test('an Enter is dropped when the field changed since', async () => {
87  const p = page();
88  p.q.value = 'tree';
89  F.input(p.q);
90  F.key(p.q, key('Enter')); // pending on "tree"
91  p.q.value = 'tre';
92  F.input(p.q);
93  await settle();
94  assert.equal(p.finderList.children.length > 0, true, 'the results of "tre" are shown');
95  assert.equal(dom.clicked.length, 0);
96});
97
98test('Enter on current results opens the cursor row, whichever way the cursor moved', async () => {
99  const p = page();
100  p.q.value = 'tree';
101  F.input(p.q);
102  await settle();
103  const items = p.finderList.children;
104  assert.equal(items[0].getAttribute('aria-selected'), 'true');
105  assert.equal(p.q.getAttribute('aria-activedescendant'), items[0].id);
106  // j/k (keys.templ) move data-cursor while the field is blurred.
107  items[0].removeAttribute('data-cursor');
108  items[2].setAttribute('data-cursor', '');
109  const e = key('Enter');
110  F.key(p.q, e);
111  assert.equal(e.prevented, true);
112  assert.equal(items[2].getAttribute('aria-selected'), 'true');
113  assert.equal(items[0].getAttribute('aria-selected'), 'false');
114  assert.equal(p.q.getAttribute('aria-activedescendant'), items[2].id);
115  assert.equal(dom.clicked[0].href, items[2].querySelector('a').href);
116  // The arrows move from there.
117  F.key(p.q, key('ArrowUp'));
118  assert.equal(p.q.getAttribute('aria-activedescendant'), items[1].id);
119  assert.ok(items[1].hasAttribute('data-cursor') && !items[2].hasAttribute('data-cursor'));
120});
121
122test('an input method composing does not drive the finder', async () => {
123  const p = page();
124  p.q.value = 'tree';
125  F.input(p.q);
126  await settle();
127  for (const k of ['Enter', 'Escape', 'ArrowDown']) {
128    const e = key(k, { isComposing: true });
129    F.key(p.q, e);
130    assert.equal(e.prevented, false, k);
131  }
132  assert.equal(p.q.value, 'tree');
133  assert.equal(dom.clicked.length, 0);
134});
135
136test('Escape clears the field and puts the cursor back on the tree', async () => {
137  const cases = [
138    { name: 'the row that had it', hide: false, want: 'r2' },
139    { name: 'the first visible row when it is hidden', hide: true, want: 'r1' },
140  ];
141  for (const c of cases) {
142    const p = page();
143    p.q.value = 'tree';
144    F.input(p.q);
145    await settle();
146    assert.equal(p.treeView.hidden, true);
147    assert.ok(!p.rows[1].hasAttribute('data-cursor'), 'the tree gives up its cursor to the results');
148    assert.ok(p.finderList.hasAttribute('data-rows'));
149    if (c.hide) p.rows[1].hidden = true;
150    F.key(p.q, key('Escape'));
151    assert.equal(p.q.value, '', c.name);
152    assert.equal(p.finder.hidden, true);
153    assert.equal(p.treeView.hidden, false);
154    assert.equal(p.finderList.children.length, 0);
155    const got = p.rows.filter((r) => r.hasAttribute('data-cursor')).map((r) => r.getAttribute('data-nm'));
156    assert.deepEqual(got, [c.want], c.name);
157    assert.ok(p.treeRows.hasAttribute('data-rows') && !p.finderList.hasAttribute('data-rows'));
158  }
159});
160
161test('label marks a surrogate pair whole', () => {
162  for (const pos of [[2], [3], [2, 3]]) {
163    const lbl = F.label('a/😀b', pos);
164    const marks = lbl.querySelectorAll('mark');
165    assert.deepEqual(marks.map((m) => m.textContent), ['😀'], JSON.stringify(pos));
166    assert.equal(lbl.textContent, 'a/😀b');
167  }
168  const lbl = F.label('a/😀b', [4]);
169  assert.deepEqual(lbl.querySelectorAll('mark').map((m) => m.textContent), ['b']);
170  assert.equal(F.label('x\u202ey', [0]).querySelectorAll('mark').length, 0, 'no highlight on a marked path');
171  assert.equal(F.label('x\u202ey', [0]).textContent, 'x<U+202E>y');
172});
173
174test('the palette lists the file hits under its entries', async () => {
175  const p = page();
176  p.cmdQ.value = 'tree';
177  F.palette(p.cmdQ);
178  await settle();
179  const hits = p.cmdList.children.filter((li) => li.className === 'finder-hit');
180  assert.equal(hits.length, 4);
181  assert.equal(p.cmdList.children[0].className, 'entry', 'the entries stay first');
182  assert.deepEqual(hits.map((li) => li.querySelector('a').href).sort(), [
183    '/blob/main/docs/web/tree-view.md',
184    '/blob/main/internal/adapters/web/tree.go',
185    '/blob/main/internal/adapters/web/tree.templ',
186    '/blob/main/src/treehouse.txt',
187  ]);
188  assert.equal(hits[0].querySelector('a').tabIndex, -1);
189  assert.equal(hits[0].querySelector('.k').textContent, 'file');
190  assert.equal(p.cmdList.children.filter((li) => li.hasAttribute('data-cursor')).length, 1, 'one cursor');
191
192  // A longer query narrows the hits; an emptied field removes them.
193  p.cmdQ.value = 'treeh';
194  F.palette(p.cmdQ);
195  await settle();
196  assert.deepEqual(hrefs({ children: p.cmdList.children.filter((li) => li.className === 'finder-hit') }), ['/blob/main/src/treehouse.txt']);
197  p.cmdQ.value = '';
198  F.palette(p.cmdQ);
199  assert.equal(p.cmdList.children.length, 1);
200});
201
202test('Enter typed before the frame of the query runs opens the top hit', async () => {
203  const p = page();
204  release();
205  await F.load(); // the path list is loaded: only the frame is pending
206  dom.holdFrames = true;
207  try {
208    p.q.value = 'maingo';
209    F.input(p.q);
210    const e = key('Enter');
211    F.key(p.q, e);
212    assert.equal(e.prevented, true);
213    assert.equal(dom.clicked.length, 0, 'nothing to open before the frame');
214    flushFrames();
215    await settle();
216    assert.deepEqual(dom.clicked.map((a) => a.href), ['/blob/main/cmd/ermite/main.go']);
217  } finally {
218    dom.holdFrames = false;
219  }
220});
221
222test('the palette keeps one search per frame and drops a search when it closes', async () => {
223  const p = page();
224  release();
225  await F.load();
226  dom.holdFrames = true;
227  try {
228    for (const q of ['t', 'tr', 'tre', 'tree']) {
229      p.cmdQ.value = q;
230      F.palette(p.cmdQ);
231    }
232    assert.equal(dom.frames.size, 1, 'the keystrokes of a frame share one search');
233    flushFrames();
234    await settle();
235    assert.equal(p.cmdList.children.filter((li) => li.className === 'finder-hit').length, 4);
236
237    p.cmdQ.value = 'readme';
238    F.palette(p.cmdQ);
239    F.paletteClose();
240    assert.equal(dom.frames.size, 0, 'closing cancels the pending frame');
241
242    // A search past its frame, still awaiting the list, is dropped at close.
243    F.palette(p.cmdQ);
244    flushFrames();
245    F.paletteClose();
246    await settle();
247    assert.equal(p.cmdList.children.filter((li) => li.className === 'finder-hit').length, 0);
248  } finally {
249    dom.holdFrames = false;
250  }
251});
252
253test('the palette drops the results of a query that is stale', async () => {
254  const p = page();
255  p.cmdQ.value = 'tree';
256  F.palette(p.cmdQ);
257  p.cmdQ.value = 'readme'; // typed before the search of "tree" finished
258  F.palette(p.cmdQ);
259  await settle();
260  const got = p.cmdList.children.filter((li) => li.className === 'finder-hit').map((li) => li.querySelector('a').href);
261  assert.deepEqual(got, ['/blob/main/README.md']);
262
263  // A field changed without a new call: the pending search is dropped.
264  p.cmdQ.value = 'main';
265  F.palette(p.cmdQ);
266  p.cmdQ.value = 'zzzz';
267  await settle();
268  assert.deepEqual(p.cmdList.children.filter((li) => li.className === 'finder-hit'), []);
269});

Keyboard shortcuts

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

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

Branches 2

Tags 45

↑↓ moveenter openesc close