detached fdb7c64

fix(ui): bind reveal state to webview identity

commit
fdb7c64f82ee2154e622d0762a902031a83daa73
author
brice · · 2026-07-15 04:24 UTC
parent
3495a30
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +244 -254
M internal/ui/component/pane_view.go +5 -2
@@ -177,8 +177,11 @@ func (pv *PaneView) AttachWebViewWidget(widget layout.Widget, revealed bool) {
177177 if hadParent && wasVisible {
178178 widget.SetVisible(true)
179179 }
180- if revealed && pv.loading != nil {
181- pv.loading.SetVisible(false)
180+ // A replacement can follow an already-revealed WebView in the same pane.
181+ // Apply both states explicitly: an unrevealed current WebView must restart
182+ // the skeleton rather than inheriting the previous view's hidden overlay.
183+ if pv.loading != nil {
184+ pv.loading.SetVisible(!revealed)
182185 }
183186
184187 ctx := pv.ctx
@@ -177,8 +177,11 @@ func (pv *PaneView) AttachWebViewWidget(widget layout.Widget, revealed bool) {
177 if hadParent && wasVisible {177 if hadParent && wasVisible {
178 widget.SetVisible(true)178 widget.SetVisible(true)
179 }179 }
180- if revealed && pv.loading != nil {180+ // A replacement can follow an already-revealed WebView in the same pane.
181- pv.loading.SetVisible(false)181+ // Apply both states explicitly: an unrevealed current WebView must restart
182+ // the skeleton rather than inheriting the previous view's hidden overlay.
183+ if pv.loading != nil {
184+ pv.loading.SetVisible(!revealed)
182 }185 }
183 186
184 ctx := pv.ctx187 ctx := pv.ctx
M internal/ui/component/pane_view_internal_test.go +24 -0
@@ -118,6 +118,7 @@ func TestPaneView_AttachWebViewWidget_UsesExplicitRevealStateAfterCleanup(t *tes
118118 widget.EXPECT().GetParent().Return(nil).Once()
119119 widget.EXPECT().IsVisible().Return(true).Once()
120120 overlay.EXPECT().SetChild(widget).Once()
121+ loadingContainer.EXPECT().SetVisible(true).Once()
121122
122123 pv := &PaneView{
123124 overlay: overlay,
@@ -126,3 +127,26 @@ func TestPaneView_AttachWebViewWidget_UsesExplicitRevealStateAfterCleanup(t *tes
126127 pv.AttachWebViewWidget(widget, false)
127128 })
128129 }
130+
131+func TestPaneView_AttachWebViewWidget_UnrevealedReplacementRestartsLoadingSkeleton(t *testing.T) {
132+ overlay := mocks.NewMockOverlayWidget(t)
133+ widget := mocks.NewMockWidget(t)
134+ loadingContainer := mocks.NewMockBoxWidget(t)
135+ spinner := mocks.NewMockSpinnerWidget(t)
136+
137+ overlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
138+ overlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
139+ widget.EXPECT().GetAllocatedWidth().Return(0).Twice()
140+ widget.EXPECT().GetAllocatedHeight().Return(0).Twice()
141+ widget.EXPECT().GetParent().Return(nil).Once()
142+ widget.EXPECT().IsVisible().Return(true).Once()
143+ overlay.EXPECT().SetChild(widget).Once()
144+ loadingContainer.EXPECT().SetVisible(true).Once()
145+ spinner.EXPECT().Start().Once()
146+
147+ pv := &PaneView{
148+ overlay: overlay,
149+ loading: &LoadingSkeleton{container: loadingContainer, spinner: spinner},
150+ }
151+ pv.AttachWebViewWidget(widget, false)
152+}
@@ -118,6 +118,7 @@ func TestPaneView_AttachWebViewWidget_UsesExplicitRevealStateAfterCleanup(t *tes
118 widget.EXPECT().GetParent().Return(nil).Once()118 widget.EXPECT().GetParent().Return(nil).Once()
119 widget.EXPECT().IsVisible().Return(true).Once()119 widget.EXPECT().IsVisible().Return(true).Once()
120 overlay.EXPECT().SetChild(widget).Once()120 overlay.EXPECT().SetChild(widget).Once()
121+ loadingContainer.EXPECT().SetVisible(true).Once()
121 122
122 pv := &PaneView{123 pv := &PaneView{
123 overlay: overlay,124 overlay: overlay,
@@ -126,3 +127,26 @@ func TestPaneView_AttachWebViewWidget_UsesExplicitRevealStateAfterCleanup(t *tes
126 pv.AttachWebViewWidget(widget, false)127 pv.AttachWebViewWidget(widget, false)
127 })128 })
128 }129 }
130+
131+func TestPaneView_AttachWebViewWidget_UnrevealedReplacementRestartsLoadingSkeleton(t *testing.T) {
132+ overlay := mocks.NewMockOverlayWidget(t)
133+ widget := mocks.NewMockWidget(t)
134+ loadingContainer := mocks.NewMockBoxWidget(t)
135+ spinner := mocks.NewMockSpinnerWidget(t)
136+
137+ overlay.EXPECT().GetAllocatedWidth().Return(0).Twice()
138+ overlay.EXPECT().GetAllocatedHeight().Return(0).Twice()
139+ widget.EXPECT().GetAllocatedWidth().Return(0).Twice()
140+ widget.EXPECT().GetAllocatedHeight().Return(0).Twice()
141+ widget.EXPECT().GetParent().Return(nil).Once()
142+ widget.EXPECT().IsVisible().Return(true).Once()
143+ overlay.EXPECT().SetChild(widget).Once()
144+ loadingContainer.EXPECT().SetVisible(true).Once()
145+ spinner.EXPECT().Start().Once()
146+
147+ pv := &PaneView{
148+ overlay: overlay,
149+ loading: &LoadingSkeleton{container: loadingContainer, spinner: spinner},
150+ }
151+ pv.AttachWebViewWidget(widget, false)
152+}
M internal/ui/coordinator/content/callbacks.go +1 -1
@@ -79,7 +79,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
7979 }
8080 },
8181 OnProgressChanged: func(progress float64) {
82- c.onProgressChanged(paneID, progress)
82+ c.onProgressChanged(paneID, wv, progress)
8383 },
8484 OnURIChanged: func(uri string) {
8585 c.handleURIChanged(ctx, paneID, wv, uri)
@@ -79,7 +79,7 @@ func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.P
79 }79 }
80 },80 },
81 OnProgressChanged: func(progress float64) {81 OnProgressChanged: func(progress float64) {
82- c.onProgressChanged(paneID, progress)82+ c.onProgressChanged(paneID, wv, progress)
83 },83 },
84 OnURIChanged: func(uri string) {84 OnURIChanged: func(uri string) {
85 c.handleURIChanged(ctx, paneID, wv, uri)85 c.handleURIChanged(ctx, paneID, wv, uri)
M internal/ui/coordinator/content/content_reveal_test.go +63 -192
@@ -2,7 +2,6 @@ package content
22
33 import (
44 "context"
5- "sync"
65 "testing"
76
87 "github.com/stretchr/testify/assert"
@@ -10,217 +9,89 @@ import (
109 "github.com/bnema/dumber/internal/application/port"
1110 "github.com/bnema/dumber/internal/application/port/mocks"
1211 "github.com/bnema/dumber/internal/domain/entity"
13- "github.com/bnema/dumber/internal/ui/component"
1412 )
1513
16-// TestPendingReveal tests the pending reveal state management.
17-// These are internal package tests that verify the state transitions
18-// without needing to mock WebKit dependencies.
19-
20-func TestWebViewRevealed_PersistsUntilWebViewRelease(t *testing.T) {
21- paneID := entity.PaneID("pane-1")
22- c := &Coordinator{}
23-
24- assert.False(t, c.WebViewRevealed(paneID))
25-
26- c.markWebViewRevealed(paneID)
27- assert.True(t, c.WebViewRevealed(paneID))
28-
29- c.clearWebViewRevealed(paneID)
30- assert.False(t, c.WebViewRevealed(paneID))
31-}
32-
33-func TestMarkPendingReveal_SetsState(t *testing.T) {
34- c := &Coordinator{
35- pendingReveal: make(map[entity.PaneID]bool),
14+func newRevealTestCoordinator() *Coordinator {
15+ return &Coordinator{
16+ webViews: make(map[entity.PaneID]port.WebView),
17+ webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),
18+ paneTitles: make(map[entity.PaneID]string),
19+ navOrigins: make(map[entity.PaneID]string),
3620 }
37- paneID := entity.PaneID("pane-1")
38-
39- c.markPendingReveal(paneID)
40-
41- c.revealMu.Lock()
42- defer c.revealMu.Unlock()
43- assert.True(t, c.pendingReveal[paneID])
4421 }
4522
46-func TestClearPendingReveal_RemovesState(t *testing.T) {
47- c := &Coordinator{
48- pendingReveal: make(map[entity.PaneID]bool),
49- }
50- paneID := entity.PaneID("pane-1")
51-
52- // First mark as pending
53- c.markPendingReveal(paneID)
54-
55- // Then clear
56- c.clearPendingReveal(paneID)
57-
58- c.revealMu.Lock()
59- defer c.revealMu.Unlock()
60- assert.False(t, c.pendingReveal[paneID])
23+func revealTestWebView(t *testing.T, id port.WebViewID, generation uint64) *mocks.MockWebView {
24+ t.Helper()
25+ wv := mocks.NewMockWebView(t)
26+ wv.EXPECT().ID().Return(id).Maybe()
27+ wv.EXPECT().Generation().Return(generation).Maybe()
28+ return wv
6129 }
6230
63-func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {
64- callbackCalled := false
65- c := &Coordinator{
66- pendingReveal: make(map[entity.PaneID]bool),
67- webViews: make(map[entity.PaneID]port.WebView),
68- onWebViewShown: func(paneID entity.PaneID) {
69- callbackCalled = true
70- },
71- }
31+func TestRevealState_IsBoundToCurrentWebViewAcrossReplacementPaths(t *testing.T) {
7232 paneID := entity.PaneID("pane-1")
33+ first := revealTestWebView(t, 101, 1)
34+ // The pool may reuse the same native WebView ID; its reuse generation is
35+ // part of the presentation identity.
36+ directReplacement := revealTestWebView(t, 101, 2)
37+ popupReplacement := revealTestWebView(t, 103, 1)
38+ c := newRevealTestCoordinator()
39+
40+ c.setWebViewLocked(paneID, first)
41+ c.markWebViewRevealed(paneID, first)
42+ assert.True(t, c.WebViewRevealed(paneID))
7343
74- // Call reveal without marking pending first
75- c.revealIfPending(context.Background(), paneID, "http://example.com", "test")
44+ // A direct replacement for the same pane must show the fresh loading state.
45+ c.setWebViewLocked(paneID, directReplacement)
46+ assert.False(t, c.WebViewRevealed(paneID))
7647
77- assert.False(t, callbackCalled, "callback should not be called when not pending")
48+ c.markWebViewRevealed(paneID, directReplacement)
49+ c.RegisterPopupWebView(paneID, popupReplacement)
50+ assert.False(t, c.WebViewRevealed(paneID), "popup registration must not inherit a prior WebView reveal")
7851 }
7952
80-func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {
81- c := &Coordinator{
82- pendingReveal: make(map[entity.PaneID]bool),
83- webViews: make(map[entity.PaneID]port.WebView),
84- }
53+func TestRevealState_ReleaseClearsStateWithoutWebViewMapping(t *testing.T) {
8554 paneID := entity.PaneID("pane-1")
86-
87- // Mark as pending
88- c.markPendingReveal(paneID)
89-
90- // Reveal (will return early since webview is nil, but should still clear state)
91- c.revealIfPending(context.Background(), paneID, "http://example.com", "test")
55+ wv := revealTestWebView(t, 101, 1)
56+ c := newRevealTestCoordinator()
57+ c.setWebViewLocked(paneID, wv)
58+ c.markWebViewRevealed(paneID, wv)
59+
60+ // Model an earlier partial cleanup that lost the WebView mapping. Release
61+ // remains responsible for clearing presentation state on its early return.
62+ c.webViewsMu.Lock()
63+ delete(c.webViews, paneID)
64+ c.webViewsMu.Unlock()
65+ c.ReleaseWebView(context.Background(), paneID)
9266
9367 c.revealMu.Lock()
94- defer c.revealMu.Unlock()
95- assert.False(t, c.pendingReveal[paneID], "pending state should be cleared after reveal attempt")
68+ _, retained := c.revealedWebViews[paneID]
69+ c.revealMu.Unlock()
70+ assert.False(t, retained)
9671 }
9772
98-func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
99- revealCount := 0
100- c := &Coordinator{
101- pendingReveal: make(map[entity.PaneID]bool),
102- webViews: make(map[entity.PaneID]port.WebView),
103- onWebViewShown: func(paneID entity.PaneID) {
104- revealCount++
105- },
106- }
73+func TestRevealState_StaleCallbackCannotRevealReplacement(t *testing.T) {
10774 paneID := entity.PaneID("pane-1")
108-
109- // Mark as pending
110- c.markPendingReveal(paneID)
111-
112- // Call reveal multiple times
113- c.revealIfPending(context.Background(), paneID, "", "first")
114- c.revealIfPending(context.Background(), paneID, "", "second")
115- c.revealIfPending(context.Background(), paneID, "", "third")
116-
117- // Callback should not be called since webview is nil, but state should be cleared
118- assert.Equal(t, 0, revealCount, "callback not called when webview is nil")
119-
120- // Verify state is cleared
121- c.revealMu.Lock()
122- defer c.revealMu.Unlock()
123- assert.False(t, c.pendingReveal[paneID])
75+ oldWV := revealTestWebView(t, 101, 1)
76+ newWV := revealTestWebView(t, 102, 1)
77+ oldWV.EXPECT().IsDestroyed().Return(false).Maybe()
78+ c := newRevealTestCoordinator()
79+ c.setWebViewLocked(paneID, oldWV)
80+ c.markPendingReveal(paneID, oldWV)
81+ c.setWebViewLocked(paneID, newWV)
82+
83+ c.revealIfPending(context.Background(), paneID, oldWV, "", "stale")
84+ assert.False(t, c.WebViewRevealed(paneID))
12485 }
12586
126-func TestOnLoadCommitted_RevealsPendingWebViewForCommittedPage(t *testing.T) {
87+func TestRevealState_RevealedCurrentWebViewPersistsForWorkspaceRebuild(t *testing.T) {
12788 paneID := entity.PaneID("pane-1")
128- shownCount := 0
129- var shownPane entity.PaneID
130-
131- wv := mocks.NewMockWebView(t)
132- wv.EXPECT().URI().Return("https://example.com")
133- wv.EXPECT().ResetBackgroundToDefault()
134- wv.EXPECT().Title().Return("")
135- wv.EXPECT().IsDestroyed().Return(false).Maybe()
136-
137- c := &Coordinator{
138- pendingReveal: make(map[entity.PaneID]bool),
139- webViews: map[entity.PaneID]port.WebView{
140- paneID: wv,
141- },
142- paneTitles: make(map[entity.PaneID]string),
143- navOrigins: make(map[entity.PaneID]string),
144- getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return nil, nil },
145- onWebViewShown: func(id entity.PaneID) {
146- shownCount++
147- shownPane = id
148- },
149- }
150- c.pendingReveal[paneID] = true
151-
152- c.onLoadCommitted(context.Background(), paneID, wv)
89+ wv := revealTestWebView(t, 101, 1)
90+ c := newRevealTestCoordinator()
91+ c.setWebViewLocked(paneID, wv)
92+ c.markWebViewRevealed(paneID, wv)
15393
154- assert.Equal(t, 1, shownCount)
155- assert.Equal(t, paneID, shownPane)
156-}
157-
158-func TestPendingReveal_ConcurrentAccess(t *testing.T) {
159- c := &Coordinator{
160- pendingReveal: make(map[entity.PaneID]bool),
161- webViews: make(map[entity.PaneID]port.WebView),
162- }
163-
164- var wg sync.WaitGroup
165- paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3", "pane-4", "pane-5"}
166-
167- // Concurrently mark and clear panes
168- for range 100 {
169- for _, paneID := range paneIDs {
170- wg.Add(3)
171-
172- go func(id entity.PaneID) {
173- defer wg.Done()
174- c.markPendingReveal(id)
175- }(paneID)
176-
177- go func(id entity.PaneID) {
178- defer wg.Done()
179- c.clearPendingReveal(id)
180- }(paneID)
181-
182- go func(id entity.PaneID) {
183- defer wg.Done()
184- c.revealIfPending(context.Background(), id, "", "concurrent")
185- }(paneID)
186- }
187- }
188-
189- wg.Wait()
190- // Test passes if no race conditions occur
191-}
192-
193-func TestMarkPendingReveal_MultiplePanes(t *testing.T) {
194- c := &Coordinator{
195- pendingReveal: make(map[entity.PaneID]bool),
196- }
197-
198- pane1 := entity.PaneID("pane-1")
199- pane2 := entity.PaneID("pane-2")
200-
201- c.markPendingReveal(pane1)
202- c.markPendingReveal(pane2)
203-
204- c.revealMu.Lock()
205- defer c.revealMu.Unlock()
206- assert.True(t, c.pendingReveal[pane1])
207- assert.True(t, c.pendingReveal[pane2])
208-}
209-
210-func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) {
211- c := &Coordinator{
212- pendingReveal: make(map[entity.PaneID]bool),
213- }
214-
215- pane1 := entity.PaneID("pane-1")
216- pane2 := entity.PaneID("pane-2")
217-
218- c.markPendingReveal(pane1)
219- c.markPendingReveal(pane2)
220- c.clearPendingReveal(pane1)
221-
222- c.revealMu.Lock()
223- defer c.revealMu.Unlock()
224- assert.False(t, c.pendingReveal[pane1], "pane1 should be cleared")
225- assert.True(t, c.pendingReveal[pane2], "pane2 should still be pending")
94+ // AttachToWorkspace asks this identity-aware query when rebuilding a pane,
95+ // so the replacement PaneView hides its skeleton for this same WebView.
96+ assert.True(t, c.WebViewRevealed(paneID))
22697 }
@@ -2,7 +2,6 @@ package content
2 2
3 import (3 import (
4 "context"4 "context"
5- "sync"
6 "testing"5 "testing"
7 6
8 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
@@ -10,217 +9,89 @@ import (
10 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/application/port/mocks"10 "github.com/bnema/dumber/internal/application/port/mocks"
12 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
13- "github.com/bnema/dumber/internal/ui/component"
14 )12 )
15 13
16-// TestPendingReveal tests the pending reveal state management.14+func newRevealTestCoordinator() *Coordinator {
17-// These are internal package tests that verify the state transitions15+ return &Coordinator{
18-// without needing to mock WebKit dependencies.16+ webViews: make(map[entity.PaneID]port.WebView),
19-17+ webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),
20-func TestWebViewRevealed_PersistsUntilWebViewRelease(t *testing.T) {18+ paneTitles: make(map[entity.PaneID]string),
21- paneID := entity.PaneID("pane-1")19+ navOrigins: make(map[entity.PaneID]string),
22- c := &Coordinator{}
23-
24- assert.False(t, c.WebViewRevealed(paneID))
25-
26- c.markWebViewRevealed(paneID)
27- assert.True(t, c.WebViewRevealed(paneID))
28-
29- c.clearWebViewRevealed(paneID)
30- assert.False(t, c.WebViewRevealed(paneID))
31-}
32-
33-func TestMarkPendingReveal_SetsState(t *testing.T) {
34- c := &Coordinator{
35- pendingReveal: make(map[entity.PaneID]bool),
36 }20 }
37- paneID := entity.PaneID("pane-1")
38-
39- c.markPendingReveal(paneID)
40-
41- c.revealMu.Lock()
42- defer c.revealMu.Unlock()
43- assert.True(t, c.pendingReveal[paneID])
44 }21 }
45 22
46-func TestClearPendingReveal_RemovesState(t *testing.T) {23+func revealTestWebView(t *testing.T, id port.WebViewID, generation uint64) *mocks.MockWebView {
47- c := &Coordinator{24+ t.Helper()
48- pendingReveal: make(map[entity.PaneID]bool),25+ wv := mocks.NewMockWebView(t)
49- }26+ wv.EXPECT().ID().Return(id).Maybe()
50- paneID := entity.PaneID("pane-1")27+ wv.EXPECT().Generation().Return(generation).Maybe()
51-28+ return wv
52- // First mark as pending
53- c.markPendingReveal(paneID)
54-
55- // Then clear
56- c.clearPendingReveal(paneID)
57-
58- c.revealMu.Lock()
59- defer c.revealMu.Unlock()
60- assert.False(t, c.pendingReveal[paneID])
61 }29 }
62 30
63-func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {31+func TestRevealState_IsBoundToCurrentWebViewAcrossReplacementPaths(t *testing.T) {
64- callbackCalled := false
65- c := &Coordinator{
66- pendingReveal: make(map[entity.PaneID]bool),
67- webViews: make(map[entity.PaneID]port.WebView),
68- onWebViewShown: func(paneID entity.PaneID) {
69- callbackCalled = true
70- },
71- }
72 paneID := entity.PaneID("pane-1")32 paneID := entity.PaneID("pane-1")
33+ first := revealTestWebView(t, 101, 1)
34+ // The pool may reuse the same native WebView ID; its reuse generation is
35+ // part of the presentation identity.
36+ directReplacement := revealTestWebView(t, 101, 2)
37+ popupReplacement := revealTestWebView(t, 103, 1)
38+ c := newRevealTestCoordinator()
39+
40+ c.setWebViewLocked(paneID, first)
41+ c.markWebViewRevealed(paneID, first)
42+ assert.True(t, c.WebViewRevealed(paneID))
73 43
74- // Call reveal without marking pending first44+ // A direct replacement for the same pane must show the fresh loading state.
75- c.revealIfPending(context.Background(), paneID, "http://example.com", "test")45+ c.setWebViewLocked(paneID, directReplacement)
46+ assert.False(t, c.WebViewRevealed(paneID))
76 47
77- assert.False(t, callbackCalled, "callback should not be called when not pending")48+ c.markWebViewRevealed(paneID, directReplacement)
49+ c.RegisterPopupWebView(paneID, popupReplacement)
50+ assert.False(t, c.WebViewRevealed(paneID), "popup registration must not inherit a prior WebView reveal")
78 }51 }
79 52
80-func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {53+func TestRevealState_ReleaseClearsStateWithoutWebViewMapping(t *testing.T) {
81- c := &Coordinator{
82- pendingReveal: make(map[entity.PaneID]bool),
83- webViews: make(map[entity.PaneID]port.WebView),
84- }
85 paneID := entity.PaneID("pane-1")54 paneID := entity.PaneID("pane-1")
86-55+ wv := revealTestWebView(t, 101, 1)
87- // Mark as pending56+ c := newRevealTestCoordinator()
88- c.markPendingReveal(paneID)57+ c.setWebViewLocked(paneID, wv)
89-58+ c.markWebViewRevealed(paneID, wv)
90- // Reveal (will return early since webview is nil, but should still clear state)59+
91- c.revealIfPending(context.Background(), paneID, "http://example.com", "test")60+ // Model an earlier partial cleanup that lost the WebView mapping. Release
61+ // remains responsible for clearing presentation state on its early return.
62+ c.webViewsMu.Lock()
63+ delete(c.webViews, paneID)
64+ c.webViewsMu.Unlock()
65+ c.ReleaseWebView(context.Background(), paneID)
92 66
93 c.revealMu.Lock()67 c.revealMu.Lock()
94- defer c.revealMu.Unlock()68+ _, retained := c.revealedWebViews[paneID]
95- assert.False(t, c.pendingReveal[paneID], "pending state should be cleared after reveal attempt")69+ c.revealMu.Unlock()
70+ assert.False(t, retained)
96 }71 }
97 72
98-func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {73+func TestRevealState_StaleCallbackCannotRevealReplacement(t *testing.T) {
99- revealCount := 0
100- c := &Coordinator{
101- pendingReveal: make(map[entity.PaneID]bool),
102- webViews: make(map[entity.PaneID]port.WebView),
103- onWebViewShown: func(paneID entity.PaneID) {
104- revealCount++
105- },
106- }
107 paneID := entity.PaneID("pane-1")74 paneID := entity.PaneID("pane-1")
108-75+ oldWV := revealTestWebView(t, 101, 1)
109- // Mark as pending76+ newWV := revealTestWebView(t, 102, 1)
110- c.markPendingReveal(paneID)77+ oldWV.EXPECT().IsDestroyed().Return(false).Maybe()
111-78+ c := newRevealTestCoordinator()
112- // Call reveal multiple times79+ c.setWebViewLocked(paneID, oldWV)
113- c.revealIfPending(context.Background(), paneID, "", "first")80+ c.markPendingReveal(paneID, oldWV)
114- c.revealIfPending(context.Background(), paneID, "", "second")81+ c.setWebViewLocked(paneID, newWV)
115- c.revealIfPending(context.Background(), paneID, "", "third")82+
116-83+ c.revealIfPending(context.Background(), paneID, oldWV, "", "stale")
117- // Callback should not be called since webview is nil, but state should be cleared84+ assert.False(t, c.WebViewRevealed(paneID))
118- assert.Equal(t, 0, revealCount, "callback not called when webview is nil")
119-
120- // Verify state is cleared
121- c.revealMu.Lock()
122- defer c.revealMu.Unlock()
123- assert.False(t, c.pendingReveal[paneID])
124 }85 }
125 86
126-func TestOnLoadCommitted_RevealsPendingWebViewForCommittedPage(t *testing.T) {87+func TestRevealState_RevealedCurrentWebViewPersistsForWorkspaceRebuild(t *testing.T) {
127 paneID := entity.PaneID("pane-1")88 paneID := entity.PaneID("pane-1")
128- shownCount := 089+ wv := revealTestWebView(t, 101, 1)
129- var shownPane entity.PaneID90+ c := newRevealTestCoordinator()
130-91+ c.setWebViewLocked(paneID, wv)
131- wv := mocks.NewMockWebView(t)92+ c.markWebViewRevealed(paneID, wv)
132- wv.EXPECT().URI().Return("https://example.com")
133- wv.EXPECT().ResetBackgroundToDefault()
134- wv.EXPECT().Title().Return("")
135- wv.EXPECT().IsDestroyed().Return(false).Maybe()
136-
137- c := &Coordinator{
138- pendingReveal: make(map[entity.PaneID]bool),
139- webViews: map[entity.PaneID]port.WebView{
140- paneID: wv,
141- },
142- paneTitles: make(map[entity.PaneID]string),
143- navOrigins: make(map[entity.PaneID]string),
144- getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) { return nil, nil },
145- onWebViewShown: func(id entity.PaneID) {
146- shownCount++
147- shownPane = id
148- },
149- }
150- c.pendingReveal[paneID] = true
151-
152- c.onLoadCommitted(context.Background(), paneID, wv)
153 93
154- assert.Equal(t, 1, shownCount)94+ // AttachToWorkspace asks this identity-aware query when rebuilding a pane,
155- assert.Equal(t, paneID, shownPane)95+ // so the replacement PaneView hides its skeleton for this same WebView.
156-}96+ assert.True(t, c.WebViewRevealed(paneID))
157-
158-func TestPendingReveal_ConcurrentAccess(t *testing.T) {
159- c := &Coordinator{
160- pendingReveal: make(map[entity.PaneID]bool),
161- webViews: make(map[entity.PaneID]port.WebView),
162- }
163-
164- var wg sync.WaitGroup
165- paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3", "pane-4", "pane-5"}
166-
167- // Concurrently mark and clear panes
168- for range 100 {
169- for _, paneID := range paneIDs {
170- wg.Add(3)
171-
172- go func(id entity.PaneID) {
173- defer wg.Done()
174- c.markPendingReveal(id)
175- }(paneID)
176-
177- go func(id entity.PaneID) {
178- defer wg.Done()
179- c.clearPendingReveal(id)
180- }(paneID)
181-
182- go func(id entity.PaneID) {
183- defer wg.Done()
184- c.revealIfPending(context.Background(), id, "", "concurrent")
185- }(paneID)
186- }
187- }
188-
189- wg.Wait()
190- // Test passes if no race conditions occur
191-}
192-
193-func TestMarkPendingReveal_MultiplePanes(t *testing.T) {
194- c := &Coordinator{
195- pendingReveal: make(map[entity.PaneID]bool),
196- }
197-
198- pane1 := entity.PaneID("pane-1")
199- pane2 := entity.PaneID("pane-2")
200-
201- c.markPendingReveal(pane1)
202- c.markPendingReveal(pane2)
203-
204- c.revealMu.Lock()
205- defer c.revealMu.Unlock()
206- assert.True(t, c.pendingReveal[pane1])
207- assert.True(t, c.pendingReveal[pane2])
208-}
209-
210-func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) {
211- c := &Coordinator{
212- pendingReveal: make(map[entity.PaneID]bool),
213- }
214-
215- pane1 := entity.PaneID("pane-1")
216- pane2 := entity.PaneID("pane-2")
217-
218- c.markPendingReveal(pane1)
219- c.markPendingReveal(pane2)
220- c.clearPendingReveal(pane1)
221-
222- c.revealMu.Lock()
223- defer c.revealMu.Unlock()
224- assert.False(t, c.pendingReveal[pane1], "pane1 should be cleared")
225- assert.True(t, c.pendingReveal[pane2], "pane2 should still be pending")
226 }97 }
M internal/ui/coordinator/content/coordinator.go +72 -21
@@ -70,9 +70,13 @@ type Coordinator struct {
7070 // Callback when the WebView becomes visible (first real commit)
7171 onWebViewShown func(paneID entity.PaneID)
7272
73- revealMu sync.Mutex
74- pendingReveal map[entity.PaneID]bool
75- revealedWebViews map[entity.PaneID]bool
73+ // revealMu is always acquired after webViewsMu. Reveal state is keyed by the
74+ // WebView identity (ID plus pool-reuse generation), never by pane alone.
75+ revealMu sync.Mutex
76+ pendingReveal map[entity.PaneID]bool
77+ revealedWebViews map[entity.PaneID]bool
78+ revealedWebViewIdentity map[entity.PaneID]webViewIdentity
79+ pendingRevealIdentity map[entity.PaneID]webViewIdentity
7680
7781 appearanceMu sync.Mutex
7882 pendingScriptRefresh map[entity.PaneID]bool
@@ -137,23 +141,25 @@ func NewCoordinator(
137141 log.Debug().Msg("creating content coordinator")
138142
139143 return &Coordinator{
140- logger: log.With().Str("component", "content-coordinator").Logger(),
141- pool: pool,
142- injector: injector,
143- widgetFactory: widgetFactory,
144- faviconAdapter: faviconAdapter,
145- zoomUC: zoomUC,
146- permissionUC: permissionUC,
147- webViews: make(map[entity.PaneID]port.WebView),
148- webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),
149- paneTitles: make(map[entity.PaneID]string),
150- navOrigins: make(map[entity.PaneID]string),
151- pendingReveal: make(map[entity.PaneID]bool),
152- revealedWebViews: make(map[entity.PaneID]bool),
153- pendingScriptRefresh: make(map[entity.PaneID]bool),
154- pendingThemePanes: make(map[entity.PaneID]bool),
155- getActiveWS: getActiveWS,
156- popups: newPopupManager(),
144+ logger: log.With().Str("component", "content-coordinator").Logger(),
145+ pool: pool,
146+ injector: injector,
147+ widgetFactory: widgetFactory,
148+ faviconAdapter: faviconAdapter,
149+ zoomUC: zoomUC,
150+ permissionUC: permissionUC,
151+ webViews: make(map[entity.PaneID]port.WebView),
152+ webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),
153+ paneTitles: make(map[entity.PaneID]string),
154+ navOrigins: make(map[entity.PaneID]string),
155+ pendingReveal: make(map[entity.PaneID]bool),
156+ revealedWebViews: make(map[entity.PaneID]bool),
157+ revealedWebViewIdentity: make(map[entity.PaneID]webViewIdentity),
158+ pendingRevealIdentity: make(map[entity.PaneID]webViewIdentity),
159+ pendingScriptRefresh: make(map[entity.PaneID]bool),
160+ pendingThemePanes: make(map[entity.PaneID]bool),
161+ getActiveWS: getActiveWS,
162+ popups: newPopupManager(),
157163 }
158164 }
159165
@@ -312,8 +318,40 @@ func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {
312318 return c.webViews[paneID]
313319 }
314320
321+type webViewIdentity struct {
322+ id port.WebViewID
323+ generation uint64
324+}
325+
326+func identityForWebView(wv port.WebView) (webViewIdentity, bool) {
327+ if wv == nil {
328+ return webViewIdentity{}, false
329+ }
330+ return webViewIdentity{id: wv.ID(), generation: wv.Generation()}, true
331+}
332+
333+func (c *Coordinator) ensureRevealMapsLocked() {
334+ if c.pendingReveal == nil {
335+ c.pendingReveal = make(map[entity.PaneID]bool)
336+ }
337+ if c.revealedWebViews == nil {
338+ c.revealedWebViews = make(map[entity.PaneID]bool)
339+ }
340+ if c.revealedWebViewIdentity == nil {
341+ c.revealedWebViewIdentity = make(map[entity.PaneID]webViewIdentity)
342+ }
343+ if c.pendingRevealIdentity == nil {
344+ c.pendingRevealIdentity = make(map[entity.PaneID]webViewIdentity)
345+ }
346+}
347+
348+// setWebViewLocked replaces the pane mapping and resets all reveal state in
349+// one critical section. It is used by normal acquisition and popup paths.
315350 func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
316351 c.webViewsMu.Lock()
352+ c.revealMu.Lock()
353+ defer c.revealMu.Unlock()
354+ defer c.webViewsMu.Unlock()
317355 if c.webViews == nil {
318356 c.webViews = make(map[entity.PaneID]port.WebView)
319357 }
@@ -324,17 +362,30 @@ func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
324362 if wv != nil && c.webViewPaneIDs != nil {
325363 c.webViewPaneIDs[wv.ID()] = paneID
326364 }
327- c.webViewsMu.Unlock()
365+ c.ensureRevealMapsLocked()
366+ delete(c.pendingReveal, paneID)
367+ delete(c.pendingRevealIdentity, paneID)
368+ delete(c.revealedWebViews, paneID)
369+ delete(c.revealedWebViewIdentity, paneID)
328370 }
329371
372+// deleteWebViewLocked removes both the mapping and any presentation state,
373+// including callers which release after another path already removed the map.
330374 func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {
331375 c.webViewsMu.Lock()
376+ c.revealMu.Lock()
377+ defer c.revealMu.Unlock()
332378 defer c.webViewsMu.Unlock()
333379 wv := c.webViews[paneID]
334380 delete(c.webViews, paneID)
335381 if wv != nil && c.webViewPaneIDs != nil {
336382 delete(c.webViewPaneIDs, wv.ID())
337383 }
384+ c.ensureRevealMapsLocked()
385+ delete(c.pendingReveal, paneID)
386+ delete(c.pendingRevealIdentity, paneID)
387+ delete(c.revealedWebViews, paneID)
388+ delete(c.revealedWebViewIdentity, paneID)
338389 return wv
339390 }
340391
@@ -70,9 +70,13 @@ type Coordinator struct {
70 // Callback when the WebView becomes visible (first real commit)70 // Callback when the WebView becomes visible (first real commit)
71 onWebViewShown func(paneID entity.PaneID)71 onWebViewShown func(paneID entity.PaneID)
72 72
73- revealMu sync.Mutex73+ // revealMu is always acquired after webViewsMu. Reveal state is keyed by the
74- pendingReveal map[entity.PaneID]bool74+ // WebView identity (ID plus pool-reuse generation), never by pane alone.
75- revealedWebViews map[entity.PaneID]bool75+ revealMu sync.Mutex
76+ pendingReveal map[entity.PaneID]bool
77+ revealedWebViews map[entity.PaneID]bool
78+ revealedWebViewIdentity map[entity.PaneID]webViewIdentity
79+ pendingRevealIdentity map[entity.PaneID]webViewIdentity
76 80
77 appearanceMu sync.Mutex81 appearanceMu sync.Mutex
78 pendingScriptRefresh map[entity.PaneID]bool82 pendingScriptRefresh map[entity.PaneID]bool
@@ -137,23 +141,25 @@ func NewCoordinator(
137 log.Debug().Msg("creating content coordinator")141 log.Debug().Msg("creating content coordinator")
138 142
139 return &Coordinator{143 return &Coordinator{
140- logger: log.With().Str("component", "content-coordinator").Logger(),144+ logger: log.With().Str("component", "content-coordinator").Logger(),
141- pool: pool,145+ pool: pool,
142- injector: injector,146+ injector: injector,
143- widgetFactory: widgetFactory,147+ widgetFactory: widgetFactory,
144- faviconAdapter: faviconAdapter,148+ faviconAdapter: faviconAdapter,
145- zoomUC: zoomUC,149+ zoomUC: zoomUC,
146- permissionUC: permissionUC,150+ permissionUC: permissionUC,
147- webViews: make(map[entity.PaneID]port.WebView),151+ webViews: make(map[entity.PaneID]port.WebView),
148- webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),152+ webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),
149- paneTitles: make(map[entity.PaneID]string),153+ paneTitles: make(map[entity.PaneID]string),
150- navOrigins: make(map[entity.PaneID]string),154+ navOrigins: make(map[entity.PaneID]string),
151- pendingReveal: make(map[entity.PaneID]bool),155+ pendingReveal: make(map[entity.PaneID]bool),
152- revealedWebViews: make(map[entity.PaneID]bool),156+ revealedWebViews: make(map[entity.PaneID]bool),
153- pendingScriptRefresh: make(map[entity.PaneID]bool),157+ revealedWebViewIdentity: make(map[entity.PaneID]webViewIdentity),
154- pendingThemePanes: make(map[entity.PaneID]bool),158+ pendingRevealIdentity: make(map[entity.PaneID]webViewIdentity),
155- getActiveWS: getActiveWS,159+ pendingScriptRefresh: make(map[entity.PaneID]bool),
156- popups: newPopupManager(),160+ pendingThemePanes: make(map[entity.PaneID]bool),
161+ getActiveWS: getActiveWS,
162+ popups: newPopupManager(),
157 }163 }
158 }164 }
159 165
@@ -312,8 +318,40 @@ func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {
312 return c.webViews[paneID]318 return c.webViews[paneID]
313 }319 }
314 320
321+type webViewIdentity struct {
322+ id port.WebViewID
323+ generation uint64
324+}
325+
326+func identityForWebView(wv port.WebView) (webViewIdentity, bool) {
327+ if wv == nil {
328+ return webViewIdentity{}, false
329+ }
330+ return webViewIdentity{id: wv.ID(), generation: wv.Generation()}, true
331+}
332+
333+func (c *Coordinator) ensureRevealMapsLocked() {
334+ if c.pendingReveal == nil {
335+ c.pendingReveal = make(map[entity.PaneID]bool)
336+ }
337+ if c.revealedWebViews == nil {
338+ c.revealedWebViews = make(map[entity.PaneID]bool)
339+ }
340+ if c.revealedWebViewIdentity == nil {
341+ c.revealedWebViewIdentity = make(map[entity.PaneID]webViewIdentity)
342+ }
343+ if c.pendingRevealIdentity == nil {
344+ c.pendingRevealIdentity = make(map[entity.PaneID]webViewIdentity)
345+ }
346+}
347+
348+// setWebViewLocked replaces the pane mapping and resets all reveal state in
349+// one critical section. It is used by normal acquisition and popup paths.
315 func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {350 func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
316 c.webViewsMu.Lock()351 c.webViewsMu.Lock()
352+ c.revealMu.Lock()
353+ defer c.revealMu.Unlock()
354+ defer c.webViewsMu.Unlock()
317 if c.webViews == nil {355 if c.webViews == nil {
318 c.webViews = make(map[entity.PaneID]port.WebView)356 c.webViews = make(map[entity.PaneID]port.WebView)
319 }357 }
@@ -324,17 +362,30 @@ func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
324 if wv != nil && c.webViewPaneIDs != nil {362 if wv != nil && c.webViewPaneIDs != nil {
325 c.webViewPaneIDs[wv.ID()] = paneID363 c.webViewPaneIDs[wv.ID()] = paneID
326 }364 }
327- c.webViewsMu.Unlock()365+ c.ensureRevealMapsLocked()
366+ delete(c.pendingReveal, paneID)
367+ delete(c.pendingRevealIdentity, paneID)
368+ delete(c.revealedWebViews, paneID)
369+ delete(c.revealedWebViewIdentity, paneID)
328 }370 }
329 371
372+// deleteWebViewLocked removes both the mapping and any presentation state,
373+// including callers which release after another path already removed the map.
330 func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {374 func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {
331 c.webViewsMu.Lock()375 c.webViewsMu.Lock()
376+ c.revealMu.Lock()
377+ defer c.revealMu.Unlock()
332 defer c.webViewsMu.Unlock()378 defer c.webViewsMu.Unlock()
333 wv := c.webViews[paneID]379 wv := c.webViews[paneID]
334 delete(c.webViews, paneID)380 delete(c.webViews, paneID)
335 if wv != nil && c.webViewPaneIDs != nil {381 if wv != nil && c.webViewPaneIDs != nil {
336 delete(c.webViewPaneIDs, wv.ID())382 delete(c.webViewPaneIDs, wv.ID())
337 }383 }
384+ c.ensureRevealMapsLocked()
385+ delete(c.pendingReveal, paneID)
386+ delete(c.pendingRevealIdentity, paneID)
387+ delete(c.revealedWebViews, paneID)
388+ delete(c.revealedWebViewIdentity, paneID)
338 return wv389 return wv
339 }390 }
340 391
M internal/ui/coordinator/content/lifecycle.go +4 -4
@@ -39,9 +39,8 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
3939 }
4040 logging.Trace().Mark("webview_acquired")
4141
42- // A pooled widget may have been previously revealed for another pane. Its
43- // presentation lifecycle starts unrevealed for this pane until a new reveal.
44- c.clearWebViewRevealed(paneID)
42+ // setWebViewLocked atomically resets presentation state for the acquired
43+ // WebView, including a pooled instance previously revealed in another pane.
4544 c.setWebViewLocked(paneID, wv)
4645 c.setupWebViewCallbacks(ctx, paneID, wv)
4746
@@ -56,6 +55,8 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
5655 func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
5756 log := logging.FromContext(ctx)
5857
58+ // deleteWebViewLocked clears reveal state before returning, even when a
59+ // previous cleanup already removed this pane's mapping.
5960 wv := c.deleteWebViewLocked(paneID)
6061 if wv == nil {
6162 return
@@ -63,7 +64,6 @@ func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID)
6364 c.ensurePopupManager().clearReusableNamedPopupByPaneID(paneID)
6465 c.ensurePopupManager().clearReusableNamedPopupByWebViewID(wv.ID())
6566 c.clearPendingAppearance(paneID)
66- c.clearWebViewRevealed(paneID)
6767
6868 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
6969 // we must release the inhibition before destroying the webview.
@@ -39,9 +39,8 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
39 }39 }
40 logging.Trace().Mark("webview_acquired")40 logging.Trace().Mark("webview_acquired")
41 41
42- // A pooled widget may have been previously revealed for another pane. Its42+ // setWebViewLocked atomically resets presentation state for the acquired
43- // presentation lifecycle starts unrevealed for this pane until a new reveal.43+ // WebView, including a pooled instance previously revealed in another pane.
44- c.clearWebViewRevealed(paneID)
45 c.setWebViewLocked(paneID, wv)44 c.setWebViewLocked(paneID, wv)
46 c.setupWebViewCallbacks(ctx, paneID, wv)45 c.setupWebViewCallbacks(ctx, paneID, wv)
47 46
@@ -56,6 +55,8 @@ func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (
56 func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {55 func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
57 log := logging.FromContext(ctx)56 log := logging.FromContext(ctx)
58 57
58+ // deleteWebViewLocked clears reveal state before returning, even when a
59+ // previous cleanup already removed this pane's mapping.
59 wv := c.deleteWebViewLocked(paneID)60 wv := c.deleteWebViewLocked(paneID)
60 if wv == nil {61 if wv == nil {
61 return62 return
@@ -63,7 +64,6 @@ func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID)
63 c.ensurePopupManager().clearReusableNamedPopupByPaneID(paneID)64 c.ensurePopupManager().clearReusableNamedPopupByPaneID(paneID)
64 c.ensurePopupManager().clearReusableNamedPopupByWebViewID(wv.ID())65 c.ensurePopupManager().clearReusableNamedPopupByWebViewID(wv.ID())
65 c.clearPendingAppearance(paneID)66 c.clearPendingAppearance(paneID)
66- c.clearWebViewRevealed(paneID)
67 67
68 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),68 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
69 // we must release the inhibition before destroying the webview.69 // we must release the inhibition before destroying the webview.
M internal/ui/coordinator/content/navigation.go +75 -34
@@ -69,7 +69,7 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID,
6969 // Avoid updating UI/domain state to about:blank when we know the pane is
7070 // navigating to a different URL. This prevents the omnibox/window title from
7171 // briefly showing about:blank on cold start.
72- c.clearPendingReveal(paneID)
72+ c.clearPendingReveal(paneID, wv)
7373 return
7474 }
7575
@@ -77,8 +77,8 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID,
7777 c.applyCurrentTheme(ctx, paneID, wv)
7878 }
7979
80- c.markPendingReveal(paneID)
81- c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
80+ c.markPendingReveal(paneID, wv)
81+ c.revealIfPending(ctx, paneID, wv, uri, "progress-after-commit")
8282
8383 // Update domain model with current URI for session snapshots
8484 c.updatePaneURI(paneID, uri)
@@ -216,7 +216,7 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID,
216216 paneView.SetLoading(false)
217217 }
218218
219- c.revealIfPending(ctx, paneID, "", "load-finished")
219+ c.revealIfPending(ctx, paneID, wv, "", "load-finished")
220220 if c.shouldSkipAboutBlankAppearance(paneID, wv) {
221221 return
222222 }
@@ -225,9 +225,9 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID,
225225 }
226226
227227 // onProgressChanged updates the progress bar with current load progress.
228-func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
228+func (c *Coordinator) onProgressChanged(paneID entity.PaneID, wv port.WebView, progress float64) {
229229 if progress > 0 {
230- c.revealIfPending(context.Background(), paneID, "", "progress")
230+ c.revealIfPending(context.Background(), paneID, wv, "", "progress")
231231 }
232232
233233 _, wsView := c.getActiveWS()
@@ -244,56 +244,98 @@ func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64)
244244 }
245245 }
246246
247-func (c *Coordinator) markPendingReveal(paneID entity.PaneID) {
247+func (c *Coordinator) markPendingReveal(paneID entity.PaneID, wv port.WebView) {
248+ identity, ok := identityForWebView(wv)
249+ if !ok {
250+ return
251+ }
252+ c.webViewsMu.RLock()
248253 c.revealMu.Lock()
254+ defer c.revealMu.Unlock()
255+ defer c.webViewsMu.RUnlock()
256+ currentWebView := c.webViews[paneID]
257+ if currentWebView == nil {
258+ return
259+ }
260+ currentIdentity, ok := identityForWebView(currentWebView)
261+ if !ok || currentIdentity != identity {
262+ return
263+ }
264+ c.ensureRevealMapsLocked()
249265 c.pendingReveal[paneID] = true
250- c.revealMu.Unlock()
266+ c.pendingRevealIdentity[paneID] = identity
251267 }
252268
253-func (c *Coordinator) clearPendingReveal(paneID entity.PaneID) {
269+func (c *Coordinator) clearPendingReveal(paneID entity.PaneID, wv port.WebView) {
270+ identity, ok := identityForWebView(wv)
271+ if !ok {
272+ return
273+ }
274+ c.webViewsMu.RLock()
254275 c.revealMu.Lock()
276+ defer c.revealMu.Unlock()
277+ defer c.webViewsMu.RUnlock()
278+ if c.pendingRevealIdentity[paneID] != identity {
279+ return
280+ }
255281 delete(c.pendingReveal, paneID)
256- c.revealMu.Unlock()
282+ delete(c.pendingRevealIdentity, paneID)
257283 }
258284
259285 // WebViewRevealed reports whether this pane's current WebView has completed a
260-// reveal. This state belongs to content lifecycle rather than GTK widget
261-// ownership, because WorkspaceView rebuilds unparent live native widgets.
286+// reveal. State is identity-bound so a replacement cannot inherit it.
262287 func (c *Coordinator) WebViewRevealed(paneID entity.PaneID) bool {
288+ c.webViewsMu.RLock()
263289 c.revealMu.Lock()
264290 defer c.revealMu.Unlock()
265- return c.revealedWebViews[paneID]
291+ defer c.webViewsMu.RUnlock()
292+ identity, ok := identityForWebView(c.webViews[paneID])
293+ return ok && c.revealedWebViews[paneID] && c.revealedWebViewIdentity[paneID] == identity
266294 }
267295
268-func (c *Coordinator) markWebViewRevealed(paneID entity.PaneID) {
296+func (c *Coordinator) markWebViewRevealed(paneID entity.PaneID, wv port.WebView) bool {
297+ identity, ok := identityForWebView(wv)
298+ if !ok {
299+ return false
300+ }
301+ c.webViewsMu.RLock()
269302 c.revealMu.Lock()
270- if c.revealedWebViews == nil {
271- c.revealedWebViews = make(map[entity.PaneID]bool)
303+ defer c.revealMu.Unlock()
304+ defer c.webViewsMu.RUnlock()
305+ currentIdentity, current := identityForWebView(c.webViews[paneID])
306+ if !current || currentIdentity != identity {
307+ return false
272308 }
309+ c.ensureRevealMapsLocked()
273310 c.revealedWebViews[paneID] = true
274- c.revealMu.Unlock()
275-}
276-
277-func (c *Coordinator) clearWebViewRevealed(paneID entity.PaneID) {
278- c.revealMu.Lock()
279- delete(c.revealedWebViews, paneID)
280- c.revealMu.Unlock()
311+ c.revealedWebViewIdentity[paneID] = identity
312+ return true
281313 }
282314
283-func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
315+func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri, reason string) {
316+ identity, ok := identityForWebView(wv)
317+ if !ok {
318+ return
319+ }
320+ c.webViewsMu.RLock()
284321 c.revealMu.Lock()
285- pending := c.pendingReveal[paneID]
286- if pending {
287- delete(c.pendingReveal, paneID)
322+ if c.pendingRevealIdentity[paneID] != identity || !c.pendingReveal[paneID] {
323+ c.revealMu.Unlock()
324+ c.webViewsMu.RUnlock()
325+ return
288326 }
289- c.revealMu.Unlock()
290-
291- if !pending {
327+ currentIdentity, current := identityForWebView(c.webViews[paneID])
328+ if !current || currentIdentity != identity {
329+ c.revealMu.Unlock()
330+ c.webViewsMu.RUnlock()
292331 return
293332 }
333+ delete(c.pendingReveal, paneID)
334+ delete(c.pendingRevealIdentity, paneID)
335+ c.revealMu.Unlock()
336+ c.webViewsMu.RUnlock()
294337
295- wv := c.getWebViewLocked(paneID)
296- if wv == nil || wv.IsDestroyed() {
338+ if wv.IsDestroyed() {
297339 return
298340 }
299341
@@ -315,8 +357,7 @@ func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID,
315357 // Record presentation state before notifying the UI. The callback may run
316358 // while a WorkspaceView is being rebuilt, and the next PaneView must inherit
317359 // this state even though the native widget was unparented during cleanup.
318- c.markWebViewRevealed(paneID)
319- if c.onWebViewShown != nil {
360+ if c.markWebViewRevealed(paneID, wv) && c.onWebViewShown != nil {
320361 c.onWebViewShown(paneID)
321362 }
322363 }
@@ -69,7 +69,7 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID,
69 // Avoid updating UI/domain state to about:blank when we know the pane is69 // Avoid updating UI/domain state to about:blank when we know the pane is
70 // navigating to a different URL. This prevents the omnibox/window title from70 // navigating to a different URL. This prevents the omnibox/window title from
71 // briefly showing about:blank on cold start.71 // briefly showing about:blank on cold start.
72- c.clearPendingReveal(paneID)72+ c.clearPendingReveal(paneID, wv)
73 return73 return
74 }74 }
75 75
@@ -77,8 +77,8 @@ func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID,
77 c.applyCurrentTheme(ctx, paneID, wv)77 c.applyCurrentTheme(ctx, paneID, wv)
78 }78 }
79 79
80- c.markPendingReveal(paneID)80+ c.markPendingReveal(paneID, wv)
81- c.revealIfPending(ctx, paneID, uri, "progress-after-commit")81+ c.revealIfPending(ctx, paneID, wv, uri, "progress-after-commit")
82 82
83 // Update domain model with current URI for session snapshots83 // Update domain model with current URI for session snapshots
84 c.updatePaneURI(paneID, uri)84 c.updatePaneURI(paneID, uri)
@@ -216,7 +216,7 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID,
216 paneView.SetLoading(false)216 paneView.SetLoading(false)
217 }217 }
218 218
219- c.revealIfPending(ctx, paneID, "", "load-finished")219+ c.revealIfPending(ctx, paneID, wv, "", "load-finished")
220 if c.shouldSkipAboutBlankAppearance(paneID, wv) {220 if c.shouldSkipAboutBlankAppearance(paneID, wv) {
221 return221 return
222 }222 }
@@ -225,9 +225,9 @@ func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID,
225 }225 }
226 226
227 // onProgressChanged updates the progress bar with current load progress.227 // onProgressChanged updates the progress bar with current load progress.
228-func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) {228+func (c *Coordinator) onProgressChanged(paneID entity.PaneID, wv port.WebView, progress float64) {
229 if progress > 0 {229 if progress > 0 {
230- c.revealIfPending(context.Background(), paneID, "", "progress")230+ c.revealIfPending(context.Background(), paneID, wv, "", "progress")
231 }231 }
232 232
233 _, wsView := c.getActiveWS()233 _, wsView := c.getActiveWS()
@@ -244,56 +244,98 @@ func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64)
244 }244 }
245 }245 }
246 246
247-func (c *Coordinator) markPendingReveal(paneID entity.PaneID) {247+func (c *Coordinator) markPendingReveal(paneID entity.PaneID, wv port.WebView) {
248+ identity, ok := identityForWebView(wv)
249+ if !ok {
250+ return
251+ }
252+ c.webViewsMu.RLock()
248 c.revealMu.Lock()253 c.revealMu.Lock()
254+ defer c.revealMu.Unlock()
255+ defer c.webViewsMu.RUnlock()
256+ currentWebView := c.webViews[paneID]
257+ if currentWebView == nil {
258+ return
259+ }
260+ currentIdentity, ok := identityForWebView(currentWebView)
261+ if !ok || currentIdentity != identity {
262+ return
263+ }
264+ c.ensureRevealMapsLocked()
249 c.pendingReveal[paneID] = true265 c.pendingReveal[paneID] = true
250- c.revealMu.Unlock()266+ c.pendingRevealIdentity[paneID] = identity
251 }267 }
252 268
253-func (c *Coordinator) clearPendingReveal(paneID entity.PaneID) {269+func (c *Coordinator) clearPendingReveal(paneID entity.PaneID, wv port.WebView) {
270+ identity, ok := identityForWebView(wv)
271+ if !ok {
272+ return
273+ }
274+ c.webViewsMu.RLock()
254 c.revealMu.Lock()275 c.revealMu.Lock()
276+ defer c.revealMu.Unlock()
277+ defer c.webViewsMu.RUnlock()
278+ if c.pendingRevealIdentity[paneID] != identity {
279+ return
280+ }
255 delete(c.pendingReveal, paneID)281 delete(c.pendingReveal, paneID)
256- c.revealMu.Unlock()282+ delete(c.pendingRevealIdentity, paneID)
257 }283 }
258 284
259 // WebViewRevealed reports whether this pane's current WebView has completed a285 // WebViewRevealed reports whether this pane's current WebView has completed a
260-// reveal. This state belongs to content lifecycle rather than GTK widget286+// reveal. State is identity-bound so a replacement cannot inherit it.
261-// ownership, because WorkspaceView rebuilds unparent live native widgets.
262 func (c *Coordinator) WebViewRevealed(paneID entity.PaneID) bool {287 func (c *Coordinator) WebViewRevealed(paneID entity.PaneID) bool {
288+ c.webViewsMu.RLock()
263 c.revealMu.Lock()289 c.revealMu.Lock()
264 defer c.revealMu.Unlock()290 defer c.revealMu.Unlock()
265- return c.revealedWebViews[paneID]291+ defer c.webViewsMu.RUnlock()
292+ identity, ok := identityForWebView(c.webViews[paneID])
293+ return ok && c.revealedWebViews[paneID] && c.revealedWebViewIdentity[paneID] == identity
266 }294 }
267 295
268-func (c *Coordinator) markWebViewRevealed(paneID entity.PaneID) {296+func (c *Coordinator) markWebViewRevealed(paneID entity.PaneID, wv port.WebView) bool {
297+ identity, ok := identityForWebView(wv)
298+ if !ok {
299+ return false
300+ }
301+ c.webViewsMu.RLock()
269 c.revealMu.Lock()302 c.revealMu.Lock()
270- if c.revealedWebViews == nil {303+ defer c.revealMu.Unlock()
271- c.revealedWebViews = make(map[entity.PaneID]bool)304+ defer c.webViewsMu.RUnlock()
305+ currentIdentity, current := identityForWebView(c.webViews[paneID])
306+ if !current || currentIdentity != identity {
307+ return false
272 }308 }
309+ c.ensureRevealMapsLocked()
273 c.revealedWebViews[paneID] = true310 c.revealedWebViews[paneID] = true
274- c.revealMu.Unlock()311+ c.revealedWebViewIdentity[paneID] = identity
275-}312+ return true
276-
277-func (c *Coordinator) clearWebViewRevealed(paneID entity.PaneID) {
278- c.revealMu.Lock()
279- delete(c.revealedWebViews, paneID)
280- c.revealMu.Unlock()
281 }313 }
282 314
283-func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {315+func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri, reason string) {
316+ identity, ok := identityForWebView(wv)
317+ if !ok {
318+ return
319+ }
320+ c.webViewsMu.RLock()
284 c.revealMu.Lock()321 c.revealMu.Lock()
285- pending := c.pendingReveal[paneID]322+ if c.pendingRevealIdentity[paneID] != identity || !c.pendingReveal[paneID] {
286- if pending {323+ c.revealMu.Unlock()
287- delete(c.pendingReveal, paneID)324+ c.webViewsMu.RUnlock()
325+ return
288 }326 }
289- c.revealMu.Unlock()327+ currentIdentity, current := identityForWebView(c.webViews[paneID])
290-328+ if !current || currentIdentity != identity {
291- if !pending {329+ c.revealMu.Unlock()
330+ c.webViewsMu.RUnlock()
292 return331 return
293 }332 }
333+ delete(c.pendingReveal, paneID)
334+ delete(c.pendingRevealIdentity, paneID)
335+ c.revealMu.Unlock()
336+ c.webViewsMu.RUnlock()
294 337
295- wv := c.getWebViewLocked(paneID)338+ if wv.IsDestroyed() {
296- if wv == nil || wv.IsDestroyed() {
297 return339 return
298 }340 }
299 341
@@ -315,8 +357,7 @@ func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID,
315 // Record presentation state before notifying the UI. The callback may run357 // Record presentation state before notifying the UI. The callback may run
316 // while a WorkspaceView is being rebuilt, and the next PaneView must inherit358 // while a WorkspaceView is being rebuilt, and the next PaneView must inherit
317 // this state even though the native widget was unparented during cleanup.359 // this state even though the native widget was unparented during cleanup.
318- c.markWebViewRevealed(paneID)360+ if c.markWebViewRevealed(paneID, wv) && c.onWebViewShown != nil {
319- if c.onWebViewShown != nil {
320 c.onWebViewShown(paneID)361 c.onWebViewShown(paneID)
321 }362 }
322 }363 }

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