detached 2baceed

fix(popup): release aborted popup shells and guard popup config

Aborted pane-hosted popups that can no longer get a browser are destroyed so their hidden pane, pending entry and OAuth state are cleaned up. Popup config reads and writes now hold the popup manager mutex.
commit
2baceedb5027d6d3ef8ba72faf5d2cdaedd6ef12
author
brice · · 2026-10-15 06:06 UTC
parent
230caa1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +203 -4
M internal/infrastructure/cef/webview.go +24 -0
@@ -1214,6 +1214,30 @@ func (wv *WebView) handleNativePopupAborted() {
12141214 wv.mu.Unlock()
12151215 }
12161216
1217+// releaseUnrecoverableAbortedPopup destroys a pane-hosted popup shell whose
1218+// CEF popup was aborted and that can no longer obtain a browser. While
1219+// pendingCreate is set the shell still recovers through direct browser
1220+// creation (see preparePopupShellDirectBrowserCreation), so it is left alone.
1221+// Destroy runs the lifecycle close callback registered by the content layer,
1222+// which drops its pending-popup and OAuth state, closes the hidden pane and
1223+// releases the view. Shells owned by a native popup host (nativePopupHostAbort
1224+// installed) or without a content-layer close callback are never touched: the
1225+// host owns their teardown.
1226+func (wv *WebView) releaseUnrecoverableAbortedPopup() bool {
1227+ if wv == nil {
1228+ return false
1229+ }
1230+ wv.mu.RLock()
1231+ release := wv.nativePopupHostAbort == nil && wv.popupLifecycleClose != nil &&
1232+ wv.browser == nil && wv.pendingCreate == nil
1233+ wv.mu.RUnlock()
1234+ if !release {
1235+ return false
1236+ }
1237+ wv.Destroy()
1238+ return true
1239+}
1240+
12171241 // ---------------------------------------------------------------------------
12181242 // JavaScript / Appearance
12191243 // ---------------------------------------------------------------------------
@@ -1214,6 +1214,30 @@ func (wv *WebView) handleNativePopupAborted() {
1214 wv.mu.Unlock()1214 wv.mu.Unlock()
1215 }1215 }
1216 1216
1217+// releaseUnrecoverableAbortedPopup destroys a pane-hosted popup shell whose
1218+// CEF popup was aborted and that can no longer obtain a browser. While
1219+// pendingCreate is set the shell still recovers through direct browser
1220+// creation (see preparePopupShellDirectBrowserCreation), so it is left alone.
1221+// Destroy runs the lifecycle close callback registered by the content layer,
1222+// which drops its pending-popup and OAuth state, closes the hidden pane and
1223+// releases the view. Shells owned by a native popup host (nativePopupHostAbort
1224+// installed) or without a content-layer close callback are never touched: the
1225+// host owns their teardown.
1226+func (wv *WebView) releaseUnrecoverableAbortedPopup() bool {
1227+ if wv == nil {
1228+ return false
1229+ }
1230+ wv.mu.RLock()
1231+ release := wv.nativePopupHostAbort == nil && wv.popupLifecycleClose != nil &&
1232+ wv.browser == nil && wv.pendingCreate == nil
1233+ wv.mu.RUnlock()
1234+ if !release {
1235+ return false
1236+ }
1237+ wv.Destroy()
1238+ return true
1239+}
1240+
1217 // ---------------------------------------------------------------------------1241 // ---------------------------------------------------------------------------
1218 // JavaScript / Appearance1242 // JavaScript / Appearance
1219 // ---------------------------------------------------------------------------1243 // ---------------------------------------------------------------------------
M internal/infrastructure/cef/webview_handlers.go +15 -2
@@ -903,9 +903,22 @@ func (h *handlerSet) handlePopupHostDecision(
903903 }
904904 }
905905
906+// OnBeforePopupAborted is keyed by CEF's popup_id, which only maps to a popup
907+// shell through the parent's pendingNativePopups. The content layer keys its
908+// pending popup and OAuth state by the shell's WebViewID, so cleanup of a shell
909+// that can no longer get a browser goes through its lifecycle close callback
910+// (content handlePopupClose) rather than by id. Recoverable shells keep using
911+// the direct-creation fallback.
906912 func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, popupID int32) {
907- if popup := h.wv.takePendingNativePopup(popupID); popup != nil {
908- popup.handleNativePopupAborted()
913+ popup := h.wv.takePendingNativePopup(popupID)
914+ if popup == nil {
915+ return
916+ }
917+ popup.handleNativePopupAborted()
918+ if popup.releaseUnrecoverableAbortedPopup() {
919+ logging.FromContext(h.currentContext()).Debug().
920+ Int32("popup_id", popupID).
921+ Msg("cef: popup aborted and unrecoverable, released popup shell")
909922 }
910923 }
911924
@@ -903,9 +903,22 @@ func (h *handlerSet) handlePopupHostDecision(
903 }903 }
904 }904 }
905 905
906+// OnBeforePopupAborted is keyed by CEF's popup_id, which only maps to a popup
907+// shell through the parent's pendingNativePopups. The content layer keys its
908+// pending popup and OAuth state by the shell's WebViewID, so cleanup of a shell
909+// that can no longer get a browser goes through its lifecycle close callback
910+// (content handlePopupClose) rather than by id. Recoverable shells keep using
911+// the direct-creation fallback.
906 func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, popupID int32) {912 func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, popupID int32) {
907- if popup := h.wv.takePendingNativePopup(popupID); popup != nil {913+ popup := h.wv.takePendingNativePopup(popupID)
908- popup.handleNativePopupAborted()914+ if popup == nil {
915+ return
916+ }
917+ popup.handleNativePopupAborted()
918+ if popup.releaseUnrecoverableAbortedPopup() {
919+ logging.FromContext(h.currentContext()).Debug().
920+ Int32("popup_id", popupID).
921+ Msg("cef: popup aborted and unrecoverable, released popup shell")
909 }922 }
910 }923 }
911 924
M internal/infrastructure/cef/webview_popup_lifecycle_test.go +50 -0
@@ -1,6 +1,7 @@
11 package cef
22
33 import (
4+ "context"
45 "testing"
56
67 "github.com/stretchr/testify/assert"
@@ -24,3 +25,52 @@ func TestPopupLifecycleCallbacksCanBeDisarmed(t *testing.T) {
2425 assert.Zero(t, lifecycleCloseCalls)
2526 assert.Equal(t, 1, oauthCloseCalls)
2627 }
28+
29+func TestOnBeforePopupAborted_ReleasesUnrecoverablePaneHostedPopup(t *testing.T) {
30+ parent := &WebView{ctx: context.Background()}
31+ popup := &WebView{ctx: context.Background(), nativePopupCandidate: true, nativePopupParent: parent, nativePopupID: 77}
32+ closeCalls := 0
33+ popup.SetOnClose(func() { closeCalls++ })
34+ parent.trackPendingNativePopup(77, popup)
35+
36+ (&handlerSet{wv: parent}).OnBeforePopupAborted(nil, 77)
37+
38+ assert.Equal(t, 1, closeCalls)
39+ assert.True(t, popup.IsDestroyed())
40+ assert.Nil(t, parent.takePendingNativePopup(77))
41+}
42+
43+func TestOnBeforePopupAborted_KeepsRecoverablePopupForDirectFallback(t *testing.T) {
44+ parent := &WebView{ctx: context.Background()}
45+ popup := &WebView{
46+ ctx: context.Background(), nativePopupCandidate: true, nativePopupParent: parent, nativePopupID: 78,
47+ pendingCreate: &pendingBrowserCreate{},
48+ }
49+ closeCalls := 0
50+ popup.SetOnClose(func() { closeCalls++ })
51+ parent.trackPendingNativePopup(78, popup)
52+
53+ (&handlerSet{wv: parent}).OnBeforePopupAborted(nil, 78)
54+
55+ assert.Zero(t, closeCalls)
56+ assert.False(t, popup.IsDestroyed())
57+ assert.False(t, popup.isNativePopupCandidate())
58+}
59+
60+func TestOnBeforePopupAborted_LeavesNativeHostedPopupToHost(t *testing.T) {
61+ parent := &WebView{ctx: context.Background()}
62+ popup := &WebView{ctx: context.Background(), nativePopupCandidate: true, nativePopupParent: parent, nativePopupID: 79}
63+ closeCalls := 0
64+ popup.SetOnClose(func() { closeCalls++ })
65+ popup.SetNativePopupHostAbort(func() {})
66+ parent.trackPendingNativePopup(79, popup)
67+
68+ (&handlerSet{wv: parent}).OnBeforePopupAborted(nil, 79)
69+
70+ assert.Zero(t, closeCalls)
71+ assert.False(t, popup.IsDestroyed())
72+}
73+
74+func TestOnBeforePopupAborted_UnknownPopupIsNoop(_ *testing.T) {
75+ (&handlerSet{wv: &WebView{ctx: context.Background()}}).OnBeforePopupAborted(nil, 123)
76+}
@@ -1,6 +1,7 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "context"
4 "testing"5 "testing"
5 6
6 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
@@ -24,3 +25,52 @@ func TestPopupLifecycleCallbacksCanBeDisarmed(t *testing.T) {
24 assert.Zero(t, lifecycleCloseCalls)25 assert.Zero(t, lifecycleCloseCalls)
25 assert.Equal(t, 1, oauthCloseCalls)26 assert.Equal(t, 1, oauthCloseCalls)
26 }27 }
28+
29+func TestOnBeforePopupAborted_ReleasesUnrecoverablePaneHostedPopup(t *testing.T) {
30+ parent := &WebView{ctx: context.Background()}
31+ popup := &WebView{ctx: context.Background(), nativePopupCandidate: true, nativePopupParent: parent, nativePopupID: 77}
32+ closeCalls := 0
33+ popup.SetOnClose(func() { closeCalls++ })
34+ parent.trackPendingNativePopup(77, popup)
35+
36+ (&handlerSet{wv: parent}).OnBeforePopupAborted(nil, 77)
37+
38+ assert.Equal(t, 1, closeCalls)
39+ assert.True(t, popup.IsDestroyed())
40+ assert.Nil(t, parent.takePendingNativePopup(77))
41+}
42+
43+func TestOnBeforePopupAborted_KeepsRecoverablePopupForDirectFallback(t *testing.T) {
44+ parent := &WebView{ctx: context.Background()}
45+ popup := &WebView{
46+ ctx: context.Background(), nativePopupCandidate: true, nativePopupParent: parent, nativePopupID: 78,
47+ pendingCreate: &pendingBrowserCreate{},
48+ }
49+ closeCalls := 0
50+ popup.SetOnClose(func() { closeCalls++ })
51+ parent.trackPendingNativePopup(78, popup)
52+
53+ (&handlerSet{wv: parent}).OnBeforePopupAborted(nil, 78)
54+
55+ assert.Zero(t, closeCalls)
56+ assert.False(t, popup.IsDestroyed())
57+ assert.False(t, popup.isNativePopupCandidate())
58+}
59+
60+func TestOnBeforePopupAborted_LeavesNativeHostedPopupToHost(t *testing.T) {
61+ parent := &WebView{ctx: context.Background()}
62+ popup := &WebView{ctx: context.Background(), nativePopupCandidate: true, nativePopupParent: parent, nativePopupID: 79}
63+ closeCalls := 0
64+ popup.SetOnClose(func() { closeCalls++ })
65+ popup.SetNativePopupHostAbort(func() {})
66+ parent.trackPendingNativePopup(79, popup)
67+
68+ (&handlerSet{wv: parent}).OnBeforePopupAborted(nil, 79)
69+
70+ assert.Zero(t, closeCalls)
71+ assert.False(t, popup.IsDestroyed())
72+}
73+
74+func TestOnBeforePopupAborted_UnknownPopupIsNoop(_ *testing.T) {
75+ (&handlerSet{wv: &WebView{ctx: context.Background()}}).OnBeforePopupAborted(nil, 123)
76+}
M internal/ui/coordinator/content/popup_lifecycle_test.go +100 -0
@@ -683,3 +683,103 @@ type popupStagingHostStub struct {
683683
684684 func (s *popupStagingHostStub) Detach() error { s.detached = true; return s.detachErr }
685685 func (s *popupStagingHostStub) Destroy() { s.destroyCalls++ }
686+
687+// ---------------------------------------------------------------------------
688+// Popup config concurrency / aborted popup cleanup
689+// ---------------------------------------------------------------------------
690+
691+func TestPopupConfig_ConcurrentUpdateAndRead(t *testing.T) {
692+ t.Parallel()
693+
694+ pm := newPopupManager()
695+ var wg sync.WaitGroup
696+ for i := range 20 {
697+ wg.Add(3)
698+ go func() {
699+ defer wg.Done()
700+ pm.updatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: i%2 == 0})
701+ }()
702+ go func() {
703+ defer wg.Done()
704+ pm.setPopupConfig(&entity.BrowsingContextConfig{OAuthAutoClose: i%2 == 0})
705+ }()
706+ go func() {
707+ defer wg.Done()
708+ if cfg := pm.currentPopupConfig(); cfg != nil {
709+ _ = cfg.OpenInNewPane
710+ }
711+ }()
712+ }
713+ wg.Wait()
714+}
715+
716+func TestCurrentPopupConfig_ReturnsSnapshot(t *testing.T) {
717+ t.Parallel()
718+
719+ pm := newPopupManager()
720+ pm.updatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: true})
721+ snapshot := pm.currentPopupConfig()
722+ require.NotNil(t, snapshot)
723+ snapshot.OpenInNewPane = false
724+
725+ require.True(t, pm.currentPopupConfig().OpenInNewPane)
726+}
727+
728+func TestPopupClose_AbortedPendingPopupCleansPendingOAuthAndPane(t *testing.T) {
729+ ctx := context.Background()
730+ parent := mocks.NewMockWebView(t)
731+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
732+ popup := &closableLifecyclePopupStub{popupNavigationWebViewStub: &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}}
733+ popup.EXPECT().ID().Return(port.WebViewID(260)).Maybe()
734+ popup.EXPECT().URI().Return("").Maybe()
735+ popup.EXPECT().IsLoading().Return(false).Maybe()
736+ popup.EXPECT().Generation().Return(uint64(1)).Maybe()
737+ popup.EXPECT().SetCallbacks(mock.Anything).Once()
738+ popup.EXPECT().Destroy().Maybe()
739+ factory := mocks.NewMockWebViewFactory(t)
740+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
741+
742+ c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
743+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: true}, func() string { return "popup-pane" })
744+ var closedPanes []entity.PaneID
745+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { return nil })
746+ c.SetOnClosePane(func(_ context.Context, paneID entity.PaneID) error {
747+ closedPanes = append(closedPanes, paneID)
748+ return nil
749+ })
750+
751+ created := c.handlePopupCreate(ctx, "parent-pane", parent, port.PopupRequest{
752+ TargetURI: "https://example.com/popup", FrameName: "popup", IsUserGesture: true,
753+ })
754+ require.Same(t, popup, created)
755+ require.Len(t, c.popups.pendingPopups, 1)
756+ // OAuth tracking is normally armed for auth-intent popups; seed it so the
757+ // cleanup contract is covered regardless of the policy's current routing.
758+ c.popups.trackOAuthPopup(260, "parent-pane", "https://app.example.com/login")
759+ require.Len(t, c.popups.popupOAuth, 1)
760+ require.NotNil(t, c.getWebViewLocked("popup-pane"))
761+ require.NotNil(t, popup.onClose)
762+
763+ // CEF aborts the popup before ready-to-show: the engine fires the lifecycle
764+ // close callback exactly as it does for a destroyed shell.
765+ popup.close()
766+
767+ assert.Empty(t, c.popups.pendingPopups)
768+ assert.Empty(t, c.popups.popupOAuth)
769+ assert.Equal(t, []entity.PaneID{"popup-pane"}, closedPanes)
770+ assert.Nil(t, c.getWebViewLocked("popup-pane"))
771+}
772+
773+// closableLifecyclePopupStub records the lifecycle close callback the content
774+// layer installs so tests can simulate the engine tearing the shell down.
775+type closableLifecyclePopupStub struct {
776+ *popupNavigationWebViewStub
777+ onClose func()
778+}
779+
780+func (s *closableLifecyclePopupStub) SetOnClose(fn func()) { s.onClose = fn }
781+func (s *closableLifecyclePopupStub) close() {
782+ if s.onClose != nil {
783+ s.onClose()
784+ }
785+}
@@ -683,3 +683,103 @@ type popupStagingHostStub struct {
683 683
684 func (s *popupStagingHostStub) Detach() error { s.detached = true; return s.detachErr }684 func (s *popupStagingHostStub) Detach() error { s.detached = true; return s.detachErr }
685 func (s *popupStagingHostStub) Destroy() { s.destroyCalls++ }685 func (s *popupStagingHostStub) Destroy() { s.destroyCalls++ }
686+
687+// ---------------------------------------------------------------------------
688+// Popup config concurrency / aborted popup cleanup
689+// ---------------------------------------------------------------------------
690+
691+func TestPopupConfig_ConcurrentUpdateAndRead(t *testing.T) {
692+ t.Parallel()
693+
694+ pm := newPopupManager()
695+ var wg sync.WaitGroup
696+ for i := range 20 {
697+ wg.Add(3)
698+ go func() {
699+ defer wg.Done()
700+ pm.updatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: i%2 == 0})
701+ }()
702+ go func() {
703+ defer wg.Done()
704+ pm.setPopupConfig(&entity.BrowsingContextConfig{OAuthAutoClose: i%2 == 0})
705+ }()
706+ go func() {
707+ defer wg.Done()
708+ if cfg := pm.currentPopupConfig(); cfg != nil {
709+ _ = cfg.OpenInNewPane
710+ }
711+ }()
712+ }
713+ wg.Wait()
714+}
715+
716+func TestCurrentPopupConfig_ReturnsSnapshot(t *testing.T) {
717+ t.Parallel()
718+
719+ pm := newPopupManager()
720+ pm.updatePopupConfig(entity.BrowsingContextConfig{OpenInNewPane: true})
721+ snapshot := pm.currentPopupConfig()
722+ require.NotNil(t, snapshot)
723+ snapshot.OpenInNewPane = false
724+
725+ require.True(t, pm.currentPopupConfig().OpenInNewPane)
726+}
727+
728+func TestPopupClose_AbortedPendingPopupCleansPendingOAuthAndPane(t *testing.T) {
729+ ctx := context.Background()
730+ parent := mocks.NewMockWebView(t)
731+ parent.EXPECT().ID().Return(port.WebViewID(101)).Once()
732+ popup := &closableLifecyclePopupStub{popupNavigationWebViewStub: &popupNavigationWebViewStub{MockWebView: mocks.NewMockWebView(t)}}
733+ popup.EXPECT().ID().Return(port.WebViewID(260)).Maybe()
734+ popup.EXPECT().URI().Return("").Maybe()
735+ popup.EXPECT().IsLoading().Return(false).Maybe()
736+ popup.EXPECT().Generation().Return(uint64(1)).Maybe()
737+ popup.EXPECT().SetCallbacks(mock.Anything).Once()
738+ popup.EXPECT().Destroy().Maybe()
739+ factory := mocks.NewMockWebViewFactory(t)
740+ factory.EXPECT().CreateRelated(mock.Anything, port.WebViewID(101)).Return(popup, nil).Once()
741+
742+ c := &Coordinator{webViews: make(map[entity.PaneID]port.WebView), popups: newPopupManager()}
743+ c.SetPopupConfig(factory, &entity.BrowsingContextConfig{OpenInNewPane: true, OAuthAutoClose: true}, func() string { return "popup-pane" })
744+ var closedPanes []entity.PaneID
745+ c.SetOnInsertPopup(func(context.Context, InsertPopupInput) error { return nil })
746+ c.SetOnClosePane(func(_ context.Context, paneID entity.PaneID) error {
747+ closedPanes = append(closedPanes, paneID)
748+ return nil
749+ })
750+
751+ created := c.handlePopupCreate(ctx, "parent-pane", parent, port.PopupRequest{
752+ TargetURI: "https://example.com/popup", FrameName: "popup", IsUserGesture: true,
753+ })
754+ require.Same(t, popup, created)
755+ require.Len(t, c.popups.pendingPopups, 1)
756+ // OAuth tracking is normally armed for auth-intent popups; seed it so the
757+ // cleanup contract is covered regardless of the policy's current routing.
758+ c.popups.trackOAuthPopup(260, "parent-pane", "https://app.example.com/login")
759+ require.Len(t, c.popups.popupOAuth, 1)
760+ require.NotNil(t, c.getWebViewLocked("popup-pane"))
761+ require.NotNil(t, popup.onClose)
762+
763+ // CEF aborts the popup before ready-to-show: the engine fires the lifecycle
764+ // close callback exactly as it does for a destroyed shell.
765+ popup.close()
766+
767+ assert.Empty(t, c.popups.pendingPopups)
768+ assert.Empty(t, c.popups.popupOAuth)
769+ assert.Equal(t, []entity.PaneID{"popup-pane"}, closedPanes)
770+ assert.Nil(t, c.getWebViewLocked("popup-pane"))
771+}
772+
773+// closableLifecyclePopupStub records the lifecycle close callback the content
774+// layer installs so tests can simulate the engine tearing the shell down.
775+type closableLifecyclePopupStub struct {
776+ *popupNavigationWebViewStub
777+ onClose func()
778+}
779+
780+func (s *closableLifecyclePopupStub) SetOnClose(fn func()) { s.onClose = fn }
781+func (s *closableLifecyclePopupStub) close() {
782+ if s.onClose != nil {
783+ s.onClose()
784+ }
785+}
M internal/ui/coordinator/content/popup_manager.go +14 -2
@@ -130,6 +130,8 @@ func (pm *popupManager) setPopupConfig(popupConfig *entity.BrowsingContextConfig
130130 if pm == nil {
131131 return
132132 }
133+ pm.mu.Lock()
134+ defer pm.mu.Unlock()
133135 if popupConfig == nil {
134136 pm.popupConfig = entity.BrowsingContextConfig{}
135137 pm.hasPopupConfig = false
@@ -143,15 +145,25 @@ func (pm *popupManager) updatePopupConfig(popupConfig entity.BrowsingContextConf
143145 if pm == nil {
144146 return
145147 }
148+ pm.mu.Lock()
149+ defer pm.mu.Unlock()
146150 pm.popupConfig = popupConfig
147151 pm.hasPopupConfig = true
148152 }
149153
154+// currentPopupConfig returns a snapshot copy of the popup config (nil when
155+// unset). The copy keeps callers from racing with concurrent config updates.
150156 func (pm *popupManager) currentPopupConfig() *entity.BrowsingContextConfig {
151- if pm == nil || !pm.hasPopupConfig {
157+ if pm == nil {
158+ return nil
159+ }
160+ pm.mu.RLock()
161+ defer pm.mu.RUnlock()
162+ if !pm.hasPopupConfig {
152163 return nil
153164 }
154- return &pm.popupConfig
165+ cfg := pm.popupConfig
166+ return &cfg
155167 }
156168
157169 func (pm *popupManager) setWindowIDResolver(fn func(entity.PaneID) (string, bool)) {
@@ -130,6 +130,8 @@ func (pm *popupManager) setPopupConfig(popupConfig *entity.BrowsingContextConfig
130 if pm == nil {130 if pm == nil {
131 return131 return
132 }132 }
133+ pm.mu.Lock()
134+ defer pm.mu.Unlock()
133 if popupConfig == nil {135 if popupConfig == nil {
134 pm.popupConfig = entity.BrowsingContextConfig{}136 pm.popupConfig = entity.BrowsingContextConfig{}
135 pm.hasPopupConfig = false137 pm.hasPopupConfig = false
@@ -143,15 +145,25 @@ func (pm *popupManager) updatePopupConfig(popupConfig entity.BrowsingContextConf
143 if pm == nil {145 if pm == nil {
144 return146 return
145 }147 }
148+ pm.mu.Lock()
149+ defer pm.mu.Unlock()
146 pm.popupConfig = popupConfig150 pm.popupConfig = popupConfig
147 pm.hasPopupConfig = true151 pm.hasPopupConfig = true
148 }152 }
149 153
154+// currentPopupConfig returns a snapshot copy of the popup config (nil when
155+// unset). The copy keeps callers from racing with concurrent config updates.
150 func (pm *popupManager) currentPopupConfig() *entity.BrowsingContextConfig {156 func (pm *popupManager) currentPopupConfig() *entity.BrowsingContextConfig {
151- if pm == nil || !pm.hasPopupConfig {157+ if pm == nil {
158+ return nil
159+ }
160+ pm.mu.RLock()
161+ defer pm.mu.RUnlock()
162+ if !pm.hasPopupConfig {
152 return nil163 return nil
153 }164 }
154- return &pm.popupConfig165+ cfg := pm.popupConfig
166+ return &cfg
155 }167 }
156 168
157 func (pm *popupManager) setWindowIDResolver(fn func(entity.PaneID) (string, bool)) {169 func (pm *popupManager) setWindowIDResolver(fn func(entity.PaneID) (string, bool)) {

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