detached 24f586c

feat(coordinator): implement popup handling with middle-click and window.open support

Add comprehensive popup handling in ContentCoordinator including:
- PopupType enumeration to distinguish tab-like (_blank) vs JS popups
- PendingPopup struct to track popup lifecycle (create → ready-to-show → insert)
- handlePopupCreate/handlePopupReadyToShow/handlePopupClose methods
- handleLinkMiddleClick handler for middle-click/Ctrl+click link detection
- OAuth auto-close detection with pattern matching
- WebViewFactory for creating regular and related (popup) WebViews

This enables popups and middle-click links to be inserted into the workspace
with proper session sharing via related WebViews.
commit
24f586c31e8525f613cf995d077a5239f5c5c875
author
brice · · 2025-12-17 19:09 UTC
committer
brice · · 2025-12-17 22:43 UTC
parent
884f77a
signature
unsigned
4 files · +869 -0
A internal/infrastructure/webkit/factory.go +129 -0
@@ -0,0 +1,129 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+// WebViewFactory creates WebView instances for the application.
12+// It supports creating regular WebViews (optionally via pool) and
13+// related WebViews that share session/cookies with a parent (for popups).
14+type WebViewFactory struct {
15+ wkCtx *WebKitContext
16+ settings *SettingsManager
17+ pool *WebViewPool
18+ injector *ContentInjector
19+ router *MessageRouter
20+}
21+
22+// NewWebViewFactory creates a new WebViewFactory.
23+// The pool parameter is optional; if nil, WebViews are created directly.
24+func NewWebViewFactory(
25+ wkCtx *WebKitContext,
26+ settings *SettingsManager,
27+ pool *WebViewPool,
28+ injector *ContentInjector,
29+ router *MessageRouter,
30+) *WebViewFactory {
31+ return &WebViewFactory{
32+ wkCtx: wkCtx,
33+ settings: settings,
34+ pool: pool,
35+ injector: injector,
36+ router: router,
37+ }
38+}
39+
40+// Create creates a new WebView instance.
41+// If a pool is configured, it will try to acquire from the pool first.
42+func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) {
43+ log := logging.FromContext(ctx)
44+
45+ // Try pool first if available
46+ if f.pool != nil {
47+ wv, err := f.pool.Acquire(ctx)
48+ if err == nil && wv != nil {
49+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool")
50+ return wv, nil
51+ }
52+ log.Debug().Err(err).Msg("pool acquire failed, creating directly")
53+ }
54+
55+ // Create directly
56+ return f.createDirect(ctx)
57+}
58+
59+// CreateRelated creates a WebView that shares session/cookies with the parent.
60+// This is required for popup windows to maintain authentication state (OAuth).
61+// Related WebViews bypass the pool since they must be linked to a specific parent.
62+func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebViewID) (*WebView, error) {
63+ log := logging.FromContext(ctx)
64+
65+ // Look up parent WebView
66+ parent := LookupWebView(WebViewID(parentID))
67+ if parent == nil {
68+ return nil, fmt.Errorf("parent webview %d not found", parentID)
69+ }
70+ if parent.IsDestroyed() {
71+ return nil, fmt.Errorf("parent webview %d is destroyed", parentID)
72+ }
73+
74+ // Create related WebView
75+ wv, err := NewWebViewWithRelated(ctx, parent, f.settings)
76+ if err != nil {
77+ return nil, fmt.Errorf("create related webview: %w", err)
78+ }
79+
80+ // Ensure visible
81+ wv.inner.SetVisible(true)
82+
83+ // Attach frontend (scripts, message handler)
84+ if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil {
85+ log.Warn().Err(err).Uint64("id", uint64(wv.ID())).Msg("failed to attach frontend to related webview")
86+ }
87+
88+ log.Debug().
89+ Uint64("id", uint64(wv.ID())).
90+ Uint64("parent_id", uint64(parentID)).
91+ Msg("created related webview for popup")
92+
93+ return wv, nil
94+}
95+
96+// createDirect creates a WebView without using the pool.
97+func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
98+ log := logging.FromContext(ctx)
99+
100+ wv, err := NewWebView(ctx, f.wkCtx, f.settings)
101+ if err != nil {
102+ return nil, err
103+ }
104+
105+ // Ensure visible
106+ wv.inner.SetVisible(true)
107+
108+ // Attach frontend
109+ if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil {
110+ log.Warn().Err(err).Uint64("id", uint64(wv.ID())).Msg("failed to attach frontend to webview")
111+ }
112+
113+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("created webview directly")
114+ return wv, nil
115+}
116+
117+// Release returns a WebView to the pool if available, otherwise destroys it.
118+// Related WebViews (popups) should be destroyed directly, not released to pool.
119+func (f *WebViewFactory) Release(ctx context.Context, wv *WebView) {
120+ if wv == nil || wv.IsDestroyed() {
121+ return
122+ }
123+
124+ if f.pool != nil {
125+ f.pool.Release(ctx, wv)
126+ } else {
127+ wv.Destroy()
128+ }
129+}
@@ -0,0 +1,129 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+// WebViewFactory creates WebView instances for the application.
12+// It supports creating regular WebViews (optionally via pool) and
13+// related WebViews that share session/cookies with a parent (for popups).
14+type WebViewFactory struct {
15+ wkCtx *WebKitContext
16+ settings *SettingsManager
17+ pool *WebViewPool
18+ injector *ContentInjector
19+ router *MessageRouter
20+}
21+
22+// NewWebViewFactory creates a new WebViewFactory.
23+// The pool parameter is optional; if nil, WebViews are created directly.
24+func NewWebViewFactory(
25+ wkCtx *WebKitContext,
26+ settings *SettingsManager,
27+ pool *WebViewPool,
28+ injector *ContentInjector,
29+ router *MessageRouter,
30+) *WebViewFactory {
31+ return &WebViewFactory{
32+ wkCtx: wkCtx,
33+ settings: settings,
34+ pool: pool,
35+ injector: injector,
36+ router: router,
37+ }
38+}
39+
40+// Create creates a new WebView instance.
41+// If a pool is configured, it will try to acquire from the pool first.
42+func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) {
43+ log := logging.FromContext(ctx)
44+
45+ // Try pool first if available
46+ if f.pool != nil {
47+ wv, err := f.pool.Acquire(ctx)
48+ if err == nil && wv != nil {
49+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool")
50+ return wv, nil
51+ }
52+ log.Debug().Err(err).Msg("pool acquire failed, creating directly")
53+ }
54+
55+ // Create directly
56+ return f.createDirect(ctx)
57+}
58+
59+// CreateRelated creates a WebView that shares session/cookies with the parent.
60+// This is required for popup windows to maintain authentication state (OAuth).
61+// Related WebViews bypass the pool since they must be linked to a specific parent.
62+func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebViewID) (*WebView, error) {
63+ log := logging.FromContext(ctx)
64+
65+ // Look up parent WebView
66+ parent := LookupWebView(WebViewID(parentID))
67+ if parent == nil {
68+ return nil, fmt.Errorf("parent webview %d not found", parentID)
69+ }
70+ if parent.IsDestroyed() {
71+ return nil, fmt.Errorf("parent webview %d is destroyed", parentID)
72+ }
73+
74+ // Create related WebView
75+ wv, err := NewWebViewWithRelated(ctx, parent, f.settings)
76+ if err != nil {
77+ return nil, fmt.Errorf("create related webview: %w", err)
78+ }
79+
80+ // Ensure visible
81+ wv.inner.SetVisible(true)
82+
83+ // Attach frontend (scripts, message handler)
84+ if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil {
85+ log.Warn().Err(err).Uint64("id", uint64(wv.ID())).Msg("failed to attach frontend to related webview")
86+ }
87+
88+ log.Debug().
89+ Uint64("id", uint64(wv.ID())).
90+ Uint64("parent_id", uint64(parentID)).
91+ Msg("created related webview for popup")
92+
93+ return wv, nil
94+}
95+
96+// createDirect creates a WebView without using the pool.
97+func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
98+ log := logging.FromContext(ctx)
99+
100+ wv, err := NewWebView(ctx, f.wkCtx, f.settings)
101+ if err != nil {
102+ return nil, err
103+ }
104+
105+ // Ensure visible
106+ wv.inner.SetVisible(true)
107+
108+ // Attach frontend
109+ if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil {
110+ log.Warn().Err(err).Uint64("id", uint64(wv.ID())).Msg("failed to attach frontend to webview")
111+ }
112+
113+ log.Debug().Uint64("id", uint64(wv.ID())).Msg("created webview directly")
114+ return wv, nil
115+}
116+
117+// Release returns a WebView to the pool if available, otherwise destroys it.
118+// Related WebViews (popups) should be destroyed directly, not released to pool.
119+func (f *WebViewFactory) Release(ctx context.Context, wv *WebView) {
120+ if wv == nil || wv.IsDestroyed() {
121+ return
122+ }
123+
124+ if f.pool != nil {
125+ f.pool.Release(ctx, wv)
126+ } else {
127+ wv.Destroy()
128+ }
129+}
M internal/ui/coordinator/content.go +471 -0
@@ -4,9 +4,12 @@ import (
44 "context"
55 "fmt"
66 "sync"
7+ "time"
78
9+ "github.com/bnema/dumber/internal/application/port"
810 "github.com/bnema/dumber/internal/application/usecase"
911 "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/infrastructure/config"
1013 "github.com/bnema/dumber/internal/infrastructure/webkit"
1114 "github.com/bnema/dumber/internal/logging"
1215 "github.com/bnema/dumber/internal/ui/cache"
@@ -43,6 +46,21 @@ type ContentCoordinator struct {
4346
4447 // Gesture action handler for mouse button navigation
4548 gestureActionHandler input.ActionHandler
49+
50+ // Popup handling
51+ factory *webkit.WebViewFactory
52+ popupConfig *config.PopupBehaviorConfig
53+ pendingPopups map[port.WebViewID]*PendingPopup
54+ popupMu sync.RWMutex
55+
56+ // Callback to insert popup into workspace (avoids circular dependency)
57+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
58+
59+ // Callback to close a pane when popup closes
60+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
61+
62+ // ID generator for popup panes
63+ generateID func() string
4664 }
4765
4866 // NewContentCoordinator creates a new ContentCoordinator.
@@ -66,6 +84,7 @@ func NewContentCoordinator(
6684 paneTitles: make(map[entity.PaneID]string),
6785 navOrigins: make(map[entity.PaneID]string),
6886 getActiveWS: getActiveWS,
87+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
6988 }
7089 }
7190
@@ -140,6 +159,14 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
140159 }
141160 }
142161
162+ // Set up middle-click / Ctrl+click handler for opening links in new pane
163+ wv.OnLinkMiddleClick = func(uri string) bool {
164+ return c.handleLinkMiddleClick(ctx, paneID, uri)
165+ }
166+
167+ // Set up popup handling for this WebView
168+ c.SetupPopupHandling(ctx, paneID, wv)
169+
143170 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
144171 return wv, nil
145172 }
@@ -262,6 +289,14 @@ func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
262289 return c.webViews[paneID]
263290 }
264291
292+// RegisterPopupWebView registers a popup WebView that was created externally.
293+// This is used when popup tabs are created and the WebView needs to be tracked.
294+func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {
295+ if wv != nil && paneID != "" {
296+ c.webViews[paneID] = wv
297+ }
298+}
299+
265300 // GetTitle returns the current title for a pane.
266301 func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {
267302 c.titleMu.RLock()
@@ -528,3 +563,439 @@ func (c *ContentCoordinator) onProgressChanged(ctx context.Context, paneID entit
528563 paneView.SetLoadProgress(progress)
529564 }
530565 }
566+
567+// --- Popup Handling ---
568+
569+// SetPopupConfig configures popup handling.
570+func (c *ContentCoordinator) SetPopupConfig(
571+ factory *webkit.WebViewFactory,
572+ popupConfig *config.PopupBehaviorConfig,
573+ generateID func() string,
574+) {
575+ c.factory = factory
576+ c.popupConfig = popupConfig
577+ c.generateID = generateID
578+}
579+
580+// SetOnInsertPopup sets the callback to insert popups into the workspace.
581+func (c *ContentCoordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
582+ c.onInsertPopup = fn
583+}
584+
585+// SetOnClosePane sets the callback to close a pane when its popup closes.
586+func (c *ContentCoordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
587+ c.onClosePane = fn
588+}
589+
590+// SetupPopupHandling wires the popup create signal for a WebView.
591+// This should be called after acquiring a WebView for a pane.
592+func (c *ContentCoordinator) SetupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
593+ log := logging.FromContext(ctx)
594+
595+ if wv == nil {
596+ return
597+ }
598+
599+ // Wire the OnCreate callback for popup requests
600+ wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView {
601+ return c.handlePopupCreate(ctx, paneID, wv, req)
602+ }
603+
604+ log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview")
605+}
606+
607+// handlePopupCreate handles the WebKit "create" signal for popup windows.
608+// Returns a new related WebView if popup is allowed, nil to block.
609+func (c *ContentCoordinator) handlePopupCreate(
610+ ctx context.Context,
611+ parentPaneID entity.PaneID,
612+ parentWV *webkit.WebView,
613+ req webkit.PopupRequest,
614+) *webkit.WebView {
615+ log := logging.FromContext(ctx)
616+
617+ log.Debug().
618+ Str("parent_pane", string(parentPaneID)).
619+ Str("target_uri", req.TargetURI).
620+ Str("frame_name", req.FrameName).
621+ Bool("user_gesture", req.IsUserGesture).
622+ Msg("popup create request")
623+
624+ // Check if popups are enabled
625+ if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
626+ log.Debug().Msg("popups disabled by config, blocking")
627+ return nil
628+ }
629+
630+ // Check if factory is available
631+ if c.factory == nil {
632+ log.Warn().Msg("no webview factory, cannot create popup")
633+ return nil
634+ }
635+
636+ // Create related WebView for session sharing
637+ parentID := port.WebViewID(parentWV.ID())
638+ popupWV, err := c.factory.CreateRelated(ctx, parentID)
639+ if err != nil {
640+ log.Error().Err(err).Msg("failed to create related webview for popup")
641+ return nil
642+ }
643+
644+ // Detect popup type
645+ popupType := DetectPopupType(req.FrameName)
646+
647+ // Store pending popup for ready-to-show handling
648+ popupID := port.WebViewID(popupWV.ID())
649+ pending := &PendingPopup{
650+ WebView: popupWV,
651+ ParentPaneID: parentPaneID,
652+ ParentWebViewID: parentID,
653+ TargetURI: req.TargetURI,
654+ FrameName: req.FrameName,
655+ IsUserGesture: req.IsUserGesture,
656+ PopupType: popupType,
657+ CreatedAt: time.Now(),
658+ }
659+
660+ c.popupMu.Lock()
661+ c.pendingPopups[popupID] = pending
662+ c.popupMu.Unlock()
663+
664+ // Wire ready-to-show and close signals
665+ popupWV.OnReadyToShow = func() {
666+ c.handlePopupReadyToShow(ctx, popupID)
667+ }
668+ popupWV.OnClose = func() {
669+ c.handlePopupClose(ctx, popupID)
670+ }
671+
672+ log.Info().
673+ Uint64("popup_id", uint64(popupID)).
674+ Str("popup_type", popupType.String()).
675+ Str("target_uri", req.TargetURI).
676+ Msg("popup created, awaiting ready-to-show")
677+
678+ return popupWV
679+}
680+
681+// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
682+// This is called when the popup is configured and ready to be displayed.
683+func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
684+ log := logging.FromContext(ctx)
685+
686+ // Get pending popup
687+ c.popupMu.Lock()
688+ pending, ok := c.pendingPopups[popupID]
689+ if ok {
690+ delete(c.pendingPopups, popupID)
691+ }
692+ c.popupMu.Unlock()
693+
694+ if !ok || pending == nil {
695+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
696+ return
697+ }
698+
699+ log.Debug().
700+ Uint64("popup_id", uint64(popupID)).
701+ Str("popup_type", pending.PopupType.String()).
702+ Msg("popup ready to show")
703+
704+ // Determine behavior from config
705+ behavior := GetBehavior(pending.PopupType, c.popupConfig)
706+ placement := "right"
707+ if c.popupConfig != nil {
708+ placement = c.popupConfig.Placement
709+ }
710+
711+ // Create popup pane entity
712+ var paneID entity.PaneID
713+ if c.generateID != nil {
714+ paneID = entity.PaneID(c.generateID())
715+ } else {
716+ paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
717+ }
718+
719+ popupPane := entity.NewPane(paneID)
720+ popupPane.WindowType = entity.WindowPopup
721+ popupPane.IsRelated = true
722+ popupPane.ParentPaneID = &pending.ParentPaneID
723+ popupPane.URI = pending.TargetURI
724+
725+ // Enable OAuth auto-close if configured
726+ if c.popupConfig != nil && c.popupConfig.OAuthAutoClose && IsOAuthURL(pending.TargetURI) {
727+ popupPane.AutoClose = true
728+ c.setupOAuthAutoClose(ctx, paneID, pending.WebView)
729+ }
730+
731+ // Register WebView in our map
732+ c.webViews[paneID] = pending.WebView
733+
734+ // Setup standard callbacks
735+ c.setupWebViewCallbacks(ctx, paneID, pending.WebView)
736+
737+ // Request insertion into workspace
738+ if c.onInsertPopup != nil {
739+ input := InsertPopupInput{
740+ ParentPaneID: pending.ParentPaneID,
741+ PopupPane: popupPane,
742+ WebView: pending.WebView,
743+ Behavior: behavior,
744+ Placement: placement,
745+ PopupType: pending.PopupType,
746+ TargetURI: pending.TargetURI,
747+ }
748+
749+ if err := c.onInsertPopup(ctx, input); err != nil {
750+ log.Error().Err(err).Msg("failed to insert popup into workspace")
751+ // Clean up on failure
752+ delete(c.webViews, paneID)
753+ pending.WebView.Destroy()
754+ return
755+ }
756+ }
757+
758+ log.Info().
759+ Str("pane_id", string(paneID)).
760+ Str("behavior", string(behavior)).
761+ Str("placement", placement).
762+ Msg("popup inserted into workspace")
763+}
764+
765+// handlePopupClose handles the WebKit "close" signal for popup windows.
766+func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
767+ log := logging.FromContext(ctx)
768+ log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
769+
770+ // Check if still pending (never shown)
771+ c.popupMu.Lock()
772+ pending, wasPending := c.pendingPopups[popupID]
773+ if wasPending {
774+ delete(c.pendingPopups, popupID)
775+ }
776+ c.popupMu.Unlock()
777+
778+ if wasPending && pending != nil {
779+ // Was never shown, just destroy
780+ pending.WebView.Destroy()
781+ log.Debug().Msg("destroyed pending popup that was never shown")
782+ return
783+ }
784+
785+ // Find pane by WebView ID
786+ var paneID entity.PaneID
787+ for pid, wv := range c.webViews {
788+ if wv != nil && port.WebViewID(wv.ID()) == popupID {
789+ paneID = pid
790+ break
791+ }
792+ }
793+
794+ if paneID == "" {
795+ log.Warn().Msg("popup close: could not find pane for webview")
796+ return
797+ }
798+
799+ // Close the pane in workspace (this removes the UI element)
800+ if c.onClosePane != nil {
801+ if err := c.onClosePane(ctx, paneID); err != nil {
802+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
803+ }
804+ }
805+
806+ // Release the WebView (this will clean up tracking)
807+ c.ReleaseWebView(ctx, paneID)
808+
809+ log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
810+}
811+
812+// setupWebViewCallbacks configures standard callbacks for a WebView.
813+func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
814+ // Title changes
815+ wv.OnTitleChanged = func(title string) {
816+ c.onTitleChanged(ctx, paneID, title)
817+ }
818+
819+ // Favicon changes
820+ wv.OnFaviconChanged = func(favicon *gdk.Texture) {
821+ c.onFaviconChanged(ctx, paneID, favicon)
822+ }
823+
824+ // Load events
825+ wv.OnLoadChanged = func(event webkit.LoadEvent) {
826+ switch event {
827+ case webkit.LoadStarted:
828+ c.onLoadStarted(ctx, paneID)
829+ case webkit.LoadCommitted:
830+ c.onLoadCommitted(ctx, paneID, wv)
831+ case webkit.LoadFinished:
832+ c.onLoadFinished(ctx, paneID)
833+ }
834+ }
835+
836+ // Progress
837+ wv.OnProgressChanged = func(progress float64) {
838+ c.onProgressChanged(ctx, paneID, progress)
839+ }
840+
841+ // SPA navigation
842+ wv.OnURIChanged = func(uri string) {
843+ if !wv.IsLoading() && uri != "" {
844+ c.onSPANavigation(ctx, paneID, uri)
845+ }
846+ }
847+
848+ // Middle-click / Ctrl+click handler for opening links in new pane
849+ wv.OnLinkMiddleClick = func(uri string) bool {
850+ return c.handleLinkMiddleClick(ctx, paneID, uri)
851+ }
852+}
853+
854+// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
855+func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
856+ log := logging.FromContext(ctx)
857+
858+ // Wrap the existing OnURIChanged to also check for OAuth callbacks
859+ originalURIChanged := wv.OnURIChanged
860+ wv.OnURIChanged = func(uri string) {
861+ // Call original handler first
862+ if originalURIChanged != nil {
863+ originalURIChanged(uri)
864+ }
865+
866+ // Check for OAuth callback
867+ if ShouldAutoClose(uri) {
868+ log.Info().
869+ Str("pane_id", string(paneID)).
870+ Str("uri", uri).
871+ Msg("OAuth callback detected, auto-closing popup")
872+
873+ // Trigger close after a short delay to let the page process
874+ go func() {
875+ time.Sleep(500 * time.Millisecond)
876+ if wv != nil && !wv.IsDestroyed() {
877+ // This will trigger handlePopupClose
878+ wv.Close()
879+ }
880+ }()
881+ }
882+ }
883+
884+ // Also check on load committed for immediate redirects
885+ originalLoadChanged := wv.OnLoadChanged
886+ wv.OnLoadChanged = func(event webkit.LoadEvent) {
887+ if originalLoadChanged != nil {
888+ originalLoadChanged(event)
889+ }
890+
891+ if event == webkit.LoadCommitted {
892+ uri := wv.URI()
893+ if ShouldAutoClose(uri) {
894+ log.Info().
895+ Str("pane_id", string(paneID)).
896+ Str("uri", uri).
897+ Msg("OAuth callback detected on load, auto-closing popup")
898+
899+ go func() {
900+ time.Sleep(500 * time.Millisecond)
901+ if wv != nil && !wv.IsDestroyed() {
902+ wv.Close()
903+ }
904+ }()
905+ }
906+ }
907+ }
908+}
909+
910+// handleLinkMiddleClick handles middle-click / Ctrl+click on links.
911+// Opens the link in a new pane using blank_target_behavior config.
912+func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
913+ log := logging.FromContext(ctx)
914+
915+ log.Info().
916+ Str("parent_pane", string(parentPaneID)).
917+ Str("uri", uri).
918+ Msg("middle-click/ctrl+click on link")
919+
920+ // Check if popups are enabled
921+ if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
922+ log.Debug().Msg("popups disabled by config, ignoring middle-click")
923+ return false
924+ }
925+
926+ // Check if factory is available
927+ if c.factory == nil {
928+ log.Warn().Msg("no webview factory, cannot handle middle-click")
929+ return false
930+ }
931+
932+ // Create a new WebView (regular, not related - just opening a link)
933+ newWV, err := c.factory.Create(ctx)
934+ if err != nil {
935+ log.Error().Err(err).Msg("failed to create webview for middle-click")
936+ return false
937+ }
938+
939+ // Generate pane ID
940+ var paneID entity.PaneID
941+ if c.generateID != nil {
942+ paneID = entity.PaneID(c.generateID())
943+ } else {
944+ paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
945+ }
946+
947+ // Create pane entity - uses blank_target_behavior
948+ newPane := entity.NewPane(paneID)
949+ newPane.WindowType = entity.WindowPopup
950+ newPane.URI = uri
951+
952+ // Register WebView
953+ c.webViews[paneID] = newWV
954+
955+ // Setup callbacks
956+ c.setupWebViewCallbacks(ctx, paneID, newWV)
957+
958+ // Get behavior from config (same as _blank links)
959+ behavior := config.PopupBehaviorStacked // default
960+ if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
961+ behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior)
962+ }
963+ placement := "right"
964+ if c.popupConfig != nil {
965+ placement = c.popupConfig.Placement
966+ }
967+
968+ // Request insertion into workspace
969+ if c.onInsertPopup != nil {
970+ input := InsertPopupInput{
971+ ParentPaneID: parentPaneID,
972+ PopupPane: newPane,
973+ WebView: newWV,
974+ Behavior: behavior,
975+ Placement: placement,
976+ PopupType: PopupTypeTab, // Treat like _blank
977+ TargetURI: uri,
978+ }
979+
980+ if err := c.onInsertPopup(ctx, input); err != nil {
981+ log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
982+ // Clean up on failure
983+ delete(c.webViews, paneID)
984+ newWV.Destroy()
985+ return false
986+ }
987+ }
988+
989+ // Load the URI after insertion
990+ if err := newWV.LoadURI(ctx, uri); err != nil {
991+ log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane")
992+ }
993+
994+ log.Info().
995+ Str("pane_id", string(paneID)).
996+ Str("behavior", string(behavior)).
997+ Str("uri", uri).
998+ Msg("middle-click link opened in new pane")
999+
1000+ return true
1001+}
@@ -4,9 +4,12 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 "sync"6 "sync"
7+ "time"
7 8
9+ "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/infrastructure/config"
10 "github.com/bnema/dumber/internal/infrastructure/webkit"13 "github.com/bnema/dumber/internal/infrastructure/webkit"
11 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/dumber/internal/ui/cache"15 "github.com/bnema/dumber/internal/ui/cache"
@@ -43,6 +46,21 @@ type ContentCoordinator struct {
43 46
44 // Gesture action handler for mouse button navigation47 // Gesture action handler for mouse button navigation
45 gestureActionHandler input.ActionHandler48 gestureActionHandler input.ActionHandler
49+
50+ // Popup handling
51+ factory *webkit.WebViewFactory
52+ popupConfig *config.PopupBehaviorConfig
53+ pendingPopups map[port.WebViewID]*PendingPopup
54+ popupMu sync.RWMutex
55+
56+ // Callback to insert popup into workspace (avoids circular dependency)
57+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
58+
59+ // Callback to close a pane when popup closes
60+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
61+
62+ // ID generator for popup panes
63+ generateID func() string
46 }64 }
47 65
48 // NewContentCoordinator creates a new ContentCoordinator.66 // NewContentCoordinator creates a new ContentCoordinator.
@@ -66,6 +84,7 @@ func NewContentCoordinator(
66 paneTitles: make(map[entity.PaneID]string),84 paneTitles: make(map[entity.PaneID]string),
67 navOrigins: make(map[entity.PaneID]string),85 navOrigins: make(map[entity.PaneID]string),
68 getActiveWS: getActiveWS,86 getActiveWS: getActiveWS,
87+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
69 }88 }
70 }89 }
71 90
@@ -140,6 +159,14 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa
140 }159 }
141 }160 }
142 161
162+ // Set up middle-click / Ctrl+click handler for opening links in new pane
163+ wv.OnLinkMiddleClick = func(uri string) bool {
164+ return c.handleLinkMiddleClick(ctx, paneID, uri)
165+ }
166+
167+ // Set up popup handling for this WebView
168+ c.SetupPopupHandling(ctx, paneID, wv)
169+
143 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")170 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
144 return wv, nil171 return wv, nil
145 }172 }
@@ -262,6 +289,14 @@ func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
262 return c.webViews[paneID]289 return c.webViews[paneID]
263 }290 }
264 291
292+// RegisterPopupWebView registers a popup WebView that was created externally.
293+// This is used when popup tabs are created and the WebView needs to be tracked.
294+func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {
295+ if wv != nil && paneID != "" {
296+ c.webViews[paneID] = wv
297+ }
298+}
299+
265 // GetTitle returns the current title for a pane.300 // GetTitle returns the current title for a pane.
266 func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {301 func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {
267 c.titleMu.RLock()302 c.titleMu.RLock()
@@ -528,3 +563,439 @@ func (c *ContentCoordinator) onProgressChanged(ctx context.Context, paneID entit
528 paneView.SetLoadProgress(progress)563 paneView.SetLoadProgress(progress)
529 }564 }
530 }565 }
566+
567+// --- Popup Handling ---
568+
569+// SetPopupConfig configures popup handling.
570+func (c *ContentCoordinator) SetPopupConfig(
571+ factory *webkit.WebViewFactory,
572+ popupConfig *config.PopupBehaviorConfig,
573+ generateID func() string,
574+) {
575+ c.factory = factory
576+ c.popupConfig = popupConfig
577+ c.generateID = generateID
578+}
579+
580+// SetOnInsertPopup sets the callback to insert popups into the workspace.
581+func (c *ContentCoordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
582+ c.onInsertPopup = fn
583+}
584+
585+// SetOnClosePane sets the callback to close a pane when its popup closes.
586+func (c *ContentCoordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
587+ c.onClosePane = fn
588+}
589+
590+// SetupPopupHandling wires the popup create signal for a WebView.
591+// This should be called after acquiring a WebView for a pane.
592+func (c *ContentCoordinator) SetupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
593+ log := logging.FromContext(ctx)
594+
595+ if wv == nil {
596+ return
597+ }
598+
599+ // Wire the OnCreate callback for popup requests
600+ wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView {
601+ return c.handlePopupCreate(ctx, paneID, wv, req)
602+ }
603+
604+ log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview")
605+}
606+
607+// handlePopupCreate handles the WebKit "create" signal for popup windows.
608+// Returns a new related WebView if popup is allowed, nil to block.
609+func (c *ContentCoordinator) handlePopupCreate(
610+ ctx context.Context,
611+ parentPaneID entity.PaneID,
612+ parentWV *webkit.WebView,
613+ req webkit.PopupRequest,
614+) *webkit.WebView {
615+ log := logging.FromContext(ctx)
616+
617+ log.Debug().
618+ Str("parent_pane", string(parentPaneID)).
619+ Str("target_uri", req.TargetURI).
620+ Str("frame_name", req.FrameName).
621+ Bool("user_gesture", req.IsUserGesture).
622+ Msg("popup create request")
623+
624+ // Check if popups are enabled
625+ if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
626+ log.Debug().Msg("popups disabled by config, blocking")
627+ return nil
628+ }
629+
630+ // Check if factory is available
631+ if c.factory == nil {
632+ log.Warn().Msg("no webview factory, cannot create popup")
633+ return nil
634+ }
635+
636+ // Create related WebView for session sharing
637+ parentID := port.WebViewID(parentWV.ID())
638+ popupWV, err := c.factory.CreateRelated(ctx, parentID)
639+ if err != nil {
640+ log.Error().Err(err).Msg("failed to create related webview for popup")
641+ return nil
642+ }
643+
644+ // Detect popup type
645+ popupType := DetectPopupType(req.FrameName)
646+
647+ // Store pending popup for ready-to-show handling
648+ popupID := port.WebViewID(popupWV.ID())
649+ pending := &PendingPopup{
650+ WebView: popupWV,
651+ ParentPaneID: parentPaneID,
652+ ParentWebViewID: parentID,
653+ TargetURI: req.TargetURI,
654+ FrameName: req.FrameName,
655+ IsUserGesture: req.IsUserGesture,
656+ PopupType: popupType,
657+ CreatedAt: time.Now(),
658+ }
659+
660+ c.popupMu.Lock()
661+ c.pendingPopups[popupID] = pending
662+ c.popupMu.Unlock()
663+
664+ // Wire ready-to-show and close signals
665+ popupWV.OnReadyToShow = func() {
666+ c.handlePopupReadyToShow(ctx, popupID)
667+ }
668+ popupWV.OnClose = func() {
669+ c.handlePopupClose(ctx, popupID)
670+ }
671+
672+ log.Info().
673+ Uint64("popup_id", uint64(popupID)).
674+ Str("popup_type", popupType.String()).
675+ Str("target_uri", req.TargetURI).
676+ Msg("popup created, awaiting ready-to-show")
677+
678+ return popupWV
679+}
680+
681+// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
682+// This is called when the popup is configured and ready to be displayed.
683+func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
684+ log := logging.FromContext(ctx)
685+
686+ // Get pending popup
687+ c.popupMu.Lock()
688+ pending, ok := c.pendingPopups[popupID]
689+ if ok {
690+ delete(c.pendingPopups, popupID)
691+ }
692+ c.popupMu.Unlock()
693+
694+ if !ok || pending == nil {
695+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
696+ return
697+ }
698+
699+ log.Debug().
700+ Uint64("popup_id", uint64(popupID)).
701+ Str("popup_type", pending.PopupType.String()).
702+ Msg("popup ready to show")
703+
704+ // Determine behavior from config
705+ behavior := GetBehavior(pending.PopupType, c.popupConfig)
706+ placement := "right"
707+ if c.popupConfig != nil {
708+ placement = c.popupConfig.Placement
709+ }
710+
711+ // Create popup pane entity
712+ var paneID entity.PaneID
713+ if c.generateID != nil {
714+ paneID = entity.PaneID(c.generateID())
715+ } else {
716+ paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
717+ }
718+
719+ popupPane := entity.NewPane(paneID)
720+ popupPane.WindowType = entity.WindowPopup
721+ popupPane.IsRelated = true
722+ popupPane.ParentPaneID = &pending.ParentPaneID
723+ popupPane.URI = pending.TargetURI
724+
725+ // Enable OAuth auto-close if configured
726+ if c.popupConfig != nil && c.popupConfig.OAuthAutoClose && IsOAuthURL(pending.TargetURI) {
727+ popupPane.AutoClose = true
728+ c.setupOAuthAutoClose(ctx, paneID, pending.WebView)
729+ }
730+
731+ // Register WebView in our map
732+ c.webViews[paneID] = pending.WebView
733+
734+ // Setup standard callbacks
735+ c.setupWebViewCallbacks(ctx, paneID, pending.WebView)
736+
737+ // Request insertion into workspace
738+ if c.onInsertPopup != nil {
739+ input := InsertPopupInput{
740+ ParentPaneID: pending.ParentPaneID,
741+ PopupPane: popupPane,
742+ WebView: pending.WebView,
743+ Behavior: behavior,
744+ Placement: placement,
745+ PopupType: pending.PopupType,
746+ TargetURI: pending.TargetURI,
747+ }
748+
749+ if err := c.onInsertPopup(ctx, input); err != nil {
750+ log.Error().Err(err).Msg("failed to insert popup into workspace")
751+ // Clean up on failure
752+ delete(c.webViews, paneID)
753+ pending.WebView.Destroy()
754+ return
755+ }
756+ }
757+
758+ log.Info().
759+ Str("pane_id", string(paneID)).
760+ Str("behavior", string(behavior)).
761+ Str("placement", placement).
762+ Msg("popup inserted into workspace")
763+}
764+
765+// handlePopupClose handles the WebKit "close" signal for popup windows.
766+func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
767+ log := logging.FromContext(ctx)
768+ log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
769+
770+ // Check if still pending (never shown)
771+ c.popupMu.Lock()
772+ pending, wasPending := c.pendingPopups[popupID]
773+ if wasPending {
774+ delete(c.pendingPopups, popupID)
775+ }
776+ c.popupMu.Unlock()
777+
778+ if wasPending && pending != nil {
779+ // Was never shown, just destroy
780+ pending.WebView.Destroy()
781+ log.Debug().Msg("destroyed pending popup that was never shown")
782+ return
783+ }
784+
785+ // Find pane by WebView ID
786+ var paneID entity.PaneID
787+ for pid, wv := range c.webViews {
788+ if wv != nil && port.WebViewID(wv.ID()) == popupID {
789+ paneID = pid
790+ break
791+ }
792+ }
793+
794+ if paneID == "" {
795+ log.Warn().Msg("popup close: could not find pane for webview")
796+ return
797+ }
798+
799+ // Close the pane in workspace (this removes the UI element)
800+ if c.onClosePane != nil {
801+ if err := c.onClosePane(ctx, paneID); err != nil {
802+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
803+ }
804+ }
805+
806+ // Release the WebView (this will clean up tracking)
807+ c.ReleaseWebView(ctx, paneID)
808+
809+ log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
810+}
811+
812+// setupWebViewCallbacks configures standard callbacks for a WebView.
813+func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
814+ // Title changes
815+ wv.OnTitleChanged = func(title string) {
816+ c.onTitleChanged(ctx, paneID, title)
817+ }
818+
819+ // Favicon changes
820+ wv.OnFaviconChanged = func(favicon *gdk.Texture) {
821+ c.onFaviconChanged(ctx, paneID, favicon)
822+ }
823+
824+ // Load events
825+ wv.OnLoadChanged = func(event webkit.LoadEvent) {
826+ switch event {
827+ case webkit.LoadStarted:
828+ c.onLoadStarted(ctx, paneID)
829+ case webkit.LoadCommitted:
830+ c.onLoadCommitted(ctx, paneID, wv)
831+ case webkit.LoadFinished:
832+ c.onLoadFinished(ctx, paneID)
833+ }
834+ }
835+
836+ // Progress
837+ wv.OnProgressChanged = func(progress float64) {
838+ c.onProgressChanged(ctx, paneID, progress)
839+ }
840+
841+ // SPA navigation
842+ wv.OnURIChanged = func(uri string) {
843+ if !wv.IsLoading() && uri != "" {
844+ c.onSPANavigation(ctx, paneID, uri)
845+ }
846+ }
847+
848+ // Middle-click / Ctrl+click handler for opening links in new pane
849+ wv.OnLinkMiddleClick = func(uri string) bool {
850+ return c.handleLinkMiddleClick(ctx, paneID, uri)
851+ }
852+}
853+
854+// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
855+func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
856+ log := logging.FromContext(ctx)
857+
858+ // Wrap the existing OnURIChanged to also check for OAuth callbacks
859+ originalURIChanged := wv.OnURIChanged
860+ wv.OnURIChanged = func(uri string) {
861+ // Call original handler first
862+ if originalURIChanged != nil {
863+ originalURIChanged(uri)
864+ }
865+
866+ // Check for OAuth callback
867+ if ShouldAutoClose(uri) {
868+ log.Info().
869+ Str("pane_id", string(paneID)).
870+ Str("uri", uri).
871+ Msg("OAuth callback detected, auto-closing popup")
872+
873+ // Trigger close after a short delay to let the page process
874+ go func() {
875+ time.Sleep(500 * time.Millisecond)
876+ if wv != nil && !wv.IsDestroyed() {
877+ // This will trigger handlePopupClose
878+ wv.Close()
879+ }
880+ }()
881+ }
882+ }
883+
884+ // Also check on load committed for immediate redirects
885+ originalLoadChanged := wv.OnLoadChanged
886+ wv.OnLoadChanged = func(event webkit.LoadEvent) {
887+ if originalLoadChanged != nil {
888+ originalLoadChanged(event)
889+ }
890+
891+ if event == webkit.LoadCommitted {
892+ uri := wv.URI()
893+ if ShouldAutoClose(uri) {
894+ log.Info().
895+ Str("pane_id", string(paneID)).
896+ Str("uri", uri).
897+ Msg("OAuth callback detected on load, auto-closing popup")
898+
899+ go func() {
900+ time.Sleep(500 * time.Millisecond)
901+ if wv != nil && !wv.IsDestroyed() {
902+ wv.Close()
903+ }
904+ }()
905+ }
906+ }
907+ }
908+}
909+
910+// handleLinkMiddleClick handles middle-click / Ctrl+click on links.
911+// Opens the link in a new pane using blank_target_behavior config.
912+func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
913+ log := logging.FromContext(ctx)
914+
915+ log.Info().
916+ Str("parent_pane", string(parentPaneID)).
917+ Str("uri", uri).
918+ Msg("middle-click/ctrl+click on link")
919+
920+ // Check if popups are enabled
921+ if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
922+ log.Debug().Msg("popups disabled by config, ignoring middle-click")
923+ return false
924+ }
925+
926+ // Check if factory is available
927+ if c.factory == nil {
928+ log.Warn().Msg("no webview factory, cannot handle middle-click")
929+ return false
930+ }
931+
932+ // Create a new WebView (regular, not related - just opening a link)
933+ newWV, err := c.factory.Create(ctx)
934+ if err != nil {
935+ log.Error().Err(err).Msg("failed to create webview for middle-click")
936+ return false
937+ }
938+
939+ // Generate pane ID
940+ var paneID entity.PaneID
941+ if c.generateID != nil {
942+ paneID = entity.PaneID(c.generateID())
943+ } else {
944+ paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
945+ }
946+
947+ // Create pane entity - uses blank_target_behavior
948+ newPane := entity.NewPane(paneID)
949+ newPane.WindowType = entity.WindowPopup
950+ newPane.URI = uri
951+
952+ // Register WebView
953+ c.webViews[paneID] = newWV
954+
955+ // Setup callbacks
956+ c.setupWebViewCallbacks(ctx, paneID, newWV)
957+
958+ // Get behavior from config (same as _blank links)
959+ behavior := config.PopupBehaviorStacked // default
960+ if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
961+ behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior)
962+ }
963+ placement := "right"
964+ if c.popupConfig != nil {
965+ placement = c.popupConfig.Placement
966+ }
967+
968+ // Request insertion into workspace
969+ if c.onInsertPopup != nil {
970+ input := InsertPopupInput{
971+ ParentPaneID: parentPaneID,
972+ PopupPane: newPane,
973+ WebView: newWV,
974+ Behavior: behavior,
975+ Placement: placement,
976+ PopupType: PopupTypeTab, // Treat like _blank
977+ TargetURI: uri,
978+ }
979+
980+ if err := c.onInsertPopup(ctx, input); err != nil {
981+ log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
982+ // Clean up on failure
983+ delete(c.webViews, paneID)
984+ newWV.Destroy()
985+ return false
986+ }
987+ }
988+
989+ // Load the URI after insertion
990+ if err := newWV.LoadURI(ctx, uri); err != nil {
991+ log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane")
992+ }
993+
994+ log.Info().
995+ Str("pane_id", string(paneID)).
996+ Str("behavior", string(behavior)).
997+ Str("uri", uri).
998+ Msg("middle-click link opened in new pane")
999+
1000+ return true
1001+}
A internal/ui/coordinator/oauth.go +141 -0
@@ -0,0 +1,141 @@
1+package coordinator
2+
3+import (
4+ "strings"
5+)
6+
7+// oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress.
8+// These appear in authorization URLs and callback redirects.
9+var oauthFlowPatterns = []string{
10+ // OAuth flow indicators
11+ "oauth",
12+ "authorize",
13+ "authorization",
14+ "auth/",
15+ "/auth",
16+ "login",
17+ "signin",
18+ "sign-in",
19+ // OpenID Connect
20+ "oidc",
21+ "openid",
22+ // Callback/redirect endpoints
23+ "callback",
24+ "redirect",
25+ "/cb",
26+}
27+
28+// oauthCallbackPatterns are URL query parameters that indicate an OAuth callback.
29+// These appear when the OAuth provider redirects back to the application.
30+var oauthCallbackPatterns = []string{
31+ // Success parameters
32+ "code=",
33+ "access_token=",
34+ "id_token=",
35+ "token_type=",
36+ "refresh_token=",
37+ // Error parameters
38+ "error=",
39+ "error_description=",
40+ "error_uri=",
41+}
42+
43+// oauthRequestPatterns are query parameters found in OAuth authorization requests.
44+var oauthRequestPatterns = []string{
45+ "response_type=",
46+ "client_id=",
47+ "redirect_uri=",
48+ "scope=",
49+ "state=",
50+ "nonce=",
51+}
52+
53+// IsOAuthURL checks if the URL is related to an OAuth flow.
54+// This includes authorization endpoints, login pages, and callback URLs.
55+func IsOAuthURL(url string) bool {
56+ if url == "" {
57+ return false
58+ }
59+ lower := strings.ToLower(url)
60+
61+ // Check for OAuth flow patterns in URL path
62+ for _, pattern := range oauthFlowPatterns {
63+ if strings.Contains(lower, pattern) {
64+ return true
65+ }
66+ }
67+
68+ // Check for OAuth request parameters
69+ for _, pattern := range oauthRequestPatterns {
70+ if strings.Contains(lower, pattern) {
71+ return true
72+ }
73+ }
74+
75+ return false
76+}
77+
78+// IsOAuthCallback checks if the URL is an OAuth callback with response parameters.
79+// This indicates the OAuth flow has completed (successfully or with error).
80+func IsOAuthCallback(url string) bool {
81+ if url == "" {
82+ return false
83+ }
84+ lower := strings.ToLower(url)
85+
86+ // Must contain callback patterns (code=, access_token=, error=, etc.)
87+ for _, pattern := range oauthCallbackPatterns {
88+ if strings.Contains(lower, pattern) {
89+ return true
90+ }
91+ }
92+
93+ return false
94+}
95+
96+// ShouldAutoClose determines if a popup at this URL should auto-close.
97+// Returns true for OAuth callbacks that indicate flow completion.
98+//
99+// This is comprehensive detection that handles:
100+// - Success: code=, access_token=, id_token=
101+// - Errors: error=, error_description=
102+// - Various OAuth providers (Google, GitHub, Auth0, etc.)
103+func ShouldAutoClose(url string) bool {
104+ // Only auto-close on callback URLs that have OAuth response params
105+ return IsOAuthCallback(url)
106+}
107+
108+// IsOAuthSuccess checks if the callback indicates successful authentication.
109+func IsOAuthSuccess(url string) bool {
110+ if url == "" {
111+ return false
112+ }
113+ lower := strings.ToLower(url)
114+
115+ // Check for success indicators
116+ successPatterns := []string{
117+ "code=",
118+ "access_token=",
119+ "id_token=",
120+ }
121+
122+ for _, pattern := range successPatterns {
123+ if strings.Contains(lower, pattern) {
124+ // Make sure it's not an error response
125+ if !strings.Contains(lower, "error=") {
126+ return true
127+ }
128+ }
129+ }
130+
131+ return false
132+}
133+
134+// IsOAuthError checks if the callback indicates an authentication error.
135+func IsOAuthError(url string) bool {
136+ if url == "" {
137+ return false
138+ }
139+ lower := strings.ToLower(url)
140+ return strings.Contains(lower, "error=")
141+}
@@ -0,0 +1,141 @@
1+package coordinator
2+
3+import (
4+ "strings"
5+)
6+
7+// oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress.
8+// These appear in authorization URLs and callback redirects.
9+var oauthFlowPatterns = []string{
10+ // OAuth flow indicators
11+ "oauth",
12+ "authorize",
13+ "authorization",
14+ "auth/",
15+ "/auth",
16+ "login",
17+ "signin",
18+ "sign-in",
19+ // OpenID Connect
20+ "oidc",
21+ "openid",
22+ // Callback/redirect endpoints
23+ "callback",
24+ "redirect",
25+ "/cb",
26+}
27+
28+// oauthCallbackPatterns are URL query parameters that indicate an OAuth callback.
29+// These appear when the OAuth provider redirects back to the application.
30+var oauthCallbackPatterns = []string{
31+ // Success parameters
32+ "code=",
33+ "access_token=",
34+ "id_token=",
35+ "token_type=",
36+ "refresh_token=",
37+ // Error parameters
38+ "error=",
39+ "error_description=",
40+ "error_uri=",
41+}
42+
43+// oauthRequestPatterns are query parameters found in OAuth authorization requests.
44+var oauthRequestPatterns = []string{
45+ "response_type=",
46+ "client_id=",
47+ "redirect_uri=",
48+ "scope=",
49+ "state=",
50+ "nonce=",
51+}
52+
53+// IsOAuthURL checks if the URL is related to an OAuth flow.
54+// This includes authorization endpoints, login pages, and callback URLs.
55+func IsOAuthURL(url string) bool {
56+ if url == "" {
57+ return false
58+ }
59+ lower := strings.ToLower(url)
60+
61+ // Check for OAuth flow patterns in URL path
62+ for _, pattern := range oauthFlowPatterns {
63+ if strings.Contains(lower, pattern) {
64+ return true
65+ }
66+ }
67+
68+ // Check for OAuth request parameters
69+ for _, pattern := range oauthRequestPatterns {
70+ if strings.Contains(lower, pattern) {
71+ return true
72+ }
73+ }
74+
75+ return false
76+}
77+
78+// IsOAuthCallback checks if the URL is an OAuth callback with response parameters.
79+// This indicates the OAuth flow has completed (successfully or with error).
80+func IsOAuthCallback(url string) bool {
81+ if url == "" {
82+ return false
83+ }
84+ lower := strings.ToLower(url)
85+
86+ // Must contain callback patterns (code=, access_token=, error=, etc.)
87+ for _, pattern := range oauthCallbackPatterns {
88+ if strings.Contains(lower, pattern) {
89+ return true
90+ }
91+ }
92+
93+ return false
94+}
95+
96+// ShouldAutoClose determines if a popup at this URL should auto-close.
97+// Returns true for OAuth callbacks that indicate flow completion.
98+//
99+// This is comprehensive detection that handles:
100+// - Success: code=, access_token=, id_token=
101+// - Errors: error=, error_description=
102+// - Various OAuth providers (Google, GitHub, Auth0, etc.)
103+func ShouldAutoClose(url string) bool {
104+ // Only auto-close on callback URLs that have OAuth response params
105+ return IsOAuthCallback(url)
106+}
107+
108+// IsOAuthSuccess checks if the callback indicates successful authentication.
109+func IsOAuthSuccess(url string) bool {
110+ if url == "" {
111+ return false
112+ }
113+ lower := strings.ToLower(url)
114+
115+ // Check for success indicators
116+ successPatterns := []string{
117+ "code=",
118+ "access_token=",
119+ "id_token=",
120+ }
121+
122+ for _, pattern := range successPatterns {
123+ if strings.Contains(lower, pattern) {
124+ // Make sure it's not an error response
125+ if !strings.Contains(lower, "error=") {
126+ return true
127+ }
128+ }
129+ }
130+
131+ return false
132+}
133+
134+// IsOAuthError checks if the callback indicates an authentication error.
135+func IsOAuthError(url string) bool {
136+ if url == "" {
137+ return false
138+ }
139+ lower := strings.ToLower(url)
140+ return strings.Contains(lower, "error=")
141+}
A internal/ui/coordinator/popup_types.go +128 -0
@@ -0,0 +1,128 @@
1+package coordinator
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+)
11+
12+// PopupType indicates whether the popup was triggered by a link or JavaScript.
13+type PopupType int
14+
15+const (
16+ // PopupTypeTab represents a popup from target="_blank" links.
17+ // These are typically user-initiated and open like new tabs.
18+ PopupTypeTab PopupType = iota
19+ // PopupTypePopup represents a popup from window.open() JavaScript calls.
20+ // These may have different placement behavior than tab-like popups.
21+ PopupTypePopup
22+)
23+
24+// String returns a human-readable name for the popup type.
25+func (t PopupType) String() string {
26+ switch t {
27+ case PopupTypeTab:
28+ return "tab"
29+ case PopupTypePopup:
30+ return "popup"
31+ default:
32+ return "unknown"
33+ }
34+}
35+
36+// PendingPopup tracks a popup WebView that has been created but not yet
37+// inserted into the workspace. This is used during the three-phase popup
38+// lifecycle: create → ready-to-show → (insert into workspace).
39+type PendingPopup struct {
40+ // WebView is the related WebView created in the "create" phase.
41+ // It shares cookies/session with the parent for OAuth support.
42+ WebView *webkit.WebView
43+
44+ // ParentPaneID is the pane that spawned this popup.
45+ ParentPaneID entity.PaneID
46+
47+ // ParentWebViewID is the WebView ID of the parent (for lookup).
48+ ParentWebViewID port.WebViewID
49+
50+ // TargetURI is the initial URL the popup will load.
51+ TargetURI string
52+
53+ // FrameName is the target frame name from the navigation action.
54+ // "_blank" indicates a tab-like popup, other values indicate JS popups.
55+ FrameName string
56+
57+ // IsUserGesture indicates if the popup was triggered by user action
58+ // (click) vs script-initiated (e.g., popup after timeout).
59+ IsUserGesture bool
60+
61+ // PopupType categorizes this as a tab-like or JS popup.
62+ PopupType PopupType
63+
64+ // CreatedAt is when the popup was created (for timeout handling).
65+ CreatedAt time.Time
66+}
67+
68+// DetectPopupType determines if this is a tab-like or JS popup based on frame name.
69+func DetectPopupType(frameName string) PopupType {
70+ // _blank is the standard indicator for "open in new tab/window"
71+ if frameName == "_blank" {
72+ return PopupTypeTab
73+ }
74+ // Empty or custom frame names indicate JS window.open()
75+ return PopupTypePopup
76+}
77+
78+// InsertPopupInput contains the data needed to insert a popup into the workspace.
79+type InsertPopupInput struct {
80+ // ParentPaneID is the pane that spawned this popup.
81+ ParentPaneID entity.PaneID
82+
83+ // PopupPane is the pre-created pane entity for the popup.
84+ // It should have IsRelated=true and ParentPaneID set.
85+ PopupPane *entity.Pane
86+
87+ // WebView is the related WebView for the popup.
88+ WebView *webkit.WebView
89+
90+ // Behavior determines how the popup is inserted (split/stacked/tabbed).
91+ Behavior config.PopupBehavior
92+
93+ // Placement specifies direction for split behavior (right/left/top/bottom).
94+ Placement string
95+
96+ // PopupType indicates if this is a tab-like or JS popup.
97+ PopupType PopupType
98+
99+ // TargetURI is the URL to load in the popup.
100+ TargetURI string
101+}
102+
103+// GetBehavior returns the appropriate behavior based on popup type and config.
104+func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.PopupBehavior {
105+ if cfg == nil {
106+ return config.PopupBehaviorSplit // Default
107+ }
108+
109+ switch popupType {
110+ case PopupTypeTab:
111+ // Tab-like popups (_blank) use blank_target_behavior
112+ switch cfg.BlankTargetBehavior {
113+ case "split":
114+ return config.PopupBehaviorSplit
115+ case "stacked":
116+ return config.PopupBehaviorStacked
117+ case "tabbed":
118+ return config.PopupBehaviorTabbed
119+ default:
120+ return config.PopupBehaviorStacked // Default for _blank
121+ }
122+ case PopupTypePopup:
123+ // JS popups use the main behavior setting
124+ return cfg.Behavior
125+ default:
126+ return cfg.Behavior
127+ }
128+}
@@ -0,0 +1,128 @@
1+package coordinator
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/webkit"
10+)
11+
12+// PopupType indicates whether the popup was triggered by a link or JavaScript.
13+type PopupType int
14+
15+const (
16+ // PopupTypeTab represents a popup from target="_blank" links.
17+ // These are typically user-initiated and open like new tabs.
18+ PopupTypeTab PopupType = iota
19+ // PopupTypePopup represents a popup from window.open() JavaScript calls.
20+ // These may have different placement behavior than tab-like popups.
21+ PopupTypePopup
22+)
23+
24+// String returns a human-readable name for the popup type.
25+func (t PopupType) String() string {
26+ switch t {
27+ case PopupTypeTab:
28+ return "tab"
29+ case PopupTypePopup:
30+ return "popup"
31+ default:
32+ return "unknown"
33+ }
34+}
35+
36+// PendingPopup tracks a popup WebView that has been created but not yet
37+// inserted into the workspace. This is used during the three-phase popup
38+// lifecycle: create → ready-to-show → (insert into workspace).
39+type PendingPopup struct {
40+ // WebView is the related WebView created in the "create" phase.
41+ // It shares cookies/session with the parent for OAuth support.
42+ WebView *webkit.WebView
43+
44+ // ParentPaneID is the pane that spawned this popup.
45+ ParentPaneID entity.PaneID
46+
47+ // ParentWebViewID is the WebView ID of the parent (for lookup).
48+ ParentWebViewID port.WebViewID
49+
50+ // TargetURI is the initial URL the popup will load.
51+ TargetURI string
52+
53+ // FrameName is the target frame name from the navigation action.
54+ // "_blank" indicates a tab-like popup, other values indicate JS popups.
55+ FrameName string
56+
57+ // IsUserGesture indicates if the popup was triggered by user action
58+ // (click) vs script-initiated (e.g., popup after timeout).
59+ IsUserGesture bool
60+
61+ // PopupType categorizes this as a tab-like or JS popup.
62+ PopupType PopupType
63+
64+ // CreatedAt is when the popup was created (for timeout handling).
65+ CreatedAt time.Time
66+}
67+
68+// DetectPopupType determines if this is a tab-like or JS popup based on frame name.
69+func DetectPopupType(frameName string) PopupType {
70+ // _blank is the standard indicator for "open in new tab/window"
71+ if frameName == "_blank" {
72+ return PopupTypeTab
73+ }
74+ // Empty or custom frame names indicate JS window.open()
75+ return PopupTypePopup
76+}
77+
78+// InsertPopupInput contains the data needed to insert a popup into the workspace.
79+type InsertPopupInput struct {
80+ // ParentPaneID is the pane that spawned this popup.
81+ ParentPaneID entity.PaneID
82+
83+ // PopupPane is the pre-created pane entity for the popup.
84+ // It should have IsRelated=true and ParentPaneID set.
85+ PopupPane *entity.Pane
86+
87+ // WebView is the related WebView for the popup.
88+ WebView *webkit.WebView
89+
90+ // Behavior determines how the popup is inserted (split/stacked/tabbed).
91+ Behavior config.PopupBehavior
92+
93+ // Placement specifies direction for split behavior (right/left/top/bottom).
94+ Placement string
95+
96+ // PopupType indicates if this is a tab-like or JS popup.
97+ PopupType PopupType
98+
99+ // TargetURI is the URL to load in the popup.
100+ TargetURI string
101+}
102+
103+// GetBehavior returns the appropriate behavior based on popup type and config.
104+func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.PopupBehavior {
105+ if cfg == nil {
106+ return config.PopupBehaviorSplit // Default
107+ }
108+
109+ switch popupType {
110+ case PopupTypeTab:
111+ // Tab-like popups (_blank) use blank_target_behavior
112+ switch cfg.BlankTargetBehavior {
113+ case "split":
114+ return config.PopupBehaviorSplit
115+ case "stacked":
116+ return config.PopupBehaviorStacked
117+ case "tabbed":
118+ return config.PopupBehaviorTabbed
119+ default:
120+ return config.PopupBehaviorStacked // Default for _blank
121+ }
122+ case PopupTypePopup:
123+ // JS popups use the main behavior setting
124+ return cfg.Behavior
125+ default:
126+ return cfg.Behavior
127+ }
128+}

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