session_state_test.go RawHistoryFolder

1package entity_test
2
3import (
4	"encoding/json"
5	"sync/atomic"
6	"testing"
7	"time"
8
9	"github.com/bnema/dumber/internal/domain/entity"
10	"github.com/stretchr/testify/assert"
11	"github.com/stretchr/testify/require"
12)
13
14// mockIDGenerator creates a simple ID generator for testing.
15func mockIDGenerator() entity.IDGenerator {
16	var counter uint64
17	return func() string {
18		id := atomic.AddUint64(&counter, 1)
19		return string(rune('a'-1+id)) + "_new"
20	}
21}
22
23func TestTabListFromSnapshot_EmptyState(t *testing.T) {
24	idGen := mockIDGenerator()
25
26	// Nil state
27	tabs := entity.TabListFromSnapshot(nil, idGen)
28	require.NotNil(t, tabs)
29	assert.Empty(t, tabs.Tabs)
30
31	// Empty state
32	state := &entity.SessionState{
33		Version: 1,
34		Tabs:    []entity.TabSnapshot{},
35	}
36	tabs = entity.TabListFromSnapshot(state, idGen)
37	require.NotNil(t, tabs)
38	assert.Empty(t, tabs.Tabs)
39}
40
41func TestTabListFromSnapshot_SingleTab(t *testing.T) {
42	idGen := mockIDGenerator()
43
44	state := &entity.SessionState{
45		Version:        1,
46		SessionID:      "test_session",
47		ActiveTabIndex: 0,
48		Tabs: []entity.TabSnapshot{
49			{
50				ID:       "old_tab_1",
51				Name:     "Test Tab",
52				Position: 0,
53				IsPinned: true,
54				Workspace: entity.WorkspaceSnapshot{
55					ID:           "old_ws_1",
56					ActivePaneID: "old_pane_1",
57					Root: &entity.PaneNodeSnapshot{
58						ID: "old_node_1",
59						Pane: &entity.PaneSnapshot{
60							ID:         "old_pane_1",
61							URI:        "https://example.com",
62							Title:      "Example",
63							ZoomFactor: 1.25,
64						},
65					},
66				},
67			},
68		},
69		SavedAt: time.Now(),
70	}
71
72	tabs := entity.TabListFromSnapshot(state, idGen)
73
74	require.NotNil(t, tabs)
75	require.Len(t, tabs.Tabs, 1)
76
77	tab := tabs.Tabs[0]
78	assert.NotEqual(t, entity.TabID("old_tab_1"), tab.ID, "should generate new ID")
79	assert.Equal(t, "Test Tab", tab.Name)
80	assert.True(t, tab.IsPinned)
81
82	require.NotNil(t, tab.Workspace)
83	require.NotNil(t, tab.Workspace.Root)
84	require.NotNil(t, tab.Workspace.Root.Pane)
85
86	pane := tab.Workspace.Root.Pane
87	assert.NotEqual(t, entity.PaneID("old_pane_1"), pane.ID, "should generate new pane ID")
88	assert.Equal(t, "https://example.com", pane.URI)
89	assert.Equal(t, "Example", pane.Title)
90	assert.InDelta(t, 1.25, pane.ZoomFactor, 0.001)
91
92	// Active tab should be set
93	assert.Equal(t, tab.ID, tabs.ActiveTabID)
94}
95
96func TestTabListFromSnapshot_MultipleTabs(t *testing.T) {
97	idGen := mockIDGenerator()
98
99	state := &entity.SessionState{
100		Version:        1,
101		ActiveTabIndex: 1, // Second tab is active
102		Tabs: []entity.TabSnapshot{
103			{
104				ID:   "tab_1",
105				Name: "First",
106				Workspace: entity.WorkspaceSnapshot{
107					Root: &entity.PaneNodeSnapshot{
108						Pane: &entity.PaneSnapshot{
109							ID:  "pane_1",
110							URI: "https://first.com",
111						},
112					},
113				},
114			},
115			{
116				ID:   "tab_2",
117				Name: "Second",
118				Workspace: entity.WorkspaceSnapshot{
119					Root: &entity.PaneNodeSnapshot{
120						Pane: &entity.PaneSnapshot{
121							ID:  "pane_2",
122							URI: "https://second.com",
123						},
124					},
125				},
126			},
127		},
128	}
129
130	tabs := entity.TabListFromSnapshot(state, idGen)
131
132	require.Len(t, tabs.Tabs, 2)
133	assert.Equal(t, "First", tabs.Tabs[0].Name)
134	assert.Equal(t, "Second", tabs.Tabs[1].Name)
135
136	// Active tab should be the second one
137	assert.Equal(t, tabs.Tabs[1].ID, tabs.ActiveTabID)
138}
139
140func TestTabListFromSnapshot_SplitPanes(t *testing.T) {
141	idGen := mockIDGenerator()
142
143	// Horizontal split with two panes
144	state := &entity.SessionState{
145		Version: 1,
146		Tabs: []entity.TabSnapshot{
147			{
148				ID:   "tab_1",
149				Name: "Split Tab",
150				Workspace: entity.WorkspaceSnapshot{
151					Root: &entity.PaneNodeSnapshot{
152						ID:         "container",
153						SplitDir:   entity.SplitHorizontal,
154						SplitRatio: 0.5,
155						Children: []*entity.PaneNodeSnapshot{
156							{
157								ID: "left_node",
158								Pane: &entity.PaneSnapshot{
159									ID:  "left_pane",
160									URI: "https://left.com",
161								},
162							},
163							{
164								ID: "right_node",
165								Pane: &entity.PaneSnapshot{
166									ID:  "right_pane",
167									URI: "https://right.com",
168								},
169							},
170						},
171					},
172				},
173			},
174		},
175	}
176
177	tabs := entity.TabListFromSnapshot(state, idGen)
178
179	require.Len(t, tabs.Tabs, 1)
180	ws := tabs.Tabs[0].Workspace
181	require.NotNil(t, ws.Root)
182
183	// Root should be a container with children
184	assert.Nil(t, ws.Root.Pane)
185	assert.Equal(t, entity.SplitHorizontal, ws.Root.SplitDir)
186	assert.InDelta(t, 0.5, ws.Root.SplitRatio, 0.001)
187	require.Len(t, ws.Root.Children, 2)
188
189	// Check children
190	leftPane := ws.Root.Children[0].Pane
191	rightPane := ws.Root.Children[1].Pane
192	require.NotNil(t, leftPane)
193	require.NotNil(t, rightPane)
194	assert.Equal(t, "https://left.com", leftPane.URI)
195	assert.Equal(t, "https://right.com", rightPane.URI)
196}
197
198func TestTabListFromSnapshot_StackedPanes(t *testing.T) {
199	idGen := mockIDGenerator()
200
201	state := &entity.SessionState{
202		Version: 1,
203		Tabs: []entity.TabSnapshot{
204			{
205				ID: "tab_1",
206				Workspace: entity.WorkspaceSnapshot{
207					Root: &entity.PaneNodeSnapshot{
208						ID:               "stack",
209						IsStacked:        true,
210						ActiveStackIndex: 1,
211						Children: []*entity.PaneNodeSnapshot{
212							{Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://a.com"}},
213							{Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://b.com"}},
214							{Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://c.com"}},
215						},
216					},
217				},
218			},
219		},
220	}
221
222	tabs := entity.TabListFromSnapshot(state, idGen)
223
224	ws := tabs.Tabs[0].Workspace
225	require.NotNil(t, ws.Root)
226	assert.True(t, ws.Root.IsStacked)
227	assert.Equal(t, 1, ws.Root.ActiveStackIndex)
228	require.Len(t, ws.Root.Children, 3)
229}
230
231func TestTabList_ReplaceFrom(t *testing.T) {
232	// Create original list
233	original := entity.NewTabList()
234	pane1 := entity.NewPane("pane1")
235	pane1.URI = "https://old.com"
236	tab1 := entity.NewTab("tab1", "ws1", pane1)
237	original.Add(tab1)
238	original.SetActive("tab1")
239
240	// Create new list
241	newList := entity.NewTabList()
242	pane2 := entity.NewPane("pane2")
243	pane2.URI = "https://new1.com"
244	pane3 := entity.NewPane("pane3")
245	pane3.URI = "https://new2.com"
246	tab2 := entity.NewTab("tab2", "ws2", pane2)
247	tab3 := entity.NewTab("tab3", "ws3", pane3)
248	newList.Add(tab2)
249	newList.Add(tab3)
250	newList.SetActive("tab3")
251
252	// Keep reference to original
253	ref := original
254
255	// Replace contents
256	original.ReplaceFrom(newList)
257
258	// Verify the reference still points to the modified list
259	assert.Same(t, original, ref)
260	assert.Len(t, ref.Tabs, 2)
261	assert.Equal(t, entity.TabID("tab3"), ref.ActiveTabID)
262	assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI)
263}
264
265func TestTabList_SnapshotClonesTabsAndWorkspace(t *testing.T) {
266	parentPaneID := entity.PaneID("parent-pane")
267	pane := entity.NewPane("pane-1")
268	pane.URI = "https://original.example"
269	pane.ParentPaneID = &parentPaneID
270	tab := entity.NewTab("tab-1", "workspace-1", pane)
271	tab.Name = "Original"
272	tabs := entity.NewTabList()
273	tabs.Add(tab)
274
275	snapshot := tabs.Snapshot()
276
277	tab.Position = 99
278	tab.Name = "Mutated"
279	tab.Workspace.Root.Pane.URI = "https://mutated.example"
280	*tab.Workspace.Root.Pane.ParentPaneID = entity.PaneID("mutated-parent")
281
282	require.Len(t, snapshot.Tabs, 1)
283	clonedTab := snapshot.Tabs[0]
284	assert.NotSame(t, tab, clonedTab)
285	assert.Equal(t, 0, clonedTab.Position)
286	assert.Equal(t, "Original", clonedTab.Name)
287	require.NotNil(t, clonedTab.Workspace)
288	assert.NotSame(t, tab.Workspace, clonedTab.Workspace)
289	require.NotNil(t, clonedTab.Workspace.Root)
290	assert.NotSame(t, tab.Workspace.Root, clonedTab.Workspace.Root)
291	require.NotNil(t, clonedTab.Workspace.Root.Pane)
292	assert.NotSame(t, tab.Workspace.Root.Pane, clonedTab.Workspace.Root.Pane)
293	assert.Equal(t, "https://original.example", clonedTab.Workspace.Root.Pane.URI)
294	require.NotNil(t, clonedTab.Workspace.Root.Pane.ParentPaneID)
295	assert.Equal(t, entity.PaneID("parent-pane"), *clonedTab.Workspace.Root.Pane.ParentPaneID)
296}
297
298func TestTabList_ReplaceFromClonesSnapshotSource(t *testing.T) {
299	pane := entity.NewPane("pane-1")
300	pane.URI = "https://original.example"
301	tab := entity.NewTab("tab-1", "workspace-1", pane)
302	other := entity.NewTabList()
303	other.Add(tab)
304
305	original := entity.NewTabList()
306	original.ReplaceFrom(other)
307
308	tab.Name = "Mutated"
309	tab.Workspace.Root.Pane.URI = "https://mutated.example"
310
311	restored := original.Find("tab-1")
312	require.NotNil(t, restored)
313	assert.NotSame(t, tab, restored)
314	assert.Empty(t, restored.Name)
315	assert.Equal(t, "https://original.example", restored.Workspace.Root.Pane.URI)
316}
317
318func TestTabList_ReplaceFrom_Nil(t *testing.T) {
319	original := entity.NewTabList()
320	pane := entity.NewPane("pane1")
321	tab := entity.NewTab("tab1", "ws1", pane)
322	original.Add(tab)
323
324	original.ReplaceFrom(nil)
325
326	assert.Empty(t, original.Tabs)
327	assert.Empty(t, original.ActiveTabID)
328}
329
330func TestSnapshotRoundTrip(t *testing.T) {
331	// Create a complex tab structure
332	idGen := mockIDGenerator()
333
334	pane1 := entity.NewPane("p1")
335	pane1.URI = "https://google.com"
336	pane1.Title = "Google"
337	pane1.ZoomFactor = 1.5
338
339	pane2 := entity.NewPane("p2")
340	pane2.URI = "https://github.com"
341	pane2.Title = "GitHub"
342
343	tab1 := entity.NewTab("t1", "ws1", pane1)
344	tab1.Name = "Search"
345	tab1.IsPinned = true
346
347	tab2 := entity.NewTab("t2", "ws2", pane2)
348	tab2.Name = "Code"
349
350	original := entity.NewTabList()
351	original.Add(tab1)
352	original.Add(tab2)
353	original.SetActive("t2")
354
355	// Snapshot
356	sessionID := entity.SessionID("test_session")
357	state := entity.SnapshotFromTabList(sessionID, original)
358
359	require.NotNil(t, state)
360	assert.Equal(t, sessionID, state.SessionID)
361	assert.Len(t, state.Tabs, 2)
362	assert.Equal(t, 1, state.ActiveTabIndex) // Second tab active
363
364	// Restore
365	restored := entity.TabListFromSnapshot(state, idGen)
366
367	require.NotNil(t, restored)
368	require.Len(t, restored.Tabs, 2)
369
370	// Check first tab
371	restoredTab1 := restored.Tabs[0]
372	assert.Equal(t, "Search", restoredTab1.Name)
373	assert.True(t, restoredTab1.IsPinned)
374	require.NotNil(t, restoredTab1.Workspace.Root.Pane)
375	assert.Equal(t, "https://google.com", restoredTab1.Workspace.Root.Pane.URI)
376	assert.Equal(t, "Google", restoredTab1.Workspace.Root.Pane.Title)
377	assert.InDelta(t, 1.5, restoredTab1.Workspace.Root.Pane.ZoomFactor, 0.001)
378
379	// Check second tab
380	restoredTab2 := restored.Tabs[1]
381	assert.Equal(t, "Code", restoredTab2.Name)
382	assert.False(t, restoredTab2.IsPinned)
383	assert.Equal(t, "https://github.com", restoredTab2.Workspace.Root.Pane.URI)
384
385	// Active tab should be second
386	assert.Equal(t, restoredTab2.ID, restored.ActiveTabID)
387}
388
389func TestFindPaneAcrossTabs(t *testing.T) {
390	// Create tabs with panes
391	pane1 := entity.NewPane("pane_in_tab1")
392	pane1.URI = "https://tab1.com"
393	tab1 := entity.NewTab("t1", "ws1", pane1)
394
395	pane2 := entity.NewPane("pane_in_tab2")
396	pane2.URI = "https://tab2.com"
397	tab2 := entity.NewTab("t2", "ws2", pane2)
398
399	tabs := entity.NewTabList()
400	tabs.Add(tab1)
401	tabs.Add(tab2)
402
403	// Helper function to find pane across all tabs (mirrors App.updatePaneURIInAllTabs logic)
404	findPaneInTabs := func(tabs *entity.TabList, paneID entity.PaneID) *entity.Pane {
405		for _, tab := range tabs.Tabs {
406			if tab.Workspace == nil {
407				continue
408			}
409			paneNode := tab.Workspace.FindPane(paneID)
410			if paneNode != nil && paneNode.Pane != nil {
411				return paneNode.Pane
412			}
413		}
414		return nil
415	}
416
417	// Find pane in first tab
418	found := findPaneInTabs(tabs, "pane_in_tab1")
419	require.NotNil(t, found)
420	assert.Equal(t, "https://tab1.com", found.URI)
421
422	// Find pane in second tab
423	found = findPaneInTabs(tabs, "pane_in_tab2")
424	require.NotNil(t, found)
425	assert.Equal(t, "https://tab2.com", found.URI)
426
427	// Update URI and verify
428	found.URI = "https://updated.com"
429	foundAgain := findPaneInTabs(tabs, "pane_in_tab2")
430	assert.Equal(t, "https://updated.com", foundAgain.URI)
431
432	// Non-existent pane
433	notFound := findPaneInTabs(tabs, "non_existent")
434	assert.Nil(t, notFound)
435}
436
437// --- Window-scoped snapshot tests ---
438
439func TestSnapshotFromWindowTabLists_V2State(t *testing.T) {
440	// Create two windows, each with tabs
441	pane1_1 := entity.NewPane("p1_w1")
442	pane1_1.URI = "https://window1.com"
443	tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1_1)
444	tab1_1.Name = "Window1 Tab1"
445
446	pane1_2 := entity.NewPane("p1_w2")
447	pane1_2.URI = "https://window2.com"
448	tab1_2 := entity.NewTab("t1_w2", "ws1_w2", pane1_2)
449	tab1_2.Name = "Window2 Tab1"
450
451	pane1_2b := entity.NewPane("p2_w2")
452	pane1_2b.URI = "https://window2-tab2.com"
453	tab1_2b := entity.NewTab("t2_w2", "ws2_w2", pane1_2b)
454	tab1_2b.Name = "Window2 Tab2"
455
456	tabs1 := entity.NewTabList()
457	tabs1.Add(tab1_1)
458
459	tabs2 := entity.NewTabList()
460	tabs2.Add(tab1_2)
461	tabs2.Add(tab1_2b)
462	tabs2.SetActive("t2_w2")
463
464	windows := []entity.WindowTabListState{
465		{WindowID: "w1", Tabs: tabs1},
466		{WindowID: "w2", Tabs: tabs2},
467	}
468
469	sessionID := entity.SessionID("test_v2")
470	state := entity.SnapshotFromWindowTabLists(sessionID, windows, 1, time.Unix(123, 0))
471
472	require.NotNil(t, state)
473	assert.Equal(t, 2, state.Version)
474	assert.Equal(t, sessionID, state.SessionID)
475
476	// Windows field populated
477	require.Len(t, state.Windows, 2)
478	assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID)
479	assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID)
480
481	// Active window index set
482	assert.Equal(t, 1, state.ActiveWindowIndex)
483
484	// No legacy Tabs when using windows
485	assert.Empty(t, state.Tabs)
486
487	// CountPanes across windows
488	assert.Equal(t, 3, state.CountPanes())
489}
490
491func TestWindowTabListsFromSnapshot_V2Restore(t *testing.T) {
492	// Create a v2 snapshot from windows, then restore
493	pane := entity.NewPane("p_w1_t1")
494	pane.URI = "https://example.com"
495	tab := entity.NewTab("t_w1_t1", "ws_w1", pane)
496	tab.Name = "MyTab"
497
498	tabs := entity.NewTabList()
499	tabs.Add(tab)
500
501	windows := []entity.WindowTabListState{
502		{WindowID: "window-1", Tabs: tabs},
503	}
504
505	state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
506
507	idGen := mockIDGenerator()
508	restored := entity.WindowTabListsFromSnapshot(state, idGen)
509
510	require.Len(t, restored, 1)
511	assert.Equal(t, entity.WindowID("window-1"), restored[0].WindowID)
512	require.NotNil(t, restored[0].Tabs)
513	require.Len(t, restored[0].Tabs.Tabs, 1)
514	assert.Equal(t, "MyTab", restored[0].Tabs.Tabs[0].Name)
515	assert.Equal(t, "https://example.com", restored[0].Tabs.Tabs[0].Workspace.Root.Pane.URI)
516}
517
518func TestWindowTabListsFromSnapshot_V1LegacySingleWindow(t *testing.T) {
519	// Legacy v1 flat tabs should be restored as a single window with empty WindowID
520	state := &entity.SessionState{
521		Version:        1,
522		SessionID:      "old_sess",
523		ActiveTabIndex: 1,
524		Tabs: []entity.TabSnapshot{
525			{
526				ID:   "tab1",
527				Name: "First",
528				Workspace: entity.WorkspaceSnapshot{
529					Root: &entity.PaneNodeSnapshot{
530						Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.com"},
531					},
532				},
533			},
534			{
535				ID:   "tab2",
536				Name: "Second",
537				Workspace: entity.WorkspaceSnapshot{
538					Root: &entity.PaneNodeSnapshot{
539						Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://second.com"},
540					},
541				},
542			},
543		},
544	}
545
546	idGen := mockIDGenerator()
547	restored := entity.WindowTabListsFromSnapshot(state, idGen)
548
549	require.Len(t, restored, 1)
550	// Legacy window gets empty WindowID
551	assert.Equal(t, entity.WindowID(""), restored[0].WindowID)
552	require.NotNil(t, restored[0].Tabs)
553	require.Len(t, restored[0].Tabs.Tabs, 2)
554	assert.Equal(t, "Second", restored[0].Tabs.Tabs[1].Name)
555	// Active tab should be second (index 1)
556	assert.Equal(t, restored[0].Tabs.Tabs[1].ID, restored[0].Tabs.ActiveTabID)
557}
558
559func TestWindowTabListsFromSnapshot_NilState(t *testing.T) {
560	idGen := mockIDGenerator()
561	restored := entity.WindowTabListsFromSnapshot(nil, idGen)
562	assert.Nil(t, restored)
563}
564
565func TestWindowTabListsFromSnapshot_InvalidIndexesFallback(t *testing.T) {
566	// Tab with active tab index out of range should fall back to first tab
567	state := &entity.SessionState{
568		Version:        1,
569		ActiveTabIndex: 999, // beyond bounds
570		Tabs: []entity.TabSnapshot{
571			{
572				ID:   "tab_a",
573				Name: "TabA",
574				Workspace: entity.WorkspaceSnapshot{
575					Root: &entity.PaneNodeSnapshot{
576						Pane: &entity.PaneSnapshot{ID: "pa", URI: "https://a.com"},
577					},
578				},
579			},
580		},
581	}
582
583	idGen := mockIDGenerator()
584	restored := entity.WindowTabListsFromSnapshot(state, idGen)
585
586	require.Len(t, restored, 1)
587	require.NotNil(t, restored[0].Tabs)
588	// Should have fallen back to first tab
589	assert.Equal(t, restored[0].Tabs.Tabs[0].ID, restored[0].Tabs.ActiveTabID)
590}
591
592func TestTabListFromSnapshot_ActiveWindowWithZeroTabsFallsBackToFirstOverallTab(t *testing.T) {
593	// Regression: when the active window has zero tabs, the active tab should
594	// be the first tab overall (window 0's tab), not the third window's tab.
595	state := &entity.SessionState{
596		Version:           entity.SessionStateVersion,
597		ActiveWindowIndex: 1,
598		Windows: []entity.WindowSnapshot{
599			{ID: "w1", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
600				{Name: "First", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.example"}}}},
601			}},
602			{ID: "w2", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{}},
603			{ID: "w3", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
604				{Name: "Third", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://third.example"}}}},
605			}},
606		},
607	}
608
609	tabs := entity.TabListFromSnapshot(state, mockIDGenerator())
610
611	require.Len(t, tabs.Tabs, 2)
612	assert.Equal(t, "First", tabs.ActiveTab().Name, "active tab should be the first overall tab, not the third window's tab")
613}
614
615func TestTabListFromSnapshot_FlattensV2WindowSnapshots(t *testing.T) {
616	state := &entity.SessionState{
617		Version:           entity.SessionStateVersion,
618		ActiveWindowIndex: 1,
619		Windows: []entity.WindowSnapshot{
620			{ID: "w1", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
621				{Name: "First", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.example"}}}},
622			}},
623			{ID: "w2", ActiveTabIndex: 1, Tabs: []entity.TabSnapshot{
624				{Name: "Second", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://second.example"}}}},
625				{Name: "Active", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://active.example"}}}},
626			}},
627		},
628	}
629
630	tabs := entity.TabListFromSnapshot(state, mockIDGenerator())
631
632	require.Len(t, tabs.Tabs, 3)
633	assert.Equal(t, "Active", tabs.ActiveTab().Name)
634}
635
636func TestWindowTabListsFromSnapshot_FutureV2VersionWithEmptyWindowsStaysEmpty(t *testing.T) {
637	state := &entity.SessionState{Version: entity.SessionStateVersion + 1, Windows: []entity.WindowSnapshot{}}
638
639	restored := entity.WindowTabListsFromSnapshot(state, mockIDGenerator())
640
641	assert.Empty(t, restored)
642}
643
644func TestSnapshotFromWindowTabLists_NilTabs(t *testing.T) {
645	windows := []entity.WindowTabListState{
646		{WindowID: "w1", Tabs: nil},
647	}
648
649	state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
650
651	require.Len(t, state.Windows, 1)
652	assert.Empty(t, state.Windows[0].Tabs)
653	assert.Equal(t, 0, state.CountPanes())
654}
655
656func TestSnapshotFromWindowTabLists_EmptyWindows(t *testing.T) {
657	state := entity.SnapshotFromWindowTabLists("sess", nil, 0, time.Unix(123, 0))
658
659	require.NotNil(t, state)
660	assert.Equal(t, 2, state.Version)
661	assert.Empty(t, state.Windows)
662	assert.Equal(t, 0, state.ActiveWindowIndex)
663}
664
665func TestSessionStateCountPanesUsesVersionForWindowSnapshots(t *testing.T) {
666	state := &entity.SessionState{
667		Version: entity.SessionStateVersion,
668		Tabs: []entity.TabSnapshot{{
669			Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "legacy-pane"}}},
670		}},
671	}
672
673	assert.Equal(t, 0, state.CountPanes())
674}
675
676func TestSnapshotFromWindowTabLists_InvalidWindowIndex(t *testing.T) {
677	pane := entity.NewPane("p1")
678	pane.URI = "https://x.com"
679	tab := entity.NewTab("t1", "ws1", pane)
680	tabs := entity.NewTabList()
681	tabs.Add(tab)
682
683	windows := []entity.WindowTabListState{
684		{WindowID: "w1", Tabs: tabs},
685	}
686
687	// Active window index out of range
688	state := entity.SnapshotFromWindowTabLists("sess", windows, 999, time.Unix(123, 0))
689
690	// Should clamp to 0
691	assert.Equal(t, 0, state.ActiveWindowIndex)
692}
693
694func TestFindPaneInNestedStructure(t *testing.T) {
695	// Create a tab with split panes
696	pane1 := entity.NewPane("left_pane")
697	pane1.URI = "https://left.com"
698
699	pane2 := entity.NewPane("right_pane")
700	pane2.URI = "https://right.com"
701
702	// Build a split structure manually
703	tab := entity.NewTab("t1", "ws1", pane1)
704
705	// Add second pane as sibling (simulating a horizontal split)
706	rightNode := &entity.PaneNode{
707		ID:   "right_node",
708		Pane: pane2,
709	}
710
711	// Create container node
712	containerNode := &entity.PaneNode{
713		ID:       "container",
714		SplitDir: entity.SplitHorizontal,
715		Children: []*entity.PaneNode{
716			tab.Workspace.Root, // left pane
717			rightNode,          // right pane
718		},
719	}
720	tab.Workspace.Root = containerNode
721
722	tabs := entity.NewTabList()
723	tabs.Add(tab)
724
725	// Find left pane
726	leftNode := tab.Workspace.FindPane("left_pane")
727	require.NotNil(t, leftNode)
728	assert.Equal(t, "https://left.com", leftNode.Pane.URI)
729
730	// Find right pane
731	rightNodeFound := tab.Workspace.FindPane("right_pane")
732	require.NotNil(t, rightNodeFound)
733	assert.Equal(t, "https://right.com", rightNodeFound.Pane.URI)
734
735	// Update URI on nested pane
736	rightNodeFound.Pane.URI = "https://right-updated.com"
737
738	// Verify update persisted
739	rightNodeAgain := tab.Workspace.FindPane("right_pane")
740	assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI)
741}
742
743func TestV2SnapshotJSON_OmitEmptyLegacyFields(t *testing.T) {
744	// When using SnapshotFromWindowTabLists (v2 path), the JSON output must
745	// NOT include top-level legacy "tabs" or "active_tab_index" fields.
746	// They should be omitted by omitempty.
747	pane := entity.NewPane(entity.PaneID("p1"))
748	pane.URI = "https://example.com"
749	tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
750	tabs := entity.NewTabList()
751	tabs.Add(tab)
752
753	windows := []entity.WindowTabListState{
754		{WindowID: entity.WindowID("w1"), Tabs: tabs},
755	}
756
757	state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
758
759	data, err := json.Marshal(state)
760	require.NoError(t, err)
761
762	var raw map[string]any
763	err = json.Unmarshal(data, &raw)
764	require.NoError(t, err)
765
766	// v2 JSON must not contain top-level legacy fields.
767	_, hasTabs := raw["tabs"]
768	_, hasActiveTabIdx := raw["active_tab_index"]
769	assert.False(t, hasTabs, "v2 JSON must not include legacy 'tabs' field")
770	assert.False(t, hasActiveTabIdx, "v2 JSON must not include legacy 'active_tab_index' field")
771
772	// But must still have the v2 windows field.
773	windowsRaw, ok := raw["windows"]
774	require.True(t, ok, "v2 JSON must include 'windows' field")
775	windowsArr, _ := windowsRaw.([]any)
776	require.Len(t, windowsArr, 1)
777}
778
779func TestV2SnapshotJSON_CanStillReadV1(t *testing.T) {
780	// When reading a v1 JSON with the legacy fields, they must still be
781	// populated correctly (omitempty is only for writes).
782	v1JSON := `{
783		"version": 1,
784		"session_id": "old-sess",
785		"tabs": [{
786			"id": "tab1",
787			"name": "MyTab",
788			"position": 0,
789			"is_pinned": false,
790			"workspace": {
791				"id": "ws1",
792				"root": {
793					"id": "node1",
794					"pane": {"id": "p1", "uri": "https://example.com", "title": "", "zoom_factor": 0},
795					"split_dir": 0,
796					"split_ratio": 0,
797					"is_stacked": false,
798					"active_stack_index": 0
799				},
800				"active_pane_id": ""
801			}
802		}],
803		"active_tab_index": 0,
804		"saved_at": "2026-01-01T00:00:00Z"
805	}`
806
807	var state entity.SessionState
808	err := json.Unmarshal([]byte(v1JSON), &state)
809	require.NoError(t, err)
810
811	assert.Equal(t, 1, state.Version)
812	assert.Equal(t, entity.SessionID("old-sess"), state.SessionID)
813	require.Len(t, state.Tabs, 1)
814	assert.Equal(t, entity.TabID("tab1"), state.Tabs[0].ID)
815	assert.Equal(t, "MyTab", state.Tabs[0].Name)
816	assert.Equal(t, 0, state.ActiveTabIndex)
817}

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