v0.31.0 bf0756b

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// ClearSemanticNavigationHighlight removes the visual target left by the most
36// recent Vim semantic navigation in this WebView.
37func (wv *WebView) ClearSemanticNavigationHighlight(_ context.Context) error {
38	if wv == nil || wv.destroyed.Load() {
39		return errDestroyed
40	}
41	return wv.scheduleJavaScript(clearHeadingNavigationHighlightScript(wv.vimHeadingHighlightNamespace()))
42}
43
44func (wv *WebView) vimHeadingHighlightNamespace() string {
45	return fmt.Sprintf("dumber-vim-heading-%p", wv)
46}
47
48// scheduleJavaScript makes CEF UI-thread scheduling failures observable to
49// callers that need to guarantee a transient UI state is cleared.
50func (wv *WebView) scheduleJavaScript(script string) error {
51	if wv == nil || wv.destroyed.Load() {
52		return errDestroyed
53	}
54	wv.mu.RLock()
55	browser := wv.browser
56	wv.mu.RUnlock()
57	if browser == nil {
58		return fmt.Errorf("schedule JavaScript: browser unavailable")
59	}
60	if wv.engine == nil {
61		frame := browser.GetMainFrame()
62		if frame == nil {
63			return fmt.Errorf("schedule JavaScript: main frame unavailable")
64		}
65		frame.ExecuteJavaScript(script, "", 0)
66		return nil
67	}
68
69	task := cefNewTask(cefTaskFunc(func() {
70		wv.executeJavaScriptNow(script)
71	}))
72	if task == nil {
73		return fmt.Errorf("schedule JavaScript: create CEF task")
74	}
75	if result := cefPostTask(purecef.ThreadIDTidUi, task); result != 1 {
76		return fmt.Errorf("schedule JavaScript: post CEF task: result %d", result)
77	}
78	return nil
79}
80
81func headingNavigationScript(direction, count int, highlightColor, namespace string) string {
82	if highlightColor == "" {
83		highlightColor = "#fbbf24"
84	}
85	return `(() => {
86  const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
87  const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
88  const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
89  const highlightColor = ` + strconv.Quote(highlightColor) + `;
90  const headings = Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"))
91    .filter((heading) => {
92      if (heading.getClientRects().length === 0) return false;
93      if (heading.closest("[hidden],[inert],[aria-hidden=\"true\"]")) return false;
94      const style = window.getComputedStyle(heading);
95      return style.visibility !== "hidden" && style.visibility !== "collapse";
96    });
97  if (headings.length === 0) return;
98
99  const prior = window[stateKey];
100  let index = headings.indexOf(prior);
101  if (index < 0) {
102    const firstAtOrAfterViewportTop = headings.findIndex(
103      (heading) => heading.getBoundingClientRect().top >= 0,
104    );
105    if (` + strconv.Itoa(direction) + ` > 0) {
106      index = firstAtOrAfterViewportTop - 1;
107      if (index < -1) index = -1;
108    } else {
109      index = firstAtOrAfterViewportTop >= 0 ? firstAtOrAfterViewportTop : headings.length;
110    }
111  }
112
113  const next = index + (` + strconv.Itoa(direction) + ` * ` + strconv.Itoa(count) + `);
114  if (next < 0 || next >= headings.length) return;
115  const target = headings[next];
116
117  const previous = document.querySelector("[" + targetAttribute + "]");
118  if (previous && previous !== target) previous.removeAttribute(targetAttribute);
119  let style = document.querySelector("style[" + styleAttribute + "]");
120  if (!style) {
121    // This visual cue is best-effort: strict page CSP can reject a DOM style
122    // element, but target selection and scrolling must stay independent of it.
123    style = document.createElement("style");
124    style.setAttribute(styleAttribute, "");
125    document.documentElement.appendChild(style);
126  }
127  // Refresh the outline on every navigation so an in-session accent change is
128  // reflected even when the target style element already exists.
129  style.textContent = "[" + targetAttribute + "]" +
130    " { outline: 3px solid " + highlightColor +
131    " !important; outline-offset: 5px !important; border-radius: 3px !important; }";
132  window[stateKey] = target;
133  target.setAttribute(targetAttribute, "");
134  target.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
135})();`
136}
137
138func clearHeadingNavigationHighlightScript(namespace string) string {
139	return `(() => {
140  const stateKey = ` + strconv.Quote("__"+namespace+"Target") + `;
141  const targetAttribute = ` + strconv.Quote("data-"+namespace+"-target") + `;
142  const styleAttribute = ` + strconv.Quote("data-"+namespace+"-style") + `;
143  document.querySelectorAll("[" + targetAttribute + "]").forEach((target) => {
144    target.removeAttribute(targetAttribute);
145  });
146  document.querySelectorAll("style[" + styleAttribute + "]").forEach((style) => {
147    style.remove();
148  });
149  delete window[stateKey];
150})();`
151}

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