v0.27.1 c78ce83

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// autoCopySelectionScript is injected to enable auto-copy on text selection (zellij-style).
118// It listens for selection changes, debounces them, and posts to the Go message handler.
119// Skips selections in input fields/textareas and requires minimum 2 characters.
120const autoCopySelectionScript = `(function() {
121  var debounceTimer = null;
122  var MIN_LENGTH = 2;
123  
124  document.addEventListener('selectionchange', function() {
125    // Skip if selection is inside input/textarea/contenteditable
126    var activeEl = document.activeElement;
127    if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) {
128      return;
129    }
130    
131    clearTimeout(debounceTimer);
132    debounceTimer = setTimeout(function() {
133      var text = window.getSelection().toString().trim();
134      if (text.length >= MIN_LENGTH && window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) {
135        window.webkit.messageHandlers.dumber.postMessage({
136          type: 'auto_copy_selection',
137          payload: { text: text }
138        });
139      }
140    }, 300);
141  });
142})();`
143
144// webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names.
145// Some pages gate support on window.RTCPeerConnection and report false negatives
146// when only webkit-prefixed constructors are present.
147const webRTCCompatScript = `(function() {
148  if (!window.RTCPeerConnection && window.webkitRTCPeerConnection) {
149    window.RTCPeerConnection = window.webkitRTCPeerConnection;
150  }
151  if (!window.RTCSessionDescription && window.webkitRTCSessionDescription) {
152    window.RTCSessionDescription = window.webkitRTCSessionDescription;
153  }
154  if (!window.RTCIceCandidate && window.webkitRTCIceCandidate) {
155    window.RTCIceCandidate = window.webkitRTCIceCandidate;
156  }
157})();`
158
159func buildWebRTCCompatScript() string {
160	return webRTCCompatScript
161}
162
163// ContentInjector encapsulates script injection into WebViews.
164// It injects dark mode detection scripts for internal pages (dumb://)
165// and theme CSS variables for WebUI styling.
166// External pages receive dark mode preference via libadwaita's StyleManager.
167// Implements port.ContentInjector interface.
168type ContentInjector struct {
169	colorResolver        port.ColorSchemeResolver
170	themeCSSVars         string      // CSS custom property declarations for WebUI
171	findCSS              string      // CSS for find-in-page highlight styling
172	autoCopyConfigGetter func() bool // Dynamic getter for auto-copy config
173}
174
175// NewContentInjector creates a new injector instance.
176// The resolver is used to dynamically determine dark mode preference.
177func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
178	return &ContentInjector{
179		colorResolver: resolver,
180	}
181}
182
183// SetAutoCopyConfigGetter sets the function to dynamically check if auto-copy is enabled.
184// This is called during script injection to determine whether to inject the selection listener.
185func (ci *ContentInjector) SetAutoCopyConfigGetter(getter func() bool) {
186	ci.autoCopyConfigGetter = getter
187}
188
189// InjectThemeCSS stores CSS variables for injection into internal pages.
190// Implements port.ContentInjector interface.
191// The CSS will be injected when InjectScripts is called on WebView creation.
192func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error {
193	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
194	ci.themeCSSVars = css
195	log.Debug().Int("css_len", len(css)).Msg("theme CSS vars set for injection")
196	return nil
197}
198
199// InjectFindHighlightCSS stores CSS for find-in-page highlight styling.
200func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
201	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
202	ci.findCSS = css
203	log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set for injection")
204	return nil
205}
206
207// PrefersDark returns the current dark mode preference from the resolver.
208func (ci *ContentInjector) PrefersDark() bool {
209	return ci.colorResolver.Resolve().PrefersDark
210}
211
212// InjectScripts adds scripts to the given content manager.
213// For internal pages (dumb://*):
214//   - Injects dark mode detection (class on <html>, matchMedia patch)
215//   - Injects theme CSS variables for WebUI styling
216//
217// For external pages:
218//   - Only injects find highlight CSS
219//   - Dark mode is handled natively via libadwaita's StyleManager
220func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
221	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
222
223	if ucm == nil {
224		log.Warn().Msg("cannot inject scripts: user content manager is nil")
225		return
226	}
227
228	addScript := func(script *webkit.UserScript, label string) {
229		if script == nil {
230			log.Warn().Str("script", label).Msg("failed to create user script")
231			return
232		}
233		ucm.AddScript(script)
234		log.Debug().Str("script", label).Msg("injected user script")
235	}
236
237	prefersDark := ci.PrefersDark()
238
239	// 1. Inject WebRTC compatibility aliases for all pages.
240	addScript(
241		webkit.NewUserScript(
242			buildWebRTCCompatScript(),
243			webkit.UserContentInjectTopFrameValue,
244			webkit.UserScriptInjectAtDocumentStartValue,
245			nil,
246			nil,
247		),
248		"webrtc-compat-shim",
249	)
250
251	// 2. Inject WebView ID for debugging (internal pages only)
252	if webviewID != 0 {
253		idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
254		addScript(
255			webkit.NewUserScript(
256				idScript,
257				webkit.UserContentInjectTopFrameValue,
258				webkit.UserScriptInjectAtDocumentStartValue,
259				internalPageAllowList,
260				nil,
261			),
262			"webview-id",
263		)
264		log.Debug().Uint64("webview_id", uint64(webviewID)).Msg("webview ID script injected")
265	} else {
266		log.Warn().Msg("webview ID is 0, skipping ID injection")
267	}
268
269	// 3. Inject dark mode handler for internal pages only
270	// This sets .dark/.light class on <html> and patches matchMedia for WebUI
271	internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
272	addScript(
273		webkit.NewUserScript(
274			internalDarkModeScript,
275			webkit.UserContentInjectTopFrameValue,
276			webkit.UserScriptInjectAtDocumentStartValue,
277			internalPageAllowList,
278			nil,
279		),
280		"internal-dark-mode-handler",
281	)
282
283	// 4. Inject theme CSS for internal pages (dumb://* only)
284	if ci.themeCSSVars != "" {
285		// Escape for JS string literal
286		escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
287		escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
288		escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
289		themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
290		addScript(
291			webkit.NewUserScript(
292				themeCSSInjectionScript,
293				webkit.UserContentInjectTopFrameValue,
294				webkit.UserScriptInjectAtDocumentEndValue,
295				internalPageAllowList,
296				nil,
297			),
298			"theme-css-vars",
299		)
300		log.Debug().Msg("theme CSS vars injection configured for internal pages")
301	}
302
303	// 5. Inject find highlight CSS for all pages
304	if ci.findCSS != "" {
305		stylesheet := webkit.NewUserStyleSheet(
306			ci.findCSS,
307			webkit.UserContentInjectAllFramesValue,
308			webkit.UserStyleLevelUserValue,
309			nil,
310			nil,
311		)
312		if stylesheet == nil {
313			log.Warn().Msg("failed to create find highlight stylesheet")
314		} else {
315			ucm.AddStyleSheet(stylesheet)
316			log.Debug().Msg("find highlight stylesheet injected")
317		}
318	}
319
320	// 6. Inject auto-copy selection script for all pages (if enabled)
321	autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
322	if autoCopyEnabled {
323		addScript(
324			webkit.NewUserScript(
325				autoCopySelectionScript,
326				webkit.UserContentInjectTopFrameValue,
327				webkit.UserScriptInjectAtDocumentEndValue,
328				nil, // All pages
329				nil,
330			),
331			"auto-copy-selection",
332		)
333		log.Debug().Msg("auto-copy selection script injected")
334	}
335
336	log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected")
337}

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