detached 85924a1

fix(ui): fix session restoration reference issues and pane URI tracking

commit
85924a1bf338105d03e04270ba1c94523dace88e
author
brice · · 2025-12-25 07:20 UTC
parent
fe2d897
signature
unsigned
4 files · +512 -16
A internal/domain/entity/session_state_test.go +430 -0
@@ -0,0 +1,430 @@
1+package entity_test
2+
3+import (
4+ "sync/atomic"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// mockIDGenerator creates a simple ID generator for testing.
14+func mockIDGenerator() entity.IDGenerator {
15+ var counter uint64
16+ return func() string {
17+ id := atomic.AddUint64(&counter, 1)
18+ return string(rune('a'-1+id)) + "_new"
19+ }
20+}
21+
22+func TestTabListFromSnapshot_EmptyState(t *testing.T) {
23+ idGen := mockIDGenerator()
24+
25+ // Nil state
26+ tabs := entity.TabListFromSnapshot(nil, idGen)
27+ require.NotNil(t, tabs)
28+ assert.Empty(t, tabs.Tabs)
29+
30+ // Empty state
31+ state := &entity.SessionState{
32+ Version: 1,
33+ Tabs: []entity.TabSnapshot{},
34+ }
35+ tabs = entity.TabListFromSnapshot(state, idGen)
36+ require.NotNil(t, tabs)
37+ assert.Empty(t, tabs.Tabs)
38+}
39+
40+func TestTabListFromSnapshot_SingleTab(t *testing.T) {
41+ idGen := mockIDGenerator()
42+
43+ state := &entity.SessionState{
44+ Version: 1,
45+ SessionID: "test_session",
46+ ActiveTabIndex: 0,
47+ Tabs: []entity.TabSnapshot{
48+ {
49+ ID: "old_tab_1",
50+ Name: "Test Tab",
51+ Position: 0,
52+ IsPinned: true,
53+ Workspace: entity.WorkspaceSnapshot{
54+ ID: "old_ws_1",
55+ ActivePaneID: "old_pane_1",
56+ Root: &entity.PaneNodeSnapshot{
57+ ID: "old_node_1",
58+ Pane: &entity.PaneSnapshot{
59+ ID: "old_pane_1",
60+ URI: "https://example.com",
61+ Title: "Example",
62+ ZoomFactor: 1.25,
63+ },
64+ },
65+ },
66+ },
67+ },
68+ SavedAt: time.Now(),
69+ }
70+
71+ tabs := entity.TabListFromSnapshot(state, idGen)
72+
73+ require.NotNil(t, tabs)
74+ require.Len(t, tabs.Tabs, 1)
75+
76+ tab := tabs.Tabs[0]
77+ assert.NotEqual(t, entity.TabID("old_tab_1"), tab.ID, "should generate new ID")
78+ assert.Equal(t, "Test Tab", tab.Name)
79+ assert.True(t, tab.IsPinned)
80+
81+ require.NotNil(t, tab.Workspace)
82+ require.NotNil(t, tab.Workspace.Root)
83+ require.NotNil(t, tab.Workspace.Root.Pane)
84+
85+ pane := tab.Workspace.Root.Pane
86+ assert.NotEqual(t, entity.PaneID("old_pane_1"), pane.ID, "should generate new pane ID")
87+ assert.Equal(t, "https://example.com", pane.URI)
88+ assert.Equal(t, "Example", pane.Title)
89+ assert.InDelta(t, 1.25, pane.ZoomFactor, 0.001)
90+
91+ // Active tab should be set
92+ assert.Equal(t, tab.ID, tabs.ActiveTabID)
93+}
94+
95+func TestTabListFromSnapshot_MultipleTabs(t *testing.T) {
96+ idGen := mockIDGenerator()
97+
98+ state := &entity.SessionState{
99+ Version: 1,
100+ ActiveTabIndex: 1, // Second tab is active
101+ Tabs: []entity.TabSnapshot{
102+ {
103+ ID: "tab_1",
104+ Name: "First",
105+ Workspace: entity.WorkspaceSnapshot{
106+ Root: &entity.PaneNodeSnapshot{
107+ Pane: &entity.PaneSnapshot{
108+ ID: "pane_1",
109+ URI: "https://first.com",
110+ },
111+ },
112+ },
113+ },
114+ {
115+ ID: "tab_2",
116+ Name: "Second",
117+ Workspace: entity.WorkspaceSnapshot{
118+ Root: &entity.PaneNodeSnapshot{
119+ Pane: &entity.PaneSnapshot{
120+ ID: "pane_2",
121+ URI: "https://second.com",
122+ },
123+ },
124+ },
125+ },
126+ },
127+ }
128+
129+ tabs := entity.TabListFromSnapshot(state, idGen)
130+
131+ require.Len(t, tabs.Tabs, 2)
132+ assert.Equal(t, "First", tabs.Tabs[0].Name)
133+ assert.Equal(t, "Second", tabs.Tabs[1].Name)
134+
135+ // Active tab should be the second one
136+ assert.Equal(t, tabs.Tabs[1].ID, tabs.ActiveTabID)
137+}
138+
139+func TestTabListFromSnapshot_SplitPanes(t *testing.T) {
140+ idGen := mockIDGenerator()
141+
142+ // Horizontal split with two panes
143+ state := &entity.SessionState{
144+ Version: 1,
145+ Tabs: []entity.TabSnapshot{
146+ {
147+ ID: "tab_1",
148+ Name: "Split Tab",
149+ Workspace: entity.WorkspaceSnapshot{
150+ Root: &entity.PaneNodeSnapshot{
151+ ID: "container",
152+ SplitDir: entity.SplitHorizontal,
153+ SplitRatio: 0.5,
154+ Children: []*entity.PaneNodeSnapshot{
155+ {
156+ ID: "left_node",
157+ Pane: &entity.PaneSnapshot{
158+ ID: "left_pane",
159+ URI: "https://left.com",
160+ },
161+ },
162+ {
163+ ID: "right_node",
164+ Pane: &entity.PaneSnapshot{
165+ ID: "right_pane",
166+ URI: "https://right.com",
167+ },
168+ },
169+ },
170+ },
171+ },
172+ },
173+ },
174+ }
175+
176+ tabs := entity.TabListFromSnapshot(state, idGen)
177+
178+ require.Len(t, tabs.Tabs, 1)
179+ ws := tabs.Tabs[0].Workspace
180+ require.NotNil(t, ws.Root)
181+
182+ // Root should be a container with children
183+ assert.Nil(t, ws.Root.Pane)
184+ assert.Equal(t, entity.SplitHorizontal, ws.Root.SplitDir)
185+ assert.InDelta(t, 0.5, ws.Root.SplitRatio, 0.001)
186+ require.Len(t, ws.Root.Children, 2)
187+
188+ // Check children
189+ leftPane := ws.Root.Children[0].Pane
190+ rightPane := ws.Root.Children[1].Pane
191+ require.NotNil(t, leftPane)
192+ require.NotNil(t, rightPane)
193+ assert.Equal(t, "https://left.com", leftPane.URI)
194+ assert.Equal(t, "https://right.com", rightPane.URI)
195+}
196+
197+func TestTabListFromSnapshot_StackedPanes(t *testing.T) {
198+ idGen := mockIDGenerator()
199+
200+ state := &entity.SessionState{
201+ Version: 1,
202+ Tabs: []entity.TabSnapshot{
203+ {
204+ ID: "tab_1",
205+ Workspace: entity.WorkspaceSnapshot{
206+ Root: &entity.PaneNodeSnapshot{
207+ ID: "stack",
208+ IsStacked: true,
209+ ActiveStackIndex: 1,
210+ Children: []*entity.PaneNodeSnapshot{
211+ {Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://a.com"}},
212+ {Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://b.com"}},
213+ {Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://c.com"}},
214+ },
215+ },
216+ },
217+ },
218+ },
219+ }
220+
221+ tabs := entity.TabListFromSnapshot(state, idGen)
222+
223+ ws := tabs.Tabs[0].Workspace
224+ require.NotNil(t, ws.Root)
225+ assert.True(t, ws.Root.IsStacked)
226+ assert.Equal(t, 1, ws.Root.ActiveStackIndex)
227+ require.Len(t, ws.Root.Children, 3)
228+}
229+
230+func TestTabList_ReplaceFrom(t *testing.T) {
231+ // Create original list
232+ original := entity.NewTabList()
233+ pane1 := entity.NewPane("pane1")
234+ pane1.URI = "https://old.com"
235+ tab1 := entity.NewTab("tab1", "ws1", pane1)
236+ original.Add(tab1)
237+ original.SetActive("tab1")
238+
239+ // Create new list
240+ newList := entity.NewTabList()
241+ pane2 := entity.NewPane("pane2")
242+ pane2.URI = "https://new1.com"
243+ pane3 := entity.NewPane("pane3")
244+ pane3.URI = "https://new2.com"
245+ tab2 := entity.NewTab("tab2", "ws2", pane2)
246+ tab3 := entity.NewTab("tab3", "ws3", pane3)
247+ newList.Add(tab2)
248+ newList.Add(tab3)
249+ newList.SetActive("tab3")
250+
251+ // Keep reference to original
252+ ref := original
253+
254+ // Replace contents
255+ original.ReplaceFrom(newList)
256+
257+ // Verify the reference still points to the modified list
258+ assert.Same(t, original, ref)
259+ assert.Len(t, ref.Tabs, 2)
260+ assert.Equal(t, entity.TabID("tab3"), ref.ActiveTabID)
261+ assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI)
262+}
263+
264+func TestTabList_ReplaceFrom_Nil(t *testing.T) {
265+ original := entity.NewTabList()
266+ pane := entity.NewPane("pane1")
267+ tab := entity.NewTab("tab1", "ws1", pane)
268+ original.Add(tab)
269+
270+ original.ReplaceFrom(nil)
271+
272+ assert.Empty(t, original.Tabs)
273+ assert.Empty(t, original.ActiveTabID)
274+}
275+
276+func TestSnapshotRoundTrip(t *testing.T) {
277+ // Create a complex tab structure
278+ idGen := mockIDGenerator()
279+
280+ pane1 := entity.NewPane("p1")
281+ pane1.URI = "https://google.com"
282+ pane1.Title = "Google"
283+ pane1.ZoomFactor = 1.5
284+
285+ pane2 := entity.NewPane("p2")
286+ pane2.URI = "https://github.com"
287+ pane2.Title = "GitHub"
288+
289+ tab1 := entity.NewTab("t1", "ws1", pane1)
290+ tab1.Name = "Search"
291+ tab1.IsPinned = true
292+
293+ tab2 := entity.NewTab("t2", "ws2", pane2)
294+ tab2.Name = "Code"
295+
296+ original := entity.NewTabList()
297+ original.Add(tab1)
298+ original.Add(tab2)
299+ original.SetActive("t2")
300+
301+ // Snapshot
302+ sessionID := entity.SessionID("test_session")
303+ state := entity.SnapshotFromTabList(sessionID, original)
304+
305+ require.NotNil(t, state)
306+ assert.Equal(t, sessionID, state.SessionID)
307+ assert.Len(t, state.Tabs, 2)
308+ assert.Equal(t, 1, state.ActiveTabIndex) // Second tab active
309+
310+ // Restore
311+ restored := entity.TabListFromSnapshot(state, idGen)
312+
313+ require.NotNil(t, restored)
314+ require.Len(t, restored.Tabs, 2)
315+
316+ // Check first tab
317+ restoredTab1 := restored.Tabs[0]
318+ assert.Equal(t, "Search", restoredTab1.Name)
319+ assert.True(t, restoredTab1.IsPinned)
320+ require.NotNil(t, restoredTab1.Workspace.Root.Pane)
321+ assert.Equal(t, "https://google.com", restoredTab1.Workspace.Root.Pane.URI)
322+ assert.Equal(t, "Google", restoredTab1.Workspace.Root.Pane.Title)
323+ assert.InDelta(t, 1.5, restoredTab1.Workspace.Root.Pane.ZoomFactor, 0.001)
324+
325+ // Check second tab
326+ restoredTab2 := restored.Tabs[1]
327+ assert.Equal(t, "Code", restoredTab2.Name)
328+ assert.False(t, restoredTab2.IsPinned)
329+ assert.Equal(t, "https://github.com", restoredTab2.Workspace.Root.Pane.URI)
330+
331+ // Active tab should be second
332+ assert.Equal(t, restoredTab2.ID, restored.ActiveTabID)
333+}
334+
335+func TestFindPaneAcrossTabs(t *testing.T) {
336+ // Create tabs with panes
337+ pane1 := entity.NewPane("pane_in_tab1")
338+ pane1.URI = "https://tab1.com"
339+ tab1 := entity.NewTab("t1", "ws1", pane1)
340+
341+ pane2 := entity.NewPane("pane_in_tab2")
342+ pane2.URI = "https://tab2.com"
343+ tab2 := entity.NewTab("t2", "ws2", pane2)
344+
345+ tabs := entity.NewTabList()
346+ tabs.Add(tab1)
347+ tabs.Add(tab2)
348+
349+ // Helper function to find pane across all tabs (mirrors App.updatePaneURIInAllTabs logic)
350+ findPaneInTabs := func(tabs *entity.TabList, paneID entity.PaneID) *entity.Pane {
351+ for _, tab := range tabs.Tabs {
352+ if tab.Workspace == nil {
353+ continue
354+ }
355+ paneNode := tab.Workspace.FindPane(paneID)
356+ if paneNode != nil && paneNode.Pane != nil {
357+ return paneNode.Pane
358+ }
359+ }
360+ return nil
361+ }
362+
363+ // Find pane in first tab
364+ found := findPaneInTabs(tabs, "pane_in_tab1")
365+ require.NotNil(t, found)
366+ assert.Equal(t, "https://tab1.com", found.URI)
367+
368+ // Find pane in second tab
369+ found = findPaneInTabs(tabs, "pane_in_tab2")
370+ require.NotNil(t, found)
371+ assert.Equal(t, "https://tab2.com", found.URI)
372+
373+ // Update URI and verify
374+ found.URI = "https://updated.com"
375+ foundAgain := findPaneInTabs(tabs, "pane_in_tab2")
376+ assert.Equal(t, "https://updated.com", foundAgain.URI)
377+
378+ // Non-existent pane
379+ notFound := findPaneInTabs(tabs, "non_existent")
380+ assert.Nil(t, notFound)
381+}
382+
383+func TestFindPaneInNestedStructure(t *testing.T) {
384+ // Create a tab with split panes
385+ pane1 := entity.NewPane("left_pane")
386+ pane1.URI = "https://left.com"
387+
388+ pane2 := entity.NewPane("right_pane")
389+ pane2.URI = "https://right.com"
390+
391+ // Build a split structure manually
392+ tab := entity.NewTab("t1", "ws1", pane1)
393+
394+ // Add second pane as sibling (simulating a horizontal split)
395+ rightNode := &entity.PaneNode{
396+ ID: "right_node",
397+ Pane: pane2,
398+ }
399+
400+ // Create container node
401+ containerNode := &entity.PaneNode{
402+ ID: "container",
403+ SplitDir: entity.SplitHorizontal,
404+ Children: []*entity.PaneNode{
405+ tab.Workspace.Root, // left pane
406+ rightNode, // right pane
407+ },
408+ }
409+ tab.Workspace.Root = containerNode
410+
411+ tabs := entity.NewTabList()
412+ tabs.Add(tab)
413+
414+ // Find left pane
415+ leftNode := tab.Workspace.FindPane("left_pane")
416+ require.NotNil(t, leftNode)
417+ assert.Equal(t, "https://left.com", leftNode.Pane.URI)
418+
419+ // Find right pane
420+ rightNodeFound := tab.Workspace.FindPane("right_pane")
421+ require.NotNil(t, rightNodeFound)
422+ assert.Equal(t, "https://right.com", rightNodeFound.Pane.URI)
423+
424+ // Update URI on nested pane
425+ rightNodeFound.Pane.URI = "https://right-updated.com"
426+
427+ // Verify update persisted
428+ rightNodeAgain := tab.Workspace.FindPane("right_pane")
429+ assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI)
430+}
@@ -0,0 +1,430 @@
1+package entity_test
2+
3+import (
4+ "sync/atomic"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+// mockIDGenerator creates a simple ID generator for testing.
14+func mockIDGenerator() entity.IDGenerator {
15+ var counter uint64
16+ return func() string {
17+ id := atomic.AddUint64(&counter, 1)
18+ return string(rune('a'-1+id)) + "_new"
19+ }
20+}
21+
22+func TestTabListFromSnapshot_EmptyState(t *testing.T) {
23+ idGen := mockIDGenerator()
24+
25+ // Nil state
26+ tabs := entity.TabListFromSnapshot(nil, idGen)
27+ require.NotNil(t, tabs)
28+ assert.Empty(t, tabs.Tabs)
29+
30+ // Empty state
31+ state := &entity.SessionState{
32+ Version: 1,
33+ Tabs: []entity.TabSnapshot{},
34+ }
35+ tabs = entity.TabListFromSnapshot(state, idGen)
36+ require.NotNil(t, tabs)
37+ assert.Empty(t, tabs.Tabs)
38+}
39+
40+func TestTabListFromSnapshot_SingleTab(t *testing.T) {
41+ idGen := mockIDGenerator()
42+
43+ state := &entity.SessionState{
44+ Version: 1,
45+ SessionID: "test_session",
46+ ActiveTabIndex: 0,
47+ Tabs: []entity.TabSnapshot{
48+ {
49+ ID: "old_tab_1",
50+ Name: "Test Tab",
51+ Position: 0,
52+ IsPinned: true,
53+ Workspace: entity.WorkspaceSnapshot{
54+ ID: "old_ws_1",
55+ ActivePaneID: "old_pane_1",
56+ Root: &entity.PaneNodeSnapshot{
57+ ID: "old_node_1",
58+ Pane: &entity.PaneSnapshot{
59+ ID: "old_pane_1",
60+ URI: "https://example.com",
61+ Title: "Example",
62+ ZoomFactor: 1.25,
63+ },
64+ },
65+ },
66+ },
67+ },
68+ SavedAt: time.Now(),
69+ }
70+
71+ tabs := entity.TabListFromSnapshot(state, idGen)
72+
73+ require.NotNil(t, tabs)
74+ require.Len(t, tabs.Tabs, 1)
75+
76+ tab := tabs.Tabs[0]
77+ assert.NotEqual(t, entity.TabID("old_tab_1"), tab.ID, "should generate new ID")
78+ assert.Equal(t, "Test Tab", tab.Name)
79+ assert.True(t, tab.IsPinned)
80+
81+ require.NotNil(t, tab.Workspace)
82+ require.NotNil(t, tab.Workspace.Root)
83+ require.NotNil(t, tab.Workspace.Root.Pane)
84+
85+ pane := tab.Workspace.Root.Pane
86+ assert.NotEqual(t, entity.PaneID("old_pane_1"), pane.ID, "should generate new pane ID")
87+ assert.Equal(t, "https://example.com", pane.URI)
88+ assert.Equal(t, "Example", pane.Title)
89+ assert.InDelta(t, 1.25, pane.ZoomFactor, 0.001)
90+
91+ // Active tab should be set
92+ assert.Equal(t, tab.ID, tabs.ActiveTabID)
93+}
94+
95+func TestTabListFromSnapshot_MultipleTabs(t *testing.T) {
96+ idGen := mockIDGenerator()
97+
98+ state := &entity.SessionState{
99+ Version: 1,
100+ ActiveTabIndex: 1, // Second tab is active
101+ Tabs: []entity.TabSnapshot{
102+ {
103+ ID: "tab_1",
104+ Name: "First",
105+ Workspace: entity.WorkspaceSnapshot{
106+ Root: &entity.PaneNodeSnapshot{
107+ Pane: &entity.PaneSnapshot{
108+ ID: "pane_1",
109+ URI: "https://first.com",
110+ },
111+ },
112+ },
113+ },
114+ {
115+ ID: "tab_2",
116+ Name: "Second",
117+ Workspace: entity.WorkspaceSnapshot{
118+ Root: &entity.PaneNodeSnapshot{
119+ Pane: &entity.PaneSnapshot{
120+ ID: "pane_2",
121+ URI: "https://second.com",
122+ },
123+ },
124+ },
125+ },
126+ },
127+ }
128+
129+ tabs := entity.TabListFromSnapshot(state, idGen)
130+
131+ require.Len(t, tabs.Tabs, 2)
132+ assert.Equal(t, "First", tabs.Tabs[0].Name)
133+ assert.Equal(t, "Second", tabs.Tabs[1].Name)
134+
135+ // Active tab should be the second one
136+ assert.Equal(t, tabs.Tabs[1].ID, tabs.ActiveTabID)
137+}
138+
139+func TestTabListFromSnapshot_SplitPanes(t *testing.T) {
140+ idGen := mockIDGenerator()
141+
142+ // Horizontal split with two panes
143+ state := &entity.SessionState{
144+ Version: 1,
145+ Tabs: []entity.TabSnapshot{
146+ {
147+ ID: "tab_1",
148+ Name: "Split Tab",
149+ Workspace: entity.WorkspaceSnapshot{
150+ Root: &entity.PaneNodeSnapshot{
151+ ID: "container",
152+ SplitDir: entity.SplitHorizontal,
153+ SplitRatio: 0.5,
154+ Children: []*entity.PaneNodeSnapshot{
155+ {
156+ ID: "left_node",
157+ Pane: &entity.PaneSnapshot{
158+ ID: "left_pane",
159+ URI: "https://left.com",
160+ },
161+ },
162+ {
163+ ID: "right_node",
164+ Pane: &entity.PaneSnapshot{
165+ ID: "right_pane",
166+ URI: "https://right.com",
167+ },
168+ },
169+ },
170+ },
171+ },
172+ },
173+ },
174+ }
175+
176+ tabs := entity.TabListFromSnapshot(state, idGen)
177+
178+ require.Len(t, tabs.Tabs, 1)
179+ ws := tabs.Tabs[0].Workspace
180+ require.NotNil(t, ws.Root)
181+
182+ // Root should be a container with children
183+ assert.Nil(t, ws.Root.Pane)
184+ assert.Equal(t, entity.SplitHorizontal, ws.Root.SplitDir)
185+ assert.InDelta(t, 0.5, ws.Root.SplitRatio, 0.001)
186+ require.Len(t, ws.Root.Children, 2)
187+
188+ // Check children
189+ leftPane := ws.Root.Children[0].Pane
190+ rightPane := ws.Root.Children[1].Pane
191+ require.NotNil(t, leftPane)
192+ require.NotNil(t, rightPane)
193+ assert.Equal(t, "https://left.com", leftPane.URI)
194+ assert.Equal(t, "https://right.com", rightPane.URI)
195+}
196+
197+func TestTabListFromSnapshot_StackedPanes(t *testing.T) {
198+ idGen := mockIDGenerator()
199+
200+ state := &entity.SessionState{
201+ Version: 1,
202+ Tabs: []entity.TabSnapshot{
203+ {
204+ ID: "tab_1",
205+ Workspace: entity.WorkspaceSnapshot{
206+ Root: &entity.PaneNodeSnapshot{
207+ ID: "stack",
208+ IsStacked: true,
209+ ActiveStackIndex: 1,
210+ Children: []*entity.PaneNodeSnapshot{
211+ {Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://a.com"}},
212+ {Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://b.com"}},
213+ {Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://c.com"}},
214+ },
215+ },
216+ },
217+ },
218+ },
219+ }
220+
221+ tabs := entity.TabListFromSnapshot(state, idGen)
222+
223+ ws := tabs.Tabs[0].Workspace
224+ require.NotNil(t, ws.Root)
225+ assert.True(t, ws.Root.IsStacked)
226+ assert.Equal(t, 1, ws.Root.ActiveStackIndex)
227+ require.Len(t, ws.Root.Children, 3)
228+}
229+
230+func TestTabList_ReplaceFrom(t *testing.T) {
231+ // Create original list
232+ original := entity.NewTabList()
233+ pane1 := entity.NewPane("pane1")
234+ pane1.URI = "https://old.com"
235+ tab1 := entity.NewTab("tab1", "ws1", pane1)
236+ original.Add(tab1)
237+ original.SetActive("tab1")
238+
239+ // Create new list
240+ newList := entity.NewTabList()
241+ pane2 := entity.NewPane("pane2")
242+ pane2.URI = "https://new1.com"
243+ pane3 := entity.NewPane("pane3")
244+ pane3.URI = "https://new2.com"
245+ tab2 := entity.NewTab("tab2", "ws2", pane2)
246+ tab3 := entity.NewTab("tab3", "ws3", pane3)
247+ newList.Add(tab2)
248+ newList.Add(tab3)
249+ newList.SetActive("tab3")
250+
251+ // Keep reference to original
252+ ref := original
253+
254+ // Replace contents
255+ original.ReplaceFrom(newList)
256+
257+ // Verify the reference still points to the modified list
258+ assert.Same(t, original, ref)
259+ assert.Len(t, ref.Tabs, 2)
260+ assert.Equal(t, entity.TabID("tab3"), ref.ActiveTabID)
261+ assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI)
262+}
263+
264+func TestTabList_ReplaceFrom_Nil(t *testing.T) {
265+ original := entity.NewTabList()
266+ pane := entity.NewPane("pane1")
267+ tab := entity.NewTab("tab1", "ws1", pane)
268+ original.Add(tab)
269+
270+ original.ReplaceFrom(nil)
271+
272+ assert.Empty(t, original.Tabs)
273+ assert.Empty(t, original.ActiveTabID)
274+}
275+
276+func TestSnapshotRoundTrip(t *testing.T) {
277+ // Create a complex tab structure
278+ idGen := mockIDGenerator()
279+
280+ pane1 := entity.NewPane("p1")
281+ pane1.URI = "https://google.com"
282+ pane1.Title = "Google"
283+ pane1.ZoomFactor = 1.5
284+
285+ pane2 := entity.NewPane("p2")
286+ pane2.URI = "https://github.com"
287+ pane2.Title = "GitHub"
288+
289+ tab1 := entity.NewTab("t1", "ws1", pane1)
290+ tab1.Name = "Search"
291+ tab1.IsPinned = true
292+
293+ tab2 := entity.NewTab("t2", "ws2", pane2)
294+ tab2.Name = "Code"
295+
296+ original := entity.NewTabList()
297+ original.Add(tab1)
298+ original.Add(tab2)
299+ original.SetActive("t2")
300+
301+ // Snapshot
302+ sessionID := entity.SessionID("test_session")
303+ state := entity.SnapshotFromTabList(sessionID, original)
304+
305+ require.NotNil(t, state)
306+ assert.Equal(t, sessionID, state.SessionID)
307+ assert.Len(t, state.Tabs, 2)
308+ assert.Equal(t, 1, state.ActiveTabIndex) // Second tab active
309+
310+ // Restore
311+ restored := entity.TabListFromSnapshot(state, idGen)
312+
313+ require.NotNil(t, restored)
314+ require.Len(t, restored.Tabs, 2)
315+
316+ // Check first tab
317+ restoredTab1 := restored.Tabs[0]
318+ assert.Equal(t, "Search", restoredTab1.Name)
319+ assert.True(t, restoredTab1.IsPinned)
320+ require.NotNil(t, restoredTab1.Workspace.Root.Pane)
321+ assert.Equal(t, "https://google.com", restoredTab1.Workspace.Root.Pane.URI)
322+ assert.Equal(t, "Google", restoredTab1.Workspace.Root.Pane.Title)
323+ assert.InDelta(t, 1.5, restoredTab1.Workspace.Root.Pane.ZoomFactor, 0.001)
324+
325+ // Check second tab
326+ restoredTab2 := restored.Tabs[1]
327+ assert.Equal(t, "Code", restoredTab2.Name)
328+ assert.False(t, restoredTab2.IsPinned)
329+ assert.Equal(t, "https://github.com", restoredTab2.Workspace.Root.Pane.URI)
330+
331+ // Active tab should be second
332+ assert.Equal(t, restoredTab2.ID, restored.ActiveTabID)
333+}
334+
335+func TestFindPaneAcrossTabs(t *testing.T) {
336+ // Create tabs with panes
337+ pane1 := entity.NewPane("pane_in_tab1")
338+ pane1.URI = "https://tab1.com"
339+ tab1 := entity.NewTab("t1", "ws1", pane1)
340+
341+ pane2 := entity.NewPane("pane_in_tab2")
342+ pane2.URI = "https://tab2.com"
343+ tab2 := entity.NewTab("t2", "ws2", pane2)
344+
345+ tabs := entity.NewTabList()
346+ tabs.Add(tab1)
347+ tabs.Add(tab2)
348+
349+ // Helper function to find pane across all tabs (mirrors App.updatePaneURIInAllTabs logic)
350+ findPaneInTabs := func(tabs *entity.TabList, paneID entity.PaneID) *entity.Pane {
351+ for _, tab := range tabs.Tabs {
352+ if tab.Workspace == nil {
353+ continue
354+ }
355+ paneNode := tab.Workspace.FindPane(paneID)
356+ if paneNode != nil && paneNode.Pane != nil {
357+ return paneNode.Pane
358+ }
359+ }
360+ return nil
361+ }
362+
363+ // Find pane in first tab
364+ found := findPaneInTabs(tabs, "pane_in_tab1")
365+ require.NotNil(t, found)
366+ assert.Equal(t, "https://tab1.com", found.URI)
367+
368+ // Find pane in second tab
369+ found = findPaneInTabs(tabs, "pane_in_tab2")
370+ require.NotNil(t, found)
371+ assert.Equal(t, "https://tab2.com", found.URI)
372+
373+ // Update URI and verify
374+ found.URI = "https://updated.com"
375+ foundAgain := findPaneInTabs(tabs, "pane_in_tab2")
376+ assert.Equal(t, "https://updated.com", foundAgain.URI)
377+
378+ // Non-existent pane
379+ notFound := findPaneInTabs(tabs, "non_existent")
380+ assert.Nil(t, notFound)
381+}
382+
383+func TestFindPaneInNestedStructure(t *testing.T) {
384+ // Create a tab with split panes
385+ pane1 := entity.NewPane("left_pane")
386+ pane1.URI = "https://left.com"
387+
388+ pane2 := entity.NewPane("right_pane")
389+ pane2.URI = "https://right.com"
390+
391+ // Build a split structure manually
392+ tab := entity.NewTab("t1", "ws1", pane1)
393+
394+ // Add second pane as sibling (simulating a horizontal split)
395+ rightNode := &entity.PaneNode{
396+ ID: "right_node",
397+ Pane: pane2,
398+ }
399+
400+ // Create container node
401+ containerNode := &entity.PaneNode{
402+ ID: "container",
403+ SplitDir: entity.SplitHorizontal,
404+ Children: []*entity.PaneNode{
405+ tab.Workspace.Root, // left pane
406+ rightNode, // right pane
407+ },
408+ }
409+ tab.Workspace.Root = containerNode
410+
411+ tabs := entity.NewTabList()
412+ tabs.Add(tab)
413+
414+ // Find left pane
415+ leftNode := tab.Workspace.FindPane("left_pane")
416+ require.NotNil(t, leftNode)
417+ assert.Equal(t, "https://left.com", leftNode.Pane.URI)
418+
419+ // Find right pane
420+ rightNodeFound := tab.Workspace.FindPane("right_pane")
421+ require.NotNil(t, rightNodeFound)
422+ assert.Equal(t, "https://right.com", rightNodeFound.Pane.URI)
423+
424+ // Update URI on nested pane
425+ rightNodeFound.Pane.URI = "https://right-updated.com"
426+
427+ // Verify update persisted
428+ rightNodeAgain := tab.Workspace.FindPane("right_pane")
429+ assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI)
430+}
M internal/domain/entity/tab.go +14 -0
@@ -155,3 +155,17 @@ func (tl *TabList) Move(id TabID, newPos int) bool {
155155 }
156156 return true
157157 }
158+
159+// ReplaceFrom replaces this TabList's contents with those from another TabList.
160+// This modifies in-place so existing references to this TabList remain valid.
161+func (tl *TabList) ReplaceFrom(other *TabList) {
162+ if other == nil {
163+ tl.Tabs = make([]*Tab, 0)
164+ tl.ActiveTabID = ""
165+ tl.PreviousActiveTabID = ""
166+ return
167+ }
168+ tl.Tabs = other.Tabs
169+ tl.ActiveTabID = other.ActiveTabID
170+ tl.PreviousActiveTabID = other.PreviousActiveTabID
171+}
@@ -155,3 +155,17 @@ func (tl *TabList) Move(id TabID, newPos int) bool {
155 }155 }
156 return true156 return true
157 }157 }
158+
159+// ReplaceFrom replaces this TabList's contents with those from another TabList.
160+// This modifies in-place so existing references to this TabList remain valid.
161+func (tl *TabList) ReplaceFrom(other *TabList) {
162+ if other == nil {
163+ tl.Tabs = make([]*Tab, 0)
164+ tl.ActiveTabID = ""
165+ tl.PreviousActiveTabID = ""
166+ return
167+ }
168+ tl.Tabs = other.Tabs
169+ tl.ActiveTabID = other.ActiveTabID
170+ tl.PreviousActiveTabID = other.PreviousActiveTabID
171+}
M internal/ui/app.go +46 -16
@@ -532,13 +532,14 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
532532 return fmt.Errorf("failed to build tab list from snapshot")
533533 }
534534
535- // Replace empty tabs with restored ones
536- a.tabs = restoredTabs
535+ // Replace tabs in-place to preserve references held by TabCoordinator
536+ a.tabs.ReplaceFrom(restoredTabs)
537537
538538 // Create UI for each restored tab and add to tab bar
539+ // Don't attach to content area yet - only the active tab should be attached
539540 tabBar := a.mainWindow.TabBar()
540541 for _, tab := range a.tabs.Tabs {
541- a.createWorkspaceView(ctx, tab)
542+ a.createWorkspaceViewWithoutAttach(ctx, tab)
542543 if tabBar != nil {
543544 tabBar.AddTab(tab)
544545 }
@@ -549,7 +550,7 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
549550 Msg("restored tab with workspace")
550551 }
551552
552- // Switch to active tab
553+ // Switch to active tab - this attaches the active workspace view to content area
553554 activeTab := a.tabs.ActiveTab()
554555 if activeTab != nil {
555556 a.switchWorkspaceView(ctx, activeTab.ID)
@@ -743,6 +744,13 @@ func (a *App) initCoordinators(ctx context.Context) {
743744 a.navCoord.RecordHistory(ctx, paneID, url)
744745 })
745746
747+ // Wire pane URI updates for session snapshots (searches all tabs)
748+ a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
749+ a.updatePaneURIInAllTabs(paneID, url)
750+ // Mark dirty so snapshot captures the new URI
751+ a.MarkDirty()
752+ })
753+
746754 // 5. Keyboard Dispatcher
747755 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
748756 ctx,
@@ -828,6 +836,24 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
828836
829837 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
830838 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
839+ a.createWorkspaceViewWithoutAttach(ctx, tab)
840+
841+ // Attach to content area
842+ wsView := a.workspaceViews[tab.ID]
843+ if wsView != nil && a.mainWindow != nil {
844+ widget := wsView.Widget()
845+ if widget != nil {
846+ gtkWidget := widget.GtkWidget()
847+ if gtkWidget != nil {
848+ a.mainWindow.SetContent(gtkWidget)
849+ }
850+ }
851+ }
852+}
853+
854+// createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
855+// Used during session restoration where we create all views first, then attach only the active one.
856+func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) {
831857 log := logging.FromContext(ctx)
832858
833859 if a.widgetFactory == nil {
@@ -864,18 +890,7 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
864890 // Store in map
865891 a.workspaceViews[tab.ID] = wsView
866892
867- // Attach to content area
868- if a.mainWindow != nil {
869- widget := wsView.Widget()
870- if widget != nil {
871- gtkWidget := widget.GtkWidget()
872- if gtkWidget != nil {
873- a.mainWindow.SetContent(gtkWidget)
874- }
875- }
876- }
877-
878- log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created and attached")
893+ log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
879894 }
880895
881896 // activeWorkspace returns the workspace of the active tab.
@@ -887,6 +902,21 @@ func (a *App) activeWorkspace() *entity.Workspace {
887902 return activeTab.Workspace
888903 }
889904
905+// updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
906+// This is necessary because panes in inactive tabs also need URI updates for session snapshots.
907+func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
908+ for _, tab := range a.tabs.Tabs {
909+ if tab.Workspace == nil {
910+ continue
911+ }
912+ paneNode := tab.Workspace.FindPane(paneID)
913+ if paneNode != nil && paneNode.Pane != nil {
914+ paneNode.Pane.URI = url
915+ return // Pane IDs are unique, no need to continue
916+ }
917+ }
918+}
919+
890920 // activeWorkspaceView returns the workspace view for the active tab.
891921 func (a *App) activeWorkspaceView() *component.WorkspaceView {
892922 activeTab := a.tabs.ActiveTab()
@@ -532,13 +532,14 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
532 return fmt.Errorf("failed to build tab list from snapshot")532 return fmt.Errorf("failed to build tab list from snapshot")
533 }533 }
534 534
535- // Replace empty tabs with restored ones535+ // Replace tabs in-place to preserve references held by TabCoordinator
536- a.tabs = restoredTabs536+ a.tabs.ReplaceFrom(restoredTabs)
537 537
538 // Create UI for each restored tab and add to tab bar538 // Create UI for each restored tab and add to tab bar
539+ // Don't attach to content area yet - only the active tab should be attached
539 tabBar := a.mainWindow.TabBar()540 tabBar := a.mainWindow.TabBar()
540 for _, tab := range a.tabs.Tabs {541 for _, tab := range a.tabs.Tabs {
541- a.createWorkspaceView(ctx, tab)542+ a.createWorkspaceViewWithoutAttach(ctx, tab)
542 if tabBar != nil {543 if tabBar != nil {
543 tabBar.AddTab(tab)544 tabBar.AddTab(tab)
544 }545 }
@@ -549,7 +550,7 @@ func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) er
549 Msg("restored tab with workspace")550 Msg("restored tab with workspace")
550 }551 }
551 552
552- // Switch to active tab553+ // Switch to active tab - this attaches the active workspace view to content area
553 activeTab := a.tabs.ActiveTab()554 activeTab := a.tabs.ActiveTab()
554 if activeTab != nil {555 if activeTab != nil {
555 a.switchWorkspaceView(ctx, activeTab.ID)556 a.switchWorkspaceView(ctx, activeTab.ID)
@@ -743,6 +744,13 @@ func (a *App) initCoordinators(ctx context.Context) {
743 a.navCoord.RecordHistory(ctx, paneID, url)744 a.navCoord.RecordHistory(ctx, paneID, url)
744 })745 })
745 746
747+ // Wire pane URI updates for session snapshots (searches all tabs)
748+ a.contentCoord.SetOnPaneURIUpdated(func(paneID entity.PaneID, url string) {
749+ a.updatePaneURIInAllTabs(paneID, url)
750+ // Mark dirty so snapshot captures the new URI
751+ a.MarkDirty()
752+ })
753+
746 // 5. Keyboard Dispatcher754 // 5. Keyboard Dispatcher
747 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(755 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
748 ctx,756 ctx,
@@ -828,6 +836,24 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
828 836
829 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.837 // createWorkspaceView creates a WorkspaceView for a tab and attaches it to the content area.
830 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {838 func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
839+ a.createWorkspaceViewWithoutAttach(ctx, tab)
840+
841+ // Attach to content area
842+ wsView := a.workspaceViews[tab.ID]
843+ if wsView != nil && a.mainWindow != nil {
844+ widget := wsView.Widget()
845+ if widget != nil {
846+ gtkWidget := widget.GtkWidget()
847+ if gtkWidget != nil {
848+ a.mainWindow.SetContent(gtkWidget)
849+ }
850+ }
851+ }
852+}
853+
854+// createWorkspaceViewWithoutAttach creates a WorkspaceView for a tab without attaching to content area.
855+// Used during session restoration where we create all views first, then attach only the active one.
856+func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.Tab) {
831 log := logging.FromContext(ctx)857 log := logging.FromContext(ctx)
832 858
833 if a.widgetFactory == nil {859 if a.widgetFactory == nil {
@@ -864,18 +890,7 @@ func (a *App) createWorkspaceView(ctx context.Context, tab *entity.Tab) {
864 // Store in map890 // Store in map
865 a.workspaceViews[tab.ID] = wsView891 a.workspaceViews[tab.ID] = wsView
866 892
867- // Attach to content area893+ log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
868- if a.mainWindow != nil {
869- widget := wsView.Widget()
870- if widget != nil {
871- gtkWidget := widget.GtkWidget()
872- if gtkWidget != nil {
873- a.mainWindow.SetContent(gtkWidget)
874- }
875- }
876- }
877-
878- log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created and attached")
879 }894 }
880 895
881 // activeWorkspace returns the workspace of the active tab.896 // activeWorkspace returns the workspace of the active tab.
@@ -887,6 +902,21 @@ func (a *App) activeWorkspace() *entity.Workspace {
887 return activeTab.Workspace902 return activeTab.Workspace
888 }903 }
889 904
905+// updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
906+// This is necessary because panes in inactive tabs also need URI updates for session snapshots.
907+func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
908+ for _, tab := range a.tabs.Tabs {
909+ if tab.Workspace == nil {
910+ continue
911+ }
912+ paneNode := tab.Workspace.FindPane(paneID)
913+ if paneNode != nil && paneNode.Pane != nil {
914+ paneNode.Pane.URI = url
915+ return // Pane IDs are unique, no need to continue
916+ }
917+ }
918+}
919+
890 // activeWorkspaceView returns the workspace view for the active tab.920 // activeWorkspaceView returns the workspace view for the active tab.
891 func (a *App) activeWorkspaceView() *component.WorkspaceView {921 func (a *App) activeWorkspaceView() *component.WorkspaceView {
892 activeTab := a.tabs.ActiveTab()922 activeTab := a.tabs.ActiveTab()
M internal/ui/coordinator/content.go +22 -0
@@ -46,6 +46,9 @@ type ContentCoordinator struct {
4646 // Callback when page is committed (for history recording)
4747 onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
4848
49+ // Callback when pane URI changes (for session snapshots)
50+ onPaneURIUpdated func(paneID entity.PaneID, url string)
51+
4952 // Gesture action handler for mouse button navigation
5053 gestureActionHandler input.ActionHandler
5154
@@ -103,6 +106,11 @@ func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, pan
103106 c.onHistoryRecord = fn
104107 }
105108
109+// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
110+func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
111+ c.onPaneURIUpdated = fn
112+}
113+
106114 // SetGestureActionHandler sets the callback for mouse button navigation gestures.
107115 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
108116 c.gestureActionHandler = handler
@@ -655,6 +663,9 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
655663 return
656664 }
657665
666+ // Update domain model with current URI for session snapshots
667+ c.updatePaneURI(paneID, url)
668+
658669 // Record history - URI is guaranteed to be correct at LoadCommitted
659670 if c.onHistoryRecord != nil {
660671 c.onHistoryRecord(ctx, paneID, url)
@@ -679,12 +690,23 @@ func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.
679690 log := logging.FromContext(ctx)
680691 log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")
681692
693+ // Update domain model with current URI for session snapshots
694+ c.updatePaneURI(paneID, url)
695+
682696 // Record history for SPA navigation
683697 if c.onHistoryRecord != nil {
684698 c.onHistoryRecord(ctx, paneID, url)
685699 }
686700 }
687701
702+// updatePaneURI updates the pane's URI in the domain model.
703+// This is called on navigation so that session snapshots capture the current URL.
704+func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) {
705+ if c.onPaneURIUpdated != nil {
706+ c.onPaneURIUpdated(paneID, url)
707+ }
708+}
709+
688710 // onLoadStarted shows the progress bar when page loading begins.
689711 func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
690712 _, wsView := c.getActiveWS()
@@ -46,6 +46,9 @@ type ContentCoordinator struct {
46 // Callback when page is committed (for history recording)46 // Callback when page is committed (for history recording)
47 onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)47 onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
48 48
49+ // Callback when pane URI changes (for session snapshots)
50+ onPaneURIUpdated func(paneID entity.PaneID, url string)
51+
49 // Gesture action handler for mouse button navigation52 // Gesture action handler for mouse button navigation
50 gestureActionHandler input.ActionHandler53 gestureActionHandler input.ActionHandler
51 54
@@ -103,6 +106,11 @@ func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, pan
103 c.onHistoryRecord = fn106 c.onHistoryRecord = fn
104 }107 }
105 108
109+// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
110+func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
111+ c.onPaneURIUpdated = fn
112+}
113+
106 // SetGestureActionHandler sets the callback for mouse button navigation gestures.114 // SetGestureActionHandler sets the callback for mouse button navigation gestures.
107 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {115 func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
108 c.gestureActionHandler = handler116 c.gestureActionHandler = handler
@@ -655,6 +663,9 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
655 return663 return
656 }664 }
657 665
666+ // Update domain model with current URI for session snapshots
667+ c.updatePaneURI(paneID, url)
668+
658 // Record history - URI is guaranteed to be correct at LoadCommitted669 // Record history - URI is guaranteed to be correct at LoadCommitted
659 if c.onHistoryRecord != nil {670 if c.onHistoryRecord != nil {
660 c.onHistoryRecord(ctx, paneID, url)671 c.onHistoryRecord(ctx, paneID, url)
@@ -679,12 +690,23 @@ func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.
679 log := logging.FromContext(ctx)690 log := logging.FromContext(ctx)
680 log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")691 log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")
681 692
693+ // Update domain model with current URI for session snapshots
694+ c.updatePaneURI(paneID, url)
695+
682 // Record history for SPA navigation696 // Record history for SPA navigation
683 if c.onHistoryRecord != nil {697 if c.onHistoryRecord != nil {
684 c.onHistoryRecord(ctx, paneID, url)698 c.onHistoryRecord(ctx, paneID, url)
685 }699 }
686 }700 }
687 701
702+// updatePaneURI updates the pane's URI in the domain model.
703+// This is called on navigation so that session snapshots capture the current URL.
704+func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) {
705+ if c.onPaneURIUpdated != nil {
706+ c.onPaneURIUpdated(paneID, url)
707+ }
708+}
709+
688 // onLoadStarted shows the progress bar when page loading begins.710 // onLoadStarted shows the progress bar when page loading begins.
689 func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {711 func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
690 _, wsView := c.getActiveWS()712 _, wsView := c.getActiveWS()

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