v0.29.0 21b3150

browser_window_test.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"errors"
6	"reflect"
7	"testing"
8	"unsafe"
9
10	"github.com/bnema/dumber/internal/application/port"
11	"github.com/bnema/dumber/internal/domain/entity"
12	"github.com/bnema/dumber/internal/ui/component"
13	"github.com/bnema/dumber/internal/ui/focus"
14	"github.com/bnema/dumber/internal/ui/layout"
15	"github.com/bnema/dumber/internal/ui/window"
16	"github.com/bnema/puregotk/v4/gtk"
17	"github.com/stretchr/testify/assert"
18	"github.com/stretchr/testify/require"
19)
20
21func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
22	mainWindow := &window.MainWindow{}
23	removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
24	remaining := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
25	app := &App{
26		mainWindow:          mainWindow,
27		browserWindows:      map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
28		lastFocusedWindowID: remaining.id,
29	}
30
31	setShellField(t, removed, "appToaster", &component.Toaster{})
32	setShellField(t, removed, "modeToaster", &component.Toaster{})
33	setShellField(t, removed, "borderMgr", &focus.BorderManager{})
34	setShellField(t, removed, "sessionManager", &component.SessionManager{})
35	setShellField(t, removed, "tabPicker", &component.TabPicker{})
36	setShellField(t, removed, "tabPickerWidget", (*testLayoutWidget)(nil))
37	setShellField(t, removed, "tabPickerPaneID", entity.PaneID("pane-1"))
38	setShellField(t, removed, "insertAccentUC", newTestAccentUseCase(t, false))
39	setShellField(t, removed, "accentPicker", &component.AccentPicker{})
40	setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil))
41	setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{})
42
43	app.removeBrowserWindow(removed.id)
44
45	for _, name := range []string{
46		"appToaster",
47		"modeToaster",
48		"borderMgr",
49		"sessionManager",
50		"tabPicker",
51		"tabPickerWidget",
52		"tabPickerPaneID",
53		"insertAccentUC",
54		"accentPicker",
55		"keyboardHandler",
56		"globalShortcutHandler",
57		"permissionDialog",
58		"webrtcIndicator",
59	} {
60		if !fieldIsZero(t, removed, name) {
61			t.Fatalf("browserWindow.%s was not cleared", name)
62		}
63	}
64}
65
66func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
67	ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
68	otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
69	removedMainWindow := &window.MainWindow{}
70	remainingMainWindow := &window.MainWindow{}
71	removedTabs := entity.NewTabList()
72	removedTabs.Add(ownedTab)
73	remainingTabs := entity.NewTabList()
74	remainingTabs.Add(otherTab)
75	removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow, tabs: removedTabs}
76	remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow, tabs: remainingTabs}
77	ownedSessionKey := floatingSessionKey{tabID: ownedTab.ID, sessionID: "profile:owned"}
78	otherSessionKey := floatingSessionKey{tabID: otherTab.ID, sessionID: "profile:other"}
79	app := &App{
80		mainWindow:     removedMainWindow,
81		browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
82		tabs:           entity.NewTabList(),
83		workspaceViews: map[entity.TabID]*component.WorkspaceView{ownedTab.ID: &component.WorkspaceView{}, otherTab.ID: &component.WorkspaceView{}},
84		windowForTab:   map[entity.TabID]*browserWindow{ownedTab.ID: removed, otherTab.ID: remaining},
85		floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
86			ownedSessionKey: {},
87			otherSessionKey: {},
88		},
89	}
90	app.tabs.Add(ownedTab)
91	app.tabs.Add(otherTab)
92
93	app.removeBrowserWindow(removed.id)
94
95	if app.workspaceViews[ownedTab.ID] != nil {
96		t.Fatalf("owned workspace view was not removed")
97	}
98	if app.windowForTab[ownedTab.ID] != nil {
99		t.Fatalf("owned window mapping was not removed")
100	}
101	if app.tabs.Find(ownedTab.ID) != nil {
102		t.Fatalf("owned tab was not removed")
103	}
104	if app.workspaceViews[otherTab.ID] == nil {
105		t.Fatalf("other workspace view should remain")
106	}
107	if app.windowForTab[otherTab.ID] != remaining {
108		t.Fatalf("other tab should remain mapped to remaining window")
109	}
110	if app.tabs.Find(otherTab.ID) == nil {
111		t.Fatalf("other tab should remain")
112	}
113	if _, ok := app.floatingSessions[ownedSessionKey]; ok {
114		t.Fatalf("owned floating session was not released")
115	}
116	if _, ok := app.floatingSessions[otherSessionKey]; !ok {
117		t.Fatalf("other floating session should remain")
118	}
119	assertWindowOwnershipInvariant(t, app)
120}
121
122func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
123	app := &App{browserWindows: make(map[string]*browserWindow)}
124	first := &browserWindow{id: "window-1"}
125	second := &browserWindow{id: "window-2"}
126
127	app.registerBrowserWindow(first)
128	app.registerBrowserWindow(second)
129
130	if got := len(app.browserWindows); got != 2 {
131		t.Fatalf("browserWindows length = %d, want 2", got)
132	}
133	if _, ok := app.browserWindows[first.id]; !ok {
134		t.Fatalf("first browser window was not registered")
135	}
136	if _, ok := app.browserWindows[second.id]; !ok {
137		t.Fatalf("second browser window was not registered")
138	}
139	if first.tabs == nil || second.tabs == nil {
140		t.Fatalf("registered browser windows must have non-nil tab lists")
141	}
142
143	app.removeBrowserWindow(first.id)
144
145	if _, ok := app.browserWindows[first.id]; ok {
146		t.Fatalf("first browser window was not removed")
147	}
148	if _, ok := app.browserWindows[second.id]; !ok {
149		t.Fatalf("second browser window should remain registered")
150	}
151}
152
153func TestBrowserWindow_RemovePromotesRemainingMainWindow(t *testing.T) {
154	mainWindow := &window.MainWindow{}
155	otherWindow := &window.MainWindow{}
156	first := &browserWindow{id: "window-1", mainWindow: mainWindow}
157	second := &browserWindow{id: "window-2", mainWindow: otherWindow}
158	app := &App{
159		mainWindow:     mainWindow,
160		browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
161	}
162
163	app.removeBrowserWindow(first.id)
164
165	if app.mainWindow != otherWindow {
166		t.Fatalf("mainWindow = %p, want promoted window %p", app.mainWindow, otherWindow)
167	}
168	if len(app.browserWindows) != 1 {
169		t.Fatalf("browserWindows length = %d, want 1", len(app.browserWindows))
170	}
171	if _, ok := app.browserWindows[second.id]; !ok {
172		t.Fatalf("remaining browser window was not kept")
173	}
174}
175
176func TestBrowserWindow_RemoveLastClearsMainWindow(t *testing.T) {
177	mainWindow := &window.MainWindow{}
178	first := &browserWindow{id: "window-1", mainWindow: mainWindow}
179	app := &App{
180		mainWindow:     mainWindow,
181		browserWindows: map[string]*browserWindow{first.id: first},
182	}
183
184	app.removeBrowserWindow(first.id)
185
186	if app.mainWindow != nil {
187		t.Fatalf("mainWindow = %p, want nil", app.mainWindow)
188	}
189	if len(app.browserWindows) != 0 {
190		t.Fatalf("browserWindows length = %d, want 0", len(app.browserWindows))
191	}
192}
193
194func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testing.T) {
195	mainWindow := &window.MainWindow{}
196	otherWindow := &window.MainWindow{}
197	removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
198	remaining := &browserWindow{id: "window-2", mainWindow: otherWindow}
199	resizeTarget := &testLayoutWidget{}
200	app := &App{
201		mainWindow:             mainWindow,
202		browserWindows:         map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
203		resizeModeBorderTarget: resizeTarget,
204	}
205
206	app.removeBrowserWindow(removed.id)
207
208	if app.resizeModeBorderTarget != nil {
209		t.Fatalf("resizeModeBorderTarget = %p, want nil", app.resizeModeBorderTarget)
210	}
211}
212
213func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
214	app := &App{browserWindows: make(map[string]*browserWindow)}
215
216	dispatched := false
217	createdURL := ""
218	app.dispatchOnMainThread = func(fn func()) {
219		dispatched = true
220		fn()
221	}
222	app.browserWindowFactory = func(ctx context.Context, url string) (*browserWindow, error) {
223		createdURL = url
224		return &browserWindow{id: "window-1", initialURL: url}, nil
225	}
226
227	if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
228		t.Fatalf("OpenFreshWindow returned error: %v", err)
229	}
230	if !dispatched {
231		t.Fatalf("OpenFreshWindow did not use the main-thread dispatcher")
232	}
233	if createdURL != "https://example.com" {
234		t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com")
235	}
236	if got := len(app.browserWindows); got != 1 {
237		t.Fatalf("browserWindows length = %d, want 1", got)
238	}
239	if got := app.browserWindows["window-1"]; got == nil {
240		t.Fatalf("browser window was not tracked")
241	}
242	if app.browserWindows["window-1"].initialURL != "https://example.com" {
243		t.Fatalf("browser window initialURL = %q, want %q", app.browserWindows["window-1"].initialURL, "https://example.com")
244	}
245}
246
247func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
248	app := &App{browserWindows: make(map[string]*browserWindow)}
249	app.dispatchOnMainThread = func(fn func()) { fn() }
250	wantErr := errors.New("factory error")
251	app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
252		return nil, wantErr
253	}
254
255	err := app.OpenFreshWindow(context.Background(), "https://example.com")
256
257	if !errors.Is(err, wantErr) {
258		t.Fatalf("expected error %v, got %v", wantErr, err)
259	}
260	if got := len(app.browserWindows); got != 0 {
261		t.Fatalf("browserWindows length = %d, want 0", got)
262	}
263}
264
265func setShellField(t *testing.T, bw *browserWindow, name string, value any) {
266	t.Helper()
267	rv := reflect.ValueOf(bw).Elem()
268	field := rv.FieldByName(name)
269	if !field.IsValid() {
270		t.Fatalf("browserWindow missing field %s", name)
271	}
272	reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
273}
274
275func fieldIsZero(t *testing.T, bw *browserWindow, name string) bool {
276	t.Helper()
277	rv := reflect.ValueOf(bw).Elem()
278	field := rv.FieldByName(name)
279	if !field.IsValid() {
280		t.Fatalf("browserWindow missing field %s", name)
281	}
282	return field.IsZero()
283}
284
285type testLayoutWidget struct{}
286
287func (*testLayoutWidget) Show()                   {}
288func (*testLayoutWidget) Hide()                   {}
289func (*testLayoutWidget) SetVisible(bool)         {}
290func (*testLayoutWidget) IsVisible() bool         { return false }
291func (*testLayoutWidget) SetOpacity(float64)      {}
292func (*testLayoutWidget) GrabFocus() bool         { return false }
293func (*testLayoutWidget) HasFocus() bool          { return false }
294func (*testLayoutWidget) SetCanFocus(bool)        {}
295func (*testLayoutWidget) SetFocusable(bool)       {}
296func (*testLayoutWidget) SetFocusOnClick(bool)    {}
297func (*testLayoutWidget) SetCanTarget(bool)       {}
298func (*testLayoutWidget) SetHexpand(bool)         {}
299func (*testLayoutWidget) SetVexpand(bool)         {}
300func (*testLayoutWidget) GetHexpand() bool        { return false }
301func (*testLayoutWidget) GetVexpand() bool        { return false }
302func (*testLayoutWidget) SetHalign(gtk.Align)     {}
303func (*testLayoutWidget) SetValign(gtk.Align)     {}
304func (*testLayoutWidget) SetSizeRequest(int, int) {}
305func (*testLayoutWidget) GetAllocatedWidth() int  { return 0 }
306func (*testLayoutWidget) GetAllocatedHeight() int { return 0 }
307func (*testLayoutWidget) ComputePoint(layout.Widget) (float64, float64, bool) {
308	return 0, 0, false
309}
310func (*testLayoutWidget) AddCssClass(string)                 {}
311func (*testLayoutWidget) RemoveCssClass(string)              {}
312func (*testLayoutWidget) HasCssClass(string) bool            { return false }
313func (*testLayoutWidget) Unparent()                          {}
314func (*testLayoutWidget) GetParent() layout.Widget           { return nil }
315func (*testLayoutWidget) GtkWidget() *gtk.Widget             { return nil }
316func (*testLayoutWidget) AddController(*gtk.EventController) {}
317
318type testPermissionDialogPresenter struct{}
319
320func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {
321}
322
323// --- Window tab list tests ---
324
325func TestGetWindowTabLists_RespectsRegistrationOrder(t *testing.T) {
326	// Register windows in non-lexicographic order: window-2 first, then window-1.
327	// The result should preserve creation order, not sorted order.
328	app := &App{
329		browserWindows: make(map[string]*browserWindow),
330	}
331
332	first := &browserWindow{id: "window-2"}
333	second := &browserWindow{id: "window-1"}
334	app.registerBrowserWindow(first)
335	app.registerBrowserWindow(second)
336
337	// Give each window a tab so they show up in results.
338	pane1 := entity.NewPane(entity.PaneID("p-w2"))
339	tab1 := entity.NewTab(entity.TabID("t-w2"), entity.WorkspaceID("ws-w2"), pane1)
340	pane2 := entity.NewPane(entity.PaneID("p-w1"))
341	tab2 := entity.NewTab(entity.TabID("t-w1"), entity.WorkspaceID("ws-w1"), pane2)
342
343	app.tabs = entity.NewTabList()
344	app.tabs.Add(tab1)
345	app.tabs.Add(tab2)
346	first.tabs.Add(tab1)
347	second.tabs.Add(tab2)
348	app.windowForTab = map[entity.TabID]*browserWindow{
349		tab1.ID: first,
350		tab2.ID: second,
351	}
352
353	result := app.GetWindowTabLists()
354
355	require.Len(t, result, 2)
356	assert.Equal(t, entity.WindowID("window-2"), result[0].WindowID, "first registered window should be first")
357	assert.Equal(t, entity.WindowID("window-1"), result[1].WindowID, "second registered window should be second")
358}
359
360func TestGetWindowTabLists_NoBrowserWindowsReturnsGlobalSnapshot(t *testing.T) {
361	pane := entity.NewPane(entity.PaneID("p1"))
362	tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
363	tabs := entity.NewTabList()
364	tabs.Add(tab)
365
366	app := &App{tabs: tabs}
367
368	result := app.GetWindowTabLists()
369
370	require.Len(t, result, 1)
371	assert.Equal(t, entity.WindowID(""), result[0].WindowID)
372	// Snapshot returns a copy so pointer identity is not expected; verify
373	// structural equality instead.
374	assert.Equal(t, tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
375	assert.Len(t, result[0].Tabs.Tabs, len(tabs.Tabs))
376}
377
378func TestGetWindowTabLists_AppliesActiveTabIndex(t *testing.T) {
379	// A window with its own TabList where the second tab is active.
380	app := &App{
381		browserWindows: make(map[string]*browserWindow),
382	}
383
384	pane1 := entity.NewPane(entity.PaneID("p1"))
385	tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
386	pane2 := entity.NewPane(entity.PaneID("p2"))
387	tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
388
389	app.tabs = entity.NewTabList()
390	app.tabs.Add(tab1)
391	app.tabs.Add(tab2)
392
393	bwTabs := entity.NewTabList()
394	bwTabs.Add(tab1)
395	bwTabs.Add(tab2)
396	bwTabs.SetActive(entity.TabID("t2"))
397	bw := &browserWindow{id: "w1", tabs: bwTabs}
398	app.registerBrowserWindow(bw)
399	app.windowForTab = map[entity.TabID]*browserWindow{
400		tab1.ID: bw,
401		tab2.ID: bw,
402	}
403
404	result := app.GetWindowTabLists()
405
406	require.Len(t, result, 1)
407	assert.Equal(t, entity.TabID("t2"), result[0].Tabs.ActiveTabID, "should reflect bw.tabs.ActiveTabID")
408}
409
410func TestGetWindowTabLists_PreservesPreviousActiveTabID(t *testing.T) {
411	app := &App{
412		browserWindows: make(map[string]*browserWindow),
413	}
414
415	pane1 := entity.NewPane(entity.PaneID("p1"))
416	tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
417	pane2 := entity.NewPane(entity.PaneID("p2"))
418	tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
419
420	app.tabs = entity.NewTabList()
421	app.tabs.Add(tab1)
422	app.tabs.Add(tab2)
423
424	bwTabs := entity.NewTabList()
425	bwTabs.Add(tab1)
426	bwTabs.Add(tab2)
427	bwTabs.SetActive(entity.TabID("t2")) // t2 becomes active, t1 becomes previous
428
429	bw := &browserWindow{id: "w1", tabs: bwTabs}
430	app.registerBrowserWindow(bw)
431	app.windowForTab = map[entity.TabID]*browserWindow{
432		tab1.ID: bw,
433		tab2.ID: bw,
434	}
435
436	result := app.GetWindowTabLists()
437
438	require.Len(t, result, 1)
439	assert.Equal(t, entity.TabID("t1"), result[0].Tabs.PreviousActiveTabID, "should preserve bw.tabs.PreviousActiveTabID")
440}
441
442func TestGetActiveWindowIndex_MatchesRegistrationOrder(t *testing.T) {
443	// Register windows in non-lexicographic order; lastFocusedWindowID should
444	// map to the correct index in registration order.
445	app := &App{
446		browserWindows: make(map[string]*browserWindow),
447	}
448
449	app.registerBrowserWindow(&browserWindow{id: "z-window"})
450	app.registerBrowserWindow(&browserWindow{id: "a-window"})
451	app.lastFocusedWindowID = "a-window"
452
453	idx := app.GetActiveWindowIndex()
454	assert.Equal(t, 1, idx, "a-window is the second registered window, index should be 1")
455}
456
457func TestGetActiveWindowIndex_MatchesGetWindowTabLists(t *testing.T) {
458	// The order of windows in GetWindowTabLists must match the indices returned
459	// by GetActiveWindowIndex.
460	app := &App{
461		browserWindows: make(map[string]*browserWindow),
462	}
463
464	w1 := &browserWindow{id: "w1"}
465	w2 := &browserWindow{id: "w2"}
466	w3 := &browserWindow{id: "w3"}
467	app.registerBrowserWindow(w1)
468	app.registerBrowserWindow(w2)
469	app.registerBrowserWindow(w3)
470
471	// Give each window a tab.
472	p1 := entity.NewPane(entity.PaneID("p1"))
473	t1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), p1)
474	p2 := entity.NewPane(entity.PaneID("p2"))
475	t2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), p2)
476	p3 := entity.NewPane(entity.PaneID("p3"))
477	t3 := entity.NewTab(entity.TabID("t3"), entity.WorkspaceID("ws3"), p3)
478	app.tabs = entity.NewTabList()
479	app.tabs.Add(t1)
480	app.tabs.Add(t2)
481	app.tabs.Add(t3)
482	w1.tabs.Add(t1)
483	w2.tabs.Add(t2)
484	w3.tabs.Add(t3)
485	app.windowForTab = map[entity.TabID]*browserWindow{
486		t1.ID: w1,
487		t2.ID: w2,
488		t3.ID: w3,
489	}
490
491	app.lastFocusedWindowID = "w2"
492
493	lists := app.GetWindowTabLists()
494	idx := app.GetActiveWindowIndex()
495
496	require.Len(t, lists, 3)
497	assert.Equal(t, entity.WindowID("w2"), lists[idx].WindowID, "active window ID must match the lists index")
498}
499
500func TestWindowOrder_FallbackWhenRegistrationOrderEmpty(t *testing.T) {
501	// When browserWindowOrder is empty (e.g., old tests / direct map manipulation),
502	// windowOrder must fall back to a deterministic (sorted) order.
503	app := &App{
504		browserWindows: map[string]*browserWindow{
505			"z-window": {id: "z-window"},
506			"a-window": {id: "a-window"},
507		},
508	}
509
510	order := app.windowOrder()
511
512	require.Len(t, order, 2)
513	// Sorted order: "a-window" < "z-window"
514	assert.Equal(t, "a-window", order[0])
515	assert.Equal(t, "z-window", order[1])
516}
517
518func TestRemoveBrowserWindow_RemovesFromOrder(t *testing.T) {
519	app := &App{
520		browserWindows: make(map[string]*browserWindow),
521	}
522
523	w1 := &browserWindow{id: "w1"}
524	w2 := &browserWindow{id: "w2"}
525	w3 := &browserWindow{id: "w3"}
526	app.registerBrowserWindow(w1)
527	app.registerBrowserWindow(w2)
528	app.registerBrowserWindow(w3)
529
530	app.removeBrowserWindow("w2")
531
532	require.Len(t, app.browserWindowOrder, 2)
533	assert.Equal(t, "w1", app.browserWindowOrder[0])
534	assert.Equal(t, "w3", app.browserWindowOrder[1])
535}
536
537func TestRegisterBrowserWindow_DuplicateIgnores(t *testing.T) {
538	app := &App{
539		browserWindows: make(map[string]*browserWindow),
540	}
541
542	bw := &browserWindow{id: "w1"}
543	app.registerBrowserWindow(bw)
544	app.registerBrowserWindow(bw) // duplicate
545
546	require.Len(t, app.browserWindowOrder, 1)
547}
548
549func TestApp_GetWindowTabListsUsesCreationOrderAndActiveIndex(t *testing.T) {
550	// Create two windows with per-window bw.tabs as the real source of truth.
551	// Ensure GetWindowTabLists returns them in registration order, with correct
552	// WindowID, and that the returned Tabs pointers are the per-window bw.tabs
553	// (not copies or derived from global App.tabs).
554	firstTabs := entity.NewTabList()
555	firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
556	firstTabs.Add(firstTab)
557	firstTabs.SetActive(entity.TabID("tab-1"))
558
559	secondTabs := entity.NewTabList()
560	secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
561	secondTabs.Add(secondTab)
562
563	first := &browserWindow{id: "window-1", tabs: firstTabs}
564	second := &browserWindow{id: "window-2", tabs: secondTabs}
565
566	app := &App{
567		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
568		browserWindowOrder:  []string{"window-1", "window-2"},
569		lastFocusedWindowID: "window-2",
570	}
571
572	result := app.GetWindowTabLists()
573
574	require.Len(t, result, 2)
575	assert.Equal(t, entity.WindowID("window-1"), result[0].WindowID)
576	assert.Equal(t, entity.WindowID("window-2"), result[1].WindowID)
577
578	// The returned Tabs are snapshot copies of per-window bw.tabs.
579	// Verify structural equality (not pointer identity, since
580	// GetWindowTabLists returns a snapshot for safe concurrent read).
581	assert.Equal(t, firstTabs.ActiveTabID, result[0].Tabs.ActiveTabID)
582	assert.Len(t, result[0].Tabs.Tabs, len(firstTabs.Tabs))
583	assert.Equal(t, secondTabs.ActiveTabID, result[1].Tabs.ActiveTabID)
584	assert.Len(t, result[1].Tabs.Tabs, len(secondTabs.Tabs))
585
586	// Active state reflects per-window bw.tabs.
587	assert.Equal(t, entity.TabID("tab-1"), result[0].Tabs.ActiveTabID)
588	assert.Equal(t, entity.TabID("tab-2"), result[1].Tabs.ActiveTabID)
589
590	// GetActiveWindowIndex respects lastFocusedWindowID mapped to registration order.
591	idx := app.GetActiveWindowIndex()
592	assert.Equal(t, 1, idx, "window-2 is second registered, index should be 1")
593}
594
595func TestApp_AssignRestoredWindowTabListsUsesFreshRuntimeWindowIDs(t *testing.T) {
596	// Create two restored TabLists with distinct tabs.
597	firstTabs := entity.NewTabList()
598	firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
599	firstTabs.Add(firstTab)
600	firstTabs.SetActive(entity.TabID("tab-1"))
601
602	secondTabs := entity.NewTabList()
603	secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
604	secondTabs.Add(secondTab)
605
606	// Create two runtime browser windows with fresh IDs distinct from saved IDs.
607	firstBW := &browserWindow{id: "runtime-w1"}
608	secondBW := &browserWindow{id: "runtime-w2"}
609
610	app := &App{
611		browserWindows: map[string]*browserWindow{"runtime-w1": firstBW, "runtime-w2": secondBW},
612	}
613
614	// Assign restored state using saved window IDs (simulating session restore).
615	app.assignRestoredWindowTabList(firstBW, entity.WindowTabListState{
616		WindowID: "saved-w1",
617		Tabs:     firstTabs,
618	})
619	app.assignRestoredWindowTabList(secondBW, entity.WindowTabListState{
620		WindowID: "saved-w2",
621		Tabs:     secondTabs,
622	})
623
624	// Tabs must be owned by runtime windows, not saved.
625	require.Same(t, firstTabs, firstBW.tabs, "first window tabs must be assigned")
626	require.Same(t, secondTabs, secondBW.tabs, "second window tabs must be assigned")
627
628	// windowForTab must map tabs to runtime windows.
629	require.Equal(t, firstBW, app.browserWindowForTab(firstTab.ID), "tab-1 must be owned by runtime-w1")
630	require.Equal(t, secondBW, app.browserWindowForTab(secondTab.ID), "tab-2 must be owned by runtime-w2")
631
632	// Saved window IDs must never leak into browserWindows.
633	require.Nil(t, app.browserWindows["saved-w1"], "saved-w1 must not appear in browserWindows")
634	require.Nil(t, app.browserWindows["saved-w2"], "saved-w2 must not appear in browserWindows")
635	assertWindowOwnershipInvariant(t, app)
636}
637
638func TestApp_BrowserWindowsOwnIndependentTabLists(t *testing.T) {
639	// Construct first browser window with its own TabList and distinct tabs.
640	firstTabs := entity.NewTabList()
641	firstTab := entity.NewTab(entity.TabID("tab-first"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
642	firstTabs.Add(firstTab)
643
644	first := &browserWindow{
645		id:   "window-first",
646		tabs: firstTabs,
647	}
648
649	// Construct second browser window with its own TabList and distinct tabs.
650	secondTabs := entity.NewTabList()
651	secondTab := entity.NewTab(entity.TabID("tab-second"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
652	secondTabs.Add(secondTab)
653
654	second := &browserWindow{
655		id:   "window-second",
656		tabs: secondTabs,
657	}
658
659	app := &App{
660		browserWindows: map[string]*browserWindow{
661			first.id:  first,
662			second.id: second,
663		},
664	}
665
666	// tabListForBrowserWindow returns the correct TabList for each window.
667	got := app.tabListForBrowserWindow(first)
668	if got != firstTabs {
669		t.Fatalf("tabListForBrowserWindow(first) = %p, want %p", got, firstTabs)
670	}
671	got = app.tabListForBrowserWindow(second)
672	if got != secondTabs {
673		t.Fatalf("tabListForBrowserWindow(second) = %p, want %p", got, secondTabs)
674	}
675
676	// Each window's tabs do not contain the other window's tabs.
677	if firstTabs.Find(secondTab.ID) != nil {
678		t.Fatalf("first window tabs unexpectedly contain second tab")
679	}
680	if secondTabs.Find(firstTab.ID) != nil {
681		t.Fatalf("second window tabs unexpectedly contain first tab")
682	}
683
684	// activeTabForBrowserWindow returns the correct active tab for each window.
685	gotTab := app.activeTabForBrowserWindow(first)
686	if gotTab != firstTab {
687		t.Fatalf("activeTabForBrowserWindow(first) = %p, want %p", gotTab, firstTab)
688	}
689	gotTab = app.activeTabForBrowserWindow(second)
690	if gotTab != secondTab {
691		t.Fatalf("activeTabForBrowserWindow(second) = %p, want %p", gotTab, secondTab)
692	}
693
694	app.windowForTab = map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second}
695	assertWindowOwnershipInvariant(t, app)
696}
697
698func TestRestoreSession_RemovesStaleBrowserWindows(t *testing.T) {
699	// After restore, only runtime windows should remain in browserWindows
700	// and browserWindowOrder. Stale pre-existing windows must be removed.
701
702	// Runtime windows with tabs (what restoreSession produces).
703	runtime1Tabs := entity.NewTabList()
704	r1Tab := entity.NewTab(entity.TabID("r1-t1"), entity.WorkspaceID("ws-r1"), entity.NewPane(entity.PaneID("p-r1")))
705	runtime1Tabs.Add(r1Tab)
706	runtime1Tabs.SetActive(entity.TabID("r1-t1"))
707
708	runtime2Tabs := entity.NewTabList()
709	r2Tab := entity.NewTab(entity.TabID("r2-t1"), entity.WorkspaceID("ws-r2"), entity.NewPane(entity.PaneID("p-r2")))
710	runtime2Tabs.Add(r2Tab)
711	runtime2Tabs.SetActive(entity.TabID("r2-t1"))
712
713	runtime1 := &browserWindow{id: "runtime-w1", tabs: runtime1Tabs}
714	runtime2 := &browserWindow{id: "runtime-w2", tabs: runtime2Tabs}
715
716	// Stale pre-existing browser window (no longer in runtime set).
717	staleTabs := entity.NewTabList()
718	staleTab := entity.NewTab(entity.TabID("stale-t1"), entity.WorkspaceID("ws-stale"), entity.NewPane(entity.PaneID("p-stale")))
719	staleTabs.Add(staleTab)
720	stale := &browserWindow{id: "stale-window", tabs: staleTabs}
721
722	app := &App{
723		browserWindows: map[string]*browserWindow{
724			"stale-window": stale,
725			"runtime-w1":   runtime1,
726			"runtime-w2":   runtime2,
727		},
728		tabs: entity.NewTabList(),
729		windowForTab: map[entity.TabID]*browserWindow{
730			staleTab.ID: stale,
731			r1Tab.ID:    runtime1,
732			r2Tab.ID:    runtime2,
733		},
734	}
735
736	// Simulate post-restore cleanup: remove stale windows not in runtime set.
737	runtimeIDs := []string{"runtime-w1", "runtime-w2"}
738	app.pruneStaleBrowserWindows([]*browserWindow{runtime1, runtime2})
739
740	// After cleanup, browserWindows contains only runtime windows.
741	require.Len(t, app.browserWindows, 2)
742	require.NotNil(t, app.browserWindows["runtime-w1"])
743	require.NotNil(t, app.browserWindows["runtime-w2"])
744	require.Nil(t, app.browserWindows["stale-window"])
745
746	// browserWindowOrder matches runtime order.
747	require.Equal(t, runtimeIDs, app.browserWindowOrder)
748
749	// GetWindowTabLists returns only runtime windows.
750	result := app.GetWindowTabLists()
751	require.Len(t, result, 2)
752	assert.Equal(t, entity.WindowID("runtime-w1"), result[0].WindowID)
753	assert.Equal(t, entity.WindowID("runtime-w2"), result[1].WindowID)
754
755	// No stale tabs leak into the result.
756	// Verify structural equality (snapshot copies, not live pointers).
757	assert.Equal(t, runtime1Tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
758	assert.Len(t, result[0].Tabs.Tabs, len(runtime1Tabs.Tabs))
759	assert.Equal(t, runtime2Tabs.ActiveTabID, result[1].Tabs.ActiveTabID)
760	assert.Len(t, result[1].Tabs.Tabs, len(runtime2Tabs.Tabs))
761	assertWindowOwnershipInvariant(t, app)
762}
763
764func TestRestoreSession_BrowserWindowOrderStartsWithFirstReusedWindow(t *testing.T) {
765	// When the first restored window reuses a browser window that was not
766	// already in browserWindowOrder, the restored order must start with
767	// the reused window ID followed by newly-created window IDs.
768
769	reuseTabs := entity.NewTabList()
770	reuseTab := entity.NewTab(entity.TabID("reuse-t1"), entity.WorkspaceID("ws-reuse"), entity.NewPane(entity.PaneID("p-reuse")))
771	reuseTabs.Add(reuseTab)
772	reuseBW := &browserWindow{id: "reused-window", tabs: reuseTabs}
773
774	createdTabs := entity.NewTabList()
775	createdTab := entity.NewTab(entity.TabID("created-t1"), entity.WorkspaceID("ws-created"), entity.NewPane(entity.PaneID("p-created")))
776	createdTabs.Add(createdTab)
777	createdBW := &browserWindow{id: "created-window", tabs: createdTabs}
778
779	// Pre-existing browserWindowOrder does NOT contain the reused window.
780	app := &App{
781		browserWindows: map[string]*browserWindow{
782			"reused-window":  reuseBW,
783			"created-window": createdBW,
784		},
785		browserWindowOrder: []string{"old-stale-window"}, // reused window not present
786		tabs:               entity.NewTabList(),
787		windowForTab:       map[entity.TabID]*browserWindow{},
788	}
789
790	// Exercise the helper used by restoreSession to prune stale windows and
791	// replace browserWindowOrder with the runtime restore order.
792	runtimeWindows := []*browserWindow{reuseBW, createdBW}
793	app.pruneStaleBrowserWindows(runtimeWindows)
794
795	require.Len(t, app.browserWindowOrder, 2)
796	assert.Equal(t, "reused-window", app.browserWindowOrder[0],
797		"first reused window must be first in order")
798	assert.Equal(t, "created-window", app.browserWindowOrder[1],
799		"newly-created window must follow the reused window")
800
801	// GetWindowTabLists respects the order.
802	result := app.GetWindowTabLists()
803	require.Len(t, result, 2)
804	assert.Equal(t, entity.WindowID("reused-window"), result[0].WindowID)
805	assert.Equal(t, entity.WindowID("created-window"), result[1].WindowID)
806}
807
808func TestRestoreSession_ActiveWindowIndexSyncsState(t *testing.T) {
809	// After restore, GetActiveWindowIndex must return the restored active index.
810	// The focused window state must be consistent: lastFocusedWindowID matches
811	// the window at that index, and the active window's tab target aligns.
812
813	runtime1Tabs := entity.NewTabList()
814	r1Tab := entity.NewTab(entity.TabID("a1-t1"), entity.WorkspaceID("ws-a1"), entity.NewPane(entity.PaneID("p-a1")))
815	runtime1Tabs.Add(r1Tab)
816	runtime1Tabs.SetActive(entity.TabID("a1-t1"))
817
818	runtime2Tabs := entity.NewTabList()
819	r2Tab := entity.NewTab(entity.TabID("a2-t1"), entity.WorkspaceID("ws-a2"), entity.NewPane(entity.PaneID("p-a2")))
820	runtime2Tabs.Add(r2Tab)
821	runtime2Tabs.SetActive(entity.TabID("a2-t1"))
822
823	runtime1 := &browserWindow{id: "active-w1", tabs: runtime1Tabs}
824	runtime2 := &browserWindow{id: "active-w2", tabs: runtime2Tabs}
825
826	app := &App{
827		browserWindows: map[string]*browserWindow{
828			"active-w1": runtime1,
829			"active-w2": runtime2,
830		},
831		browserWindowOrder:  []string{"active-w1", "active-w2"},
832		lastFocusedWindowID: "active-w2",
833		tabs:                entity.NewTabList(),
834		windowForTab:        map[entity.TabID]*browserWindow{},
835	}
836
837	// Active index should be 1 (second window in order).
838	idx := app.GetActiveWindowIndex()
839	assert.Equal(t, 1, idx)
840
841	// The focused window is the one at that index.
842	focused := app.lastFocusedBrowserWindow()
843	require.NotNil(t, focused)
844	assert.Equal(t, "active-w2", focused.id)
845
846	// Verify that GetActiveWindowIndex and GetWindowTabLists are consistent:
847	// the window at the active index matches the focused window.
848	result := app.GetWindowTabLists()
849	require.Len(t, result, 2)
850	assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID,
851		"window at active index must match focused window ID")
852}

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