v0.28.0 fca9155

dom.mjs RawHistoryFolder

1// A DOM just big enough for the glue of static/js/files.js: elements with
2// attributes, dataset, children and the few selectors the script uses
3// ("tag.class[attr][attr=v]:not([attr])", optionally after ":scope >" or
4// "#id >"). Not a test file: the tests import it.
5import assert from 'node:assert/strict';
6
7const kebab = (k) => 'data-' + k.replace(/[A-Z]/g, (c) => '-' + c.toLowerCase());
8
9export class Text {
10  constructor(data) {
11    this.nodeType = 3;
12    this.data = data;
13    this.parent = null;
14  }
15  remove() {
16    if (!this.parent) return;
17    this.parent.nodes.splice(this.parent.nodes.indexOf(this), 1);
18    this.parent = null;
19  }
20  get textContent() { return this.data; }
21}
22
23// A compound selector is checked on one element: tag, .class, #id, [attr],
24// [attr=v], :not([attr]). `hidden` is a property of the element, as in a page.
25function compound(sel) {
26  const re = /([a-z][a-z0-9]*)|\.([\w-]+)|#([\w-]+)|:not\(\[([\w-]+)\]\)|\[([\w-]+)(?:=(?:"([^"]*)"|([^\]]*)))?\]/y;
27  const want = { tag: null, cls: [], id: null, not: [], attrs: [] };
28  while (re.lastIndex < sel.length) {
29    const m = re.exec(sel);
30    assert.ok(m, 'unsupported selector: ' + sel);
31    if (m[1]) want.tag = m[1].toUpperCase();
32    else if (m[2]) want.cls.push(m[2]);
33    else if (m[3]) want.id = m[3];
34    else if (m[4]) want.not.push(m[4]);
35    else want.attrs.push([m[5], m[6] ?? m[7]]);
36  }
37  return (el) => {
38    if (want.tag && el.tagName !== want.tag) return false;
39    if (want.id && el.id !== want.id) return false;
40    if (!want.cls.every((c) => el.className.split(/\s+/).includes(c))) return false;
41    if (want.not.some((a) => el.hasAttribute(a))) return false;
42    return want.attrs.every(([a, v]) => (v === undefined ? el.hasAttribute(a) : el.getAttribute(a) === v));
43  };
44}
45
46export class El {
47  constructor(tag) {
48    this.nodeType = 1;
49    this.tagName = tag.toUpperCase();
50    this.attrs = new Map();
51    this.nodes = [];
52    this.parent = null;
53    this.hidden = false;
54    this.className = '';
55    this.id = '';
56    this.value = '';
57    this.href = '';
58    this.tabIndex = 0;
59    this.text = '';
60    this.style = { props: {}, setProperty(k, v) { this.props[k] = v; } };
61    this.dataset = new Proxy({}, {
62      get: (_, k) => this.getAttribute(kebab(k)) ?? undefined,
63      set: (_, k, v) => { this.setAttribute(kebab(k), v); return true; },
64      has: (_, k) => this.hasAttribute(kebab(k)),
65    });
66  }
67  setAttribute(k, v) { this.attrs.set(k, String(v)); }
68  getAttribute(k) { return this.attrs.has(k) ? this.attrs.get(k) : null; }
69  hasAttribute(k) { return k === 'hidden' ? this.hidden : this.attrs.has(k); }
70  removeAttribute(k) { this.attrs.delete(k); }
71
72  get children() { return this.nodes.filter((n) => n.nodeType === 1); }
73  get parentElement() { return this.parent; }
74  get nextElementSibling() {
75    if (!this.parent) return null;
76    const sib = this.parent.children;
77    return sib[sib.indexOf(this) + 1] || null;
78  }
79  get previousElementSibling() {
80    if (!this.parent) return null;
81    const sib = this.parent.children;
82    return sib[sib.indexOf(this) - 1] || null;
83  }
84  get textContent() { return this.text + this.nodes.map((n) => n.textContent).join(''); }
85  set textContent(v) {
86    this.nodes.forEach((n) => { n.parent = null; });
87    this.nodes = [];
88    this.text = String(v);
89  }
90
91  adopt(list) {
92    return list.map((n) => {
93      const c = typeof n === 'string' ? new Text(n) : n;
94      c.remove();
95      c.parent = this;
96      return c;
97    });
98  }
99  append(...list) { this.nodes.push(...this.adopt(list)); }
100  replaceChildren(...list) {
101    this.nodes.forEach((n) => { n.parent = null; });
102    this.nodes = [];
103    this.append(...list);
104  }
105  after(...list) {
106    const p = this.parent;
107    const added = p.adopt(list);
108    p.nodes.splice(p.nodes.indexOf(this) + 1, 0, ...added);
109  }
110  remove() {
111    if (!this.parent) return;
112    this.parent.nodes.splice(this.parent.nodes.indexOf(this), 1);
113    this.parent = null;
114  }
115  closest(sel) {
116    const ok = compound(sel);
117    for (let e = this; e; e = e.parent) if (ok(e)) return e;
118    return null;
119  }
120  matches(sel) { return compound(sel)(this); }
121
122  // querySelectorAll: ":scope > c" and "#id > c" look at the children of the
123  // scope or of the element with that id; anything else at the descendants.
124  querySelectorAll(sel) {
125    const m = /^(:scope|#[\w-]+)\s*>\s*(.+)$/.exec(sel);
126    const ok = compound(m ? m[2] : sel);
127    if (m) {
128      const scope = m[1] === ':scope' ? this : this.find((e) => e.id === m[1].slice(1));
129      return scope ? scope.children.filter(ok) : [];
130    }
131    const out = [];
132    const walk = (e) => e.children.forEach((c) => { if (ok(c)) out.push(c); walk(c); });
133    walk(this);
134    return out;
135  }
136  querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
137  find(pred) {
138    for (const c of this.children) {
139      if (pred(c)) return c;
140      const d = c.find(pred);
141      if (d) return d;
142    }
143    return null;
144  }
145  scrollIntoView() {}
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  }
151  click() { dom.clicked.push(this); }
152}
153
154export const dom = { clicked: [], focused: [], active: null, body: null, holdFrames: false, frames: new Map(), frameID: 0 };
155
156// flushFrames runs the frames held since the last flush.
157export function flushFrames() {
158  const fs = [...dom.frames.values()];
159  dom.frames.clear();
160  fs.forEach((f) => f());
161}
162
163// installDom puts the globals files.js expects. parseHTML(text) answers the
164// nodes that a template's innerHTML makes.
165export function installDom({ parseHTML } = {}) {
166  dom.body = new El('body');
167  dom.clicked = [];
168  dom.focused = [];
169  dom.active = null;
170  const doc = {
171    body: dom.body,
172    get activeElement() { return dom.active || dom.body; },
173    createElement(tag) {
174      const e = new El(tag);
175      if (tag === 'template') {
176        e.content = new El('#fragment');
177        Object.defineProperty(e, 'innerHTML', { set(text) { e.content.replaceChildren(...parseHTML(text)); } });
178      }
179      return e;
180    },
181    createTextNode: (data) => new Text(data),
182    getElementById: (id) => dom.body.find((e) => e.id === id),
183    querySelectorAll: (sel) => dom.body.querySelectorAll(sel),
184    querySelector: (sel) => dom.body.querySelector(sel),
185  };
186  globalThis.window = globalThis;
187  globalThis.document = doc;
188  globalThis.location = { href: '' };
189  // A frame runs at once, unless a test holds the frames (dom.holdFrames) to
190  // act between a keystroke and its frame, as in a browser; flushFrames runs them.
191  globalThis.requestAnimationFrame = (f) => {
192    if (!dom.holdFrames) { f(); return 0; }
193    dom.frames.set(++dom.frameID, f);
194    return dom.frameID;
195  };
196  globalThis.cancelAnimationFrame = (id) => { dom.frames.delete(id); };
197  return doc;
198}
199
200// el makes an element: el('li', {id, cls, attrs}, ...children).
201export function el(tag, { id = '', cls = '', attrs = {}, ...props } = {}, ...children) {
202  const e = new El(tag);
203  e.id = id;
204  e.className = cls;
205  Object.assign(e, props);
206  for (const [k, v] of Object.entries(attrs)) e.setAttribute(k, v);
207  e.append(...children);
208  return e;
209}
210
211export const settle = () => new Promise((r) => setTimeout(r, 0));

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