v0.22.0 a85da87

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

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