v0.20.0 c741a39

injector.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"fmt"
6	"strings"
7
8	"github.com/bnema/dumber/internal/logging"
9	"github.com/bnema/puregotk-webkit/webkit"
10)
11
12const (
13	// ScriptWorldName is the isolated world used for the injected UI.
14	ScriptWorldName = "dumber"
15	// MessageHandlerName is the name of the script message handler registered with WebKit.
16	MessageHandlerName = "dumber"
17)
18
19// themeCSSScript injects theme CSS into the page.
20// The %s placeholder is replaced with CSS text (newlines escaped as \n).
21const themeCSSScript = `(function() {
22  var style = document.createElement('style');
23  style.setAttribute('data-dumber-theme-vars', '');
24  style.textContent = '%s';
25  (document.head || document.documentElement).appendChild(style);
26})();`
27
28// darkModeScript patches window.matchMedia and sets theme class on <html>.
29// It must be injected at document start, after __dumber_gtk_prefers_dark is set.
30const darkModeScript = `(function() {
31  const prefersDark = window.__dumber_gtk_prefers_dark || false;
32  const originalMatchMedia = window.matchMedia.bind(window);
33
34  // Apply dark/light class to document element for CSS theming
35  if (prefersDark) {
36    document.documentElement.classList.add('dark');
37    document.documentElement.classList.remove('light');
38  } else {
39    document.documentElement.classList.add('light');
40    document.documentElement.classList.remove('dark');
41  }
42
43  // Inject color-scheme meta tag
44  const meta = document.createElement('meta');
45  meta.name = 'color-scheme';
46  meta.content = prefersDark ? 'dark light' : 'light dark';
47  document.documentElement.appendChild(meta);
48
49  // Inject root color-scheme style
50  const style = document.createElement('style');
51  style.setAttribute('data-dumber-color-scheme', '');
52  style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}';
53  document.documentElement.appendChild(style);
54
55  // Patch matchMedia for prefers-color-scheme queries
56  window.matchMedia = function(query) {
57    if (typeof query === 'string') {
58      if (query.includes('prefers-color-scheme: dark')) {
59        return {
60          matches: prefersDark,
61          media: query,
62          onchange: null,
63          addListener: function() {},
64          removeListener: function() {},
65          addEventListener: function() {},
66          removeEventListener: function() {},
67          dispatchEvent: function() { return false; }
68        };
69      }
70      if (query.includes('prefers-color-scheme: light')) {
71        return {
72          matches: !prefersDark,
73          media: query,
74          onchange: null,
75          addListener: function() {},
76          removeListener: function() {},
77          addEventListener: function() {},
78          removeEventListener: function() {},
79          dispatchEvent: function() { return false; }
80        };
81      }
82    }
83    return originalMatchMedia(query);
84  };
85})();`
86
87// ContentInjector encapsulates script injection into WebViews.
88// It injects minimal scripts for dark mode detection in web pages
89// and theme CSS variables for internal pages (dumb://).
90// Implements port.ContentInjector interface.
91type ContentInjector struct {
92	prefersDark  bool
93	themeCSSVars string // CSS custom property declarations for WebUI
94	findCSS      string // CSS for find-in-page highlight styling
95}
96
97// NewContentInjector creates a new injector instance.
98// The prefersDark parameter should come from ThemeManager.PrefersDark().
99func NewContentInjector(prefersDark bool) *ContentInjector {
100	return &ContentInjector{
101		prefersDark: prefersDark,
102	}
103}
104
105// InjectThemeCSS stores CSS variables for injection into internal pages.
106// Implements port.ContentInjector interface.
107// The CSS will be injected when InjectScripts is called on WebView creation.
108func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error {
109	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
110	ci.themeCSSVars = css
111	log.Debug().Int("css_len", len(css)).Msg("theme CSS vars set for injection")
112	return nil
113}
114
115// InjectFindHighlightCSS stores CSS for find-in-page highlight styling.
116func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
117	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
118	ci.findCSS = css
119	log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set for injection")
120	return nil
121}
122
123// PrefersDark returns the current dark mode preference.
124func (ci *ContentInjector) PrefersDark() bool {
125	return ci.prefersDark
126}
127
128// SetPrefersDark updates the dark mode preference.
129// Call this when theme changes at runtime.
130func (ci *ContentInjector) SetPrefersDark(prefersDark bool) {
131	ci.prefersDark = prefersDark
132}
133
134// InjectScripts adds the minimal dark mode detection scripts to the given content manager.
135// Only injects:
136// - window.__dumber_gtk_prefers_dark flag
137// - window.__dumber_webview_id (for debugging)
138// - darkModeScript (patches matchMedia for prefers-color-scheme)
139func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
140	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
141
142	if ucm == nil {
143		log.Warn().Msg("cannot inject scripts: user content manager is nil")
144		return
145	}
146
147	addScript := func(script *webkit.UserScript, label string) {
148		if script == nil {
149			log.Warn().Str("script", label).Msg("failed to create user script")
150			return
151		}
152		ucm.AddScript(script)
153		log.Debug().Str("script", label).Msg("injected user script")
154	}
155
156	// 1. Inject GTK dark mode preference (must be before dark mode handler)
157	darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
158	addScript(
159		webkit.NewUserScript(
160			darkModePrefScript,
161			webkit.UserContentInjectTopFrameValue,
162			webkit.UserScriptInjectAtDocumentStartValue,
163			nil,
164			nil,
165		),
166		"gtk-dark-mode-pref",
167	)
168
169	// 2. Inject WebView ID for debugging
170	if webviewID != 0 {
171		idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
172		addScript(
173			webkit.NewUserScript(
174				idScript,
175				webkit.UserContentInjectTopFrameValue,
176				webkit.UserScriptInjectAtDocumentStartValue,
177				nil,
178				nil,
179			),
180			"webview-id",
181		)
182	}
183
184	// 3. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark)
185	addScript(
186		webkit.NewUserScript(
187			darkModeScript,
188			webkit.UserContentInjectTopFrameValue,
189			webkit.UserScriptInjectAtDocumentStartValue,
190			nil,
191			nil,
192		),
193		"dark-mode-handler",
194	)
195
196	// 4. Inject theme CSS for internal pages (dumb://* only)
197	if ci.themeCSSVars != "" {
198		// Escape for JS string literal
199		escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
200		escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
201		escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
202		themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
203		addScript(
204			webkit.NewUserScript(
205				themeCSSInjectionScript,
206				webkit.UserContentInjectTopFrameValue,
207				webkit.UserScriptInjectAtDocumentEndValue,
208				nil, // Inject on all pages - allowlist patterns don't work for custom schemes
209				nil,
210			),
211			"theme-css-vars",
212		)
213		log.Debug().Msg("theme CSS vars injection configured")
214	}
215
216	// 5. Inject find highlight CSS for all pages
217	if ci.findCSS != "" {
218		stylesheet := webkit.NewUserStyleSheet(
219			ci.findCSS,
220			webkit.UserContentInjectAllFramesValue,
221			webkit.UserStyleLevelUserValue,
222			nil,
223			nil,
224		)
225		if stylesheet == nil {
226			log.Warn().Msg("failed to create find highlight stylesheet")
227		} else {
228			ucm.AddStyleSheet(stylesheet)
229			log.Debug().Msg("find highlight stylesheet injected")
230		}
231	}
232
233	log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected")
234}

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