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}