v0.29.0 21b3150

app_browser_launch_test.go RawHistoryFolder

1package ui
2
3import (
4	"context"
5	"errors"
6	"io"
7	"math"
8	"os"
9	"path/filepath"
10	"reflect"
11	"strconv"
12	"strings"
13	"testing"
14	"time"
15	"unsafe"
16
17	"github.com/bnema/dumber/internal/application/port"
18	portmocks "github.com/bnema/dumber/internal/application/port/mocks"
19	"github.com/bnema/dumber/internal/application/usecase"
20	"github.com/bnema/dumber/internal/domain/entity"
21	"github.com/bnema/dumber/internal/domain/repository"
22	"github.com/bnema/dumber/internal/infrastructure/config"
23	"github.com/bnema/dumber/internal/ui/component"
24	"github.com/bnema/dumber/internal/ui/coordinator"
25	contentcoord "github.com/bnema/dumber/internal/ui/coordinator/content"
26	"github.com/bnema/dumber/internal/ui/dispatcher"
27	"github.com/bnema/dumber/internal/ui/input"
28	"github.com/bnema/dumber/internal/ui/layout"
29	layoutmocks "github.com/bnema/dumber/internal/ui/layout/mocks"
30	"github.com/bnema/dumber/internal/ui/window"
31	"github.com/bnema/puregotk/v4/gdk"
32	"github.com/bnema/puregotk/v4/gio"
33	"github.com/bnema/puregotk/v4/gtk"
34)
35
36type testBrowserLaunchRelay struct {
37	listenCalls int
38	closer      *testCloser
39}
40
41func (r *testBrowserLaunchRelay) DeliverOpenFreshWindow(context.Context, string) (bool, error) {
42	return false, nil
43}
44
45func (r *testBrowserLaunchRelay) Listen(_ context.Context, opener port.BrowserWindowOpener) (io.Closer, error) {
46	r.listenCalls++
47	_ = opener
48	return r.closer, nil
49}
50
51type testCloser struct {
52	closed bool
53}
54
55func (c *testCloser) Close() error {
56	c.closed = true
57	return nil
58}
59
60type fakeSessionStateRepo struct {
61	state *entity.SessionState
62}
63
64var _ repository.SessionStateRepository = (*fakeSessionStateRepo)(nil)
65
66func (r *fakeSessionStateRepo) SaveSnapshot(context.Context, *entity.SessionState) error { return nil }
67
68func (r *fakeSessionStateRepo) GetSnapshot(context.Context, entity.SessionID) (*entity.SessionState, error) {
69	return r.state, nil
70}
71
72func (r *fakeSessionStateRepo) DeleteSnapshot(context.Context, entity.SessionID) error { return nil }
73
74func (r *fakeSessionStateRepo) GetAllSnapshots(context.Context) ([]*entity.SessionState, error) {
75	return nil, nil
76}
77
78func (r *fakeSessionStateRepo) GetTotalSnapshotsSize(context.Context) (int64, error) { return 0, nil }
79
80func testHasUsableGTKDisplay() bool {
81	if waylandDisplay := os.Getenv("WAYLAND_DISPLAY"); waylandDisplay != "" {
82		candidates := []string{waylandDisplay}
83		if runtimeDir := os.Getenv("XDG_RUNTIME_DIR"); runtimeDir != "" && !filepath.IsAbs(waylandDisplay) {
84			candidates = append([]string{filepath.Join(runtimeDir, waylandDisplay)}, candidates...)
85		}
86		for _, candidate := range candidates {
87			if _, err := os.Stat(candidate); err == nil {
88				return true
89			}
90		}
91	}
92
93	if display := os.Getenv("DISPLAY"); display != "" {
94		if strings.HasPrefix(display, ":") {
95			displayNum := strings.TrimPrefix(display, ":")
96			displayNum = strings.SplitN(displayNum, ".", 2)[0]
97			if displayNum == "" {
98				return false
99			}
100			x11SocketCandidates := []string{filepath.Join(os.TempDir(), ".X11-unix", "X"+displayNum)}
101			if fallback := "/tmp/.X11-unix/X" + displayNum; fallback != x11SocketCandidates[0] {
102				x11SocketCandidates = append(x11SocketCandidates, fallback)
103			}
104			for _, candidate := range x11SocketCandidates {
105				if _, err := os.Stat(candidate); err == nil {
106					return true
107				}
108			}
109			return false
110		}
111		return true
112	}
113
114	return false
115}
116
117func requireGTKDisplayApp(t *testing.T) *gtk.Application {
118	t.Helper()
119	if !testHasUsableGTKDisplay() {
120		t.Skip("GTK display not available")
121	}
122	EnsureAdwaitaInitialized()
123	if gdk.DisplayGetDefault() == nil {
124		t.Skip("GTK display not available")
125	}
126	appID := AppID
127	gtkApp := gtk.NewApplication(&appID, gio.GApplicationNonUniqueValue)
128	if gtkApp == nil {
129		t.Fatal("gtk application creation failed")
130	}
131	return gtkApp
132}
133
134// setDependencyField uses reflection to reach unexported dependency fields for tests.
135// Keep this test-only so production visibility stays narrow.
136func setDependencyField(t *testing.T, deps *Dependencies, field string, value any) {
137	t.Helper()
138
139	rv := reflect.ValueOf(deps).Elem()
140	fv := rv.FieldByName(field)
141	if !fv.IsValid() {
142		t.Fatalf("Dependencies missing %s field", field)
143	}
144	fv.Set(reflect.ValueOf(value))
145}
146
147// windowForTabCount uses reflection to inspect unexported app state in tests.
148// Keep this test-only so production visibility stays narrow.
149func windowForTabCount(t *testing.T, app *App) int {
150	t.Helper()
151
152	rv := reflect.ValueOf(app).Elem()
153	fv := rv.FieldByName("windowForTab")
154	if !fv.IsValid() {
155		t.Fatalf("App missing windowForTab field")
156	}
157	return fv.Len()
158}
159
160func assertWindowOwnershipInvariant(t *testing.T, app *App) {
161	t.Helper()
162
163	seen := make(map[entity.TabID]string)
164	for windowID, bw := range app.browserWindows {
165		if bw == nil {
166			continue
167		}
168		if bw.tabs == nil {
169			t.Errorf("browser window %s has nil tabs", windowID)
170			continue
171		}
172		for _, tab := range bw.tabs.Tabs {
173			if tab == nil {
174				continue
175			}
176			if previous, exists := seen[tab.ID]; exists {
177				t.Errorf("tab %s is owned by both %s and %s", tab.ID, previous, windowID)
178				continue
179			}
180			seen[tab.ID] = windowID
181			if got := app.windowForTab[tab.ID]; got != bw {
182				t.Errorf("windowForTab[%s] = %p, want owner window %s (%p)", tab.ID, got, windowID, bw)
183			}
184		}
185	}
186	for tabID, bw := range app.windowForTab {
187		if bw == nil || bw.tabs == nil || bw.tabs.Find(tabID) == nil {
188			t.Errorf("windowForTab[%s] points to stale or non-owning window %p", tabID, bw)
189		}
190	}
191}
192
193// tabCoordinatorMainWindowPtr uses reflection to inspect unexported coordinator state in tests.
194// Keep this test-only so production visibility stays narrow.
195func tabCoordinatorMainWindowPtr(t *testing.T, tc *coordinator.TabCoordinator) uintptr {
196	t.Helper()
197
198	rv := reflect.ValueOf(tc).Elem()
199	fv := rv.FieldByName("mainWindow")
200	if !fv.IsValid() {
201		t.Fatalf("TabCoordinator missing mainWindow field")
202	}
203	return fv.Pointer()
204}
205
206func setWindowTabBar(t *testing.T, mw *window.MainWindow, tabBar *component.TabBar) {
207	t.Helper()
208
209	rv := reflect.ValueOf(mw).Elem()
210	fv := rv.FieldByName("tabBar")
211	if !fv.IsValid() {
212		t.Fatalf("MainWindow missing tabBar field")
213	}
214	reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(tabBar))
215}
216
217func omniboxNavigateCallbackForTest(t *testing.T, omnibox *component.Omnibox) func(context.Context, string) error {
218	t.Helper()
219	if omnibox == nil {
220		t.Fatal("omnibox is nil")
221	}
222
223	rv := reflect.ValueOf(omnibox).Elem()
224	fv := rv.FieldByName("onNavigate")
225	if !fv.IsValid() {
226		t.Fatal("Omnibox missing onNavigate field")
227	}
228	cb, ok := reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Interface().(func(context.Context, string) error)
229	if !ok || cb == nil {
230		t.Fatal("omnibox onNavigate callback is nil or has unexpected type")
231	}
232	return cb
233}
234
235func newTestTabBarShell(t *testing.T, tabIDs ...entity.TabID) *component.TabBar {
236	t.Helper()
237
238	tabBar := &component.TabBar{}
239	buttons := make(map[entity.TabID]*component.TabButton, len(tabIDs))
240	for _, tabID := range tabIDs {
241		buttons[tabID] = &component.TabButton{}
242	}
243
244	rv := reflect.ValueOf(tabBar).Elem()
245	fv := rv.FieldByName("buttons")
246	if !fv.IsValid() {
247		t.Fatalf("TabBar missing buttons field")
248	}
249	reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(buttons))
250
251	return tabBar
252}
253
254func windowTabBarActiveID(t *testing.T, mw *window.MainWindow) entity.TabID {
255	t.Helper()
256	if mw == nil || mw.TabBar() == nil {
257		return ""
258	}
259	return mw.TabBar().ActiveTabID()
260}
261
262func windowTitle(t *testing.T, mw *window.MainWindow) string {
263	t.Helper()
264	if mw == nil || mw.Window() == nil {
265		return ""
266	}
267	return mw.Window().GetTitle()
268}
269
270func windowTabBarVisible(t *testing.T, mw *window.MainWindow) bool {
271	t.Helper()
272	if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil {
273		return false
274	}
275	return mw.TabBar().Box().GetVisible()
276}
277
278func assertWindowTabBarAutoHidden(t *testing.T, mw *window.MainWindow, wantHidden bool) {
279	t.Helper()
280	if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil {
281		t.Fatal("missing tab bar")
282	}
283	box := mw.TabBar().Box()
284	if got := box.GetVisible(); !got {
285		t.Fatalf("tab bar visible = %v, want true so allocation is preserved", got)
286	}
287	if wantHidden {
288		if got := box.GetOpacity(); got != 0.0 {
289			t.Fatalf("tab bar opacity = %v, want 0", got)
290		}
291		if got := box.GetCanTarget(); got {
292			t.Fatalf("tab bar can target = %v, want false while auto-hidden", got)
293		}
294		return
295	}
296	if got := box.GetOpacity(); got != 1.0 {
297		t.Fatalf("tab bar opacity = %v, want 1", got)
298	}
299	if got := box.GetCanTarget(); !got {
300		t.Fatalf("tab bar can target = %v, want true while shown", got)
301	}
302}
303
304func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool {
305	t.Helper()
306	if sv == nil {
307		return true
308	}
309
310	rv := reflect.ValueOf(sv).Elem()
311	fv := rv.FieldByName("onActivate")
312	if !fv.IsValid() {
313		t.Fatalf("StackedView missing onActivate field")
314	}
315	return reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().IsNil()
316}
317
318func newTestShellToaster(t *testing.T) (*component.Toaster, *layoutmocks.MockBoxWidget, *layoutmocks.MockLabelWidget) {
319	t.Helper()
320
321	factory := layoutmocks.NewMockWidgetFactory(t)
322	box := layoutmocks.NewMockBoxWidget(t)
323	label := layoutmocks.NewMockLabelWidget(t)
324
325	factory.EXPECT().NewBox(layout.OrientationHorizontal, 0).Return(box).Once()
326	factory.EXPECT().NewLabel("").Return(label).Once()
327	box.EXPECT().AddCssClass("toast").Once()
328	box.EXPECT().AddCssClass("toast-info").Once()
329	box.EXPECT().SetHalign(gtk.AlignStartValue).Twice()
330	box.EXPECT().SetValign(gtk.AlignStartValue).Twice()
331	box.EXPECT().SetHexpand(false).Once()
332	box.EXPECT().SetVexpand(false).Once()
333	box.EXPECT().SetCanTarget(false).Once()
334	box.EXPECT().SetCanFocus(false).Once()
335	box.EXPECT().SetVisible(false).Once()
336	box.EXPECT().Append(label).Once()
337	label.EXPECT().SetCanTarget(false).Once()
338	label.EXPECT().SetCanFocus(false).Once()
339
340	return component.NewToaster(factory), box, label
341}
342
343func TestApp_ShowFilterStatusUsesLastFocusedBrowserWindowToaster(t *testing.T) {
344	ctx := context.Background()
345	toaster, box, label := newTestShellToaster(t)
346	box.EXPECT().SetVisible(true).Once()
347	label.EXPECT().SetText("Ad blocker loading").Once()
348
349	bw := &browserWindow{id: "window-1", appToaster: toaster}
350	app := &App{
351		browserWindows:      map[string]*browserWindow{bw.id: bw},
352		lastFocusedWindowID: bw.id,
353	}
354
355	app.showFilterStatus(ctx, port.FilterStatus{State: port.FilterStateLoading, Message: "Ad blocker loading"})
356}
357
358func TestApp_CheckConfigMigrationUsesLastFocusedBrowserWindowToaster(t *testing.T) {
359	ctx := context.Background()
360	toaster, box, label := newTestShellToaster(t)
361	box.EXPECT().SetVisible(true).Once()
362	label.EXPECT().SetText("Config has 1 new settings. Run 'dumber config migrate'").Once()
363
364	migrator := portmocks.NewMockConfigMigrator(t)
365	migrator.EXPECT().CheckMigration().Return(&port.MigrationResult{MissingKeys: []string{"update.notify_on_new_settings"}}, nil).Once()
366
367	bw := &browserWindow{id: "window-1", appToaster: toaster}
368	app := &App{
369		deps: &Dependencies{
370			Config: &config.Config{
371				Update: config.UpdateConfig{NotifyOnNewSettings: true},
372			},
373			ConfigMigrator: migrator,
374		},
375		browserWindows:      map[string]*browserWindow{bw.id: bw},
376		lastFocusedWindowID: bw.id,
377	}
378
379	app.checkConfigMigration(ctx)
380}
381
382func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t *testing.T) {
383	relay := &testBrowserLaunchRelay{closer: &testCloser{}}
384	deps := &Dependencies{}
385	setDependencyField(t, deps, "BrowserLaunchRelay", relay)
386
387	app := &App{deps: deps, cancel: func(error) {}}
388
389	app.finalizeActivation(context.Background())
390	app.finalizeActivation(context.Background())
391
392	if relay.listenCalls != 1 {
393		t.Fatalf("Listen calls = %d, want 1", relay.listenCalls)
394	}
395
396	app.onShutdown(context.Background())
397
398	if !relay.closer.closed {
399		t.Fatalf("relay listener closer was not closed")
400	}
401}
402
403func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
404	existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
405	existingTabs := entity.NewTabList()
406	existingTabs.Add(existingTab)
407	existingTabs.SetActive(existingTab.ID)
408	existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs}
409	created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
410	app := &App{
411		tabs:           entity.NewTabList(),
412		tabsUC:         usecase.NewManageTabsUseCase(func() string { return "id-1" }),
413		browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
414		windowForTab:   map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
415		workspaceViews: map[entity.TabID]*component.WorkspaceView{entity.TabID("id-1"): &component.WorkspaceView{}},
416		browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
417			return created, nil
418		},
419	}
420
421	if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
422		t.Fatalf("OpenFreshWindow returned error: %v", err)
423	}
424	if got := windowForTabCount(t, app); got != 2 {
425		t.Fatalf("windowForTab length = %d, want 2 (existing + new tab)", got)
426	}
427	assertWindowOwnershipInvariant(t, app)
428}
429
430func TestApp_RestoreSessionHandlesEmptyWindowSnapshotAsSuccess(t *testing.T) {
431	ctx := context.Background()
432	sessionID := entity.SessionID("session-empty-windows")
433	staleTab := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-workspace"), entity.NewPane(entity.PaneID("stale-pane")))
434	staleTabs := entity.NewTabList()
435	staleTabs.Add(staleTab)
436	staleWindow := &browserWindow{id: "window-stale", tabs: staleTabs}
437	state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))
438
439	app := &App{
440		deps: &Dependencies{
441			Config:           &config.Config{},
442			SessionStateRepo: &fakeSessionStateRepo{state: state},
443		},
444		tabs:                entity.NewTabList(),
445		browserWindows:      map[string]*browserWindow{staleWindow.id: staleWindow},
446		lastFocusedWindowID: staleWindow.id,
447		workspaceViews:      map[entity.TabID]*component.WorkspaceView{staleTab.ID: {}},
448		windowForTab:        map[entity.TabID]*browserWindow{staleTab.ID: staleWindow},
449	}
450	app.tabs.Add(staleTab)
451
452	err := app.restoreSession(ctx, sessionID)
453	if err != nil {
454		t.Fatalf("restoreSession returned error for valid v2 empty window snapshot: %v", err)
455	}
456	// Stale state should be cleared and stale window should have an empty tab list.
457	if _, ok := app.workspaceViews[staleTab.ID]; ok {
458		t.Fatal("stale workspace view was not cleared")
459	}
460	if _, ok := app.windowForTab[staleTab.ID]; ok {
461		t.Fatal("stale windowForTab entry was not cleared")
462	}
463	if got := app.tabs.Count(); got != 0 {
464		t.Fatalf("app.tabs.Count() = %d, want 0 after empty restore", got)
465	}
466	if got := staleWindow.tabs.Count(); got != 0 {
467		t.Fatalf("staleWindow.tabs.Count() = %d, want 0 after empty restore", got)
468	}
469}
470
471func TestApp_CreateInitialTabNoFallbackAfterEmptyWindowRestore(t *testing.T) {
472	ctx := context.Background()
473	sessionID := entity.SessionID("session-empty-windows-fallback")
474	state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))
475	tabsUC := usecase.NewManageTabsUseCase(func() string { return "fallback-tab" })
476	windowTabs := entity.NewTabList()
477	bw := &browserWindow{id: "window-1", tabs: windowTabs}
478	app := &App{
479		deps: &Dependencies{
480			Config:           &config.Config{},
481			RestoreSessionID: string(sessionID),
482			InitialURL:       "https://fallback.example",
483			SessionStateRepo: &fakeSessionStateRepo{state: state},
484		},
485		tabs:                entity.NewTabList(),
486		tabsUC:              tabsUC,
487		tabCoord:            coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: tabsUC, Tabs: entity.NewTabList()}),
488		browserWindows:      map[string]*browserWindow{bw.id: bw},
489		lastFocusedWindowID: bw.id,
490	}
491
492	app.createInitialTab(ctx)
493
494	// For valid v2 empty-window restore, no fallback tab should be created.
495	if created := windowTabs.Find(entity.TabID("fallback-tab")); created != nil {
496		t.Fatal("fallback tab was created despite valid v2 empty-window restore")
497	}
498	if got := app.tabs.Count(); got != 0 {
499		t.Fatalf("app.tabs.Count() = %d, want 0 after empty restore", got)
500	}
501}
502
503func TestApp_RestoreSessionClearsStaleUIStateBeforeApplyingRestoredTabs(t *testing.T) {
504	gtkApp := requireGTKDisplayApp(t)
505	defer gtkApp.Unref()
506
507	mainWindow, err := window.New(context.Background(), gtkApp, "top")
508	if err != nil {
509		t.Fatalf("window creation failed: %v", err)
510	}
511	defer mainWindow.Destroy()
512
513	staleTab := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-workspace"), entity.NewPane(entity.PaneID("stale-pane")))
514	staleWindow := &browserWindow{id: "window-stale", mainWindow: mainWindow}
515	staleSessionKey := floatingSessionKey{tabID: staleTab.ID, sessionID: "profile-stale"}
516	restoredSessionID := entity.SessionID("session-restore")
517	restoredTabs := entity.NewTabList()
518	restoredTabs.Add(entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-workspace"), entity.NewPane(entity.PaneID("restored-pane"))))
519
520	app := &App{
521		deps: &Dependencies{
522			Config:           &config.Config{},
523			SessionStateRepo: &fakeSessionStateRepo{state: entity.SnapshotFromTabList(restoredSessionID, restoredTabs)},
524		},
525		mainWindow:     mainWindow,
526		widgetFactory:  layout.NewGtkWidgetFactory(),
527		tabs:           entity.NewTabList(),
528		browserWindows: map[string]*browserWindow{staleWindow.id: staleWindow},
529		workspaceViews: map[entity.TabID]*component.WorkspaceView{staleTab.ID: &component.WorkspaceView{}},
530		windowForTab:   map[entity.TabID]*browserWindow{staleTab.ID: staleWindow},
531		floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
532			staleSessionKey: {},
533		},
534	}
535	app.tabs.Add(staleTab)
536	mainWindow.TabBar().AddTab(staleTab)
537
538	if err := app.restoreSession(context.Background(), restoredSessionID); err != nil {
539		t.Fatalf("restoreSession returned error: %v", err)
540	}
541
542	if got := app.tabs.Count(); got != 1 {
543		t.Fatalf("tabs.Count() = %d, want 1", got)
544	}
545	if got := mainWindow.TabBar().Count(); got != 1 {
546		t.Fatalf("tab bar count = %d, want 1", got)
547	}
548	if _, ok := app.workspaceViews[staleTab.ID]; ok {
549		t.Fatalf("stale workspace view was not removed")
550	}
551	if _, ok := app.windowForTab[staleTab.ID]; ok {
552		t.Fatalf("stale windowForTab entry was not removed")
553	}
554	if _, ok := app.floatingSessions[staleSessionKey]; ok {
555		t.Fatalf("stale floating session was not removed")
556	}
557	assertWindowOwnershipInvariant(t, app)
558}
559
560func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) {
561	gtkApp := requireGTKDisplayApp(t)
562	defer gtkApp.Unref()
563
564	mainWindow, err := window.New(context.Background(), gtkApp, "top")
565	if err != nil {
566		t.Fatalf("window creation failed: %v", err)
567	}
568	defer mainWindow.Destroy()
569
570	stackedSessionID := entity.SessionID("session-stacked-restore")
571	stackedTabs := entity.NewTabList()
572	stackedTabs.Add(entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-workspace"), entity.NewPane(entity.PaneID("restored-pane-1"))))
573	stackedTabs.Tabs[0].Workspace = &entity.Workspace{
574		ID: entity.WorkspaceID("restored-workspace"),
575		Root: &entity.PaneNode{
576			ID:        "restored-stack-root",
577			IsStacked: true,
578			Children: []*entity.PaneNode{
579				{ID: "restored-child-1", Pane: entity.NewPane(entity.PaneID("restored-pane-1"))},
580				{ID: "restored-child-2", Pane: entity.NewPane(entity.PaneID("restored-pane-2"))},
581			},
582			ActiveStackIndex: 0,
583		},
584		ActivePaneID: entity.PaneID("restored-pane-1"),
585	}
586
587	app := &App{
588		deps: &Dependencies{
589			Config:           &config.Config{},
590			SessionStateRepo: &fakeSessionStateRepo{state: entity.SnapshotFromTabList(stackedSessionID, stackedTabs)},
591		},
592		mainWindow:     mainWindow,
593		widgetFactory:  layout.NewGtkWidgetFactory(),
594		contentCoord:   &contentcoord.Coordinator{},
595		wsCoord:        coordinator.NewWorkspaceCoordinator(context.Background(), coordinator.WorkspaceCoordinatorConfig{ContentCoord: &contentcoord.Coordinator{}}),
596		tabs:           entity.NewTabList(),
597		browserWindows: map[string]*browserWindow{},
598		workspaceViews: map[entity.TabID]*component.WorkspaceView{},
599		windowForTab:   map[entity.TabID]*browserWindow{},
600	}
601
602	if err := app.restoreSession(context.Background(), stackedSessionID); err != nil {
603		t.Fatalf("restoreSession returned error: %v", err)
604	}
605
606	restoredTab := app.tabs.ActiveTab()
607	if restoredTab == nil {
608		t.Fatal("restored active tab missing")
609	}
610	wv := app.workspaceViews[restoredTab.ID]
611	if wv == nil {
612		t.Fatal("restored workspace view missing")
613	}
614	tr := wv.TreeRenderer()
615	if tr == nil {
616		t.Fatal("restored tree renderer missing")
617	}
618	if restoredTab.Workspace == nil || restoredTab.Workspace.Root == nil || len(restoredTab.Workspace.Root.Children) == 0 {
619		t.Fatal("restored stacked workspace missing children")
620	}
621	stackedView := tr.GetStackedViewForPane(string(restoredTab.Workspace.Root.Children[0].Pane.ID))
622	if stackedView == nil {
623		t.Fatal("restored stacked view missing")
624	}
625	if stackedViewOnActivateIsNil(t, stackedView) {
626		t.Fatal("restored stacked view onActivate callback is nil")
627	}
628}
629
630func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T) {
631	oldWindow := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
632	newWindow := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
633	tc := coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
634		MainWindow: oldWindow.mainWindow,
635	})
636	app := &App{
637		mainWindow:     oldWindow.mainWindow,
638		browserWindows: map[string]*browserWindow{oldWindow.id: oldWindow, newWindow.id: newWindow},
639		tabCoord:       tc,
640	}
641
642	app.removeBrowserWindow(oldWindow.id)
643
644	if app.mainWindow != newWindow.mainWindow {
645		t.Fatalf("mainWindow = %p, want %p", app.mainWindow, newWindow.mainWindow)
646	}
647	if got := tabCoordinatorMainWindowPtr(t, tc); got != reflect.ValueOf(newWindow.mainWindow).Pointer() {
648		t.Fatalf("tab coordinator mainWindow = %x, want %x", got, reflect.ValueOf(newWindow.mainWindow).Pointer())
649	}
650}
651
652func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
653	created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
654	originalWindow := &window.MainWindow{}
655	tabBar := &component.TabBar{}
656	setWindowTabBar(t, originalWindow, tabBar)
657	existingTabID := entity.TabID("existing-tab")
658	staleTabID := entity.TabID("stale-tab")
659	tabBar.SetActive(staleTabID)
660
661	existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
662	existingTabs := entity.NewTabList()
663	existingTabs.Add(existingTab)
664	existingTabs.SetActive(existingTab.ID)
665	existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs, mainWindow: originalWindow}
666
667	app := &App{
668		tabs:           entity.NewTabList(),
669		tabsUC:         usecase.NewManageTabsUseCase(func() string { return "id-1" }),
670		browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
671		windowForTab:   map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
672		browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
673			return created, nil
674		},
675		mainWindow: originalWindow,
676	}
677	app.tabCoord = coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
678		TabsUC:     app.tabsUC,
679		Tabs:       entity.NewTabList(),
680		MainWindow: &window.MainWindow{},
681	})
682
683	if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil {
684		t.Fatalf("OpenFreshWindow = nil error, want failure")
685	}
686	if got := len(app.browserWindows); got != 1 {
687		t.Fatalf("browserWindows length = %d, want 1 (existing window only)", got)
688	}
689	if app.browserWindows[existingWindow.id] == nil {
690		t.Fatal("existing window should remain after rollback")
691	}
692	if app.browserWindows["window-1"] != nil {
693		t.Fatal("created window should be removed after rollback")
694	}
695	if got := windowForTabCount(t, app); got != 1 {
696		t.Fatalf("windowForTab length = %d, want 1 (existing tab only)", got)
697	}
698	if got := windowTabBarActiveID(t, originalWindow); got != staleTabID {
699		t.Fatalf("tab bar active tab = %q, want %q (original window unchanged)", got, staleTabID)
700	}
701}
702
703func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
704	existingTabID := entity.TabID("existing-tab")
705	createdTabID := entity.TabID("id-1")
706	oldWindow := &window.MainWindow{}
707	newWindow := &window.MainWindow{}
708	setWindowTabBar(t, oldWindow, newTestTabBarShell(t))
709	setWindowTabBar(t, newWindow, newTestTabBarShell(t, createdTabID))
710	tabs := entity.NewTabList()
711	tabsUC := usecase.NewManageTabsUseCase(func() string { return string(createdTabID) })
712
713	existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
714	existingTabs := entity.NewTabList()
715	existingTabs.Add(existingTab)
716	existingTabs.SetActive(existingTab.ID)
717	existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs, mainWindow: oldWindow}
718
719	app := &App{
720		tabs:           tabs,
721		tabsUC:         tabsUC,
722		browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
723		windowForTab:   map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
724		browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
725			return &browserWindow{id: "window-1", mainWindow: newWindow}, nil
726		},
727		mainWindow: oldWindow,
728		tabCoord: coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
729			TabsUC:                  tabsUC,
730			Tabs:                    tabs,
731			MainWindow:              oldWindow,
732			HideTabBarWhenSingleTab: true,
733		}),
734		workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
735	}
736	app.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
737		app.workspaceViews[tab.ID] = &component.WorkspaceView{}
738	})
739
740	if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
741		t.Fatalf("OpenFreshWindow returned error: %v", err)
742	}
743
744	gotCreatedTabID := app.tabs.ActiveTabID
745	if gotCreatedTabID == "" {
746		t.Fatalf("created tab id = %q, want non-empty", gotCreatedTabID)
747	}
748	if got := windowTabBarActiveID(t, newWindow); got != gotCreatedTabID {
749		t.Fatalf("new window tab bar active tab = %q, want %q", got, gotCreatedTabID)
750	}
751	if got := windowTabBarActiveID(t, oldWindow); got != "" {
752		t.Fatalf("old window tab bar active tab = %q, want empty", got)
753	}
754	// The lightweight test tab bar shell has no GTK box, so allocation state is
755	// covered by TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation.
756}
757
758func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {
759	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
760	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
761	tabs := entity.NewTabList()
762	tabs.Add(tab1)
763	tabs.Add(tab2)
764	ws1 := &component.WorkspaceView{}
765	ws2 := &component.WorkspaceView{}
766	firstTabs := entity.NewTabList()
767	firstTabs.Add(tab1)
768	firstTabs.SetActive(tab1.ID)
769	first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
770	secondTabs := entity.NewTabList()
771	secondTabs.Add(tab2)
772	secondTabs.SetActive(tab2.ID)
773	second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
774	kh := &input.KeyboardHandler{}
775	gs := &input.GlobalShortcutHandler{}
776	second.keyboardHandler = kh
777	second.globalShortcutHandler = gs
778	app := &App{
779		tabs:           tabs,
780		mainWindow:     first.mainWindow,
781		browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
782		windowForTab:   map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
783		workspaceViews: map[entity.TabID]*component.WorkspaceView{tab1.ID: ws1, tab2.ID: ws2},
784	}
785
786	app.activateBrowserWindow(second)
787
788	if app.mainWindow != second.mainWindow {
789		t.Fatalf("mainWindow = %p, want %p", app.mainWindow, second.mainWindow)
790	}
791	if second.tabs.ActiveTabID != tab2.ID {
792		t.Fatalf("active tab = %q, want %q", second.tabs.ActiveTabID, tab2.ID)
793	}
794	if app.activeWorkspaceView() != ws2 {
795		t.Fatalf("active workspace view = %p, want %p", app.activeWorkspaceView(), ws2)
796	}
797	if app.keyboardHandler != kh {
798		t.Fatalf("keyboardHandler = %p, want %p", app.keyboardHandler, kh)
799	}
800	if app.globalShortcutHandler != gs {
801		t.Fatalf("globalShortcutHandler = %p, want %p", app.globalShortcutHandler, gs)
802	}
803}
804
805func TestApp_BrowserWindowForPaneFindsOwningWindow(t *testing.T) {
806	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
807	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
808	tabs := entity.NewTabList()
809	tabs.Add(tab1)
810	tabs.Add(tab2)
811	firstTabs := entity.NewTabList()
812	firstTabs.Add(tab1)
813	secondTabs := entity.NewTabList()
814	secondTabs.Add(tab2)
815	first := &browserWindow{id: "window-1", tabs: firstTabs}
816	second := &browserWindow{id: "window-2", tabs: secondTabs}
817	app := &App{
818		tabs:           tabs,
819		browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
820		windowForTab:   map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
821	}
822
823	got := app.browserWindowForPane(entity.PaneID("pane-2"))
824
825	if got != second {
826		t.Fatalf("browserWindowForPane = %p, want %p", got, second)
827	}
828}
829
830func TestApp_OwnerOrLastFocusedBrowserWindowPrefersPaneOwner(t *testing.T) {
831	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
832	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
833	tabs := entity.NewTabList()
834	tabs.Add(tab1)
835	tabs.Add(tab2)
836	firstTabs := entity.NewTabList()
837	firstTabs.Add(tab1)
838	secondTabs := entity.NewTabList()
839	secondTabs.Add(tab2)
840	first := &browserWindow{id: "window-1", tabs: firstTabs}
841	second := &browserWindow{id: "window-2", tabs: secondTabs}
842	app := &App{
843		tabs:                tabs,
844		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
845		windowForTab:        map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
846		lastFocusedWindowID: first.id,
847	}
848
849	got := app.ownerOrLastFocusedBrowserWindow("", entity.PaneID("pane-2"))
850
851	if got != second {
852		t.Fatalf("ownerOrLastFocusedBrowserWindow = %p, want %p", got, second)
853	}
854}
855
856func TestApp_HandlePaneWindowTitleChangedTargetsOwningWindow(t *testing.T) {
857	gtkApp := requireGTKDisplayApp(t)
858	defer gtkApp.Unref()
859
860	firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
861	if err != nil {
862		t.Fatalf("first window creation failed: %v", err)
863	}
864	defer firstMainWindow.Destroy()
865	secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
866	if err != nil {
867		t.Fatalf("second window creation failed: %v", err)
868	}
869	defer secondMainWindow.Destroy()
870
871	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
872	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
873	tabs := entity.NewTabList()
874	tabs.Add(tab1)
875	tabs.Add(tab2)
876	firstTabs := entity.NewTabList()
877	firstTabs.Add(tab1)
878	firstTabs.SetActive(tab1.ID)
879	secondTabs := entity.NewTabList()
880	secondTabs.Add(tab2)
881	secondTabs.SetActive(tab2.ID)
882	first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
883	second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
884	app := &App{
885		tabs:                tabs,
886		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
887		windowForTab:        map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
888		lastFocusedWindowID: first.id,
889	}
890
891	app.handlePaneWindowTitleChanged(entity.PaneID("pane-2"), "Pane Two")
892
893	if got := windowTitle(t, firstMainWindow); got != "Dumber" {
894		t.Fatalf("first window title = %q, want %q", got, "Dumber")
895	}
896	if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" {
897		t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber")
898	}
899}
900
901func TestApp_ActivateBrowserWindowResyncsTitleFromBackgroundPane(t *testing.T) {
902	gtkApp := requireGTKDisplayApp(t)
903	defer gtkApp.Unref()
904
905	firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
906	if err != nil {
907		t.Fatalf("first window creation failed: %v", err)
908	}
909	defer firstMainWindow.Destroy()
910	secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
911	if err != nil {
912		t.Fatalf("second window creation failed: %v", err)
913	}
914	defer secondMainWindow.Destroy()
915
916	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
917	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
918	tabs := entity.NewTabList()
919	tabs.Add(tab1)
920	tabs.Add(tab2)
921	firstTabs := entity.NewTabList()
922	firstTabs.Add(tab1)
923	firstTabs.SetActive(tab1.ID)
924	first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
925	secondTabs := entity.NewTabList()
926	secondTabs.Add(tab2)
927	secondTabs.SetActive(tab2.ID)
928	second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
929	contentCoord := &contentcoord.Coordinator{}
930	contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles")
931	reflect.NewAt(contentTitles.Type(), unsafe.Pointer(contentTitles.UnsafeAddr())).Elem().Set(reflect.ValueOf(map[entity.PaneID]string{tab2.Workspace.ActivePaneID: "Pane Two"}))
932	app := &App{
933		tabs:           tabs,
934		browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
935		windowForTab:   map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
936		contentCoord:   contentCoord,
937	}
938
939	app.handlePaneWindowTitleChanged(tab2.Workspace.ActivePaneID, "Pane Two")
940	secondMainWindow.SetTitle("Dumber")
941
942	app.activateBrowserWindow(second)
943
944	if got := windowTitle(t, secondMainWindow); got != "Pane Two - Dumber" {
945		t.Fatalf("second window title = %q, want %q", got, "Pane Two - Dumber")
946	}
947	if got := windowTitle(t, firstMainWindow); got != "Dumber" {
948		t.Fatalf("first window title = %q, want %q", got, "Dumber")
949	}
950}
951
952func TestApp_HandlePaneFullscreenChangedTargetsOwningWindow(t *testing.T) {
953	gtkApp := requireGTKDisplayApp(t)
954	defer gtkApp.Unref()
955
956	firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
957	if err != nil {
958		t.Fatalf("first window creation failed: %v", err)
959	}
960	defer firstMainWindow.Destroy()
961	secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
962	if err != nil {
963		t.Fatalf("second window creation failed: %v", err)
964	}
965	defer secondMainWindow.Destroy()
966
967	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
968	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
969	tabs := entity.NewTabList()
970	tabs.Add(tab1)
971	tabs.Add(tab2)
972	secondMainWindow.TabBar().AddTab(tab1)
973	secondMainWindow.TabBar().AddTab(tab2)
974	firstTabs := entity.NewTabList()
975	firstTabs.Add(tab1)
976	firstTabs.SetActive(tab1.ID)
977	secondTabs := entity.NewTabList()
978	secondTabs.Add(tab2)
979	secondTabs.SetActive(tab2.ID)
980	first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
981	second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
982	app := &App{
983		tabs:                tabs,
984		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
985		windowForTab:        map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
986		lastFocusedWindowID: first.id,
987	}
988
989	app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true)
990
991	if windowTabBarVisible(t, firstMainWindow) != true {
992		t.Fatalf("first window tab bar visibility changed unexpectedly")
993	}
994	if got := windowTabBarVisible(t, secondMainWindow); got {
995		t.Fatalf("second window tab bar visible = %v, want false", got)
996	}
997
998	app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false)
999
1000	if got := windowTabBarVisible(t, secondMainWindow); !got {
1001		t.Fatalf("second window tab bar visible = %v, want true", got)
1002	}
1003}
1004
1005func TestApp_HandlePaneFullscreenChangedIgnoresUnresolvedPane(t *testing.T) {
1006	gtkApp := requireGTKDisplayApp(t)
1007	defer gtkApp.Unref()
1008
1009	firstMainWindow, err := window.New(context.Background(), gtkApp, "top")
1010	if err != nil {
1011		t.Fatalf("first window creation failed: %v", err)
1012	}
1013	defer firstMainWindow.Destroy()
1014	secondMainWindow, err := window.New(context.Background(), gtkApp, "top")
1015	if err != nil {
1016		t.Fatalf("second window creation failed: %v", err)
1017	}
1018	defer secondMainWindow.Destroy()
1019	firstMainWindow.TabBar().SetVisible(true)
1020	secondMainWindow.TabBar().SetVisible(true)
1021	first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}
1022	second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}
1023	app := &App{
1024		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1025		lastFocusedWindowID: first.id,
1026	}
1027
1028	app.handlePaneFullscreenChanged(entity.PaneID("missing-pane"), true)
1029
1030	if got := windowTabBarVisible(t, firstMainWindow); !got {
1031		t.Fatalf("first window tab bar visible = %v, want true", got)
1032	}
1033	if got := windowTabBarVisible(t, secondMainWindow); !got {
1034		t.Fatalf("second window tab bar visible = %v, want true", got)
1035	}
1036}
1037
1038func TestApp_HandleAccentKeyPressDelegatesToOwningWindow(t *testing.T) {
1039	first := newTestAccentUseCase(t, true)
1040	second := newTestAccentUseCase(t, false)
1041	firstWindow := &browserWindow{id: "window-1", insertAccentUC: first}
1042	secondWindow := &browserWindow{id: "window-2", insertAccentUC: second}
1043	app := &App{
1044		browserWindows:      map[string]*browserWindow{firstWindow.id: firstWindow, secondWindow.id: secondWindow},
1045		lastFocusedWindowID: secondWindow.id,
1046	}
1047
1048	if got := app.handleAccentKeyPress(context.Background(), uint('e'), 0); got {
1049		t.Fatalf("handleAccentKeyPress returned %v, want false from active shell handler", got)
1050	}
1051	if got := first.IsPickerVisible(); !got {
1052		t.Fatalf("first shell accent handler should remain visible")
1053	}
1054	if got := second.IsPickerVisible(); got {
1055		t.Fatalf("second shell accent handler should remain hidden")
1056	}
1057}
1058
1059func TestApp_MoveActivePaneToTabFromBrowserWindowAnchorsOwningWindow(t *testing.T) {
1060	tabs := entity.NewTabList()
1061	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1062	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1063	tabs.Add(tab1)
1064	tabs.Add(tab2)
1065	firstTabs := entity.NewTabList()
1066	firstTabs.Add(tab1)
1067	firstTabs.SetActive(tab1.ID)
1068	first := &browserWindow{id: "window-1", tabs: firstTabs}
1069	secondTabs := entity.NewTabList()
1070	secondTabs.Add(tab2)
1071	secondTabs.SetActive(tab2.ID)
1072	second := &browserWindow{id: "window-2", tabs: secondTabs}
1073	app := &App{
1074		tabs:                tabs,
1075		movePaneToTabUC:     usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }),
1076		deps:                &Dependencies{Config: &config.Config{}},
1077		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1078		lastFocusedWindowID: second.id,
1079	}
1080
1081	if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, ""); err != nil {
1082		t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
1083	}
1084
1085	if tabs.Find(tab1.ID) != nil {
1086		t.Fatalf("tab-1 should have been moved from owning window")
1087	}
1088	if tabs.Find(tab2.ID) == nil {
1089		t.Fatalf("tab-2 should remain when owning window is preserved")
1090	}
1091}
1092
1093func TestApp_MoveActivePaneToTabFromBrowserWindowActivatesTargetOwnerForCrossWindowTab(t *testing.T) {
1094	tabs := entity.NewTabList()
1095	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1096	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1097	tab3 := entity.NewTab(entity.TabID("tab-3"), entity.WorkspaceID("workspace-3"), entity.NewPane(entity.PaneID("pane-3")))
1098	tabs.Add(tab1)
1099	tabs.Add(tab2)
1100	tabs.Add(tab3)
1101	tabs.SetActive(tab1.ID)
1102	firstTabs := entity.NewTabList()
1103	firstTabs.Add(tab1)
1104	firstTabs.SetActive(tab1.ID)
1105	first := &browserWindow{id: "window-1", tabs: firstTabs}
1106	secondTabs := entity.NewTabList()
1107	secondTabs.Add(tab2)
1108	secondTabs.SetActive(tab2.ID)
1109	second := &browserWindow{id: "window-2", tabs: secondTabs}
1110	app := &App{
1111		tabs:                tabs,
1112		movePaneToTabUC:     usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),
1113		deps:                &Dependencies{Config: &config.Config{}},
1114		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1115		windowForTab:        map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second, tab3.ID: second},
1116		lastFocusedWindowID: first.id,
1117	}
1118
1119	if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, tab3.ID); err != nil {
1120		t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
1121	}
1122
1123	if tabs.Find(tab1.ID) != nil {
1124		t.Fatalf("tab-1 should be removed after its active pane moves away")
1125	}
1126	if tabs.Find(tab2.ID) == nil {
1127		t.Fatalf("tab-2 should remain in the target owner window")
1128	}
1129	if tabs.Find(tab3.ID) == nil {
1130		t.Fatalf("tab-3 should remain as the move target")
1131	}
1132	if app.tabs.ActiveTabID != tab3.ID {
1133		t.Fatalf("active tab = %q, want %q", app.tabs.ActiveTabID, tab3.ID)
1134	}
1135	if app.lastFocusedWindowID != second.id {
1136		t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
1137	}
1138}
1139
1140func TestApp_MoveActivePaneToTabFromBrowserWindowCrossWindowDerivedMirror(t *testing.T) {
1141	// app.tabs initially contains ONLY source tab (not target tab) to simulate
1142	// a target created only in browserWindow.tabs (the real-world scenario that
1143	// CodeRabbit flagged). The fix ensures buildMovePaneToTabInput syncs the
1144	// derived global mirror before passing a.tabs to the move usecase.
1145	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1146	tab3 := entity.NewTab(entity.TabID("tab-3"), entity.WorkspaceID("workspace-3"), entity.NewPane(entity.PaneID("pane-3")))
1147
1148	// app.tabs contains only tab1 — tab3 is absent from the mirror.
1149	tabs := entity.NewTabList()
1150	tabs.Add(tab1)
1151	tabs.SetActive(tab1.ID)
1152
1153	firstTabs := entity.NewTabList()
1154	firstTabs.Add(tab1)
1155	firstTabs.SetActive(tab1.ID)
1156	first := &browserWindow{id: "window-1", tabs: firstTabs}
1157
1158	secondTabs := entity.NewTabList()
1159	secondTabs.Add(tab3)
1160	secondTabs.SetActive(tab3.ID)
1161	second := &browserWindow{id: "window-2", tabs: secondTabs}
1162
1163	initialPaneCount := tab3.Workspace.PaneCount()
1164
1165	app := &App{
1166		tabs:                tabs,
1167		movePaneToTabUC:     usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),
1168		deps:                &Dependencies{Config: &config.Config{}},
1169		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1170		windowForTab:        map[entity.TabID]*browserWindow{tab1.ID: first, tab3.ID: second},
1171		lastFocusedWindowID: first.id,
1172	}
1173
1174	if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, tab3.ID); err != nil {
1175		t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
1176	}
1177
1178	// The derived mirror must now contain tab3 (synced by the fix).
1179	if app.tabs.Find(tab3.ID) == nil {
1180		t.Fatalf("tab-3 should have been synced into the derived global mirror")
1181	}
1182
1183	// No new tab should have been generated — the move should target tab3.
1184	if app.tabs.Find(entity.TabID("generated")) != nil {
1185		t.Fatalf("a new tab was incorrectly generated; the move should target tab-3")
1186	}
1187
1188	// The moved pane should be in tab3's workspace.
1189	if got := tab3.Workspace.PaneCount(); got != initialPaneCount+1 {
1190		t.Fatalf("tab-3 pane count = %d, want %d (original + moved pane)", got, initialPaneCount+1)
1191	}
1192
1193	// windowForTab for tab3 must remain second.
1194	if app.windowForTab[tab3.ID] != second {
1195		t.Fatalf("windowForTab[tab-3] = %p, want second window %p", app.windowForTab[tab3.ID], second)
1196	}
1197}
1198
1199func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) {
1200	gtkApp := requireGTKDisplayApp(t)
1201	defer gtkApp.Unref()
1202
1203	mainWindow, err := window.New(context.Background(), gtkApp, "top")
1204	if err != nil {
1205		t.Fatalf("window creation failed: %v", err)
1206	}
1207	defer mainWindow.Destroy()
1208
1209	sentinelKeyboardHandler := &input.KeyboardHandler{}
1210	sentinelShortcutHandler := &input.GlobalShortcutHandler{}
1211	bw := &browserWindow{
1212		id:                    "window-1",
1213		mainWindow:            mainWindow,
1214		keyboardHandler:       sentinelKeyboardHandler,
1215		globalShortcutHandler: sentinelShortcutHandler,
1216	}
1217	app := &App{
1218		deps:           &Dependencies{Config: &config.Config{}},
1219		mainWindow:     mainWindow,
1220		browserWindows: map[string]*browserWindow{bw.id: bw},
1221		kbDispatcher:   dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, dispatcher.KeyboardActions{}, func(context.Context) entity.PaneID { return "" }),
1222	}
1223
1224	app.initKeyboardHandler(context.Background())
1225
1226	if bw.keyboardHandler != sentinelKeyboardHandler {
1227		t.Fatalf("keyboardHandler was reattached")
1228	}
1229	if bw.globalShortcutHandler != sentinelShortcutHandler {
1230		t.Fatalf("globalShortcutHandler was reattached")
1231	}
1232}
1233
1234func newTestAccentUseCase(t *testing.T, pickerVisible bool) *usecase.InsertAccentUseCase {
1235	t.Helper()
1236	uc := usecase.NewInsertAccentUseCase(&testFocusedInputProvider{}, nil, nil)
1237	if uc == nil {
1238		t.Fatal("failed to create accent use case")
1239	}
1240	setAccentUseCaseBoolField(t, uc, "pickerVisible", pickerVisible)
1241	return uc
1242}
1243
1244func setAccentUseCaseBoolField(t *testing.T, uc *usecase.InsertAccentUseCase, field string, value bool) {
1245	t.Helper()
1246	rv := reflect.ValueOf(uc).Elem()
1247	fv := rv.FieldByName(field)
1248	if !fv.IsValid() {
1249		t.Fatalf("InsertAccentUseCase missing %s field", field)
1250	}
1251	reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().SetBool(value)
1252}
1253
1254type testFocusedInputProvider struct{}
1255
1256func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil }
1257func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget)  {}
1258
1259func TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation(t *testing.T) {
1260	gtkApp := requireGTKDisplayApp(t)
1261	defer gtkApp.Unref()
1262
1263	mainWindow := &window.MainWindow{}
1264	tabBar := component.NewTabBar()
1265	if tabBar == nil {
1266		t.Fatal("tab bar creation failed")
1267	}
1268	setWindowTabBar(t, mainWindow, tabBar)
1269	bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
1270	app := &App{
1271		deps: &Dependencies{Config: &config.Config{}},
1272	}
1273	app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1274
1275	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1276	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1277	tabBar.AddTab(tab1)
1278
1279	app.updateBrowserWindowTabBarVisibility(bw)
1280	assertWindowTabBarAutoHidden(t, mainWindow, true)
1281
1282	tabBar.AddTab(tab2)
1283	app.updateBrowserWindowTabBarVisibility(bw)
1284	assertWindowTabBarAutoHidden(t, mainWindow, false)
1285
1286	tabBar.SetVisible(false)
1287	app.updateBrowserWindowTabBarVisibility(bw)
1288	if got := tabBar.Box().GetVisible(); got {
1289		t.Fatalf("tab bar visible = %v, want false while explicitly hidden", got)
1290	}
1291
1292	tabBar.SetVisible(true)
1293	app.updateBrowserWindowTabBarVisibility(bw)
1294	assertWindowTabBarAutoHidden(t, mainWindow, false)
1295
1296	tabBar.RemoveTab(tab2.ID)
1297	app.updateBrowserWindowTabBarVisibility(bw)
1298	assertWindowTabBarAutoHidden(t, mainWindow, true)
1299}
1300
1301func TestTabBarSetVisibleDoesNotClearAutoHiddenInteractionState(t *testing.T) {
1302	gtkApp := requireGTKDisplayApp(t)
1303	defer gtkApp.Unref()
1304
1305	tabBar := component.NewTabBar()
1306	if tabBar == nil {
1307		t.Fatal("tab bar creation failed")
1308	}
1309
1310	tabBar.SetAutoHidden(true)
1311	tabBar.SetVisible(false)
1312	tabBar.SetVisible(true)
1313
1314	box := tabBar.Box()
1315	if !box.GetVisible() {
1316		t.Fatal("tab bar should stay mounted when explicitly visible")
1317	}
1318	if got := box.GetOpacity(); got != 0.0 {
1319		t.Fatalf("tab bar opacity = %v, want 0 while auto-hidden", got)
1320	}
1321	if got := box.GetCanTarget(); got {
1322		t.Fatalf("tab bar can target = %v, want false while auto-hidden", got)
1323	}
1324	if got := box.GetFocusable(); got {
1325		t.Fatalf("tab bar focusable = %v, want false while auto-hidden", got)
1326	}
1327}
1328
1329func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {
1330	gtkApp := requireGTKDisplayApp(t)
1331	defer gtkApp.Unref()
1332
1333	mainWindow := &window.MainWindow{}
1334	tabBar := component.NewTabBar()
1335	if tabBar == nil {
1336		t.Fatal("tab bar creation failed")
1337	}
1338	setWindowTabBar(t, mainWindow, tabBar)
1339	bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
1340	app := &App{
1341		deps: &Dependencies{Config: &config.Config{}},
1342	}
1343	app.deps.Config.Workspace.HideTabBarWhenSingleTab = false
1344
1345	app.updateBrowserWindowTabBarVisibility(bw)
1346
1347	assertWindowTabBarAutoHidden(t, mainWindow, false)
1348}
1349
1350func TestApp_ActivePaneIDForNilBrowserWindowIgnoresStaleOverride(t *testing.T) {
1351	ctx := context.Background()
1352	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1353	contentCoord.SetActivePaneOverride(entity.PaneID("stale-pane"))
1354	app := &App{contentCoord: contentCoord}
1355
1356	if got := app.activePaneIDForBrowserWindow(nil); got != "" {
1357		t.Fatalf("active pane for nil browser window = %q, want empty", got)
1358	}
1359}
1360
1361func TestApp_BuildRestoredWindowUIUpdatesEmptyWindowTabBarVisibility(t *testing.T) {
1362	gtkApp := requireGTKDisplayApp(t)
1363	defer gtkApp.Unref()
1364
1365	mw, err := window.New(context.Background(), gtkApp, "bottom")
1366	if err != nil {
1367		t.Fatalf("window creation failed: %v", err)
1368	}
1369	defer mw.Destroy()
1370
1371	mw.SetTabBarContentInsetVisible(true)
1372	bw := &browserWindow{id: "window-1", mainWindow: mw, tabs: entity.NewTabList()}
1373	app := &App{deps: &Dependencies{Config: &config.Config{}}}
1374	app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1375
1376	app.buildRestoredWindowUI(context.Background(), []*browserWindow{bw})
1377
1378	assertWindowTabBarAutoHidden(t, mw, true)
1379	if mw.HasTabBarContentInset() {
1380		t.Fatal("empty restored bottom window should not keep tab bar content inset")
1381	}
1382}
1383
1384func TestApp_UpdateBrowserWindowTabBarVisibility_BottomInset(t *testing.T) {
1385	gtkApp := requireGTKDisplayApp(t)
1386	defer gtkApp.Unref()
1387
1388	// Use a real bottom MainWindow so SetTabBarContentInsetVisible is effective.
1389	mainWindow, err := window.New(context.Background(), gtkApp, "bottom")
1390	if err != nil {
1391		t.Fatalf("window creation failed: %v", err)
1392	}
1393	defer mainWindow.Destroy()
1394
1395	bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
1396	app := &App{
1397		deps: &Dependencies{Config: &config.Config{}},
1398	}
1399	app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1400
1401	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1402	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1403
1404	// One tab + hide enabled: auto-hidden and no inset
1405	mainWindow.TabBar().AddTab(tab1)
1406	app.updateBrowserWindowTabBarVisibility(bw)
1407	assertWindowTabBarAutoHidden(t, mainWindow, true)
1408	if mainWindow.HasTabBarContentInset() {
1409		t.Fatal("one tab: expected no content area inset")
1410	}
1411
1412	// Two tabs: visible and inset present
1413	mainWindow.TabBar().AddTab(tab2)
1414	app.updateBrowserWindowTabBarVisibility(bw)
1415	assertWindowTabBarAutoHidden(t, mainWindow, false)
1416	if !mainWindow.HasTabBarContentInset() {
1417		t.Fatal("two tabs: expected content area inset")
1418	}
1419
1420	// Remove back to one tab: auto-hidden and no inset
1421	mainWindow.TabBar().RemoveTab(tab2.ID)
1422	app.updateBrowserWindowTabBarVisibility(bw)
1423	assertWindowTabBarAutoHidden(t, mainWindow, true)
1424	if mainWindow.HasTabBarContentInset() {
1425		t.Fatal("back to one tab: expected no content area inset")
1426	}
1427}
1428
1429func TestApp_HandlePaneFullscreenChanged_ClearsBottomInset(t *testing.T) {
1430	gtkApp := requireGTKDisplayApp(t)
1431	defer gtkApp.Unref()
1432
1433	// Use a bottom MainWindow so SetTabBarContentInsetVisible is effective.
1434	mainWindow, err := window.New(context.Background(), gtkApp, "bottom")
1435	if err != nil {
1436		t.Fatalf("window creation failed: %v", err)
1437	}
1438	defer mainWindow.Destroy()
1439
1440	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1441	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1442	mainWindow.TabBar().AddTab(tab1)
1443	mainWindow.TabBar().AddTab(tab2)
1444
1445	tabs := entity.NewTabList()
1446	tabs.Add(tab1)
1447	tabs.Add(tab2)
1448	tabs.SetActive(tab2.ID)
1449
1450	bw := &browserWindow{id: "window-1", mainWindow: mainWindow, tabs: tabs}
1451	app := &App{
1452		tabs:                tabs,
1453		deps:                &Dependencies{Config: &config.Config{}},
1454		browserWindows:      map[string]*browserWindow{bw.id: bw},
1455		lastFocusedWindowID: bw.id,
1456	}
1457	app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1458
1459	// Start with two tabs: inset should be present
1460	app.updateBrowserWindowTabBarVisibility(bw)
1461	assertWindowTabBarAutoHidden(t, mainWindow, false)
1462	if !mainWindow.HasTabBarContentInset() {
1463		t.Fatal("two tabs: expected content area inset before fullscreen")
1464	}
1465
1466	// Enter fullscreen: tab bar hidden, inset cleared
1467	app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true)
1468	if windowTabBarVisible(t, mainWindow) {
1469		t.Fatal("fullscreen: tab bar should be not visible")
1470	}
1471	if mainWindow.HasTabBarContentInset() {
1472		t.Fatal("fullscreen: expected no content area inset")
1473	}
1474
1475	// Exit fullscreen: tab bar restored, inset restored
1476	app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false)
1477	if !windowTabBarVisible(t, mainWindow) {
1478		t.Fatal("exited fullscreen: tab bar should be visible")
1479	}
1480	if !mainWindow.HasTabBarContentInset() {
1481		t.Fatal("exited fullscreen: expected content area inset to be restored")
1482	}
1483}
1484
1485func TestApp_RemoveBrowserWindowPromotesDeterministicFallbackWithMainWindow(t *testing.T) {
1486	for i := 0; i < 20; i++ {
1487		removed := &browserWindow{id: "window-z", mainWindow: &window.MainWindow{}}
1488		nilWindow := &browserWindow{id: "window-a"}
1489		firstValid := &browserWindow{id: "window-b", mainWindow: &window.MainWindow{}}
1490		secondValid := &browserWindow{id: "window-c", mainWindow: &window.MainWindow{}}
1491		app := &App{
1492			mainWindow: removed.mainWindow,
1493			browserWindows: map[string]*browserWindow{
1494				removed.id:     removed,
1495				nilWindow.id:   nilWindow,
1496				firstValid.id:  firstValid,
1497				secondValid.id: secondValid,
1498			},
1499			browserWindowOrder:  []string{removed.id, nilWindow.id, secondValid.id, firstValid.id},
1500			lastFocusedWindowID: removed.id,
1501		}
1502
1503		app.removeBrowserWindow(removed.id)
1504
1505		if app.mainWindow != secondValid.mainWindow {
1506			t.Fatalf("mainWindow = %p, want %p", app.mainWindow, secondValid.mainWindow)
1507		}
1508		if app.lastFocusedWindowID != secondValid.id {
1509			t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, secondValid.id)
1510		}
1511	}
1512}
1513
1514func TestApp_BrowserWindowActivationHookUpdatesLastFocusedWindowID(t *testing.T) {
1515	first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
1516	second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
1517	app := &App{
1518		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1519		lastFocusedWindowID: first.id,
1520	}
1521
1522	app.handleBrowserWindowActivationChanged(second, true)
1523
1524	if app.lastFocusedWindowID != second.id {
1525		t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
1526	}
1527}
1528
1529func TestApp_ActivateBrowserWindowTracksLastFocusedWindow(t *testing.T) {
1530	first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
1531	second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
1532	app := &App{
1533		browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1534	}
1535
1536	app.activateBrowserWindow(second)
1537
1538	if app.lastFocusedWindowID != second.id {
1539		t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, second.id)
1540	}
1541}
1542
1543func TestApp_LastFocusedBrowserWindowFallsBackWhenOwnerMissing(t *testing.T) {
1544	first := &browserWindow{id: "window-1"}
1545	second := &browserWindow{id: "window-2"}
1546	app := &App{
1547		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1548		lastFocusedWindowID: second.id,
1549	}
1550
1551	got := app.lastFocusedBrowserWindow()
1552
1553	if got != second {
1554		t.Fatalf("lastFocusedBrowserWindow = %p, want %p", got, second)
1555	}
1556}
1557
1558func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) {
1559	ctx := context.Background()
1560	firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1561	secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1562	globalTabs := entity.NewTabList()
1563	globalTabs.Add(firstTab)
1564	globalTabs.Add(secondTab)
1565	firstTabs := entity.NewTabList()
1566	firstTabs.Add(firstTab)
1567	secondTabs := entity.NewTabList()
1568	secondTabs.Add(secondTab)
1569	first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1570	second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1571	app := &App{
1572		deps:                &Dependencies{Config: &config.Config{}},
1573		tabs:                globalTabs,
1574		tabsUC:              usecase.NewManageTabsUseCase(func() string { return "popup-tab" }),
1575		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1576		windowForTab:        map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
1577		workspaceViews:      make(map[entity.TabID]*component.WorkspaceView),
1578		lastFocusedWindowID: first.id,
1579		mainWindow:          first.mainWindow,
1580	}
1581	app.initTabCoordinator(ctx)
1582
1583	err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{
1584		ParentPaneID: secondTab.Workspace.ActivePaneID,
1585		PopupPane:    entity.NewPane(entity.PaneID("popup-pane")),
1586		TargetURI:    "https://example.com/popup",
1587	})
1588	if err != nil {
1589		t.Fatalf("createPopupTab returned error: %v", err)
1590	}
1591
1592	if first.tabs.Find(entity.TabID("popup-tab")) != nil {
1593		t.Fatalf("popup tab was added to stale focused window")
1594	}
1595	created := second.tabs.Find(entity.TabID("popup-tab"))
1596	if created == nil {
1597		t.Fatalf("popup tab was not added to parent pane owner window")
1598	}
1599	if got := app.windowForTab[created.ID]; got != second {
1600		t.Fatalf("popup tab owner = %p, want %p", got, second)
1601	}
1602}
1603
1604func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
1605	first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
1606	second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
1607	app := &App{
1608		mainWindow:          second.mainWindow,
1609		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1610		lastFocusedWindowID: second.id,
1611	}
1612
1613	app.removeBrowserWindow(second.id)
1614
1615	if app.lastFocusedWindowID != first.id {
1616		t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id)
1617	}
1618}
1619
1620func TestApp_TabCoordinatorCallbacksUseTargetWindowWhenFocusIsStale(t *testing.T) {
1621	ctx := context.Background()
1622	firstTabs := entity.NewTabList()
1623	firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1624	firstTabs.Add(firstTab)
1625	secondTabs := entity.NewTabList()
1626	secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1627	secondTabs.Add(secondTab)
1628
1629	first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1630	second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1631	app := &App{
1632		deps:                &Dependencies{Config: &config.Config{}},
1633		tabs:                entity.NewTabList(),
1634		tabsUC:              usecase.NewManageTabsUseCase(func() string { return "created-tab" }),
1635		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1636		lastFocusedWindowID: first.id,
1637		mainWindow:          first.mainWindow,
1638		windowForTab:        map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
1639		workspaceViews:      make(map[entity.TabID]*component.WorkspaceView),
1640	}
1641	app.tabs.Add(firstTab)
1642	app.tabs.Add(secondTab)
1643	app.initTabCoordinator(ctx)
1644
1645	created, err := app.tabCoord.Create(ctx, app.tabTargetForBrowserWindow(second), "https://example.com")
1646	if err != nil {
1647		t.Fatalf("Create returned error: %v", err)
1648	}
1649	if got := app.windowForTab[created.ID]; got != second {
1650		t.Fatalf("created tab owner = %p, want second window %p", got, second)
1651	}
1652	if app.mainWindow != second.mainWindow {
1653		t.Fatalf("mainWindow after create switch = %p, want %p", app.mainWindow, second.mainWindow)
1654	}
1655
1656	app.mainWindow = first.mainWindow
1657	app.lastFocusedWindowID = first.id
1658	if err := app.tabCoord.Switch(ctx, app.tabTargetForBrowserWindow(second), secondTab.ID); err != nil {
1659		t.Fatalf("Switch returned error: %v", err)
1660	}
1661	if app.mainWindow != second.mainWindow {
1662		t.Fatalf("mainWindow after target switch = %p, want %p", app.mainWindow, second.mainWindow)
1663	}
1664}
1665
1666func TestApp_ActivateBrowserWindowAddsGlobalSnapshotWithoutChangingWindowTabPosition(t *testing.T) {
1667	firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1668	secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1669	firstTabs := entity.NewTabList()
1670	firstTabs.Add(firstTab)
1671	secondTabs := entity.NewTabList()
1672	secondTabs.Add(secondTab)
1673	secondTabs.SetActive(secondTab.ID)
1674	first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1675	second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1676	globalTabs := entity.NewTabList()
1677	globalTabs.Add(firstTab)
1678	app := &App{
1679		tabs:           globalTabs,
1680		browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1681	}
1682
1683	app.activateBrowserWindow(second)
1684
1685	if secondTab.Position != 0 {
1686		t.Fatalf("window tab position = %d, want 0", secondTab.Position)
1687	}
1688	globalSecond := app.tabs.Find(secondTab.ID)
1689	if globalSecond == nil {
1690		t.Fatalf("global tab snapshot was not added")
1691	}
1692	if globalSecond == secondTab {
1693		t.Fatalf("global tab should be a snapshot, not the live per-window tab")
1694	}
1695	if globalSecond.Position != 1 {
1696		t.Fatalf("global tab position = %d, want 1", globalSecond.Position)
1697	}
1698}
1699
1700func TestApp_ActiveWorkspaceRequiresFocusedBrowserWindow(t *testing.T) {
1701	globalTab := entity.NewTab(entity.TabID("global-tab"), entity.WorkspaceID("ws-global"), entity.NewPane(entity.PaneID("pane-global")))
1702	globalTabs := entity.NewTabList()
1703	globalTabs.Add(globalTab)
1704	globalTabs.SetActive(globalTab.ID)
1705	app := &App{
1706		tabs:           globalTabs,
1707		browserWindows: map[string]*browserWindow{},
1708		workspaceViews: map[entity.TabID]*component.WorkspaceView{globalTab.ID: {}},
1709	}
1710
1711	if got := app.activeWorkspace(); got != nil {
1712		t.Fatalf("activeWorkspace() = %p, want nil without focused browser window", got)
1713	}
1714	if got := app.activeWorkspaceView(); got != nil {
1715		t.Fatalf("activeWorkspaceView() = %p, want nil without focused browser window", got)
1716	}
1717}
1718
1719func TestApp_ActiveWorkspaceUsesLastFocusedWindowTabsNotGlobalActiveTab(t *testing.T) {
1720	firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1721	secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1722	firstTabs := entity.NewTabList()
1723	firstTabs.Add(firstTab)
1724	firstTabs.SetActive(firstTab.ID)
1725	secondTabs := entity.NewTabList()
1726	secondTabs.Add(secondTab)
1727	secondTabs.SetActive(secondTab.ID)
1728	first := &browserWindow{id: "window-1", tabs: firstTabs}
1729	second := &browserWindow{id: "window-2", tabs: secondTabs}
1730	globalTabs := entity.NewTabList()
1731	globalTabs.Add(firstTab)
1732	globalTabs.Add(secondTab)
1733	globalTabs.SetActive(firstTab.ID)
1734	secondView := &component.WorkspaceView{}
1735	app := &App{
1736		tabs:                globalTabs,
1737		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1738		lastFocusedWindowID: second.id,
1739		workspaceViews:      map[entity.TabID]*component.WorkspaceView{secondTab.ID: secondView},
1740	}
1741
1742	if got := app.activeWorkspace(); got != secondTab.Workspace {
1743		t.Fatalf("activeWorkspace() = %p, want second workspace %p", got, secondTab.Workspace)
1744	}
1745	if got := app.activeWorkspaceView(); got != secondView {
1746		t.Fatalf("activeWorkspaceView() = %p, want second view %p", got, secondView)
1747	}
1748}
1749
1750// fakeRecordingWebView is a complete port.WebView that records which navigation
1751// methods were called and with what arguments. It does not use reflect or unsafe.
1752type fakeRecordingWebView struct {
1753	id port.WebViewID
1754
1755	loadURICalled          bool
1756	loadURILastURI         string
1757	reloadCalls            int
1758	reloadBypassCacheCalls int
1759	stopCalls              int
1760	goBackCalls            int
1761	goForwardCalls         int
1762	loadHTMLCalls          int
1763	setZoomLevelCalls      int
1764	setZoomLevelLastLevel  float64
1765	openDevToolsCalls      int
1766	printPageCalls         int
1767	destroyCalls           int
1768}
1769
1770func (f *fakeRecordingWebView) ID() port.WebViewID { return f.id }
1771
1772func (f *fakeRecordingWebView) LoadURI(_ context.Context, uri string) error {
1773	f.loadURICalled = true
1774	f.loadURILastURI = uri
1775	return nil
1776}
1777
1778func (f *fakeRecordingWebView) LoadHTML(_ context.Context, _, _ string) error {
1779	f.loadHTMLCalls++
1780	return nil
1781}
1782
1783func (f *fakeRecordingWebView) Reload(_ context.Context) error {
1784	f.reloadCalls++
1785	return nil
1786}
1787
1788func (f *fakeRecordingWebView) ReloadBypassCache(_ context.Context) error {
1789	f.reloadBypassCacheCalls++
1790	return nil
1791}
1792
1793func (f *fakeRecordingWebView) Stop(_ context.Context) error {
1794	f.stopCalls++
1795	return nil
1796}
1797
1798func (f *fakeRecordingWebView) GoBack(_ context.Context) error {
1799	f.goBackCalls++
1800	return nil
1801}
1802
1803func (f *fakeRecordingWebView) GoForward(_ context.Context) error {
1804	f.goForwardCalls++
1805	return nil
1806}
1807
1808func (f *fakeRecordingWebView) State() port.WebViewState { return port.WebViewState{} }
1809
1810func (f *fakeRecordingWebView) URI() string   { return f.loadURILastURI }
1811func (f *fakeRecordingWebView) Title() string { return "" }
1812
1813func (f *fakeRecordingWebView) IsLoading() bool            { return false }
1814func (f *fakeRecordingWebView) EstimatedProgress() float64 { return 0 }
1815func (f *fakeRecordingWebView) CanGoBack() bool            { return false }
1816func (f *fakeRecordingWebView) CanGoForward() bool         { return false }
1817
1818func (f *fakeRecordingWebView) SetZoomLevel(_ context.Context, level float64) error {
1819	f.setZoomLevelCalls++
1820	f.setZoomLevelLastLevel = level
1821	return nil
1822}
1823
1824func (f *fakeRecordingWebView) OpenDevTools() { f.openDevToolsCalls++ }
1825func (f *fakeRecordingWebView) PrintPage()    { f.printPageCalls++ }
1826
1827func (f *fakeRecordingWebView) GetZoomLevel() float64                     { return 1.0 }
1828func (f *fakeRecordingWebView) GetFindController() port.FindController    { return nil }
1829func (f *fakeRecordingWebView) SetCallbacks(_ *port.WebViewCallbacks)     {}
1830func (f *fakeRecordingWebView) RunJavaScript(_ context.Context, _ string) {}
1831func (f *fakeRecordingWebView) SetBackgroundColor(_, _, _, _ float64)     {}
1832func (f *fakeRecordingWebView) ResetBackgroundToDefault()                 {}
1833func (f *fakeRecordingWebView) Favicon() port.Texture                     { return nil }
1834func (f *fakeRecordingWebView) Generation() uint64                        { return 0 }
1835func (f *fakeRecordingWebView) IsFullscreen() bool                        { return false }
1836func (f *fakeRecordingWebView) IsPlayingAudio() bool                      { return false }
1837func (f *fakeRecordingWebView) IsDestroyed() bool                         { return f.destroyCalls > 0 }
1838func (f *fakeRecordingWebView) Destroy()                                  { f.destroyCalls++ }
1839
1840func TestApp_AttachPopupToTabDestroysPopupWhenWorkspaceViewMissing(t *testing.T) {
1841	ctx := context.Background()
1842	popupWV := &fakeRecordingWebView{id: 1}
1843	app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{}}
1844
1845	app.attachPopupToTab(ctx, entity.TabID("missing-tab"), entity.NewPane(entity.PaneID("popup-pane")), popupWV)
1846
1847	if popupWV.destroyCalls != 1 {
1848		t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
1849	}
1850}
1851
1852func TestApp_AttachPopupToTabDestroysPopupWhenPaneNil(t *testing.T) {
1853	ctx := context.Background()
1854	popupWV := &fakeRecordingWebView{id: 1}
1855	tabID := entity.TabID("tab-1")
1856	app := &App{workspaceViews: map[entity.TabID]*component.WorkspaceView{tabID: &component.WorkspaceView{}}}
1857
1858	app.attachPopupToTab(ctx, tabID, nil, popupWV)
1859
1860	if popupWV.destroyCalls != 1 {
1861		t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
1862	}
1863}
1864
1865func TestApp_AttachPopupToTabSkipsRegistrationWhenPaneViewMissing(t *testing.T) {
1866	ctx := context.Background()
1867	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1868	tabID := entity.TabID("tab-1")
1869	pane := entity.NewPane(entity.PaneID("missing-pane"))
1870	app := &App{
1871		contentCoord: contentCoord,
1872		workspaceViews: map[entity.TabID]*component.WorkspaceView{
1873			tabID: &component.WorkspaceView{},
1874		},
1875	}
1876
1877	popupWV := &fakeRecordingWebView{id: 1}
1878	app.attachPopupToTab(ctx, tabID, pane, popupWV)
1879
1880	if got := contentCoord.GetWebView(pane.ID); got != nil {
1881		t.Fatalf("popup webview was registered for missing pane view: %v", got)
1882	}
1883	if popupWV.destroyCalls != 1 {
1884		t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
1885	}
1886}
1887
1888func TestApp_AttachPopupToTabReleasesRegistrationWhenWrapFails(t *testing.T) {
1889	ctx := context.Background()
1890	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1891	tabID := entity.TabID("tab-1")
1892	pane := entity.NewPane(entity.PaneID("popup-pane"))
1893
1894	factory := layoutmocks.NewMockWidgetFactory(t)
1895	container := layoutmocks.NewMockBoxWidget(t)
1896	overlay := layoutmocks.NewMockOverlayWidget(t)
1897	factory.EXPECT().NewBox(layout.OrientationVertical, 0).Return(container).Once()
1898	container.EXPECT().SetHexpand(true).Once()
1899	container.EXPECT().SetVexpand(true).Once()
1900	container.EXPECT().SetVisible(true).Once()
1901	factory.EXPECT().NewOverlay().Return(overlay).Once()
1902	overlay.EXPECT().SetHexpand(true).Once()
1903	overlay.EXPECT().SetVexpand(true).Once()
1904	overlay.EXPECT().SetChild(container).Once()
1905	overlay.EXPECT().SetVisible(true).Once()
1906
1907	wsView := component.NewWorkspaceView(ctx, factory)
1908	container.EXPECT().AddCssClass("single-pane").Once()
1909	wsView.RegisterPaneView(pane.ID, &component.PaneView{})
1910
1911	app := &App{
1912		contentCoord: contentCoord,
1913		workspaceViews: map[entity.TabID]*component.WorkspaceView{
1914			tabID: wsView,
1915		},
1916	}
1917	popupWV := &fakeRecordingWebView{id: 1}
1918
1919	app.attachPopupToTab(ctx, tabID, pane, popupWV)
1920
1921	if got := contentCoord.GetWebView(pane.ID); got != nil {
1922		t.Fatalf("popup webview registration remained after wrap failure: %v", got)
1923	}
1924	if popupWV.destroyCalls != 1 {
1925		t.Fatalf("popup webview destroy calls = %d, want 1", popupWV.destroyCalls)
1926	}
1927}
1928
1929type fakeZoomRepo struct{}
1930
1931func (f *fakeZoomRepo) Get(context.Context, string) (*entity.ZoomLevel, error) { return nil, nil }
1932func (f *fakeZoomRepo) Set(context.Context, *entity.ZoomLevel) error           { return nil }
1933func (f *fakeZoomRepo) Delete(context.Context, string) error                   { return nil }
1934func (f *fakeZoomRepo) GetAll(context.Context) ([]*entity.ZoomLevel, error)    { return nil, nil }
1935
1936func TestApp_BrowserWindowWebViewActionsIgnoreStaleFocusedWindow(t *testing.T) {
1937	ctx := context.Background()
1938
1939	// Build two browser windows with independent bw.tabs and active tabs/panes.
1940	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
1941	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
1942
1943	firstTabs := entity.NewTabList()
1944	firstTabs.Add(tab1)
1945	firstTabs.SetActive(tab1.ID)
1946	first := &browserWindow{id: "window-1", tabs: firstTabs}
1947
1948	secondTabs := entity.NewTabList()
1949	secondTabs.Add(tab2)
1950	secondTabs.SetActive(tab2.ID)
1951	second := &browserWindow{id: "window-2", tabs: secondTabs}
1952
1953	// Create fake recording webviews.
1954	fakeWv1 := &fakeRecordingWebView{id: 1}
1955	fakeWv2 := &fakeRecordingWebView{id: 2}
1956
1957	// Create content coordinator with initialized internal maps to avoid
1958	// nil-map panics in SetNavigationOrigin during NavigateWebView calls.
1959	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1960	contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
1961	contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
1962
1963	// Create NavigationCoordinator (no navigateUC needed for direct webview calls).
1964	navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
1965
1966	app := &App{
1967		tabs:                entity.NewTabList(),
1968		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
1969		lastFocusedWindowID: first.id, // stale! should NOT be used
1970		contentCoord:        contentCoord,
1971		navCoord:            navCoord,
1972		workspaceViews: map[entity.TabID]*component.WorkspaceView{
1973			tab1.ID: &component.WorkspaceView{},
1974			tab2.ID: &component.WorkspaceView{},
1975		},
1976	}
1977	app.tabs.Add(tab1)
1978	app.tabs.Add(tab2)
1979
1980	// Call window-scoped app helpers for the second window.
1981	if err := app.navigateFromBrowserWindow(ctx, second, "https://google.com"); err != nil {
1982		t.Fatalf("navigateFromBrowserWindow returned error: %v", err)
1983	}
1984	if err := app.reloadBrowserWindow(ctx, second, false); err != nil {
1985		t.Fatalf("reloadBrowserWindow (soft) returned error: %v", err)
1986	}
1987	if err := app.reloadBrowserWindow(ctx, second, true); err != nil {
1988		t.Fatalf("reloadBrowserWindow (hard) returned error: %v", err)
1989	}
1990	if err := app.stopBrowserWindow(ctx, second); err != nil {
1991		t.Fatalf("stopBrowserWindow returned error: %v", err)
1992	}
1993	if err := app.goBackBrowserWindow(ctx, second); err != nil {
1994		t.Fatalf("goBackBrowserWindow returned error: %v", err)
1995	}
1996	if err := app.goForwardBrowserWindow(ctx, second); err != nil {
1997		t.Fatalf("goForwardBrowserWindow returned error: %v", err)
1998	}
1999
2000	assertRecordingWebViewIdle(t, "first", fakeWv1)
2001	assertRecordingWebViewActions(t, "second", fakeWv2, "https://google.com")
2002}
2003
2004func assertRecordingWebViewIdle(t *testing.T, name string, wv *fakeRecordingWebView) {
2005	t.Helper()
2006	if wv.loadURICalled || wv.loadURILastURI != "" {
2007		t.Errorf("%s webview LoadURI was called, expected zero calls", name)
2008	}
2009	if wv.reloadCalls > 0 {
2010		t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls)
2011	}
2012	if wv.reloadBypassCacheCalls > 0 {
2013		t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls)
2014	}
2015	if wv.stopCalls > 0 {
2016		t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls)
2017	}
2018	if wv.goBackCalls > 0 {
2019		t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls)
2020	}
2021	if wv.goForwardCalls > 0 {
2022		t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls)
2023	}
2024}
2025
2026func assertRecordingWebViewActions(t *testing.T, name string, wv *fakeRecordingWebView, wantURI string) {
2027	t.Helper()
2028	if !wv.loadURICalled {
2029		t.Errorf("%s webview LoadURI was not called", name)
2030	}
2031	if wv.loadURILastURI != wantURI {
2032		t.Errorf("%s webview LoadURI URI = %q, want %q", name, wv.loadURILastURI, wantURI)
2033	}
2034	if wv.reloadCalls != 1 {
2035		t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls)
2036	}
2037	if wv.reloadBypassCacheCalls != 1 {
2038		t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls)
2039	}
2040	if wv.stopCalls != 1 {
2041		t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls)
2042	}
2043	if wv.goBackCalls != 1 {
2044		t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls)
2045	}
2046	if wv.goForwardCalls != 1 {
2047		t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls)
2048	}
2049}
2050
2051func assertRecordingWebViewBrowserActionsIdle(t *testing.T, name string, wv *fakeRecordingWebView) {
2052	t.Helper()
2053	if wv.reloadCalls > 0 {
2054		t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls)
2055	}
2056	if wv.reloadBypassCacheCalls > 0 {
2057		t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls)
2058	}
2059	if wv.stopCalls > 0 {
2060		t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls)
2061	}
2062	if wv.goBackCalls > 0 {
2063		t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls)
2064	}
2065	if wv.goForwardCalls > 0 {
2066		t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls)
2067	}
2068	if wv.printPageCalls > 0 {
2069		t.Errorf("%s webview PrintPage calls = %d, want 0", name, wv.printPageCalls)
2070	}
2071	if wv.openDevToolsCalls > 0 {
2072		t.Errorf("%s webview OpenDevTools calls = %d, want 0", name, wv.openDevToolsCalls)
2073	}
2074	if wv.setZoomLevelCalls > 0 {
2075		t.Errorf("%s webview SetZoomLevel calls = %d, want 0", name, wv.setZoomLevelCalls)
2076	}
2077}
2078
2079func assertRecordingWebViewBrowserActionsCalledOnce(t *testing.T, name string, wv *fakeRecordingWebView) {
2080	t.Helper()
2081	if wv.reloadCalls != 1 {
2082		t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls)
2083	}
2084	if wv.reloadBypassCacheCalls != 1 {
2085		t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls)
2086	}
2087	if wv.stopCalls != 1 {
2088		t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls)
2089	}
2090	if wv.goBackCalls != 1 {
2091		t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls)
2092	}
2093	if wv.goForwardCalls != 1 {
2094		t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls)
2095	}
2096	if wv.printPageCalls != 1 {
2097		t.Errorf("%s webview PrintPage calls = %d, want 1", name, wv.printPageCalls)
2098	}
2099	if wv.openDevToolsCalls != 1 {
2100		t.Errorf("%s webview OpenDevTools calls = %d, want 1", name, wv.openDevToolsCalls)
2101	}
2102	if wv.setZoomLevelCalls != 1 {
2103		t.Errorf("%s webview SetZoomLevel calls = %d, want 1", name, wv.setZoomLevelCalls)
2104	}
2105}
2106
2107func TestApp_OmniboxNavigateCallbackCapturesBrowserWindow(t *testing.T) {
2108	second := &browserWindow{id: "window-2"}
2109
2110	var capturedBW *browserWindow
2111	var capturedURL string
2112	testNavigate := func(_ context.Context, bw *browserWindow, url string) error {
2113		capturedBW = bw
2114		capturedURL = url
2115		return nil
2116	}
2117
2118	ctx := context.Background()
2119	cb := omniboxNavigateForBrowserWindow(ctx, second, testNavigate)
2120	if err := cb(ctx, "https://google.com"); err != nil {
2121		t.Fatalf("omnibox navigate callback returned error: %v", err)
2122	}
2123
2124	if capturedBW != second {
2125		capturedID := "<nil>"
2126		if capturedBW != nil {
2127			capturedID = capturedBW.id
2128		}
2129		t.Errorf("captured browser window = %p (id=%s), want %p (id=%s)", capturedBW, capturedID, second, second.id)
2130	}
2131	if capturedURL != "https://google.com" {
2132		t.Errorf("captured URL = %q, want %q", capturedURL, "https://google.com")
2133	}
2134}
2135
2136func TestApp_DispatchBrowserWindowActionUsesSourceWindow(t *testing.T) {
2137	ctx := context.Background()
2138
2139	// Build two browser windows with independent bw.tabs and active panes.
2140	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2141	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
2142
2143	firstTabs := entity.NewTabList()
2144	firstTabs.Add(tab1)
2145	firstTabs.SetActive(tab1.ID)
2146	first := &browserWindow{id: "window-1", tabs: firstTabs}
2147
2148	secondTabs := entity.NewTabList()
2149	secondTabs.Add(tab2)
2150	secondTabs.SetActive(tab2.ID)
2151	second := &browserWindow{id: "window-2", tabs: secondTabs}
2152
2153	// Create fake recording webviews.
2154	fakeWv1 := &fakeRecordingWebView{id: 1, loadURILastURI: "https://first.example"}
2155	fakeWv2 := &fakeRecordingWebView{id: 2, loadURILastURI: "https://second.example"}
2156
2157	// Register fake webviews in content coordinator.
2158	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2159	contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
2160	contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
2161
2162	// Create navCoord (no navigateUC needed for direct webview calls).
2163	navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
2164
2165	app := &App{
2166		tabs:                entity.NewTabList(),
2167		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
2168		lastFocusedWindowID: first.id, // stale! should NOT be used
2169		contentCoord:        contentCoord,
2170		navCoord:            navCoord,
2171		deps:                &Dependencies{ZoomUC: usecase.NewManageZoomUseCase(&fakeZoomRepo{}, 1.0, nil)},
2172		workspaceViews: map[entity.TabID]*component.WorkspaceView{
2173			tab1.ID: &component.WorkspaceView{},
2174			tab2.ID: &component.WorkspaceView{},
2175		},
2176	}
2177	app.tabs.Add(tab1)
2178	app.tabs.Add(tab2)
2179
2180	// Dispatch browser window actions targeting the second window.
2181	for _, a := range []input.Action{
2182		input.ActionReload,
2183		input.ActionHardReload,
2184		input.ActionStop,
2185		input.ActionGoBack,
2186		input.ActionGoForward,
2187		input.ActionPrintPage,
2188		input.ActionOpenDevTools,
2189		input.ActionZoomIn,
2190	} {
2191		if err := app.dispatchBrowserWindowAction(ctx, second, a); err != nil {
2192			t.Fatalf("dispatchBrowserWindowAction (%s) returned error: %v", a, err)
2193		}
2194	}
2195
2196	// Assert first fake webview receives zero calls (stale focus is ignored).
2197	assertRecordingWebViewBrowserActionsIdle(t, "first", fakeWv1)
2198
2199	// Assert second fake webview receives exactly one call per action.
2200	assertRecordingWebViewBrowserActionsCalledOnce(t, "second", fakeWv2)
2201}
2202
2203func TestApp_DispatchBrowserWindowActionZoomInSupportsFileURLs(t *testing.T) {
2204	ctx := context.Background()
2205
2206	tab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2207	tabs := entity.NewTabList()
2208	tabs.Add(tab)
2209	tabs.SetActive(tab.ID)
2210	bw := &browserWindow{id: "window-1", tabs: tabs}
2211
2212	fakeWv := &fakeRecordingWebView{id: 1, loadURILastURI: "file:///tmp/demo.html"}
2213	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2214	contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv)
2215
2216	app := &App{
2217		browserWindows: map[string]*browserWindow{bw.id: bw},
2218		tabs:           entity.NewTabList(),
2219		windowForTab:   map[entity.TabID]*browserWindow{tab.ID: bw},
2220		contentCoord:   contentCoord,
2221		deps:           &Dependencies{ZoomUC: usecase.NewManageZoomUseCase(&fakeZoomRepo{}, 1.0, nil)},
2222		workspaceViews: map[entity.TabID]*component.WorkspaceView{
2223			tab.ID: &component.WorkspaceView{},
2224		},
2225	}
2226	app.tabs.Add(tab)
2227	app.tabs.SetActive(tab.ID)
2228
2229	if err := app.dispatchBrowserWindowAction(ctx, bw, input.ActionZoomIn); err != nil {
2230		t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2231	}
2232	if fakeWv.setZoomLevelCalls != 1 {
2233		t.Fatalf("webview SetZoomLevel calls = %d, want 1", fakeWv.setZoomLevelCalls)
2234	}
2235	if diff := math.Abs(fakeWv.setZoomLevelLastLevel - 1.1); diff > 0.0001 {
2236		t.Fatalf("webview SetZoomLevel level = %f, want 1.1", fakeWv.setZoomLevelLastLevel)
2237	}
2238}
2239
2240func counterIDGen() func() string {
2241	var counter int
2242	return func() string {
2243		counter++
2244		return strconv.Itoa(counter)
2245	}
2246}
2247
2248func TestApp_DispatchBrowserWindowActionSwitchTabIndexUsesSourceWindow(t *testing.T) {
2249	ctx := context.Background()
2250
2251	firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2252	firstTabs := entity.NewTabList()
2253	firstTabs.Add(firstTab)
2254	firstTabs.SetActive(firstTab.ID)
2255	first := &browserWindow{id: "window-1", tabs: firstTabs}
2256
2257	secondTab1 := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2258	secondTab2 := entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2")))
2259	secondTabs := entity.NewTabList()
2260	secondTabs.Add(secondTab1)
2261	secondTabs.Add(secondTab2)
2262	secondTabs.SetActive(secondTab1.ID)
2263	second := &browserWindow{id: "window-2", tabs: secondTabs}
2264
2265	app := &App{
2266		deps: &Dependencies{
2267			Config: &config.Config{
2268				Workspace: config.WorkspaceConfig{
2269					NewPaneURL: "about:blank",
2270				},
2271			},
2272		},
2273		tabsUC:              usecase.NewManageTabsUseCase(counterIDGen()),
2274		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
2275		windowForTab:        map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab1.ID: second, secondTab2.ID: second},
2276		lastFocusedWindowID: first.id, // stale — should NOT be used
2277	}
2278	app.initTabCoordinator(ctx)
2279
2280	err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex2)
2281	if err != nil {
2282		t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2283	}
2284
2285	if got := secondTabs.ActiveTabID; got != secondTab2.ID {
2286		t.Fatalf("second active tab = %q, want %q", got, secondTab2.ID)
2287	}
2288	if got := firstTabs.ActiveTabID; got != firstTab.ID {
2289		t.Fatalf("first active tab = %q, want %q", got, firstTab.ID)
2290	}
2291}
2292
2293func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesOnlyOneMissingTab(t *testing.T) {
2294	ctx := context.Background()
2295
2296	firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2297	firstTabs := entity.NewTabList()
2298	firstTabs.Add(firstTab)
2299	firstTabs.SetActive(firstTab.ID)
2300	first := &browserWindow{id: "window-1", tabs: firstTabs}
2301
2302	secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2303	secondTabs := entity.NewTabList()
2304	secondTabs.Add(secondTab)
2305	secondTabs.SetActive(secondTab.ID)
2306	second := &browserWindow{id: "window-2", tabs: secondTabs}
2307
2308	app := &App{
2309		deps: &Dependencies{
2310			Config: &config.Config{
2311				Workspace: config.WorkspaceConfig{
2312					NewPaneURL: "about:blank",
2313				},
2314			},
2315		},
2316		tabsUC:              usecase.NewManageTabsUseCase(counterIDGen()),
2317		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
2318		windowForTab:        map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
2319		lastFocusedWindowID: first.id, // stale — should NOT be used
2320	}
2321	app.initTabCoordinator(ctx)
2322
2323	// Dispatch ActionSwitchTabIndex4 targeting the second window.
2324	err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4)
2325	if err != nil {
2326		t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2327	}
2328
2329	// Out-of-range switches should create a single new tab in the source window.
2330	if got := secondTabs.Count(); got != 2 {
2331		t.Fatalf("second target tab count = %d, want 2", got)
2332	}
2333	if got := secondTabs.ActiveTabID; got != secondTabs.Tabs[1].ID {
2334		t.Fatalf("active tab = %q, want %q", got, secondTabs.Tabs[1].ID)
2335	}
2336
2337	if got := firstTabs.Count(); got != 1 {
2338		t.Fatalf("first target tab count = %d, want 1", got)
2339	}
2340	if got := firstTabs.ActiveTabID; got != firstTab.ID {
2341		t.Fatalf("first active tab = %q, want %q", got, firstTab.ID)
2342	}
2343
2344	if got := app.windowForTab[secondTabs.Tabs[1].ID]; got != second {
2345		t.Fatalf("windowForTab[%s] = %p, want second window %p", secondTabs.Tabs[1].ID, got, second)
2346	}
2347}
2348
2349func TestApp_SwitchBrowserWindowTabIndexNegativeIndexIsIgnored(t *testing.T) {
2350	ctx := context.Background()
2351
2352	secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2353	secondTabs := entity.NewTabList()
2354	secondTabs.Add(secondTab)
2355	secondTabs.SetActive(secondTab.ID)
2356	second := &browserWindow{id: "window-2", tabs: secondTabs}
2357
2358	app := &App{
2359		deps: &Dependencies{
2360			Config: &config.Config{Workspace: config.WorkspaceConfig{NewPaneURL: "about:blank"}},
2361		},
2362		tabsUC:         usecase.NewManageTabsUseCase(counterIDGen()),
2363		browserWindows: map[string]*browserWindow{second.id: second},
2364		windowForTab:   map[entity.TabID]*browserWindow{secondTab.ID: second},
2365	}
2366	app.initTabCoordinator(ctx)
2367
2368	err := app.switchBrowserWindowTabIndex(ctx, second, -1)
2369	if err != nil {
2370		t.Fatalf("switchBrowserWindowTabIndex returned error: %v", err)
2371	}
2372	if got := secondTabs.Count(); got != 1 {
2373		t.Fatalf("second target tab count = %d, want 1", got)
2374	}
2375	if got := secondTabs.ActiveTabID; got != secondTab.ID {
2376		t.Fatalf("active tab = %q, want %q", got, secondTab.ID)
2377	}
2378}
2379
2380func TestApp_DispatchBrowserWindowActionSwitchTabIndexNegativeDoesNotMutateWindowState(t *testing.T) {
2381	ctx := context.Background()
2382
2383	second := &browserWindow{id: "window-2"}
2384
2385	app := &App{
2386		deps: &Dependencies{
2387			Config: &config.Config{Workspace: config.WorkspaceConfig{NewPaneURL: "about:blank"}},
2388		},
2389		tabsUC:              usecase.NewManageTabsUseCase(counterIDGen()),
2390		browserWindows:      map[string]*browserWindow{second.id: second},
2391		lastFocusedWindowID: "window-1",
2392	}
2393	app.initTabCoordinator(ctx)
2394
2395	err := app.switchBrowserWindowTabIndex(ctx, second, -1)
2396	if err != nil {
2397		t.Fatalf("switchBrowserWindowTabIndex returned error: %v", err)
2398	}
2399	if got := app.lastFocusedWindowID; got != "window-1" {
2400		t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1")
2401	}
2402	if second.tabs != nil {
2403		t.Fatalf("second tabs should stay nil for invalid index")
2404	}
2405}
2406
2407func TestApp_DispatchBrowserWindowActionSwitchTabIndexMissingURLDoesNotCreateTab(t *testing.T) {
2408	ctx := context.Background()
2409
2410	secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2411	secondTabs := entity.NewTabList()
2412	secondTabs.Add(secondTab)
2413	secondTabs.SetActive(secondTab.ID)
2414	second := &browserWindow{id: "window-2", tabs: secondTabs}
2415
2416	app := &App{
2417		deps:           &Dependencies{Config: &config.Config{}},
2418		tabsUC:         usecase.NewManageTabsUseCase(counterIDGen()),
2419		browserWindows: map[string]*browserWindow{second.id: second},
2420		windowForTab:   map[entity.TabID]*browserWindow{secondTab.ID: second},
2421	}
2422	app.initTabCoordinator(ctx)
2423
2424	err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4)
2425	if err == nil {
2426		t.Fatal("dispatchBrowserWindowAction returned nil error, want config error")
2427	}
2428	if got := secondTabs.Count(); got != 1 {
2429		t.Fatalf("second target tab count = %d, want 1", got)
2430	}
2431	if got := secondTabs.ActiveTabID; got != secondTab.ID {
2432		t.Fatalf("active tab = %q, want %q", got, secondTab.ID)
2433	}
2434}
2435
2436func TestApp_SwitchBrowserWindowTabIndexMissingURLDoesNotMutateWindowState(t *testing.T) {
2437	ctx := context.Background()
2438
2439	second := &browserWindow{id: "window-2"}
2440
2441	app := &App{
2442		deps:                &Dependencies{Config: &config.Config{}},
2443		tabsUC:              usecase.NewManageTabsUseCase(counterIDGen()),
2444		browserWindows:      map[string]*browserWindow{second.id: second},
2445		lastFocusedWindowID: "window-1",
2446	}
2447	app.initTabCoordinator(ctx)
2448
2449	err := app.switchBrowserWindowTabIndex(ctx, second, 3)
2450	if err == nil {
2451		t.Fatal("switchBrowserWindowTabIndex returned nil error, want config error")
2452	}
2453	if got := app.lastFocusedWindowID; got != "window-1" {
2454		t.Fatalf("lastFocusedWindowID = %q, want %q", got, "window-1")
2455	}
2456	if second.tabs != nil {
2457		t.Fatalf("second tabs should stay nil when new pane URL is missing")
2458	}
2459}
2460
2461func TestApp_WorkspaceOmniboxNavigateUsesOwnerWindow(t *testing.T) {
2462	gtkApp := requireGTKDisplayApp(t)
2463	defer gtkApp.Unref()
2464
2465	ctx := context.Background()
2466	tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2467	tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
2468	firstTabs := entity.NewTabList()
2469	firstTabs.Add(tab1)
2470	firstTabs.SetActive(tab1.ID)
2471	secondTabs := entity.NewTabList()
2472	secondTabs.Add(tab2)
2473	secondTabs.SetActive(tab2.ID)
2474	first := &browserWindow{id: "window-1", tabs: firstTabs}
2475	second := &browserWindow{id: "window-2", tabs: secondTabs}
2476
2477	fakeWv1 := &fakeRecordingWebView{id: 1}
2478	fakeWv2 := &fakeRecordingWebView{id: 2}
2479	contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2480	contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
2481	contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
2482
2483	app := &App{
2484		deps:                &Dependencies{Config: &config.Config{}},
2485		widgetFactory:       layout.NewGtkWidgetFactory(),
2486		browserWindows:      map[string]*browserWindow{first.id: first, second.id: second},
2487		windowForTab:        map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
2488		lastFocusedWindowID: first.id,
2489		contentCoord:        contentCoord,
2490		navCoord:            coordinator.NewNavigationCoordinator(ctx, nil, contentCoord),
2491		workspaceViews:      map[entity.TabID]*component.WorkspaceView{},
2492	}
2493
2494	app.createWorkspaceViewWithoutAttach(ctx, tab2)
2495	wsView := app.workspaceViews[tab2.ID]
2496	if wsView == nil {
2497		t.Fatal("workspace view was not created for second tab")
2498	}
2499	wsView.ShowOmnibox(ctx, "")
2500	cb := omniboxNavigateCallbackForTest(t, wsView.GetOmnibox())
2501	if err := cb(ctx, "https://example.com"); err != nil {
2502		t.Fatalf("omnibox navigate callback returned error: %v", err)
2503	}
2504
2505	if fakeWv1.loadURICalled {
2506		t.Errorf("first window webview navigated to %q, want no navigation", fakeWv1.loadURILastURI)
2507	}
2508	if !fakeWv2.loadURICalled || fakeWv2.loadURILastURI != "https://example.com" {
2509		t.Errorf("second window webview navigation = called %v uri %q, want https://example.com", fakeWv2.loadURICalled, fakeWv2.loadURILastURI)
2510	}
2511}
2512
2513func TestApp_FloatingOmniboxNavigateUsesSessionPane(t *testing.T) {
2514	gtkApp := requireGTKDisplayApp(t)
2515	defer gtkApp.Unref()
2516
2517	ctx := context.Background()
2518	factory := layout.NewGtkWidgetFactory()
2519	overlay := factory.NewOverlay()
2520
2521	loadedURL := ""
2522	pane := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
2523		OnNavigate: func(_ context.Context, url string) error {
2524			loadedURL = url
2525			return nil
2526		},
2527	})
2528	session := &floatingWorkspaceSession{
2529		paneID:  entity.PaneID("floating-pane"),
2530		pane:    pane,
2531		overlay: overlay,
2532	}
2533	app := &App{widgetFactory: factory}
2534
2535	app.showFloatingOmnibox(ctx, session)
2536	cb := omniboxNavigateCallbackForTest(t, session.omnibox)
2537	if err := cb(ctx, "https://example.com"); err != nil {
2538		t.Fatalf("floating omnibox navigate callback returned error: %v", err)
2539	}
2540
2541	if loadedURL != "https://example.com" {
2542		t.Fatalf("floating pane loaded URL = %q, want https://example.com", loadedURL)
2543	}
2544}
2545
2546// TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge verifies that existing
2547// browserWindows not participating in the restore do not leak their tabs into the
2548// restored global tab list or UI aggregation. Only runtimeWindows (the windows
2549// actually restored) contribute.
2550func TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge(t *testing.T) {
2551	// Two stale browserWindows exist in the map; only one window is restored.
2552	staleBW1 := &browserWindow{id: "stale-w1"}
2553	staleBW2 := &browserWindow{id: "stale-w2"}
2554
2555	staleTab1 := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-ws"), entity.NewPane(entity.PaneID("stale-pane")))
2556	staleTab1.Name = "StaleTab"
2557	staleBW1.tabs = entity.NewTabList()
2558	staleBW1.tabs.Add(staleTab1)
2559
2560	staleBW2.tabs = entity.NewTabList()
2561
2562	// One restored window with its own tab. Tab names survive the
2563	// snapshot/restore cycle even though IDs are regenerated.
2564	restoredTabs := entity.NewTabList()
2565	restoredTab := entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-ws"), entity.NewPane(entity.PaneID("restored-pane")))
2566	restoredTab.Name = "RestoredTab"
2567	restoredTabs.Add(restoredTab)
2568
2569	sessionID := entity.SessionID("test-session")
2570	state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
2571		{WindowID: "saved-w1", Tabs: restoredTabs},
2572	}, 0, time.Unix(123, 0))
2573
2574	mainWindow := &window.MainWindow{}
2575	runtimeBW := &browserWindow{id: "runtime-w1", mainWindow: mainWindow}
2576
2577	app := &App{
2578		deps: &Dependencies{
2579			Config:           &config.Config{},
2580			SessionStateRepo: &fakeSessionStateRepo{state: state},
2581		},
2582		mainWindow: mainWindow,
2583		browserWindows: map[string]*browserWindow{
2584			staleBW1.id:  staleBW1,
2585			staleBW2.id:  staleBW2,
2586			runtimeBW.id: runtimeBW,
2587		},
2588		tabs: entity.NewTabList(),
2589	}
2590	// Seed global tabs with a stale tab to verify it is replaced.
2591	app.tabs.Add(staleTab1)
2592
2593	if err := app.restoreSession(context.Background(), sessionID); err != nil {
2594		t.Fatalf("restoreSession returned error: %v", err)
2595	}
2596
2597	// Global tabs must only contain the restored tab, not stale tabs.
2598	if got := app.tabs.Count(); got != 1 {
2599		t.Fatalf("tabs.Count() = %d, want 1 (only restored tab)", got)
2600	}
2601	// Verify by name since IDs are regenerated during restore.
2602	restored := app.tabs.Tabs[0]
2603	if restored == nil || restored.Name != "RestoredTab" {
2604		t.Fatalf("restored tab name = %q, want RestoredTab", firstTabName(app.tabs))
2605	}
2606	if app.tabs.Find(staleTab1.ID) != nil {
2607		t.Fatal("stale tab leaked into global tabs")
2608	}
2609}
2610
2611func firstTabName(tl *entity.TabList) string {
2612	if len(tl.Tabs) == 0 || tl.Tabs[0] == nil {
2613		return ""
2614	}
2615	return tl.Tabs[0].Name
2616}
2617
2618// TestApp_RestoreSessionHonorsActiveWindowIndex verifies that when restoring a
2619// v2 multi-window session, the ActiveWindowIndex from the snapshot is used to
2620// determine the focused window and global active tab.
2621func TestApp_RestoreSessionHonorsActiveWindowIndex(t *testing.T) {
2622	gtkApp := requireGTKDisplayApp(t)
2623	defer gtkApp.Unref()
2624
2625	mainWindow, err := window.New(context.Background(), gtkApp, "top")
2626	if err != nil {
2627		t.Fatalf("window creation failed: %v", err)
2628	}
2629	defer mainWindow.Destroy()
2630
2631	// Window 0 has a tab named "Tab-W0", window 1 has "Tab-W1" (active).
2632	// Tab names survive snapshot/restore; IDs are regenerated.
2633	pane0 := entity.NewPane("pane-w0")
2634	tabW0 := entity.NewTab("tab-w0", "ws-w0", pane0)
2635	tabW0.Name = "Tab-W0"
2636	tabs0 := entity.NewTabList()
2637	tabs0.Add(tabW0)
2638
2639	pane1 := entity.NewPane("pane-w1")
2640	tabW1 := entity.NewTab("tab-w1", "ws-w1", pane1)
2641	tabW1.Name = "Tab-W1"
2642	tabs1 := entity.NewTabList()
2643	tabs1.Add(tabW1)
2644	tabs1.SetActive("tab-w1")
2645
2646	sessionID := entity.SessionID("test-sess")
2647	// ActiveWindowIndex = 1: second window should be focused.
2648	state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
2649		{WindowID: "saved-w0", Tabs: tabs0},
2650		{WindowID: "saved-w1", Tabs: tabs1},
2651	}, 1, time.Unix(123, 0))
2652
2653	firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow}
2654	// Factory creates the second runtime window; share the mainWindow so the UI
2655	// restore loop can walk its tab bar without needing a second GTK window.
2656	var runtimeW1 *browserWindow
2657	app := &App{
2658		deps: &Dependencies{
2659			Config:           &config.Config{},
2660			SessionStateRepo: &fakeSessionStateRepo{state: state},
2661		},
2662		mainWindow:     mainWindow,
2663		widgetFactory:  layout.NewGtkWidgetFactory(),
2664		browserWindows: map[string]*browserWindow{firstBW.id: firstBW},
2665		tabs:           entity.NewTabList(),
2666		windowForTab:   map[entity.TabID]*browserWindow{},
2667		workspaceViews: map[entity.TabID]*component.WorkspaceView{},
2668		browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) {
2669			runtimeW1 = &browserWindow{id: "runtime-w1", mainWindow: mainWindow}
2670			return runtimeW1, nil
2671		},
2672	}
2673
2674	if err := app.restoreSession(context.Background(), sessionID); err != nil {
2675		t.Fatalf("restoreSession returned error: %v", err)
2676	}
2677
2678	// lastFocusedWindowID must point to the active window (index 1).
2679	if app.lastFocusedWindowID != "runtime-w1" {
2680		t.Errorf("lastFocusedWindowID = %s, want runtime-w1 (active window index 1)", app.lastFocusedWindowID)
2681	}
2682
2683	// Global active tab must come from the active window (Tab-W1), not the first (Tab-W0).
2684	activeTab := app.tabs.ActiveTab()
2685	if activeTab == nil {
2686		t.Fatal("active tab is nil")
2687	}
2688	if activeTab.Name != "Tab-W1" {
2689		t.Errorf("active tab name = %q, want Tab-W1 (from active window)", activeTab.Name)
2690	}
2691}
2692
2693// TestApp_RestoreSessionFailsOnAdditionalWindowCreationError verifies that
2694// restoreSession returns an error (not silent partial restore) when creating
2695// a browser window for an additional restored window fails.
2696func TestApp_RestoreSessionFailsOnAdditionalWindowCreationError(t *testing.T) {
2697	gtkApp := requireGTKDisplayApp(t)
2698	defer gtkApp.Unref()
2699
2700	mainWindow, err := window.New(context.Background(), gtkApp, "top")
2701	if err != nil {
2702		t.Fatalf("window creation failed: %v", err)
2703	}
2704	defer mainWindow.Destroy()
2705
2706	pane0 := entity.NewPane("pane-w0")
2707	tab0 := entity.NewTab("tab-w0", "ws-w0", pane0)
2708	tabs0 := entity.NewTabList()
2709	tabs0.Add(tab0)
2710
2711	pane1 := entity.NewPane("pane-w1")
2712	tab1 := entity.NewTab("tab-w1", "ws-w1", pane1)
2713	tabs1 := entity.NewTabList()
2714	tabs1.Add(tab1)
2715
2716	sessionID := entity.SessionID("test-sess")
2717	state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
2718		{WindowID: "saved-w0", Tabs: tabs0},
2719		{WindowID: "saved-w1", Tabs: tabs1},
2720	}, 0, time.Unix(123, 0))
2721
2722	firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow}
2723	factoryErr := errors.New("window creation failed")
2724	app := &App{
2725		deps: &Dependencies{
2726			Config:           &config.Config{},
2727			SessionStateRepo: &fakeSessionStateRepo{state: state},
2728		},
2729		mainWindow:     mainWindow,
2730		browserWindows: map[string]*browserWindow{firstBW.id: firstBW},
2731		tabs:           entity.NewTabList(),
2732		windowForTab:   map[entity.TabID]*browserWindow{},
2733		browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) {
2734			return nil, factoryErr
2735		},
2736	}
2737
2738	gotErr := app.restoreSession(context.Background(), sessionID)
2739	if gotErr == nil {
2740		t.Fatal("restoreSession returned nil error, want failure when additional window creation fails")
2741	}
2742	if !strings.Contains(gotErr.Error(), "create browser window 1 for restore") {
2743		t.Errorf("error message should mention window creation for restore, got: %v", gotErr)
2744	}
2745	if !errors.Is(gotErr, factoryErr) {
2746		t.Errorf("error should wrap factoryErr, got: %v", gotErr)
2747	}
2748}
2749
2750func TestMainWindow_BottomTabBar_ContentAreaHasInset(t *testing.T) {
2751	gtkApp := requireGTKDisplayApp(t)
2752	defer gtkApp.Unref()
2753
2754	mw, err := window.New(context.Background(), gtkApp, "bottom")
2755	if err != nil {
2756		t.Fatalf("window creation failed: %v", err)
2757	}
2758	defer mw.Destroy()
2759
2760	// Bottom window should start WITHOUT the inset
2761	if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2762		t.Fatal("bottom-tab-bar content area: should NOT have inset at window creation")
2763	}
2764
2765	// HasTabBarContentInset must return false initially
2766	if mw.HasTabBarContentInset() {
2767		t.Fatal("HasTabBarContentInset should return false initially")
2768	}
2769
2770	// SetTabBarContentInsetVisible(true) must add the class
2771	mw.SetTabBarContentInsetVisible(true)
2772	if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2773		t.Fatal("bottom-tab-bar content area: expected content-area-tabbar-inset-bottom CSS class after SetTabBarContentInsetVisible(true)")
2774	}
2775	if !mw.HasTabBarContentInset() {
2776		t.Fatal("HasTabBarContentInset should return true after SetTabBarContentInsetVisible(true)")
2777	}
2778
2779	// SetTabBarContentInsetVisible(false) must remove the class
2780	mw.SetTabBarContentInsetVisible(false)
2781	if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2782		t.Fatal("bottom-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)")
2783	}
2784	if mw.HasTabBarContentInset() {
2785		t.Fatal("HasTabBarContentInset should return false after SetTabBarContentInsetVisible(false)")
2786	}
2787
2788	// Verify tab bar remains a non-measured overlay regardless of inset state
2789	if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) {
2790		t.Fatal("bottom-tab-bar: expected tab bar to remain a non-measured overlay")
2791	}
2792}
2793
2794func TestMainWindow_TopTabBar_ContentAreaHasTopInset(t *testing.T) {
2795	gtkApp := requireGTKDisplayApp(t)
2796	defer gtkApp.Unref()
2797
2798	mw, err := window.New(context.Background(), gtkApp, "top")
2799	if err != nil {
2800		t.Fatalf("window creation failed: %v", err)
2801	}
2802	defer mw.Destroy()
2803
2804	if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") {
2805		t.Fatal("top-tab-bar content area: should NOT have inset at window creation")
2806	}
2807	if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2808		t.Fatal("top-tab-bar content area: should NOT have content-area-tabbar-inset-bottom CSS class")
2809	}
2810
2811	mw.SetTabBarContentInsetVisible(true)
2812	if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") {
2813		t.Fatal("top-tab-bar content area: expected content-area-tabbar-inset-top CSS class after SetTabBarContentInsetVisible(true)")
2814	}
2815	if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2816		t.Fatal("top-tab-bar content area: should NOT have bottom inset after SetTabBarContentInsetVisible(true)")
2817	}
2818	if !mw.HasTabBarContentInset() {
2819		t.Fatal("HasTabBarContentInset should return true for top inset")
2820	}
2821
2822	mw.SetTabBarContentInsetVisible(false)
2823	if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") {
2824		t.Fatal("top-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)")
2825	}
2826	if mw.HasTabBarContentInset() {
2827		t.Fatal("HasTabBarContentInset should return false after removing top inset")
2828	}
2829
2830	if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) {
2831		t.Fatal("top-tab-bar: expected tab bar to remain a non-measured overlay")
2832	}
2833}

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