v0.15.0 9006957

webview.go RawHistoryFolder

1package webkit
2
3import (
4	"encoding/json"
5	"fmt"
6	"strconv"
7	"sync"
8	"sync/atomic"
9
10	"github.com/bnema/dumber/internal/logging"
11	webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12	"github.com/diamondburned/gotk4/pkg/gdk/v4"
13	"github.com/diamondburned/gotk4/pkg/gtk/v4"
14)
15
16var (
17	viewIDCounter uint64
18	viewRegistry  = make(map[uint64]*WebView)
19	viewMu        sync.RWMutex
20)
21
22// WebView wraps a WebKitGTK WebView
23type WebView struct {
24	view      *webkit.WebView
25	container *gtk.Box // Container that holds the WebView
26	window    *Window  // Optional window (only if CreateWindow=true)
27	id        uint64
28
29	// State
30	config    *Config
31	destroyed bool
32	mu        sync.RWMutex
33
34	// Event handlers
35	onScriptMessage       func(string)
36	onTitleChanged        func(string)
37	onURIChanged          func(string)
38	onFaviconChanged      func([]byte)
39	onFaviconURIChanged   func(pageURI, faviconURI string)
40	onZoomChanged         func(float64)
41	onLoadCommitted       func(string)                            // Called when page load is committed (safe to apply zoom)
42	onLoadStarted         func()                                  // Called when a load starts
43	onLoadFinished        func()                                  // Called when a load finishes
44	onLoadProgress        func(float64)                           // Called when estimated-load-progress changes
45	onPopupCreate         func(*webkit.NavigationAction) *WebView // New WebKit create signal handler
46	onReadyToShow         func()                                  // WebKit ready-to-show signal handler
47	onClose               func()
48	onNavigationPolicy    func(url string, isUserGesture bool) bool
49	onWindowTypeDetected  func(WindowType, *WindowFeatures)
50	onWorkspaceNavigation func(direction string) bool // Workspace pane navigation
51	onPaneModeShortcut    func(action string) bool    // Pane mode shortcuts (enter, actions, exit)
52	isPaneModeActive      func() bool                 // Check if pane mode is active
53	onMiddleClickLink     func(url string) bool       // Middle mouse click on link handler
54}
55
56// NewWebView creates a new WebView with the given configuration
57func NewWebView(cfg *Config) (*WebView, error) {
58	if cfg == nil {
59		cfg = GetDefaultConfig()
60	}
61
62	// Generate unique ID immediately
63	id := atomic.AddUint64(&viewIDCounter, 1)
64	logging.Debug(fmt.Sprintf("[webkit] Generated WebView ID: %d (CreateWindow=%v)", id, cfg.CreateWindow))
65
66	InitMainThread()
67
68	// Initialize persistent session - REQUIRED, no ephemeral fallback
69	// This must be done BEFORE creating the WebView
70	if cfg.DataDir == "" || cfg.CacheDir == "" {
71		return nil, fmt.Errorf("data and cache directories are required for persistent storage")
72	}
73
74	if err := InitPersistentSession(cfg.DataDir, cfg.CacheDir); err != nil {
75		return nil, fmt.Errorf("failed to initialize persistent storage: %w", err)
76	}
77
78	// Create WebView - it will automatically use the persistent session
79	// Per WebKitGTK 6.0: newly created session becomes the default for all WebViews
80	wkView := webkit.NewWebView()
81	if wkView == nil {
82		return nil, ErrWebViewNotInitialized
83	}
84
85	// Set background color based on user's theme preference to prevent white/black flash
86	// This must be done before any content loads
87	var bg gdk.RGBA
88	if PrefersDarkTheme() {
89		bg = gdk.NewRGBA(0.11, 0.11, 0.11, 1.0) // #1c1c1c - dark background
90	} else {
91		bg = gdk.NewRGBA(0.96, 0.96, 0.96, 1.0) // #f5f5f5 - light background
92	}
93	wkView.SetBackgroundColor(&bg)
94
95	// Verify the WebView is using the persistent session
96	viewSession := wkView.NetworkSession()
97	if viewSession == nil {
98		return nil, fmt.Errorf("WebView has no network session")
99	}
100	if viewSession.IsEphemeral() {
101		return nil, fmt.Errorf("WebView is using ephemeral session despite persistent session initialization")
102	}
103
104	// CRITICAL: Configure CookieManager on the WebView's actual session
105	// This ensures cookies are persisted regardless of whether the WebView
106	// uses our global session or creates its own
107	cookieManager := viewSession.CookieManager()
108	if cookieManager == nil {
109		return nil, fmt.Errorf("failed to get cookie manager from WebView's network session")
110	}
111	cookiePath := cfg.DataDir + "/cookies.db"
112	cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqlite)
113	cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdParty)
114
115	// Setup UserContentManager and inject GUI scripts (including webview ID)
116	// This must be done BEFORE any pages are loaded
117	if err := SetupUserContentManager(wkView, cfg.AppearanceConfigJSON, id); err != nil {
118		return nil, fmt.Errorf("failed to setup user content manager: %w", err)
119	}
120
121	// Create container (GtkBox) to hold the WebView
122	// This allows the WebView to be reparented into workspace panes
123	container := gtk.NewBox(gtk.OrientationVertical, 0)
124	container.SetHExpand(true)
125	container.SetVExpand(true)
126
127	// Configure WebView widget for expansion
128	wkView.SetHExpand(true)
129	wkView.SetVExpand(true)
130
131	// Add WebView to container
132	container.Append(wkView)
133
134	wv := &WebView{
135		view:      wkView,
136		container: container,
137		window:    nil, // Will be set below if CreateWindow is true
138		id:        id,
139		config:    cfg,
140	}
141
142	// Apply configuration
143	if err := wv.applyConfig(); err != nil {
144		return nil, err
145	}
146
147	// Setup event handlers
148	wv.setupEventHandlers()
149
150	// Attach keyboard bridge to forward keyboard events to JavaScript
151	wv.AttachKeyboardBridge()
152
153	// Attach mouse gesture controls for hardware navigation buttons
154	wv.AttachMouseGestures()
155
156	// Attach touchpad gesture controls for swipe navigation
157	wv.AttachTouchpadGestures()
158
159	// Only create window if requested (standalone WebViews)
160	// Workspace panes will set CreateWindow=false and manage the container themselves
161	if cfg.CreateWindow {
162		window, err := NewWindow("Dumber Browser")
163		if err != nil {
164			return nil, err
165		}
166		wv.window = window
167		// Add container as child of window
168		window.SetChild(container)
169		logging.Debug(fmt.Sprintf("[webkit] Created standalone window for WebView ID %d", id))
170	} else {
171		logging.Debug(fmt.Sprintf("[webkit] Created WebView ID %d without window (for workspace embedding)", id))
172	}
173
174	// Register in global registry
175	viewMu.Lock()
176	viewRegistry[id] = wv
177	viewMu.Unlock()
178
179	return wv, nil
180}
181
182// applyConfig applies the configuration to the WebView settings
183func (w *WebView) applyConfig() error {
184	settings := w.view.Settings()
185	if settings == nil {
186		return fmt.Errorf("webkit: failed to get settings")
187	}
188
189	// Apply settings from config
190	settings.SetEnableJavascript(w.config.EnableJavaScript)
191	settings.SetEnableWebgl(w.config.EnableWebGL)
192	settings.SetDefaultFontSize(uint32(w.config.DefaultFontSize))
193	settings.SetMinimumFontSize(uint32(w.config.MinimumFontSize))
194
195	if w.config.UserAgent != "" {
196		settings.SetUserAgent(w.config.UserAgent)
197	}
198
199	// Enable developer tools (F12, inspector)
200	settings.SetEnableDeveloperExtras(true)
201
202	// Enable hardware acceleration if configured
203	settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlways)
204
205	// Performance optimizations for faster page transitions
206	// Enable page cache for instant back/forward navigation (bfcache)
207	settings.SetEnablePageCache(w.config.EnablePageCache)
208
209	// Enable smooth scrolling for better UX
210	settings.SetEnableSmoothScrolling(w.config.EnableSmoothScrolling)
211
212	// Disable console messages to stdout to prevent page script flooding
213	// This stops malicious/buggy pages from spamming the terminal with console.log()
214	// Inspector/DevTools console (F12) will still show all messages
215	settings.SetEnableWriteConsoleMessagesToStdout(false)
216
217	return nil
218}
219
220// setupEventHandlers connects GTK signals to internal handlers
221func (w *WebView) setupEventHandlers() {
222	// Title changed - connect to notify::title signal
223	w.view.Connect("notify::title", func() {
224		if w.onTitleChanged != nil {
225			title := w.view.Title()
226			w.onTitleChanged(title)
227		}
228	})
229
230	// URI changed - connect to notify::uri signal
231	w.view.Connect("notify::uri", func() {
232		if w.onURIChanged != nil {
233			uri := w.view.URI()
234			w.onURIChanged(uri)
235		}
236	})
237
238	// Zoom level changed - connect to notify::zoom-level signal
239	w.view.Connect("notify::zoom-level", func() {
240		if w.onZoomChanged != nil {
241			zoomLevel := w.view.ZoomLevel()
242			w.onZoomChanged(zoomLevel)
243		}
244	})
245
246	// Load committed - connect to load-changed signal for WEBKIT_LOAD_COMMITTED
247	w.view.ConnectLoadChanged(func(loadEvent webkit.LoadEvent) {
248		if loadEvent == webkit.LoadStarted && w.onLoadStarted != nil {
249			w.onLoadStarted()
250		}
251		if loadEvent == webkit.LoadCommitted && w.onLoadCommitted != nil {
252			uri := w.view.URI()
253			w.onLoadCommitted(uri)
254		}
255		if loadEvent == webkit.LoadFinished && w.onLoadFinished != nil {
256			w.onLoadFinished()
257		}
258	})
259
260	// Estimated load progress - notify::estimated-load-progress property
261	w.view.Connect("notify::estimated-load-progress", func() {
262		if w.onLoadProgress != nil {
263			w.onLoadProgress(w.view.EstimatedLoadProgress())
264		}
265	})
266
267	// Favicon changed - connect to FaviconDatabase
268	w.setupFaviconHandlers()
269
270	// TLS error handling - connect to load-failed-with-tls-errors signal
271	w.setupTLSErrorHandler()
272
273	// Close
274	w.view.ConnectClose(func() {
275		if w.onClose != nil {
276			w.onClose()
277		}
278	})
279
280	// Create signal - for popup lifecycle management
281	w.view.ConnectCreate(func(navigationAction *webkit.NavigationAction) gtk.Widgetter {
282		logging.Debug(fmt.Sprintf("[webkit] ConnectCreate callback fired for parent WebView ID=%d", w.id))
283		if w.onPopupCreate != nil {
284			logging.Debug(fmt.Sprintf("[webkit] Calling onPopupCreate handler"))
285			newWebView := w.onPopupCreate(navigationAction)
286			if newWebView != nil {
287				logging.Debug(fmt.Sprintf("[webkit] onPopupCreate returned WebView wrapper ID=%d", newWebView.id))
288				logging.Debug(fmt.Sprintf("[webkit] Extracting underlying gotk4 WebView from wrapper"))
289				underlyingView := newWebView.view
290				logging.Debug(fmt.Sprintf("[webkit] Extracted gotk4 WebView=%p, about to return to WebKit", underlyingView))
291				// Return the underlying WebView widget to WebKit
292				return underlyingView
293			}
294			logging.Debug(fmt.Sprintf("[webkit] onPopupCreate returned nil, blocking popup"))
295		}
296		// Return nil to cancel popup creation
297		logging.Debug(fmt.Sprintf("[webkit] No onPopupCreate handler, blocking popup"))
298		return nil
299	})
300
301	// Ready-to-show signal - emitted when popup is ready to be displayed
302	w.view.ConnectReadyToShow(func() {
303		if w.onReadyToShow != nil {
304			w.onReadyToShow()
305		}
306	})
307
308	// Script message received - connect to UserContentManager's script-message-received signal
309	// This receives messages from JavaScript via webkit.messageHandlers.dumber.postMessage()
310	// The signal name includes the handler name as a detail: "script-message-received::dumber"
311	ucm := w.view.UserContentManager()
312	if ucm != nil {
313		// GTK signals pass the sender as the first parameter, the actual signal data as subsequent parameters
314		ucm.Connect("script-message-received::dumber", func(sender interface{}, jscValue interface{}) {
315			if w.onScriptMessage != nil && jscValue != nil {
316				// Convert JSCValue to string using gotk4 javascriptcore bindings
317				valueStr := JSCValueToString(jscValue)
318				if valueStr != "" {
319					w.onScriptMessage(valueStr)
320				}
321			}
322		})
323	}
324
325	// Setup navigation policy handler for middle-click and Ctrl+click interception
326	w.setupNavigationPolicyHandler()
327}
328
329// setupNavigationPolicyHandler sets up the decide-policy signal handler
330// This intercepts navigation actions BEFORE they occur, allowing us to detect
331// middle-click and Ctrl+click on links and open them in new panes
332func (w *WebView) setupNavigationPolicyHandler() {
333	w.view.ConnectDecidePolicy(func(decision webkit.PolicyDecisioner, decisionType webkit.PolicyDecisionType) bool {
334		// Only handle navigation actions (not new window or response policies)
335		if decisionType != webkit.PolicyDecisionTypeNavigationAction {
336			return false // Let WebKit handle
337		}
338
339		// Cast to NavigationPolicyDecision to access NavigationAction
340		navDecision, ok := decision.(*webkit.NavigationPolicyDecision)
341		if !ok {
342			return false
343		}
344
345		navAction := navDecision.NavigationAction()
346		if navAction == nil {
347			return false
348		}
349
350		// Check if this is a link click
351		if navAction.NavigationType() != webkit.NavigationTypeLinkClicked {
352			return false // Not a link click, let WebKit handle normally
353		}
354
355		// Get mouse button and modifiers
356		mouseButton := navAction.MouseButton()
357		modifiers := navAction.Modifiers()
358
359		// Check for middle-click (button 2) or Ctrl+left-click (button 1 + Ctrl)
360		isMiddleClick := (mouseButton == 2)
361		isCtrlClick := (mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMask) != 0)
362
363		if !isMiddleClick && !isCtrlClick {
364			return false // Normal click, let WebKit handle
365		}
366
367		// Get the link URL
368		request := navAction.Request()
369		if request == nil {
370			return false
371		}
372
373		linkURL := request.URI()
374		if linkURL == "" {
375			return false
376		}
377
378		clickType := "middle-click"
379		if isCtrlClick {
380			clickType = "Ctrl+click"
381		}
382		logging.Debug(fmt.Sprintf("[navigation-policy] Detected %s on link: %s", clickType, linkURL))
383
384		// Call the registered handler
385		w.mu.RLock()
386		handler := w.onMiddleClickLink
387		w.mu.RUnlock()
388
389		if handler != nil {
390			handled := handler(linkURL)
391			if handled {
392				logging.Debug(fmt.Sprintf("[navigation-policy] %s handled by workspace, blocking navigation", clickType))
393				// Block the navigation by calling Ignore()
394				navDecision.Ignore()
395				return true // Prevent default behavior
396			}
397		}
398
399		return false // Let WebKit handle if not handled
400	})
401
402	logging.Debug(fmt.Sprintf("[webkit] Navigation policy handler attached to WebView ID %d", w.id))
403}
404
405// LoadURL loads the given URL in the WebView
406func (w *WebView) LoadURL(url string) error {
407	w.mu.RLock()
408	defer w.mu.RUnlock()
409
410	if w.destroyed {
411		return ErrWebViewDestroyed
412	}
413
414	if url == "" {
415		return ErrInvalidURL
416	}
417
418	w.view.LoadURI(url)
419	return nil
420}
421
422// GetCurrentURL returns the current URL
423func (w *WebView) GetCurrentURL() string {
424	w.mu.RLock()
425	defer w.mu.RUnlock()
426
427	if w.destroyed {
428		return ""
429	}
430
431	return w.view.URI()
432}
433
434// GetTitle returns the current page title
435func (w *WebView) GetTitle() string {
436	w.mu.RLock()
437	defer w.mu.RUnlock()
438
439	if w.destroyed {
440		return ""
441	}
442
443	return w.view.Title()
444}
445
446// GoBack navigates back in history
447func (w *WebView) GoBack() error {
448	w.mu.RLock()
449	defer w.mu.RUnlock()
450
451	if w.destroyed {
452		return ErrWebViewDestroyed
453	}
454
455	w.view.GoBack()
456	return nil
457}
458
459// GoForward navigates forward in history
460func (w *WebView) GoForward() error {
461	w.mu.RLock()
462	defer w.mu.RUnlock()
463
464	if w.destroyed {
465		return ErrWebViewDestroyed
466	}
467
468	w.view.GoForward()
469	return nil
470}
471
472// Reload reloads the current page
473func (w *WebView) Reload() error {
474	w.mu.RLock()
475	defer w.mu.RUnlock()
476
477	if w.destroyed {
478		return ErrWebViewDestroyed
479	}
480
481	w.view.Reload()
482	return nil
483}
484
485// ReloadBypassCache reloads the current page, bypassing cache
486func (w *WebView) ReloadBypassCache() error {
487	w.mu.RLock()
488	defer w.mu.RUnlock()
489
490	if w.destroyed {
491		return ErrWebViewDestroyed
492	}
493
494	w.view.ReloadBypassCache()
495	return nil
496}
497
498// Show makes the WebView visible
499func (w *WebView) Show() error {
500	w.mu.RLock()
501	defer w.mu.RUnlock()
502
503	if w.destroyed {
504		return ErrWebViewDestroyed
505	}
506
507	// Make the WebView widget visible
508	w.view.SetVisible(true)
509
510	// Show the window containing the WebView
511	if w.window != nil {
512		w.window.Show()
513	}
514
515	return nil
516}
517
518// Hide makes the WebView invisible
519func (w *WebView) Hide() error {
520	w.mu.RLock()
521	defer w.mu.RUnlock()
522
523	if w.destroyed {
524		return ErrWebViewDestroyed
525	}
526
527	w.view.SetVisible(false)
528	return nil
529}
530
531// Destroy destroys the WebView and releases resources
532func (w *WebView) Destroy() error {
533	// DO NOT acquire w.mu lock here to avoid deadlock!
534	// After GTK main loop exits, any goroutine holding the lock and calling GTK methods
535	// will block forever waiting for the main loop. This causes Destroy() to deadlock
536	// trying to acquire the same lock.
537	//
538	// During shutdown, thread safety is not critical since we're tearing everything down.
539
540	if w.destroyed {
541		return nil
542	}
543
544	w.destroyed = true
545
546	// Unregister from global registry
547	viewMu.Lock()
548	delete(viewRegistry, w.id)
549	viewMu.Unlock()
550
551	// DO NOT call StopLoading() or any other GTK methods here!
552	// After the main loop exits, GTK calls will block forever.
553	// The GTK widget will be cleaned up by Go GC.
554
555	return nil
556}
557
558// IsDestroyed returns true if the WebView has been destroyed
559func (w *WebView) IsDestroyed() bool {
560	// DO NOT acquire lock - read of bool is atomic on all platforms Go supports
561	// This prevents deadlock during shutdown when GTK signal handlers check IsDestroyed
562	return w.destroyed
563}
564
565// ID returns the unique identifier for this WebView
566func (w *WebView) ID() uint64 {
567	return w.id
568}
569
570// IDString returns the WebView ID as a string
571// This is a convenience method for compatibility with code expecting string IDs
572func (w *WebView) IDString() string {
573	return fmt.Sprintf("%d", w.id)
574}
575
576// ParseWebViewID converts a string WebView ID to uint64
577// Returns 0 if the string cannot be parsed
578func ParseWebViewID(idStr string) uint64 {
579	id, err := strconv.ParseUint(idStr, 10, 64)
580	if err != nil {
581		return 0
582	}
583	return id
584}
585
586// AsWidget returns the WebView as a gtk.Widgetter
587func (w *WebView) AsWidget() gtk.Widgetter {
588	if w == nil || w.view == nil {
589		return nil
590	}
591	return w.view
592}
593
594// Event handler registration methods
595
596// RegisterScriptMessageHandler registers a handler for script messages
597func (w *WebView) RegisterScriptMessageHandler(handler func(string)) {
598	w.mu.Lock()
599	defer w.mu.Unlock()
600	w.onScriptMessage = handler
601}
602
603// RegisterTitleChangedHandler registers a handler for title changes
604func (w *WebView) RegisterTitleChangedHandler(handler func(string)) {
605	w.mu.Lock()
606	defer w.mu.Unlock()
607	w.onTitleChanged = handler
608}
609
610// RegisterURIChangedHandler registers a handler for URI changes
611func (w *WebView) RegisterURIChangedHandler(handler func(string)) {
612	w.mu.Lock()
613	defer w.mu.Unlock()
614	w.onURIChanged = handler
615}
616
617// RegisterFaviconChangedHandler registers a handler for favicon changes
618func (w *WebView) RegisterFaviconChangedHandler(handler func([]byte)) {
619	w.mu.Lock()
620	defer w.mu.Unlock()
621	w.onFaviconChanged = handler
622}
623
624// RegisterFaviconURIChangedHandler registers a handler for favicon URI changes
625func (w *WebView) RegisterFaviconURIChangedHandler(handler func(pageURI, faviconURI string)) {
626	w.mu.Lock()
627	defer w.mu.Unlock()
628	w.onFaviconURIChanged = handler
629}
630
631// RegisterZoomChangedHandler registers a handler for zoom changes
632func (w *WebView) RegisterZoomChangedHandler(handler func(float64)) {
633	w.mu.Lock()
634	defer w.mu.Unlock()
635	w.onZoomChanged = handler
636}
637
638// RegisterLoadCommittedHandler registers a handler for load committed events
639// This fires when the page actually starts loading new content (after URI change)
640func (w *WebView) RegisterLoadCommittedHandler(handler func(string)) {
641	w.mu.Lock()
642	defer w.mu.Unlock()
643	w.onLoadCommitted = handler
644}
645
646// RegisterLoadStartedHandler registers a handler for load start events.
647func (w *WebView) RegisterLoadStartedHandler(handler func()) {
648	w.mu.Lock()
649	defer w.mu.Unlock()
650	w.onLoadStarted = handler
651}
652
653// RegisterLoadFinishedHandler registers a handler for load finished events.
654func (w *WebView) RegisterLoadFinishedHandler(handler func()) {
655	w.mu.Lock()
656	defer w.mu.Unlock()
657	w.onLoadFinished = handler
658}
659
660// RegisterLoadProgressHandler registers a handler for load progress updates (0.0 - 1.0).
661func (w *WebView) RegisterLoadProgressHandler(handler func(float64)) {
662	w.mu.Lock()
663	defer w.mu.Unlock()
664	w.onLoadProgress = handler
665}
666
667// RegisterCloseHandler registers a handler for close requests
668func (w *WebView) RegisterCloseHandler(handler func()) {
669	w.mu.Lock()
670	defer w.mu.Unlock()
671	w.onClose = handler
672}
673
674// RegisterPopupCreateHandler registers a handler for WebKit's create signal
675// This is called when JavaScript calls window.open() or a link with target="_blank" is clicked
676// The handler should create and return a new WebView for the popup, or return nil to block it
677// The returned WebView should NOT be shown yet - WebKit will emit ready-to-show when it's ready
678func (w *WebView) RegisterPopupCreateHandler(handler func(*webkit.NavigationAction) *WebView) {
679	w.mu.Lock()
680	defer w.mu.Unlock()
681	w.onPopupCreate = handler
682}
683
684// RegisterReadyToShowHandler registers a handler for WebKit's ready-to-show signal
685// This is called when a popup WebView is ready to be displayed
686// At this point it's safe to insert the popup into the UI (workspace, window, etc.)
687func (w *WebView) RegisterReadyToShowHandler(handler func()) {
688	w.mu.Lock()
689	defer w.mu.Unlock()
690	w.onReadyToShow = handler
691}
692
693// RegisterNavigationPolicyHandler registers a handler for navigation policy decisions
694func (w *WebView) RegisterNavigationPolicyHandler(handler func(url string, isUserGesture bool) bool) {
695	w.mu.Lock()
696	defer w.mu.Unlock()
697	w.onNavigationPolicy = handler
698}
699
700// OnWindowTypeDetected registers a handler for window type detection
701func (w *WebView) OnWindowTypeDetected(handler func(WindowType, *WindowFeatures)) {
702	w.mu.Lock()
703	defer w.mu.Unlock()
704	w.onWindowTypeDetected = handler
705}
706
707// RegisterMiddleClickLinkHandler registers a handler for middle mouse button clicks on links
708// The handler receives the link URL and returns true if handled
709func (w *WebView) RegisterMiddleClickLinkHandler(handler func(url string) bool) {
710	w.mu.Lock()
711	defer w.mu.Unlock()
712	w.onMiddleClickLink = handler
713}
714
715// RunOnMainThread executes a function on the GTK main thread
716func (w *WebView) RunOnMainThread(fn func()) {
717	RunOnMainThread(fn)
718}
719
720// GetWebView returns the underlying webkit.WebView for advanced operations
721func (w *WebView) GetWebView() *webkit.WebView {
722	return w.view
723}
724
725// Widget returns the WebView widget (for compatibility with old code expecting uintptr)
726// Deprecated: Use AsWidget() instead
727func (w *WebView) Widget() gtk.Widgetter {
728	return w.AsWidget()
729}
730
731// RootWidget returns the root container widget for this WebView
732// This is the GtkBox container that holds the WebView and can be reparented
733func (w *WebView) RootWidget() gtk.Widgetter {
734	if w.container != nil {
735		return w.container
736	}
737	// Fallback to WebView if container is not available (shouldn't happen)
738	return w.AsWidget()
739}
740
741// SetZoom sets the zoom level of the WebView
742func (w *WebView) SetZoom(zoom float64) error {
743	w.mu.RLock()
744	defer w.mu.RUnlock()
745
746	if w.destroyed {
747		return ErrWebViewDestroyed
748	}
749
750	w.view.SetZoomLevel(zoom)
751	return nil
752}
753
754// GetZoom returns the current zoom level
755func (w *WebView) GetZoom() float64 {
756	w.mu.RLock()
757	defer w.mu.RUnlock()
758
759	if w.destroyed {
760		return 1.0
761	}
762
763	return w.view.ZoomLevel()
764}
765
766// UsesDomZoom indicates if this WebView uses DOM-based zoom
767// In gotk4/WebKitGTK, zoom is always viewport-based
768func (w *WebView) UsesDomZoom() bool {
769	return false
770}
771
772// SeedDomZoom is a no-op in gotk4 as we use viewport zoom
773func (w *WebView) SeedDomZoom(zoom float64) error {
774	// Not needed in gotk4 - zoom is handled differently
775	return nil
776}
777
778// InjectScript executes JavaScript in the WebView
779func (w *WebView) InjectScript(script string) error {
780	w.mu.RLock()
781	defer w.mu.RUnlock()
782
783	if w.destroyed {
784		return ErrWebViewDestroyed
785	}
786
787	// Execute JavaScript using our CGO wrapper
788	EvaluateJavascript(w.view, script)
789	return nil
790}
791
792// DispatchCustomEvent dispatches a custom event via JavaScript
793// This works across JavaScript world boundaries because it uses document.dispatchEvent.
794// GUI scripts running in the isolated world (see DumberIsolatedWorld in user_content.go)
795// can listen for these events because they share the same Document object with the main world.
796func (w *WebView) DispatchCustomEvent(eventName string, data interface{}) error {
797	// Serialize data to JSON
798	jsonData, err := json.Marshal(data)
799	if err != nil {
800		return fmt.Errorf("failed to marshal event data: %w", err)
801	}
802
803	logging.Debug(fmt.Sprintf("[webkit] Dispatching event '%s' to WebView ID %d with data: %s", eventName, w.id, string(jsonData)))
804
805	// Use document.dispatchEvent (not window) so events cross JavaScript world boundaries
806	// The GUI scripts run in an isolated world but share the same Document object
807	script := fmt.Sprintf(`
808		(function() {
809			try {
810				var detail = %s;
811				console.log('[dumber] Dispatching event: %s', detail);
812				document.dispatchEvent(new CustomEvent('%s', { detail: detail }));
813			} catch (e) {
814				console.error('[dumber] Failed to dispatch %s', e);
815			}
816		})();
817	`, string(jsonData), eventName, eventName, eventName)
818	return w.InjectScript(script)
819}
820
821// ShowDevTools opens the WebKit inspector/developer tools
822func (w *WebView) ShowDevTools() error {
823	w.mu.RLock()
824	defer w.mu.RUnlock()
825
826	if w.destroyed {
827		return ErrWebViewDestroyed
828	}
829
830	inspector := w.view.Inspector()
831	if inspector != nil {
832		inspector.Show()
833	}
834	return nil
835}
836
837// ShowPrintDialog shows the print dialog for the current page
838func (w *WebView) ShowPrintDialog() error {
839	w.mu.RLock()
840	defer w.mu.RUnlock()
841
842	if w.destroyed {
843		return ErrWebViewDestroyed
844	}
845
846	// Create and run print operation with GTK print dialog
847	printOp := webkit.NewPrintOperation(w.view)
848	printOp.RunDialog(nil) // nil = no parent window, uses active window
849	return nil
850}
851
852// RegisterKeyboardShortcut registers a keyboard shortcut handler
853// This is a compatibility method - actual shortcut handling is done at the window level
854func (w *WebView) RegisterKeyboardShortcut(key string, modifiers uint, handler func()) error {
855	// TODO: Implement keyboard shortcut registration if needed
856	// For now, shortcuts are handled at the window/application level
857	return nil
858}
859
860// SetWindowFeatures sets window features for popup windows
861func (w *WebView) SetWindowFeatures(features *WindowFeatures) {
862	// This is typically used for popup windows
863	// The features would be applied when creating the window
864}
865
866// IsActive returns whether this WebView is currently active/focused
867func (w *WebView) IsActive() bool {
868	w.mu.RLock()
869	defer w.mu.RUnlock()
870
871	if w.destroyed {
872		return false
873	}
874
875	widget := w.view
876	if widget != nil {
877		return widget.IsFocus()
878	}
879	return false
880}
881
882// Window returns the parent Window of this WebView
883func (w *WebView) Window() *Window {
884	w.mu.RLock()
885	defer w.mu.RUnlock()
886
887	if w.destroyed {
888		return nil
889	}
890
891	return w.window
892}
893
894// GetFaviconDatabase returns the WebKit FaviconDatabase for this WebView
895// Returns nil if the favicon database is not available
896func (w *WebView) GetFaviconDatabase() *webkit.FaviconDatabase {
897	w.mu.RLock()
898	defer w.mu.RUnlock()
899
900	if w.destroyed || w.view == nil {
901		return nil
902	}
903
904	session := w.view.NetworkSession()
905	if session == nil {
906		return nil
907	}
908
909	dataManager := session.WebsiteDataManager()
910	if dataManager == nil {
911		return nil
912	}
913
914	return dataManager.FaviconDatabase()
915}
916
917// UpdateContentFilters updates the content filtering rules
918func (w *WebView) UpdateContentFilters(rules string) error {
919	w.mu.RLock()
920	defer w.mu.RUnlock()
921
922	if w.destroyed {
923		return ErrWebViewDestroyed
924	}
925
926	// Note: Content filtering is handled via InitializeContentBlocking()
927	// This method is kept for backward compatibility but is deprecated
928	logging.Warn(fmt.Sprintf("[webkit] UpdateContentFilters called but is deprecated - use InitializeContentBlocking instead"))
929	return nil
930}
931
932// InitializeContentBlocking initializes content blocking with filter lists
933func (w *WebView) InitializeContentBlocking(filterJSON []byte) error {
934	w.mu.RLock()
935	defer w.mu.RUnlock()
936
937	if w.destroyed {
938		return ErrWebViewDestroyed
939	}
940
941	if len(filterJSON) == 0 {
942		return fmt.Errorf("no filter rules provided")
943	}
944
945	// Apply filters using the content blocking API
946	return ApplyFiltersToWebView(w, filterJSON)
947}
948
949// OnNavigate registers a navigation handler
950func (w *WebView) OnNavigate(handler func(url string)) {
951	w.mu.Lock()
952	defer w.mu.Unlock()
953
954	// This wraps the URI changed handler
955	w.onURIChanged = handler
956}
957
958// RegisterWorkspaceNavigationHandler registers a handler for workspace pane navigation
959// The handler receives the direction ("up", "down", "left", "right") and returns true if handled
960func (w *WebView) RegisterWorkspaceNavigationHandler(handler func(direction string) bool) {
961	w.mu.Lock()
962	defer w.mu.Unlock()
963	w.onWorkspaceNavigation = handler
964}
965
966// RegisterPaneModeHandler registers a handler for pane mode shortcuts
967// The handler receives the action ("enter", "close", "split-right", etc.) and returns true if handled
968func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isActiveChecker func() bool) {
969	w.mu.Lock()
970	defer w.mu.Unlock()
971	w.onPaneModeShortcut = handler
972	w.isPaneModeActive = isActiveChecker
973}
974
975// setupFaviconHandlers enables favicons for this WebView
976// Note: The actual favicon-changed handler is registered ONCE at the FaviconService level
977// to avoid duplicate handlers when multiple WebViews exist
978func (w *WebView) setupFaviconHandlers() {
979	// Get the NetworkSession from the WebView
980	session := w.view.NetworkSession()
981	if session == nil {
982		logging.Warn(fmt.Sprintf("[webkit] Warning: No NetworkSession available for favicon handling"))
983		return
984	}
985
986	// Get the WebsiteDataManager from the NetworkSession
987	dataManager := session.WebsiteDataManager()
988	if dataManager == nil {
989		logging.Warn(fmt.Sprintf("[webkit] Warning: No WebsiteDataManager available for favicon handling"))
990		return
991	}
992
993	// Enable favicons if not already enabled
994	if !dataManager.FaviconsEnabled() {
995		dataManager.SetFaviconsEnabled(true)
996		logging.Debug(fmt.Sprintf("[webkit] Enabled favicons for WebView ID %d", w.id))
997	}
998
999}
1000
1001// GtkWebView returns the underlying gotk4 WebView for advanced operations
1002// This is used when creating related views (popups) that need to share the same session
1003func (w *WebView) GtkWebView() *webkit.WebView {
1004	w.mu.RLock()
1005	defer w.mu.RUnlock()
1006	return w.view
1007}
1008
1009// WrapBareWebView creates a minimal WebView wrapper around a bare gotk4 WebView
1010// This is used during popup creation to return a WebView to WebKit before initialization
1011// Full initialization (container, scripts, event handlers) should be done later
1012func WrapBareWebView(bareView *webkit.WebView) *WebView {
1013	if bareView == nil {
1014		return nil
1015	}
1016
1017	// Generate unique ID
1018	id := atomic.AddUint64(&viewIDCounter, 1)
1019	logging.Debug(fmt.Sprintf("[webkit] Created minimal wrapper for bare WebView (ID: %d)", id))
1020
1021	// Create minimal wrapper - NO initialization yet
1022	wv := &WebView{
1023		view:      bareView,
1024		container: nil, // Will be created during full initialization
1025		window:    nil,
1026		id:        id,
1027		config:    nil, // Will be set during full initialization
1028	}
1029
1030	// Register in global registry
1031	viewMu.Lock()
1032	viewRegistry[id] = wv
1033	viewMu.Unlock()
1034
1035	return wv
1036}
1037
1038// InitializeFromBare completes initialization of a bare WebView wrapper
1039// This should be called in ready-to-show after WebKit has configured the view
1040func (w *WebView) InitializeFromBare(cfg *Config) error {
1041	w.mu.Lock()
1042	defer w.mu.Unlock()
1043
1044	if w.config != nil {
1045		return fmt.Errorf("WebView already initialized")
1046	}
1047
1048	if cfg == nil {
1049		cfg = GetDefaultConfig()
1050	}
1051
1052	w.config = cfg
1053
1054	// Verify persistent session
1055	viewSession := w.view.NetworkSession()
1056	if viewSession == nil {
1057		return fmt.Errorf("WebView has no network session")
1058	}
1059
1060	// Setup UserContentManager and inject GUI scripts
1061	if err := SetupUserContentManager(w.view, cfg.AppearanceConfigJSON, w.id); err != nil {
1062		return fmt.Errorf("failed to setup user content manager: %w", err)
1063	}
1064
1065	// Create container (GtkBox) to hold the WebView
1066	container := gtk.NewBox(gtk.OrientationVertical, 0)
1067	container.SetHExpand(true)
1068	container.SetVExpand(true)
1069
1070	// Configure WebView widget for expansion
1071	w.view.SetHExpand(true)
1072	w.view.SetVExpand(true)
1073
1074	// Add WebView to container
1075	container.Append(w.view)
1076
1077	w.container = container
1078
1079	// Apply configuration
1080	if err := w.applyConfig(); err != nil {
1081		return err
1082	}
1083
1084	// Setup event handlers
1085	w.setupEventHandlers()
1086
1087	// Attach keyboard bridge
1088	w.AttachKeyboardBridge()
1089
1090	// Attach mouse gesture controls
1091	w.AttachMouseGestures()
1092
1093	// Attach touchpad gesture controls
1094	w.AttachTouchpadGestures()
1095
1096	logging.Debug(fmt.Sprintf("[webkit] Completed initialization for WebView ID %d", w.id))
1097
1098	return nil
1099}

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