v0.28.0 3cb3770

injector.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"fmt"
6	"sort"
7	"strings"
8
9	"github.com/bnema/dumber/internal/application/port"
10	"github.com/bnema/dumber/internal/domain/entity"
11	"github.com/bnema/dumber/internal/logging"
12	"github.com/bnema/puregotk-webkit/webkit"
13)
14
15const (
16	// ScriptWorldName is the isolated world used for the injected UI.
17	ScriptWorldName = "dumber"
18	// MessageHandlerName is the name of the script message handler registered with WebKit.
19	MessageHandlerName = "dumber"
20)
21
22// themeCSSScript injects theme CSS into the page.
23// The %s placeholder is replaced with CSS text (newlines escaped as \n).
24const themeCSSScript = `(function() {
25  var style = document.createElement('style');
26  style.setAttribute('data-dumber-theme-vars', '');
27  style.textContent = '%s';
28  (document.head || document.documentElement).appendChild(style);
29})();`
30
31// internalDarkModeScriptTemplate is injected ONLY on internal pages (dumb://*).
32// It sets dark/light class on <html> for Tailwind CSS theming and patches matchMedia
33// for JS-based dark mode detection in WebUI components.
34//
35// NOTE: This script is NOT injected on external pages. External pages receive
36// dark mode preference via libadwaita's StyleManager, which WebKit respects
37// for the native prefers-color-scheme media query.
38//
39// The matchMedia patch handles various query formats:
40// - (prefers-color-scheme: dark)
41// - (prefers-color-scheme:dark)  -- no space
42// - screen and (prefers-color-scheme: dark)
43const internalDarkModeScriptTemplate = `(function() {
44  var prefersDark = %t;
45  window.__dumber_gtk_prefers_dark = prefersDark;
46  var originalMatchMedia = window.matchMedia.bind(window);
47
48  // Apply dark/light class to document element for Tailwind CSS theming
49  if (prefersDark) {
50    document.documentElement.classList.add('dark');
51    document.documentElement.classList.remove('light');
52  } else {
53    document.documentElement.classList.add('light');
54    document.documentElement.classList.remove('dark');
55  }
56
57  // Helper: Check if query is a prefers-color-scheme query
58  function isColorSchemeQuery(query, scheme) {
59    if (typeof query !== 'string') return false;
60    var normalized = query.replace(/\s+/g, '').toLowerCase();
61    return normalized.indexOf('prefers-color-scheme:' + scheme) !== -1;
62  }
63
64  // Create a fake MediaQueryList that implements the full interface
65  function createFakeMediaQueryList(query, matches) {
66    var listeners = [];
67    var onchangeHandler = null;
68
69    return {
70      matches: matches,
71      media: query,
72      get onchange() { return onchangeHandler; },
73      set onchange(fn) { onchangeHandler = fn; },
74      addListener: function(cb) {
75        if (typeof cb === 'function') listeners.push(cb);
76      },
77      removeListener: function(cb) {
78        var idx = listeners.indexOf(cb);
79        if (idx !== -1) listeners.splice(idx, 1);
80      },
81      addEventListener: function(type, cb) {
82        if (type === 'change' && typeof cb === 'function') {
83          listeners.push(cb);
84        }
85      },
86      removeEventListener: function(type, cb) {
87        if (type === 'change') {
88          var idx = listeners.indexOf(cb);
89          if (idx !== -1) listeners.splice(idx, 1);
90        }
91      },
92      dispatchEvent: function(event) {
93        for (var i = 0; i < listeners.length; i++) {
94          try { listeners[i](event); } catch (e) {}
95        }
96        if (onchangeHandler) {
97          try { onchangeHandler(event); } catch (e) {}
98        }
99        return true;
100      }
101    };
102  }
103
104  // Patch matchMedia for prefers-color-scheme queries
105  window.matchMedia = function(query) {
106    if (isColorSchemeQuery(query, 'dark')) {
107      return createFakeMediaQueryList(query, prefersDark);
108    }
109    if (isColorSchemeQuery(query, 'light')) {
110      return createFakeMediaQueryList(query, !prefersDark);
111    }
112    return originalMatchMedia(query);
113  };
114})();`
115
116// internalPageAllowList restricts script injection to internal dumb:// pages only.
117var internalPageAllowList = []string{"dumb://*"}
118
119// autoCopySelectionScript is injected to enable auto-copy on text selection (zellij-style).
120// It listens for selection changes, debounces them, and posts to the Go message handler.
121// Skips selections in input fields/textareas and requires minimum 2 characters.
122const autoCopySelectionScript = `(function() {
123  var debounceTimer = null;
124  var MIN_LENGTH = 2;
125  
126  document.addEventListener('selectionchange', function() {
127    // Skip if selection is inside input/textarea/contenteditable
128    var activeEl = document.activeElement;
129    if (activeEl && (activeEl.tagName === 'INPUT' || activeEl.tagName === 'TEXTAREA' || activeEl.isContentEditable)) {
130      return;
131    }
132    
133    clearTimeout(debounceTimer);
134    debounceTimer = setTimeout(function() {
135      var text = window.getSelection().toString().trim();
136      if (text.length >= MIN_LENGTH && window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) {
137        window.webkit.messageHandlers.dumber.postMessage({
138          type: 'auto_copy_selection',
139          payload: { text: text }
140        });
141      }
142    }, 300);
143  });
144})();`
145
146// accentDetectionScript is built at init from entity.AccentMap so the JS
147// filter stays in sync with the Go-side accent table.
148var accentDetectionScript string
149
150func init() {
151	// Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])"
152	keys := make([]string, 0, len(entity.AccentMap))
153	for k := range entity.AccentMap {
154		keys = append(keys, fmt.Sprintf("'%c'", k))
155	}
156	sort.Strings(keys)
157
158	accentDetectionScript = fmt.Sprintf(`(function() {
159    'use strict';
160    const accentKeys = new Set([%s]);
161    let pressedKey = null;
162
163    function isEditableTarget(target) {
164        const el = target instanceof Element ? target : null;
165        if (!el) return false;
166        if (el.closest('[contenteditable=""], [contenteditable="true"]')) return true;
167        if (el.closest('textarea')) return true;
168        const input = el.closest('input');
169        if (!input) return false;
170        return !/^(button|checkbox|color|file|hidden|image|radio|range|reset|submit)$/i.test(input.type);
171    }
172
173    document.addEventListener('keydown', function(e) {
174        if (!isEditableTarget(e.target)) return;
175        if (e.ctrlKey || e.altKey || e.metaKey) return;
176        const key = e.key.toLowerCase();
177        if (key.length !== 1 || !accentKeys.has(key)) return;
178        pressedKey = key;
179        window.webkit.messageHandlers.dumber.postMessage({
180            type: 'accent_key_press',
181            payload: { char: key, shift: e.shiftKey }
182        });
183    }, true);
184
185    document.addEventListener('keyup', function(e) {
186        if (!isEditableTarget(e.target)) return;
187        const key = e.key.toLowerCase();
188        if (key === pressedKey) {
189            pressedKey = null;
190            window.webkit.messageHandlers.dumber.postMessage({
191                type: 'accent_key_release',
192                payload: { char: key }
193            });
194        }
195    }, true);
196
197	    document.addEventListener('focusin', function(e) {
198	        if (isEditableTarget(e.target)) {
199	            window.__dumber_lastEditableEl = e.target;
200	        }
201	    }, true);
202})();`, strings.Join(keys, ","))
203}
204
205// accentDetectionInjectionMode controls which frames receive the accent detection script.
206// Set to AllFrames so the script runs in iframes as well as the top-level document.
207const accentDetectionInjectionMode = webkit.UserContentInjectAllFramesValue
208
209// webRTCCompatScript maps legacy Safari-prefixed WebRTC globals to standard names.
210// Some pages gate support on window.RTCPeerConnection and report false negatives
211// when only webkit-prefixed constructors are present.
212const webRTCCompatScript = `(function() {
213  if (!window.RTCPeerConnection && window.webkitRTCPeerConnection) {
214    window.RTCPeerConnection = window.webkitRTCPeerConnection;
215  }
216  if (!window.RTCSessionDescription && window.webkitRTCSessionDescription) {
217    window.RTCSessionDescription = window.webkitRTCSessionDescription;
218  }
219  if (!window.RTCIceCandidate && window.webkitRTCIceCandidate) {
220    window.RTCIceCandidate = window.webkitRTCIceCandidate;
221  }
222})();`
223
224func buildWebRTCCompatScript() string {
225	return webRTCCompatScript
226}
227
228// ContentInjector encapsulates script injection into WebViews.
229// It injects dark mode detection scripts for internal pages (dumb://)
230// and theme CSS variables for WebUI styling.
231// External pages receive dark mode preference via libadwaita's StyleManager.
232// Implements port.ContentInjector interface.
233type ContentInjector struct {
234	colorResolver        port.ColorSchemeResolver
235	themeCSSVars         string      // CSS custom property declarations for WebUI
236	findCSS              string      // CSS for find-in-page highlight styling
237	autoCopyConfigGetter func() bool // Dynamic getter for auto-copy config
238}
239
240// NewContentInjector creates a new injector instance.
241// The resolver is used to dynamically determine dark mode preference.
242func NewContentInjector(resolver port.ColorSchemeResolver) *ContentInjector {
243	return &ContentInjector{
244		colorResolver: resolver,
245	}
246}
247
248// SetAutoCopyConfigGetter sets the function to dynamically check if auto-copy is enabled.
249// This is called during script injection to determine whether to inject the selection listener.
250func (ci *ContentInjector) SetAutoCopyConfigGetter(getter func() bool) {
251	ci.autoCopyConfigGetter = getter
252}
253
254// InjectThemeCSS stores CSS variables for injection into internal pages.
255// Implements port.ContentInjector interface.
256// The CSS will be injected when InjectScripts is called on WebView creation.
257func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error {
258	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
259	ci.themeCSSVars = css
260	log.Debug().Int("css_len", len(css)).Msg("theme CSS vars set for injection")
261	return nil
262}
263
264// InjectFindHighlightCSS stores CSS for find-in-page highlight styling.
265func (ci *ContentInjector) InjectFindHighlightCSS(ctx context.Context, css string) error {
266	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
267	ci.findCSS = css
268	log.Debug().Int("css_len", len(css)).Msg("find highlight CSS set for injection")
269	return nil
270}
271
272// PrefersDark returns the current dark mode preference from the resolver.
273func (ci *ContentInjector) PrefersDark() bool {
274	return ci.colorResolver.Resolve().PrefersDark
275}
276
277// InjectScripts adds scripts to the given content manager.
278// For internal pages (dumb://*):
279//   - Injects dark mode detection (class on <html>, matchMedia patch)
280//   - Injects theme CSS variables for WebUI styling
281//
282// For external pages:
283//   - Only injects find highlight CSS
284//   - Dark mode is handled natively via libadwaita's StyleManager
285func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserContentManager, webviewID WebViewID) {
286	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
287
288	if ucm == nil {
289		log.Warn().Msg("cannot inject scripts: user content manager is nil")
290		return
291	}
292
293	addScript := func(script *webkit.UserScript, label string) {
294		if script == nil {
295			log.Warn().Str("script", label).Msg("failed to create user script")
296			return
297		}
298		ucm.AddScript(script)
299		log.Debug().Str("script", label).Msg("injected user script")
300	}
301
302	prefersDark := ci.PrefersDark()
303
304	// 1. Inject WebRTC compatibility aliases for all pages.
305	addScript(
306		webkit.NewUserScript(
307			buildWebRTCCompatScript(),
308			webkit.UserContentInjectTopFrameValue,
309			webkit.UserScriptInjectAtDocumentStartValue,
310			nil,
311			nil,
312		),
313		"webrtc-compat-shim",
314	)
315
316	// 2. Inject WebView ID for debugging (internal pages only)
317	if webviewID != 0 {
318		idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
319		addScript(
320			webkit.NewUserScript(
321				idScript,
322				webkit.UserContentInjectTopFrameValue,
323				webkit.UserScriptInjectAtDocumentStartValue,
324				internalPageAllowList,
325				nil,
326			),
327			"webview-id",
328		)
329		log.Debug().Uint64("webview_id", uint64(webviewID)).Msg("webview ID script injected")
330	} else {
331		log.Warn().Msg("webview ID is 0, skipping ID injection")
332	}
333
334	// 3. Inject dark mode handler for internal pages only
335	// This sets .dark/.light class on <html> and patches matchMedia for WebUI
336	internalDarkModeScript := fmt.Sprintf(internalDarkModeScriptTemplate, prefersDark)
337	addScript(
338		webkit.NewUserScript(
339			internalDarkModeScript,
340			webkit.UserContentInjectTopFrameValue,
341			webkit.UserScriptInjectAtDocumentStartValue,
342			internalPageAllowList,
343			nil,
344		),
345		"internal-dark-mode-handler",
346	)
347
348	// 4. Inject theme CSS for internal pages (dumb://* only)
349	if ci.themeCSSVars != "" {
350		// Escape for JS string literal
351		escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
352		escapedCSS = strings.ReplaceAll(escapedCSS, "'", "\\'")
353		escapedCSS = strings.ReplaceAll(escapedCSS, "\n", "\\n")
354		themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
355		addScript(
356			webkit.NewUserScript(
357				themeCSSInjectionScript,
358				webkit.UserContentInjectTopFrameValue,
359				webkit.UserScriptInjectAtDocumentEndValue,
360				internalPageAllowList,
361				nil,
362			),
363			"theme-css-vars",
364		)
365		log.Debug().Msg("theme CSS vars injection configured for internal pages")
366	}
367
368	// 5. Inject find highlight CSS for all pages
369	if ci.findCSS != "" {
370		stylesheet := webkit.NewUserStyleSheet(
371			ci.findCSS,
372			webkit.UserContentInjectAllFramesValue,
373			webkit.UserStyleLevelUserValue,
374			nil,
375			nil,
376		)
377		if stylesheet == nil {
378			log.Warn().Msg("failed to create find highlight stylesheet")
379		} else {
380			ucm.AddStyleSheet(stylesheet)
381			log.Debug().Msg("find highlight stylesheet injected")
382		}
383	}
384
385	// 6. Inject auto-copy selection script for all pages (if enabled)
386	autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
387	if autoCopyEnabled {
388		addScript(
389			webkit.NewUserScript(
390				autoCopySelectionScript,
391				webkit.UserContentInjectTopFrameValue,
392				webkit.UserScriptInjectAtDocumentEndValue,
393				nil, // All pages
394				nil,
395			),
396			"auto-copy-selection",
397		)
398		log.Debug().Msg("auto-copy selection script injected")
399	}
400
401	// 7. Inject accent key detection for all pages and all frames (unconditional).
402	// JS only reports keydown/keyup events; Go handles timing and picker display.
403	addScript(
404		webkit.NewUserScript(
405			accentDetectionScript,
406			accentDetectionInjectionMode,
407			webkit.UserScriptInjectAtDocumentEndValue,
408			nil, // all pages
409			nil,
410		),
411		"accent-key-detection",
412	)
413
414	log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected")
415}
416
417// RefreshScripts clears and re-injects user scripts for a single WebView.
418// Implements port.ContentInjector.
419// Returns nil for "not applicable" cases (nil wv, wrong engine type, nil UCM).
420func (ci *ContentInjector) RefreshScripts(ctx context.Context, wv port.WebView) error {
421	log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
422	if wv == nil {
423		log.Debug().Msg("RefreshScripts: nil webview")
424		return nil
425	}
426	wkWV, ok := wv.(*WebView)
427	if !ok {
428		log.Debug().Msg("RefreshScripts: webview is not *webkit.WebView")
429		return nil
430	}
431	ucm := wkWV.UserContentManager()
432	if ucm == nil {
433		log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("RefreshScripts: nil UserContentManager")
434		return nil
435	}
436	ucm.RemoveAllScripts()
437	ucm.RemoveAllStyleSheets()
438	ci.InjectScripts(ctx, ucm, wv.ID())
439	return nil
440}

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