detached b95b119

fix(popup): preserve lifecycle and fallback contracts

commit
b95b1196b3343de119ad5c01063060c60b70c218
author
brice · · 2026-07-27 08:30 UTC
parent
986803d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +28 -32
M internal/infrastructure/webkit/webview.go +1 -0
@@ -1700,6 +1700,7 @@ func (wv *WebView) runCloseCallbacks() {
17001700 wv.mu.Lock()
17011701 base := wv.OnClose
17021702 lifecycle := wv.popupLifecycleClose
1703+ wv.OnClose = nil
17031704 wv.popupLifecycleClose = nil
17041705 wv.mu.Unlock()
17051706 if base != nil {
@@ -1700,6 +1700,7 @@ func (wv *WebView) runCloseCallbacks() {
1700 wv.mu.Lock()1700 wv.mu.Lock()
1701 base := wv.OnClose1701 base := wv.OnClose
1702 lifecycle := wv.popupLifecycleClose1702 lifecycle := wv.popupLifecycleClose
1703+ wv.OnClose = nil
1703 wv.popupLifecycleClose = nil1704 wv.popupLifecycleClose = nil
1704 wv.mu.Unlock()1705 wv.mu.Unlock()
1705 if base != nil {1706 if base != nil {
M internal/infrastructure/webkit/webview_popup_lifecycle_test.go +14 -0
@@ -6,6 +6,20 @@ import (
66 "github.com/stretchr/testify/assert"
77 )
88
9+func TestPopupCloseCallbacksRunExactlyOnce(t *testing.T) {
10+ wv := &WebView{}
11+ baseCalls := 0
12+ lifecycleCalls := 0
13+ wv.AddCloseCallback(func() { baseCalls++ })
14+ wv.SetOnClose(func() { lifecycleCalls++ })
15+
16+ wv.runCloseCallbacks()
17+ wv.runCloseCallbacks()
18+
19+ assert.Equal(t, 1, baseCalls)
20+ assert.Equal(t, 1, lifecycleCalls)
21+}
22+
923 func TestPopupLifecycleCallbacksCanBeDisarmed(t *testing.T) {
1024 wv := &WebView{}
1125 readyCalls := 0
@@ -6,6 +6,20 @@ import (
6 "github.com/stretchr/testify/assert"6 "github.com/stretchr/testify/assert"
7 )7 )
8 8
9+func TestPopupCloseCallbacksRunExactlyOnce(t *testing.T) {
10+ wv := &WebView{}
11+ baseCalls := 0
12+ lifecycleCalls := 0
13+ wv.AddCloseCallback(func() { baseCalls++ })
14+ wv.SetOnClose(func() { lifecycleCalls++ })
15+
16+ wv.runCloseCallbacks()
17+ wv.runCloseCallbacks()
18+
19+ assert.Equal(t, 1, baseCalls)
20+ assert.Equal(t, 1, lifecycleCalls)
21+}
22+
9 func TestPopupLifecycleCallbacksCanBeDisarmed(t *testing.T) {23 func TestPopupLifecycleCallbacksCanBeDisarmed(t *testing.T) {
10 wv := &WebView{}24 wv := &WebView{}
11 readyCalls := 025 readyCalls := 0
M internal/ui/coordinator/content/browsing_context_policy.go +4 -15
@@ -80,26 +80,15 @@ func inferPopupWindowDisposition(req port.PopupRequest) dto.WindowDisposition {
8080 }
8181
8282 func inferPopupTriggerKind(req port.PopupRequest) dto.TriggerKind {
83+ if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
84+ return dto.TriggerNamedTargetNavigation
85+ }
8386 switch inferPopupWindowDisposition(req) {
84- case dto.WindowDispositionCurrentTab:
85- if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
86- return dto.TriggerNamedTargetNavigation
87- }
88- return dto.TriggerLinkNewPage
89- case dto.WindowDispositionNewTab:
90- if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
91- return dto.TriggerNamedTargetNavigation
92- }
87+ case dto.WindowDispositionCurrentTab, dto.WindowDispositionNewTab:
9388 return dto.TriggerLinkNewPage
9489 case dto.WindowDispositionNewPopup, dto.WindowDispositionNewWindow:
95- if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
96- return dto.TriggerNamedTargetNavigation
97- }
9890 return dto.TriggerScriptWindowOpen
9991 default:
100- if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
101- return dto.TriggerNamedTargetNavigation
102- }
10392 return dto.TriggerUnknown
10493 }
10594 }
@@ -80,26 +80,15 @@ func inferPopupWindowDisposition(req port.PopupRequest) dto.WindowDisposition {
80 }80 }
81 81
82 func inferPopupTriggerKind(req port.PopupRequest) dto.TriggerKind {82 func inferPopupTriggerKind(req port.PopupRequest) dto.TriggerKind {
83+ if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
84+ return dto.TriggerNamedTargetNavigation
85+ }
83 switch inferPopupWindowDisposition(req) {86 switch inferPopupWindowDisposition(req) {
84- case dto.WindowDispositionCurrentTab:87+ case dto.WindowDispositionCurrentTab, dto.WindowDispositionNewTab:
85- if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
86- return dto.TriggerNamedTargetNavigation
87- }
88- return dto.TriggerLinkNewPage
89- case dto.WindowDispositionNewTab:
90- if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
91- return dto.TriggerNamedTargetNavigation
92- }
93 return dto.TriggerLinkNewPage88 return dto.TriggerLinkNewPage
94 case dto.WindowDispositionNewPopup, dto.WindowDispositionNewWindow:89 case dto.WindowDispositionNewPopup, dto.WindowDispositionNewWindow:
95- if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
96- return dto.TriggerNamedTargetNavigation
97- }
98 return dto.TriggerScriptWindowOpen90 return dto.TriggerScriptWindowOpen
99 default:91 default:
100- if usecase.ReusableBrowsingContextName(req.FrameName) != "" {
101- return dto.TriggerNamedTargetNavigation
102- }
103 return dto.TriggerUnknown92 return dto.TriggerUnknown
104 }93 }
105 }94 }
M internal/ui/coordinator/content/popup.go +3 -1
@@ -142,7 +142,9 @@ type NativePopupInput struct {
142142 Request port.PopupRequest
143143 ObserveOAuthAutoClose bool
144144 AllowBrowserWindowFallback bool
145- OnNativeHostAbort func(context.Context, port.WebView) bool
145+ // OnNativeHostAbort returns true when fallback adopted the WebView. A false
146+ // result leaves destruction responsibility with the native host.
147+ OnNativeHostAbort func(context.Context, port.WebView) bool
146148 }
147149
148150 // GetBehavior returns the appropriate behavior based on popup type and config.
@@ -142,7 +142,9 @@ type NativePopupInput struct {
142 Request port.PopupRequest142 Request port.PopupRequest
143 ObserveOAuthAutoClose bool143 ObserveOAuthAutoClose bool
144 AllowBrowserWindowFallback bool144 AllowBrowserWindowFallback bool
145- OnNativeHostAbort func(context.Context, port.WebView) bool145+ // OnNativeHostAbort returns true when fallback adopted the WebView. A false
146+ // result leaves destruction responsibility with the native host.
147+ OnNativeHostAbort func(context.Context, port.WebView) bool
146 }148 }
147 149
148 // GetBehavior returns the appropriate behavior based on popup type and config.150 // GetBehavior returns the appropriate behavior based on popup type and config.
M internal/ui/coordinator/content/popup_browsing_context_test.go +1 -1
@@ -796,7 +796,7 @@ func TestHandlePopupCreate_FallsBackToNewPaneWhenNamedReuseBecomesUnavailable(t
796796 parentPaneID := entity.PaneID("parent-pane")
797797 stalePaneID := entity.PaneID("stale-pane")
798798 parentWV := mocks.NewMockWebView(t)
799- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()
799+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
800800
801801 staleWV := mocks.NewMockWebView(t)
802802 staleWV.EXPECT().IsDestroyed().Return(false).Once()
@@ -796,7 +796,7 @@ func TestHandlePopupCreate_FallsBackToNewPaneWhenNamedReuseBecomesUnavailable(t
796 parentPaneID := entity.PaneID("parent-pane")796 parentPaneID := entity.PaneID("parent-pane")
797 stalePaneID := entity.PaneID("stale-pane")797 stalePaneID := entity.PaneID("stale-pane")
798 parentWV := mocks.NewMockWebView(t)798 parentWV := mocks.NewMockWebView(t)
799- parentWV.EXPECT().ID().Return(port.WebViewID(101)).Once()799+ parentWV.EXPECT().ID().Return(port.WebViewID(101)).Twice()
800 800
801 staleWV := mocks.NewMockWebView(t)801 staleWV := mocks.NewMockWebView(t)
802 staleWV.EXPECT().IsDestroyed().Return(false).Once()802 staleWV.EXPECT().IsDestroyed().Return(false).Once()
M internal/ui/coordinator/content/popup_hosting.go +0 -6
@@ -245,9 +245,3 @@ func (pm *popupManager) adoptPopupInNativePopup(
245245 }
246246 return true
247247 }
248-
249-func (*popupManager) readyOnCreate(dto.BrowserEngineKind) bool {
250- // Deferred WebKit requests are staged and routed by a later phase. Every
251- // immediate route must be visible without depending on another signal.
252- return true
253-}
@@ -245,9 +245,3 @@ func (pm *popupManager) adoptPopupInNativePopup(
245 }245 }
246 return true246 return true
247 }247 }
248-
249-func (*popupManager) readyOnCreate(dto.BrowserEngineKind) bool {
250- // Deferred WebKit requests are staged and routed by a later phase. Every
251- // immediate route must be visible without depending on another signal.
252- return true
253-}
M internal/ui/coordinator/content/popup_manager.go +4 -8
@@ -592,14 +592,14 @@ func (pm *popupManager) handlePopupCreate(
592592 return nil
593593 }
594594 log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse requested but target was unavailable")
595- decision.Kind = dto.HostDecisionCreatePane
596- decision.ReuseContextName = ""
597- decision.Reason = "named browsing context unavailable; creating replacement pane"
595+ // Lookup removes the stale registry entry. Re-enter policy once so the
596+ // replacement host remains source-aware instead of forcing a workspace pane.
597+ return pm.handlePopupCreate(ctx, hooks, parentPaneID, parentWV, req)
598598 case dto.HostDecisionNavigateSource:
599599 pm.navigatePopupSource(ctx, parentID, parentWV, req, request, decision)
600600 return nil
601601 case dto.HostDecisionCreateBrowserWindow:
602- return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, pm.readyOnCreate(req.Engine))
602+ return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, true)
603603 case dto.HostDecisionCreateNativePopup:
604604 return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision)
605605 case dto.HostDecisionAwaitPopupFeatures:
@@ -922,10 +922,6 @@ func (pm *popupManager) handleLinkMiddleClick(
922922 request.SourceHost = sourceHost
923923 decision := pm.policy.Decide(request, false)
924924 logBrowsingContextDecision(*log, request, decision)
925- log.Debug().
926- Str("decision", string(decision.Kind)).
927- Str("reason", decision.Reason).
928- Msg("middle-click browsing context host decision")
929925 if decision.Kind == dto.HostDecisionNavigateSource {
930926 return pm.navigateSource(ctx, parentWV, uri)
931927 }
@@ -592,14 +592,14 @@ func (pm *popupManager) handlePopupCreate(
592 return nil592 return nil
593 }593 }
594 log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse requested but target was unavailable")594 log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse requested but target was unavailable")
595- decision.Kind = dto.HostDecisionCreatePane595+ // Lookup removes the stale registry entry. Re-enter policy once so the
596- decision.ReuseContextName = ""596+ // replacement host remains source-aware instead of forcing a workspace pane.
597- decision.Reason = "named browsing context unavailable; creating replacement pane"597+ return pm.handlePopupCreate(ctx, hooks, parentPaneID, parentWV, req)
598 case dto.HostDecisionNavigateSource:598 case dto.HostDecisionNavigateSource:
599 pm.navigatePopupSource(ctx, parentID, parentWV, req, request, decision)599 pm.navigatePopupSource(ctx, parentID, parentWV, req, request, decision)
600 return nil600 return nil
601 case dto.HostDecisionCreateBrowserWindow:601 case dto.HostDecisionCreateBrowserWindow:
602- return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, pm.readyOnCreate(req.Engine))602+ return pm.openBrowserWindow(ctx, hooks, parentPaneID, parentID, req, decision, true)
603 case dto.HostDecisionCreateNativePopup:603 case dto.HostDecisionCreateNativePopup:
604 return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision)604 return pm.openNativePopup(ctx, hooks, parentPaneID, parentID, parentURIAtOpen, req, decision)
605 case dto.HostDecisionAwaitPopupFeatures:605 case dto.HostDecisionAwaitPopupFeatures:
@@ -922,10 +922,6 @@ func (pm *popupManager) handleLinkMiddleClick(
922 request.SourceHost = sourceHost922 request.SourceHost = sourceHost
923 decision := pm.policy.Decide(request, false)923 decision := pm.policy.Decide(request, false)
924 logBrowsingContextDecision(*log, request, decision)924 logBrowsingContextDecision(*log, request, decision)
925- log.Debug().
926- Str("decision", string(decision.Kind)).
927- Str("reason", decision.Reason).
928- Msg("middle-click browsing context host decision")
929 if decision.Kind == dto.HostDecisionNavigateSource {925 if decision.Kind == dto.HostDecisionNavigateSource {
930 return pm.navigateSource(ctx, parentWV, uri)926 return pm.navigateSource(ctx, parentWV, uri)
931 }927 }
M internal/ui/native_popup_window.go +1 -1
@@ -117,7 +117,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
117117 if aborter, ok := input.PopupWebView.(port.NativePopupHostAbortCapable); ok {
118118 aborter.SetNativePopupHostAbort(func() {
119119 a.dispatchNativePopupLifecycle("ui.native_popup.abort", popupID, func() {
120- a.abortNativePopupWindow(ctx, popupID, input)
120+ a.abortNativePopupWindow(context.Background(), popupID, input)
121121 })
122122 })
123123 }
@@ -117,7 +117,7 @@ func (a *App) openNativePopupWindow(ctx context.Context, input content.NativePop
117 if aborter, ok := input.PopupWebView.(port.NativePopupHostAbortCapable); ok {117 if aborter, ok := input.PopupWebView.(port.NativePopupHostAbortCapable); ok {
118 aborter.SetNativePopupHostAbort(func() {118 aborter.SetNativePopupHostAbort(func() {
119 a.dispatchNativePopupLifecycle("ui.native_popup.abort", popupID, func() {119 a.dispatchNativePopupLifecycle("ui.native_popup.abort", popupID, func() {
120- a.abortNativePopupWindow(ctx, popupID, input)120+ a.abortNativePopupWindow(context.Background(), popupID, input)
121 })121 })
122 })122 })
123 }123 }

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