semantic_navigation.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"fmt"
6	"strconv"
7
8	purecef "github.com/bnema/purego-cef/cef"
9
10	"github.com/bnema/dumber/internal/application/dto"
11)
12
13// NavigateSemantic performs the initial live Vim structural navigation path.
14// It deliberately uses only stable HTML semantics and a static script: CEF's
15// raw accessibility payload is not yet a documented application contract.
16func (wv *WebView) NavigateSemantic(ctx context.Context, request dto.SemanticNavigationRequest) error {
17	if wv == nil || wv.destroyed.Load() {
18		return errDestroyed
19	}
20	if request.Target != dto.SemanticNavigationTargetHeading {
21		return fmt.Errorf("unsupported semantic navigation target: %d", request.Target)
22	}
23	if request.Direction != dto.SemanticNavigationForward && request.Direction != dto.SemanticNavigationBackward {
24		return fmt.Errorf("unsupported semantic navigation direction: %d", request.Direction)
25	}
26
27	count := request.Count
28	if count < 1 {
29		count = 1
30	}
31	wv.RunJavaScript(ctx, headingNavigationScript(int(request.Direction), count, request.HighlightColor, wv.vimHeadingHighlightNamespace()))
32	return nil
33}
34
35// ActivateSemanticNavigationTarget follows the link associated with the most
36// recent Vim semantic navigation target, if that target has one.
37func (wv *WebView) ActivateSemanticNavigationTarget(_ context.Context) error {
38	if wv == nil || wv.destroyed.Load() {
39		return errDestroyed
40	}
41	return wv.scheduleJavaScript(activateSemanticNavigationTargetScript(wv.vimHeadingHighlightNamespace()))
42}
43
44// ClearSemanticNavigationHighlight removes the visual target left by the most
45// recent Vim semantic navigation in this WebView.
46func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error {
47	if wv == nil || wv.destroyed.Load() {
48		return errDestroyed
49	}
50	return wv.scheduleJavaScript(clearHeadingNavigationHighlightScript(wv.vimHeadingHighlightNamespace()))
51}
52
53func (wv *WebView) vimHeadingHighlightNamespace() string {
54	return fmt.Sprintf("dumber-vim-heading-%p", wv)
55}
56
57// scheduleJavaScript makes CEF UI-thread scheduling failures observable to
58// callers that need to guarantee a transient UI state is cleared.
59func (wv *WebView) scheduleJavaScript(script string) error {
60	if wv == nil || wv.destroyed.Load() {
61		return errDestroyed
62	}
63	wv.mu.RLock()
64	browser := wv.browser
65	wv.mu.RUnlock()
66	if browser == nil {
67		return fmt.Errorf("schedule JavaScript: browser unavailable")
68	}
69	if wv.engine == nil {
70		frame := browser.GetMainFrame()
71		if frame == nil {
72			return fmt.Errorf("schedule JavaScript: main frame unavailable")
73		}
74		frame.ExecuteJavaScript(script, "", 0)
75		return nil
76	}
77
78	task := cefNewTask(cefTaskFunc(func() {
79		wv.executeJavaScriptNow(script)
80	}))
81	if task == nil {
82		return fmt.Errorf("schedule JavaScript: create CEF task")
83	}
84	if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
85		return fmt.Errorf("schedule JavaScript: post CEF task: result %d", result)
86	}
87	return nil
88}
89
90func headingNavigationScript(direction, count int, highlightColor, namespace string) string {
91	if highlightColor == "" {
92		highlightColor = "#fbbf24"
93	}
94	return `(() => {
95  const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
96  const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
97  const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
98  const highlightColor = ` + strconv.Quote(highlightColor) + `;
99  const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))
100    .filter((heading) => {
101      if (heading.getClientRects().length === 0) return false;
102      if (heading.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false;
103      const style = window.getComputedStyle(heading);
104      return style.visibility !== "hidden" && style.visibility !== "collapse";
105    });
106  if (headings.length === 0) return;
107
108  const prior = window[stateKey];
109  let index = headings.indexOf(prior);
110  if (index < 0) {
111    const firstAtOrAfterViewportTop = headings.findIndex(
112      (heading) => heading.getBoundingClientRect().top >= 0,
113    );
114    if (` + strconv.Itoa(direction) + ` > 0) {
115      index = firstAtOrAfterViewportTop - 1;
116      if (index < -1) index = -1;
117    } else {
118      index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length;
119    }
120  }
121
122  const next = index + (` + strconv.Itoa(direction) + ` * ` + strconv.Itoa(count) + `);
123  if (next < 0 || next >= headings.length) return;
124  const target = headings[next];
125
126  const previous = document.querySelector("[" + targetAttribute + "]");
127  if (previous && previous !== target) previous.removeAttribute(targetAttribute);
128  let style = document.querySelector("style[" + styleAttribute + "]");
129  if (!style) {
130    // This visual cue is best-effort: strict page CSP can reject a DOM style
131    // element, but target selection and scrolling must stay independent of it.
132    style = document.createElement("style");
133    style.setAttribute(styleAttribute, "");
134    document.documentElement.appendChild(style);
135  }
136  // Refresh the outline on every navigation so an in-session accent change is
137  // reflected even when the target style element already exists.
138  style.textContent = "[" + targetAttribute + "]" +
139    " { outline: 3px solid " + highlightColor +
140    " !important; outline-offset: 5px !important; border-radius: 3px !important; }";
141  window[stateKey] = target;
142  target.setAttribute(targetAttribute, "");
143  target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
144})();`
145}
146
147func activateSemanticNavigationTargetScript(namespace string) string {
148	return `(() => {
149  const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
150  const target = window[stateKey];
151  if (!(target instanceof Element) || !target.isConnected) return;
152
153  let link = target.closest("a[href]");
154  if (!link) link = target.querySelector("a[href]");
155  if (!link) return;
156  link.click();
157})();`
158}
159
160func clearHeadingNavigationHighlightScript(namespace string) string {
161	return `(() => {
162  const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
163  const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
164  const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
165  document.querySelectorAll("[" + targetAttribute + "]").forEach((target) => {
166    target.removeAttribute(targetAttribute);
167  });
168  document.querySelectorAll("style[" + styleAttribute + "]").forEach((style) => {
169    style.remove();
170  });
171  delete window[stateKey];
172})();`
173}

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