Preserve UI state for stale pane IDs
- commit
- 7bf171bdfc53a1cd3c1c7fbac76da6212eccc298
- author
- brice · · 2026-05-11 17:26 UTC
- parent
- e79a598
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +94 -6
M internal/ui/app.go +8 -0
| @@ -3810,6 +3810,14 @@ func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *en | |||
| 3810 | 3810 | log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab") | |
| 3811 | 3811 | return | |
| 3812 | 3812 | } | |
| 3813 | + | if pane == nil { | |
| 3814 | + | log.Warn().Str("tab_id", string(tabID)).Msg("popup pane is nil") | |
| 3815 | + | return | |
| 3816 | + | } | |
| 3817 | + | if wsView.GetPaneView(pane.ID) == nil { | |
| 3818 | + | log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup") | |
| 3819 | + | return | |
| 3820 | + | } | |
| 3813 | 3821 | ||
| 3814 | 3822 | // Register WebView with content coordinator | |
| 3815 | 3823 | if a.contentCoord != nil { | |
| @@ -3810,6 +3810,14 @@ func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *en | |||||
| 3810 | log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab") | 3810 | log.Warn().Str("tab_id", string(tabID)).Msg("workspace view not found for popup tab") | ||
| 3811 | return | 3811 | return | ||
| 3812 | } | 3812 | } | ||
| 3813 | + | if pane == nil { | |||
| 3814 | + | log.Warn().Str("tab_id", string(tabID)).Msg("popup pane is nil") | |||
| 3815 | + | return | |||
| 3816 | + | } | |||
| 3817 | + | if wsView.GetPaneView(pane.ID) == nil { | |||
| 3818 | + | log.Warn().Str("pane_id", string(pane.ID)).Msg("pane view not found for popup") | |||
| 3819 | + | return | |||
| 3820 | + | } | |||
| 3813 | 3821 | ||||
| 3814 | // Register WebView with content coordinator | 3822 | // Register WebView with content coordinator | ||
| 3815 | if a.contentCoord != nil { | 3823 | if a.contentCoord != nil { | ||
M internal/ui/app_browser_launch_test.go +19 -0
| @@ -1835,6 +1835,25 @@ func (f *fakeRecordingWebView) IsPlayingAudio() bool { retu | |||
| 1835 | 1835 | func (f *fakeRecordingWebView) IsDestroyed() bool { return false } | |
| 1836 | 1836 | func (f *fakeRecordingWebView) Destroy() {} | |
| 1837 | 1837 | ||
| 1838 | + | func TestApp_AttachPopupToTabSkipsRegistrationWhenPaneViewMissing(t *testing.T) { | |
| 1839 | + | ctx := context.Background() | |
| 1840 | + | contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil) | |
| 1841 | + | tabID := entity.TabID("tab-1") | |
| 1842 | + | pane := entity.NewPane(entity.PaneID("missing-pane")) | |
| 1843 | + | app := &App{ | |
| 1844 | + | contentCoord: contentCoord, | |
| 1845 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{ | |
| 1846 | + | tabID: &component.WorkspaceView{}, | |
| 1847 | + | }, | |
| 1848 | + | } | |
| 1849 | + | ||
| 1850 | + | app.attachPopupToTab(ctx, tabID, pane, &fakeRecordingWebView{id: 1}) | |
| 1851 | + | ||
| 1852 | + | if got := contentCoord.GetWebView(pane.ID); got != nil { | |
| 1853 | + | t.Fatalf("popup webview was registered for missing pane view: %v", got) | |
| 1854 | + | } | |
| 1855 | + | } | |
| 1856 | + | ||
| 1838 | 1857 | type fakeZoomRepo struct{} | |
| 1839 | 1858 | ||
| 1840 | 1859 | func (f *fakeZoomRepo) Get(context.Context, string) (*entity.ZoomLevel, error) { return nil, nil } | |
| @@ -1835,6 +1835,25 @@ func (f *fakeRecordingWebView) IsPlayingAudio() bool { retu | |||||
| 1835 | func (f *fakeRecordingWebView) IsDestroyed() bool { return false } | 1835 | func (f *fakeRecordingWebView) IsDestroyed() bool { return false } | ||
| 1836 | func (f *fakeRecordingWebView) Destroy() {} | 1836 | func (f *fakeRecordingWebView) Destroy() {} | ||
| 1837 | 1837 | ||||
| 1838 | + | func TestApp_AttachPopupToTabSkipsRegistrationWhenPaneViewMissing(t *testing.T) { | |||
| 1839 | + | ctx := context.Background() | |||
| 1840 | + | contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil) | |||
| 1841 | + | tabID := entity.TabID("tab-1") | |||
| 1842 | + | pane := entity.NewPane(entity.PaneID("missing-pane")) | |||
| 1843 | + | app := &App{ | |||
| 1844 | + | contentCoord: contentCoord, | |||
| 1845 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{ | |||
| 1846 | + | tabID: &component.WorkspaceView{}, | |||
| 1847 | + | }, | |||
| 1848 | + | } | |||
| 1849 | + | ||||
| 1850 | + | app.attachPopupToTab(ctx, tabID, pane, &fakeRecordingWebView{id: 1}) | |||
| 1851 | + | ||||
| 1852 | + | if got := contentCoord.GetWebView(pane.ID); got != nil { | |||
| 1853 | + | t.Fatalf("popup webview was registered for missing pane view: %v", got) | |||
| 1854 | + | } | |||
| 1855 | + | } | |||
| 1856 | + | ||||
| 1838 | type fakeZoomRepo struct{} | 1857 | type fakeZoomRepo struct{} | ||
| 1839 | 1858 | ||||
| 1840 | func (f *fakeZoomRepo) Get(context.Context, string) (*entity.ZoomLevel, error) { return nil, nil } | 1859 | func (f *fakeZoomRepo) Get(context.Context, string) (*entity.ZoomLevel, error) { return nil, nil } | ||
M internal/ui/component/workspace_view.go +7 -6
| @@ -256,6 +256,13 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error { | |||
| 256 | 256 | Str("to_pane", string(paneID)). | |
| 257 | 257 | Msg("active pane changing") | |
| 258 | 258 | ||
| 259 | + | // Validate the target pane before mutating UI state. Invalid/stale IDs | |
| 260 | + | // should leave the current active pane styling and overlays untouched. | |
| 261 | + | newPV, ok := wv.paneViews[paneID] | |
| 262 | + | if !ok { | |
| 263 | + | return ErrPaneNotFound | |
| 264 | + | } | |
| 265 | + | ||
| 259 | 266 | // Destroy find bar if active pane is changing | |
| 260 | 267 | if currentActiveID != paneID && wv.findBar != nil { | |
| 261 | 268 | wv.hideFindBarInternal() | |
| @@ -273,12 +280,6 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error { | |||
| 273 | 280 | } | |
| 274 | 281 | } | |
| 275 | 282 | ||
| 276 | - | // Activate new pane | |
| 277 | - | newPV, ok := wv.paneViews[paneID] | |
| 278 | - | if !ok { | |
| 279 | - | return ErrPaneNotFound | |
| 280 | - | } | |
| 281 | - | ||
| 282 | 283 | newPV.SetActive(true) | |
| 283 | 284 | ||
| 284 | 285 | // Update domain model - single source of truth | |
| @@ -256,6 +256,13 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error { | |||||
| 256 | Str("to_pane", string(paneID)). | 256 | Str("to_pane", string(paneID)). | ||
| 257 | Msg("active pane changing") | 257 | Msg("active pane changing") | ||
| 258 | 258 | ||||
| 259 | + | // Validate the target pane before mutating UI state. Invalid/stale IDs | |||
| 260 | + | // should leave the current active pane styling and overlays untouched. | |||
| 261 | + | newPV, ok := wv.paneViews[paneID] | |||
| 262 | + | if !ok { | |||
| 263 | + | return ErrPaneNotFound | |||
| 264 | + | } | |||
| 265 | + | ||||
| 259 | // Destroy find bar if active pane is changing | 266 | // Destroy find bar if active pane is changing | ||
| 260 | if currentActiveID != paneID && wv.findBar != nil { | 267 | if currentActiveID != paneID && wv.findBar != nil { | ||
| 261 | wv.hideFindBarInternal() | 268 | wv.hideFindBarInternal() | ||
| @@ -273,12 +280,6 @@ func (wv *WorkspaceView) setActivePaneIDInternal(paneID entity.PaneID) error { | |||||
| 273 | } | 280 | } | ||
| 274 | } | 281 | } | ||
| 275 | 282 | ||||
| 276 | - | // Activate new pane | |||
| 277 | - | newPV, ok := wv.paneViews[paneID] | |||
| 278 | - | if !ok { | |||
| 279 | - | return ErrPaneNotFound | |||
| 280 | - | } | |||
| 281 | - | ||||
| 282 | newPV.SetActive(true) | 283 | newPV.SetActive(true) | ||
| 283 | 284 | ||||
| 284 | // Update domain model - single source of truth | 285 | // Update domain model - single source of truth | ||
M internal/ui/component/workspace_view_test.go +60 -0
| @@ -443,6 +443,66 @@ func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) { | |||
| 443 | 443 | assert.ErrorIs(t, err, component.ErrPaneNotFound) | |
| 444 | 444 | } | |
| 445 | 445 | ||
| 446 | + | func TestSetActivePaneID_InvalidID_PreservesCurrentActivePane(t *testing.T) { | |
| 447 | + | // Arrange | |
| 448 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |
| 449 | + | ctx, mockBox := setupWorkspaceViewBase(t, mockFactory) | |
| 450 | + | ||
| 451 | + | wv := component.NewWorkspaceView(ctx, mockFactory) | |
| 452 | + | ||
| 453 | + | pane1 := entity.NewPane(entity.PaneID("pane-1")) | |
| 454 | + | pane2 := entity.NewPane(entity.PaneID("pane-2")) | |
| 455 | + | node1 := &entity.PaneNode{ID: "node-1", Pane: pane1} | |
| 456 | + | node2 := &entity.PaneNode{ID: "node-2", Pane: pane2} | |
| 457 | + | ||
| 458 | + | mockPaned := mocks.NewMockPanedWidget(t) | |
| 459 | + | mockOverlay1, mockBorderBox1 := setupWorkspacePaneViewMocks(t, mockFactory) | |
| 460 | + | mockStackBox1 := setupStackedLeafMocks(t, mockFactory, mockOverlay1) | |
| 461 | + | mockOverlay2, _ := setupWorkspacePaneViewMocks(t, mockFactory) | |
| 462 | + | mockStackBox2 := setupStackedLeafMocks(t, mockFactory, mockOverlay2) | |
| 463 | + | ||
| 464 | + | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once() | |
| 465 | + | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |
| 466 | + | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |
| 467 | + | mockPaned.EXPECT().SetVisible(true).Twice() | |
| 468 | + | mockStackBox1.EXPECT().SetVisible(true).Once() | |
| 469 | + | mockStackBox2.EXPECT().SetVisible(true).Once() | |
| 470 | + | mockPaned.EXPECT().SetStartChild(mockStackBox1).Once() | |
| 471 | + | mockPaned.EXPECT().SetEndChild(mockStackBox2).Once() | |
| 472 | + | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once() | |
| 473 | + | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |
| 474 | + | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once() | |
| 475 | + | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |
| 476 | + | ||
| 477 | + | mockBox.EXPECT().Append(mockPaned).Once() | |
| 478 | + | mockBox.EXPECT().RemoveCssClass("single-pane").Once() | |
| 479 | + | mockBorderBox1.EXPECT().AddCssClass("pane-active").Once() | |
| 480 | + | ||
| 481 | + | splitNode := &entity.PaneNode{ | |
| 482 | + | ID: "split", | |
| 483 | + | SplitDir: entity.SplitHorizontal, | |
| 484 | + | SplitRatio: 0.5, | |
| 485 | + | Children: []*entity.PaneNode{node1, node2}, | |
| 486 | + | } | |
| 487 | + | ws := &entity.Workspace{ | |
| 488 | + | ID: "ws-1", | |
| 489 | + | Root: splitNode, | |
| 490 | + | ActivePaneID: pane1.ID, | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | err := wv.SetWorkspace(ctx, ws) | |
| 494 | + | require.NoError(t, err) | |
| 495 | + | require.True(t, wv.GetPaneView(pane1.ID).IsActive()) | |
| 496 | + | ||
| 497 | + | // Act | |
| 498 | + | err = wv.SetActivePaneID(entity.PaneID("non-existent")) | |
| 499 | + | ||
| 500 | + | // Assert | |
| 501 | + | require.ErrorIs(t, err, component.ErrPaneNotFound) | |
| 502 | + | assert.Equal(t, pane1.ID, wv.GetActivePaneID()) | |
| 503 | + | assert.True(t, wv.GetPaneView(pane1.ID).IsActive()) | |
| 504 | + | } | |
| 505 | + | ||
| 446 | 506 | func TestSetWebViewWidget_InvokesAttachedCallback(t *testing.T) { | |
| 447 | 507 | mockFactory := mocks.NewMockWidgetFactory(t) | |
| 448 | 508 | ctx, mockBox := setupWorkspaceViewBase(t, mockFactory) | |
| @@ -443,6 +443,66 @@ func TestSetActivePaneID_InvalidID_ReturnsError(t *testing.T) { | |||||
| 443 | assert.ErrorIs(t, err, component.ErrPaneNotFound) | 443 | assert.ErrorIs(t, err, component.ErrPaneNotFound) | ||
| 444 | } | 444 | } | ||
| 445 | 445 | ||||
| 446 | + | func TestSetActivePaneID_InvalidID_PreservesCurrentActivePane(t *testing.T) { | |||
| 447 | + | // Arrange | |||
| 448 | + | mockFactory := mocks.NewMockWidgetFactory(t) | |||
| 449 | + | ctx, mockBox := setupWorkspaceViewBase(t, mockFactory) | |||
| 450 | + | ||||
| 451 | + | wv := component.NewWorkspaceView(ctx, mockFactory) | |||
| 452 | + | ||||
| 453 | + | pane1 := entity.NewPane(entity.PaneID("pane-1")) | |||
| 454 | + | pane2 := entity.NewPane(entity.PaneID("pane-2")) | |||
| 455 | + | node1 := &entity.PaneNode{ID: "node-1", Pane: pane1} | |||
| 456 | + | node2 := &entity.PaneNode{ID: "node-2", Pane: pane2} | |||
| 457 | + | ||||
| 458 | + | mockPaned := mocks.NewMockPanedWidget(t) | |||
| 459 | + | mockOverlay1, mockBorderBox1 := setupWorkspacePaneViewMocks(t, mockFactory) | |||
| 460 | + | mockStackBox1 := setupStackedLeafMocks(t, mockFactory, mockOverlay1) | |||
| 461 | + | mockOverlay2, _ := setupWorkspacePaneViewMocks(t, mockFactory) | |||
| 462 | + | mockStackBox2 := setupStackedLeafMocks(t, mockFactory, mockOverlay2) | |||
| 463 | + | ||||
| 464 | + | mockFactory.EXPECT().NewPaned(layout.OrientationHorizontal).Return(mockPaned).Once() | |||
| 465 | + | mockPaned.EXPECT().SetResizeStartChild(true).Once() | |||
| 466 | + | mockPaned.EXPECT().SetResizeEndChild(true).Once() | |||
| 467 | + | mockPaned.EXPECT().SetVisible(true).Twice() | |||
| 468 | + | mockStackBox1.EXPECT().SetVisible(true).Once() | |||
| 469 | + | mockStackBox2.EXPECT().SetVisible(true).Once() | |||
| 470 | + | mockPaned.EXPECT().SetStartChild(mockStackBox1).Once() | |||
| 471 | + | mockPaned.EXPECT().SetEndChild(mockStackBox2).Once() | |||
| 472 | + | mockPaned.EXPECT().ConnectNotifyPosition(mock.Anything).Return(uint(0)).Once() | |||
| 473 | + | mockPaned.EXPECT().GetAllocatedWidth().Return(0).Once() | |||
| 474 | + | mockPaned.EXPECT().ConnectMap(mock.Anything).Return(uint(0)).Once() | |||
| 475 | + | mockPaned.EXPECT().AddTickCallback(mock.Anything).Return(uint(0)).Once() | |||
| 476 | + | ||||
| 477 | + | mockBox.EXPECT().Append(mockPaned).Once() | |||
| 478 | + | mockBox.EXPECT().RemoveCssClass("single-pane").Once() | |||
| 479 | + | mockBorderBox1.EXPECT().AddCssClass("pane-active").Once() | |||
| 480 | + | ||||
| 481 | + | splitNode := &entity.PaneNode{ | |||
| 482 | + | ID: "split", | |||
| 483 | + | SplitDir: entity.SplitHorizontal, | |||
| 484 | + | SplitRatio: 0.5, | |||
| 485 | + | Children: []*entity.PaneNode{node1, node2}, | |||
| 486 | + | } | |||
| 487 | + | ws := &entity.Workspace{ | |||
| 488 | + | ID: "ws-1", | |||
| 489 | + | Root: splitNode, | |||
| 490 | + | ActivePaneID: pane1.ID, | |||
| 491 | + | } | |||
| 492 | + | ||||
| 493 | + | err := wv.SetWorkspace(ctx, ws) | |||
| 494 | + | require.NoError(t, err) | |||
| 495 | + | require.True(t, wv.GetPaneView(pane1.ID).IsActive()) | |||
| 496 | + | ||||
| 497 | + | // Act | |||
| 498 | + | err = wv.SetActivePaneID(entity.PaneID("non-existent")) | |||
| 499 | + | ||||
| 500 | + | // Assert | |||
| 501 | + | require.ErrorIs(t, err, component.ErrPaneNotFound) | |||
| 502 | + | assert.Equal(t, pane1.ID, wv.GetActivePaneID()) | |||
| 503 | + | assert.True(t, wv.GetPaneView(pane1.ID).IsActive()) | |||
| 504 | + | } | |||
| 505 | + | ||||
| 446 | func TestSetWebViewWidget_InvokesAttachedCallback(t *testing.T) { | 506 | func TestSetWebViewWidget_InvokesAttachedCallback(t *testing.T) { | ||
| 447 | mockFactory := mocks.NewMockWidgetFactory(t) | 507 | mockFactory := mocks.NewMockWidgetFactory(t) | ||
| 448 | ctx, mockBox := setupWorkspaceViewBase(t, mockFactory) | 508 | ctx, mockBox := setupWorkspaceViewBase(t, mockFactory) | ||