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/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", &modeToaster{})
38	setShellField(t, removed, "modeFrame", &modeFrame{})
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		"modeFrame",
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_RemoveBrowserWindowReleasesOnlyRelatedNativePopups(t *testing.T) {
278	closedWebView := portmocks.NewMockWebView(t)
279	closedWebView.EXPECT().IsDestroyed().Return(false).Once()
280	closedWebView.EXPECT().Destroy().Once()
281	unrelatedWebView := portmocks.NewMockWebView(t)
282	closedShell := &popupDestroySpy{}
283	unrelatedShell := &popupDestroySpy{}
284	removed := &browserWindow{id: "owner-window", tabs: entity.NewTabList()}
285	remaining := &browserWindow{id: "other-window", tabs: entity.NewTabList()}
286	app := &App{
287		browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
288		nativePopupWindows: map[port.WebViewID]*nativePopupWindow{
289			1: {popupID: 1, parentWindowID: removed.id, webView: closedWebView, popupWindow: closedShell},
290			2: {popupID: 2, parentWindowID: remaining.id, webView: unrelatedWebView, popupWindow: unrelatedShell},
291		},
292	}
293
294	app.removeBrowserWindow(removed.id)
295
296	assert.NotContains(t, app.nativePopupWindows, port.WebViewID(1))
297	assert.Contains(t, app.nativePopupWindows, port.WebViewID(2))
298	assert.Equal(t, 1, closedShell.destroyed)
299	assert.Zero(t, unrelatedShell.destroyed)
300}
301
302func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
303	ownedTab := entity.NewTab(entity.TabID("tab-owned"), entity.WorkspaceID("workspace-owned"), entity.NewPane(entity.PaneID("pane-owned")))
304	otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
305	removedMainWindow := &window.MainWindow{}
306	remainingMainWindow := &window.MainWindow{}
307	removedTabs := entity.NewTabList()
308	removedTabs.Add(ownedTab)
309	remainingTabs := entity.NewTabList()
310	remainingTabs.Add(otherTab)
311	removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow, tabs: removedTabs}
312	remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow, tabs: remainingTabs}
313	ownedSessionKey := floatingSessionKey{tabID: ownedTab.ID, sessionID: "profile:owned"}
314	otherSessionKey := floatingSessionKey{tabID: otherTab.ID, sessionID: "profile:other"}
315	app := &App{
316		mainWindow:     removedMainWindow,
317		browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
318		tabs:           entity.NewTabList(),
319		workspaceViews: map[entity.TabID]*component.WorkspaceView{ownedTab.ID: &component.WorkspaceView{}, otherTab.ID: &component.WorkspaceView{}},
320		windowForTab:   map[entity.TabID]*browserWindow{ownedTab.ID: removed, otherTab.ID: remaining},
321		floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
322			ownedSessionKey: {},
323			otherSessionKey: {},
324		},
325	}
326	app.tabs.Add(ownedTab)
327	app.tabs.Add(otherTab)
328
329	app.removeBrowserWindow(removed.id)
330
331	if app.workspaceViews[ownedTab.ID] != nil {
332		t.Fatalf("owned workspace view was not removed")
333	}
334	if app.windowForTab[ownedTab.ID] != nil {
335		t.Fatalf("owned window mapping was not removed")
336	}
337	if app.tabs.Find(ownedTab.ID) != nil {
338		t.Fatalf("owned tab was not removed")
339	}
340	if app.workspaceViews[otherTab.ID] == nil {
341		t.Fatalf("other workspace view should remain")
342	}
343	if app.windowForTab[otherTab.ID] != remaining {
344		t.Fatalf("other tab should remain mapped to remaining window")
345	}
346	if app.tabs.Find(otherTab.ID) == nil {
347		t.Fatalf("other tab should remain")
348	}
349	if _, ok := app.floatingSessions[ownedSessionKey]; ok {
350		t.Fatalf("owned floating session was not released")
351	}
352	if _, ok := app.floatingSessions[otherSessionKey]; !ok {
353		t.Fatalf("other floating session should remain")
354	}
355	assertWindowOwnershipInvariant(t, app)
356}
357
358func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
359	app := &App{browserWindows: make(map[string]*browserWindow)}
360	first := &browserWindow{id: "window-1"}
361	second := &browserWindow{id: "window-2"}
362
363	app.registerBrowserWindow(first)
364	app.registerBrowserWindow(second)
365
366	if got := len(app.browserWindows); got != 2 {
367		t.Fatalf("browserWindows length = %d, want 2", got)
368	}
369	if _, ok := app.browserWindows[first.id]; !ok {
370		t.Fatalf("first browser window was not registered")
371	}
372	if _, ok := app.browserWindows[second.id]; !ok {
373		t.Fatalf("second browser window was not registered")
374	}
375	if first.tabs == nil || second.tabs == nil {
376		t.Fatalf("registered browser windows must have non-nil tab lists")
377	}
378
379	app.removeBrowserWindow(first.id)
380
381	if _, ok := app.browserWindows[first.id]; ok {
382		t.Fatalf("first browser window was not removed")
383	}
384	if _, ok := app.browserWindows[second.id]; !ok {
385		t.Fatalf("second browser window should remain registered")
386	}
387}
388
389func TestBrowserWindow_RemovePromotesRemainingMainWindow(t *testing.T) {
390	mainWindow := &window.MainWindow{}
391	otherWindow := &window.MainWindow{}
392	first := &browserWindow{id: "window-1", mainWindow: mainWindow}
393	second := &browserWindow{id: "window-2", mainWindow: otherWindow}
394	app := &App{
395		mainWindow:     mainWindow,
396		browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
397	}
398
399	app.removeBrowserWindow(first.id)
400
401	if app.mainWindow != otherWindow {
402		t.Fatalf("mainWindow = %p, want promoted window %p", app.mainWindow, otherWindow)
403	}
404	if len(app.browserWindows) != 1 {
405		t.Fatalf("browserWindows length = %d, want 1", len(app.browserWindows))
406	}
407	if _, ok := app.browserWindows[second.id]; !ok {
408		t.Fatalf("remaining browser window was not kept")
409	}
410}
411
412func TestBrowserWindow_RemoveLastClearsMainWindow(t *testing.T) {
413	mainWindow := &window.MainWindow{}
414	first := &browserWindow{id: "window-1", mainWindow: mainWindow}
415	app := &App{
416		mainWindow:     mainWindow,
417		browserWindows: map[string]*browserWindow{first.id: first},
418	}
419
420	app.removeBrowserWindow(first.id)
421
422	if app.mainWindow != nil {
423		t.Fatalf("mainWindow = %p, want nil", app.mainWindow)
424	}
425	if len(app.browserWindows) != 0 {
426		t.Fatalf("browserWindows length = %d, want 0", len(app.browserWindows))
427	}
428}
429
430func TestBrowserWindow_RemovePromotedWindowKeepsRemainingWindow(t *testing.T) {
431	mainWindow := &window.MainWindow{}
432	otherWindow := &window.MainWindow{}
433	removed := &browserWindow{id: "window-1", mainWindow: mainWindow}
434	remaining := &browserWindow{id: "window-2", mainWindow: otherWindow}
435	app := &App{
436		mainWindow:     mainWindow,
437		browserWindows: map[string]*browserWindow{removed.id: removed, remaining.id: remaining},
438	}
439
440	app.removeBrowserWindow(removed.id)
441
442	if app.mainWindow != otherWindow {
443		t.Fatalf("mainWindow = %p, want %p", app.mainWindow, otherWindow)
444	}
445}
446
447func TestOpenExternalURL_DispatchesAndTracksBrowserWindow(t *testing.T) {
448	app := &App{browserWindows: make(map[string]*browserWindow)}
449
450	dispatched := false
451	createdURL := ""
452	app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
453		dispatched = true
454		if label != "ui.open_external_url" {
455			t.Fatalf("dispatch label = %q, want ui.open_external_url", label)
456		}
457		fn()
458		return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
459	}
460	app.browserWindowFactory = func(ctx context.Context, url string) (*browserWindow, error) {
461		createdURL = url
462		return &browserWindow{id: "window-1", initialURL: url}, nil
463	}
464
465	if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
466		t.Fatalf("OpenExternalURL returned error: %v", err)
467	}
468	if !dispatched {
469		t.Fatalf("OpenExternalURL did not use the main-thread dispatcher")
470	}
471	if createdURL != "https://example.com" {
472		t.Fatalf("browser window factory URL = %q, want %q", createdURL, "https://example.com")
473	}
474	if got := len(app.browserWindows); got != 1 {
475		t.Fatalf("browserWindows length = %d, want 1", got)
476	}
477	if got := app.browserWindows["window-1"]; got == nil {
478		t.Fatalf("browser window was not tracked")
479	}
480	if app.browserWindows["window-1"].initialURL != "https://example.com" {
481		t.Fatalf("browser window initialURL = %q, want %q", app.browserWindows["window-1"].initialURL, "https://example.com")
482	}
483}
484
485func TestOpenInitialBrowserWindowShellRegistersWithoutCreatingTab(t *testing.T) {
486	app := &App{
487		tabs:           entity.NewTabList(),
488		tabsUC:         usecase.NewManageTabsUseCase(func() string { return "unexpected-tab" }, nil),
489		browserWindows: make(map[string]*browserWindow),
490	}
491	app.browserWindowFactory = func(_ context.Context, initialURL string) (*browserWindow, error) {
492		return &browserWindow{id: "window-1", initialURL: initialURL, tabs: entity.NewTabList()}, nil
493	}
494
495	if err := app.openInitialBrowserWindowShell(context.Background(), "https://example.com"); err != nil {
496		t.Fatalf("openInitialBrowserWindowShell returned error: %v", err)
497	}
498	if got := len(app.browserWindows); got != 1 {
499		t.Fatalf("browserWindows length = %d, want 1", got)
500	}
501	if app.lastFocusedWindowID != "window-1" {
502		t.Fatalf("lastFocusedWindowID = %q, want window-1", app.lastFocusedWindowID)
503	}
504	if got := app.tabs.Count(); got != 0 {
505		t.Fatalf("tabs count = %d, want 0; initial shell should wait for createInitialTab", got)
506	}
507	windowTabs := app.browserWindows["window-1"].tabs
508	require.NotNil(t, windowTabs)
509	if got := windowTabs.Count(); got != 0 {
510		t.Fatalf("window tabs count = %d, want 0; initial shell should wait for createInitialTab", got)
511	}
512	require.Equal(t, "https://example.com", app.browserWindows["window-1"].initialURL)
513}
514
515func TestOpenExternalURL_FirstWindowWithRequestedURLCreatesTabInAppAndWindow(t *testing.T) {
516	app := &App{
517		tabs:           entity.NewTabList(),
518		tabsUC:         usecase.NewManageTabsUseCase(counterIDGen(), nil),
519		browserWindows: make(map[string]*browserWindow),
520		workspaceViews: map[entity.TabID]*component.WorkspaceView{},
521		windowForTab:   map[entity.TabID]*browserWindow{},
522	}
523	app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
524		fn()
525		return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
526	}
527	app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
528		return &browserWindow{id: "window-1", tabs: entity.NewTabList()}, nil
529	}
530	app.workspaceViewCreateOverride = func(_ context.Context, tab *entity.Tab) bool {
531		app.workspaceViews[tab.ID] = &component.WorkspaceView{}
532		return true
533	}
534
535	if err := app.OpenExternalURL(context.Background(), "https://example.com"); err != nil {
536		t.Fatalf("OpenExternalURL returned error: %v", err)
537	}
538
539	windowTabs := app.browserWindows["window-1"].tabs
540	require.NotNil(t, windowTabs)
541	require.Equal(t, 1, app.tabs.Count())
542	require.Equal(t, 1, windowTabs.Count())
543	tab := windowTabs.ActiveTab()
544	require.NotNil(t, tab)
545	require.NotNil(t, tab.Workspace)
546	activePane := tab.Workspace.FindPane(tab.Workspace.ActivePaneID)
547	require.NotNil(t, activePane)
548	require.Equal(t, "https://example.com", activePane.Pane.URI)
549	require.NotNil(t, app.tabs.Find(tab.ID))
550	require.Same(t, app.browserWindows["window-1"], app.browserWindowForTab(tab.ID))
551}
552
553func TestOpenExternalURL_PropagatesFactoryError(t *testing.T) {
554	app := &App{browserWindows: make(map[string]*browserWindow)}
555	app.dispatchOnMainThread = func(label string, fn func()) syncdispatch.SyncDispatchResult {
556		fn()
557		return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
558	}
559	wantErr := errors.New("factory error")
560	app.browserWindowFactory = func(context.Context, string) (*browserWindow, error) {
561		return nil, wantErr
562	}
563
564	err := app.OpenExternalURL(context.Background(), "https://example.com")
565
566	if !errors.Is(err, wantErr) {
567		t.Fatalf("expected error %v, got %v", wantErr, err)
568	}
569	if got := len(app.browserWindows); got != 0 {
570		t.Fatalf("browserWindows length = %d, want 0", got)
571	}
572}
573
574func setShellField(t *testing.T, bw *browserWindow, name string, value any) {
575	t.Helper()
576	rv := reflect.ValueOf(bw).Elem()
577	field := rv.FieldByName(name)
578	if !field.IsValid() {
579		t.Fatalf("browserWindow missing field %s", name)
580	}
581	reflect.NewAt(field.Type(), unsafe.Pointer(field.UnsafeAddr())).Elem().Set(reflect.ValueOf(value))
582}
583
584func fieldIsZero(t *testing.T, bw *browserWindow, name string) bool {
585	t.Helper()
586	rv := reflect.ValueOf(bw).Elem()
587	field := rv.FieldByName(name)
588	if !field.IsValid() {
589		t.Fatalf("browserWindow missing field %s", name)
590	}
591	return field.IsZero()
592}
593
594type testLayoutWidget struct{}
595
596func (*testLayoutWidget) Show()                   {}
597func (*testLayoutWidget) Hide()                   {}
598func (*testLayoutWidget) SetVisible(bool)         {}
599func (*testLayoutWidget) IsVisible() bool         { return false }
600func (*testLayoutWidget) SetOpacity(float64)      {}
601func (*testLayoutWidget) GrabFocus() bool         { return false }
602func (*testLayoutWidget) HasFocus() bool          { return false }
603func (*testLayoutWidget) SetCanFocus(bool)        {}
604func (*testLayoutWidget) SetFocusable(bool)       {}
605func (*testLayoutWidget) SetFocusOnClick(bool)    {}
606func (*testLayoutWidget) SetCanTarget(bool)       {}
607func (*testLayoutWidget) SetHexpand(bool)         {}
608func (*testLayoutWidget) SetVexpand(bool)         {}
609func (*testLayoutWidget) GetHexpand() bool        { return false }
610func (*testLayoutWidget) GetVexpand() bool        { return false }
611func (*testLayoutWidget) SetHalign(gtk.Align)     {}
612func (*testLayoutWidget) SetValign(gtk.Align)     {}
613func (*testLayoutWidget) SetSizeRequest(int, int) {}
614func (*testLayoutWidget) GetAllocatedWidth() int  { return 0 }
615func (*testLayoutWidget) GetAllocatedHeight() int { return 0 }
616func (*testLayoutWidget) ComputePoint(layout.Widget) (float64, float64, bool) {
617	return 0, 0, false
618}
619func (*testLayoutWidget) AddCssClass(string)                 {}
620func (*testLayoutWidget) RemoveCssClass(string)              {}
621func (*testLayoutWidget) HasCssClass(string) bool            { return false }
622func (*testLayoutWidget) Unparent()                          {}
623func (*testLayoutWidget) GetParent() layout.Widget           { return nil }
624func (*testLayoutWidget) GtkWidget() *gtk.Widget             { return nil }
625func (*testLayoutWidget) AddController(*gtk.EventController) {}
626
627type testPermissionDialogPresenter struct{}
628
629func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {
630}
631
632// --- Window tab list tests ---
633
634func TestGetWindowTabLists_RespectsRegistrationOrder(t *testing.T) {
635	// Register windows in non-lexicographic order: window-2 first, then window-1.
636	// The result should preserve creation order, not sorted order.
637	app := &App{
638		browserWindows: make(map[string]*browserWindow),
639	}
640
641	first := &browserWindow{id: "window-2"}
642	second := &browserWindow{id: "window-1"}
643	app.registerBrowserWindow(first)
644	app.registerBrowserWindow(second)
645
646	// Give each window a tab so they show up in results.
647	pane1 := entity.NewPane(entity.PaneID("p-w2"))
648	tab1 := entity.NewTab(entity.TabID("t-w2"), entity.WorkspaceID("ws-w2"), pane1)
649	pane2 := entity.NewPane(entity.PaneID("p-w1"))
650	tab2 := entity.NewTab(entity.TabID("t-w1"), entity.WorkspaceID("ws-w1"), pane2)
651
652	app.tabs = entity.NewTabList()
653	app.tabs.Add(tab1)
654	app.tabs.Add(tab2)
655	first.tabs.Add(tab1)
656	second.tabs.Add(tab2)
657	app.windowForTab = map[entity.TabID]*browserWindow{
658		tab1.ID: first,
659		tab2.ID: second,
660	}
661
662	result := app.GetWindowTabLists()
663
664	require.Len(t, result, 2)
665	assert.Equal(t, entity.WindowID("window-2"), result[0].WindowID, "first registered window should be first")
666	assert.Equal(t, entity.WindowID("window-1"), result[1].WindowID, "second registered window should be second")
667}
668
669func TestGetWindowTabLists_NoBrowserWindowsReturnsGlobalSnapshot(t *testing.T) {
670	pane := entity.NewPane(entity.PaneID("p1"))
671	tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
672	tabs := entity.NewTabList()
673	tabs.Add(tab)
674
675	app := &App{tabs: tabs}
676
677	result := app.GetWindowTabLists()
678
679	require.Len(t, result, 1)
680	assert.Equal(t, entity.WindowID(""), result[0].WindowID)
681	// Snapshot returns a copy so pointer identity is not expected; verify
682	// structural equality instead.
683	assert.Equal(t, tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
684	assert.Len(t, result[0].Tabs.Tabs, len(tabs.Tabs))
685}
686
687func TestGetWindowTabLists_AppliesActiveTabIndex(t *testing.T) {
688	// A window with its own TabList where the second tab is active.
689	app := &App{
690		browserWindows: make(map[string]*browserWindow),
691	}
692
693	pane1 := entity.NewPane(entity.PaneID("p1"))
694	tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
695	pane2 := entity.NewPane(entity.PaneID("p2"))
696	tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
697
698	app.tabs = entity.NewTabList()
699	app.tabs.Add(tab1)
700	app.tabs.Add(tab2)
701
702	bwTabs := entity.NewTabList()
703	bwTabs.Add(tab1)
704	bwTabs.Add(tab2)
705	bwTabs.SetActive(entity.TabID("t2"))
706	bw := &browserWindow{id: "w1", tabs: bwTabs}
707	app.registerBrowserWindow(bw)
708	app.windowForTab = map[entity.TabID]*browserWindow{
709		tab1.ID: bw,
710		tab2.ID: bw,
711	}
712
713	result := app.GetWindowTabLists()
714
715	require.Len(t, result, 1)
716	assert.Equal(t, entity.TabID("t2"), result[0].Tabs.ActiveTabID, "should reflect bw.tabs.ActiveTabID")
717}
718
719func TestGetWindowTabLists_PreservesPreviousActiveTabID(t *testing.T) {
720	app := &App{
721		browserWindows: make(map[string]*browserWindow),
722	}
723
724	pane1 := entity.NewPane(entity.PaneID("p1"))
725	tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
726	pane2 := entity.NewPane(entity.PaneID("p2"))
727	tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
728
729	app.tabs = entity.NewTabList()
730	app.tabs.Add(tab1)
731	app.tabs.Add(tab2)
732
733	bwTabs := entity.NewTabList()
734	bwTabs.Add(tab1)
735	bwTabs.Add(tab2)
736	bwTabs.SetActive(entity.TabID("t2")) // t2 becomes active, t1 becomes previous
737
738	bw := &browserWindow{id: "w1", tabs: bwTabs}
739	app.registerBrowserWindow(bw)
740	app.windowForTab = map[entity.TabID]*browserWindow{
741		tab1.ID: bw,
742		tab2.ID: bw,
743	}
744
745	result := app.GetWindowTabLists()
746
747	require.Len(t, result, 1)
748	assert.Equal(t, entity.TabID("t1"), result[0].Tabs.PreviousActiveTabID, "should preserve bw.tabs.PreviousActiveTabID")
749}
750
751func TestGetActiveWindowIndex_MatchesRegistrationOrder(t *testing.T) {
752	// Register windows in non-lexicographic order; lastFocusedWindowID should
753	// map to the correct index in registration order.
754	app := &App{
755		browserWindows: make(map[string]*browserWindow),
756	}
757
758	app.registerBrowserWindow(&browserWindow{id: "z-window"})
759	app.registerBrowserWindow(&browserWindow{id: "a-window"})
760	app.lastFocusedWindowID = "a-window"
761
762	idx := app.GetActiveWindowIndex()
763	assert.Equal(t, 1, idx, "a-window is the second registered window, index should be 1")
764}
765
766func TestGetActiveWindowIndex_MatchesGetWindowTabLists(t *testing.T) {
767	// The order of windows in GetWindowTabLists must match the indices returned
768	// by GetActiveWindowIndex.
769	app := &App{
770		browserWindows: make(map[string]*browserWindow),
771	}
772
773	w1 := &browserWindow{id: "w1"}
774	w2 := &browserWindow{id: "w2"}
775	w3 := &browserWindow{id: "w3"}
776	app.registerBrowserWindow(w1)
777	app.registerBrowserWindow(w2)
778	app.registerBrowserWindow(w3)
779
780	// Give each window a tab.
781	p1 := entity.NewPane(entity.PaneID("p1"))
782	t1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), p1)
783	p2 := entity.NewPane(entity.PaneID("p2"))
784	t2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), p2)
785	p3 := entity.NewPane(entity.PaneID("p3"))
786	t3 := entity.NewTab(entity.TabID("t3"), entity.WorkspaceID("ws3"), p3)
787	app.tabs = entity.NewTabList()
788	app.tabs.Add(t1)
789	app.tabs.Add(t2)
790	app.tabs.Add(t3)
791	w1.tabs.Add(t1)
792	w2.tabs.Add(t2)
793	w3.tabs.Add(t3)
794	app.windowForTab = map[entity.TabID]*browserWindow{
795		t1.ID: w1,
796		t2.ID: w2,
797		t3.ID: w3,
798	}
799
800	app.lastFocusedWindowID = "w2"
801
802	lists := app.GetWindowTabLists()
803	idx := app.GetActiveWindowIndex()
804
805	require.Len(t, lists, 3)
806	assert.Equal(t, entity.WindowID("w2"), lists[idx].WindowID, "active window ID must match the lists index")
807}
808
809func TestWindowOrder_FallbackWhenRegistrationOrderEmpty(t *testing.T) {
810	// When browserWindowOrder is empty (e.g., old tests / direct map manipulation),
811	// windowOrder must fall back to a deterministic (sorted) order.
812	app := &App{
813		browserWindows: map[string]*browserWindow{
814			"z-window": {id: "z-window"},
815			"a-window": {id: "a-window"},
816		},
817	}
818
819	order := app.windowOrder()
820
821	require.Len(t, order, 2)
822	// Sorted order: "a-window" < "z-window"
823	assert.Equal(t, "a-window", order[0])
824	assert.Equal(t, "z-window", order[1])
825}
826
827func TestRemoveBrowserWindow_RemovesFromOrder(t *testing.T) {
828	app := &App{
829		browserWindows: make(map[string]*browserWindow),
830	}
831
832	w1 := &browserWindow{id: "w1"}
833	w2 := &browserWindow{id: "w2"}
834	w3 := &browserWindow{id: "w3"}
835	app.registerBrowserWindow(w1)
836	app.registerBrowserWindow(w2)
837	app.registerBrowserWindow(w3)
838
839	app.removeBrowserWindow("w2")
840
841	require.Len(t, app.browserWindowOrder, 2)
842	assert.Equal(t, "w1", app.browserWindowOrder[0])
843	assert.Equal(t, "w3", app.browserWindowOrder[1])
844}
845
846func TestRegisterBrowserWindow_DuplicateIgnores(t *testing.T) {
847	app := &App{
848		browserWindows: make(map[string]*browserWindow),
849	}
850
851	bw := &browserWindow{id: "w1"}
852	app.registerBrowserWindow(bw)
853	app.registerBrowserWindow(bw) // duplicate
854
855	require.Len(t, app.browserWindowOrder, 1)
856}
857
858func TestApp_GetWindowTabListsUsesCreationOrderAndActiveIndex(t *testing.T) {
859	// Create two windows with per-window bw.tabs as the real source of truth.
860	// Ensure GetWindowTabLists returns them in registration order, with correct
861	// WindowID, and that the returned Tabs pointers are the per-window bw.tabs
862	// (not copies or derived from global App.tabs).
863	firstTabs := entity.NewTabList()
864	firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
865	firstTabs.Add(firstTab)
866	firstTabs.SetActive(entity.TabID("tab-1"))
867
868	secondTabs := entity.NewTabList()
869	secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
870	secondTabs.Add(secondTab)
871
872	first := &browserWindow{id: "window-1", tabs: firstTabs}
873	second := &browserWindow{id: "window-2", tabs: secondTabs}
874
875	app := &App{
876		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
877		browserWindowOrder:  []string{"window-1", "window-2"},
878		lastFocusedWindowID: "window-2",
879	}
880
881	result := app.GetWindowTabLists()
882
883	require.Len(t, result, 2)
884	assert.Equal(t, entity.WindowID("window-1"), result[0].WindowID)
885	assert.Equal(t, entity.WindowID("window-2"), result[1].WindowID)
886
887	// The returned Tabs are snapshot copies of per-window bw.tabs.
888	// Verify structural equality (not pointer identity, since
889	// GetWindowTabLists returns a snapshot for safe concurrent read).
890	assert.Equal(t, firstTabs.ActiveTabID, result[0].Tabs.ActiveTabID)
891	assert.Len(t, result[0].Tabs.Tabs, len(firstTabs.Tabs))
892	assert.Equal(t, secondTabs.ActiveTabID, result[1].Tabs.ActiveTabID)
893	assert.Len(t, result[1].Tabs.Tabs, len(secondTabs.Tabs))
894
895	// Active state reflects per-window bw.tabs.
896	assert.Equal(t, entity.TabID("tab-1"), result[0].Tabs.ActiveTabID)
897	assert.Equal(t, entity.TabID("tab-2"), result[1].Tabs.ActiveTabID)
898
899	// GetActiveWindowIndex respects lastFocusedWindowID mapped to registration order.
900	idx := app.GetActiveWindowIndex()
901	assert.Equal(t, 1, idx, "window-2 is second registered, index should be 1")
902}
903
904func TestApp_AssignRestoredWindowTabListsUsesFreshRuntimeWindowIDs(t *testing.T) {
905	// Create two restored TabLists with distinct tabs.
906	firstTabs := entity.NewTabList()
907	firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
908	firstTabs.Add(firstTab)
909	firstTabs.SetActive(entity.TabID("tab-1"))
910
911	secondTabs := entity.NewTabList()
912	secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
913	secondTabs.Add(secondTab)
914
915	// Create two runtime browser windows with fresh IDs distinct from saved IDs.
916	firstBW := &browserWindow{id: "runtime-w1"}
917	secondBW := &browserWindow{id: "runtime-w2"}
918
919	app := &App{
920		browserWindows: map[string]*browserWindow{"runtime-w1": firstBW, "runtime-w2": secondBW},
921	}
922
923	// Assign restored state using saved window IDs (simulating session restore).
924	app.assignRestoredWindowTabList(firstBW, entity.WindowTabListState{
925		WindowID: "saved-w1",
926		Tabs:     firstTabs,
927	})
928	app.assignRestoredWindowTabList(secondBW, entity.WindowTabListState{
929		WindowID: "saved-w2",
930		Tabs:     secondTabs,
931	})
932
933	// Tabs must be owned by runtime windows, not saved.
934	require.Same(t, firstTabs, firstBW.tabs, "first window tabs must be assigned")
935	require.Same(t, secondTabs, secondBW.tabs, "second window tabs must be assigned")
936
937	// windowForTab must map tabs to runtime windows.
938	require.Equal(t, firstBW, app.browserWindowForTab(firstTab.ID), "tab-1 must be owned by runtime-w1")
939	require.Equal(t, secondBW, app.browserWindowForTab(secondTab.ID), "tab-2 must be owned by runtime-w2")
940
941	// Saved window IDs must never leak into browserWindows.
942	require.Nil(t, app.browserWindows["saved-w1"], "saved-w1 must not appear in browserWindows")
943	require.Nil(t, app.browserWindows["saved-w2"], "saved-w2 must not appear in browserWindows")
944	assertWindowOwnershipInvariant(t, app)
945}
946
947func TestApp_BrowserWindowsOwnIndependentTabLists(t *testing.T) {
948	// Construct first browser window with its own TabList and distinct tabs.
949	firstTabs := entity.NewTabList()
950	firstTab := entity.NewTab(entity.TabID("tab-first"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
951	firstTabs.Add(firstTab)
952
953	first := &browserWindow{
954		id:   "window-first",
955		tabs: firstTabs,
956	}
957
958	// Construct second browser window with its own TabList and distinct tabs.
959	secondTabs := entity.NewTabList()
960	secondTab := entity.NewTab(entity.TabID("tab-second"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
961	secondTabs.Add(secondTab)
962
963	second := &browserWindow{
964		id:   "window-second",
965		tabs: secondTabs,
966	}
967
968	app := &App{
969		browserWindows: map[string]*browserWindow{
970			first.id:  first,
971			second.id: second,
972		},
973	}
974
975	// tabListForBrowserWindow returns the correct TabList for each window.
976	got := app.tabListForBrowserWindow(first)
977	if got != firstTabs {
978		t.Fatalf("tabListForBrowserWindow(first) = %p, want %p", got, firstTabs)
979	}
980	got = app.tabListForBrowserWindow(second)
981	if got != secondTabs {
982		t.Fatalf("tabListForBrowserWindow(second) = %p, want %p", got, secondTabs)
983	}
984
985	// Each window's tabs do not contain the other window's tabs.
986	if firstTabs.Find(secondTab.ID) != nil {
987		t.Fatalf("first window tabs unexpectedly contain second tab")
988	}
989	if secondTabs.Find(firstTab.ID) != nil {
990		t.Fatalf("second window tabs unexpectedly contain first tab")
991	}
992
993	// activeTabForBrowserWindow returns the correct active tab for each window.
994	gotTab := app.activeTabForBrowserWindow(first)
995	if gotTab != firstTab {
996		t.Fatalf("activeTabForBrowserWindow(first) = %p, want %p", gotTab, firstTab)
997	}
998	gotTab = app.activeTabForBrowserWindow(second)
999	if gotTab != secondTab {
1000		t.Fatalf("activeTabForBrowserWindow(second) = %p, want %p", gotTab, secondTab)
1001	}
1002
1003	app.windowForTab = map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second}
1004	assertWindowOwnershipInvariant(t, app)
1005}
1006
1007func TestRestoreSession_RemovesStaleBrowserWindows(t *testing.T) {
1008	// After restore, only runtime windows should remain in browserWindows
1009	// and browserWindowOrder. Stale pre-existing windows must be removed.
1010
1011	// Runtime windows with tabs (what restoreSession produces).
1012	runtime1Tabs := entity.NewTabList()
1013	r1Tab := entity.NewTab(entity.TabID("r1-t1"), entity.WorkspaceID("ws-r1"), entity.NewPane(entity.PaneID("p-r1")))
1014	runtime1Tabs.Add(r1Tab)
1015	runtime1Tabs.SetActive(entity.TabID("r1-t1"))
1016
1017	runtime2Tabs := entity.NewTabList()
1018	r2Tab := entity.NewTab(entity.TabID("r2-t1"), entity.WorkspaceID("ws-r2"), entity.NewPane(entity.PaneID("p-r2")))
1019	runtime2Tabs.Add(r2Tab)
1020	runtime2Tabs.SetActive(entity.TabID("r2-t1"))
1021
1022	runtime1 := &browserWindow{id: "runtime-w1", tabs: runtime1Tabs}
1023	runtime2 := &browserWindow{id: "runtime-w2", tabs: runtime2Tabs}
1024
1025	// Stale pre-existing browser window (no longer in runtime set).
1026	staleTabs := entity.NewTabList()
1027	staleTab := entity.NewTab(entity.TabID("stale-t1"), entity.WorkspaceID("ws-stale"), entity.NewPane(entity.PaneID("p-stale")))
1028	staleTabs.Add(staleTab)
1029	stale := &browserWindow{id: "stale-window", tabs: staleTabs}
1030
1031	app := &App{
1032		browserWindows: map[string]*browserWindow{
1033			"stale-window": stale,
1034			"runtime-w1":   runtime1,
1035			"runtime-w2":   runtime2,
1036		},
1037		tabs: entity.NewTabList(),
1038		windowForTab: map[entity.TabID]*browserWindow{
1039			staleTab.ID: stale,
1040			r1Tab.ID:    runtime1,
1041			r2Tab.ID:    runtime2,
1042		},
1043	}
1044
1045	// Simulate post-restore cleanup: remove stale windows not in runtime set.
1046	runtimeIDs := []string{"runtime-w1", "runtime-w2"}
1047	app.pruneStaleBrowserWindows([]*browserWindow{runtime1, runtime2})
1048
1049	// After cleanup, browserWindows contains only runtime windows.
1050	require.Len(t, app.browserWindows, 2)
1051	require.NotNil(t, app.browserWindows["runtime-w1"])
1052	require.NotNil(t, app.browserWindows["runtime-w2"])
1053	require.Nil(t, app.browserWindows["stale-window"])
1054
1055	// browserWindowOrder matches runtime order.
1056	require.Equal(t, runtimeIDs, app.browserWindowOrder)
1057
1058	// GetWindowTabLists returns only runtime windows.
1059	result := app.GetWindowTabLists()
1060	require.Len(t, result, 2)
1061	assert.Equal(t, entity.WindowID("runtime-w1"), result[0].WindowID)
1062	assert.Equal(t, entity.WindowID("runtime-w2"), result[1].WindowID)
1063
1064	// No stale tabs leak into the result.
1065	// Verify structural equality (snapshot copies, not live pointers).
1066	assert.Equal(t, runtime1Tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
1067	assert.Len(t, result[0].Tabs.Tabs, len(runtime1Tabs.Tabs))
1068	assert.Equal(t, runtime2Tabs.ActiveTabID, result[1].Tabs.ActiveTabID)
1069	assert.Len(t, result[1].Tabs.Tabs, len(runtime2Tabs.Tabs))
1070	assertWindowOwnershipInvariant(t, app)
1071}
1072
1073func TestRestoreSession_BrowserWindowOrderStartsWithFirstReusedWindow(t *testing.T) {
1074	// When the first restored window reuses a browser window that was not
1075	// already in browserWindowOrder, the restored order must start with
1076	// the reused window ID followed by newly-created window IDs.
1077
1078	reuseTabs := entity.NewTabList()
1079	reuseTab := entity.NewTab(entity.TabID("reuse-t1"), entity.WorkspaceID("ws-reuse"), entity.NewPane(entity.PaneID("p-reuse")))
1080	reuseTabs.Add(reuseTab)
1081	reuseBW := &browserWindow{id: "reused-window", tabs: reuseTabs}
1082
1083	createdTabs := entity.NewTabList()
1084	createdTab := entity.NewTab(entity.TabID("created-t1"), entity.WorkspaceID("ws-created"), entity.NewPane(entity.PaneID("p-created")))
1085	createdTabs.Add(createdTab)
1086	createdBW := &browserWindow{id: "created-window", tabs: createdTabs}
1087
1088	// Pre-existing browserWindowOrder does NOT contain the reused window.
1089	app := &App{
1090		browserWindows: map[string]*browserWindow{
1091			"reused-window":  reuseBW,
1092			"created-window": createdBW,
1093		},
1094		browserWindowOrder: []string{"old-stale-window"}, // reused window not present
1095		tabs:               entity.NewTabList(),
1096		windowForTab:       map[entity.TabID]*browserWindow{},
1097	}
1098
1099	// Exercise the helper used by restoreSession to prune stale windows and
1100	// replace browserWindowOrder with the runtime restore order.
1101	runtimeWindows := []*browserWindow{reuseBW, createdBW}
1102	app.pruneStaleBrowserWindows(runtimeWindows)
1103
1104	require.Len(t, app.browserWindowOrder, 2)
1105	assert.Equal(t, "reused-window", app.browserWindowOrder[0],
1106		"first reused window must be first in order")
1107	assert.Equal(t, "created-window", app.browserWindowOrder[1],
1108		"newly-created window must follow the reused window")
1109
1110	// GetWindowTabLists respects the order.
1111	result := app.GetWindowTabLists()
1112	require.Len(t, result, 2)
1113	assert.Equal(t, entity.WindowID("reused-window"), result[0].WindowID)
1114	assert.Equal(t, entity.WindowID("created-window"), result[1].WindowID)
1115}
1116
1117func TestRestoreSession_ActiveWindowIndexSyncsState(t *testing.T) {
1118	// After restore, GetActiveWindowIndex must return the restored active index.
1119	// The focused window state must be consistent: lastFocusedWindowID matches
1120	// the window at that index, and the active window's tab target aligns.
1121
1122	runtime1Tabs := entity.NewTabList()
1123	r1Tab := entity.NewTab(entity.TabID("a1-t1"), entity.WorkspaceID("ws-a1"), entity.NewPane(entity.PaneID("p-a1")))
1124	runtime1Tabs.Add(r1Tab)
1125	runtime1Tabs.SetActive(entity.TabID("a1-t1"))
1126
1127	runtime2Tabs := entity.NewTabList()
1128	r2Tab := entity.NewTab(entity.TabID("a2-t1"), entity.WorkspaceID("ws-a2"), entity.NewPane(entity.PaneID("p-a2")))
1129	runtime2Tabs.Add(r2Tab)
1130	runtime2Tabs.SetActive(entity.TabID("a2-t1"))
1131
1132	runtime1 := &browserWindow{id: "active-w1", tabs: runtime1Tabs}
1133	runtime2 := &browserWindow{id: "active-w2", tabs: runtime2Tabs}
1134
1135	app := &App{
1136		browserWindows: map[string]*browserWindow{
1137			"active-w1": runtime1,
1138			"active-w2": runtime2,
1139		},
1140		browserWindowOrder:  []string{"active-w1", "active-w2"},
1141		lastFocusedWindowID: "active-w2",
1142		tabs:                entity.NewTabList(),
1143		windowForTab:        map[entity.TabID]*browserWindow{},
1144	}
1145
1146	// Active index should be 1 (second window in order).
1147	idx := app.GetActiveWindowIndex()
1148	assert.Equal(t, 1, idx)
1149
1150	// The focused window is the one at that index.
1151	focused := app.lastFocusedBrowserWindow()
1152	require.NotNil(t, focused)
1153	assert.Equal(t, "active-w2", focused.id)
1154
1155	// Verify that GetActiveWindowIndex and GetWindowTabLists are consistent:
1156	// the window at the active index matches the focused window.
1157	result := app.GetWindowTabLists()
1158	require.Len(t, result, 2)
1159	assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID,
1160		"window at active index must match focused window ID")
1161}
1162
1163func TestApp_PermissionDialogForPane(t *testing.T) {
1164	tabID := entity.TabID("tab-1")
1165	tabPane := entity.PaneID("pane-in-tab")
1166	floatingPane := entity.PaneID("pane-floating")
1167	tab := entity.NewTab(tabID, entity.WorkspaceID("ws-1"), entity.NewPane(tabPane))
1168	tabs := entity.NewTabList()
1169	tabs.Add(tab)
1170	dialogA := &testPermissionDialogPresenter{}
1171	owner := &browserWindow{id: "window-1", tabs: tabs, permissionDialog: dialogA}
1172	noDialog := &browserWindow{id: "window-2", tabs: entity.NewTabList()}
1173	app := &App{
1174		browserWindows: map[string]*browserWindow{owner.id: owner, noDialog.id: noDialog},
1175		windowForTab:   map[entity.TabID]*browserWindow{tabID: owner},
1176		floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
1177			{tabID: tabID, sessionID: "profile"}: {paneID: floatingPane},
1178		},
1179	}
1180
1181	assert.Same(t, dialogA, app.permissionDialogForPane(tabPane), "tiled pane")
1182	assert.Same(t, dialogA, app.permissionDialogForPane(floatingPane), "floating pane uses its tab's window")
1183	assert.Nil(t, app.permissionDialogForPane("unknown"), "unowned pane clears the presenter")
1184	assert.Nil(t, app.permissionDialogForPane(""))
1185
1186	owner.permissionDialog = nil
1187	assert.Nil(t, app.permissionDialogForPane(tabPane), "window without dialog")
1188}
1189
1190func TestApp_ResetWebRTCIndicatorOnNavigationCoversFloatingPanes(t *testing.T) {
1191	tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1192	tabs := entity.NewTabList()
1193	tabs.Add(tab)
1194	indicator := &component.WebRTCPermissionIndicator{}
1195	bw := &browserWindow{id: "window-1", tabs: tabs, webrtcIndicator: indicator}
1196	floatingPaneID := entity.PaneID("floating-pane")
1197	app := &App{
1198		browserWindows: map[string]*browserWindow{bw.id: bw},
1199		windowForTab:   map[entity.TabID]*browserWindow{tab.ID: bw},
1200		floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
1201			{tabID: tab.ID, sessionID: "profile:one"}: {paneID: floatingPaneID},
1202		},
1203	}
1204
1205	indicator.SetOrigin("https://old.example")
1206	app.resetWebRTCIndicatorOnNavigation(floatingPaneID, "https://old.example/page")
1207	assert.Equal(t, "https://old.example", indicator.Origin(), "same-origin navigation keeps the indicator")
1208
1209	app.resetWebRTCIndicatorOnNavigation(floatingPaneID, "https://new.example/")
1210	assert.Empty(t, indicator.Origin(), "cross-origin navigation in a floating pane resets the indicator")
1211}

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