v0.33.2 57f21d6

vim_page_harness.js RawHistoryFolder

1// DOM shim for the Vim page runtime tests. It models a page of single-line
2// paragraphs (10px per character, 20px lines) with Chromium-like Selection and
3// Range semantics: crossing a paragraph boundary is one character step that
4// adds one "\n" to the selected text. Native "word" granularity is a trap: the
5// runtime must implement word motions itself (Vimium #1441).
6const posted = [];
7const scrolls = [];
8const modifyCalls = [];
9const frames = new Map();
10let nextFrame = 1;
11let toStringCalls = 0;
12let closestCalls = 0;
13let rangeRectCalls = 0;
14const listeners = {};
15const CHAR_WIDTH = 10;
16const LINE_HEIGHT = 20;
17const rect = (x, y, w, h) => ({ left: x, top: y, right: x + w, bottom: y + h, width: w, height: h });
18
19class Node_ {
20  constructor(type, tag) {
21    this.nodeType = type; this.tagName = tag; this.children = []; this.parentElement = null;
22    // style.cssText assignments are parsed so tests can read properties.
23    const style = {};
24    Object.defineProperty(style, "cssText", { enumerable: false, set(text) {
25      for (const decl of text.split(";")) { const i = decl.indexOf(":"); if (i > 0) style[decl.slice(0, i).trim()] = decl.slice(i + 1).trim(); }
26    } });
27    this.style = style; this.attrs = {}; this.isConnected = true; this.shadowRoot = null;
28  }
29  appendChild(child) { child.parentElement = this; this.children.push(child); return child; }
30  append(...items) { for (const item of items) if (item instanceof Node_) this.appendChild(item); }
31  remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((c) => c !== this); this.parentElement = null; }
32  setAttribute(name, value) { this.attrs[name] = value; }
33  closest() { closestCalls++; return null; }
34  contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; }
35  getClientRects() { return this.rect ? [this.rect] : []; }
36  getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); }
37  getRootNode() { return document; }
38  querySelectorAll(selector) {
39    const out = [];
40    const visit = (node) => { for (const c of node.children) { if (c.nodeType === 1) { if (matches(c, selector)) out.push(c); visit(c); } } };
41    visit(this);
42    return out;
43  }
44  focus() { document.activeElement = this; }
45  click() { posted.push({ clicked: this.href }); }
46  dispatchEvent(event) { posted.push({ dispatched: event.type, bubbles: event.bubbles, cancelable: event.cancelable, target: this.tagName }); return true; }
47}
48function matches(el, selector) {
49  return selector.split(",").some((part) => {
50    part = part.trim();
51    if (part === "*") return true;
52    if (part === "a[href]") return el.tagName === "A" && el.href;
53    if (part.startsWith("[")) return false;
54    const base = part.replace(/:not\(.*\)$/, "");
55    return el.tagName === base.toUpperCase();
56  });
57}
58class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } }
59class HTMLElement extends Element {}
60class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } }
61// SVGAElement_ models an SVG <a>: href is an SVGAnimatedString, there is no
62// click(), and the link is not an HTMLAnchorElement.
63class SVGAElement_ extends Element {
64  constructor(href) { super("a"); this.href = { baseVal: href }; this.target = { baseVal: "" }; this.click = undefined; }
65}
66class HTMLInputElement extends HTMLElement {}
67class HTMLTextAreaElement extends HTMLElement {}
68class HTMLSelectElement extends HTMLElement {}
69class Text_ extends Node_ { constructor(data) { super(3, "#text"); this.data = data; } }
70
71const html = new HTMLElement("html");
72const body = html.appendChild(new HTMLElement("body"));
73const texts = [];
74function paragraph(data, top) {
75  const p = body.appendChild(new HTMLElement("p"));
76  p.rect = rect(10, top, 600, LINE_HEIGHT);
77  const text = p.appendChild(new Text_(data));
78  texts.push(text);
79  return { p, text };
80}
81const LONG = "The quick brown fox jumps over the lazy dog near the river";
82const first = paragraph("Alpha bravo charlie", 10);
83const second = paragraph(LONG, 40);
84const third = paragraph("Third line, end.", 70);
85const far = paragraph("Far below the fold", 2000);
86const one = body.appendChild(new HTMLAnchorElement("file:///one.html")); one.rect = rect(10, 120, 40, 20);
87const two = body.appendChild(new HTMLAnchorElement("https://example.com/two")); two.rect = rect(80, 120, 40, 20);
88// shadowParagraph adds a host whose paragraph lives in an open shadow root;
89// its text node joins the selection model at the end of the document order.
90function shadowParagraph(data, top) {
91  const host = body.appendChild(new HTMLElement("div"));
92  const p = new HTMLElement("p");
93  p.rect = rect(10, top, 600, LINE_HEIGHT);
94  p.parentElement = null;
95  const text = p.appendChild(new Text_(data));
96  texts.push(text);
97  host.shadowRoot = { children: [p], querySelectorAll: (selector) => Node_.prototype.querySelectorAll.call({ children: [p] }, selector), getRootNode: () => document };
98  return { host, p, text };
99}
100
101const index = (node) => texts.indexOf(node);
102const order = (node, offset) => index(node) * 100000 + offset;
103class Range_ {
104  constructor() { this.startContainer = this.endContainer = null; this.startOffset = this.endOffset = 0; }
105  get collapsed() { return this.startContainer === this.endContainer && this.startOffset === this.endOffset; }
106  setStart(node, offset) {
107    this.startContainer = node; this.startOffset = offset;
108    if (!this.endContainer || order(node, offset) > order(this.endContainer, this.endOffset)) { this.endContainer = node; this.endOffset = offset; }
109  }
110  setEnd(node, offset) {
111    this.endContainer = node; this.endOffset = offset;
112    if (!this.startContainer || order(node, offset) < order(this.startContainer, this.startOffset)) { this.startContainer = node; this.startOffset = offset; }
113  }
114  collapse(toStart) { if (toStart) { this.endContainer = this.startContainer; this.endOffset = this.startOffset; } else { this.startContainer = this.endContainer; this.startOffset = this.endOffset; } }
115  selectNodeContents(node) { this.startContainer = this.endContainer = node; this.startOffset = 0; this.endOffset = node.data ? node.data.length : 0; }
116  getClientRects() {
117    rangeRectCalls++;
118    const node = this.startContainer;
119    const box = node && node.parentElement && node.parentElement.rect;
120    if (!box) return [];
121    const from = this.startOffset;
122    const to = this.endContainer === node ? this.endOffset : node.data.length;
123    return [rect(box.left + from * CHAR_WIDTH, box.top, (to - from) * CHAR_WIDTH, LINE_HEIGHT)];
124  }
125  getBoundingClientRect() { return this.getClientRects()[0] || rect(0, 0, 0, 0); }
126}
127
128const selection = {
129  anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, rangeCount: 0,
130  get isCollapsed() { return this.rangeCount === 0 || (this.anchorNode === this.focusNode && this.anchorOffset === this.focusOffset); },
131  setBaseAndExtent(an, ao, fn, fo) { this.anchorNode = an; this.anchorOffset = ao; this.focusNode = fn; this.focusOffset = fo; this.rangeCount = 1; },
132  collapse(node, offset) { this.setBaseAndExtent(node, offset, node, offset); },
133  modify(alter, direction, granularity) {
134    modifyCalls.push([alter, direction, granularity]);
135    if (granularity === "word") throw new Error("native word granularity must not be used");
136    const step = direction === "forward" ? 1 : -1;
137    let i = index(this.focusNode);
138    let o = this.focusOffset;
139    const len = (n) => texts[n].data.length;
140    if (granularity === "character") {
141      if (step > 0) { if (o < len(i)) o++; else if (i < texts.length - 1) { i++; o = 0; } }
142      else if (o > 0) o--; else if (i > 0) { i--; o = len(i); }
143    } else if (granularity === "lineboundary") {
144      o = step > 0 ? len(i) : 0;
145    } else if (granularity === "line") {
146      const j = i + step;
147      if (j >= 0 && j < texts.length) { i = j; o = Math.min(o, len(j)); } else o = step > 0 ? len(i) : 0;
148    } else if (granularity === "documentboundary") {
149      i = step > 0 ? texts.length - 1 : 0; o = step > 0 ? len(i) : 0;
150    }
151    // Sentence and paragraph granularities are native: this model leaves the
152    // focus alone, and tests assert the modify() calls instead.
153    this.focusNode = texts[i]; this.focusOffset = o;
154    if (alter === "move") { this.anchorNode = this.focusNode; this.anchorOffset = this.focusOffset; }
155  },
156  removeAllRanges() { this.rangeCount = 0; this.anchorNode = this.focusNode = null; },
157  addRange(range) { this.setBaseAndExtent(range.startContainer, range.startOffset, range.endContainer, range.endOffset); },
158  getRangeAt() {
159    const range = new Range_();
160    const forward = order(this.anchorNode, this.anchorOffset) <= order(this.focusNode, this.focusOffset);
161    const [s, e] = forward ? [[this.anchorNode, this.anchorOffset], [this.focusNode, this.focusOffset]] : [[this.focusNode, this.focusOffset], [this.anchorNode, this.anchorOffset]];
162    range.startContainer = s[0]; range.startOffset = s[1]; range.endContainer = e[0]; range.endOffset = e[1];
163    return range;
164  },
165  toString() {
166    toStringCalls++;
167    if (!this.rangeCount) return "";
168    const r = this.getRangeAt();
169    const a = index(r.startContainer);
170    const b = index(r.endContainer);
171    if (a === b) return r.startContainer.data.slice(r.startOffset, r.endOffset);
172    const parts = [r.startContainer.data.slice(r.startOffset)];
173    for (let n = a + 1; n < b; n++) parts.push(texts[n].data);
174    parts.push(r.endContainer.data.slice(0, r.endOffset));
175    return parts.join("\n");
176  },
177};
178
179global.Node = { TEXT_NODE: 3, ELEMENT_NODE: 1 };
180global.NodeFilter = { SHOW_ELEMENT: 1, SHOW_TEXT: 4 };
181global.Element = Element; global.HTMLElement = HTMLElement; global.HTMLAnchorElement = HTMLAnchorElement;
182global.HTMLInputElement = HTMLInputElement; global.HTMLTextAreaElement = HTMLTextAreaElement; global.HTMLSelectElement = HTMLSelectElement;
183global.document = {
184  body, documentElement: html, activeElement: body,
185  createElement: (tag) => new HTMLElement(tag),
186  createRange: () => new Range_(),
187  createTreeWalker: (root) => {
188    const nodes = []; const visit = (n) => { nodes.push(n); for (const c of n.children) visit(c); }; visit(root);
189    let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null };
190  },
191  querySelectorAll: (selector) => html.querySelectorAll(selector),
192  baseURI: "https://example.com/dir/page.html",
193  elementFromPoint: (x, y) => [two, one, ...texts.map((t) => t.parentElement), ...body.children.slice().reverse()].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body,
194};
195global.MouseEvent = class { constructor(type, init) { this.type = type; Object.assign(this, init); } };
196global.window = {
197  innerWidth: 800, innerHeight: 600,
198  scrollBy(x, y) { scrolls.push([x, y]); },
199  addEventListener(type, fn) { (listeners[type] = listeners[type] || []).push(fn); },
200  removeEventListener(type, fn) { listeners[type] = (listeners[type] || []).filter((f) => f !== fn); },
201  getSelection: () => selection,
202  getComputedStyle: () => ({ display: "block", visibility: "visible" }),
203  // The runtime sends btoa(unescape(encodeURIComponent(json))): base64 of the
204  // UTF-8 bytes, which is what Go decodes.
205  fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("utf8"))); return Promise.resolve(); },
206  requestAnimationFrame(fn) { const id = nextFrame++; frames.set(id, fn); return id; },
207  cancelAnimationFrame(id) { frames.delete(id); },
208};
209global.setTimeout = (fn) => { fn(); return 0; };
210
211// Helpers for drivers.
212const pos = (node, offset) => index(node) + ":" + offset;
213const snap = () => ({
214  text: selection.toString(),
215  anchor: selection.rangeCount ? pos(selection.anchorNode, selection.anchorOffset) : null,
216  focus: selection.rangeCount ? pos(selection.focusNode, selection.focusOffset) : null,
217});
218const overlays = (name) => html.children.filter((c) => c.attrs && name in c.attrs);
219const cursor = () => overlays("data-dumber-vim-caret")[0] || null;
220const hintLabels = () => overlays("data-dumber-vim-hints").flatMap((root) => root.children.map((s) => s.textContent));
221const fire = (type) => (listeners[type] || []).slice().forEach((fn) => fn({}));
222// flushFrames runs the animation frames requested so far.
223const flushFrames = () => { const pending = Array.from(frames.values()); frames.clear(); pending.forEach((fn) => fn()); };
224const vp = () => window.__dumberVimPage;
225const keys = (token, ...list) => list.forEach((k) => window.__dumberVimPage.key(token, k));
226// startCaret begins the visual flow and places the caret at the start of the
227// paragraph with the given hint label ("s", "a", "d" for the first, second,
228// third visible paragraph).
229const startCaret = (token, label, color) => {
230  window.__dumberVimPage.start(token, { kind: "visual", color: color || "#fbbf24" });
231  window.__dumberVimPage.key(token, label);
232};
233// S records a named snapshot of the selection (and any extra fields).
234const S = (n, extra) => posted.push(Object.assign({ n }, snap(), extra));

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 8

Tags 42

↑↓ moveenter openesc close