v0.20.0 c741a39

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

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