v0.31.0 bf0756b

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

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