main 5041353

injector.go RawHistoryFolder

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

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