v0.26.1 75cd777

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

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