v0.22.0 a85da87

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// earlyBackgroundScript injects a temporary background color to prevent white flash.
29// It's injected at document start and removed after the page loads.
30// The %s placeholder is replaced with the hex color (e.g., "#0a0a0b").
31const earlyBackgroundScript = `(function() {
32  var style = document.createElement('style');
33  style.id = '__dumber_early_bg';
34  style.textContent = 'html,body{background-color:%s!important}';
35  document.documentElement.appendChild(style);
36  
37  // Remove the style after page content loads to avoid overriding page styles
38  function removeEarlyBg() {
39    var s = document.getElementById('__dumber_early_bg');
40    if (s) s.remove();
41  }
42  
43  // Try multiple events to ensure removal
44  if (document.readyState === 'complete') {
45    setTimeout(removeEarlyBg, 100);
46  } else {
47    window.addEventListener('DOMContentLoaded', function() {
48      setTimeout(removeEarlyBg, 100);
49    }, {once: true});
50    window.addEventListener('load', function() {
51      setTimeout(removeEarlyBg, 100);
52    }, {once: true});
53  }
54})();`
55
56// darkModeScript patches window.matchMedia and sets theme class on <html>.
57// It must be injected at document start, after __dumber_gtk_prefers_dark is set.
58// This script:
59// 1. Sets dark/light class on <html> for CSS-based theme detection
60// 2. Injects color-scheme meta tag and CSS for browser UA style hints
61// 3. Patches matchMedia to return the GTK theme preference for prefers-color-scheme queries
62//
63// The matchMedia patch handles various query formats:
64// - (prefers-color-scheme: dark)
65// - (prefers-color-scheme:dark)  -- no space
66// - screen and (prefers-color-scheme: dark)
67// And provides proper addEventListener/removeEventListener stubs for compatibility.
68const darkModeScript = `(function() {
69  var prefersDark = window.__dumber_gtk_prefers_dark || false;
70  var originalMatchMedia = window.matchMedia.bind(window);
71
72  // Apply dark/light class to document element for CSS theming
73  if (prefersDark) {
74    document.documentElement.classList.add('dark');
75    document.documentElement.classList.remove('light');
76  } else {
77    document.documentElement.classList.add('light');
78    document.documentElement.classList.remove('dark');
79  }
80
81  // Inject color-scheme meta tag
82  var meta = document.createElement('meta');
83  meta.name = 'color-scheme';
84  meta.content = prefersDark ? 'dark light' : 'light dark';
85  document.documentElement.appendChild(meta);
86
87  // Inject root color-scheme style
88  var style = document.createElement('style');
89  style.setAttribute('data-dumber-color-scheme', '');
90  style.textContent = ':root{color-scheme:' + (prefersDark ? 'dark' : 'light') + ';}';
91  document.documentElement.appendChild(style);
92
93  // Helper: Check if query is a prefers-color-scheme query
94  // Normalizes by removing whitespace and lowercasing
95  function isColorSchemeQuery(query, scheme) {
96    if (typeof query !== 'string') return false;
97    var normalized = query.replace(/\s+/g, '').toLowerCase();
98    return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
99  }
100
101  // Create a fake MediaQueryList that implements the full interface
102  function createFakeMediaQueryList(query, matches) {
103    var listeners = [];
104    var onchangeHandler = null;
105
106    return {
107      matches: matches,
108      media: query,
109      get onchange() { return onchangeHandler; },
110      set onchange(fn) { onchangeHandler = fn; },
111      // Deprecated but still used by some sites
112      addListener: function(cb) {
113        if (typeof cb === 'function') listeners.push(cb);
114      },
115      removeListener: function(cb) {
116        var idx = listeners.indexOf(cb);
117        if (idx !== -1) listeners.splice(idx, 1);
118      },
119      // Modern API
120      addEventListener: function(type, cb) {
121        if (type === 'change' && typeof cb === 'function') {
122          listeners.push(cb);
123        }
124      },
125      removeEventListener: function(type, cb) {
126        if (type === 'change') {
127          var idx = listeners.indexOf(cb);
128          if (idx !== -1) listeners.splice(idx, 1);
129        }
130      },
131      dispatchEvent: function(event) {
132        for (var i = 0; i < listeners.length; i++) {
133          try { listeners[i](event); } catch (e) {}
134        }
135        if (onchangeHandler) {
136          try { onchangeHandler(event); } catch (e) {}
137        }
138        return true;
139      }
140    };
141  }
142
143  // Patch matchMedia for prefers-color-scheme queries
144  window.matchMedia = function(query) {
145    if (isColorSchemeQuery(query, 'dark')) {
146      return createFakeMediaQueryList(query, prefersDark);
147    }
148    if (isColorSchemeQuery(query, 'light')) {
149      return createFakeMediaQueryList(query, !prefersDark);
150    }
151    return originalMatchMedia(query);
152  };
153})();`
154
155// ContentInjector encapsulates script injection into WebViews.
156// It injects minimal scripts for dark mode detection in web pages
157// and theme CSS variables for internal pages (dumb://).
158// Implements port.ContentInjector interface.
159type ContentInjector struct {
160	prefersDark  bool
161	themeCSSVars string // CSS custom property declarations for WebUI
162	findCSS      string // CSS for find-in-page highlight styling
163	bgColor      string // Background color hex for early injection (prevents white flash)
164}
165
166// NewContentInjector creates a new injector instance.
167// The prefersDark parameter should come from ThemeManager.PrefersDark().
168func NewContentInjector(prefersDark bool) *ContentInjector {
169	return &ContentInjector{
170		prefersDark: prefersDark,
171	}
172}
173
174// InjectThemeCSS stores CSS variables for injection into internal pages.
175// Implements port.ContentInjector interface.
176// The CSS will be injected when InjectScripts is called on WebView creation.
177func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error {
178	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
179	ci.themeCSSVars = css
180	log.Debug().Int("css_len", len(css)).Msg("theme CSS vars set for injection")
181	return nil
182}
183
184// InjectFindHighlightCSS stores CSS for find-in-page highlight styling.
185func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
186	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
187	ci.findCSS = css
188	log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set for injection")
189	return nil
190}
191
192// PrefersDark returns the current dark mode preference.
193func (ci *ContentInjector) PrefersDark() bool {
194	return ci.prefersDark
195}
196
197// SetPrefersDark updates the dark mode preference.
198// Call this when theme changes at runtime.
199func (ci *ContentInjector) SetPrefersDark(prefersDark bool) {
200	ci.prefersDark = prefersDark
201}
202
203// SetBackgroundColor sets the background color for early CSS injection.
204// This color is applied to html/body at document start to prevent white flash.
205// The hex color should include the # prefix (e.g., "#0a0a0b").
206func (ci *ContentInjector) SetBackgroundColor(hex string) {
207	ci.bgColor = hex
208}
209
210// InjectScripts adds the minimal dark mode detection scripts to the given content manager.
211// Only injects:
212// - window.__dumber_gtk_prefers_dark flag
213// - window.__dumber_webview_id (for debugging)
214// - darkModeScript (patches matchMedia for prefers-color-scheme)
215func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
216	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
217
218	if ucm == nil {
219		log.Warn().Msg("cannot inject scripts: user content manager is nil")
220		return
221	}
222
223	addScript := func(script *webkit.UserScript, label string) {
224		if script == nil {
225			log.Warn().Str("script", label).Msg("failed to create user script")
226			return
227		}
228		ucm.AddScript(script)
229		log.Debug().Str("script", label).Msg("injected user script")
230	}
231
232	// 1. Inject early background color to prevent white flash (must be first!)
233	if ci.bgColor != "" {
234		earlyBgScript := fmt.Sprintf(earlyBackgroundScript, ci.bgColor)
235		addScript(
236			webkit.NewUserScript(
237				earlyBgScript,
238				webkit.UserContentInjectTopFrameValue,
239				webkit.UserScriptInjectAtDocumentStartValue,
240				nil,
241				nil,
242			),
243			"early-background",
244		)
245	}
246
247	// 2. Inject GTK dark mode preference (must be before dark mode handler)
248	darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
249	addScript(
250		webkit.NewUserScript(
251			darkModePrefScript,
252			webkit.UserContentInjectTopFrameValue,
253			webkit.UserScriptInjectAtDocumentStartValue,
254			nil,
255			nil,
256		),
257		"gtk-dark-mode-pref",
258	)
259
260	// 3. Inject WebView ID for debugging
261	if webviewID != 0 {
262		idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
263		addScript(
264			webkit.NewUserScript(
265				idScript,
266				webkit.UserContentInjectTopFrameValue,
267				webkit.UserScriptInjectAtDocumentStartValue,
268				nil,
269				nil,
270			),
271			"webview-id",
272		)
273	}
274
275	// 4. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark)
276	addScript(
277		webkit.NewUserScript(
278			darkModeScript,
279			webkit.UserContentInjectTopFrameValue,
280			webkit.UserScriptInjectAtDocumentStartValue,
281			nil,
282			nil,
283		),
284		"dark-mode-handler",
285	)
286
287	// 5. Inject theme CSS for internal pages (dumb://* only)
288	if ci.themeCSSVars != "" {
289		// Escape for JS string literal
290		escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
291		escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
292		escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
293		themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
294		addScript(
295			webkit.NewUserScript(
296				themeCSSInjectionScript,
297				webkit.UserContentInjectTopFrameValue,
298				webkit.UserScriptInjectAtDocumentEndValue,
299				nil, // Inject on all pages - allowlist patterns don't work for custom schemes
300				nil,
301			),
302			"theme-css-vars",
303		)
304		log.Debug().Msg("theme CSS vars injection configured")
305	}
306
307	// 6. Inject find highlight CSS for all pages
308	if ci.findCSS != "" {
309		stylesheet := webkit.NewUserStyleSheet(
310			ci.findCSS,
311			webkit.UserContentInjectAllFramesValue,
312			webkit.UserStyleLevelUserValue,
313			nil,
314			nil,
315		)
316		if stylesheet == nil {
317			log.Warn().Msg("failed to create find highlight stylesheet")
318		} else {
319			ucm.AddStyleSheet(stylesheet)
320			log.Debug().Msg("find highlight stylesheet injected")
321		}
322	}
323
324	log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected")
325}

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