detached f439770

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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) {
16191619 }
16201620 }
16211621
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+
16221660 func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
16231661 log := logging.FromContext(ctx)
16241662
@@ -1644,6 +1682,8 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
16441682 return fmt.Errorf("failed to build tab list from snapshot")
16451683 }
16461684
1685+ a.clearSessionRestoreUIState(ctx)
1686+
16471687 // Replace tabs in-place to preserve references held by TabCoordinator
16481688 a.tabs.ReplaceFrom(restoredTabs)
16491689
@@ -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 TabCoordinator1687 // 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 (
1111 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1212 "github.com/bnema/dumber/internal/application/usecase"
1313 "github.com/bnema/dumber/internal/domain/entity"
14+ "github.com/bnema/dumber/internal/domain/repository"
1415 "github.com/bnema/dumber/internal/infrastructure/config"
1516 "github.com/bnema/dumber/internal/ui/component"
1617 "github.com/bnema/dumber/internal/ui/coordinator"
@@ -48,6 +49,26 @@ func (c *testCloser) Close() error {
4849 return nil
4950 }
5051
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+
5172 // setDependencyField uses reflection to reach unexported dependency fields for tests.
5273 // Keep this test-only so production visibility stays narrow.
5374 func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) {
@@ -247,6 +268,67 @@ func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
247268 }
248269 }
249270
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+
250332 func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) {
251333 oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
252334 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 nil49 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{}}

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