v0.25.0 2e6c485

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

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