v0.25.1 7fabed9

fit.test.mjs RawHistoryFolder

1// Tests of the one-line chip lists of static/js/ermite.js (window.ermiteFit).
2// Only that block is evaluated, on a list whose layout is computed here:
3// chips flow left to right in a row of a given width and wrap, as flexbox
4// does. Run with
5//   node --test 'internal/adapters/web/jstest/*.test.mjs'
6import assert from 'node:assert/strict';
7import { readFileSync } from 'node:fs';
8import { test } from 'node:test';
9
10const src = readFileSync(new URL('../static/js/ermite.js', import.meta.url), 'utf8');
11const start = src.indexOf('  window.ermiteFit = ');
12const block = src.slice(start, src.indexOf('})();', start) + 5);
13
14const GAP = 6;
15const CHAR = 8;
16const PAD = 20;
17
18// A row of chips of the given width (ul.width, which a test may change); a
19// chip is as wide as its text. Only what the block uses is there.
20function list(width, names) {
21  const attrs = {};
22  const ul = {
23    width,
24    children: [],
25    hasAttribute: (k) => k in attrs,
26    setAttribute: (k, v) => { attrs[k] = v; },
27    querySelector(sel) {
28      assert.equal(sel, ':scope > li.more');
29      return this.children.find((c) => c.className === 'more') || null;
30    },
31    append(li) { li.parent = this; this.children.push(li); },
32  };
33  const layout = () => {
34    let x = 0, y = 0;
35    for (const c of ul.children) {
36      if (c.hidden) { c.top = 0; continue; }
37      const w = c.textContent.length * CHAR + PAD;
38      if (!('data-open' in attrs) && x > 0 && x + w > ul.width) { x = 0; y += 28; }
39      c.top = y;
40      x += w + GAP;
41    }
42  };
43  const el = (tag, text = '') => {
44    const e = {
45      tag, className: '', hidden: false, title: '', attrs: {}, kids: [], parent: null, focused: false,
46      get textContent() { return this.kids.length ? this.kids.map((k) => k.textContent).join('') : text; },
47      set textContent(v) { text = v; },
48      get offsetTop() { layout(); return this.top; },
49      setAttribute(k, v) { this.attrs[k] = v; },
50      get firstElementChild() { return this.kids[0] || null; },
51      append(k) { k.parent = this; this.kids.push(k); },
52      remove() { ul.children.splice(ul.children.indexOf(this), 1); },
53      focus() { this.focused = true; globalThis.document.activeElement = this; },
54      closest(sel) {
55        if (sel === '[data-fit]') return ul;
56        if (sel === 'li') return tag === 'li' ? this : this.parent;
57        return null;
58      },
59    };
60    return e;
61  };
62  for (const n of names) ul.append(el('li', n));
63  globalThis.document = { createElement: (tag) => el(tag), activeElement: null };
64  return ul;
65}
66
67function load() {
68  globalThis.window = {};
69  new Function(block)();
70  return globalThis.window.ermiteFit;
71}
72
73const shown = (ul) => ul.children.filter((c) => !c.hidden).map((c) => c.textContent);
74const moreBtn = (ul) => ul.children.find((c) => c.className === 'more')?.kids[0];
75
76test('a list that fits is left alone', () => {
77  const fit = load();
78  const ul = list(400, ['go', 'git', 'forge']);
79  fit.fit(ul);
80  assert.deepEqual(shown(ul), ['go', 'git', 'forge']);
81});
82
83test('the chips past the line give way to a … button that names them', () => {
84  const fit = load();
85  // each chip is 6 chars: 68px, 74px with the gap. In 380px, 5 fit (364px)
86  // but not with the 28px … chip after them: the 5th makes way.
87  const ul = list(380, ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg']);
88  fit.fit(ul);
89  assert.deepEqual(shown(ul), ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', '…']);
90  const btn = moreBtn(ul);
91  assert.equal(btn.tag, 'button');
92  assert.equal(btn.type, 'button');
93  assert.equal(btn.title, 'eeeeee, ffffff, gggggg');
94  assert.equal(btn.attrs['aria-label'], 'show 3 more: eeeeee, ffffff, gggggg');
95  for (const c of ul.children.filter((c) => !c.hidden)) assert.equal(c.offsetTop, 0, 'all on the first line');
96
97  // In 400px, the 5th chip and the … chip both fit.
98  const roomy = list(400, ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff']);
99  fit.fit(roomy);
100  assert.deepEqual(shown(roomy), ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', '…']);
101});
102
103test('fitting again after a resize shows what fits now, with one … button', () => {
104  const fit = load();
105  const names = ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg'];
106  const ul = list(380, names);
107  fit.fit(ul);
108  fit.fit(ul);
109  assert.equal(ul.children.filter((c) => c.className === 'more').length, 1, 'idempotent');
110
111  ul.width = 2000; // wider: everything shows, no … button
112  fit.fit(ul);
113  assert.deepEqual(shown(ul), names);
114  assert.equal(moreBtn(ul), undefined);
115
116  ul.width = 250; // narrower again: fewer chips, the label follows
117  fit.fit(ul);
118  assert.deepEqual(shown(ul), ['aaaaaa', 'bbbbbb', 'cccccc', '…']);
119  assert.equal(moreBtn(ul).attrs['aria-label'], 'show 4 more: dddddd, eeeeee, ffffff, gggggg');
120});
121
122test('a refit keeps the … button, and its focus', () => {
123  const fit = load();
124  const names = ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg'];
125  const ul = list(380, names);
126  fit.fit(ul);
127  const btn = moreBtn(ul);
128  btn.focus();
129  ul.width = 250;
130  fit.fit(ul);
131  assert.equal(moreBtn(ul), btn, 'the same button');
132  assert.equal(document.activeElement, btn);
133  ul.width = 2000; // all fit: the focus goes to the last chip
134  fit.fit(ul);
135  assert.equal(document.activeElement.textContent, 'gggggg');
136});
137
138test('pressing … shows every chip and moves the focus to the first one it showed', () => {
139  const fit = load();
140  const names = ['aaaaaa', 'bbbbbb', 'cccccc', 'dddddd', 'eeeeee', 'ffffff', 'gggggg'];
141  const ul = list(380, names);
142  fit.fit(ul);
143  fit.open(moreBtn(ul));
144  assert.deepEqual(shown(ul), names, 'all shown, no … left');
145  const e = ul.children.find((c) => c.textContent === 'eeeeee');
146  assert.ok(e.focused);
147  assert.equal(e.attrs.tabindex, '-1');
148  fit.fit(ul); // a resize keeps it open
149  assert.deepEqual(shown(ul), names);
150});

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