detached 3424122

test(cef): exercise the vim page runtime under node

Run the embedded runtime against a small DOM shim with a Chromium-like
Selection so visual motions, y, v and Escape, hint labels, follow, open-new
and yank-url, paragraph yank, and stale-token keys are checked end to end
without a browser. Skips when node is unavailable, like the scroll helper
test.
commit
3424122e44bc0ab86e66e3ce752324b86cdb1ea0
author
brice · · 2026-10-04 10:31 UTC
committer
brice · · 2026-10-10 08:27 UTC
parent
560f676
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
1 file · +210 -0
A internal/infrastructure/cef/vim_page_runtime_test.go +210 -0
@@ -0,0 +1,210 @@
1+package cef
2+
3+import (
4+ "encoding/json"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// vimPageRuntimeHarness is a minimal DOM shim for the embedded runtime: one
13+// paragraph of text and two links, a Selection with Chromium-like
14+// setBaseAndExtent/modify semantics over that text, and a fetch that records
15+// every message posted to the Go bridge.
16+const vimPageRuntimeHarness = `
17+const posted = [];
18+const TEXT = "Alpha bravo charlie";
19+const rect = (x, y, w, h) => ({ left: x, top: y, right: x + w, bottom: y + h, width: w, height: h });
20+class Node_ {
21+ constructor(type, tag) {
22+ this.nodeType = type; this.tagName = tag; this.children = []; this.parentElement = null;
23+ this.style = {}; this.attrs = {}; this.isConnected = true; this.shadowRoot = null;
24+ this.classList = { add() {}, remove() {} };
25+ }
26+ appendChild(child) { child.parentElement = this; this.children.push(child); return child; }
27+ append(...items) { for (const item of items) if (item instanceof Node_) this.appendChild(item); }
28+ remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((c) => c !== this); }
29+ setAttribute(name, value) { this.attrs[name] = value; }
30+ closest() { return null; }
31+ contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; }
32+ getClientRects() { return this.rect ? [this.rect] : []; }
33+ getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); }
34+ getRootNode() { return document; }
35+ querySelectorAll(selector) {
36+ const out = [];
37+ const visit = (node) => { for (const c of node.children) { if (c.nodeType === 1) { if (matches(c, selector)) out.push(c); visit(c); } } };
38+ visit(this);
39+ return out;
40+ }
41+ focus() { document.activeElement = this; }
42+ click() { posted.push({ clicked: this.href }); }
43+}
44+function matches(el, selector) {
45+ return selector.split(",").some((part) => {
46+ part = part.trim();
47+ if (part === "*") return true;
48+ if (part === "a[href]") return el.tagName === "A" && el.href;
49+ if (part.startsWith("[")) return false;
50+ const base = part.replace(/:not\(.*\)$/, "");
51+ return el.tagName === base.toUpperCase();
52+ });
53+}
54+class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } }
55+class HTMLElement extends Element {}
56+class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } }
57+class HTMLInputElement extends HTMLElement {}
58+class HTMLTextAreaElement extends HTMLElement {}
59+class HTMLSelectElement extends HTMLElement {}
60+const Element_ = HTMLElement;
61+class Text_ extends Node_ { constructor(data) { super(3, "#text"); this.data = data; } }
62+const html = new Element_("html");
63+const body = html.appendChild(new Element_("body"));
64+const p = body.appendChild(new Element_("p")); p.rect = rect(10, 10, 300, 20);
65+const text = p.appendChild(new Text_(TEXT));
66+const one = body.appendChild(new HTMLAnchorElement("file:///one.html")); one.rect = rect(10, 50, 40, 20);
67+const two = body.appendChild(new HTMLAnchorElement("https://example.com/two")); two.rect = rect(80, 50, 40, 20);
68+const selection = {
69+ anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, rangeCount: 0,
70+ get isCollapsed() { return this.rangeCount === 0 || (this.anchorNode === this.focusNode && this.anchorOffset === this.focusOffset); },
71+ setBaseAndExtent(an, ao, fn, fo) { this.anchorNode = an; this.anchorOffset = ao; this.focusNode = fn; this.focusOffset = fo; this.rangeCount = 1; },
72+ modify(_alter, direction, granularity) {
73+ const step = direction === "forward" ? 1 : -1;
74+ if (granularity === "character") this.focusOffset = Math.max(0, Math.min(TEXT.length, this.focusOffset + step));
75+ else if (granularity === "word") {
76+ let i = this.focusOffset;
77+ if (step > 0) { while (i < TEXT.length && TEXT[i] !== " ") i++; while (i < TEXT.length && TEXT[i] === " ") i++; }
78+ else { while (i > 0 && TEXT[i - 1] === " ") i--; while (i > 0 && TEXT[i - 1] !== " ") i--; }
79+ this.focusOffset = i;
80+ } else if (granularity === "lineboundary" || granularity === "documentboundary" || granularity === "line") {
81+ this.focusOffset = step > 0 ? TEXT.length : 0;
82+ }
83+ },
84+ removeAllRanges() { this.rangeCount = 0; this.anchorNode = this.focusNode = null; },
85+ addRange() { this.rangeCount = 1; },
86+ getRangeAt() { return {}; },
87+ toString() { return this.rangeCount ? TEXT.slice(Math.min(this.anchorOffset, this.focusOffset), Math.max(this.anchorOffset, this.focusOffset)) : ""; },
88+};
89+global.Node = { TEXT_NODE: 3, ELEMENT_NODE: 1 };
90+global.NodeFilter = { SHOW_ELEMENT: 1, SHOW_TEXT: 4 };
91+global.Element = Element; global.HTMLElement = HTMLElement; global.HTMLAnchorElement = HTMLAnchorElement;
92+global.HTMLInputElement = HTMLInputElement; global.HTMLTextAreaElement = HTMLTextAreaElement; global.HTMLSelectElement = HTMLSelectElement;
93+global.document = {
94+ body, documentElement: html, activeElement: body,
95+ createElement: (tag) => new HTMLElement(tag),
96+ createRange: () => ({ setStart() {}, collapse() {}, selectNodeContents() {}, getBoundingClientRect: () => rect(10, 10, 300, 20) }),
97+ createTreeWalker: (root) => {
98+ const nodes = []; const visit = (n) => { nodes.push(n); for (const c of n.children) visit(c); }; visit(root);
99+ let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null };
100+ },
101+ querySelectorAll: (selector) => html.querySelectorAll(selector),
102+ elementFromPoint: (x, y) => [two, one, p].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body,
103+};
104+global.window = {
105+ innerWidth: 800, innerHeight: 600, scrollBy() {},
106+ getSelection: () => selection,
107+ getComputedStyle: () => ({ display: "block", visibility: "visible" }),
108+ fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("latin1"))); return Promise.resolve(); },
109+};
110+global.btoa = (s) => Buffer.from(s, "binary").toString("base64");
111+global.unescape = (s) => decodeURIComponent(s);
112+global.setTimeout = (fn) => { fn(); return 0; };
113+`
114+
115+// runVimPageRuntime executes the embedded runtime inside the harness, then
116+// the given driver script, and returns every message posted to the bridge
117+// plus the driver's own output appended to the "posted" array.
118+func runVimPageRuntime(t *testing.T, driver string) []map[string]any {
119+ t.Helper()
120+ node, err := exec.LookPath("node")
121+ if err != nil {
122+ t.Skip("node is required to execute the vim page runtime")
123+ }
124+ script := vimPageRuntimeHarness + "\n" + vimPageRuntimeTemplateJS + "\n" + driver +
125+ "\nprocess.stdout.write(JSON.stringify(posted));"
126+ output, err := exec.Command(node, "-e", script).CombinedOutput()
127+ require.NoError(t, err, "runtime failed: %s", output)
128+ var posted []map[string]any
129+ require.NoError(t, json.Unmarshal(output, &posted), "runtime output: %s", output)
130+ return posted
131+}
132+
133+func TestVimPageRuntimeVisualExtendsAndYanks(t *testing.T) {
134+ posted := runVimPageRuntime(t, `
135+const vp = window.__dumberVimPage;
136+vp.start("tok", { kind: "visual", color: "#fbbf24" });
137+posted.push({ started: selection.toString() });
138+for (const key of ["l", "l", "w"]) vp.key("tok", key);
139+posted.push({ extended: selection.toString() });
140+vp.key("tok", "y");
141+posted.push({ after: selection.rangeCount });
142+`)
143+ require.Len(t, posted, 4)
144+ require.Equal(t, "A", posted[0]["started"], "visual starts on the first visible character")
145+ require.Equal(t, "Alpha ", posted[1]["extended"], "motions extend the selection")
146+ require.Equal(t, map[string]any{"token": "tok", "type": "copy", "text": "Alpha "}, posted[2])
147+ require.EqualValues(t, 0, posted[3]["after"], "yank clears the selection")
148+}
149+
150+func TestVimPageRuntimeVisualExitsOnVAndEscapeWithoutCopy(t *testing.T) {
151+ for _, key := range []string{"v", "<Escape>"} {
152+ posted := runVimPageRuntime(t, `
153+const vp = window.__dumberVimPage;
154+vp.start("tok", { kind: "visual" });
155+vp.key("tok", "l");
156+vp.key("tok", `+jsString(key)+`);
157+`)
158+ require.Len(t, posted, 1, "key %s", key)
159+ require.Equal(t, "end", posted[0]["type"], "key %s", key)
160+ }
161+}
162+
163+func TestVimPageRuntimeStartDoesNotEndImmediately(t *testing.T) {
164+ posted := runVimPageRuntime(t, `
165+window.__dumberVimPage.start("tok", { kind: "visual" });
166+window.__dumberVimPage.start("hint", { kind: "hint-follow" });
167+`)
168+ require.Empty(t, posted, "starting an interaction with visible content posts nothing until a key ends it")
169+}
170+
171+func TestVimPageRuntimeHintsFollowAndYank(t *testing.T) {
172+ posted := runVimPageRuntime(t, `
173+const vp = window.__dumberVimPage;
174+vp.start("a", { kind: "hint-follow" });
175+posted.push({ labels: html.querySelectorAll("span").map((s) => s.textContent) });
176+vp.key("a", "a");
177+vp.start("b", { kind: "hint-yank-url" });
178+vp.key("b", "s");
179+vp.start("c", { kind: "hint-follow-new" });
180+vp.key("c", "a");
181+`)
182+ require.Len(t, posted, 5)
183+ require.ElementsMatch(t, []any{"s", "a"}, posted[0]["labels"], "two visible links get one-letter labels")
184+ require.Equal(t, "https://example.com/two", posted[1]["clicked"], "follow clicks the chosen link")
185+ require.Equal(t, "end", posted[2]["type"])
186+ require.Equal(t, map[string]any{"token": "b", "type": "copy", "text": "file:///one.html"}, posted[3])
187+ require.Equal(t, map[string]any{"token": "c", "type": "open-new", "url": "https://example.com/two"}, posted[4])
188+}
189+
190+func TestVimPageRuntimeYankParagraph(t *testing.T) {
191+ posted := runVimPageRuntime(t, `
192+p.innerText = TEXT;
193+window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
194+`)
195+ require.Len(t, posted, 1)
196+ require.Equal(t, "copy", posted[0]["type"])
197+ require.True(t, strings.HasPrefix(posted[0]["text"].(string), "Alpha"))
198+}
199+
200+func TestVimPageRuntimeStaleTokenKeyReleasesCapture(t *testing.T) {
201+ posted := runVimPageRuntime(t, `
202+const vp = window.__dumberVimPage;
203+vp.start("live", { kind: "visual" });
204+vp.key("stale", "j");
205+posted.push({ liveSelection: selection.rangeCount });
206+`)
207+ require.Len(t, posted, 2)
208+ require.Equal(t, map[string]any{"token": "stale", "type": "end"}, posted[0])
209+ require.EqualValues(t, 1, posted[1]["liveSelection"], "a stale key must not disturb the live interaction")
210+}
@@ -0,0 +1,210 @@
1+package cef
2+
3+import (
4+ "encoding/json"
5+ "os/exec"
6+ "strings"
7+ "testing"
8+
9+ "github.com/stretchr/testify/require"
10+)
11+
12+// vimPageRuntimeHarness is a minimal DOM shim for the embedded runtime: one
13+// paragraph of text and two links, a Selection with Chromium-like
14+// setBaseAndExtent/modify semantics over that text, and a fetch that records
15+// every message posted to the Go bridge.
16+const vimPageRuntimeHarness = `
17+const posted = [];
18+const TEXT = "Alpha bravo charlie";
19+const rect = (x, y, w, h) => ({ left: x, top: y, right: x + w, bottom: y + h, width: w, height: h });
20+class Node_ {
21+ constructor(type, tag) {
22+ this.nodeType = type; this.tagName = tag; this.children = []; this.parentElement = null;
23+ this.style = {}; this.attrs = {}; this.isConnected = true; this.shadowRoot = null;
24+ this.classList = { add() {}, remove() {} };
25+ }
26+ appendChild(child) { child.parentElement = this; this.children.push(child); return child; }
27+ append(...items) { for (const item of items) if (item instanceof Node_) this.appendChild(item); }
28+ remove() { if (this.parentElement) this.parentElement.children = this.parentElement.children.filter((c) => c !== this); }
29+ setAttribute(name, value) { this.attrs[name] = value; }
30+ closest() { return null; }
31+ contains(other) { for (let n = other; n; n = n.parentElement) if (n === this) return true; return false; }
32+ getClientRects() { return this.rect ? [this.rect] : []; }
33+ getBoundingClientRect() { return this.rect || rect(0, 0, 0, 0); }
34+ getRootNode() { return document; }
35+ querySelectorAll(selector) {
36+ const out = [];
37+ const visit = (node) => { for (const c of node.children) { if (c.nodeType === 1) { if (matches(c, selector)) out.push(c); visit(c); } } };
38+ visit(this);
39+ return out;
40+ }
41+ focus() { document.activeElement = this; }
42+ click() { posted.push({ clicked: this.href }); }
43+}
44+function matches(el, selector) {
45+ return selector.split(",").some((part) => {
46+ part = part.trim();
47+ if (part === "*") return true;
48+ if (part === "a[href]") return el.tagName === "A" && el.href;
49+ if (part.startsWith("[")) return false;
50+ const base = part.replace(/:not\(.*\)$/, "");
51+ return el.tagName === base.toUpperCase();
52+ });
53+}
54+class Element extends Node_ { constructor(tag) { super(1, tag.toUpperCase()); } }
55+class HTMLElement extends Element {}
56+class HTMLAnchorElement extends HTMLElement { constructor(href) { super("a"); this.href = href; this.target = ""; } }
57+class HTMLInputElement extends HTMLElement {}
58+class HTMLTextAreaElement extends HTMLElement {}
59+class HTMLSelectElement extends HTMLElement {}
60+const Element_ = HTMLElement;
61+class Text_ extends Node_ { constructor(data) { super(3, "#text"); this.data = data; } }
62+const html = new Element_("html");
63+const body = html.appendChild(new Element_("body"));
64+const p = body.appendChild(new Element_("p")); p.rect = rect(10, 10, 300, 20);
65+const text = p.appendChild(new Text_(TEXT));
66+const one = body.appendChild(new HTMLAnchorElement("file:///one.html")); one.rect = rect(10, 50, 40, 20);
67+const two = body.appendChild(new HTMLAnchorElement("https://example.com/two")); two.rect = rect(80, 50, 40, 20);
68+const selection = {
69+ anchorNode: null, anchorOffset: 0, focusNode: null, focusOffset: 0, rangeCount: 0,
70+ get isCollapsed() { return this.rangeCount === 0 || (this.anchorNode === this.focusNode && this.anchorOffset === this.focusOffset); },
71+ setBaseAndExtent(an, ao, fn, fo) { this.anchorNode = an; this.anchorOffset = ao; this.focusNode = fn; this.focusOffset = fo; this.rangeCount = 1; },
72+ modify(_alter, direction, granularity) {
73+ const step = direction === "forward" ? 1 : -1;
74+ if (granularity === "character") this.focusOffset = Math.max(0, Math.min(TEXT.length, this.focusOffset + step));
75+ else if (granularity === "word") {
76+ let i = this.focusOffset;
77+ if (step > 0) { while (i < TEXT.length && TEXT[i] !== " ") i++; while (i < TEXT.length && TEXT[i] === " ") i++; }
78+ else { while (i > 0 && TEXT[i - 1] === " ") i--; while (i > 0 && TEXT[i - 1] !== " ") i--; }
79+ this.focusOffset = i;
80+ } else if (granularity === "lineboundary" || granularity === "documentboundary" || granularity === "line") {
81+ this.focusOffset = step > 0 ? TEXT.length : 0;
82+ }
83+ },
84+ removeAllRanges() { this.rangeCount = 0; this.anchorNode = this.focusNode = null; },
85+ addRange() { this.rangeCount = 1; },
86+ getRangeAt() { return {}; },
87+ toString() { return this.rangeCount ? TEXT.slice(Math.min(this.anchorOffset, this.focusOffset), Math.max(this.anchorOffset, this.focusOffset)) : ""; },
88+};
89+global.Node = { TEXT_NODE: 3, ELEMENT_NODE: 1 };
90+global.NodeFilter = { SHOW_ELEMENT: 1, SHOW_TEXT: 4 };
91+global.Element = Element; global.HTMLElement = HTMLElement; global.HTMLAnchorElement = HTMLAnchorElement;
92+global.HTMLInputElement = HTMLInputElement; global.HTMLTextAreaElement = HTMLTextAreaElement; global.HTMLSelectElement = HTMLSelectElement;
93+global.document = {
94+ body, documentElement: html, activeElement: body,
95+ createElement: (tag) => new HTMLElement(tag),
96+ createRange: () => ({ setStart() {}, collapse() {}, selectNodeContents() {}, getBoundingClientRect: () => rect(10, 10, 300, 20) }),
97+ createTreeWalker: (root) => {
98+ const nodes = []; const visit = (n) => { nodes.push(n); for (const c of n.children) visit(c); }; visit(root);
99+ let i = 0; return { currentNode: nodes[0], nextNode: () => nodes[++i] || null };
100+ },
101+ querySelectorAll: (selector) => html.querySelectorAll(selector),
102+ elementFromPoint: (x, y) => [two, one, p].find((el) => el.rect && x >= el.rect.left && x < el.rect.right && y >= el.rect.top && y < el.rect.bottom) || body,
103+};
104+global.window = {
105+ innerWidth: 800, innerHeight: 600, scrollBy() {},
106+ getSelection: () => selection,
107+ getComputedStyle: () => ({ display: "block", visibility: "visible" }),
108+ fetch: (url, init) => { posted.push(JSON.parse(Buffer.from(init.headers["X-Dumber-Body"], "base64").toString("latin1"))); return Promise.resolve(); },
109+};
110+global.btoa = (s) => Buffer.from(s, "binary").toString("base64");
111+global.unescape = (s) => decodeURIComponent(s);
112+global.setTimeout = (fn) => { fn(); return 0; };
113+`
114+
115+// runVimPageRuntime executes the embedded runtime inside the harness, then
116+// the given driver script, and returns every message posted to the bridge
117+// plus the driver's own output appended to the "posted" array.
118+func runVimPageRuntime(t *testing.T, driver string) []map[string]any {
119+ t.Helper()
120+ node, err := exec.LookPath("node")
121+ if err != nil {
122+ t.Skip("node is required to execute the vim page runtime")
123+ }
124+ script := vimPageRuntimeHarness + "\n" + vimPageRuntimeTemplateJS + "\n" + driver +
125+ "\nprocess.stdout.write(JSON.stringify(posted));"
126+ output, err := exec.Command(node, "-e", script).CombinedOutput()
127+ require.NoError(t, err, "runtime failed: %s", output)
128+ var posted []map[string]any
129+ require.NoError(t, json.Unmarshal(output, &posted), "runtime output: %s", output)
130+ return posted
131+}
132+
133+func TestVimPageRuntimeVisualExtendsAndYanks(t *testing.T) {
134+ posted := runVimPageRuntime(t, `
135+const vp = window.__dumberVimPage;
136+vp.start("tok", { kind: "visual", color: "#fbbf24" });
137+posted.push({ started: selection.toString() });
138+for (const key of ["l", "l", "w"]) vp.key("tok", key);
139+posted.push({ extended: selection.toString() });
140+vp.key("tok", "y");
141+posted.push({ after: selection.rangeCount });
142+`)
143+ require.Len(t, posted, 4)
144+ require.Equal(t, "A", posted[0]["started"], "visual starts on the first visible character")
145+ require.Equal(t, "Alpha ", posted[1]["extended"], "motions extend the selection")
146+ require.Equal(t, map[string]any{"token": "tok", "type": "copy", "text": "Alpha "}, posted[2])
147+ require.EqualValues(t, 0, posted[3]["after"], "yank clears the selection")
148+}
149+
150+func TestVimPageRuntimeVisualExitsOnVAndEscapeWithoutCopy(t *testing.T) {
151+ for _, key := range []string{"v", "<Escape>"} {
152+ posted := runVimPageRuntime(t, `
153+const vp = window.__dumberVimPage;
154+vp.start("tok", { kind: "visual" });
155+vp.key("tok", "l");
156+vp.key("tok", `+jsString(key)+`);
157+`)
158+ require.Len(t, posted, 1, "key %s", key)
159+ require.Equal(t, "end", posted[0]["type"], "key %s", key)
160+ }
161+}
162+
163+func TestVimPageRuntimeStartDoesNotEndImmediately(t *testing.T) {
164+ posted := runVimPageRuntime(t, `
165+window.__dumberVimPage.start("tok", { kind: "visual" });
166+window.__dumberVimPage.start("hint", { kind: "hint-follow" });
167+`)
168+ require.Empty(t, posted, "starting an interaction with visible content posts nothing until a key ends it")
169+}
170+
171+func TestVimPageRuntimeHintsFollowAndYank(t *testing.T) {
172+ posted := runVimPageRuntime(t, `
173+const vp = window.__dumberVimPage;
174+vp.start("a", { kind: "hint-follow" });
175+posted.push({ labels: html.querySelectorAll("span").map((s) => s.textContent) });
176+vp.key("a", "a");
177+vp.start("b", { kind: "hint-yank-url" });
178+vp.key("b", "s");
179+vp.start("c", { kind: "hint-follow-new" });
180+vp.key("c", "a");
181+`)
182+ require.Len(t, posted, 5)
183+ require.ElementsMatch(t, []any{"s", "a"}, posted[0]["labels"], "two visible links get one-letter labels")
184+ require.Equal(t, "https://example.com/two", posted[1]["clicked"], "follow clicks the chosen link")
185+ require.Equal(t, "end", posted[2]["type"])
186+ require.Equal(t, map[string]any{"token": "b", "type": "copy", "text": "file:///one.html"}, posted[3])
187+ require.Equal(t, map[string]any{"token": "c", "type": "open-new", "url": "https://example.com/two"}, posted[4])
188+}
189+
190+func TestVimPageRuntimeYankParagraph(t *testing.T) {
191+ posted := runVimPageRuntime(t, `
192+p.innerText = TEXT;
193+window.__dumberVimPage.start("tok", { kind: "yank", object: "paragraph" });
194+`)
195+ require.Len(t, posted, 1)
196+ require.Equal(t, "copy", posted[0]["type"])
197+ require.True(t, strings.HasPrefix(posted[0]["text"].(string), "Alpha"))
198+}
199+
200+func TestVimPageRuntimeStaleTokenKeyReleasesCapture(t *testing.T) {
201+ posted := runVimPageRuntime(t, `
202+const vp = window.__dumberVimPage;
203+vp.start("live", { kind: "visual" });
204+vp.key("stale", "j");
205+posted.push({ liveSelection: selection.rangeCount });
206+`)
207+ require.Len(t, posted, 2)
208+ require.Equal(t, map[string]any{"token": "stale", "type": "end"}, posted[0])
209+ require.EqualValues(t, 1, posted[1]["liveSelection"], "a stale key must not disturb the live interaction")
210+}

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