v0.30.0 eb3068f

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/application/usecase"
12	"github.com/bnema/dumber/internal/domain/entity"
13	"github.com/bnema/dumber/internal/infrastructure/config"
14	"github.com/bnema/dumber/internal/shared/syncdispatch"
15	"github.com/bnema/dumber/internal/ui/component"
16	contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
17	"github.com/bnema/dumber/internal/ui/focus"
18	"github.com/bnema/dumber/internal/ui/input"
19	"github.com/bnema/dumber/internal/ui/layout"
20	"github.com/bnema/dumber/internal/ui/window"
21	"github.com/bnema/puregotk/v4/gtk"
22	"github.com/stretchr/testify/assert"
23	"github.com/stretchr/testify/require"
24)
25
26func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
27	mainWindow := &window.MainWindow{}
28	removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
29	remaining := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
30	app := &App{
31		mainWindow:          mainWindow,
32		browserWindows:      map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
33		lastFocusedWindowID: remaining.id,
34	}
35
36	setShellField(t, removed, "appToaster", &component.Toaster{})
37	setShellField(t, removed, "modeToaster", &component.Toaster{})
38	setShellField(t, removed, "borderMgr", &focus.BorderManager{})
39	setShellField(t, removed, "sessionManager", &component.SessionManager{})
40	setShellField(t, removed, "tabPicker", &component.TabPicker{})
41	setShellField(t, removed, "tabPickerWidget", (*testLayoutWidget)(nil))
42	setShellField(t, removed, "tabPickerPaneID", entity.PaneID("pane-1"))
43	setShellField(t, removed, "insertAccentUC", newTestAccentUseCase(t, false))
44	setShellField(t, removed, "accentPicker", &component.AccentPicker{})
45	setShellField(t, removed, "keyboardHandler", &input.KeyboardHandler{})
46	setShellField(t, removed, "globalShortcutHandler", &input.GlobalShortcutHandler{})
47	setShellField(t, removed, "permissionDialog", (*testPermissionDialogPresenter)(nil))
48	setShellField(t, removed, "webrtcIndicator", &component.WebRTCPermissionIndicator{})
49	setShellField(t, removed, "historySidebar", &component.HistorySidebar{})
50
51	app.removeBrowserWindow(removed.id)
52
53	for _, name := range []string{
54		"appToaster",
55		"modeToaster",
56		"borderMgr",
57		"sessionManager",
58		"tabPicker",
59		"tabPickerWidget",
60		"tabPickerPaneID",
61		"insertAccentUC",
62		"accentPicker",
63		"keyboardHandler",
64		"globalShortcutHandler",
65		"permissionDialog",
66		"webrtcIndicator",
67		"historySidebar",
68	} {
69		if !fieldIsZero(t, removed, name) {
70			t.Fatalf("browserWindow.%s was not cleared", name)
71		}
72	}
73}
74
75func TestApp_CleanupCreatedBrowserWindowsDetachesUnregisteredWindowBeforeDestroy(t *testing.T) {
76	transientMainWindow := &window.MainWindow{}
77	fallbackMainWindow := &window.MainWindow{}
78	transient := &browserWindow{id: "window-transient", mainWindow: transientMainWindow}
79	fallback := &browserWindow{id: "window-fallback", mainWindow: fallbackMainWindow}
80	app := &App{
81		mainWindow:          transientMainWindow,
82		browserWindows:      map[string]*browserWindow{fallback.id: fallback},
83		browserWindowOrder:  []string{fallback.id},
84		lastFocusedWindowID: transient.id,
85	}
86
87	setShellField(t, transient, "keyboardHandler", &input.KeyboardHandler{})
88	setShellField(t, transient, "globalShortcutHandler", &input.GlobalShortcutHandler{})
89	setShellField(t, transient, "accentPicker", &component.AccentPicker{})
90
91	app.cleanupCreatedBrowserWindows([]*browserWindow{transient})
92
93	for _, name := range []string{"keyboardHandler", "globalShortcutHandler", "accentPicker"} {
94		if !fieldIsZero(t, transient, name) {
95			t.Fatalf("transient browserWindow.%s was not cleared", name)
96		}
97	}
98	if app.mainWindow != fallbackMainWindow {
99		t.Fatalf("mainWindow = %p, want fallback main window %p", app.mainWindow, fallbackMainWindow)
100	}
101	if app.lastFocusedWindowID != fallback.id {
102		t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, fallback.id)
103	}
104	if got := app.browserWindows[fallback.id]; got != fallback {
105		t.Fatalf("fallback browser window changed during transient cleanup: got %p want %p", got, fallback)
106	}
107}
108
109type recordingWebViewPool struct {
110	released []port.WebView
111}
112
113func (p *recordingWebViewPool) Acquire(context.Context) (port.WebView, error) {
114	return nil, errors.New("not implemented")
115}
116func (p *recordingWebViewPool) Release(wv port.WebView)           { p.released = append(p.released, wv) }
117func (p *recordingWebViewPool) Prewarm(int)                       {}
118func (p *recordingWebViewPool) PrewarmAsync(context.Context, int) {}
119func (p *recordingWebViewPool) Size() int                         { return 0 }
120func (p *recordingWebViewPool) Close()                            {}
121
122func TestTabCoordinator_CloseReleasesClosedTabWorkspaceWebViews(t *testing.T) {
123	ctx := context.Background()
124	closedTab := entity.NewTab(entity.TabID("tab-x"), entity.WorkspaceID("workspace-x"), entity.NewPane(entity.PaneID("pane-x")))
125	survivingTab := entity.NewTab(entity.TabID("tab-survivor"), entity.WorkspaceID("workspace-survivor"), entity.NewPane(entity.PaneID("pane-survivor")))
126	tabs := entity.NewTabList()
127	tabs.Add(closedTab)
128	tabs.Add(survivingTab)
129	tabs.SetActive(closedTab.ID)
130
131	pool := &recordingWebViewPool{}
132	contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
133	closedWV := &recordingWebView{id: 101}
134	survivingWV := &recordingWebView{id: 102}
135	contentCoord.RegisterPopupWebView(closedTab.Workspace.ActivePaneID, closedWV)
136	contentCoord.RegisterPopupWebView(survivingTab.Workspace.ActivePaneID, survivingWV)
137
138	bw := &browserWindow{id: "window-1", tabs: tabs}
139	app := &App{
140		deps:             &Dependencies{},
141		runtimeConfig:    runtimeConfigStateForTest(config.DefaultConfig()),
142		tabsUC:           usecase.NewManageTabsUseCase(counterIDGen(), nil),
143		contentCoord:     contentCoord,
144		browserWindows:   map[string]*browserWindow{bw.id: bw},
145		tabs:             entity.NewTabList(),
146		workspaceViews:   map[entity.TabID]*component.WorkspaceView{closedTab.ID: {}, survivingTab.ID: {}},
147		windowForTab:     map[entity.TabID]*browserWindow{closedTab.ID: bw, survivingTab.ID: bw},
148		floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
149	}
150	app.tabs.Add(closedTab)
151	app.tabs.Add(survivingTab)
152	app.initTabCoordinator(ctx)
153
154	require.NoError(t, app.tabCoord.Close(ctx, app.ensureTabTargetForBrowserWindow(bw)))
155
156	require.Len(t, pool.released, 1)
157	assert.Same(t, closedWV, pool.released[0])
158	assert.Nil(t, contentCoord.GetWebView(closedTab.Workspace.ActivePaneID))
159	assert.Same(t, survivingWV, contentCoord.GetWebView(survivingTab.Workspace.ActivePaneID))
160	assert.Nil(t, app.workspaceViews[closedTab.ID])
161	assert.Nil(t, app.windowForTab[closedTab.ID])
162	assert.Nil(t, app.tabs.Find(closedTab.ID))
163	assert.Equal(t, survivingTab.ID, tabs.ActiveTabID)
164}
165
166func TestTabCoordinator_SwitchDoesNotReleaseWorkspaceWebViews(t *testing.T) {
167	ctx := context.Background()
168	firstTab := entity.NewTab(entity.TabID("tab-first"), entity.WorkspaceID("workspace-first"), entity.NewPane(entity.PaneID("pane-first")))
169	secondTab := entity.NewTab(entity.TabID("tab-second"), entity.WorkspaceID("workspace-second"), entity.NewPane(entity.PaneID("pane-second")))
170	tabs := entity.NewTabList()
171	tabs.Add(firstTab)
172	tabs.Add(secondTab)
173	tabs.SetActive(firstTab.ID)
174
175	pool := &recordingWebViewPool{}
176	contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
177	firstWV := &recordingWebView{id: 111}
178	secondWV := &recordingWebView{id: 112}
179	contentCoord.RegisterPopupWebView(firstTab.Workspace.ActivePaneID, firstWV)
180	contentCoord.RegisterPopupWebView(secondTab.Workspace.ActivePaneID, secondWV)
181
182	bw := &browserWindow{id: "window-1", tabs: tabs}
183	app := &App{
184		deps:             &Dependencies{},
185		runtimeConfig:    runtimeConfigStateForTest(config.DefaultConfig()),
186		tabsUC:           usecase.NewManageTabsUseCase(counterIDGen(), nil),
187		contentCoord:     contentCoord,
188		browserWindows:   map[string]*browserWindow{bw.id: bw},
189		tabs:             entity.NewTabList(),
190		workspaceViews:   map[entity.TabID]*component.WorkspaceView{firstTab.ID: {}, secondTab.ID: {}},
191		windowForTab:     map[entity.TabID]*browserWindow{firstTab.ID: bw, secondTab.ID: bw},
192		floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
193	}
194	app.tabs.Add(firstTab)
195	app.tabs.Add(secondTab)
196	app.initTabCoordinator(ctx)
197
198	require.NoError(t, app.tabCoord.Switch(ctx, app.ensureTabTargetForBrowserWindow(bw), secondTab.ID))
199
200	assert.Empty(t, pool.released)
201	assert.Same(t, firstWV, contentCoord.GetWebView(firstTab.Workspace.ActivePaneID))
202	assert.Same(t, secondWV, contentCoord.GetWebView(secondTab.Workspace.ActivePaneID))
203}
204
205func TestTabCoordinator_CloseLastTabReleasesWorkspaceBeforeWindowRemoval(t *testing.T) {
206	ctx := context.Background()
207	closedTab := entity.NewTab(entity.TabID("tab-only"), entity.WorkspaceID("workspace-only"), entity.NewPane(entity.PaneID("pane-only")))
208	tabs := entity.NewTabList()
209	tabs.Add(closedTab)
210	tabs.SetActive(closedTab.ID)
211
212	pool := &recordingWebViewPool{}
213	contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
214	closedWV := &recordingWebView{id: 121}
215	contentCoord.RegisterPopupWebView(closedTab.Workspace.ActivePaneID, closedWV)
216
217	bw := &browserWindow{id: "window-1", tabs: tabs}
218	app := &App{
219		deps:             &Dependencies{},
220		runtimeConfig:    runtimeConfigStateForTest(config.DefaultConfig()),
221		tabsUC:           usecase.NewManageTabsUseCase(counterIDGen(), nil),
222		contentCoord:     contentCoord,
223		browserWindows:   map[string]*browserWindow{bw.id: bw},
224		tabs:             entity.NewTabList(),
225		workspaceViews:   map[entity.TabID]*component.WorkspaceView{closedTab.ID: {}},
226		windowForTab:     map[entity.TabID]*browserWindow{closedTab.ID: bw},
227		floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
228	}
229	app.tabs.Add(closedTab)
230	app.initTabCoordinator(ctx)
231
232	require.NoError(t, app.tabCoord.Close(ctx, app.ensureTabTargetForBrowserWindow(bw)))
233
234	require.Len(t, pool.released, 1)
235	assert.Same(t, closedWV, pool.released[0])
236	assert.Nil(t, contentCoord.GetWebView(closedTab.Workspace.ActivePaneID))
237	assert.Empty(t, app.browserWindows)
238	assert.Nil(t, app.workspaceViews[closedTab.ID])
239	assert.Nil(t, app.windowForTab[closedTab.ID])
240}
241
242func TestBrowserWindow_RemoveBrowserWindowReleasesOwnedTabWorkspaceWebViews(t *testing.T) {
243	ctx := context.Background()
244	ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
245	otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
246	removed := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
247	remaining := &browserWindow{id: "window-2", tabs: entity.NewTabList()}
248	removed.tabs.Add(ownedTab)
249	remaining.tabs.Add(otherTab)
250
251	pool := &recordingWebViewPool{}
252	contentCoord := contentcoord.NewCoordinator(ctx, pool, nil, nil, nil, nil, nil, nil)
253	ownedWV := &recordingWebView{id: 201}
254	otherWV := &recordingWebView{id: 202}
255	contentCoord.RegisterPopupWebView(ownedTab.Workspace.ActivePaneID, ownedWV)
256	contentCoord.RegisterPopupWebView(otherTab.Workspace.ActivePaneID, otherWV)
257
258	app := &App{
259		contentCoord:     contentCoord,
260		browserWindows:   map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
261		tabs:             entity.NewTabList(),
262		workspaceViews:   map[entity.TabID]*component.WorkspaceView{ownedTab.ID: {}, otherTab.ID: {}},
263		windowForTab:     map[entity.TabID]*browserWindow{ownedTab.ID: removed, otherTab.ID: remaining},
264		floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{},
265	}
266	app.tabs.Add(ownedTab)
267	app.tabs.Add(otherTab)
268
269	app.removeBrowserWindow(removed.id)
270
271	require.Len(t, pool.released, 1)
272	assert.Same(t, ownedWV, pool.released[0])
273	assert.Nil(t, contentCoord.GetWebView(ownedTab.Workspace.ActivePaneID))
274	assert.Same(t, otherWV, contentCoord.GetWebView(otherTab.Workspace.ActivePaneID))
275}
276
277func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
278	ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
279	otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
280	removedMainWindow := &window.MainWindow{}
281	remainingMainWindow := &window.MainWindow{}
282	removedTabs := entity.NewTabList()
283	removedTabs.Add(ownedTab)
284	remainingTabs := entity.NewTabList()
285	remainingTabs.Add(otherTab)
286	removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow, tabs: removedTabs}
287	remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow, tabs: remainingTabs}
288	ownedSessionKey := floatingSessionKey{tabID: ownedTab.ID, sessionID: "profile:owned"}
289	otherSessionKey := floatingSessionKey{tabID: otherTab.ID, sessionID: "profile:other"}
290	app := &App{
291		mainWindow:     removedMainWindow,
292		browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
293		tabs:           entity.NewTabList(),
294		workspaceViews: map[entity.TabID]*component.WorkspaceView{ownedTab.ID: &component.WorkspaceView{}, otherTab.ID: &component.WorkspaceView{}},
295		windowForTab:   map[entity.TabID]*browserWindow{ownedTab.ID: removed, otherTab.ID: remaining},
296		floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
297			ownedSessionKey: {},
298			otherSessionKey: {},
299		},
300	}
301	app.tabs.Add(ownedTab)
302	app.tabs.Add(otherTab)
303
304	app.removeBrowserWindow(removed.id)
305
306	if app.workspaceViews[ownedTab.ID] != nil {
307		t.Fatalf("owned workspace view was not removed")
308	}
309	if app.windowForTab[ownedTab.ID] != nil {
310		t.Fatalf("owned window mapping was not removed")
311	}
312	if app.tabs.Find(ownedTab.ID) != nil {
313		t.Fatalf("owned tab was not removed")
314	}
315	if app.workspaceViews[otherTab.ID] == nil {
316		t.Fatalf("other workspace view should remain")
317	}
318	if app.windowForTab[otherTab.ID] != remaining {
319		t.Fatalf("other tab should remain mapped to remaining window")
320	}
321	if app.tabs.Find(otherTab.ID) == nil {
322		t.Fatalf("other tab should remain")
323	}
324	if _, ok := app.floatingSessions[ownedSessionKey]; ok {
325		t.Fatalf("owned floating session was not released")
326	}
327	if _, ok := app.floatingSessions[otherSessionKey]; !ok {
328		t.Fatalf("other floating session should remain")
329	}
330	assertWindowOwnershipInvariant(t, app)
331}
332
333func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
334	app := &App{browserWindows: make(map[string]*browserWindow)}
335	first := &browserWindow{id: "window-1"}
336	second := &browserWindow{id: "window-2"}
337
338	app.registerBrowserWindow(first)
339	app.registerBrowserWindow(second)
340
341	if got := len(app.browserWindows); got != 2 {
342		t.Fatalf("browserWindows length = %d, want 2", got)
343	}
344	if _, ok := app.browserWindows[first.id]; !ok {
345		t.Fatalf("first browser window was not registered")
346	}
347	if _, ok := app.browserWindows[second.id]; !ok {
348		t.Fatalf("second browser window was not registered")
349	}
350	if first.tabs == nil || second.tabs == nil {
351		t.Fatalf("registered browser windows must have non-nil tab lists")
352	}
353
354	app.removeBrowserWindow(first.id)
355
356	if _, ok := app.browserWindows[first.id]; ok {
357		t.Fatalf("first browser window was not removed")
358	}
359	if _, ok := app.browserWindows[second.id]; !ok {
360		t.Fatalf("second browser window should remain registered")
361	}
362}
363
364func TestBrowserWindow_RemovePromotesRemainingMainWindow(t *testing.T) {
365	mainWindow := &window.MainWindow{}
366	otherWindow := &window.MainWindow{}
367	first := &browserWindow{id: "window-1", mainWindow: mainWindow}
368	second := &browserWindow{id: "window-2", mainWindow: otherWindow}
369	app := &App{
370		mainWindow:     mainWindow,
371		browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
372	}
373
374	app.removeBrowserWindow(first.id)
375
376	if app.mainWindow != otherWindow {
377		t.Fatalf("mainWindow = %p, want promoted window %p", app.mainWindow, otherWindow)
378	}
379	if len(app.browserWindows) != 1 {
380		t.Fatalf("browserWindows length = %d, want 1", len(app.browserWindows))
381	}
382	if _, ok := app.browserWindows[second.id]; !ok {
383		t.Fatalf("remaining browser window was not kept")
384	}
385}
386
387func TestBrowserWindow_RemoveLastClearsMainWindow(t *testing.T) {
388	mainWindow := &window.MainWindow{}
389	first := &browserWindow{id: "window-1", mainWindow: mainWindow}
390	app := &App{
391		mainWindow:     mainWindow,
392		browserWindows: map[string]*browserWindow{first.id: first},
393	}
394
395	app.removeBrowserWindow(first.id)
396
397	if app.mainWindow != nil {
398		t.Fatalf("mainWindow = %p, want nil", app.mainWindow)
399	}
400	if len(app.browserWindows) != 0 {
401		t.Fatalf("browserWindows length = %d, want 0", len(app.browserWindows))
402	}
403}
404
405func TestBrowserWindow_RemovePromotedWindowClearsResizeModeBorderTarget(t *testing.T) {
406	mainWindow := &window.MainWindow{}
407	otherWindow := &window.MainWindow{}
408	removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
409	remaining := &browserWindow{id: "window-2", mainWindow: otherWindow}
410	resizeTarget := &testLayoutWidget{}
411	app := &App{
412		mainWindow:             mainWindow,
413		browserWindows:         map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
414		resizeModeBorderTarget: resizeTarget,
415	}
416
417	app.removeBrowserWindow(removed.id)
418
419	if app.resizeModeBorderTarget != nil {
420		t.Fatalf("resizeModeBorderTarget = %p, want nil", app.resizeModeBorderTarget)
421	}
422}
423
424func TestOpenFreshWindow_DispatchesAndTracksBrowserWindow(t *testing.T) {
425	app := &App{browserWindows: make(map[string]*browserWindow)}
426
427	dispatched := false
428	createdURL := ""
429	app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
430		dispatched = true
431		if label != "ui.open_fresh_window" {
432			t.Fatalf("dispatch label = %q, want ui.open_fresh_window", label)
433		}
434		fn()
435		return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
436	}
437	app.browserWindowFactory = func(ctx context.Context, url string) (*browserWindow, error) {
438		createdURL = url
439		return &browserWindow{id: "window-1", initialURL: url}, nil
440	}
441
442	if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
443		t.Fatalf("OpenFreshWindow returned error: %v", err)
444	}
445	if !dispatched {
446		t.Fatalf("OpenFreshWindow did not use the main-thread dispatcher")
447	}
448	if createdURL != "https://example.com" {
449		t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com")
450	}
451	if got := len(app.browserWindows); got != 1 {
452		t.Fatalf("browserWindows length = %d, want 1", got)
453	}
454	if got := app.browserWindows["window-1"]; got == nil {
455		t.Fatalf("browser window was not tracked")
456	}
457	if app.browserWindows["window-1"].initialURL != "https://example.com" {
458		t.Fatalf("browser window initialURL = %q, want %q", app.browserWindows["window-1"].initialURL, "https://example.com")
459	}
460}
461
462func TestOpenInitialBrowserWindowShellRegistersWithoutCreatingTab(t *testing.T) {
463	app := &App{
464		tabs:           entity.NewTabList(),
465		tabsUC:         usecase.NewManageTabsUseCase(func() string { return "unexpected-tab" }, nil),
466		browserWindows: make(map[string]*browserWindow),
467	}
468	app.browserWindowFactory = func(_ context.Context, initialURL string) (*browserWindow, error) {
469		return &browserWindow{id: "window-1", initialURL: initialURL, tabs: entity.NewTabList()}, nil
470	}
471
472	if err := app.openInitialBrowserWindowShell(context.Background(), "https://example.com"); err != nil {
473		t.Fatalf("openInitialBrowserWindowShell returned error: %v", err)
474	}
475	if got := len(app.browserWindows); got != 1 {
476		t.Fatalf("browserWindows length = %d, want 1", got)
477	}
478	if app.lastFocusedWindowID != "window-1" {
479		t.Fatalf("lastFocusedWindowID = %q, want window-1", app.lastFocusedWindowID)
480	}
481	if got := app.tabs.Count(); got != 0 {
482		t.Fatalf("tabs count = %d, want 0; initial shell should wait for createInitialTab", got)
483	}
484	windowTabs := app.browserWindows["window-1"].tabs
485	require.NotNil(t, windowTabs)
486	if got := windowTabs.Count(); got != 0 {
487		t.Fatalf("window tabs count = %d, want 0; initial shell should wait for createInitialTab", got)
488	}
489	require.Equal(t, "https://example.com", app.browserWindows["window-1"].initialURL)
490}
491
492func TestOpenFreshWindow_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {
493	app := &App{
494		tabs:           entity.NewTabList(),
495		tabsUC:         usecase.NewManageTabsUseCase(counterIDGen(), nil),
496		browserWindows: make(map[string]*browserWindow),
497		workspaceViews: map[entity.TabID]*component.WorkspaceView{},
498		windowForTab:   map[entity.TabID]*browserWindow{},
499	}
500	app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
501		fn()
502		return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
503	}
504	app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
505		return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil
506	}
507	app.workspaceViewCreateOverride = func(_ context.Context, tab *entity.Tab) bool {
508		app.workspaceViews[tab.ID] = &component.WorkspaceView{}
509		return true
510	}
511
512	if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
513		t.Fatalf("OpenFreshWindow returned error: %v", err)
514	}
515
516	windowTabs := app.browserWindows["window-1"].tabs
517	require.NotNil(t, windowTabs)
518	require.Equal(t, 1, app.tabs.Count())
519	require.Equal(t, 1, windowTabs.Count())
520	tab := windowTabs.ActiveTab()
521	require.NotNil(t, tab)
522	require.NotNil(t, tab.Workspace)
523	activePane := tab.Workspace.FindPane(tab.Workspace.ActivePaneID)
524	require.NotNil(t, activePane)
525	require.Equal(t, "https://example.com", activePane.Pane.URI)
526	require.NotNil(t, app.tabs.Find(tab.ID))
527	require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID))
528}
529
530func TestOpenFreshWindow_PropagatesFactoryError(t *testing.T) {
531	app := &App{browserWindows: make(map[string]*browserWindow)}
532	app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
533		fn()
534		return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
535	}
536	wantErr := errors.New("factory error")
537	app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
538		return nil, wantErr
539	}
540
541	err := app.OpenFreshWindow(context.Background(), "https://example.com")
542
543	if !errors.Is(err, wantErr) {
544		t.Fatalf("expected error %v, got %v", wantErr, err)
545	}
546	if got := len(app.browserWindows); got != 0 {
547		t.Fatalf("browserWindows length = %d, want 0", got)
548	}
549}
550
551func setShellField(t *testing.T, bw *browserWindow, name string, value any) {
552	t.Helper()
553	rv := reflect.ValueOf(bw).Elem()
554	field := rv.FieldByName(name)
555	if !field.IsValid() {
556		t.Fatalf("browserWindow missing field %s", name)
557	}
558	reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
559}
560
561func fieldIsZero(t *testing.T, bw *browserWindow, name string) bool {
562	t.Helper()
563	rv := reflect.ValueOf(bw).Elem()
564	field := rv.FieldByName(name)
565	if !field.IsValid() {
566		t.Fatalf("browserWindow missing field %s", name)
567	}
568	return field.IsZero()
569}
570
571type testLayoutWidget struct{}
572
573func (*testLayoutWidget) Show()                   {}
574func (*testLayoutWidget) Hide()                   {}
575func (*testLayoutWidget) SetVisible(bool)         {}
576func (*testLayoutWidget) IsVisible() bool         { return false }
577func (*testLayoutWidget) SetOpacity(float64)      {}
578func (*testLayoutWidget) GrabFocus() bool         { return false }
579func (*testLayoutWidget) HasFocus() bool          { return false }
580func (*testLayoutWidget) SetCanFocus(bool)        {}
581func (*testLayoutWidget) SetFocusable(bool)       {}
582func (*testLayoutWidget) SetFocusOnClick(bool)    {}
583func (*testLayoutWidget) SetCanTarget(bool)       {}
584func (*testLayoutWidget) SetHexpand(bool)         {}
585func (*testLayoutWidget) SetVexpand(bool)         {}
586func (*testLayoutWidget) GetHexpand() bool        { return false }
587func (*testLayoutWidget) GetVexpand() bool        { return false }
588func (*testLayoutWidget) SetHalign(gtk.Align)     {}
589func (*testLayoutWidget) SetValign(gtk.Align)     {}
590func (*testLayoutWidget) SetSizeRequest(int, int) {}
591func (*testLayoutWidget) GetAllocatedWidth() int  { return 0 }
592func (*testLayoutWidget) GetAllocatedHeight() int { return 0 }
593func (*testLayoutWidget) ComputePoint(layout.Widget) (float64, float64, bool) {
594	return 0, 0, false
595}
596func (*testLayoutWidget) AddCssClass(string)                 {}
597func (*testLayoutWidget) RemoveCssClass(string)              {}
598func (*testLayoutWidget) HasCssClass(string) bool            { return false }
599func (*testLayoutWidget) Unparent()                          {}
600func (*testLayoutWidget) GetParent() layout.Widget           { return nil }
601func (*testLayoutWidget) GtkWidget() *gtk.Widget             { return nil }
602func (*testLayoutWidget) AddController(*gtk.EventController) {}
603
604type testPermissionDialogPresenter struct{}
605
606func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {
607}
608
609// --- Window tab list tests ---
610
611func TestGetWindowTabLists_RespectsRegistrationOrder(t *testing.T) {
612	// Register windows in non-lexicographic order: window-2 first, then window-1.
613	// The result should preserve creation order, not sorted order.
614	app := &App{
615		browserWindows: make(map[string]*browserWindow),
616	}
617
618	first := &browserWindow{id: "window-2"}
619	second := &browserWindow{id: "window-1"}
620	app.registerBrowserWindow(first)
621	app.registerBrowserWindow(second)
622
623	// Give each window a tab so they show up in results.
624	pane1 := entity.NewPane(entity.PaneID("p-w2"))
625	tab1 := entity.NewTab(entity.TabID("t-w2"), entity.WorkspaceID("ws-w2"), pane1)
626	pane2 := entity.NewPane(entity.PaneID("p-w1"))
627	tab2 := entity.NewTab(entity.TabID("t-w1"), entity.WorkspaceID("ws-w1"), pane2)
628
629	app.tabs = entity.NewTabList()
630	app.tabs.Add(tab1)
631	app.tabs.Add(tab2)
632	first.tabs.Add(tab1)
633	second.tabs.Add(tab2)
634	app.windowForTab = map[entity.TabID]*browserWindow{
635		tab1.ID: first,
636		tab2.ID: second,
637	}
638
639	result := app.GetWindowTabLists()
640
641	require.Len(t, result, 2)
642	assert.Equal(t, entity.WindowID("window-2"), result[0].WindowID, "first registered window should be first")
643	assert.Equal(t, entity.WindowID("window-1"), result[1].WindowID, "second registered window should be second")
644}
645
646func TestGetWindowTabLists_NoBrowserWindowsReturnsGlobalSnapshot(t *testing.T) {
647	pane := entity.NewPane(entity.PaneID("p1"))
648	tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
649	tabs := entity.NewTabList()
650	tabs.Add(tab)
651
652	app := &App{tabs: tabs}
653
654	result := app.GetWindowTabLists()
655
656	require.Len(t, result, 1)
657	assert.Equal(t, entity.WindowID(""), result[0].WindowID)
658	// Snapshot returns a copy so pointer identity is not expected; verify
659	// structural equality instead.
660	assert.Equal(t, tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
661	assert.Len(t, result[0].Tabs.Tabs, len(tabs.Tabs))
662}
663
664func TestGetWindowTabLists_AppliesActiveTabIndex(t *testing.T) {
665	// A window with its own TabList where the second tab is active.
666	app := &App{
667		browserWindows: make(map[string]*browserWindow),
668	}
669
670	pane1 := entity.NewPane(entity.PaneID("p1"))
671	tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
672	pane2 := entity.NewPane(entity.PaneID("p2"))
673	tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
674
675	app.tabs = entity.NewTabList()
676	app.tabs.Add(tab1)
677	app.tabs.Add(tab2)
678
679	bwTabs := entity.NewTabList()
680	bwTabs.Add(tab1)
681	bwTabs.Add(tab2)
682	bwTabs.SetActive(entity.TabID("t2"))
683	bw := &browserWindow{id: "w1", tabs: bwTabs}
684	app.registerBrowserWindow(bw)
685	app.windowForTab = map[entity.TabID]*browserWindow{
686		tab1.ID: bw,
687		tab2.ID: bw,
688	}
689
690	result := app.GetWindowTabLists()
691
692	require.Len(t, result, 1)
693	assert.Equal(t, entity.TabID("t2"), result[0].Tabs.ActiveTabID, "should reflect bw.tabs.ActiveTabID")
694}
695
696func TestGetWindowTabLists_PreservesPreviousActiveTabID(t *testing.T) {
697	app := &App{
698		browserWindows: make(map[string]*browserWindow),
699	}
700
701	pane1 := entity.NewPane(entity.PaneID("p1"))
702	tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
703	pane2 := entity.NewPane(entity.PaneID("p2"))
704	tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
705
706	app.tabs = entity.NewTabList()
707	app.tabs.Add(tab1)
708	app.tabs.Add(tab2)
709
710	bwTabs := entity.NewTabList()
711	bwTabs.Add(tab1)
712	bwTabs.Add(tab2)
713	bwTabs.SetActive(entity.TabID("t2")) // t2 becomes active, t1 becomes previous
714
715	bw := &browserWindow{id: "w1", tabs: bwTabs}
716	app.registerBrowserWindow(bw)
717	app.windowForTab = map[entity.TabID]*browserWindow{
718		tab1.ID: bw,
719		tab2.ID: bw,
720	}
721
722	result := app.GetWindowTabLists()
723
724	require.Len(t, result, 1)
725	assert.Equal(t, entity.TabID("t1"), result[0].Tabs.PreviousActiveTabID, "should preserve bw.tabs.PreviousActiveTabID")
726}
727
728func TestGetActiveWindowIndex_MatchesRegistrationOrder(t *testing.T) {
729	// Register windows in non-lexicographic order; lastFocusedWindowID should
730	// map to the correct index in registration order.
731	app := &App{
732		browserWindows: make(map[string]*browserWindow),
733	}
734
735	app.registerBrowserWindow(&browserWindow{id: "z-window"})
736	app.registerBrowserWindow(&browserWindow{id: "a-window"})
737	app.lastFocusedWindowID = "a-window"
738
739	idx := app.GetActiveWindowIndex()
740	assert.Equal(t, 1, idx, "a-window is the second registered window, index should be 1")
741}
742
743func TestGetActiveWindowIndex_MatchesGetWindowTabLists(t *testing.T) {
744	// The order of windows in GetWindowTabLists must match the indices returned
745	// by GetActiveWindowIndex.
746	app := &App{
747		browserWindows: make(map[string]*browserWindow),
748	}
749
750	w1 := &browserWindow{id: "w1"}
751	w2 := &browserWindow{id: "w2"}
752	w3 := &browserWindow{id: "w3"}
753	app.registerBrowserWindow(w1)
754	app.registerBrowserWindow(w2)
755	app.registerBrowserWindow(w3)
756
757	// Give each window a tab.
758	p1 := entity.NewPane(entity.PaneID("p1"))
759	t1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), p1)
760	p2 := entity.NewPane(entity.PaneID("p2"))
761	t2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), p2)
762	p3 := entity.NewPane(entity.PaneID("p3"))
763	t3 := entity.NewTab(entity.TabID("t3"), entity.WorkspaceID("ws3"), p3)
764	app.tabs = entity.NewTabList()
765	app.tabs.Add(t1)
766	app.tabs.Add(t2)
767	app.tabs.Add(t3)
768	w1.tabs.Add(t1)
769	w2.tabs.Add(t2)
770	w3.tabs.Add(t3)
771	app.windowForTab = map[entity.TabID]*browserWindow{
772		t1.ID: w1,
773		t2.ID: w2,
774		t3.ID: w3,
775	}
776
777	app.lastFocusedWindowID = "w2"
778
779	lists := app.GetWindowTabLists()
780	idx := app.GetActiveWindowIndex()
781
782	require.Len(t, lists, 3)
783	assert.Equal(t, entity.WindowID("w2"), lists[idx].WindowID, "active window ID must match the lists index")
784}
785
786func TestWindowOrder_FallbackWhenRegistrationOrderEmpty(t *testing.T) {
787	// When browserWindowOrder is empty (e.g., old tests / direct map manipulation),
788	// windowOrder must fall back to a deterministic (sorted) order.
789	app := &App{
790		browserWindows: map[string]*browserWindow{
791			"z-window": {id: "z-window"},
792			"a-window": {id: "a-window"},
793		},
794	}
795
796	order := app.windowOrder()
797
798	require.Len(t, order, 2)
799	// Sorted order: "a-window" < "z-window"
800	assert.Equal(t, "a-window", order[0])
801	assert.Equal(t, "z-window", order[1])
802}
803
804func TestRemoveBrowserWindow_RemovesFromOrder(t *testing.T) {
805	app := &App{
806		browserWindows: make(map[string]*browserWindow),
807	}
808
809	w1 := &browserWindow{id: "w1"}
810	w2 := &browserWindow{id: "w2"}
811	w3 := &browserWindow{id: "w3"}
812	app.registerBrowserWindow(w1)
813	app.registerBrowserWindow(w2)
814	app.registerBrowserWindow(w3)
815
816	app.removeBrowserWindow("w2")
817
818	require.Len(t, app.browserWindowOrder, 2)
819	assert.Equal(t, "w1", app.browserWindowOrder[0])
820	assert.Equal(t, "w3", app.browserWindowOrder[1])
821}
822
823func TestRegisterBrowserWindow_DuplicateIgnores(t *testing.T) {
824	app := &App{
825		browserWindows: make(map[string]*browserWindow),
826	}
827
828	bw := &browserWindow{id: "w1"}
829	app.registerBrowserWindow(bw)
830	app.registerBrowserWindow(bw) // duplicate
831
832	require.Len(t, app.browserWindowOrder, 1)
833}
834
835func TestApp_GetWindowTabListsUsesCreationOrderAndActiveIndex(t *testing.T) {
836	// Create two windows with per-window bw.tabs as the real source of truth.
837	// Ensure GetWindowTabLists returns them in registration order, with correct
838	// WindowID, and that the returned Tabs pointers are the per-window bw.tabs
839	// (not copies or derived from global App.tabs).
840	firstTabs := entity.NewTabList()
841	firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
842	firstTabs.Add(firstTab)
843	firstTabs.SetActive(entity.TabID("tab-1"))
844
845	secondTabs := entity.NewTabList()
846	secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
847	secondTabs.Add(secondTab)
848
849	first := &browserWindow{id: "window-1", tabs: firstTabs}
850	second := &browserWindow{id: "window-2", tabs: secondTabs}
851
852	app := &App{
853		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
854		browserWindowOrder:  []string{"window-1", "window-2"},
855		lastFocusedWindowID: "window-2",
856	}
857
858	result := app.GetWindowTabLists()
859
860	require.Len(t, result, 2)
861	assert.Equal(t, entity.WindowID("window-1"), result[0].WindowID)
862	assert.Equal(t, entity.WindowID("window-2"), result[1].WindowID)
863
864	// The returned Tabs are snapshot copies of per-window bw.tabs.
865	// Verify structural equality (not pointer identity, since
866	// GetWindowTabLists returns a snapshot for safe concurrent read).
867	assert.Equal(t, firstTabs.ActiveTabID, result[0].Tabs.ActiveTabID)
868	assert.Len(t, result[0].Tabs.Tabs, len(firstTabs.Tabs))
869	assert.Equal(t, secondTabs.ActiveTabID, result[1].Tabs.ActiveTabID)
870	assert.Len(t, result[1].Tabs.Tabs, len(secondTabs.Tabs))
871
872	// Active state reflects per-window bw.tabs.
873	assert.Equal(t, entity.TabID("tab-1"), result[0].Tabs.ActiveTabID)
874	assert.Equal(t, entity.TabID("tab-2"), result[1].Tabs.ActiveTabID)
875
876	// GetActiveWindowIndex respects lastFocusedWindowID mapped to registration order.
877	idx := app.GetActiveWindowIndex()
878	assert.Equal(t, 1, idx, "window-2 is second registered, index should be 1")
879}
880
881func TestApp_AssignRestoredWindowTabListsUsesFreshRuntimeWindowIDs(t *testing.T) {
882	// Create two restored TabLists with distinct tabs.
883	firstTabs := entity.NewTabList()
884	firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
885	firstTabs.Add(firstTab)
886	firstTabs.SetActive(entity.TabID("tab-1"))
887
888	secondTabs := entity.NewTabList()
889	secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
890	secondTabs.Add(secondTab)
891
892	// Create two runtime browser windows with fresh IDs distinct from saved IDs.
893	firstBW := &browserWindow{id: "runtime-w1"}
894	secondBW := &browserWindow{id: "runtime-w2"}
895
896	app := &App{
897		browserWindows: map[string]*browserWindow{"runtime-w1": firstBW, "runtime-w2": secondBW},
898	}
899
900	// Assign restored state using saved window IDs (simulating session restore).
901	app.assignRestoredWindowTabList(firstBW, entity.WindowTabListState{
902		WindowID: "saved-w1",
903		Tabs:     firstTabs,
904	})
905	app.assignRestoredWindowTabList(secondBW, entity.WindowTabListState{
906		WindowID: "saved-w2",
907		Tabs:     secondTabs,
908	})
909
910	// Tabs must be owned by runtime windows, not saved.
911	require.Same(t, firstTabs, firstBW.tabs, "first window tabs must be assigned")
912	require.Same(t, secondTabs, secondBW.tabs, "second window tabs must be assigned")
913
914	// windowForTab must map tabs to runtime windows.
915	require.Equal(t, firstBW, app.browserWindowForTab(firstTab.ID), "tab-1 must be owned by runtime-w1")
916	require.Equal(t, secondBW, app.browserWindowForTab(secondTab.ID), "tab-2 must be owned by runtime-w2")
917
918	// Saved window IDs must never leak into browserWindows.
919	require.Nil(t, app.browserWindows["saved-w1"], "saved-w1 must not appear in browserWindows")
920	require.Nil(t, app.browserWindows["saved-w2"], "saved-w2 must not appear in browserWindows")
921	assertWindowOwnershipInvariant(t, app)
922}
923
924func TestApp_BrowserWindowsOwnIndependentTabLists(t *testing.T) {
925	// Construct first browser window with its own TabList and distinct tabs.
926	firstTabs := entity.NewTabList()
927	firstTab := entity.NewTab(entity.TabID("tab-first"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
928	firstTabs.Add(firstTab)
929
930	first := &browserWindow{
931		id:   "window-first",
932		tabs: firstTabs,
933	}
934
935	// Construct second browser window with its own TabList and distinct tabs.
936	secondTabs := entity.NewTabList()
937	secondTab := entity.NewTab(entity.TabID("tab-second"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
938	secondTabs.Add(secondTab)
939
940	second := &browserWindow{
941		id:   "window-second",
942		tabs: secondTabs,
943	}
944
945	app := &App{
946		browserWindows: map[string]*browserWindow{
947			first.id:  first,
948			second.id: second,
949		},
950	}
951
952	// tabListForBrowserWindow returns the correct TabList for each window.
953	got := app.tabListForBrowserWindow(first)
954	if got != firstTabs {
955		t.Fatalf("tabListForBrowserWindow(first) = %p, want %p", got, firstTabs)
956	}
957	got = app.tabListForBrowserWindow(second)
958	if got != secondTabs {
959		t.Fatalf("tabListForBrowserWindow(second) = %p, want %p", got, secondTabs)
960	}
961
962	// Each window's tabs do not contain the other window's tabs.
963	if firstTabs.Find(secondTab.ID) != nil {
964		t.Fatalf("first window tabs unexpectedly contain second tab")
965	}
966	if secondTabs.Find(firstTab.ID) != nil {
967		t.Fatalf("second window tabs unexpectedly contain first tab")
968	}
969
970	// activeTabForBrowserWindow returns the correct active tab for each window.
971	gotTab := app.activeTabForBrowserWindow(first)
972	if gotTab != firstTab {
973		t.Fatalf("activeTabForBrowserWindow(first) = %p, want %p", gotTab, firstTab)
974	}
975	gotTab = app.activeTabForBrowserWindow(second)
976	if gotTab != secondTab {
977		t.Fatalf("activeTabForBrowserWindow(second) = %p, want %p", gotTab, secondTab)
978	}
979
980	app.windowForTab = map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second}
981	assertWindowOwnershipInvariant(t, app)
982}
983
984func TestRestoreSession_RemovesStaleBrowserWindows(t *testing.T) {
985	// After restore, only runtime windows should remain in browserWindows
986	// and browserWindowOrder. Stale pre-existing windows must be removed.
987
988	// Runtime windows with tabs (what restoreSession produces).
989	runtime1Tabs := entity.NewTabList()
990	r1Tab := entity.NewTab(entity.TabID("r1-t1"), entity.WorkspaceID("ws-r1"), entity.NewPane(entity.PaneID("p-r1")))
991	runtime1Tabs.Add(r1Tab)
992	runtime1Tabs.SetActive(entity.TabID("r1-t1"))
993
994	runtime2Tabs := entity.NewTabList()
995	r2Tab := entity.NewTab(entity.TabID("r2-t1"), entity.WorkspaceID("ws-r2"), entity.NewPane(entity.PaneID("p-r2")))
996	runtime2Tabs.Add(r2Tab)
997	runtime2Tabs.SetActive(entity.TabID("r2-t1"))
998
999	runtime1 := &browserWindow{id: "runtime-w1", tabs: runtime1Tabs}
1000	runtime2 := &browserWindow{id: "runtime-w2", tabs: runtime2Tabs}
1001
1002	// Stale pre-existing browser window (no longer in runtime set).
1003	staleTabs := entity.NewTabList()
1004	staleTab := entity.NewTab(entity.TabID("stale-t1"), entity.WorkspaceID("ws-stale"), entity.NewPane(entity.PaneID("p-stale")))
1005	staleTabs.Add(staleTab)
1006	stale := &browserWindow{id: "stale-window", tabs: staleTabs}
1007
1008	app := &App{
1009		browserWindows: map[string]*browserWindow{
1010			"stale-window": stale,
1011			"runtime-w1":   runtime1,
1012			"runtime-w2":   runtime2,
1013		},
1014		tabs: entity.NewTabList(),
1015		windowForTab: map[entity.TabID]*browserWindow{
1016			staleTab.ID: stale,
1017			r1Tab.ID:    runtime1,
1018			r2Tab.ID:    runtime2,
1019		},
1020	}
1021
1022	// Simulate post-restore cleanup: remove stale windows not in runtime set.
1023	runtimeIDs := []string{"runtime-w1", "runtime-w2"}
1024	app.pruneStaleBrowserWindows([]*browserWindow{runtime1, runtime2})
1025
1026	// After cleanup, browserWindows contains only runtime windows.
1027	require.Len(t, app.browserWindows, 2)
1028	require.NotNil(t, app.browserWindows["runtime-w1"])
1029	require.NotNil(t, app.browserWindows["runtime-w2"])
1030	require.Nil(t, app.browserWindows["stale-window"])
1031
1032	// browserWindowOrder matches runtime order.
1033	require.Equal(t, runtimeIDs, app.browserWindowOrder)
1034
1035	// GetWindowTabLists returns only runtime windows.
1036	result := app.GetWindowTabLists()
1037	require.Len(t, result, 2)
1038	assert.Equal(t, entity.WindowID("runtime-w1"), result[0].WindowID)
1039	assert.Equal(t, entity.WindowID("runtime-w2"), result[1].WindowID)
1040
1041	// No stale tabs leak into the result.
1042	// Verify structural equality (snapshot copies, not live pointers).
1043	assert.Equal(t, runtime1Tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
1044	assert.Len(t, result[0].Tabs.Tabs, len(runtime1Tabs.Tabs))
1045	assert.Equal(t, runtime2Tabs.ActiveTabID, result[1].Tabs.ActiveTabID)
1046	assert.Len(t, result[1].Tabs.Tabs, len(runtime2Tabs.Tabs))
1047	assertWindowOwnershipInvariant(t, app)
1048}
1049
1050func TestRestoreSession_BrowserWindowOrderStartsWithFirstReusedWindow(t *testing.T) {
1051	// When the first restored window reuses a browser window that was not
1052	// already in browserWindowOrder, the restored order must start with
1053	// the reused window ID followed by newly-created window IDs.
1054
1055	reuseTabs := entity.NewTabList()
1056	reuseTab := entity.NewTab(entity.TabID("reuse-t1"), entity.WorkspaceID("ws-reuse"), entity.NewPane(entity.PaneID("p-reuse")))
1057	reuseTabs.Add(reuseTab)
1058	reuseBW := &browserWindow{id: "reused-window", tabs: reuseTabs}
1059
1060	createdTabs := entity.NewTabList()
1061	createdTab := entity.NewTab(entity.TabID("created-t1"), entity.WorkspaceID("ws-created"), entity.NewPane(entity.PaneID("p-created")))
1062	createdTabs.Add(createdTab)
1063	createdBW := &browserWindow{id: "created-window", tabs: createdTabs}
1064
1065	// Pre-existing browserWindowOrder does NOT contain the reused window.
1066	app := &App{
1067		browserWindows: map[string]*browserWindow{
1068			"reused-window":  reuseBW,
1069			"created-window": createdBW,
1070		},
1071		browserWindowOrder: []string{"old-stale-window"}, // reused window not present
1072		tabs:               entity.NewTabList(),
1073		windowForTab:       map[entity.TabID]*browserWindow{},
1074	}
1075
1076	// Exercise the helper used by restoreSession to prune stale windows and
1077	// replace browserWindowOrder with the runtime restore order.
1078	runtimeWindows := []*browserWindow{reuseBW, createdBW}
1079	app.pruneStaleBrowserWindows(runtimeWindows)
1080
1081	require.Len(t, app.browserWindowOrder, 2)
1082	assert.Equal(t, "reused-window", app.browserWindowOrder[0],
1083		"first reused window must be first in order")
1084	assert.Equal(t, "created-window", app.browserWindowOrder[1],
1085		"newly-created window must follow the reused window")
1086
1087	// GetWindowTabLists respects the order.
1088	result := app.GetWindowTabLists()
1089	require.Len(t, result, 2)
1090	assert.Equal(t, entity.WindowID("reused-window"), result[0].WindowID)
1091	assert.Equal(t, entity.WindowID("created-window"), result[1].WindowID)
1092}
1093
1094func TestRestoreSession_ActiveWindowIndexSyncsState(t *testing.T) {
1095	// After restore, GetActiveWindowIndex must return the restored active index.
1096	// The focused window state must be consistent: lastFocusedWindowID matches
1097	// the window at that index, and the active window's tab target aligns.
1098
1099	runtime1Tabs := entity.NewTabList()
1100	r1Tab := entity.NewTab(entity.TabID("a1-t1"), entity.WorkspaceID("ws-a1"), entity.NewPane(entity.PaneID("p-a1")))
1101	runtime1Tabs.Add(r1Tab)
1102	runtime1Tabs.SetActive(entity.TabID("a1-t1"))
1103
1104	runtime2Tabs := entity.NewTabList()
1105	r2Tab := entity.NewTab(entity.TabID("a2-t1"), entity.WorkspaceID("ws-a2"), entity.NewPane(entity.PaneID("p-a2")))
1106	runtime2Tabs.Add(r2Tab)
1107	runtime2Tabs.SetActive(entity.TabID("a2-t1"))
1108
1109	runtime1 := &browserWindow{id: "active-w1", tabs: runtime1Tabs}
1110	runtime2 := &browserWindow{id: "active-w2", tabs: runtime2Tabs}
1111
1112	app := &App{
1113		browserWindows: map[string]*browserWindow{
1114			"active-w1": runtime1,
1115			"active-w2": runtime2,
1116		},
1117		browserWindowOrder:  []string{"active-w1", "active-w2"},
1118		lastFocusedWindowID: "active-w2",
1119		tabs:                entity.NewTabList(),
1120		windowForTab:        map[entity.TabID]*browserWindow{},
1121	}
1122
1123	// Active index should be 1 (second window in order).
1124	idx := app.GetActiveWindowIndex()
1125	assert.Equal(t, 1, idx)
1126
1127	// The focused window is the one at that index.
1128	focused := app.lastFocusedBrowserWindow()
1129	require.NotNil(t, focused)
1130	assert.Equal(t, "active-w2", focused.id)
1131
1132	// Verify that GetActiveWindowIndex and GetWindowTabLists are consistent:
1133	// the window at the active index matches the focused window.
1134	result := app.GetWindowTabLists()
1135	require.Len(t, result, 2)
1136	assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID,
1137		"window at active index must match focused window ID")
1138}

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