v0.29.0 21b3150

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

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