v0.23.0 d05d132

injector.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"fmt"
6	"strings"
7
8	"github.com/bnema/dumber/internal/application/port"
9	"github.com/bnema/dumber/internal/logging"
10	"github.com/bnema/puregotk-webkit/webkit"
11)
12
13const (
14	// ScriptWorldName is the isolated world used for the injected UI.
15	ScriptWorldName = "dumber"
16	// MessageHandlerName is the name of the script message handler registered with WebKit.
17	MessageHandlerName = "dumber"
18)
19
20// themeCSSScript injects theme CSS into the page.
21// The %s placeholder is replaced with CSS text (newlines escaped as \n).
22const themeCSSScript = `(function() {
23  var style = document.createElement('style');
24  style.setAttribute('data-dumber-theme-vars', '');
25  style.textContent = '%s';
26  (document.head || document.documentElement).appendChild(style);
27})();`
28
29// internalDarkModeScriptTemplate is injected ONLY on internal pages (dumb://*).
30// It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia
31// for JS-based dark mode detection in WebUI components.
32//
33// NOTE: This script is NOT injected on external pages. External pages receive
34// dark mode preference via libadwaita's StyleManager, which WebKit respects
35// for the native prefers-color-scheme media query.
36//
37// The matchMedia patch handles various query formats:
38// - (prefers-color-scheme: dark)
39// - (prefers-color-scheme:dark)  -- no space
40// - screen and (prefers-color-scheme: dark)
41const internalDarkModeScriptTemplate = `(function() {
42  var prefersDark = %t;
43  window.__dumber_gtk_prefers_dark = prefersDark;
44  var originalMatchMedia = window.matchMedia.bind(window);
45
46  // Apply dark/light class to document element for Tailwind CSS theming
47  if (prefersDark) {
48    document.documentElement.classList.add('dark');
49    document.documentElement.classList.remove('light');
50  } else {
51    document.documentElement.classList.add('light');
52    document.documentElement.classList.remove('dark');
53  }
54
55  // Helper: Check if query is a prefers-color-scheme query
56  function isColorSchemeQuery(query, scheme) {
57    if (typeof query !== 'string') return false;
58    var normalized = query.replace(/\s+/g, '').toLowerCase();
59    return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
60  }
61
62  // Create a fake MediaQueryList that implements the full interface
63  function createFakeMediaQueryList(query, matches) {
64    var listeners = [];
65    var onchangeHandler = null;
66
67    return {
68      matches: matches,
69      media: query,
70      get onchange() { return onchangeHandler; },
71      set onchange(fn) { onchangeHandler = fn; },
72      addListener: function(cb) {
73        if (typeof cb === 'function') listeners.push(cb);
74      },
75      removeListener: function(cb) {
76        var idx = listeners.indexOf(cb);
77        if (idx !== -1) listeners.splice(idx, 1);
78      },
79      addEventListener: function(type, cb) {
80        if (type === 'change' && typeof cb === 'function') {
81          listeners.push(cb);
82        }
83      },
84      removeEventListener: function(type, cb) {
85        if (type === 'change') {
86          var idx = listeners.indexOf(cb);
87          if (idx !== -1) listeners.splice(idx, 1);
88        }
89      },
90      dispatchEvent: function(event) {
91        for (var i = 0; i < listeners.length; i++) {
92          try { listeners[i](event); } catch (e) {}
93        }
94        if (onchangeHandler) {
95          try { onchangeHandler(event); } catch (e) {}
96        }
97        return true;
98      }
99    };
100  }
101
102  // Patch matchMedia for prefers-color-scheme queries
103  window.matchMedia = function(query) {
104    if (isColorSchemeQuery(query, 'dark')) {
105      return createFakeMediaQueryList(query, prefersDark);
106    }
107    if (isColorSchemeQuery(query, 'light')) {
108      return createFakeMediaQueryList(query, !prefersDark);
109    }
110    return originalMatchMedia(query);
111  };
112})();`
113
114// internalPageAllowList restricts script injection to internal dumb:// pages only.
115var internalPageAllowList = []string{"dumb://*"}
116
117// ContentInjector encapsulates script injection into WebViews.
118// It injects dark mode detection scripts for internal pages (dumb://)
119// and theme CSS variables for WebUI styling.
120// External pages receive dark mode preference via libadwaita's StyleManager.
121// Implements port.ContentInjector interface.
122type ContentInjector struct {
123	colorResolver port.ColorSchemeResolver
124	themeCSSVars  string // CSS custom property declarations for WebUI
125	findCSS       string // CSS for find-in-page highlight styling
126}
127
128// NewContentInjector creates a new injector instance.
129// The resolver is used to dynamically determine dark mode preference.
130func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
131	return &ContentInjector{
132		colorResolver: resolver,
133	}
134}
135
136// InjectThemeCSS stores CSS variables for injection into internal pages.
137// Implements port.ContentInjector interface.
138// The CSS will be injected when InjectScripts is called on WebView creation.
139func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error {
140	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
141	ci.themeCSSVars = css
142	log.Debug().Int("css_len", len(css)).Msg("theme CSS vars set for injection")
143	return nil
144}
145
146// InjectFindHighlightCSS stores CSS for find-in-page highlight styling.
147func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
148	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
149	ci.findCSS = css
150	log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set for injection")
151	return nil
152}
153
154// PrefersDark returns the current dark mode preference from the resolver.
155func (ci *ContentInjector) PrefersDark() bool {
156	return ci.colorResolver.Resolve().PrefersDark
157}
158
159// InjectScripts adds scripts to the given content manager.
160// For internal pages (dumb://*):
161//   - Injects dark mode detection (class on <html>, matchMedia patch)
162//   - Injects theme CSS variables for WebUI styling
163//
164// For external pages:
165//   - Only injects find highlight CSS
166//   - Dark mode is handled natively via libadwaita's StyleManager
167func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
168	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
169
170	if ucm == nil {
171		log.Warn().Msg("cannot inject scripts: user content manager is nil")
172		return
173	}
174
175	addScript := func(script *webkit.UserScript, label string) {
176		if script == nil {
177			log.Warn().Str("script", label).Msg("failed to create user script")
178			return
179		}
180		ucm.AddScript(script)
181		log.Debug().Str("script", label).Msg("injected user script")
182	}
183
184	prefersDark := ci.PrefersDark()
185
186	// 1. Inject WebView ID for debugging (internal pages only)
187	if webviewID != 0 {
188		idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
189		addScript(
190			webkit.NewUserScript(
191				idScript,
192				webkit.UserContentInjectTopFrameValue,
193				webkit.UserScriptInjectAtDocumentStartValue,
194				internalPageAllowList,
195				nil,
196			),
197			"webview-id",
198		)
199		log.Debug().Uint64("webview_id", uint64(webviewID)).Msg("webview ID script injected")
200	} else {
201		log.Warn().Msg("webview ID is 0, skipping ID injection")
202	}
203
204	// 2. Inject dark mode handler for internal pages only
205	// This sets .dark/.light class on <html> and patches matchMedia for WebUI
206	internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
207	addScript(
208		webkit.NewUserScript(
209			internalDarkModeScript,
210			webkit.UserContentInjectTopFrameValue,
211			webkit.UserScriptInjectAtDocumentStartValue,
212			internalPageAllowList,
213			nil,
214		),
215		"internal-dark-mode-handler",
216	)
217
218	// 3. Inject theme CSS for internal pages (dumb://* only)
219	if ci.themeCSSVars != "" {
220		// Escape for JS string literal
221		escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
222		escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
223		escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
224		themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
225		addScript(
226			webkit.NewUserScript(
227				themeCSSInjectionScript,
228				webkit.UserContentInjectTopFrameValue,
229				webkit.UserScriptInjectAtDocumentEndValue,
230				internalPageAllowList,
231				nil,
232			),
233			"theme-css-vars",
234		)
235		log.Debug().Msg("theme CSS vars injection configured for internal pages")
236	}
237
238	// 4. Inject find highlight CSS for all pages
239	if ci.findCSS != "" {
240		stylesheet := webkit.NewUserStyleSheet(
241			ci.findCSS,
242			webkit.UserContentInjectAllFramesValue,
243			webkit.UserStyleLevelUserValue,
244			nil,
245			nil,
246		)
247		if stylesheet == nil {
248			log.Warn().Msg("failed to create find highlight stylesheet")
249		} else {
250			ucm.AddStyleSheet(stylesheet)
251			log.Debug().Msg("find highlight stylesheet injected")
252		}
253	}
254
255	log.Debug().Bool("prefers_dark", prefersDark).Msg("scripts injected")
256}

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