v0.14.0 76d1892

webview.go RawHistoryFolder

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

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