fix: clear stale UI before session restore
Make session restore rebuild tab UI from a clean state so bootstrap leftovers cannot duplicate restored content into an extra tab.
- commit
- f43977077e66c27888bf7ad9010a5717d3f92e0e
- author
- brice · · 2026-04-15 08:41 UTC
- parent
- 39880c9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +122 -0
M internal/ui/app.go +40 -0
| @@ -1619,6 +1619,44 @@ func (a *App) createInitialTab(ctx context.Context) { | |||
| 1619 | 1619 | } | |
| 1620 | 1620 | } | |
| 1621 | 1621 | ||
| 1622 | + | func (a *App) clearSessionRestoreUIState(ctx context.Context) { | |
| 1623 | + | if a == nil { | |
| 1624 | + | return | |
| 1625 | + | } | |
| 1626 | + | ||
| 1627 | + | tabIDs := make(map[entity.TabID]struct{}) | |
| 1628 | + | if a.tabs != nil { | |
| 1629 | + | for _, tab := range a.tabs.Tabs { | |
| 1630 | + | if tab == nil { | |
| 1631 | + | continue | |
| 1632 | + | } | |
| 1633 | + | tabIDs[tab.ID] = struct{}{} | |
| 1634 | + | } | |
| 1635 | + | } | |
| 1636 | + | for tabID := range a.workspaceViews { | |
| 1637 | + | tabIDs[tabID] = struct{}{} | |
| 1638 | + | } | |
| 1639 | + | for tabID := range a.windowForTab { | |
| 1640 | + | tabIDs[tabID] = struct{}{} | |
| 1641 | + | } | |
| 1642 | + | ||
| 1643 | + | var tabBar *component.TabBar | |
| 1644 | + | if a.mainWindow != nil { | |
| 1645 | + | tabBar = a.mainWindow.TabBar() | |
| 1646 | + | } | |
| 1647 | + | for tabID := range tabIDs { | |
| 1648 | + | if tabBar != nil { | |
| 1649 | + | tabBar.RemoveTab(tabID) | |
| 1650 | + | } | |
| 1651 | + | a.releaseFloatingSessionsForTab(ctx, tabID) | |
| 1652 | + | delete(a.workspaceViews, tabID) | |
| 1653 | + | delete(a.windowForTab, tabID) | |
| 1654 | + | } | |
| 1655 | + | if a.mainWindow != nil { | |
| 1656 | + | a.mainWindow.SetContent(nil) | |
| 1657 | + | } | |
| 1658 | + | } | |
| 1659 | + | ||
| 1622 | 1660 | func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error { | |
| 1623 | 1661 | log := logging.FromContext(ctx) | |
| 1624 | 1662 | ||
| @@ -1644,6 +1682,8 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er | |||
| 1644 | 1682 | return fmt.Errorf("failed to build tab list from snapshot") | |
| 1645 | 1683 | } | |
| 1646 | 1684 | ||
| 1685 | + | a.clearSessionRestoreUIState(ctx) | |
| 1686 | + | ||
| 1647 | 1687 | // Replace tabs in-place to preserve references held by TabCoordinator | |
| 1648 | 1688 | a.tabs.ReplaceFrom(restoredTabs) | |
| 1649 | 1689 | ||
| @@ -1619,6 +1619,44 @@ func (a *App) createInitialTab(ctx context.Context) { | |||||
| 1619 | } | 1619 | } | ||
| 1620 | } | 1620 | } | ||
| 1621 | 1621 | ||||
| 1622 | + | func (a *App) clearSessionRestoreUIState(ctx context.Context) { | |||
| 1623 | + | if a == nil { | |||
| 1624 | + | return | |||
| 1625 | + | } | |||
| 1626 | + | ||||
| 1627 | + | tabIDs := make(map[entity.TabID]struct{}) | |||
| 1628 | + | if a.tabs != nil { | |||
| 1629 | + | for _, tab := range a.tabs.Tabs { | |||
| 1630 | + | if tab == nil { | |||
| 1631 | + | continue | |||
| 1632 | + | } | |||
| 1633 | + | tabIDs[tab.ID] = struct{}{} | |||
| 1634 | + | } | |||
| 1635 | + | } | |||
| 1636 | + | for tabID := range a.workspaceViews { | |||
| 1637 | + | tabIDs[tabID] = struct{}{} | |||
| 1638 | + | } | |||
| 1639 | + | for tabID := range a.windowForTab { | |||
| 1640 | + | tabIDs[tabID] = struct{}{} | |||
| 1641 | + | } | |||
| 1642 | + | ||||
| 1643 | + | var tabBar *component.TabBar | |||
| 1644 | + | if a.mainWindow != nil { | |||
| 1645 | + | tabBar = a.mainWindow.TabBar() | |||
| 1646 | + | } | |||
| 1647 | + | for tabID := range tabIDs { | |||
| 1648 | + | if tabBar != nil { | |||
| 1649 | + | tabBar.RemoveTab(tabID) | |||
| 1650 | + | } | |||
| 1651 | + | a.releaseFloatingSessionsForTab(ctx, tabID) | |||
| 1652 | + | delete(a.workspaceViews, tabID) | |||
| 1653 | + | delete(a.windowForTab, tabID) | |||
| 1654 | + | } | |||
| 1655 | + | if a.mainWindow != nil { | |||
| 1656 | + | a.mainWindow.SetContent(nil) | |||
| 1657 | + | } | |||
| 1658 | + | } | |||
| 1659 | + | ||||
| 1622 | func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error { | 1660 | func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error { | ||
| 1623 | log := logging.FromContext(ctx) | 1661 | log := logging.FromContext(ctx) | ||
| 1624 | 1662 | ||||
| @@ -1644,6 +1682,8 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er | |||||
| 1644 | return fmt.Errorf("failed to build tab list from snapshot") | 1682 | return fmt.Errorf("failed to build tab list from snapshot") | ||
| 1645 | } | 1683 | } | ||
| 1646 | 1684 | ||||
| 1685 | + | a.clearSessionRestoreUIState(ctx) | |||
| 1686 | + | ||||
| 1647 | // Replace tabs in-place to preserve references held by TabCoordinator | 1687 | // Replace tabs in-place to preserve references held by TabCoordinator | ||
| 1648 | a.tabs.ReplaceFrom(restoredTabs) | 1688 | a.tabs.ReplaceFrom(restoredTabs) | ||
| 1649 | 1689 | ||||
M internal/ui/app_browser_launch_test.go +82 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 12 | 12 | "github.com/bnema/dumber/internal/application/usecase" | |
| 13 | 13 | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | + | "github.com/bnema/dumber/internal/domain/repository" | |
| 14 | 15 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 15 | 16 | "github.com/bnema/dumber/internal/ui/component" | |
| 16 | 17 | "github.com/bnema/dumber/internal/ui/coordinator" | |
| @@ -48,6 +49,26 @@ func (c *testCloser) Close() error { | |||
| 48 | 49 | return nil | |
| 49 | 50 | } | |
| 50 | 51 | ||
| 52 | + | type fakeSessionStateRepo struct { | |
| 53 | + | state *entity.SessionState | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | var _ repository.SessionStateRepository = (*fakeSessionStateRepo)(nil) | |
| 57 | + | ||
| 58 | + | func (r *fakeSessionStateRepo) SaveSnapshot(context.Context, *entity.SessionState) error { return nil } | |
| 59 | + | ||
| 60 | + | func (r *fakeSessionStateRepo) GetSnapshot(context.Context, entity.SessionID) (*entity.SessionState, error) { | |
| 61 | + | return r.state, nil | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func (r *fakeSessionStateRepo) DeleteSnapshot(context.Context, entity.SessionID) error { return nil } | |
| 65 | + | ||
| 66 | + | func (r *fakeSessionStateRepo) GetAllSnapshots(context.Context) ([]*entity.SessionState, error) { | |
| 67 | + | return nil, nil | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func (r *fakeSessionStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) { return 0, nil } | |
| 71 | + | ||
| 51 | 72 | // setDependencyField uses reflection to reach unexported dependency fields for tests. | |
| 52 | 73 | // Keep this test-only so production visibility stays narrow. | |
| 53 | 74 | func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) { | |
| @@ -247,6 +268,67 @@ func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) { | |||
| 247 | 268 | } | |
| 248 | 269 | } | |
| 249 | 270 | ||
| 271 | + | func TestApp_RestoreSessionClearsStaleUIStateBeforeApplyingRestoredTabs(t *testing.T) { | |
| 272 | + | EnsureAdwaitaInitialized() | |
| 273 | + | appID := AppID | |
| 274 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |
| 275 | + | if gtkApp == nil { | |
| 276 | + | t.Fatal("gtk application creation failed") | |
| 277 | + | } | |
| 278 | + | defer gtkApp.Unref() | |
| 279 | + | ||
| 280 | + | mainWindow, err := window.New(context.Background(), gtkApp, "top") | |
| 281 | + | if err != nil { | |
| 282 | + | t.Fatalf("window creation failed: %v", err) | |
| 283 | + | } | |
| 284 | + | defer mainWindow.Destroy() | |
| 285 | + | ||
| 286 | + | staleTab := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-workspace"), entity.NewPane(entity.PaneID("stale-pane"))) | |
| 287 | + | staleWindow := &browserWindow{id: "window-stale", mainWindow: mainWindow} | |
| 288 | + | staleSessionKey := floatingSessionKey{tabID: staleTab.ID, sessionID: "profile-stale"} | |
| 289 | + | restoredSessionID := entity.SessionID("session-restore") | |
| 290 | + | restoredTabs := entity.NewTabList() | |
| 291 | + | restoredTabs.Add(entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-workspace"), entity.NewPane(entity.PaneID("restored-pane")))) | |
| 292 | + | ||
| 293 | + | app := &App{ | |
| 294 | + | deps: &Dependencies{ | |
| 295 | + | Config: &config.Config{}, | |
| 296 | + | SessionStateRepo: &fakeSessionStateRepo{state: entity.SnapshotFromTabList(restoredSessionID, restoredTabs)}, | |
| 297 | + | }, | |
| 298 | + | mainWindow: mainWindow, | |
| 299 | + | widgetFactory: layout.NewGtkWidgetFactory(), | |
| 300 | + | tabs: entity.NewTabList(), | |
| 301 | + | browserWindows: map[string]*browserWindow{staleWindow.id: staleWindow}, | |
| 302 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{staleTab.ID: &component.WorkspaceView{}}, | |
| 303 | + | windowForTab: map[entity.TabID]*browserWindow{staleTab.ID: staleWindow}, | |
| 304 | + | floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{ | |
| 305 | + | staleSessionKey: {}, | |
| 306 | + | }, | |
| 307 | + | } | |
| 308 | + | app.tabs.Add(staleTab) | |
| 309 | + | mainWindow.TabBar().AddTab(staleTab) | |
| 310 | + | ||
| 311 | + | if err := app.restoreSession(context.Background(), restoredSessionID); err != nil { | |
| 312 | + | t.Fatalf("restoreSession returned error: %v", err) | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | if got := app.tabs.Count(); got != 1 { | |
| 316 | + | t.Fatalf("tabs.Count() = %d, want 1", got) | |
| 317 | + | } | |
| 318 | + | if got := mainWindow.TabBar().Count(); got != 1 { | |
| 319 | + | t.Fatalf("tab bar count = %d, want 1", got) | |
| 320 | + | } | |
| 321 | + | if _, ok := app.workspaceViews[staleTab.ID]; ok { | |
| 322 | + | t.Fatalf("stale workspace view was not removed") | |
| 323 | + | } | |
| 324 | + | if _, ok := app.windowForTab[staleTab.ID]; ok { | |
| 325 | + | t.Fatalf("stale windowForTab entry was not removed") | |
| 326 | + | } | |
| 327 | + | if _, ok := app.floatingSessions[staleSessionKey]; ok { | |
| 328 | + | t.Fatalf("stale floating session was not removed") | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 250 | 332 | func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) { | |
| 251 | 333 | oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | |
| 252 | 334 | newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | |
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | 11 | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | ||
| 12 | "github.com/bnema/dumber/internal/application/usecase" | 12 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 13 | "github.com/bnema/dumber/internal/domain/entity" | 13 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 14 | + | "github.com/bnema/dumber/internal/domain/repository" | |||
| 14 | "github.com/bnema/dumber/internal/infrastructure/config" | 15 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 15 | "github.com/bnema/dumber/internal/ui/component" | 16 | "github.com/bnema/dumber/internal/ui/component" | ||
| 16 | "github.com/bnema/dumber/internal/ui/coordinator" | 17 | "github.com/bnema/dumber/internal/ui/coordinator" | ||
| @@ -48,6 +49,26 @@ func (c *testCloser) Close() error { | |||||
| 48 | return nil | 49 | return nil | ||
| 49 | } | 50 | } | ||
| 50 | 51 | ||||
| 52 | + | type fakeSessionStateRepo struct { | |||
| 53 | + | state *entity.SessionState | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | var _ repository.SessionStateRepository = (*fakeSessionStateRepo)(nil) | |||
| 57 | + | ||||
| 58 | + | func (r *fakeSessionStateRepo) SaveSnapshot(context.Context, *entity.SessionState) error { return nil } | |||
| 59 | + | ||||
| 60 | + | func (r *fakeSessionStateRepo) GetSnapshot(context.Context, entity.SessionID) (*entity.SessionState, error) { | |||
| 61 | + | return r.state, nil | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func (r *fakeSessionStateRepo) DeleteSnapshot(context.Context, entity.SessionID) error { return nil } | |||
| 65 | + | ||||
| 66 | + | func (r *fakeSessionStateRepo) GetAllSnapshots(context.Context) ([]*entity.SessionState, error) { | |||
| 67 | + | return nil, nil | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func (r *fakeSessionStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) { return 0, nil } | |||
| 71 | + | ||||
| 51 | // setDependencyField uses reflection to reach unexported dependency fields for tests. | 72 | // setDependencyField uses reflection to reach unexported dependency fields for tests. | ||
| 52 | // Keep this test-only so production visibility stays narrow. | 73 | // Keep this test-only so production visibility stays narrow. | ||
| 53 | func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) { | 74 | func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) { | ||
| @@ -247,6 +268,67 @@ func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) { | |||||
| 247 | } | 268 | } | ||
| 248 | } | 269 | } | ||
| 249 | 270 | ||||
| 271 | + | func TestApp_RestoreSessionClearsStaleUIStateBeforeApplyingRestoredTabs(t *testing.T) { | |||
| 272 | + | EnsureAdwaitaInitialized() | |||
| 273 | + | appID := AppID | |||
| 274 | + | gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue) | |||
| 275 | + | if gtkApp == nil { | |||
| 276 | + | t.Fatal("gtk application creation failed") | |||
| 277 | + | } | |||
| 278 | + | defer gtkApp.Unref() | |||
| 279 | + | ||||
| 280 | + | mainWindow, err := window.New(context.Background(), gtkApp, "top") | |||
| 281 | + | if err != nil { | |||
| 282 | + | t.Fatalf("window creation failed: %v", err) | |||
| 283 | + | } | |||
| 284 | + | defer mainWindow.Destroy() | |||
| 285 | + | ||||
| 286 | + | staleTab := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-workspace"), entity.NewPane(entity.PaneID("stale-pane"))) | |||
| 287 | + | staleWindow := &browserWindow{id: "window-stale", mainWindow: mainWindow} | |||
| 288 | + | staleSessionKey := floatingSessionKey{tabID: staleTab.ID, sessionID: "profile-stale"} | |||
| 289 | + | restoredSessionID := entity.SessionID("session-restore") | |||
| 290 | + | restoredTabs := entity.NewTabList() | |||
| 291 | + | restoredTabs.Add(entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-workspace"), entity.NewPane(entity.PaneID("restored-pane")))) | |||
| 292 | + | ||||
| 293 | + | app := &App{ | |||
| 294 | + | deps: &Dependencies{ | |||
| 295 | + | Config: &config.Config{}, | |||
| 296 | + | SessionStateRepo: &fakeSessionStateRepo{state: entity.SnapshotFromTabList(restoredSessionID, restoredTabs)}, | |||
| 297 | + | }, | |||
| 298 | + | mainWindow: mainWindow, | |||
| 299 | + | widgetFactory: layout.NewGtkWidgetFactory(), | |||
| 300 | + | tabs: entity.NewTabList(), | |||
| 301 | + | browserWindows: map[string]*browserWindow{staleWindow.id: staleWindow}, | |||
| 302 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{staleTab.ID: &component.WorkspaceView{}}, | |||
| 303 | + | windowForTab: map[entity.TabID]*browserWindow{staleTab.ID: staleWindow}, | |||
| 304 | + | floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{ | |||
| 305 | + | staleSessionKey: {}, | |||
| 306 | + | }, | |||
| 307 | + | } | |||
| 308 | + | app.tabs.Add(staleTab) | |||
| 309 | + | mainWindow.TabBar().AddTab(staleTab) | |||
| 310 | + | ||||
| 311 | + | if err := app.restoreSession(context.Background(), restoredSessionID); err != nil { | |||
| 312 | + | t.Fatalf("restoreSession returned error: %v", err) | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | if got := app.tabs.Count(); got != 1 { | |||
| 316 | + | t.Fatalf("tabs.Count() = %d, want 1", got) | |||
| 317 | + | } | |||
| 318 | + | if got := mainWindow.TabBar().Count(); got != 1 { | |||
| 319 | + | t.Fatalf("tab bar count = %d, want 1", got) | |||
| 320 | + | } | |||
| 321 | + | if _, ok := app.workspaceViews[staleTab.ID]; ok { | |||
| 322 | + | t.Fatalf("stale workspace view was not removed") | |||
| 323 | + | } | |||
| 324 | + | if _, ok := app.windowForTab[staleTab.ID]; ok { | |||
| 325 | + | t.Fatalf("stale windowForTab entry was not removed") | |||
| 326 | + | } | |||
| 327 | + | if _, ok := app.floatingSessions[staleSessionKey]; ok { | |||
| 328 | + | t.Fatalf("stale floating session was not removed") | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | ||||
| 250 | func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) { | 332 | func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) { | ||
| 251 | oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | 333 | oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | ||
| 252 | newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | 334 | newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | ||