v0.27.0 dcb4c51

content.go RawHistoryFolder

1package coordinator
2
3import (
4	"context"
5	"encoding/json"
6	"fmt"
7	"net/url"
8	"strings"
9	"sync"
10	"time"
11
12	"github.com/bnema/dumber/internal/application/port"
13	"github.com/bnema/dumber/internal/application/usecase"
14	"github.com/bnema/dumber/internal/domain/entity"
15	urlutil "github.com/bnema/dumber/internal/domain/url"
16	"github.com/bnema/dumber/internal/infrastructure/config"
17	"github.com/bnema/dumber/internal/infrastructure/desktop"
18	"github.com/bnema/dumber/internal/infrastructure/webkit"
19	"github.com/bnema/dumber/internal/logging"
20	"github.com/bnema/dumber/internal/ui/adapter"
21	"github.com/bnema/dumber/internal/ui/component"
22	"github.com/bnema/dumber/internal/ui/input"
23	"github.com/bnema/dumber/internal/ui/layout"
24	webkitlib "github.com/bnema/puregotk-webkit/webkit"
25	"github.com/jwijenbergh/puregotk/v4/gdk"
26	"github.com/jwijenbergh/puregotk/v4/glib"
27)
28
29const (
30	aboutBlankURI              = "about:blank"
31	crashPageURI               = "dumb://home/crash"
32	logURLMaxLen               = 80
33	oauthParentRefreshDebounce = 200 * time.Millisecond
34
35	// Dark theme background color (#0a0a0b) as float32 RGBA values
36	darkBgR = 0.039
37	darkBgG = 0.039
38	darkBgB = 0.043
39	darkBgA = 1.0
40)
41
42func shouldRenderCrashPage(reason webkitlib.WebProcessTerminationReason) bool {
43	switch reason {
44	case webkitlib.WebProcessCrashedValue, webkitlib.WebProcessExceededMemoryLimitValue:
45		return true
46	case webkitlib.WebProcessTerminatedByApiValue:
47		return false
48	default:
49		return true
50	}
51}
52
53func extractOriginalURIFromCrashPage(uri string) string {
54	if uri == "" {
55		return ""
56	}
57
58	parsed, err := url.Parse(uri)
59	if err != nil {
60		return uri
61	}
62
63	if parsed.Scheme != "dumb" || parsed.Host != webkit.HomePath {
64		return uri
65	}
66
67	if strings.Trim(parsed.Path, "/") != "crash" {
68		return uri
69	}
70
71	original := strings.TrimSpace(parsed.Query().Get("url"))
72	if original == "" {
73		return ""
74	}
75	return original
76}
77
78func buildCrashPageURI(originalURI string) string {
79	if strings.TrimSpace(originalURI) == "" {
80		return crashPageURI
81	}
82	query := url.Values{}
83	query.Set("url", originalURI)
84	return crashPageURI + "?" + query.Encode()
85}
86
87// ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
88type ContentCoordinator struct {
89	pool           *webkit.WebViewPool
90	widgetFactory  layout.WidgetFactory
91	faviconAdapter *adapter.FaviconAdapter
92	zoomUC         *usecase.ManageZoomUseCase
93	permissionUC   *usecase.HandlePermissionUseCase
94	injector       *webkit.ContentInjector
95
96	webViews   map[entity.PaneID]*webkit.WebView
97	webViewsMu sync.RWMutex
98
99	activePaneOverride   entity.PaneID
100	activePaneOverrideMu sync.RWMutex
101
102	paneTitles map[entity.PaneID]string
103	titleMu    sync.RWMutex
104
105	// Track original navigation URLs to handle cross-domain redirects
106	// e.g., google.fr → google.com: cache favicon under both domains
107	navOrigins  map[entity.PaneID]string
108	navOriginMu sync.RWMutex
109
110	// Callback to get active workspace state (avoids circular dependency)
111	getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
112
113	// Callback when title changes (for history persistence)
114	onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
115
116	// Callback when page is committed (for history recording)
117	onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
118
119	// Callback when pane URI changes (for session snapshots)
120	onPaneURIUpdated func(paneID entity.PaneID, url string)
121
122	// Callback when active pane title changes (for window title updates)
123	onWindowTitleChanged func(title string)
124
125	// Callback when media permission activity changes (requesting/allowed/blocked).
126	onPermissionActivity func(origin string, permTypes []entity.PermissionType, state PermissionActivityState)
127
128	// Callback when the active pane commits a navigation (new page loading).
129	onActiveNavigationCommitted func(uri string)
130
131	// Callback when the WebView becomes visible (first real commit)
132	onWebViewShown func(paneID entity.PaneID)
133
134	revealMu      sync.Mutex
135	pendingReveal map[entity.PaneID]bool
136
137	appearanceMu          sync.Mutex
138	pendingScriptRefresh  map[entity.PaneID]bool
139	pendingThemePanes     map[entity.PaneID]bool
140	pendingThemeUpdate    pendingThemeUpdate
141	hasPendingThemeUpdate bool
142	currentTheme          pendingThemeUpdate
143	hasCurrentTheme       bool
144
145	// Gesture action handler for mouse button navigation
146	gestureActionHandler input.ActionHandler
147
148	// Popup handling
149	factory       *webkit.WebViewFactory
150	popupConfig   *config.PopupBehaviorConfig
151	pendingPopups map[port.WebViewID]*PendingPopup
152	popupOAuth    map[port.WebViewID]*popupOAuthState
153	popupRefresh  map[entity.PaneID]*time.Timer
154	popupMu       sync.RWMutex
155
156	// Callback to insert popup into workspace (avoids circular dependency)
157	onInsertPopup func(ctx context.Context, input InsertPopupInput) error
158
159	// Callback to close a pane when popup closes
160	onClosePane func(ctx context.Context, paneID entity.PaneID) error
161
162	// ID generator for popup panes
163	generateID func() string
164
165	// Idle inhibitor for fullscreen video playback
166	idleInhibitor port.IdleInhibitor
167
168	// Callback when fullscreen state changes (for hiding/showing tab bar)
169	onFullscreenChanged func(entering bool)
170
171	// Callback when WebView gains focus (for accent picker text input targeting)
172	onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView)
173
174	// Callback for first load_started event (triggers deferred initialization)
175	onFirstLoadStarted func()
176	loadStartedOnce    sync.Once
177}
178
179type pendingThemeUpdate struct {
180	prefersDark bool
181	cssText     string
182}
183
184type popupOAuthState struct {
185	ParentPaneID entity.PaneID
186	CallbackURI  string
187	Success      bool
188	Error        bool
189	Seen         bool
190}
191
192// PermissionActivityState represents the visible state for media permission activity.
193type PermissionActivityState string
194
195const (
196	PermissionActivityRequesting PermissionActivityState = "requesting"
197	PermissionActivityAllowed    PermissionActivityState = "allowed"
198	PermissionActivityBlocked    PermissionActivityState = "blocked"
199)
200
201// NewContentCoordinator creates a new ContentCoordinator.
202func NewContentCoordinator(
203	ctx context.Context,
204	pool *webkit.WebViewPool,
205	widgetFactory layout.WidgetFactory,
206	faviconAdapter *adapter.FaviconAdapter,
207	getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
208	zoomUC *usecase.ManageZoomUseCase,
209	permissionUC *usecase.HandlePermissionUseCase,
210) *ContentCoordinator {
211	log := logging.FromContext(ctx)
212	log.Debug().Msg("creating content coordinator")
213
214	return &ContentCoordinator{
215		pool:           pool,
216		widgetFactory:  widgetFactory,
217		faviconAdapter: faviconAdapter,
218		zoomUC:         zoomUC,
219		permissionUC:   permissionUC,
220		webViews:       make(map[entity.PaneID]*webkit.WebView),
221		paneTitles:     make(map[entity.PaneID]string),
222		navOrigins:     make(map[entity.PaneID]string),
223		pendingReveal:  make(map[entity.PaneID]bool),
224		getActiveWS:    getActiveWS,
225		pendingPopups:  make(map[port.WebViewID]*PendingPopup),
226		popupOAuth:     make(map[port.WebViewID]*popupOAuthState),
227		popupRefresh:   make(map[entity.PaneID]*time.Timer),
228	}
229}
230
231// SetOnTitleUpdated sets the callback for title changes (for history persistence).
232func (c *ContentCoordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
233	c.onTitleUpdated = fn
234}
235
236// SetOnHistoryRecord sets the callback for recording history on page commit.
237func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
238	c.onHistoryRecord = fn
239}
240
241// SetOnPermissionActivity sets a callback for WebRTC permission activity changes.
242func (c *ContentCoordinator) SetOnPermissionActivity(
243	fn func(origin string, permTypes []entity.PermissionType, state PermissionActivityState),
244) {
245	c.onPermissionActivity = fn
246}
247
248// SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
249func (c *ContentCoordinator) SetOnActiveNavigationCommitted(fn func(uri string)) {
250	c.onActiveNavigationCommitted = fn
251}
252
253// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
254func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
255	c.onPaneURIUpdated = fn
256}
257
258// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
259func (c *ContentCoordinator) SetOnWindowTitleChanged(fn func(title string)) {
260	c.onWindowTitleChanged = fn
261}
262
263// SetOnWebViewShown sets a callback that fires when a pane's WebView is shown.
264func (c *ContentCoordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) {
265	c.onWebViewShown = fn
266}
267
268// SetGestureActionHandler sets the callback for mouse button navigation gestures.
269func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
270	c.gestureActionHandler = handler
271}
272
273// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
274func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
275	c.idleInhibitor = inhibitor
276}
277
278// SetOnFullscreenChanged sets the callback for fullscreen state changes.
279func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) {
280	c.onFullscreenChanged = fn
281}
282
283// SetOnWebViewFocused sets the callback for when a WebView gains focus.
284func (c *ContentCoordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv *webkit.WebView)) {
285	c.onWebViewFocused = fn
286}
287
288// SetOnFirstLoadStarted sets the callback for when the first navigation starts.
289// This is used to trigger deferred initialization after the initial load_uri()
290// has been processed by the GTK main loop.
291func (c *ContentCoordinator) SetOnFirstLoadStarted(fn func()) {
292	c.onFirstLoadStarted = fn
293}
294
295// EnsureWebView acquires or reuses a WebView for the given pane.
296func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
297	log := logging.FromContext(ctx)
298
299	if wv := c.getWebViewLocked(paneID); wv != nil && !wv.IsDestroyed() {
300		return wv, nil
301	}
302
303	if c.pool == nil {
304		return nil, fmt.Errorf("webview pool not configured")
305	}
306
307	// Mark tab_created on first webview (first tab)
308	if c.webViewCount() == 0 {
309		logging.Trace().Mark("tab_created")
310	}
311
312	wv, err := c.pool.Acquire(ctx)
313	if err != nil {
314		return nil, err
315	}
316	logging.Trace().Mark("webview_acquired")
317
318	c.setWebViewLocked(paneID, wv)
319	c.setupWebViewCallbacks(ctx, paneID, wv)
320
321	log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
322	return wv, nil
323}
324
325// ReleaseWebView returns the WebView for a pane to the pool.
326func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
327	log := logging.FromContext(ctx)
328
329	wv := c.deleteWebViewLocked(paneID)
330	if wv == nil {
331		return
332	}
333	c.clearPendingAppearance(paneID)
334
335	// CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
336	// we must release the inhibition before destroying the webview.
337	// Otherwise the D-Bus inhibit request stays active forever.
338	if c.idleInhibitor != nil {
339		if wv.IsFullscreen() {
340			log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was fullscreen)")
341			if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
342				log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (fullscreen)")
343			}
344		}
345		if wv.IsPlayingAudio() {
346			log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was playing audio)")
347			if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
348				log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (audio)")
349			}
350		}
351	}
352
353	// Clean up title tracking
354	c.titleMu.Lock()
355	delete(c.paneTitles, paneID)
356	c.titleMu.Unlock()
357
358	// Clean up navigation origin tracking
359	c.navOriginMu.Lock()
360	delete(c.navOrigins, paneID)
361	c.navOriginMu.Unlock()
362
363	if c.pool != nil {
364		c.pool.Release(ctx, wv)
365	} else {
366		wv.Destroy()
367	}
368
369	log.Debug().Str("pane_id", string(paneID)).Msg("webview released")
370}
371
372// AttachToWorkspace ensures each pane in the workspace has a WebView widget attached.
373func (c *ContentCoordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
374	log := logging.FromContext(ctx)
375
376	if ws == nil || wsView == nil || c.widgetFactory == nil {
377		return
378	}
379
380	for _, pane := range ws.AllPanes() {
381		if pane == nil {
382			continue
383		}
384
385		wv, err := c.EnsureWebView(ctx, pane.ID)
386		if err != nil {
387			log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
388			continue
389		}
390
391		// Load the pane's URI if set and different from current
392		if pane.URI != "" && pane.URI != wv.URI() {
393			if err := wv.LoadURI(ctx, pane.URI); err != nil {
394				log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
395			}
396		}
397
398		widget := c.WrapWidget(ctx, wv)
399		if widget == nil {
400			continue
401		}
402
403		if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
404			log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
405		}
406		logging.Trace().Mark("webview_attached")
407	}
408}
409
410// WrapWidget converts a WebView to a layout.Widget for embedding.
411// It also attaches gesture handlers for mouse button navigation.
412func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) layout.Widget {
413	log := logging.FromContext(ctx)
414
415	if wv == nil || c.widgetFactory == nil {
416		log.Debug().Msg("cannot wrap nil webview or factory")
417		return nil
418	}
419
420	gtkView := wv.Widget()
421	if gtkView == nil {
422		return nil
423	}
424
425	widget := c.widgetFactory.WrapWidget(&gtkView.Widget)
426
427	// Attach gesture handler for mouse button 8/9 navigation
428	if widget != nil {
429		gestureHandler := input.NewGestureHandler(ctx)
430		// Pass WebView directly to preserve user gesture context (like Epiphany)
431		gestureHandler.SetNavigator(wv)
432		// Keep callback as fallback
433		if c.gestureActionHandler != nil {
434			gestureHandler.SetOnAction(c.gestureActionHandler)
435		}
436		gestureHandler.AttachTo(widget.GtkWidget())
437		log.Debug().Msg("gesture handler attached to webview with direct navigator")
438	}
439
440	return widget
441}
442
443// ActiveWebView returns the WebView for the active pane.
444func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
445	log := logging.FromContext(ctx)
446
447	if paneID, ok := c.activePaneOverrideID(); ok {
448		wv := c.getWebViewLocked(paneID)
449		if wv != nil {
450			return wv
451		}
452		log.Debug().Str("pane_id", string(paneID)).Msg("active pane override has no webview, falling back")
453	}
454	if c.getActiveWS == nil {
455		log.Debug().Msg("no active workspace resolver")
456		return nil
457	}
458
459	ws, _ := c.getActiveWS()
460	if ws == nil {
461		log.Debug().Msg("no active workspace")
462		return nil
463	}
464
465	pane := ws.ActivePane()
466	if pane == nil || pane.Pane == nil {
467		log.Debug().Msg("no active pane")
468		return nil
469	}
470
471	return c.getWebViewLocked(pane.Pane.ID)
472}
473
474// ActivePaneID returns the currently active pane ID used by navigation.
475func (c *ContentCoordinator) ActivePaneID(ctx context.Context) entity.PaneID {
476	if paneID, ok := c.activePaneOverrideID(); ok {
477		return paneID
478	}
479	if c.getActiveWS == nil {
480		return ""
481	}
482
483	ws, _ := c.getActiveWS()
484	if ws == nil {
485		return ""
486	}
487	return ws.ActivePaneID
488}
489
490// SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane.
491func (c *ContentCoordinator) SetActivePaneOverride(paneID entity.PaneID) {
492	c.activePaneOverrideMu.Lock()
493	c.activePaneOverride = paneID
494	c.activePaneOverrideMu.Unlock()
495}
496
497// ClearActivePaneOverride removes any forced active pane override.
498func (c *ContentCoordinator) ClearActivePaneOverride() {
499	c.activePaneOverrideMu.Lock()
500	c.activePaneOverride = ""
501	c.activePaneOverrideMu.Unlock()
502}
503
504func (c *ContentCoordinator) activePaneOverrideID() (entity.PaneID, bool) {
505	c.activePaneOverrideMu.RLock()
506	defer c.activePaneOverrideMu.RUnlock()
507	if c.activePaneOverride == "" {
508		return "", false
509	}
510	return c.activePaneOverride, true
511}
512
513// GetWebView returns the WebView for a specific pane.
514func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
515	return c.getWebViewLocked(paneID)
516}
517
518// RegisterPopupWebView registers a popup WebView that was created externally.
519// This is used when popup tabs are created and the WebView needs to be tracked.
520func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {
521	if wv != nil && paneID != "" {
522		c.setWebViewLocked(paneID, wv)
523	}
524}
525
526func (c *ContentCoordinator) getWebViewLocked(paneID entity.PaneID) *webkit.WebView {
527	c.webViewsMu.RLock()
528	defer c.webViewsMu.RUnlock()
529	return c.webViews[paneID]
530}
531
532func (c *ContentCoordinator) setWebViewLocked(paneID entity.PaneID, wv *webkit.WebView) {
533	c.webViewsMu.Lock()
534	c.webViews[paneID] = wv
535	c.webViewsMu.Unlock()
536}
537
538func (c *ContentCoordinator) deleteWebViewLocked(paneID entity.PaneID) *webkit.WebView {
539	c.webViewsMu.Lock()
540	defer c.webViewsMu.Unlock()
541	wv := c.webViews[paneID]
542	delete(c.webViews, paneID)
543	return wv
544}
545
546func (c *ContentCoordinator) webViewCount() int {
547	c.webViewsMu.RLock()
548	defer c.webViewsMu.RUnlock()
549	return len(c.webViews)
550}
551
552func (c *ContentCoordinator) snapshotWebViews() map[entity.PaneID]*webkit.WebView {
553	c.webViewsMu.RLock()
554	defer c.webViewsMu.RUnlock()
555	snapshot := make(map[entity.PaneID]*webkit.WebView, len(c.webViews))
556	for paneID, wv := range c.webViews {
557		snapshot[paneID] = wv
558	}
559	return snapshot
560}
561
562// ApplyFiltersToAll applies content filters to all active webviews.
563// Called when filters become available after webviews were already created.
564func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) {
565	log := logging.FromContext(ctx)
566
567	for paneID, wv := range c.snapshotWebViews() {
568		if wv != nil && !wv.IsDestroyed() {
569			applier.ApplyTo(ctx, wv.UserContentManager())
570			log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview")
571		}
572	}
573}
574
575// ApplySettingsToAll reapplies WebKit settings to all active WebViews.
576func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.SettingsManager) {
577	log := logging.FromContext(ctx)
578	if sm == nil {
579		return
580	}
581
582	for paneID, wv := range c.snapshotWebViews() {
583		if wv == nil || wv.IsDestroyed() {
584			continue
585		}
586		sm.ApplyToWebView(ctx, wv.Widget())
587		log.Debug().Str("pane_id", string(paneID)).Msg("reapplied settings to webview")
588	}
589}
590
591// RefreshInjectedScriptsToAll clears and re-injects user scripts into all active WebViews.
592//
593// WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
594// appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
595// the scripts so future navigations pick up the latest values.
596// Script refresh is deferred for any WebView that is currently loading to avoid
597// removing scripts mid-navigation.
598func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
599	log := logging.FromContext(ctx)
600	if injector == nil {
601		return
602	}
603
604	c.injector = injector
605	for paneID, wv := range c.webViews {
606		if wv == nil || wv.IsDestroyed() {
607			continue
608		}
609		if c.shouldDeferAppearance(wv) {
610			c.queueScriptRefresh(paneID)
611			log.Debug().Str("pane_id", string(paneID)).Msg("deferred script refresh until load finished")
612			continue
613		}
614
615		c.refreshInjectedScripts(ctx, injector, paneID, wv)
616	}
617}
618
619// ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages.
620// This is necessary because user scripts only run on navigation.
621func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) {
622	log := logging.FromContext(ctx)
623	if cssText == "" {
624		return
625	}
626
627	c.setCurrentTheme(prefersDark, cssText)
628
629	script, err := buildWebUIThemeScript(prefersDark, cssText)
630	if err != nil {
631		log.Warn().Err(err).Msg("failed to build WebUI theme script")
632		return
633	}
634
635	for paneID, wv := range c.webViews {
636		if wv == nil || wv.IsDestroyed() {
637			continue
638		}
639		if c.shouldDeferAppearance(wv) {
640			c.queueThemeApply(paneID, prefersDark, cssText)
641			log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
642			continue
643		}
644		c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
645	}
646}
647
648func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
649	cssJSON, err := json.Marshal(cssText)
650	if err != nil {
651		return "", err
652	}
653
654	script := fmt.Sprintf(`(function(){
655  try {
656    var cssText = %s;
657    var prefersDark = %t;
658
659    // Keep the global flag in sync
660    window.__dumber_gtk_prefers_dark = prefersDark;
661
662    // Update dark/light class
663    if (prefersDark) {
664      document.documentElement.classList.add('dark');
665      document.documentElement.classList.remove('light');
666    } else {
667      document.documentElement.classList.add('light');
668      document.documentElement.classList.remove('dark');
669    }
670
671    // Update or insert theme style
672    var style = document.querySelector('style[data-dumber-theme-vars]');
673    if (!style) {
674      style = document.createElement('style');
675      style.setAttribute('data-dumber-theme-vars', '');
676      (document.head || document.documentElement).appendChild(style);
677    }
678    style.textContent = cssText;
679
680    // Notify any running WebUI that theme changed
681    try {
682      window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
683        detail: { prefersDark: prefersDark }
684      }));
685    } catch (e) {
686      // ignore
687    }
688
689    // Keep color-scheme consistent
690    var meta = document.querySelector('meta[name="color-scheme"]');
691    if (!meta) {
692      meta = document.createElement('meta');
693      meta.name = 'color-scheme';
694      document.documentElement.appendChild(meta);
695    }
696    meta.content = prefersDark ? 'dark light' : 'light dark';
697  } catch (e) {
698    console.error('[dumber] failed to apply theme', e);
699  }
700})();`, string(cssJSON), prefersDark)
701
702	return script, nil
703}
704
705func (c *ContentCoordinator) applyWebUITheme(
706	ctx context.Context,
707	paneID entity.PaneID,
708	wv *webkit.WebView,
709	script string,
710	prefersDark bool,
711) {
712	if wv == nil || wv.IsDestroyed() {
713		return
714	}
715	uri := wv.URI()
716	if !strings.HasPrefix(uri, "dumb://") {
717		return
718	}
719	wv.RunJavaScript(ctx, script, "")
720	logging.FromContext(ctx).
721		Debug().
722		Str("pane_id", string(paneID)).
723		Str("uri", uri).
724		Bool("prefers_dark", prefersDark).
725		Msg("applied WebUI theme")
726}
727
728func (c *ContentCoordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
729	c.appearanceMu.Lock()
730	if c.pendingThemePanes == nil {
731		c.pendingThemePanes = make(map[entity.PaneID]bool)
732	}
733	c.pendingThemePanes[paneID] = true
734	c.pendingThemeUpdate = pendingThemeUpdate{
735		prefersDark: prefersDark,
736		cssText:     cssText,
737	}
738	c.hasPendingThemeUpdate = true
739	c.appearanceMu.Unlock()
740}
741
742func (c *ContentCoordinator) setCurrentTheme(prefersDark bool, cssText string) {
743	c.appearanceMu.Lock()
744	c.currentTheme = pendingThemeUpdate{
745		prefersDark: prefersDark,
746		cssText:     cssText,
747	}
748	c.hasCurrentTheme = true
749	c.appearanceMu.Unlock()
750}
751
752func (c *ContentCoordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
753	c.appearanceMu.Lock()
754	defer c.appearanceMu.Unlock()
755
756	if !c.hasCurrentTheme {
757		return pendingThemeUpdate{}, false
758	}
759	return c.currentTheme, true
760}
761
762func (c *ContentCoordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
763	c.appearanceMu.Lock()
764	defer c.appearanceMu.Unlock()
765
766	if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
767		return pendingThemeUpdate{}, false
768	}
769	delete(c.pendingThemePanes, paneID)
770	update := c.pendingThemeUpdate
771	if len(c.pendingThemePanes) == 0 {
772		c.hasPendingThemeUpdate = false
773	}
774	return update, true
775}
776
777func (c *ContentCoordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
778	update, ok := c.takePendingThemeApply(paneID)
779	if !ok {
780		return false
781	}
782
783	script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
784	if err != nil {
785		logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
786		return false
787	}
788	c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
789	return true
790}
791
792func (c *ContentCoordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
793	update, ok := c.getCurrentTheme()
794	if !ok || update.cssText == "" {
795		return false
796	}
797
798	script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
799	if err != nil {
800		logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
801		return false
802	}
803	c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
804	return true
805}
806
807func (c *ContentCoordinator) queueScriptRefresh(paneID entity.PaneID) {
808	c.appearanceMu.Lock()
809	if c.pendingScriptRefresh == nil {
810		c.pendingScriptRefresh = make(map[entity.PaneID]bool)
811	}
812	c.pendingScriptRefresh[paneID] = true
813	c.appearanceMu.Unlock()
814}
815
816func (c *ContentCoordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
817	c.appearanceMu.Lock()
818	defer c.appearanceMu.Unlock()
819
820	if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
821		return false
822	}
823	delete(c.pendingScriptRefresh, paneID)
824	return true
825}
826
827func (c *ContentCoordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
828	if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
829		return
830	}
831	if !c.takePendingScriptRefresh(paneID) {
832		return
833	}
834	if c.injector == nil {
835		return
836	}
837	c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
838}
839
840func (c *ContentCoordinator) shouldDeferAppearance(wv *webkit.WebView) bool {
841	if wv == nil || wv.IsDestroyed() {
842		return false
843	}
844	if wv.IsLoading() {
845		return true
846	}
847	return wv.EstimatedProgress() < 1.0
848}
849
850func (c *ContentCoordinator) refreshInjectedScripts(
851	ctx context.Context,
852	injector *webkit.ContentInjector,
853	paneID entity.PaneID,
854	wv *webkit.WebView,
855) {
856	if injector == nil || wv == nil || wv.IsDestroyed() {
857		return
858	}
859	ucm := wv.UserContentManager()
860	if ucm == nil {
861		return
862	}
863	ucm.RemoveAllScripts()
864	ucm.RemoveAllStyleSheets()
865	injector.InjectScripts(ctx, ucm, wv.ID())
866	logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
867}
868
869func (c *ContentCoordinator) clearPendingAppearance(paneID entity.PaneID) {
870	c.appearanceMu.Lock()
871	if c.pendingScriptRefresh != nil {
872		delete(c.pendingScriptRefresh, paneID)
873	}
874	if c.pendingThemePanes != nil {
875		delete(c.pendingThemePanes, paneID)
876		if len(c.pendingThemePanes) == 0 {
877			c.hasPendingThemeUpdate = false
878		}
879	}
880	c.appearanceMu.Unlock()
881}
882
883// GetTitle returns the current title for a pane.
884func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {
885	c.titleMu.RLock()
886	defer c.titleMu.RUnlock()
887	return c.paneTitles[paneID]
888}
889
890// onTitleChanged updates title tracking when a WebView's title changes.
891func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
892	log := logging.FromContext(ctx)
893
894	// Update title map
895	c.titleMu.Lock()
896	c.paneTitles[paneID] = title
897	c.titleMu.Unlock()
898
899	// Update domain model and check if this is the active pane
900	isActivePaneTitle := false
901	ws, wsView := c.getActiveWS()
902	if ws != nil {
903		paneNode := ws.FindPane(paneID)
904		if paneNode != nil && paneNode.Pane != nil {
905			paneNode.Pane.Title = title
906		}
907		// Check if this pane is the active one
908		if ws.ActivePaneID == paneID {
909			isActivePaneTitle = true
910		}
911	}
912
913	// Update StackedView title bar if this pane is in a stack
914	if wsView != nil {
915		tr := wsView.TreeRenderer()
916		if tr != nil {
917			stackedView := tr.GetStackedViewForPane(string(paneID))
918			if stackedView != nil {
919				c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
920			}
921		}
922	}
923
924	// Notify history persistence (get URL from WebView)
925	if c.onTitleUpdated != nil {
926		if wv := c.getWebViewLocked(paneID); wv != nil {
927			currentURI := wv.URI()
928			if currentURI != "" && title != "" {
929				c.onTitleUpdated(ctx, paneID, currentURI, title)
930			}
931		}
932	}
933
934	// Notify window title update if this is the active pane
935	if isActivePaneTitle && c.onWindowTitleChanged != nil {
936		c.onWindowTitleChanged(title)
937	}
938
939	log.Debug().
940		Str("pane_id", string(paneID)).
941		Str("title", title).
942		Msg("pane title updated")
943}
944
945// updateStackedPaneTitle updates the title of a pane in a StackedView.
946func (c *ContentCoordinator) updateStackedPaneTitle(
947	ctx context.Context,
948	sv *layout.StackedView,
949	paneID entity.PaneID,
950	title string,
951) {
952	log := logging.FromContext(ctx)
953
954	// Find the pane's index directly in the StackedView
955	index := sv.FindPaneIndex(string(paneID))
956	if index < 0 {
957		log.Debug().
958			Str("pane_id", string(paneID)).
959			Msg("pane not found in StackedView for title update")
960		return
961	}
962
963	if err := sv.UpdateTitle(index, title); err != nil {
964		log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane title")
965	}
966}
967
968// syncStackedTitle updates the stacked title bar for a pane if it's in a stack.
969// Called from onLoadCommitted to keep titles in sync during navigation.
970func (c *ContentCoordinator) syncStackedTitle(ctx context.Context, paneID entity.PaneID, title string) {
971	_, wsView := c.getActiveWS()
972	if wsView == nil {
973		return
974	}
975	tr := wsView.TreeRenderer()
976	if tr == nil {
977		return
978	}
979	if sv := tr.GetStackedViewForPane(string(paneID)); sv != nil {
980		c.updateStackedPaneTitle(ctx, sv, paneID, title)
981	}
982}
983
984// onFaviconChanged updates favicon tracking when a WebView's favicon changes.
985func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) {
986	log := logging.FromContext(ctx)
987
988	// Get current URI to extract domain for caching
989	wv := c.getWebViewLocked(paneID)
990	if wv == nil {
991		return
992	}
993	uri := wv.URI()
994
995	// Update favicon cache with domain key (handles cross-domain redirects)
996	if c.faviconAdapter != nil && favicon != nil && uri != "" {
997		c.navOriginMu.RLock()
998		originURL := c.navOrigins[paneID]
999		c.navOriginMu.RUnlock()
1000		c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon)
1001	}
1002
1003	// Update StackedView favicon if this pane is in a stack
1004	_, wsView := c.getActiveWS()
1005	if wsView != nil {
1006		tr := wsView.TreeRenderer()
1007		if tr != nil {
1008			stackedView := tr.GetStackedViewForPane(string(paneID))
1009			if stackedView != nil {
1010				c.updateStackedPaneFavicon(ctx, stackedView, paneID, favicon)
1011			}
1012		}
1013	}
1014
1015	log.Debug().
1016		Str("pane_id", string(paneID)).
1017		Str("uri", uri).
1018		Bool("has_favicon", favicon != nil).
1019		Msg("pane favicon updated")
1020}
1021
1022// updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
1023func (c *ContentCoordinator) updateStackedPaneFavicon(
1024	ctx context.Context,
1025	sv *layout.StackedView,
1026	paneID entity.PaneID,
1027	favicon *gdk.Texture,
1028) {
1029	log := logging.FromContext(ctx)
1030
1031	// Find the pane's index directly in the StackedView
1032	index := sv.FindPaneIndex(string(paneID))
1033	if index < 0 {
1034		log.Debug().
1035			Str("pane_id", string(paneID)).
1036			Msg("pane not found in StackedView for favicon update")
1037		return
1038	}
1039
1040	if err := sv.UpdateFaviconTexture(index, favicon); err != nil {
1041		log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane favicon")
1042	}
1043}
1044
1045// FaviconAdapter returns the favicon adapter for external use (e.g., omnibox).
1046func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter {
1047	return c.faviconAdapter
1048}
1049
1050// SetNavigationOrigin records the original URL before navigation starts.
1051// This allows caching favicons under both original and final domains
1052// when cross-domain redirects occur (e.g., google.fr → google.com).
1053func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, uri string) {
1054	c.navOriginMu.Lock()
1055	c.navOrigins[paneID] = uri
1056	c.navOriginMu.Unlock()
1057}
1058
1059// PreloadCachedFavicon checks the favicon cache and updates the stacked pane
1060// title bar immediately if a cached favicon exists for the URL.
1061// This provides instant favicon display without waiting for WebKit.
1062func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, uri string) {
1063	if c.faviconAdapter == nil || uri == "" {
1064		return
1065	}
1066	select {
1067	case <-ctx.Done():
1068		return
1069	default:
1070	}
1071
1072	// Check memory and disk cache (no external fetch)
1073	texture := c.faviconAdapter.PreloadFromCache(uri)
1074	select {
1075	case <-ctx.Done():
1076		return
1077	default:
1078	}
1079
1080	// Update stacked pane favicon on GTK main loop.
1081	cb := glib.SourceFunc(func(_ uintptr) bool {
1082		select {
1083		case <-ctx.Done():
1084			return false
1085		default:
1086		}
1087		if c.getActiveWS == nil {
1088			return false
1089		}
1090		// A nil texture triggers the default icon fallback, which avoids stale favicons.
1091		_, wsView := c.getActiveWS()
1092		if wsView == nil {
1093			return false
1094		}
1095		tr := wsView.TreeRenderer()
1096		if tr == nil {
1097			return false
1098		}
1099		stackedView := tr.GetStackedViewForPane(string(paneID))
1100		if stackedView == nil {
1101			return false
1102		}
1103		c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
1104		return false
1105	})
1106	glib.IdleAdd(&cb, 0)
1107}
1108
1109// onLoadCommitted re-applies zoom when page content starts loading and records history.
1110// WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
1111// History is recorded here because the URI is guaranteed to be correct after commit.
1112// Also shows the WebView widget (it's hidden during creation to avoid white flash).
1113func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1114	log := logging.FromContext(ctx)
1115	logging.Trace().Mark("load_committed")
1116
1117	uri := wv.URI()
1118	if uri == "" {
1119		return
1120	}
1121
1122	// Set appropriate background color based on page type to prevent dark background bleeding.
1123	switch {
1124	case strings.HasPrefix(uri, "dumb://"):
1125		// Internal pages: apply themed background
1126		theme, ok := c.getCurrentTheme()
1127		if ok && theme.prefersDark {
1128			wv.SetBackgroundColor(darkBgR, darkBgG, darkBgB, darkBgA)
1129		} else {
1130			wv.ResetBackgroundToDefault()
1131		}
1132	case strings.HasPrefix(uri, "about:"):
1133		// Keep pool background (no action)
1134	default:
1135		// External pages: white background
1136		wv.ResetBackgroundToDefault()
1137	}
1138
1139	// Show the WebView now that content is being painted
1140	// (WebViews are hidden on creation to avoid white flash)
1141	// Skip showing if this is about:blank but the pane is loading a different URL
1142	// This prevents the brief flash of about:blank during initial navigation
1143	shouldShow := true
1144	if uri == aboutBlankURI {
1145		// Get the pane's intended URI from the workspace
1146		ws, _ := c.getActiveWS()
1147		if ws != nil {
1148			if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
1149				// Don't show about:blank if the pane is supposed to load a different URL
1150				if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
1151					shouldShow = false
1152					log.Debug().
1153						Str("pane_id", string(paneID)).
1154						Str("pane_uri", paneNode.Pane.URI).
1155						Msg("skipping webview show for about:blank (pane loading different URL)")
1156				}
1157			}
1158		}
1159	}
1160
1161	if !shouldShow {
1162		// Avoid updating UI/domain state to about:blank when we know the pane is
1163		// navigating to a different URL. This prevents the omnibox/window title from
1164		// briefly showing about:blank on cold start.
1165		c.clearPendingReveal(paneID)
1166		return
1167	}
1168
1169	if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
1170		c.applyCurrentTheme(ctx, paneID, wv)
1171	}
1172
1173	c.markPendingReveal(paneID)
1174	if wv.EstimatedProgress() > 0 {
1175		c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
1176	}
1177
1178	// Update domain model with current URI for session snapshots
1179	c.updatePaneURI(paneID, uri)
1180
1181	// Sync StackedView title bar with the WebView's current title.
1182	// This keeps the stacked title bar up-to-date immediately on navigation,
1183	// before the asynchronous notify::title signal fires.
1184	if title := wv.Title(); title != "" {
1185		c.syncStackedTitle(ctx, paneID, title)
1186	}
1187
1188	// Record history - URI is guaranteed to be correct at LoadCommitted
1189	if c.onHistoryRecord != nil {
1190		c.onHistoryRecord(ctx, paneID, uri)
1191	}
1192
1193	// Notify active pane navigation for permission indicator reset.
1194	c.notifyActiveNavigation(paneID, uri)
1195
1196	// Apply zoom
1197	if c.zoomUC == nil {
1198		return
1199	}
1200
1201	domain, err := usecase.ExtractDomain(uri)
1202	if err != nil {
1203		return
1204	}
1205
1206	_ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
1207}
1208
1209func (c *ContentCoordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
1210	if c.onActiveNavigationCommitted == nil {
1211		return
1212	}
1213	ws, _ := c.getActiveWS()
1214	if ws != nil && ws.ActivePaneID == paneID {
1215		c.onActiveNavigationCommitted(uri)
1216	}
1217}
1218
1219func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
1220	if wv == nil || wv.IsDestroyed() {
1221		return false
1222	}
1223	if wv.URI() != aboutBlankURI {
1224		return false
1225	}
1226	ws, _ := c.getActiveWS()
1227	if ws == nil {
1228		return false
1229	}
1230	paneNode := ws.FindPane(paneID)
1231	if paneNode == nil || paneNode.Pane == nil {
1232		return false
1233	}
1234	if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
1235		return true
1236	}
1237	return false
1238}
1239
1240// onSPANavigation records history when URL changes via JavaScript (History API).
1241// This handles SPA navigation like YouTube search, where the URL changes without a page load.
1242func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) {
1243	// Update domain model with current URI for session snapshots
1244	c.updatePaneURI(paneID, uri)
1245
1246	// Record history for SPA navigation
1247	if c.onHistoryRecord != nil {
1248		c.onHistoryRecord(ctx, paneID, uri)
1249	}
1250}
1251
1252// updatePaneURI updates the pane's URI in the domain model.
1253// This is called on navigation so that session snapshots capture the current URL.
1254func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, uri string) {
1255	if c.onPaneURIUpdated != nil {
1256		c.onPaneURIUpdated(paneID, uri)
1257	}
1258}
1259
1260// onLoadStarted shows the progress bar when page loading begins.
1261func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
1262	logging.Trace().Mark("load_started")
1263
1264	// Trigger deferred initialization on first load_started.
1265	// This ensures non-critical init runs after initial navigation starts.
1266	c.loadStartedOnce.Do(func() {
1267		if c.onFirstLoadStarted != nil {
1268			c.onFirstLoadStarted()
1269		}
1270	})
1271
1272	_, wsView := c.getActiveWS()
1273	if wsView == nil {
1274		return
1275	}
1276
1277	paneView := wsView.GetPaneView(paneID)
1278	if paneView != nil {
1279		paneView.SetLoading(true)
1280	}
1281}
1282
1283// onLoadFinished hides the progress bar when page loading completes.
1284func (c *ContentCoordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1285	_, wsView := c.getActiveWS()
1286	if wsView == nil {
1287		return
1288	}
1289
1290	paneView := wsView.GetPaneView(paneID)
1291	if paneView != nil {
1292		paneView.SetLoading(false)
1293	}
1294
1295	c.revealIfPending(context.Background(), paneID, "", "load-finished")
1296	if c.shouldSkipAboutBlankAppearance(paneID, wv) {
1297		return
1298	}
1299	c.applyPendingThemeUpdate(ctx, paneID, wv)
1300	c.refreshPendingScripts(ctx, paneID, wv)
1301}
1302
1303// onProgressChanged updates the progress bar with current load progress.
1304func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
1305	if progress > 0 {
1306		c.revealIfPending(context.Background(), paneID, "", "progress")
1307	}
1308
1309	_, wsView := c.getActiveWS()
1310	if wsView == nil {
1311		return
1312	}
1313
1314	paneView := wsView.GetPaneView(paneID)
1315	if paneView != nil {
1316		paneView.SetLoadProgress(progress)
1317	}
1318}
1319
1320func (c *ContentCoordinator) markPendingReveal(paneID entity.PaneID) {
1321	c.revealMu.Lock()
1322	c.pendingReveal[paneID] = true
1323	c.revealMu.Unlock()
1324}
1325
1326func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) {
1327	c.revealMu.Lock()
1328	delete(c.pendingReveal, paneID)
1329	c.revealMu.Unlock()
1330}
1331
1332func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
1333	c.revealMu.Lock()
1334	pending := c.pendingReveal[paneID]
1335	if pending {
1336		delete(c.pendingReveal, paneID)
1337	}
1338	c.revealMu.Unlock()
1339
1340	if !pending {
1341		return
1342	}
1343
1344	wv := c.getWebViewLocked(paneID)
1345	if wv == nil || wv.IsDestroyed() {
1346		return
1347	}
1348
1349	if inner := wv.Widget(); inner != nil {
1350		inner.SetVisible(true)
1351		logging.FromContext(ctx).
1352			Debug().
1353			Str("pane_id", string(paneID)).
1354			Str("uri", uri).
1355			Str("reason", reason).
1356			Msg("webview revealed")
1357	}
1358
1359	// Mark first_paint and finish startup trace
1360	logging.Trace().Mark("first_paint")
1361	logging.Trace().Finish()
1362
1363	if c.onWebViewShown != nil {
1364		c.onWebViewShown(paneID)
1365	}
1366}
1367
1368// onLinkHover updates the link status overlay when hovering over links.
1369func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) {
1370	_, wsView := c.getActiveWS()
1371	if wsView == nil {
1372		return
1373	}
1374
1375	paneView := wsView.GetPaneView(paneID)
1376	if paneView == nil {
1377		return
1378	}
1379
1380	if uri != "" {
1381		paneView.ShowLinkStatus(uri)
1382	} else {
1383		paneView.HideLinkStatus()
1384	}
1385}
1386
1387// --- Popup Handling ---
1388
1389// SetPopupConfig configures popup handling.
1390func (c *ContentCoordinator) SetPopupConfig(
1391	factory *webkit.WebViewFactory,
1392	popupConfig *config.PopupBehaviorConfig,
1393	generateID func() string,
1394) {
1395	c.factory = factory
1396	c.popupConfig = popupConfig
1397	c.generateID = generateID
1398}
1399
1400// SetOnInsertPopup sets the callback to insert popups into the workspace.
1401func (c *ContentCoordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
1402	c.onInsertPopup = fn
1403}
1404
1405// SetOnClosePane sets the callback to close a pane when its popup closes.
1406func (c *ContentCoordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
1407	c.onClosePane = fn
1408}
1409
1410// setupPopupHandling wires the popup create signal for a WebView.
1411// This should be called after acquiring a WebView for a pane.
1412func (c *ContentCoordinator) setupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1413	log := logging.FromContext(ctx)
1414
1415	if wv == nil {
1416		return
1417	}
1418
1419	// Wire the OnCreate callback for popup requests
1420	wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView {
1421		return c.handlePopupCreate(ctx, paneID, wv, req)
1422	}
1423
1424	log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview")
1425}
1426
1427// createPopupPane creates a new pane entity for a popup window.
1428func (c *ContentCoordinator) createPopupPane(
1429	popupID port.WebViewID,
1430	parentPaneID entity.PaneID,
1431	targetURI string,
1432) (entity.PaneID, *entity.Pane) {
1433	var paneID entity.PaneID
1434	if c.generateID != nil {
1435		paneID = entity.PaneID(c.generateID())
1436	} else {
1437		paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
1438	}
1439
1440	popupPane := entity.NewPane(paneID)
1441	popupPane.WindowType = entity.WindowPopup
1442	popupPane.IsRelated = true
1443	popupPane.ParentPaneID = &parentPaneID
1444	popupPane.URI = targetURI
1445
1446	return paneID, popupPane
1447}
1448
1449// handlePopupCreate handles the WebKit "create" signal for popup windows.
1450// Returns a new related WebView if popup is allowed, nil to block.
1451//
1452// IMPORTANT: The WebView MUST be added to a GtkWindow hierarchy BEFORE this
1453// signal handler returns. Otherwise WebKit won't establish window.opener.
1454// The WebView stays hidden until ready-to-show signal is received.
1455func (c *ContentCoordinator) handlePopupCreate(
1456	ctx context.Context,
1457	parentPaneID entity.PaneID,
1458	parentWV *webkit.WebView,
1459	req webkit.PopupRequest,
1460) *webkit.WebView {
1461	log := logging.FromContext(ctx)
1462
1463	log.Debug().
1464		Str("parent_pane", string(parentPaneID)).
1465		Str("target_uri", req.TargetURI).
1466		Str("frame_name", req.FrameName).
1467		Bool("user_gesture", req.IsUserGesture).
1468		Msg("popup create request")
1469
1470	// Check if popups are enabled
1471	if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
1472		log.Debug().Msg("popups disabled by config, blocking")
1473		return nil
1474	}
1475
1476	// Check if factory is available
1477	if c.factory == nil {
1478		log.Warn().Msg("no webview factory, cannot create popup")
1479		return nil
1480	}
1481
1482	// Create related WebView for session sharing (created hidden)
1483	parentID := parentWV.ID()
1484	popupWV, err := c.factory.CreateRelated(ctx, parentID)
1485	if err != nil {
1486		log.Error().Err(err).Msg("failed to create related webview for popup")
1487		return nil
1488	}
1489
1490	// Detect popup type
1491	popupType := DetectPopupType(req.FrameName)
1492	popupID := popupWV.ID()
1493
1494	// Determine behavior from config
1495	behavior := GetBehavior(popupType, c.popupConfig)
1496	placement := "right"
1497	if c.popupConfig != nil {
1498		placement = c.popupConfig.Placement
1499	}
1500
1501	// Create popup pane entity
1502	paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
1503
1504	// Check OAuth configuration
1505	hasConfig := c.popupConfig != nil
1506	oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose
1507	isOAuth := IsOAuthURL(req.TargetURI)
1508	log.Debug().
1509		Bool("has_config", hasConfig).
1510		Bool("oauth_enabled", oauthEnabled).
1511		Bool("is_oauth", isOAuth).
1512		Str("uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
1513		Msg("popup OAuth check")
1514
1515	// Insert into workspace IMMEDIATELY (WebView stays hidden)
1516	// This is required for WebKit to establish window.opener relationship
1517	if c.onInsertPopup != nil {
1518		popupInput := InsertPopupInput{
1519			ParentPaneID: parentPaneID,
1520			PopupPane:    popupPane,
1521			WebView:      popupWV,
1522			Behavior:     behavior,
1523			Placement:    placement,
1524			PopupType:    popupType,
1525			TargetURI:    req.TargetURI,
1526		}
1527
1528		if err := c.onInsertPopup(ctx, popupInput); err != nil {
1529			log.Error().Err(err).Msg("failed to insert popup into workspace")
1530			popupWV.Destroy()
1531			return nil
1532		}
1533	}
1534
1535	// Register WebView in our map (after successful insertion)
1536	c.setWebViewLocked(paneID, popupWV)
1537
1538	// Setup standard callbacks (after successful insertion to avoid leak)
1539	c.setupWebViewCallbacks(ctx, paneID, popupWV)
1540
1541	// Setup OAuth auto-close if configured
1542	if hasConfig && oauthEnabled && isOAuth {
1543		popupPane.AutoClose = true
1544		c.trackOAuthPopup(popupID, parentPaneID)
1545		c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV)
1546		log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")
1547	}
1548
1549	// Store pending popup for ready-to-show handling (just visibility now)
1550	pending := &PendingPopup{
1551		WebView:         popupWV,
1552		ParentPaneID:    parentPaneID,
1553		ParentWebViewID: parentID,
1554		TargetURI:       req.TargetURI,
1555		FrameName:       req.FrameName,
1556		IsUserGesture:   req.IsUserGesture,
1557		PopupType:       popupType,
1558		CreatedAt:       time.Now(),
1559	}
1560
1561	c.popupMu.Lock()
1562	c.pendingPopups[popupID] = pending
1563	c.popupMu.Unlock()
1564
1565	// Wire ready-to-show (now just for visibility) and close signals
1566	popupWV.OnReadyToShow = func() {
1567		c.handlePopupReadyToShow(ctx, popupID)
1568	}
1569	popupWV.OnClose = composeOnClose(popupWV.OnClose, func() {
1570		c.handlePopupClose(ctx, popupID)
1571	})
1572
1573	log.Info().
1574		Uint64("popup_id", uint64(popupID)).
1575		Str("pane_id", string(paneID)).
1576		Str("popup_type", popupType.String()).
1577		Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
1578		Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
1579
1580	return popupWV
1581}
1582
1583// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
1584// The popup WebView was already inserted into the workspace (hidden) during
1585// the create signal. This handler just makes it visible.
1586func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
1587	log := logging.FromContext(ctx)
1588
1589	// Get pending popup
1590	c.popupMu.Lock()
1591	pending, ok := c.pendingPopups[popupID]
1592	if ok {
1593		delete(c.pendingPopups, popupID)
1594	}
1595	c.popupMu.Unlock()
1596
1597	if !ok || pending == nil {
1598		log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
1599		return
1600	}
1601
1602	log.Debug().
1603		Uint64("popup_id", uint64(popupID)).
1604		Str("popup_type", pending.PopupType.String()).
1605		Msg("popup ready to show - making visible")
1606
1607	// Make the WebView visible now that it's ready
1608	if pending.WebView != nil {
1609		pending.WebView.Show()
1610	}
1611
1612	log.Info().
1613		Uint64("popup_id", uint64(popupID)).
1614		Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
1615		Msg("popup now visible")
1616}
1617
1618// handlePopupClose handles the WebKit "close" signal for popup windows.
1619func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
1620	log := logging.FromContext(ctx)
1621	log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
1622
1623	// Check if still pending (never shown)
1624	c.popupMu.Lock()
1625	pending, wasPending := c.pendingPopups[popupID]
1626	if wasPending {
1627		delete(c.pendingPopups, popupID)
1628	}
1629	c.popupMu.Unlock()
1630
1631	if wasPending && pending != nil {
1632		c.handlePopupOAuthClose(ctx, popupID)
1633		// Was never shown, just destroy
1634		pending.WebView.Destroy()
1635		log.Debug().Msg("destroyed pending popup that was never shown")
1636		return
1637	}
1638
1639	// Find pane by WebView ID
1640	var paneID entity.PaneID
1641	for pid, wv := range c.webViews {
1642		if wv != nil && wv.ID() == popupID {
1643			paneID = pid
1644			break
1645		}
1646	}
1647
1648	if paneID == "" {
1649		c.handlePopupOAuthClose(ctx, popupID)
1650		log.Warn().Msg("popup close: could not find pane for webview")
1651		return
1652	}
1653
1654	// Close the pane in workspace (this removes the UI element)
1655	if c.onClosePane != nil {
1656		if err := c.onClosePane(ctx, paneID); err != nil {
1657			log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
1658		}
1659	}
1660
1661	c.handlePopupOAuthClose(ctx, popupID)
1662
1663	// Release the WebView (this will clean up tracking)
1664	c.ReleaseWebView(ctx, paneID)
1665
1666	log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
1667}
1668
1669// setupWebViewCallbacks configures standard callbacks and popup handling.
1670func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1671	log := logging.FromContext(ctx)
1672
1673	// Title changes
1674	wv.OnTitleChanged = func(title string) {
1675		c.onTitleChanged(ctx, paneID, title)
1676	}
1677
1678	// Favicon changes
1679	wv.OnFaviconChanged = func(favicon *gdk.Texture) {
1680		c.onFaviconChanged(ctx, paneID, favicon)
1681	}
1682
1683	// Load events
1684	wv.OnLoadChanged = func(event webkit.LoadEvent) {
1685		switch event {
1686		case webkit.LoadStarted:
1687			c.onLoadStarted(paneID)
1688		case webkit.LoadCommitted:
1689			c.onLoadCommitted(ctx, paneID, wv)
1690		case webkit.LoadFinished:
1691			c.onLoadFinished(ctx, paneID, wv)
1692		}
1693	}
1694
1695	// Progress
1696	wv.OnProgressChanged = func(progress float64) {
1697		c.onProgressChanged(paneID, progress)
1698	}
1699
1700	// SPA navigation and external scheme handling
1701	wv.OnURIChanged = func(uri string) {
1702		if uri == "" {
1703			return
1704		}
1705
1706		// Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
1707		// These are typically triggered by JavaScript redirects (window.location)
1708		isExternal := urlutil.IsExternalScheme(uri)
1709
1710		if isExternal {
1711			log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
1712
1713			// Launch externally
1714			desktop.LaunchExternalURL(uri)
1715
1716			// Stop loading to prevent WebKit from showing an error page
1717			// The page stays on the previous URL before the JS redirect
1718			_ = wv.Stop(ctx)
1719
1720			// Navigate back to avoid stale URI in omnibox/history
1721			if wv.CanGoBack() {
1722				_ = wv.GoBack(ctx)
1723			}
1724			return
1725		}
1726
1727		if !wv.IsLoading() {
1728			c.onSPANavigation(ctx, paneID, uri)
1729		}
1730	}
1731
1732	// Middle-click / Ctrl+click handler for opening links in new pane
1733	wv.OnLinkMiddleClick = func(uri string) bool {
1734		return c.handleLinkMiddleClick(ctx, paneID, uri)
1735	}
1736
1737	// Link hover callback for status overlay
1738	wv.OnLinkHover = func(uri string) {
1739		c.onLinkHover(paneID, uri)
1740	}
1741
1742	wv.OnWebProcessTerminated = func(reason webkitlib.WebProcessTerminationReason, reasonLabel string, uri string) {
1743		originalURI := extractOriginalURIFromCrashPage(uri)
1744		if !shouldRenderCrashPage(reason) {
1745			log.Info().
1746				Str("pane_id", string(paneID)).
1747				Str("reason", reasonLabel).
1748				Str("uri", uri).
1749				Msg("web process termination handled without crash page")
1750			return
1751		}
1752
1753		crashURI := buildCrashPageURI(originalURI)
1754		log.Warn().
1755			Str("pane_id", string(paneID)).
1756			Str("reason", reasonLabel).
1757			Str("uri", uri).
1758			Str("crash_uri", crashURI).
1759			Msg("web process terminated, redirecting to crash page")
1760
1761		if err := wv.LoadURI(ctx, crashURI); err != nil {
1762			log.Error().
1763				Err(err).
1764				Str("pane_id", string(paneID)).
1765				Str("reason", reasonLabel).
1766				Str("uri", uri).
1767				Str("crash_uri", crashURI).
1768				Msg("failed to load crash page after web process termination")
1769		}
1770	}
1771
1772	// Permission request handler
1773	wv.OnPermissionRequest = func(origin string, permTypes []string, allow, deny func()) bool {
1774		return c.handlePermissionRequest(ctx, origin, permTypes, allow, deny)
1775	}
1776	// Fullscreen handlers for idle inhibition
1777	c.setupIdleInhibitionHandlers(ctx, paneID, wv)
1778
1779	// Popup handling for nested popups
1780	c.setupPopupHandling(ctx, paneID, wv)
1781}
1782
1783// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
1784// It uses URL pattern detection for standard OAuth callbacks (code=, access_token=, etc.)
1785// For providers using postMessage (like Google Sign-In), we rely on the provider calling
1786// window.close() which triggers WebKit's close signal.
1787// A long safety timeout (30s) catches popups that get stuck.
1788func (c *ContentCoordinator) setupOAuthAutoClose(
1789	ctx context.Context,
1790	paneID entity.PaneID,
1791	popupID port.WebViewID,
1792	wv *webkit.WebView,
1793) {
1794	log := logging.FromContext(ctx)
1795
1796	// Safety timeout - only triggers if popup gets stuck (provider should close via window.close)
1797	var safetyTimer *time.Timer
1798	var safetyTimerMu sync.Mutex
1799	var cancelSafetyTimerOnce sync.Once
1800	var requestCloseOnce sync.Once
1801	const oauthSafetyTimeout = 30 * time.Second
1802
1803	startSafetyTimer := func() {
1804		safetyTimerMu.Lock()
1805		defer safetyTimerMu.Unlock()
1806		if safetyTimer != nil {
1807			safetyTimer.Stop()
1808		}
1809		safetyTimer = time.AfterFunc(oauthSafetyTimeout, func() {
1810			if wv != nil && !wv.IsDestroyed() {
1811				log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
1812				wv.Close()
1813			}
1814		})
1815	}
1816
1817	cancelSafetyTimer := func() {
1818		cancelSafetyTimerOnce.Do(func() {
1819			safetyTimerMu.Lock()
1820			defer safetyTimerMu.Unlock()
1821			if safetyTimer != nil {
1822				safetyTimer.Stop()
1823				safetyTimer = nil
1824			}
1825		})
1826	}
1827
1828	requestOAuthClose := func(uri string, reason string) {
1829		c.capturePopupOAuthState(popupID, uri)
1830		cancelSafetyTimer()
1831		log.Info().
1832			Str("pane", string(paneID)).
1833			Str("reason", reason).
1834			Msg("oauth callback detected, closing")
1835		requestCloseOnce.Do(func() {
1836			go func() {
1837				time.Sleep(500 * time.Millisecond)
1838				if wv != nil && !wv.IsDestroyed() {
1839					wv.Close()
1840				}
1841			}()
1842		})
1843	}
1844
1845	// Start safety timer immediately.
1846	startSafetyTimer()
1847
1848	// Wrap OnURIChanged to check for OAuth callbacks.
1849	wv.OnURIChanged = composeOnURIChanged(wv.OnURIChanged, func(uri string) {
1850		if ShouldAutoClose(uri) {
1851			requestOAuthClose(uri, "uri_changed")
1852		}
1853	})
1854
1855	// Monitor load events for URL-based detection.
1856	wv.OnLoadChanged = composeOnLoadChanged(wv.OnLoadChanged, func(event webkit.LoadEvent) {
1857		if event == webkit.LoadCommitted {
1858			uri := wv.URI()
1859			if ShouldAutoClose(uri) {
1860				requestOAuthClose(uri, "load_committed")
1861			}
1862		}
1863	})
1864
1865	// Cancel safety timer on any close path.
1866	wv.OnClose = composeOnClose(func() {
1867		cancelSafetyTimer()
1868	}, wv.OnClose)
1869}
1870
1871func (c *ContentCoordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) {
1872	c.popupMu.Lock()
1873	defer c.popupMu.Unlock()
1874	if c.popupOAuth == nil {
1875		c.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
1876	}
1877	c.popupOAuth[popupID] = &popupOAuthState{
1878		ParentPaneID: parentPaneID,
1879	}
1880}
1881
1882func (c *ContentCoordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
1883	c.popupMu.Lock()
1884	defer c.popupMu.Unlock()
1885
1886	state, ok := c.popupOAuth[popupID]
1887	if !ok {
1888		return
1889	}
1890
1891	state.Seen = true
1892	state.CallbackURI = uri
1893	state.Success = IsOAuthSuccess(uri)
1894	state.Error = IsOAuthError(uri)
1895}
1896
1897func (c *ContentCoordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
1898	log := logging.FromContext(ctx)
1899
1900	c.popupMu.Lock()
1901	state, ok := c.popupOAuth[popupID]
1902	if ok {
1903		delete(c.popupOAuth, popupID)
1904	}
1905	c.popupMu.Unlock()
1906
1907	if !ok || state == nil || !state.Seen {
1908		return
1909	}
1910
1911	log.Debug().
1912		Uint64("popup_id", uint64(popupID)).
1913		Str("parent_pane_id", string(state.ParentPaneID)).
1914		Bool("oauth_success", state.Success).
1915		Bool("oauth_error", state.Error).
1916		Msg("popup oauth result captured on close")
1917
1918	if !state.Success || state.ParentPaneID == "" {
1919		return
1920	}
1921
1922	c.scheduleParentPaneRefresh(ctx, state.ParentPaneID, popupID)
1923}
1924
1925func (c *ContentCoordinator) scheduleParentPaneRefresh(
1926	ctx context.Context,
1927	parentPaneID entity.PaneID,
1928	popupID port.WebViewID,
1929) {
1930	c.popupMu.Lock()
1931	if c.popupRefresh == nil {
1932		c.popupRefresh = make(map[entity.PaneID]*time.Timer)
1933	}
1934	if existing := c.popupRefresh[parentPaneID]; existing != nil {
1935		existing.Stop()
1936	}
1937	c.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
1938		c.popupMu.Lock()
1939		delete(c.popupRefresh, parentPaneID)
1940		c.popupMu.Unlock()
1941		cb := glib.SourceFunc(func(_ uintptr) bool {
1942			c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)
1943			return false
1944		})
1945		glib.IdleAdd(&cb, 0)
1946	})
1947	c.popupMu.Unlock()
1948}
1949
1950func (c *ContentCoordinator) refreshPaneAfterOAuth(
1951	ctx context.Context,
1952	parentPaneID entity.PaneID,
1953	popupID port.WebViewID,
1954) {
1955	log := logging.FromContext(ctx)
1956	wv := c.getWebViewLocked(parentPaneID)
1957	if wv == nil || wv.IsDestroyed() {
1958		log.Debug().
1959			Str("parent_pane_id", string(parentPaneID)).
1960			Uint64("popup_id", uint64(popupID)).
1961			Msg("skipping parent pane refresh after oauth close: parent webview unavailable")
1962		return
1963	}
1964
1965	if err := wv.Reload(ctx); err != nil {
1966		log.Warn().
1967			Err(err).
1968			Str("parent_pane_id", string(parentPaneID)).
1969			Uint64("popup_id", uint64(popupID)).
1970			Msg("failed parent pane refresh after oauth popup close")
1971		return
1972	}
1973
1974	log.Info().
1975		Str("parent_pane_id", string(parentPaneID)).
1976		Uint64("popup_id", uint64(popupID)).
1977		Msg("refreshed parent pane after oauth popup success")
1978}
1979
1980// handleLinkMiddleClick handles middle-click / Ctrl+click on links.
1981// Opens the link in a new pane using blank_target_behavior config.
1982func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
1983	log := logging.FromContext(ctx)
1984
1985	log.Info().
1986		Str("parent_pane", string(parentPaneID)).
1987		Str("uri", uri).
1988		Msg("middle-click/ctrl+click on link")
1989
1990	// Check if popups are enabled
1991	if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
1992		log.Debug().Msg("popups disabled by config, ignoring middle-click")
1993		return false
1994	}
1995
1996	// Check if factory is available
1997	if c.factory == nil {
1998		log.Warn().Msg("no webview factory, cannot handle middle-click")
1999		return false
2000	}
2001
2002	// Create a new WebView (regular, not related - just opening a link)
2003	newWV, err := c.factory.Create(ctx)
2004	if err != nil {
2005		log.Error().Err(err).Msg("failed to create webview for middle-click")
2006		return false
2007	}
2008
2009	// Generate pane ID
2010	var paneID entity.PaneID
2011	if c.generateID != nil {
2012		paneID = entity.PaneID(c.generateID())
2013	} else {
2014		paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
2015	}
2016
2017	// Create pane entity - uses blank_target_behavior
2018	newPane := entity.NewPane(paneID)
2019	newPane.WindowType = entity.WindowPopup
2020	newPane.URI = uri
2021
2022	// Register WebView
2023	c.setWebViewLocked(paneID, newWV)
2024
2025	// Setup callbacks
2026	c.setupWebViewCallbacks(ctx, paneID, newWV)
2027
2028	// Get behavior from config (same as _blank links)
2029	behavior := config.PopupBehaviorStacked // default
2030	if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
2031		behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior)
2032	}
2033	placement := "right"
2034	if c.popupConfig != nil {
2035		placement = c.popupConfig.Placement
2036	}
2037
2038	// Request insertion into workspace
2039	if c.onInsertPopup != nil {
2040		popupInput := InsertPopupInput{
2041			ParentPaneID: parentPaneID,
2042			PopupPane:    newPane,
2043			WebView:      newWV,
2044			Behavior:     behavior,
2045			Placement:    placement,
2046			PopupType:    PopupTypeTab, // Treat like _blank
2047			TargetURI:    uri,
2048		}
2049
2050		if err := c.onInsertPopup(ctx, popupInput); err != nil {
2051			log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
2052			// Clean up on failure
2053			delete(c.webViews, paneID)
2054			newWV.Destroy()
2055			return false
2056		}
2057	}
2058
2059	// Load the URI after insertion
2060	if err := newWV.LoadURI(ctx, uri); err != nil {
2061		log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane")
2062	}
2063
2064	log.Info().
2065		Str("pane_id", string(paneID)).
2066		Str("behavior", string(behavior)).
2067		Str("uri", uri).
2068		Msg("middle-click link opened in new pane")
2069
2070	return true
2071}
2072
2073// handlePermissionRequest processes media permission requests from WebKit.
2074// It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
2075func (c *ContentCoordinator) handlePermissionRequest(
2076	ctx context.Context,
2077	origin string,
2078	permTypes []string,
2079	allow, deny func(),
2080) bool {
2081	log := logging.FromContext(ctx)
2082
2083	// Convert string permission types to entity types
2084	entityTypes := make([]entity.PermissionType, 0, len(permTypes))
2085	for _, pt := range permTypes {
2086		switch pt {
2087		case "microphone":
2088			entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
2089		case "camera":
2090			entityTypes = append(entityTypes, entity.PermissionTypeCamera)
2091		case "display":
2092			entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
2093		case "device_info":
2094			entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
2095		default:
2096			log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
2097		}
2098	}
2099
2100	if len(entityTypes) == 0 {
2101		log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
2102		deny()
2103		return true
2104	}
2105
2106	trackedTypes := filterWebRTCPermissionTypes(entityTypes)
2107	notifyActivity := func(state PermissionActivityState) {
2108		if c.onPermissionActivity == nil || len(trackedTypes) == 0 {
2109			return
2110		}
2111		c.onPermissionActivity(origin, trackedTypes, state)
2112	}
2113
2114	notifyActivity(PermissionActivityRequesting)
2115
2116	wrappedAllow := func() {
2117		notifyActivity(PermissionActivityAllowed)
2118		allow()
2119	}
2120	wrappedDeny := func() {
2121		notifyActivity(PermissionActivityBlocked)
2122		deny()
2123	}
2124
2125	// Check if permission use case is available
2126	if c.permissionUC == nil {
2127		log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
2128		// Auto-allow display and device_info, deny others
2129		allAutoAllow := true
2130		for _, pt := range entityTypes {
2131			if !entity.IsAutoAllow(pt) {
2132				allAutoAllow = false
2133				break
2134			}
2135		}
2136		if allAutoAllow {
2137			wrappedAllow()
2138		} else {
2139			wrappedDeny()
2140		}
2141		return true
2142	}
2143
2144	// Delegate to use case
2145	callback := usecase.PermissionCallback{
2146		Allow: wrappedAllow,
2147		Deny:  wrappedDeny,
2148	}
2149
2150	c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback)
2151	return true
2152}
2153
2154func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
2155	filtered := make([]entity.PermissionType, 0, len(types))
2156	for _, permType := range types {
2157		switch permType {
2158		case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
2159			filtered = append(filtered, permType)
2160		}
2161	}
2162	return filtered
2163}
2164
2165// setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
2166// Idle is inhibited when:
2167// - The webview enters fullscreen mode (e.g., fullscreen video)
2168// - The webview is playing audio (e.g., video/music playback)
2169// The inhibitor uses refcounting, so both can be active simultaneously.
2170func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
2171	log := logging.FromContext(ctx)
2172
2173	if wv == nil {
2174		return
2175	}
2176
2177	// Fullscreen handling
2178	wv.OnEnterFullscreen = func() bool {
2179		if c.idleInhibitor != nil {
2180			if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
2181				log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
2182			}
2183		}
2184		if c.onFullscreenChanged != nil {
2185			c.onFullscreenChanged(true)
2186		}
2187		return false // Allow fullscreen
2188	}
2189
2190	wv.OnLeaveFullscreen = func() bool {
2191		if c.idleInhibitor != nil {
2192			if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
2193				log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
2194			}
2195		}
2196		if c.onFullscreenChanged != nil {
2197			c.onFullscreenChanged(false)
2198		}
2199		return false // Allow leaving fullscreen
2200	}
2201
2202	// Audio playback handling
2203	wv.OnAudioStateChanged = func(playing bool) {
2204		if c.idleInhibitor == nil {
2205			return
2206		}
2207		if playing {
2208			if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil {
2209				log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio")
2210			}
2211		} else {
2212			if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
2213				log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio")
2214			}
2215		}
2216	}
2217}

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