v0.20.1 d9a391f

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	// Gesture action handler for mouse button navigation
50	gestureActionHandler input.ActionHandler
51
52	// Popup handling
53	factory       *webkit.WebViewFactory
54	popupConfig   *config.PopupBehaviorConfig
55	pendingPopups map[port.WebViewID]*PendingPopup
56	popupMu       sync.RWMutex
57
58	// Callback to insert popup into workspace (avoids circular dependency)
59	onInsertPopup func(ctx context.Context, input InsertPopupInput) error
60
61	// Callback to close a pane when popup closes
62	onClosePane func(ctx context.Context, paneID entity.PaneID) error
63
64	// ID generator for popup panes
65	generateID func() string
66
67	// Idle inhibitor for fullscreen video playback
68	idleInhibitor port.IdleInhibitor
69}
70
71// NewContentCoordinator creates a new ContentCoordinator.
72func NewContentCoordinator(
73	ctx context.Context,
74	pool *webkit.WebViewPool,
75	widgetFactory layout.WidgetFactory,
76	faviconAdapter *adapter.FaviconAdapter,
77	getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
78	zoomUC *usecase.ManageZoomUseCase,
79) *ContentCoordinator {
80	log := logging.FromContext(ctx)
81	log.Debug().Msg("creating content coordinator")
82
83	return &ContentCoordinator{
84		pool:           pool,
85		widgetFactory:  widgetFactory,
86		faviconAdapter: faviconAdapter,
87		zoomUC:         zoomUC,
88		webViews:       make(map[entity.PaneID]*webkit.WebView),
89		paneTitles:     make(map[entity.PaneID]string),
90		navOrigins:     make(map[entity.PaneID]string),
91		getActiveWS:    getActiveWS,
92		pendingPopups:  make(map[port.WebViewID]*PendingPopup),
93	}
94}
95
96// SetOnTitleUpdated sets the callback for title changes (for history persistence).
97func (c *ContentCoordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
98	c.onTitleUpdated = fn
99}
100
101// SetOnHistoryRecord sets the callback for recording history on page commit.
102func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
103	c.onHistoryRecord = fn
104}
105
106// SetGestureActionHandler sets the callback for mouse button navigation gestures.
107func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
108	c.gestureActionHandler = handler
109}
110
111// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
112func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
113	c.idleInhibitor = inhibitor
114}
115
116// EnsureWebView acquires or reuses a WebView for the given pane.
117func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
118	log := logging.FromContext(ctx)
119
120	if wv, ok := c.webViews[paneID]; ok && wv != nil && !wv.IsDestroyed() {
121		return wv, nil
122	}
123
124	if c.pool == nil {
125		return nil, fmt.Errorf("webview pool not configured")
126	}
127
128	wv, err := c.pool.Acquire(ctx)
129	if err != nil {
130		return nil, err
131	}
132
133	c.webViews[paneID] = wv
134
135	// Set up title change callback
136	wv.OnTitleChanged = func(title string) {
137		c.onTitleChanged(ctx, paneID, title)
138	}
139
140	// Set up favicon change callback
141	wv.OnFaviconChanged = func(favicon *gdk.Texture) {
142		c.onFaviconChanged(ctx, paneID, favicon)
143	}
144
145	// Set up load change callback to re-apply zoom on navigation and handle progress bar
146	wv.OnLoadChanged = func(event webkit.LoadEvent) {
147		switch event {
148		case webkit.LoadStarted:
149			c.onLoadStarted(paneID)
150		case webkit.LoadCommitted:
151			c.onLoadCommitted(ctx, paneID, wv)
152		case webkit.LoadFinished:
153			c.onLoadFinished(paneID)
154		}
155	}
156
157	// Set up progress callback for loading indicator
158	wv.OnProgressChanged = func(progress float64) {
159		c.onProgressChanged(paneID, progress)
160	}
161
162	// Set up URI change callback for SPA navigation (History API)
163	// This fires when URL changes via JavaScript without a full page load
164	wv.OnURIChanged = func(uri string) {
165		// Only record if not loading (SPA navigation via History API)
166		// Full page loads are handled by OnLoadCommitted
167		if !wv.IsLoading() && uri != "" {
168			c.onSPANavigation(ctx, paneID, uri)
169		}
170	}
171
172	// Set up middle-click / Ctrl+click handler for opening links in new pane
173	wv.OnLinkMiddleClick = func(uri string) bool {
174		return c.handleLinkMiddleClick(ctx, paneID, uri)
175	}
176
177	// Set up fullscreen handlers for idle inhibition
178	c.setupFullscreenHandlers(ctx, paneID, wv)
179
180	// Set up popup handling for this WebView
181	c.SetupPopupHandling(ctx, paneID, wv)
182
183	log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
184	return wv, nil
185}
186
187// ReleaseWebView returns the WebView for a pane to the pool.
188func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
189	log := logging.FromContext(ctx)
190
191	wv, ok := c.webViews[paneID]
192	if !ok || wv == nil {
193		return
194	}
195	delete(c.webViews, paneID)
196
197	// Clean up title tracking
198	c.titleMu.Lock()
199	delete(c.paneTitles, paneID)
200	c.titleMu.Unlock()
201
202	// Clean up navigation origin tracking
203	c.navOriginMu.Lock()
204	delete(c.navOrigins, paneID)
205	c.navOriginMu.Unlock()
206
207	if c.pool != nil {
208		c.pool.Release(ctx, wv)
209	} else {
210		wv.Destroy()
211	}
212
213	log.Debug().Str("pane_id", string(paneID)).Msg("webview released")
214}
215
216// AttachToWorkspace ensures each pane in the workspace has a WebView widget attached.
217func (c *ContentCoordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
218	log := logging.FromContext(ctx)
219
220	if ws == nil || wsView == nil || c.widgetFactory == nil {
221		return
222	}
223
224	for _, pane := range ws.AllPanes() {
225		if pane == nil {
226			continue
227		}
228
229		wv, err := c.EnsureWebView(ctx, pane.ID)
230		if err != nil {
231			log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
232			continue
233		}
234
235		// Load the pane's URI if set and different from current
236		if pane.URI != "" && pane.URI != wv.URI() {
237			if err := wv.LoadURI(ctx, pane.URI); err != nil {
238				log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
239			}
240		}
241
242		widget := c.WrapWidget(ctx, wv)
243		if widget == nil {
244			continue
245		}
246
247		if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
248			log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
249		}
250	}
251}
252
253// WrapWidget converts a WebView to a layout.Widget for embedding.
254// It also attaches gesture handlers for mouse button navigation.
255func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) layout.Widget {
256	log := logging.FromContext(ctx)
257
258	if wv == nil || c.widgetFactory == nil {
259		log.Debug().Msg("cannot wrap nil webview or factory")
260		return nil
261	}
262
263	gtkView := wv.Widget()
264	if gtkView == nil {
265		return nil
266	}
267
268	widget := c.widgetFactory.WrapWidget(&gtkView.Widget)
269
270	// Attach gesture handler for mouse button 8/9 navigation
271	if widget != nil && c.gestureActionHandler != nil {
272		gestureHandler := input.NewGestureHandler(ctx)
273		gestureHandler.SetOnAction(c.gestureActionHandler)
274		gestureHandler.AttachTo(widget.GtkWidget())
275		log.Debug().Msg("gesture handler attached to webview")
276	}
277
278	return widget
279}
280
281// ActiveWebView returns the WebView for the active pane.
282func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
283	log := logging.FromContext(ctx)
284
285	ws, _ := c.getActiveWS()
286	if ws == nil {
287		log.Debug().Msg("no active workspace")
288		return nil
289	}
290
291	pane := ws.ActivePane()
292	if pane == nil || pane.Pane == nil {
293		log.Debug().Msg("no active pane")
294		return nil
295	}
296
297	return c.webViews[pane.Pane.ID]
298}
299
300// GetWebView returns the WebView for a specific pane.
301func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
302	return c.webViews[paneID]
303}
304
305// RegisterPopupWebView registers a popup WebView that was created externally.
306// This is used when popup tabs are created and the WebView needs to be tracked.
307func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {
308	if wv != nil && paneID != "" {
309		c.webViews[paneID] = wv
310	}
311}
312
313// ApplyFiltersToAll applies content filters to all active webviews.
314// Called when filters become available after webviews were already created.
315func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) {
316	log := logging.FromContext(ctx)
317
318	for paneID, wv := range c.webViews {
319		if wv != nil && !wv.IsDestroyed() {
320			applier.ApplyTo(ctx, wv.UserContentManager())
321			log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview")
322		}
323	}
324}
325
326// ApplySettingsToAll reapplies WebKit settings to all active WebViews.
327func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.SettingsManager) {
328	log := logging.FromContext(ctx)
329	if sm == nil {
330		return
331	}
332
333	for paneID, wv := range c.webViews {
334		if wv == nil || wv.IsDestroyed() {
335			continue
336		}
337		sm.ApplyToWebView(ctx, wv.Widget())
338		log.Debug().Str("pane_id", string(paneID)).Msg("reapplied settings to webview")
339	}
340}
341
342// RefreshInjectedScriptsToAll clears and re-injects user scripts into all active WebViews.
343//
344// WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
345// appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
346// the scripts so future navigations pick up the latest values.
347func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
348	log := logging.FromContext(ctx)
349	if injector == nil {
350		return
351	}
352
353	for paneID, wv := range c.webViews {
354		if wv == nil || wv.IsDestroyed() {
355			continue
356		}
357		ucm := wv.UserContentManager()
358		if ucm == nil {
359			continue
360		}
361
362		ucm.RemoveAllScripts()
363		injector.InjectScripts(ctx, ucm, wv.ID())
364		log.Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
365	}
366}
367
368// ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages.
369// This is necessary because user scripts only run on navigation.
370func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) {
371	log := logging.FromContext(ctx)
372	if cssText == "" {
373		return
374	}
375
376	// Safely embed CSS in JS
377	cssJSON, err := json.Marshal(cssText)
378	if err != nil {
379		log.Warn().Err(err).Msg("failed to marshal WebUI CSS vars")
380		return
381	}
382
383	script := fmt.Sprintf(`(function(){
384  try {
385    var cssText = %s;
386    var prefersDark = %t;
387
388    // Keep the global flag in sync
389    window.__dumber_gtk_prefers_dark = prefersDark;
390
391    // Update dark/light class
392    if (prefersDark) {
393      document.documentElement.classList.add('dark');
394      document.documentElement.classList.remove('light');
395    } else {
396      document.documentElement.classList.add('light');
397      document.documentElement.classList.remove('dark');
398    }
399
400    // Update or insert theme style
401    var style = document.querySelector('style[data-dumber-theme-vars]');
402    if (!style) {
403      style = document.createElement('style');
404      style.setAttribute('data-dumber-theme-vars', '');
405      (document.head || document.documentElement).appendChild(style);
406    }
407    style.textContent = cssText;
408
409    // Notify any running WebUI that theme changed
410    try {
411      window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
412        detail: { prefersDark: prefersDark }
413      }));
414    } catch (e) {
415      // ignore
416    }
417
418    // Keep color-scheme consistent
419    var meta = document.querySelector('meta[name="color-scheme"]');
420    if (!meta) {
421      meta = document.createElement('meta');
422      meta.name = 'color-scheme';
423      document.documentElement.appendChild(meta);
424    }
425    meta.content = prefersDark ? 'dark light' : 'light dark';
426  } catch (e) {
427    console.error('[dumber] failed to apply theme', e);
428  }
429})();`, string(cssJSON), prefersDark)
430
431	for paneID, wv := range c.webViews {
432		if wv == nil || wv.IsDestroyed() {
433			continue
434		}
435		uri := wv.URI()
436		if !strings.HasPrefix(uri, "dumb://") {
437			continue
438		}
439		wv.RunJavaScript(ctx, script, "")
440		log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("applied WebUI theme")
441	}
442}
443
444// GetTitle returns the current title for a pane.
445func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {
446	c.titleMu.RLock()
447	defer c.titleMu.RUnlock()
448	return c.paneTitles[paneID]
449}
450
451// onTitleChanged updates title tracking when a WebView's title changes.
452func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
453	log := logging.FromContext(ctx)
454
455	// Update title map
456	c.titleMu.Lock()
457	c.paneTitles[paneID] = title
458	c.titleMu.Unlock()
459
460	// Update domain model
461	ws, wsView := c.getActiveWS()
462	if ws != nil {
463		paneNode := ws.FindPane(paneID)
464		if paneNode != nil && paneNode.Pane != nil {
465			paneNode.Pane.Title = title
466		}
467	}
468
469	// Update StackedView title bar if this pane is in a stack
470	if wsView != nil {
471		tr := wsView.TreeRenderer()
472		if tr != nil {
473			stackedView := tr.GetStackedViewForPane(string(paneID))
474			if stackedView != nil {
475				c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
476			}
477		}
478	}
479
480	// Notify history persistence (get URL from WebView)
481	if c.onTitleUpdated != nil {
482		if wv := c.webViews[paneID]; wv != nil {
483			url := wv.URI()
484			if url != "" && title != "" {
485				c.onTitleUpdated(ctx, paneID, url, title)
486			}
487		}
488	}
489
490	log.Debug().
491		Str("pane_id", string(paneID)).
492		Str("title", title).
493		Msg("pane title updated")
494}
495
496// updateStackedPaneTitle updates the title of a pane in a StackedView.
497func (c *ContentCoordinator) updateStackedPaneTitle(
498	ctx context.Context,
499	sv *layout.StackedView,
500	paneID entity.PaneID,
501	title string,
502) {
503	log := logging.FromContext(ctx)
504
505	// Find the pane's index directly in the StackedView
506	index := sv.FindPaneIndex(string(paneID))
507	if index < 0 {
508		log.Debug().
509			Str("pane_id", string(paneID)).
510			Msg("pane not found in StackedView for title update")
511		return
512	}
513
514	if err := sv.UpdateTitle(index, title); err != nil {
515		log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane title")
516	}
517}
518
519// onFaviconChanged updates favicon tracking when a WebView's favicon changes.
520func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) {
521	log := logging.FromContext(ctx)
522
523	// Get current URI to extract domain for caching
524	wv := c.webViews[paneID]
525	if wv == nil {
526		return
527	}
528	uri := wv.URI()
529
530	// Update favicon cache with domain key (handles cross-domain redirects)
531	if c.faviconAdapter != nil && favicon != nil && uri != "" {
532		c.navOriginMu.RLock()
533		originURL := c.navOrigins[paneID]
534		c.navOriginMu.RUnlock()
535		c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon)
536	}
537
538	// Update StackedView favicon if this pane is in a stack
539	_, wsView := c.getActiveWS()
540	if wsView != nil {
541		tr := wsView.TreeRenderer()
542		if tr != nil {
543			stackedView := tr.GetStackedViewForPane(string(paneID))
544			if stackedView != nil {
545				c.updateStackedPaneFavicon(ctx, stackedView, paneID, favicon)
546			}
547		}
548	}
549
550	log.Debug().
551		Str("pane_id", string(paneID)).
552		Str("uri", uri).
553		Bool("has_favicon", favicon != nil).
554		Msg("pane favicon updated")
555}
556
557// updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
558func (c *ContentCoordinator) updateStackedPaneFavicon(
559	ctx context.Context,
560	sv *layout.StackedView,
561	paneID entity.PaneID,
562	favicon *gdk.Texture,
563) {
564	log := logging.FromContext(ctx)
565
566	// Find the pane's index directly in the StackedView
567	index := sv.FindPaneIndex(string(paneID))
568	if index < 0 {
569		log.Debug().
570			Str("pane_id", string(paneID)).
571			Msg("pane not found in StackedView for favicon update")
572		return
573	}
574
575	if err := sv.UpdateFaviconTexture(index, favicon); err != nil {
576		log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane favicon")
577	}
578}
579
580// FaviconAdapter returns the favicon adapter for external use (e.g., omnibox).
581func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter {
582	return c.faviconAdapter
583}
584
585// SetNavigationOrigin records the original URL before navigation starts.
586// This allows caching favicons under both original and final domains
587// when cross-domain redirects occur (e.g., google.fr → google.com).
588func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url string) {
589	c.navOriginMu.Lock()
590	c.navOrigins[paneID] = url
591	c.navOriginMu.Unlock()
592}
593
594// PreloadCachedFavicon checks the favicon cache and updates the stacked pane
595// title bar immediately if a cached favicon exists for the URL.
596// This provides instant favicon display without waiting for WebKit.
597func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, url string) {
598	if c.faviconAdapter == nil || url == "" {
599		return
600	}
601
602	// Check memory and disk cache (no external fetch)
603	texture := c.faviconAdapter.PreloadFromCache(url)
604	if texture == nil {
605		return
606	}
607
608	// Update stacked pane favicon if applicable
609	_, wsView := c.getActiveWS()
610	if wsView != nil {
611		tr := wsView.TreeRenderer()
612		if tr != nil {
613			stackedView := tr.GetStackedViewForPane(string(paneID))
614			if stackedView != nil {
615				c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
616			}
617		}
618	}
619}
620
621// onLoadCommitted re-applies zoom when page content starts loading and records history.
622// WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
623// History is recorded here because the URI is guaranteed to be correct after commit.
624// Also shows the WebView widget (it's hidden during creation to avoid white flash).
625func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
626	log := logging.FromContext(ctx)
627
628	// Show the WebView now that content is being painted
629	// (WebViews are hidden on creation to avoid white flash)
630	if inner := wv.Widget(); inner != nil {
631		inner.SetVisible(true)
632		log.Debug().Str("pane_id", string(paneID)).Msg("webview shown on load committed")
633	}
634
635	url := wv.URI()
636	if url == "" {
637		return
638	}
639
640	// Record history - URI is guaranteed to be correct at LoadCommitted
641	if c.onHistoryRecord != nil {
642		c.onHistoryRecord(ctx, paneID, url)
643	}
644
645	// Apply zoom
646	if c.zoomUC == nil {
647		return
648	}
649
650	domain, err := usecase.ExtractDomain(url)
651	if err != nil {
652		return
653	}
654
655	_ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
656}
657
658// onSPANavigation records history when URL changes via JavaScript (History API).
659// This handles SPA navigation like YouTube search, where the URL changes without a page load.
660func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
661	log := logging.FromContext(ctx)
662	log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")
663
664	// Record history for SPA navigation
665	if c.onHistoryRecord != nil {
666		c.onHistoryRecord(ctx, paneID, url)
667	}
668}
669
670// onLoadStarted shows the progress bar when page loading begins.
671func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
672	_, wsView := c.getActiveWS()
673	if wsView == nil {
674		return
675	}
676
677	paneView := wsView.GetPaneView(paneID)
678	if paneView != nil {
679		paneView.SetLoading(true)
680	}
681}
682
683// onLoadFinished hides the progress bar when page loading completes.
684func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
685	_, wsView := c.getActiveWS()
686	if wsView == nil {
687		return
688	}
689
690	paneView := wsView.GetPaneView(paneID)
691	if paneView != nil {
692		paneView.SetLoading(false)
693	}
694}
695
696// onProgressChanged updates the progress bar with current load progress.
697func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
698	_, wsView := c.getActiveWS()
699	if wsView == nil {
700		return
701	}
702
703	paneView := wsView.GetPaneView(paneID)
704	if paneView != nil {
705		paneView.SetLoadProgress(progress)
706	}
707}
708
709// --- Popup Handling ---
710
711// SetPopupConfig configures popup handling.
712func (c *ContentCoordinator) SetPopupConfig(
713	factory *webkit.WebViewFactory,
714	popupConfig *config.PopupBehaviorConfig,
715	generateID func() string,
716) {
717	c.factory = factory
718	c.popupConfig = popupConfig
719	c.generateID = generateID
720}
721
722// SetOnInsertPopup sets the callback to insert popups into the workspace.
723func (c *ContentCoordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
724	c.onInsertPopup = fn
725}
726
727// SetOnClosePane sets the callback to close a pane when its popup closes.
728func (c *ContentCoordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
729	c.onClosePane = fn
730}
731
732// SetupPopupHandling wires the popup create signal for a WebView.
733// This should be called after acquiring a WebView for a pane.
734func (c *ContentCoordinator) SetupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
735	log := logging.FromContext(ctx)
736
737	if wv == nil {
738		return
739	}
740
741	// Wire the OnCreate callback for popup requests
742	wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView {
743		return c.handlePopupCreate(ctx, paneID, wv, req)
744	}
745
746	log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview")
747}
748
749// handlePopupCreate handles the WebKit "create" signal for popup windows.
750// Returns a new related WebView if popup is allowed, nil to block.
751func (c *ContentCoordinator) handlePopupCreate(
752	ctx context.Context,
753	parentPaneID entity.PaneID,
754	parentWV *webkit.WebView,
755	req webkit.PopupRequest,
756) *webkit.WebView {
757	log := logging.FromContext(ctx)
758
759	log.Debug().
760		Str("parent_pane", string(parentPaneID)).
761		Str("target_uri", req.TargetURI).
762		Str("frame_name", req.FrameName).
763		Bool("user_gesture", req.IsUserGesture).
764		Msg("popup create request")
765
766	// Check if popups are enabled
767	if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
768		log.Debug().Msg("popups disabled by config, blocking")
769		return nil
770	}
771
772	// Check if factory is available
773	if c.factory == nil {
774		log.Warn().Msg("no webview factory, cannot create popup")
775		return nil
776	}
777
778	// Create related WebView for session sharing
779	parentID := parentWV.ID()
780	popupWV, err := c.factory.CreateRelated(ctx, parentID)
781	if err != nil {
782		log.Error().Err(err).Msg("failed to create related webview for popup")
783		return nil
784	}
785
786	// Detect popup type
787	popupType := DetectPopupType(req.FrameName)
788
789	// Store pending popup for ready-to-show handling
790	popupID := popupWV.ID()
791	pending := &PendingPopup{
792		WebView:         popupWV,
793		ParentPaneID:    parentPaneID,
794		ParentWebViewID: parentID,
795		TargetURI:       req.TargetURI,
796		FrameName:       req.FrameName,
797		IsUserGesture:   req.IsUserGesture,
798		PopupType:       popupType,
799		CreatedAt:       time.Now(),
800	}
801
802	c.popupMu.Lock()
803	c.pendingPopups[popupID] = pending
804	c.popupMu.Unlock()
805
806	// Wire ready-to-show and close signals
807	popupWV.OnReadyToShow = func() {
808		c.handlePopupReadyToShow(ctx, popupID)
809	}
810	popupWV.OnClose = func() {
811		c.handlePopupClose(ctx, popupID)
812	}
813
814	log.Info().
815		Uint64("popup_id", uint64(popupID)).
816		Str("popup_type", popupType.String()).
817		Str("target_uri", req.TargetURI).
818		Msg("popup created, awaiting ready-to-show")
819
820	return popupWV
821}
822
823// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
824// This is called when the popup is configured and ready to be displayed.
825func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
826	log := logging.FromContext(ctx)
827
828	// Get pending popup
829	c.popupMu.Lock()
830	pending, ok := c.pendingPopups[popupID]
831	if ok {
832		delete(c.pendingPopups, popupID)
833	}
834	c.popupMu.Unlock()
835
836	if !ok || pending == nil {
837		log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
838		return
839	}
840
841	log.Debug().
842		Uint64("popup_id", uint64(popupID)).
843		Str("popup_type", pending.PopupType.String()).
844		Msg("popup ready to show")
845
846	// Determine behavior from config
847	behavior := GetBehavior(pending.PopupType, c.popupConfig)
848	placement := "right"
849	if c.popupConfig != nil {
850		placement = c.popupConfig.Placement
851	}
852
853	// Create popup pane entity
854	var paneID entity.PaneID
855	if c.generateID != nil {
856		paneID = entity.PaneID(c.generateID())
857	} else {
858		paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
859	}
860
861	popupPane := entity.NewPane(paneID)
862	popupPane.WindowType = entity.WindowPopup
863	popupPane.IsRelated = true
864	popupPane.ParentPaneID = &pending.ParentPaneID
865	popupPane.URI = pending.TargetURI
866
867	// Enable OAuth auto-close if configured
868	if c.popupConfig != nil && c.popupConfig.OAuthAutoClose && IsOAuthURL(pending.TargetURI) {
869		popupPane.AutoClose = true
870		c.setupOAuthAutoClose(ctx, paneID, pending.WebView)
871	}
872
873	// Register WebView in our map
874	c.webViews[paneID] = pending.WebView
875
876	// Setup standard callbacks
877	c.setupWebViewCallbacks(ctx, paneID, pending.WebView)
878
879	// Request insertion into workspace
880	if c.onInsertPopup != nil {
881		popupInput := InsertPopupInput{
882			ParentPaneID: pending.ParentPaneID,
883			PopupPane:    popupPane,
884			WebView:      pending.WebView,
885			Behavior:     behavior,
886			Placement:    placement,
887			PopupType:    pending.PopupType,
888			TargetURI:    pending.TargetURI,
889		}
890
891		if err := c.onInsertPopup(ctx, popupInput); err != nil {
892			log.Error().Err(err).Msg("failed to insert popup into workspace")
893			// Clean up on failure
894			delete(c.webViews, paneID)
895			pending.WebView.Destroy()
896			return
897		}
898	}
899
900	log.Info().
901		Str("pane_id", string(paneID)).
902		Str("behavior", string(behavior)).
903		Str("placement", placement).
904		Msg("popup inserted into workspace")
905}
906
907// handlePopupClose handles the WebKit "close" signal for popup windows.
908func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
909	log := logging.FromContext(ctx)
910	log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
911
912	// Check if still pending (never shown)
913	c.popupMu.Lock()
914	pending, wasPending := c.pendingPopups[popupID]
915	if wasPending {
916		delete(c.pendingPopups, popupID)
917	}
918	c.popupMu.Unlock()
919
920	if wasPending && pending != nil {
921		// Was never shown, just destroy
922		pending.WebView.Destroy()
923		log.Debug().Msg("destroyed pending popup that was never shown")
924		return
925	}
926
927	// Find pane by WebView ID
928	var paneID entity.PaneID
929	for pid, wv := range c.webViews {
930		if wv != nil && wv.ID() == popupID {
931			paneID = pid
932			break
933		}
934	}
935
936	if paneID == "" {
937		log.Warn().Msg("popup close: could not find pane for webview")
938		return
939	}
940
941	// Close the pane in workspace (this removes the UI element)
942	if c.onClosePane != nil {
943		if err := c.onClosePane(ctx, paneID); err != nil {
944			log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
945		}
946	}
947
948	// Release the WebView (this will clean up tracking)
949	c.ReleaseWebView(ctx, paneID)
950
951	log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
952}
953
954// setupWebViewCallbacks configures standard callbacks for a WebView.
955func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
956	// Title changes
957	wv.OnTitleChanged = func(title string) {
958		c.onTitleChanged(ctx, paneID, title)
959	}
960
961	// Favicon changes
962	wv.OnFaviconChanged = func(favicon *gdk.Texture) {
963		c.onFaviconChanged(ctx, paneID, favicon)
964	}
965
966	// Load events
967	wv.OnLoadChanged = func(event webkit.LoadEvent) {
968		switch event {
969		case webkit.LoadStarted:
970			c.onLoadStarted(paneID)
971		case webkit.LoadCommitted:
972			c.onLoadCommitted(ctx, paneID, wv)
973		case webkit.LoadFinished:
974			c.onLoadFinished(paneID)
975		}
976	}
977
978	// Progress
979	wv.OnProgressChanged = func(progress float64) {
980		c.onProgressChanged(paneID, progress)
981	}
982
983	// SPA navigation
984	wv.OnURIChanged = func(uri string) {
985		if !wv.IsLoading() && uri != "" {
986			c.onSPANavigation(ctx, paneID, uri)
987		}
988	}
989
990	// Middle-click / Ctrl+click handler for opening links in new pane
991	wv.OnLinkMiddleClick = func(uri string) bool {
992		return c.handleLinkMiddleClick(ctx, paneID, uri)
993	}
994
995	// Fullscreen handlers for idle inhibition
996	c.setupFullscreenHandlers(ctx, paneID, wv)
997}
998
999// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
1000func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1001	log := logging.FromContext(ctx)
1002
1003	// Wrap the existing OnURIChanged to also check for OAuth callbacks
1004	originalURIChanged := wv.OnURIChanged
1005	wv.OnURIChanged = func(uri string) {
1006		// Call original handler first
1007		if originalURIChanged != nil {
1008			originalURIChanged(uri)
1009		}
1010
1011		// Check for OAuth callback
1012		if ShouldAutoClose(uri) {
1013			log.Info().
1014				Str("pane_id", string(paneID)).
1015				Str("uri", uri).
1016				Msg("OAuth callback detected, auto-closing popup")
1017
1018			// Trigger close after a short delay to let the page process
1019			go func() {
1020				time.Sleep(500 * time.Millisecond)
1021				if wv != nil && !wv.IsDestroyed() {
1022					// This will trigger handlePopupClose
1023					wv.Close()
1024				}
1025			}()
1026		}
1027	}
1028
1029	// Also check on load committed for immediate redirects
1030	originalLoadChanged := wv.OnLoadChanged
1031	wv.OnLoadChanged = func(event webkit.LoadEvent) {
1032		if originalLoadChanged != nil {
1033			originalLoadChanged(event)
1034		}
1035
1036		if event == webkit.LoadCommitted {
1037			uri := wv.URI()
1038			if ShouldAutoClose(uri) {
1039				log.Info().
1040					Str("pane_id", string(paneID)).
1041					Str("uri", uri).
1042					Msg("OAuth callback detected on load, auto-closing popup")
1043
1044				go func() {
1045					time.Sleep(500 * time.Millisecond)
1046					if wv != nil && !wv.IsDestroyed() {
1047						wv.Close()
1048					}
1049				}()
1050			}
1051		}
1052	}
1053}
1054
1055// handleLinkMiddleClick handles middle-click / Ctrl+click on links.
1056// Opens the link in a new pane using blank_target_behavior config.
1057func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
1058	log := logging.FromContext(ctx)
1059
1060	log.Info().
1061		Str("parent_pane", string(parentPaneID)).
1062		Str("uri", uri).
1063		Msg("middle-click/ctrl+click on link")
1064
1065	// Check if popups are enabled
1066	if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
1067		log.Debug().Msg("popups disabled by config, ignoring middle-click")
1068		return false
1069	}
1070
1071	// Check if factory is available
1072	if c.factory == nil {
1073		log.Warn().Msg("no webview factory, cannot handle middle-click")
1074		return false
1075	}
1076
1077	// Create a new WebView (regular, not related - just opening a link)
1078	newWV, err := c.factory.Create(ctx)
1079	if err != nil {
1080		log.Error().Err(err).Msg("failed to create webview for middle-click")
1081		return false
1082	}
1083
1084	// Generate pane ID
1085	var paneID entity.PaneID
1086	if c.generateID != nil {
1087		paneID = entity.PaneID(c.generateID())
1088	} else {
1089		paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
1090	}
1091
1092	// Create pane entity - uses blank_target_behavior
1093	newPane := entity.NewPane(paneID)
1094	newPane.WindowType = entity.WindowPopup
1095	newPane.URI = uri
1096
1097	// Register WebView
1098	c.webViews[paneID] = newWV
1099
1100	// Setup callbacks
1101	c.setupWebViewCallbacks(ctx, paneID, newWV)
1102
1103	// Get behavior from config (same as _blank links)
1104	behavior := config.PopupBehaviorStacked // default
1105	if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
1106		behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior)
1107	}
1108	placement := "right"
1109	if c.popupConfig != nil {
1110		placement = c.popupConfig.Placement
1111	}
1112
1113	// Request insertion into workspace
1114	if c.onInsertPopup != nil {
1115		popupInput := InsertPopupInput{
1116			ParentPaneID: parentPaneID,
1117			PopupPane:    newPane,
1118			WebView:      newWV,
1119			Behavior:     behavior,
1120			Placement:    placement,
1121			PopupType:    PopupTypeTab, // Treat like _blank
1122			TargetURI:    uri,
1123		}
1124
1125		if err := c.onInsertPopup(ctx, popupInput); err != nil {
1126			log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
1127			// Clean up on failure
1128			delete(c.webViews, paneID)
1129			newWV.Destroy()
1130			return false
1131		}
1132	}
1133
1134	// Load the URI after insertion
1135	if err := newWV.LoadURI(ctx, uri); err != nil {
1136		log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane")
1137	}
1138
1139	log.Info().
1140		Str("pane_id", string(paneID)).
1141		Str("behavior", string(behavior)).
1142		Str("uri", uri).
1143		Msg("middle-click link opened in new pane")
1144
1145	return true
1146}
1147
1148// setupFullscreenHandlers configures fullscreen callbacks for idle inhibition.
1149func (c *ContentCoordinator) setupFullscreenHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1150	log := logging.FromContext(ctx)
1151
1152	if wv == nil {
1153		return
1154	}
1155
1156	wv.OnEnterFullscreen = func() bool {
1157		if c.idleInhibitor != nil {
1158			if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
1159				log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
1160			}
1161		}
1162		return false // Allow fullscreen
1163	}
1164
1165	wv.OnLeaveFullscreen = func() bool {
1166		if c.idleInhibitor != nil {
1167			if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
1168				log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
1169			}
1170		}
1171		return false // Allow leaving fullscreen
1172	}
1173}

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