detached 7db1f11

fix(vim): reach shadow DOM content in hints, visual, and yanks

Component sites such as Reddit render links and text inside open shadow roots, which document-level queries missed. Early ends now log why the page interaction stopped.
commit
7db1f11e078d5a1a4cd095238d37f110c5bc3cdc
author
brice · · 2026-09-26 13:51 UTC
committer
brice · · 2026-10-10 08:27 UTC
parent
2bc6356
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +37 -10
M internal/infrastructure/cef/vim_page.go +6 -0
@@ -31,6 +31,8 @@ type vimPageBridgePayload struct {
3131 Token string `json:"token"`
3232 Text string `json:"text,omitempty"`
3333 URL string `json:"url,omitempty"`
34+ // Reason explains an early end (for example no visible targets).
35+ Reason string `json:"reason,omitempty"`
3436 }
3537
3638 const (
@@ -305,6 +307,10 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
305307 return
306308 }
307309
310+ logging.FromContext(wv.ctx).Debug().
311+ Str("type", payload.Type).
312+ Str("reason", payload.Reason).
313+ Msg("cef: vim page interaction finished")
308314 if kind.CapturesKeys() {
309315 wv.notifyVimPageInteractionEnded(generation)
310316 }
@@ -31,6 +31,8 @@ type vimPageBridgePayload struct {
31 Token string `json:"token"`31 Token string `json:"token"`
32 Text string `json:"text,omitempty"`32 Text string `json:"text,omitempty"`
33 URL string `json:"url,omitempty"`33 URL string `json:"url,omitempty"`
34+ // Reason explains an early end (for example no visible targets).
35+ Reason string `json:"reason,omitempty"`
34 }36 }
35 37
36 const (38 const (
@@ -305,6 +307,10 @@ func (wv *WebView) handleVimPageResult(payload vimPageBridgePayload) {
305 return307 return
306 }308 }
307 309
310+ logging.FromContext(wv.ctx).Debug().
311+ Str("type", payload.Type).
312+ Str("reason", payload.Reason).
313+ Msg("cef: vim page interaction finished")
308 if kind.CapturesKeys() {314 if kind.CapturesKeys() {
309 wv.notifyVimPageInteractionEnded(generation)315 wv.notifyVimPageInteractionEnded(generation)
310 }316 }
M internal/infrastructure/cef/vim_page_runtime.js +31 -10
@@ -35,6 +35,29 @@
3535
3636 let state = null;
3737
38+ // deepQueryAll matches selector across the document and every open shadow
39+ // root; component-heavy sites (for example Reddit) render content there.
40+ function deepQueryAll(selector, root = document) {
41+ const results = Array.from(root.querySelectorAll(selector));
42+ for (const element of root.querySelectorAll("*")) {
43+ if (element.shadowRoot) results.push(...deepQueryAll(selector, element.shadowRoot));
44+ }
45+ return results;
46+ }
47+
48+ // deepTextNodes yields non-blank text nodes in document order, descending
49+ // into open shadow roots.
50+ function* deepTextNodes(root) {
51+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
52+ for (let node = walker.currentNode; node; node = walker.nextNode()) {
53+ if (node.nodeType === Node.TEXT_NODE) {
54+ if (node.data.trim()) yield node;
55+ } else if (node.shadowRoot) {
56+ yield* deepTextNodes(node.shadowRoot);
57+ }
58+ }
59+ }
60+
3861 function post(token, message) {
3962 let encoded = "";
4063 try {
@@ -101,14 +124,15 @@
101124 function hintTargets(linksOnly) {
102125 const seen = new Set();
103126 const targets = [];
104- for (const element of document.querySelectorAll(linksOnly ? "a[href]" : ALL_TARGETS)) {
127+ for (const element of deepQueryAll(linksOnly ? "a[href]" : ALL_TARGETS)) {
105128 if (seen.has(element) || element.disabled) continue;
106129 const rect = visibleRect(element);
107130 if (!rect) continue;
108131 const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);
109132 const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);
110- const hit = document.elementFromPoint(x, y);
111- if (hit && !element.contains(hit) && !hit.contains(element)) continue;
133+ const root = element.getRootNode();
134+ const hit = (root.elementFromPoint ? root : document).elementFromPoint(x, y);
135+ if (hit && !element.contains(hit) && !hit.contains(element) && !(hit.shadowRoot && hit.shadowRoot.contains(element))) continue;
112136 seen.add(element);
113137 targets.push({ element, rect });
114138 }
@@ -192,10 +216,7 @@
192216 // --- Visual selection -----------------------------------------------------
193217
194218 function firstVisibleTextPosition() {
195- const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT, {
196- acceptNode: (node) => (node.data.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT),
197- });
198- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
219+ for (const node of deepTextNodes(document.body || document.documentElement)) {
199220 if (!visibleRect(node.parentElement)) continue;
200221 const range = document.createRange();
201222 range.selectNodeContents(node);
@@ -300,7 +321,7 @@
300321 }
301322
302323 function sectionText(color) {
303- const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))
324+ const headings = deepQueryAll("h1,h2,h3,h4,h5,h6")
304325 .filter((heading) => heading.getClientRects().length > 0);
305326 if (headings.length === 0) return "";
306327 const threshold = window.innerHeight * 0.25;
@@ -323,7 +344,7 @@
323344 function objectText(object, color) {
324345 if (object === "section") return sectionText(color);
325346 for (const selector of OBJECT_SELECTORS[object] || []) {
326- const candidates = Array.from(document.querySelectorAll(selector));
347+ const candidates = deepQueryAll(selector);
327348 const element = candidates.find((candidate) =>
328349 visibleRect(candidate) && !candidates.some((other) => other !== candidate && other.contains(candidate)));
329350 if (element) {
@@ -362,7 +383,7 @@
362383 }
363384 state = { kind: request.kind, token };
364385 const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color);
365- if (!started) finish({ type: "end" });
386+ if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" });
366387 },
367388 key(token, key) {
368389 if (!state || state.token !== token) {
@@ -35,6 +35,29 @@
35 35
36 let state = null;36 let state = null;
37 37
38+ // deepQueryAll matches selector across the document and every open shadow
39+ // root; component-heavy sites (for example Reddit) render content there.
40+ function deepQueryAll(selector, root = document) {
41+ const results = Array.from(root.querySelectorAll(selector));
42+ for (const element of root.querySelectorAll("*")) {
43+ if (element.shadowRoot) results.push(...deepQueryAll(selector, element.shadowRoot));
44+ }
45+ return results;
46+ }
47+
48+ // deepTextNodes yields non-blank text nodes in document order, descending
49+ // into open shadow roots.
50+ function* deepTextNodes(root) {
51+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
52+ for (let node = walker.currentNode; node; node = walker.nextNode()) {
53+ if (node.nodeType === Node.TEXT_NODE) {
54+ if (node.data.trim()) yield node;
55+ } else if (node.shadowRoot) {
56+ yield* deepTextNodes(node.shadowRoot);
57+ }
58+ }
59+ }
60+
38 function post(token, message) {61 function post(token, message) {
39 let encoded = "";62 let encoded = "";
40 try {63 try {
@@ -101,14 +124,15 @@
101 function hintTargets(linksOnly) {124 function hintTargets(linksOnly) {
102 const seen = new Set();125 const seen = new Set();
103 const targets = [];126 const targets = [];
104- for (const element of document.querySelectorAll(linksOnly ? "a[href]" : ALL_TARGETS)) {127+ for (const element of deepQueryAll(linksOnly ? "a[href]" : ALL_TARGETS)) {
105 if (seen.has(element) || element.disabled) continue;128 if (seen.has(element) || element.disabled) continue;
106 const rect = visibleRect(element);129 const rect = visibleRect(element);
107 if (!rect) continue;130 if (!rect) continue;
108 const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);131 const x = Math.min(Math.max(rect.left + rect.width / 2, 0), window.innerWidth - 1);
109 const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);132 const y = Math.min(Math.max(rect.top + rect.height / 2, 0), window.innerHeight - 1);
110- const hit = document.elementFromPoint(x, y);133+ const root = element.getRootNode();
111- if (hit && !element.contains(hit) && !hit.contains(element)) continue;134+ const hit = (root.elementFromPoint ? root : document).elementFromPoint(x, y);
135+ if (hit && !element.contains(hit) && !hit.contains(element) && !(hit.shadowRoot && hit.shadowRoot.contains(element))) continue;
112 seen.add(element);136 seen.add(element);
113 targets.push({ element, rect });137 targets.push({ element, rect });
114 }138 }
@@ -192,10 +216,7 @@
192 // --- Visual selection -----------------------------------------------------216 // --- Visual selection -----------------------------------------------------
193 217
194 function firstVisibleTextPosition() {218 function firstVisibleTextPosition() {
195- const walker = document.createTreeWalker(document.body || document.documentElement, NodeFilter.SHOW_TEXT, {219+ for (const node of deepTextNodes(document.body || document.documentElement)) {
196- acceptNode: (node) => (node.data.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT),
197- });
198- for (let node = walker.nextNode(); node; node = walker.nextNode()) {
199 if (!visibleRect(node.parentElement)) continue;220 if (!visibleRect(node.parentElement)) continue;
200 const range = document.createRange();221 const range = document.createRange();
201 range.selectNodeContents(node);222 range.selectNodeContents(node);
@@ -300,7 +321,7 @@
300 }321 }
301 322
302 function sectionText(color) {323 function sectionText(color) {
303- const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))324+ const headings = deepQueryAll("h1,h2,h3,h4,h5,h6")
304 .filter((heading) => heading.getClientRects().length > 0);325 .filter((heading) => heading.getClientRects().length > 0);
305 if (headings.length === 0) return "";326 if (headings.length === 0) return "";
306 const threshold = window.innerHeight * 0.25;327 const threshold = window.innerHeight * 0.25;
@@ -323,7 +344,7 @@
323 function objectText(object, color) {344 function objectText(object, color) {
324 if (object === "section") return sectionText(color);345 if (object === "section") return sectionText(color);
325 for (const selector of OBJECT_SELECTORS[object] || []) {346 for (const selector of OBJECT_SELECTORS[object] || []) {
326- const candidates = Array.from(document.querySelectorAll(selector));347+ const candidates = deepQueryAll(selector);
327 const element = candidates.find((candidate) =>348 const element = candidates.find((candidate) =>
328 visibleRect(candidate) && !candidates.some((other) => other !== candidate && other.contains(candidate)));349 visibleRect(candidate) && !candidates.some((other) => other !== candidate && other.contains(candidate)));
329 if (element) {350 if (element) {
@@ -362,7 +383,7 @@
362 }383 }
363 state = { kind: request.kind, token };384 state = { kind: request.kind, token };
364 const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color);385 const started = request.kind === "visual" ? startVisual(color) : startHints(request.kind, color);
365- if (!started) finish({ type: "end" });386+ if (!started) finish({ type: "end", reason: request.kind === "visual" ? "no-visible-text" : "no-visible-targets" });
366 },387 },
367 key(token, key) {388 key(token, key) {
368 if (!state || state.token !== token) {389 if (!state || state.token !== token) {

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