v0.20.1 d9a391f

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

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