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(>kView.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}