v0.26.0 ae3a77a

content.go RawHistoryFolder

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

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