detached acba2a5

Scope browser windows and stabilize tab geometry

Make browser windows the runtime source of truth for tab state, active panes, navigation, omnibox actions, floating sessions, and snapshots.

Stabilize global shortcut dispatch and route tab/webview actions through explicit window targets to avoid cross-window bleed.

Keep the tab bar as a non-measured overlay while reserving bottom content inset only when the bottom tab bar is visible, preserving OSR WebView geometry during tab switches without leaving a blank strip for single-tab windows.
commit
acba2a525802aea35744c14ad8146a091e2a6f55
author
brice · · 2026-05-05 12:24 UTC
committer
brice · · 2026-05-05 12:47 UTC
parent
6066b2a
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
32 files · +5371 -863
M .mockery.yaml +1 -0
@@ -16,6 +16,7 @@ packages:
16 SessionLogger: {}16 SessionLogger: {}
17 SessionSpawner: {}17 SessionSpawner: {}
18 TabListProvider: {}18 TabListProvider: {}
19+ WindowStateProvider: {}
19 WebView: {}20 WebView: {}
20 WebViewFactory: {}21 WebViewFactory: {}
21 WebViewPool: {}22 WebViewPool: {}
M cmd/dumber/main.go +1 -1
@@ -845,7 +845,7 @@ func buildUIDependencies(
845 SessionStateRepo: repos.sessionState,845 SessionStateRepo: repos.sessionState,
846 CurrentSessionID: currentSessionID,846 CurrentSessionID: currentSessionID,
847 SnapshotUC: uc.snapshot,847 SnapshotUC: uc.snapshot,
848- SnapshotServiceFactory: func(provider port.TabListProvider, intervalMs int) port.SnapshotService {848+ SnapshotServiceFactory: func(provider port.WindowStateProvider, intervalMs int) port.SnapshotService {
849 return snapshot.NewService(uc.snapshot, provider, intervalMs)849 return snapshot.NewService(uc.snapshot, provider, intervalMs)
850 },850 },
851 CheckUpdateUC: uc.checkUpdate,851 CheckUpdateUC: uc.checkUpdate,
M internal/application/port/mocks/mocks.go +216 -0
@@ -10792,6 +10792,222 @@ func (_c *MockTabListProvider_GetTabList_Call) RunAndReturn(run func() *entity.T
10792 return _c10792 return _c
10793 }10793 }
10794 10794
10795+// NewMockWindowStateProvider creates a new instance of MockWindowStateProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
10796+// The first argument is typically a *testing.T value.
10797+func NewMockWindowStateProvider(t interface {
10798+ mock.TestingT
10799+ Cleanup(func())
10800+}) *MockWindowStateProvider {
10801+ mock := &MockWindowStateProvider{}
10802+ mock.Mock.Test(t)
10803+
10804+ t.Cleanup(func() { mock.AssertExpectations(t) })
10805+
10806+ return mock
10807+}
10808+
10809+// MockWindowStateProvider is an autogenerated mock type for the WindowStateProvider type
10810+type MockWindowStateProvider struct {
10811+ mock.Mock
10812+}
10813+
10814+type MockWindowStateProvider_Expecter struct {
10815+ mock *mock.Mock
10816+}
10817+
10818+func (_m *MockWindowStateProvider) EXPECT() *MockWindowStateProvider_Expecter {
10819+ return &MockWindowStateProvider_Expecter{mock: &_m.Mock}
10820+}
10821+
10822+// GetActiveWindowIndex provides a mock function for the type MockWindowStateProvider
10823+func (_mock *MockWindowStateProvider) GetActiveWindowIndex() int {
10824+ ret := _mock.Called()
10825+
10826+ if len(ret) == 0 {
10827+ panic("no return value specified for GetActiveWindowIndex")
10828+ }
10829+
10830+ var r0 int
10831+ if returnFunc, ok := ret.Get(0).(func() int); ok {
10832+ r0 = returnFunc()
10833+ } else {
10834+ r0 = ret.Get(0).(int)
10835+ }
10836+ return r0
10837+}
10838+
10839+// MockWindowStateProvider_GetActiveWindowIndex_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActiveWindowIndex'
10840+type MockWindowStateProvider_GetActiveWindowIndex_Call struct {
10841+ *mock.Call
10842+}
10843+
10844+// GetActiveWindowIndex is a helper method to define mock.On call
10845+func (_e *MockWindowStateProvider_Expecter) GetActiveWindowIndex() *MockWindowStateProvider_GetActiveWindowIndex_Call {
10846+ return &MockWindowStateProvider_GetActiveWindowIndex_Call{Call: _e.mock.On("GetActiveWindowIndex")}
10847+}
10848+
10849+func (_c *MockWindowStateProvider_GetActiveWindowIndex_Call) Run(run func()) *MockWindowStateProvider_GetActiveWindowIndex_Call {
10850+ _c.Call.Run(func(args mock.Arguments) {
10851+ run()
10852+ })
10853+ return _c
10854+}
10855+
10856+func (_c *MockWindowStateProvider_GetActiveWindowIndex_Call) Return(n int) *MockWindowStateProvider_GetActiveWindowIndex_Call {
10857+ _c.Call.Return(n)
10858+ return _c
10859+}
10860+
10861+func (_c *MockWindowStateProvider_GetActiveWindowIndex_Call) RunAndReturn(run func() int) *MockWindowStateProvider_GetActiveWindowIndex_Call {
10862+ _c.Call.Return(run)
10863+ return _c
10864+}
10865+
10866+// GetSessionID provides a mock function for the type MockWindowStateProvider
10867+func (_mock *MockWindowStateProvider) GetSessionID() entity.SessionID {
10868+ ret := _mock.Called()
10869+
10870+ if len(ret) == 0 {
10871+ panic("no return value specified for GetSessionID")
10872+ }
10873+
10874+ var r0 entity.SessionID
10875+ if returnFunc, ok := ret.Get(0).(func() entity.SessionID); ok {
10876+ r0 = returnFunc()
10877+ } else {
10878+ r0 = ret.Get(0).(entity.SessionID)
10879+ }
10880+ return r0
10881+}
10882+
10883+// MockWindowStateProvider_GetSessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSessionID'
10884+type MockWindowStateProvider_GetSessionID_Call struct {
10885+ *mock.Call
10886+}
10887+
10888+// GetSessionID is a helper method to define mock.On call
10889+func (_e *MockWindowStateProvider_Expecter) GetSessionID() *MockWindowStateProvider_GetSessionID_Call {
10890+ return &MockWindowStateProvider_GetSessionID_Call{Call: _e.mock.On("GetSessionID")}
10891+}
10892+
10893+func (_c *MockWindowStateProvider_GetSessionID_Call) Run(run func()) *MockWindowStateProvider_GetSessionID_Call {
10894+ _c.Call.Run(func(args mock.Arguments) {
10895+ run()
10896+ })
10897+ return _c
10898+}
10899+
10900+func (_c *MockWindowStateProvider_GetSessionID_Call) Return(sessionID entity.SessionID) *MockWindowStateProvider_GetSessionID_Call {
10901+ _c.Call.Return(sessionID)
10902+ return _c
10903+}
10904+
10905+func (_c *MockWindowStateProvider_GetSessionID_Call) RunAndReturn(run func() entity.SessionID) *MockWindowStateProvider_GetSessionID_Call {
10906+ _c.Call.Return(run)
10907+ return _c
10908+}
10909+
10910+// GetWindowSnapshotState provides a mock function for the type MockWindowStateProvider
10911+func (_mock *MockWindowStateProvider) GetWindowSnapshotState() ([]entity.WindowTabListState, int) {
10912+ ret := _mock.Called()
10913+
10914+ if len(ret) == 0 {
10915+ panic("no return value specified for GetWindowSnapshotState")
10916+ }
10917+
10918+ var r0 []entity.WindowTabListState
10919+ var r1 int
10920+ if returnFunc, ok := ret.Get(0).(func() ([]entity.WindowTabListState, int)); ok {
10921+ return returnFunc()
10922+ }
10923+ if returnFunc, ok := ret.Get(0).(func() []entity.WindowTabListState); ok {
10924+ r0 = returnFunc()
10925+ } else {
10926+ if ret.Get(0) != nil {
10927+ r0 = ret.Get(0).([]entity.WindowTabListState)
10928+ }
10929+ }
10930+ if returnFunc, ok := ret.Get(1).(func() int); ok {
10931+ r1 = returnFunc()
10932+ } else {
10933+ r1 = ret.Get(1).(int)
10934+ }
10935+ return r0, r1
10936+}
10937+
10938+// MockWindowStateProvider_GetWindowSnapshotState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWindowSnapshotState'
10939+type MockWindowStateProvider_GetWindowSnapshotState_Call struct {
10940+ *mock.Call
10941+}
10942+
10943+// GetWindowSnapshotState is a helper method to define mock.On call
10944+func (_e *MockWindowStateProvider_Expecter) GetWindowSnapshotState() *MockWindowStateProvider_GetWindowSnapshotState_Call {
10945+ return &MockWindowStateProvider_GetWindowSnapshotState_Call{Call: _e.mock.On("GetWindowSnapshotState")}
10946+}
10947+
10948+func (_c *MockWindowStateProvider_GetWindowSnapshotState_Call) Run(run func()) *MockWindowStateProvider_GetWindowSnapshotState_Call {
10949+ _c.Call.Run(func(args mock.Arguments) {
10950+ run()
10951+ })
10952+ return _c
10953+}
10954+
10955+func (_c *MockWindowStateProvider_GetWindowSnapshotState_Call) Return(windowTabListStates []entity.WindowTabListState, n int) *MockWindowStateProvider_GetWindowSnapshotState_Call {
10956+ _c.Call.Return(windowTabListStates, n)
10957+ return _c
10958+}
10959+
10960+func (_c *MockWindowStateProvider_GetWindowSnapshotState_Call) RunAndReturn(run func() ([]entity.WindowTabListState, int)) *MockWindowStateProvider_GetWindowSnapshotState_Call {
10961+ _c.Call.Return(run)
10962+ return _c
10963+}
10964+
10965+// GetWindowTabLists provides a mock function for the type MockWindowStateProvider
10966+func (_mock *MockWindowStateProvider) GetWindowTabLists() []entity.WindowTabListState {
10967+ ret := _mock.Called()
10968+
10969+ if len(ret) == 0 {
10970+ panic("no return value specified for GetWindowTabLists")
10971+ }
10972+
10973+ var r0 []entity.WindowTabListState
10974+ if returnFunc, ok := ret.Get(0).(func() []entity.WindowTabListState); ok {
10975+ r0 = returnFunc()
10976+ } else {
10977+ if ret.Get(0) != nil {
10978+ r0 = ret.Get(0).([]entity.WindowTabListState)
10979+ }
10980+ }
10981+ return r0
10982+}
10983+
10984+// MockWindowStateProvider_GetWindowTabLists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWindowTabLists'
10985+type MockWindowStateProvider_GetWindowTabLists_Call struct {
10986+ *mock.Call
10987+}
10988+
10989+// GetWindowTabLists is a helper method to define mock.On call
10990+func (_e *MockWindowStateProvider_Expecter) GetWindowTabLists() *MockWindowStateProvider_GetWindowTabLists_Call {
10991+ return &MockWindowStateProvider_GetWindowTabLists_Call{Call: _e.mock.On("GetWindowTabLists")}
10992+}
10993+
10994+func (_c *MockWindowStateProvider_GetWindowTabLists_Call) Run(run func()) *MockWindowStateProvider_GetWindowTabLists_Call {
10995+ _c.Call.Run(func(args mock.Arguments) {
10996+ run()
10997+ })
10998+ return _c
10999+}
11000+
11001+func (_c *MockWindowStateProvider_GetWindowTabLists_Call) Return(windowTabListStates []entity.WindowTabListState) *MockWindowStateProvider_GetWindowTabLists_Call {
11002+ _c.Call.Return(windowTabListStates)
11003+ return _c
11004+}
11005+
11006+func (_c *MockWindowStateProvider_GetWindowTabLists_Call) RunAndReturn(run func() []entity.WindowTabListState) *MockWindowStateProvider_GetWindowTabLists_Call {
11007+ _c.Call.Return(run)
11008+ return _c
11009+}
11010+
10795 // NewMockSessionSpawner creates a new instance of MockSessionSpawner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.11011 // NewMockSessionSpawner creates a new instance of MockSessionSpawner. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
10796 // The first argument is typically a *testing.T value.11012 // The first argument is typically a *testing.T value.
10797 func NewMockSessionSpawner(t interface {11013 func NewMockSessionSpawner(t interface {
M internal/application/port/session_state.go +17 -0
@@ -8,6 +8,8 @@ import (
8 8
9 // TabListProvider provides access to the current tab list state.9 // TabListProvider provides access to the current tab list state.
10 // Implemented by the UI layer to allow the snapshot service to read state.10 // Implemented by the UI layer to allow the snapshot service to read state.
11+//
12+// Deprecated: use WindowStateProvider for v2 window-scoped snapshots.
11 type TabListProvider interface {13 type TabListProvider interface {
12 // GetTabList returns the current tab list state.14 // GetTabList returns the current tab list state.
13 GetTabList() *entity.TabList15 GetTabList() *entity.TabList
@@ -15,6 +17,21 @@ type TabListProvider interface {
15 GetSessionID() entity.SessionID17 GetSessionID() entity.SessionID
16 }18 }
17 19
20+// WindowStateProvider provides window-scoped browser state for v2 snapshots.
21+// Implemented by the UI layer and consumed by the snapshot service.
22+type WindowStateProvider interface {
23+ // GetWindowSnapshotState returns the ordered window tab lists and active window index atomically.
24+ GetWindowSnapshotState() ([]entity.WindowTabListState, int)
25+ // GetWindowTabLists returns the ordered list of windows with their tab lists.
26+ // For consistent reads alongside GetActiveWindowIndex, prefer GetWindowSnapshotState.
27+ GetWindowTabLists() []entity.WindowTabListState
28+ // GetActiveWindowIndex returns the index of the currently focused window.
29+ // For consistent reads alongside GetWindowTabLists, prefer GetWindowSnapshotState.
30+ GetActiveWindowIndex() int
31+ // GetSessionID returns the current session ID.
32+ GetSessionID() entity.SessionID
33+}
34+
18 // SessionSpawner spawns a new dumber instance for resurrection.35 // SessionSpawner spawns a new dumber instance for resurrection.
19 type SessionSpawner interface {36 type SessionSpawner interface {
20 // SpawnWithSession starts a new dumber instance to restore a session.37 // SpawnWithSession starts a new dumber instance to restore a session.
M internal/application/usecase/snapshot_session.go +28 -6
@@ -3,6 +3,7 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "time"
6 7
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/bnema/dumber/internal/domain/repository"9 "github.com/bnema/dumber/internal/domain/repository"
@@ -23,23 +24,44 @@ func NewSnapshotSessionUseCase(stateRepo repository.SessionStateRepository) *Sna
23 type SnapshotInput struct {24 type SnapshotInput struct {
24 SessionID entity.SessionID25 SessionID entity.SessionID
25 TabList *entity.TabList26 TabList *entity.TabList
27+
28+ // v2: window-scoped snapshot. A non-nil slice, including an empty one,
29+ // requests the v2 path; leave nil when using the legacy TabList path.
30+ Windows []entity.WindowTabListState
31+ ActiveWindowIndex int
26 }32 }
27 33
28 // Execute creates a snapshot of the current session state and saves it.34 // Execute creates a snapshot of the current session state and saves it.
35+// When Windows is non-nil, uses the v2 window-scoped path, including explicit
36+// zero-window snapshots. Otherwise falls back to the legacy flat TabList path.
29 func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error {37 func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error {
30 log := logging.FromContext(ctx)38 log := logging.FromContext(ctx)
31 39
32 if input.SessionID == "" {40 if input.SessionID == "" {
33 return fmt.Errorf("session id required")41 return fmt.Errorf("session id required")
34 }42 }
43+ if input.Windows != nil && input.TabList != nil {
44+ return fmt.Errorf("windows and tab list are mutually exclusive")
45+ }
35 46
36- state := entity.SnapshotFromTabList(input.SessionID, input.TabList)47+ var state *entity.SessionState
37 48
38- log.Debug().49+ if input.Windows != nil {
39- Str("session_id", string(input.SessionID)).50+ state = entity.SnapshotFromWindowTabLists(input.SessionID, input.Windows, input.ActiveWindowIndex, time.Now())
40- Int("tab_count", len(state.Tabs)).51+ log.Debug().
41- Int("pane_count", state.CountPanes()).52+ Str("session_id", string(input.SessionID)).
42- Msg("creating session snapshot")53+ Int("window_count", len(state.Windows)).
54+ Int("active_window", state.ActiveWindowIndex).
55+ Int("pane_count", state.CountPanes()).
56+ Msg("creating window-scoped session snapshot")
57+ } else {
58+ state = entity.SnapshotFromTabList(input.SessionID, input.TabList)
59+ log.Debug().
60+ Str("session_id", string(input.SessionID)).
61+ Int("tab_count", len(state.Tabs)).
62+ Int("pane_count", state.CountPanes()).
63+ Msg("creating session snapshot")
64+ }
43 65
44 if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil {66 if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil {
45 return fmt.Errorf("save session snapshot: %w", err)67 return fmt.Errorf("save session snapshot: %w", err)
M internal/application/usecase/snapshot_session_test.go +102 -2
@@ -34,7 +34,7 @@ func TestSnapshotSessionUseCase_Execute_SavesSnapshot(t *testing.T) {
34 require.Equal(t, sessionID, state.SessionID)34 require.Equal(t, sessionID, state.SessionID)
35 require.Len(t, state.Tabs, 1)35 require.Len(t, state.Tabs, 1)
36 require.Equal(t, "Test Tab", state.Tabs[0].Name)36 require.Equal(t, "Test Tab", state.Tabs[0].Name)
37- require.Equal(t, entity.SessionStateVersion, state.Version)37+ require.Equal(t, entity.LegacySessionStateVersion, state.Version)
38 }).38 }).
39 Return(nil)39 Return(nil)
40 40
@@ -85,6 +85,47 @@ func TestSnapshotSessionUseCase_Execute_RequiresSessionID(t *testing.T) {
85 assert.Contains(t, err.Error(), "session id required")85 assert.Contains(t, err.Error(), "session id required")
86 }86 }
87 87
88+func TestSnapshotSessionUseCase_Execute_RejectsAmbiguousWindowAndTabListInput(t *testing.T) {
89+ ctx := testContext()
90+
91+ stateRepo := repomocks.NewMockSessionStateRepository(t)
92+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
93+
94+ err := uc.Execute(ctx, usecase.SnapshotInput{
95+ SessionID: entity.SessionID("20251224_120000_ambiguous"),
96+ TabList: entity.NewTabList(),
97+ Windows: []entity.WindowTabListState{},
98+ })
99+ require.Error(t, err)
100+ assert.Contains(t, err.Error(), "windows and tab list are mutually exclusive")
101+}
102+
103+func TestSnapshotSessionUseCase_Execute_SavesEmptyWindowSnapshot(t *testing.T) {
104+ ctx := testContext()
105+
106+ stateRepo := repomocks.NewMockSessionStateRepository(t)
107+ sessionID := entity.SessionID("20251224_120000_empty_v2_windows")
108+
109+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
110+ Run(func(_ context.Context, state *entity.SessionState) {
111+ require.Equal(t, sessionID, state.SessionID)
112+ require.Equal(t, entity.SessionStateVersion, state.Version)
113+ require.Empty(t, state.Windows)
114+ require.Empty(t, state.Tabs)
115+ assert.Equal(t, 0, state.ActiveWindowIndex)
116+ }).
117+ Return(nil)
118+
119+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
120+
121+ err := uc.Execute(ctx, usecase.SnapshotInput{
122+ SessionID: sessionID,
123+ Windows: []entity.WindowTabListState{},
124+ ActiveWindowIndex: 7,
125+ })
126+ require.NoError(t, err)
127+}
128+
88 func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {129 func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {
89 sessionID := entity.SessionID("20251224_120000_snap")130 sessionID := entity.SessionID("20251224_120000_snap")
90 131
@@ -108,7 +149,7 @@ func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {
108 state := entity.SnapshotFromTabList(sessionID, tabList)149 state := entity.SnapshotFromTabList(sessionID, tabList)
109 150
110 require.NotNil(t, state)151 require.NotNil(t, state)
111- assert.Equal(t, entity.SessionStateVersion, state.Version)152+ assert.Equal(t, entity.LegacySessionStateVersion, state.Version)
112 assert.Equal(t, sessionID, state.SessionID)153 assert.Equal(t, sessionID, state.SessionID)
113 assert.Len(t, state.Tabs, 1)154 assert.Len(t, state.Tabs, 1)
114 assert.Equal(t, "Dev Tab", state.Tabs[0].Name)155 assert.Equal(t, "Dev Tab", state.Tabs[0].Name)
@@ -119,6 +160,65 @@ func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {
119 assert.False(t, state.SavedAt.IsZero())160 assert.False(t, state.SavedAt.IsZero())
120 }161 }
121 162
163+func TestSnapshotSessionUseCase_Execute_SavesWindowSnapshots(t *testing.T) {
164+ ctx := testContext()
165+
166+ stateRepo := repomocks.NewMockSessionStateRepository(t)
167+
168+ // Create two windows, each with tabs
169+ pane1_1 := entity.NewPane("p1_w1")
170+ pane1_1.URI = "https://window1.com"
171+ tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1_1)
172+ tab1_1.Name = "Window1 Tab1"
173+
174+ pane1_2 := entity.NewPane("p1_w2")
175+ pane1_2.URI = "https://window2.com"
176+ tab1_2 := entity.NewTab("t1_w2", "ws1_w2", pane1_2)
177+ tab1_2.Name = "Window2 Tab1"
178+
179+ tabs1 := entity.NewTabList()
180+ tabs1.Add(tab1_1)
181+ tabs2 := entity.NewTabList()
182+ tabs2.Add(tab1_2)
183+
184+ sessionID := entity.SessionID("20251224_120000_v2_windows")
185+
186+ windows := []entity.WindowTabListState{
187+ {WindowID: "w1", Tabs: tabs1},
188+ {WindowID: "w2", Tabs: tabs2},
189+ }
190+
191+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
192+ Run(func(_ context.Context, state *entity.SessionState) {
193+ require.Equal(t, sessionID, state.SessionID)
194+ require.Equal(t, 2, state.Version)
195+ // Windows field populated
196+ require.Len(t, state.Windows, 2)
197+ assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID)
198+ assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID)
199+ // Window 1 has 1 tab
200+ require.Len(t, state.Windows[0].Tabs, 1)
201+ assert.Equal(t, "Window1 Tab1", state.Windows[0].Tabs[0].Name)
202+ // Window 2 has 1 tab
203+ require.Len(t, state.Windows[1].Tabs, 1)
204+ assert.Equal(t, "Window2 Tab1", state.Windows[1].Tabs[0].Name)
205+ // Active window index set
206+ assert.Equal(t, 0, state.ActiveWindowIndex)
207+ // Legacy Tabs empty when windows are provided
208+ assert.Empty(t, state.Tabs)
209+ }).
210+ Return(nil)
211+
212+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
213+
214+ err := uc.Execute(ctx, usecase.SnapshotInput{
215+ SessionID: sessionID,
216+ Windows: windows,
217+ ActiveWindowIndex: 0,
218+ })
219+ require.NoError(t, err)
220+}
221+
122 func TestSessionState_CountPanes(t *testing.T) {222 func TestSessionState_CountPanes(t *testing.T) {
123 state := &entity.SessionState{223 state := &entity.SessionState{
124 Tabs: []entity.TabSnapshot{224 Tabs: []entity.TabSnapshot{
M internal/domain/entity/session_state.go +67 -16
@@ -2,18 +2,24 @@ package entity
2 2
3 import "time"3 import "time"
4 4
5+const LegacySessionStateVersion = 1
6+
5 // SessionStateVersion is the current schema version for session state.7 // SessionStateVersion is the current schema version for session state.
6 // Increment when making breaking changes to the serialization format.8 // Increment when making breaking changes to the serialization format.
7-const SessionStateVersion = 19+// Version 2: added window-scoped sessions (Windows, ActiveWindowIndex).
10+const SessionStateVersion = 2
8 11
9 // SessionState represents a complete snapshot of a browser session.12 // SessionState represents a complete snapshot of a browser session.
10 // This is serialized to JSON and stored in the database.13 // This is serialized to JSON and stored in the database.
11 type SessionState struct {14 type SessionState struct {
12 Version int `json:"version"`15 Version int `json:"version"`
13 SessionID SessionID `json:"session_id"`16 SessionID SessionID `json:"session_id"`
14- Tabs []TabSnapshot `json:"tabs"`17+ Tabs []TabSnapshot `json:"tabs,omitempty"`
15- ActiveTabIndex int `json:"active_tab_index"`18+ ActiveTabIndex int `json:"active_tab_index,omitempty"`
16- SavedAt time.Time `json:"saved_at"`19+ // v2: window-scoped sessions
20+ Windows []WindowSnapshot `json:"windows,omitempty"`
21+ ActiveWindowIndex int `json:"active_window_index,omitempty"`
22+ SavedAt time.Time `json:"saved_at"`
17 }23 }
18 24
19 // TabSnapshot captures the state of a single tab.25 // TabSnapshot captures the state of a single tab.
@@ -55,7 +61,7 @@ type PaneSnapshot struct {
55 func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState {61 func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState {
56 if tabs == nil {62 if tabs == nil {
57 return &SessionState{63 return &SessionState{
58- Version: SessionStateVersion,64+ Version: LegacySessionStateVersion,
59 SessionID: sessionID,65 SessionID: sessionID,
60 Tabs: []TabSnapshot{},66 Tabs: []TabSnapshot{},
61 SavedAt: time.Now(),67 SavedAt: time.Now(),
@@ -73,7 +79,7 @@ func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState {
73 }79 }
74 80
75 return &SessionState{81 return &SessionState{
76- Version: SessionStateVersion,82+ Version: LegacySessionStateVersion,
77 SessionID: sessionID,83 SessionID: sessionID,
78 Tabs: snapTabs,84 Tabs: snapTabs,
79 ActiveTabIndex: activeTabIndex,85 ActiveTabIndex: activeTabIndex,
@@ -147,6 +153,17 @@ type SessionInfo struct {
147 153
148 // CountPanes returns the total number of panes in the session state.154 // CountPanes returns the total number of panes in the session state.
149 func (s *SessionState) CountPanes() int {155 func (s *SessionState) CountPanes() int {
156+ // v2: count across windows
157+ if len(s.Windows) > 0 {
158+ count := 0
159+ for _, w := range s.Windows {
160+ for _, tab := range w.Tabs {
161+ count += countPanesInNode(tab.Workspace.Root)
162+ }
163+ }
164+ return count
165+ }
166+ // v1: legacy flat tabs
150 count := 0167 count := 0
151 for _, tab := range s.Tabs {168 for _, tab := range s.Tabs {
152 count += countPanesInNode(tab.Workspace.Root)169 count += countPanesInNode(tab.Workspace.Root)
@@ -171,17 +188,12 @@ func countPanesInNode(node *PaneNodeSnapshot) int {
171 // IDGenerator is a function that generates unique IDs.188 // IDGenerator is a function that generates unique IDs.
172 type IDGenerator func() string189 type IDGenerator func() string
173 190
174-// TabListFromSnapshot reconstructs a TabList from a SessionState snapshot.191+// tabListFromSnapshots converts a slice of TabSnapshots to a live TabList.
175-// Generates new IDs for all entities using the provided generator.192+// activeIndex is the index in snapshots that should become the active tab.
176-// This is the inverse of SnapshotFromTabList.193+func tabListFromSnapshots(snaps []TabSnapshot, activeIndex int, idGen IDGenerator) *TabList {
177-func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
178- if state == nil {
179- return NewTabList()
180- }
181-
182 tabs := NewTabList()194 tabs := NewTabList()
183 195
184- for i, tabSnap := range state.Tabs {196+ for i, tabSnap := range snaps {
185 tab := tabFromSnapshot(&tabSnap, idGen)197 tab := tabFromSnapshot(&tabSnap, idGen)
186 if tab == nil {198 if tab == nil {
187 continue199 continue
@@ -189,7 +201,7 @@ func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
189 tabs.Tabs = append(tabs.Tabs, tab)201 tabs.Tabs = append(tabs.Tabs, tab)
190 tab.Position = len(tabs.Tabs) - 1202 tab.Position = len(tabs.Tabs) - 1
191 203
192- if i == state.ActiveTabIndex {204+ if i == activeIndex {
193 tabs.ActiveTabID = tab.ID205 tabs.ActiveTabID = tab.ID
194 }206 }
195 }207 }
@@ -202,6 +214,45 @@ func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
202 return tabs214 return tabs
203 }215 }
204 216
217+// TabListFromSnapshot reconstructs a TabList from a SessionState snapshot.
218+// Generates new IDs for all entities using the provided generator.
219+// This is the inverse of SnapshotFromTabList.
220+func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
221+ if state == nil {
222+ return NewTabList()
223+ }
224+ if state.Version >= SessionStateVersion {
225+ tabs, activeIndex := flattenWindowTabSnapshots(state.Windows, state.ActiveWindowIndex)
226+ return tabListFromSnapshots(tabs, activeIndex, idGen)
227+ }
228+
229+ return tabListFromSnapshots(state.Tabs, state.ActiveTabIndex, idGen)
230+}
231+
232+func flattenWindowTabSnapshots(windows []WindowSnapshot, activeWindowIndex int) ([]TabSnapshot, int) {
233+ if activeWindowIndex < 0 || activeWindowIndex >= len(windows) {
234+ activeWindowIndex = 0
235+ }
236+ flattened := make([]TabSnapshot, 0)
237+ activeTabIndex := 0
238+ for i, win := range windows {
239+ if i == activeWindowIndex {
240+ if len(win.Tabs) == 0 {
241+ // Active window has zero tabs; activeTabIndex stays at 0
242+ // (first tab overall if any), not len(flattened).
243+ } else {
244+ winActiveIndex := win.ActiveTabIndex
245+ if winActiveIndex < 0 || winActiveIndex >= len(win.Tabs) {
246+ winActiveIndex = 0
247+ }
248+ activeTabIndex = len(flattened) + winActiveIndex
249+ }
250+ }
251+ flattened = append(flattened, win.Tabs...)
252+ }
253+ return flattened, activeTabIndex
254+}
255+
205 func tabFromSnapshot(snap *TabSnapshot, idGen IDGenerator) *Tab {256 func tabFromSnapshot(snap *TabSnapshot, idGen IDGenerator) *Tab {
206 if snap == nil {257 if snap == nil {
207 return nil258 return nil
M internal/domain/entity/session_state_test.go +323 -0
@@ -1,6 +1,7 @@
1 package entity_test1 package entity_test
2 2
3 import (3 import (
4+ "encoding/json"
4 "sync/atomic"5 "sync/atomic"
5 "testing"6 "testing"
6 "time"7 "time"
@@ -380,6 +381,252 @@ func TestFindPaneAcrossTabs(t *testing.T) {
380 assert.Nil(t, notFound)381 assert.Nil(t, notFound)
381 }382 }
382 383
384+// --- Window-scoped snapshot tests ---
385+
386+func TestSnapshotFromWindowTabLists_V2State(t *testing.T) {
387+ // Create two windows, each with tabs
388+ pane1_1 := entity.NewPane("p1_w1")
389+ pane1_1.URI = "https://window1.com"
390+ tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1_1)
391+ tab1_1.Name = "Window1 Tab1"
392+
393+ pane1_2 := entity.NewPane("p1_w2")
394+ pane1_2.URI = "https://window2.com"
395+ tab1_2 := entity.NewTab("t1_w2", "ws1_w2", pane1_2)
396+ tab1_2.Name = "Window2 Tab1"
397+
398+ pane1_2b := entity.NewPane("p2_w2")
399+ pane1_2b.URI = "https://window2-tab2.com"
400+ tab1_2b := entity.NewTab("t2_w2", "ws2_w2", pane1_2b)
401+ tab1_2b.Name = "Window2 Tab2"
402+
403+ tabs1 := entity.NewTabList()
404+ tabs1.Add(tab1_1)
405+
406+ tabs2 := entity.NewTabList()
407+ tabs2.Add(tab1_2)
408+ tabs2.Add(tab1_2b)
409+ tabs2.SetActive("t2_w2")
410+
411+ windows := []entity.WindowTabListState{
412+ {WindowID: "w1", Tabs: tabs1},
413+ {WindowID: "w2", Tabs: tabs2},
414+ }
415+
416+ sessionID := entity.SessionID("test_v2")
417+ state := entity.SnapshotFromWindowTabLists(sessionID, windows, 1, time.Unix(123, 0))
418+
419+ require.NotNil(t, state)
420+ assert.Equal(t, 2, state.Version)
421+ assert.Equal(t, sessionID, state.SessionID)
422+
423+ // Windows field populated
424+ require.Len(t, state.Windows, 2)
425+ assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID)
426+ assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID)
427+
428+ // Active window index set
429+ assert.Equal(t, 1, state.ActiveWindowIndex)
430+
431+ // No legacy Tabs when using windows
432+ assert.Empty(t, state.Tabs)
433+
434+ // CountPanes across windows
435+ assert.Equal(t, 3, state.CountPanes())
436+}
437+
438+func TestWindowTabListsFromSnapshot_V2Restore(t *testing.T) {
439+ // Create a v2 snapshot from windows, then restore
440+ pane := entity.NewPane("p_w1_t1")
441+ pane.URI = "https://example.com"
442+ tab := entity.NewTab("t_w1_t1", "ws_w1", pane)
443+ tab.Name = "MyTab"
444+
445+ tabs := entity.NewTabList()
446+ tabs.Add(tab)
447+
448+ windows := []entity.WindowTabListState{
449+ {WindowID: "window-1", Tabs: tabs},
450+ }
451+
452+ state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
453+
454+ idGen := mockIDGenerator()
455+ restored := entity.WindowTabListsFromSnapshot(state, idGen)
456+
457+ require.Len(t, restored, 1)
458+ assert.Equal(t, entity.WindowID("window-1"), restored[0].WindowID)
459+ require.NotNil(t, restored[0].Tabs)
460+ require.Len(t, restored[0].Tabs.Tabs, 1)
461+ assert.Equal(t, "MyTab", restored[0].Tabs.Tabs[0].Name)
462+ assert.Equal(t, "https://example.com", restored[0].Tabs.Tabs[0].Workspace.Root.Pane.URI)
463+}
464+
465+func TestWindowTabListsFromSnapshot_V1LegacySingleWindow(t *testing.T) {
466+ // Legacy v1 flat tabs should be restored as a single window with empty WindowID
467+ state := &entity.SessionState{
468+ Version: 1,
469+ SessionID: "old_sess",
470+ ActiveTabIndex: 1,
471+ Tabs: []entity.TabSnapshot{
472+ {
473+ ID: "tab1",
474+ Name: "First",
475+ Workspace: entity.WorkspaceSnapshot{
476+ Root: &entity.PaneNodeSnapshot{
477+ Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.com"},
478+ },
479+ },
480+ },
481+ {
482+ ID: "tab2",
483+ Name: "Second",
484+ Workspace: entity.WorkspaceSnapshot{
485+ Root: &entity.PaneNodeSnapshot{
486+ Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://second.com"},
487+ },
488+ },
489+ },
490+ },
491+ }
492+
493+ idGen := mockIDGenerator()
494+ restored := entity.WindowTabListsFromSnapshot(state, idGen)
495+
496+ require.Len(t, restored, 1)
497+ // Legacy window gets empty WindowID
498+ assert.Equal(t, entity.WindowID(""), restored[0].WindowID)
499+ require.NotNil(t, restored[0].Tabs)
500+ require.Len(t, restored[0].Tabs.Tabs, 2)
501+ assert.Equal(t, "Second", restored[0].Tabs.Tabs[1].Name)
502+ // Active tab should be second (index 1)
503+ assert.Equal(t, restored[0].Tabs.Tabs[1].ID, restored[0].Tabs.ActiveTabID)
504+}
505+
506+func TestWindowTabListsFromSnapshot_NilState(t *testing.T) {
507+ idGen := mockIDGenerator()
508+ restored := entity.WindowTabListsFromSnapshot(nil, idGen)
509+ assert.Nil(t, restored)
510+}
511+
512+func TestWindowTabListsFromSnapshot_InvalidIndexesFallback(t *testing.T) {
513+ // Tab with active tab index out of range should fall back to first tab
514+ state := &entity.SessionState{
515+ Version: 1,
516+ ActiveTabIndex: 999, // beyond bounds
517+ Tabs: []entity.TabSnapshot{
518+ {
519+ ID: "tab_a",
520+ Name: "TabA",
521+ Workspace: entity.WorkspaceSnapshot{
522+ Root: &entity.PaneNodeSnapshot{
523+ Pane: &entity.PaneSnapshot{ID: "pa", URI: "https://a.com"},
524+ },
525+ },
526+ },
527+ },
528+ }
529+
530+ idGen := mockIDGenerator()
531+ restored := entity.WindowTabListsFromSnapshot(state, idGen)
532+
533+ require.Len(t, restored, 1)
534+ require.NotNil(t, restored[0].Tabs)
535+ // Should have fallen back to first tab
536+ assert.Equal(t, restored[0].Tabs.Tabs[0].ID, restored[0].Tabs.ActiveTabID)
537+}
538+
539+func TestTabListFromSnapshot_ActiveWindowWithZeroTabsFallsBackToFirstOverallTab(t *testing.T) {
540+ // Regression: when the active window has zero tabs, the active tab should
541+ // be the first tab overall (window 0's tab), not the third window's tab.
542+ state := &entity.SessionState{
543+ Version: entity.SessionStateVersion,
544+ ActiveWindowIndex: 1,
545+ Windows: []entity.WindowSnapshot{
546+ {ID: "w1", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
547+ {Name: "First", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.example"}}}},
548+ }},
549+ {ID: "w2", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{}},
550+ {ID: "w3", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
551+ {Name: "Third", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://third.example"}}}},
552+ }},
553+ },
554+ }
555+
556+ tabs := entity.TabListFromSnapshot(state, mockIDGenerator())
557+
558+ require.Len(t, tabs.Tabs, 2)
559+ assert.Equal(t, "First", tabs.ActiveTab().Name, "active tab should be the first overall tab, not the third window's tab")
560+}
561+
562+func TestTabListFromSnapshot_FlattensV2WindowSnapshots(t *testing.T) {
563+ state := &entity.SessionState{
564+ Version: entity.SessionStateVersion,
565+ ActiveWindowIndex: 1,
566+ Windows: []entity.WindowSnapshot{
567+ {ID: "w1", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{
568+ {Name: "First", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.example"}}}},
569+ }},
570+ {ID: "w2", ActiveTabIndex: 1, Tabs: []entity.TabSnapshot{
571+ {Name: "Second", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://second.example"}}}},
572+ {Name: "Active", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://active.example"}}}},
573+ }},
574+ },
575+ }
576+
577+ tabs := entity.TabListFromSnapshot(state, mockIDGenerator())
578+
579+ require.Len(t, tabs.Tabs, 3)
580+ assert.Equal(t, "Active", tabs.ActiveTab().Name)
581+}
582+
583+func TestWindowTabListsFromSnapshot_FutureV2VersionWithEmptyWindowsStaysEmpty(t *testing.T) {
584+ state := &entity.SessionState{Version: entity.SessionStateVersion + 1, Windows: []entity.WindowSnapshot{}}
585+
586+ restored := entity.WindowTabListsFromSnapshot(state, mockIDGenerator())
587+
588+ assert.Empty(t, restored)
589+}
590+
591+func TestSnapshotFromWindowTabLists_NilTabs(t *testing.T) {
592+ windows := []entity.WindowTabListState{
593+ {WindowID: "w1", Tabs: nil},
594+ }
595+
596+ state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
597+
598+ require.Len(t, state.Windows, 1)
599+ assert.Empty(t, state.Windows[0].Tabs)
600+ assert.Equal(t, 0, state.CountPanes())
601+}
602+
603+func TestSnapshotFromWindowTabLists_EmptyWindows(t *testing.T) {
604+ state := entity.SnapshotFromWindowTabLists("sess", nil, 0, time.Unix(123, 0))
605+
606+ require.NotNil(t, state)
607+ assert.Equal(t, 2, state.Version)
608+ assert.Empty(t, state.Windows)
609+ assert.Equal(t, 0, state.ActiveWindowIndex)
610+}
611+
612+func TestSnapshotFromWindowTabLists_InvalidWindowIndex(t *testing.T) {
613+ pane := entity.NewPane("p1")
614+ pane.URI = "https://x.com"
615+ tab := entity.NewTab("t1", "ws1", pane)
616+ tabs := entity.NewTabList()
617+ tabs.Add(tab)
618+
619+ windows := []entity.WindowTabListState{
620+ {WindowID: "w1", Tabs: tabs},
621+ }
622+
623+ // Active window index out of range
624+ state := entity.SnapshotFromWindowTabLists("sess", windows, 999, time.Unix(123, 0))
625+
626+ // Should clamp to 0
627+ assert.Equal(t, 0, state.ActiveWindowIndex)
628+}
629+
383 func TestFindPaneInNestedStructure(t *testing.T) {630 func TestFindPaneInNestedStructure(t *testing.T) {
384 // Create a tab with split panes631 // Create a tab with split panes
385 pane1 := entity.NewPane("left_pane")632 pane1 := entity.NewPane("left_pane")
@@ -428,3 +675,79 @@ func TestFindPaneInNestedStructure(t *testing.T) {
428 rightNodeAgain := tab.Workspace.FindPane("right_pane")675 rightNodeAgain := tab.Workspace.FindPane("right_pane")
429 assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI)676 assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI)
430 }677 }
678+
679+func TestV2SnapshotJSON_OmitEmptyLegacyFields(t *testing.T) {
680+ // When using SnapshotFromWindowTabLists (v2 path), the JSON output must
681+ // NOT include top-level legacy "tabs" or "active_tab_index" fields.
682+ // They should be omitted by omitempty.
683+ pane := entity.NewPane(entity.PaneID("p1"))
684+ pane.URI = "https://example.com"
685+ tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
686+ tabs := entity.NewTabList()
687+ tabs.Add(tab)
688+
689+ windows := []entity.WindowTabListState{
690+ {WindowID: entity.WindowID("w1"), Tabs: tabs},
691+ }
692+
693+ state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0))
694+
695+ data, err := json.Marshal(state)
696+ require.NoError(t, err)
697+
698+ var raw map[string]any
699+ err = json.Unmarshal(data, &raw)
700+ require.NoError(t, err)
701+
702+ // v2 JSON must not contain top-level legacy fields.
703+ _, hasTabs := raw["tabs"]
704+ _, hasActiveTabIdx := raw["active_tab_index"]
705+ assert.False(t, hasTabs, "v2 JSON must not include legacy 'tabs' field")
706+ assert.False(t, hasActiveTabIdx, "v2 JSON must not include legacy 'active_tab_index' field")
707+
708+ // But must still have the v2 windows field.
709+ windowsRaw, ok := raw["windows"]
710+ require.True(t, ok, "v2 JSON must include 'windows' field")
711+ windowsArr, _ := windowsRaw.([]any)
712+ require.Len(t, windowsArr, 1)
713+}
714+
715+func TestV2SnapshotJSON_CanStillReadV1(t *testing.T) {
716+ // When reading a v1 JSON with the legacy fields, they must still be
717+ // populated correctly (omitempty is only for writes).
718+ v1JSON := `{
719+ "version": 1,
720+ "session_id": "old-sess",
721+ "tabs": [{
722+ "id": "tab1",
723+ "name": "MyTab",
724+ "position": 0,
725+ "is_pinned": false,
726+ "workspace": {
727+ "id": "ws1",
728+ "root": {
729+ "id": "node1",
730+ "pane": {"id": "p1", "uri": "https://example.com", "title": "", "zoom_factor": 0},
731+ "split_dir": 0,
732+ "split_ratio": 0,
733+ "is_stacked": false,
734+ "active_stack_index": 0
735+ },
736+ "active_pane_id": ""
737+ }
738+ }],
739+ "active_tab_index": 0,
740+ "saved_at": "2026-01-01T00:00:00Z"
741+ }`
742+
743+ var state entity.SessionState
744+ err := json.Unmarshal([]byte(v1JSON), &state)
745+ require.NoError(t, err)
746+
747+ assert.Equal(t, 1, state.Version)
748+ assert.Equal(t, entity.SessionID("old-sess"), state.SessionID)
749+ require.Len(t, state.Tabs, 1)
750+ assert.Equal(t, entity.TabID("tab1"), state.Tabs[0].ID)
751+ assert.Equal(t, "MyTab", state.Tabs[0].Name)
752+ assert.Equal(t, 0, state.ActiveTabIndex)
753+}
M internal/domain/entity/tab.go +17 -0
@@ -161,6 +161,23 @@ func (tl *TabList) Move(id TabID, newPos int) bool {
161 return true161 return true
162 }162 }
163 163
164+// Snapshot returns a shallow copy of the TabList safe for concurrent read.
165+// The returned TabList shares the underlying Tab pointers but has its own
166+// slice header and scalar fields, protecting against races from concurrent
167+// structural mutations (add/remove) and ActiveTabID changes on the original.
168+func (tl *TabList) Snapshot() *TabList {
169+ if tl == nil {
170+ return nil
171+ }
172+ tabs := make([]*Tab, len(tl.Tabs))
173+ copy(tabs, tl.Tabs)
174+ return &TabList{
175+ Tabs: tabs,
176+ ActiveTabID: tl.ActiveTabID,
177+ PreviousActiveTabID: tl.PreviousActiveTabID,
178+ }
179+}
180+
164 // ReplaceFrom replaces this TabList's contents with those from another TabList.181 // ReplaceFrom replaces this TabList's contents with those from another TabList.
165 // This modifies in-place so existing references to this TabList remain valid.182 // This modifies in-place so existing references to this TabList remain valid.
166 func (tl *TabList) ReplaceFrom(other *TabList) {183 func (tl *TabList) ReplaceFrom(other *TabList) {
A internal/domain/entity/window_state.go +103 -0
@@ -0,0 +1,103 @@
1+package entity
2+
3+import "time"
4+
5+// WindowID uniquely identifies a browser window.
6+type WindowID string
7+
8+// WindowSnapshot captures the state of a single browser window.
9+type WindowSnapshot struct {
10+ ID WindowID `json:"id"`
11+ Tabs []TabSnapshot `json:"tabs"`
12+ ActiveTabIndex int `json:"active_tab_index"`
13+}
14+
15+// WindowTabListState pairs a window ID with its live TabList.
16+type WindowTabListState struct {
17+ WindowID WindowID
18+ Tabs *TabList
19+}
20+
21+// SnapshotFromWindowTabLists creates a v2 SessionState from ordered windows.
22+func SnapshotFromWindowTabLists(sessionID SessionID, windows []WindowTabListState, activeWindowIndex int, savedAt time.Time) *SessionState {
23+ // Clamp active window index
24+ if activeWindowIndex < 0 || activeWindowIndex >= len(windows) {
25+ activeWindowIndex = 0
26+ }
27+
28+ snapWindows := make([]WindowSnapshot, 0, len(windows))
29+ for _, w := range windows {
30+ snapWindows = append(snapWindows, windowToSnapshot(w.WindowID, w.Tabs))
31+ }
32+
33+ return &SessionState{
34+ Version: SessionStateVersion,
35+ SessionID: sessionID,
36+ Windows: snapWindows,
37+ ActiveWindowIndex: activeWindowIndex,
38+ SavedAt: savedAt,
39+ }
40+}
41+
42+// windowToSnapshot converts a live TabList to a WindowSnapshot.
43+func windowToSnapshot(id WindowID, tabs *TabList) WindowSnapshot {
44+ if tabs == nil {
45+ return WindowSnapshot{
46+ ID: id,
47+ Tabs: []TabSnapshot{},
48+ }
49+ }
50+
51+ snapTabs := make([]TabSnapshot, 0, len(tabs.Tabs))
52+ activeTabIndex := 0
53+
54+ for i, tab := range tabs.Tabs {
55+ if tab.ID == tabs.ActiveTabID {
56+ activeTabIndex = i
57+ }
58+ snapTabs = append(snapTabs, snapshotTab(tab))
59+ }
60+
61+ return WindowSnapshot{
62+ ID: id,
63+ Tabs: snapTabs,
64+ ActiveTabIndex: activeTabIndex,
65+ }
66+}
67+
68+// WindowTabListsFromSnapshot restores window/tab lists from a SessionState.
69+// For v2 states (with Windows), each WindowSnapshot is restored independently.
70+// For legacy v1 states (flat Tabs), returns a single window with empty WindowID.
71+func WindowTabListsFromSnapshot(state *SessionState, idGen IDGenerator) []WindowTabListState {
72+ if state == nil {
73+ return nil
74+ }
75+
76+ // v2+: restore from Windows, including valid empty-window snapshots.
77+ if state.Version >= SessionStateVersion {
78+ result := make([]WindowTabListState, 0, len(state.Windows))
79+ for _, wSnap := range state.Windows {
80+ tabs := tabListFromWindowSnapshot(&wSnap, idGen)
81+ result = append(result, WindowTabListState{
82+ WindowID: wSnap.ID,
83+ Tabs: tabs,
84+ })
85+ }
86+ return result
87+ }
88+
89+ // v1: legacy flat tabs wrapped in a single window
90+ tabs := TabListFromSnapshot(state, idGen)
91+ return []WindowTabListState{
92+ {WindowID: "", Tabs: tabs},
93+ }
94+}
95+
96+// tabListFromWindowSnapshot converts a single WindowSnapshot to a live TabList.
97+func tabListFromWindowSnapshot(snap *WindowSnapshot, idGen IDGenerator) *TabList {
98+ if snap == nil {
99+ return NewTabList()
100+ }
101+
102+ return tabListFromSnapshots(snap.Tabs, snap.ActiveTabIndex, idGen)
103+}
M internal/infrastructure/snapshot/service.go +20 -7
@@ -10,6 +10,7 @@ import (
10 10
11 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"12 "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
14 )15 )
15 16
@@ -25,7 +26,7 @@ var _ port.SnapshotService = (*Service)(nil)
25 // Service handles debounced session state snapshots.26 // Service handles debounced session state snapshots.
26 type Service struct {27 type Service struct {
27 snapshotUC *usecase.SnapshotSessionUseCase28 snapshotUC *usecase.SnapshotSessionUseCase
28- provider port.TabListProvider29+ provider port.WindowStateProvider
29 interval time.Duration30 interval time.Duration
30 retries int31 retries int
31 retryDelay time.Duration32 retryDelay time.Duration
@@ -41,7 +42,7 @@ type Service struct {
41 // NewService creates a new snapshot service.42 // NewService creates a new snapshot service.
42 func NewService(43 func NewService(
43 snapshotUC *usecase.SnapshotSessionUseCase,44 snapshotUC *usecase.SnapshotSessionUseCase,
44- provider port.TabListProvider,45+ provider port.WindowStateProvider,
45 intervalMs int,46 intervalMs int,
46 ) *Service {47 ) *Service {
47 if intervalMs <= 0 {48 if intervalMs <= 0 {
@@ -163,7 +164,6 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
163 s.dirty = false164 s.dirty = false
164 s.mu.Unlock()165 s.mu.Unlock()
165 166
166- tabList := s.provider.GetTabList()
167 sessionID := s.provider.GetSessionID()167 sessionID := s.provider.GetSessionID()
168 168
169 if sessionID == "" {169 if sessionID == "" {
@@ -171,10 +171,23 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
171 return nil171 return nil
172 }172 }
173 173
174- if err := s.executeWithRetry(ctx, usecase.SnapshotInput{174+ windows, activeWindowIndex := s.provider.GetWindowSnapshotState()
175- SessionID: sessionID,175+ if windows == nil {
176- TabList: tabList,176+ windows = make([]entity.WindowTabListState, 0)
177- }); err != nil {177+ }
178+ input := usecase.SnapshotInput{
179+ SessionID: sessionID,
180+ Windows: windows,
181+ ActiveWindowIndex: activeWindowIndex,
182+ }
183+ // single window with WindowID == "" is legacy v1 sentinel; populate TabList and clear Windows/ActiveWindowIndex.
184+ if len(windows) == 1 && windows[0].WindowID == "" {
185+ input.Windows = nil
186+ input.ActiveWindowIndex = 0
187+ input.TabList = windows[0].Tabs
188+ }
189+
190+ if err := s.executeWithRetry(ctx, input); err != nil {
178 s.markDirty()191 s.markDirty()
179 return err192 return err
180 }193 }
M internal/infrastructure/snapshot/service_test.go +123 -27
@@ -6,6 +6,7 @@ import (
6 "testing"6 "testing"
7 "time"7 "time"
8 8
9+ "github.com/bnema/dumber/internal/application/port/mocks"
9 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
11 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"12 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
@@ -14,17 +15,16 @@ import (
14 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
15 )16 )
16 17
17-type testProvider struct {18+func newWindowStateProvider(
18- sessionID entity.SessionID19+ t *testing.T,
19- tabList *entity.TabList20+ sessionID entity.SessionID,
20-}21+ windows []entity.WindowTabListState,
21-22+ activeWindowIndex int,
22-func (p *testProvider) GetTabList() *entity.TabList {23+) *mocks.MockWindowStateProvider {
23- return p.tabList24+ provider := mocks.NewMockWindowStateProvider(t)
24-}25+ provider.EXPECT().GetSessionID().Return(sessionID).Once()
25-26+ provider.EXPECT().GetWindowSnapshotState().Return(windows, activeWindowIndex).Once()
26-func (p *testProvider) GetSessionID() entity.SessionID {27+ return provider
27- return p.sessionID
28 }28 }
29 29
30 func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {30 func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {
@@ -41,10 +41,7 @@ func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {
41 })41 })
42 42
43 uc := usecase.NewSnapshotSessionUseCase(repo)43 uc := usecase.NewSnapshotSessionUseCase(repo)
44- svc := NewService(uc, &testProvider{44+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fk_retry_ok", nil, 0), 1)
45- sessionID: "20260207_120000_fk_retry_ok",
46- tabList: entity.NewTabList(),
47- }, 1)
48 svc.retryDelay = time.Millisecond45 svc.retryDelay = time.Millisecond
49 svc.ready = true46 svc.ready = true
50 svc.dirty = true47 svc.dirty = true
@@ -67,10 +64,7 @@ func TestService_SaveSnapshot_RetriesTransientFKAndFails(t *testing.T) {
67 })64 })
68 65
69 uc := usecase.NewSnapshotSessionUseCase(repo)66 uc := usecase.NewSnapshotSessionUseCase(repo)
70- svc := NewService(uc, &testProvider{67+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fk_retry_fail", nil, 0), 1)
71- sessionID: "20260207_120000_fk_retry_fail",
72- tabList: entity.NewTabList(),
73- }, 1)
74 svc.retryDelay = time.Millisecond68 svc.retryDelay = time.Millisecond
75 svc.ready = true69 svc.ready = true
76 svc.dirty = true70 svc.dirty = true
@@ -94,10 +88,7 @@ func TestService_SaveSnapshot_DoesNotRetryNonFKError(t *testing.T) {
94 })88 })
95 89
96 uc := usecase.NewSnapshotSessionUseCase(repo)90 uc := usecase.NewSnapshotSessionUseCase(repo)
97- svc := NewService(uc, &testProvider{91+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_non_fk", nil, 0), 1)
98- sessionID: "20260207_120000_non_fk",
99- tabList: entity.NewTabList(),
100- }, 1)
101 svc.retryDelay = time.Millisecond92 svc.retryDelay = time.Millisecond
102 svc.ready = true93 svc.ready = true
103 svc.dirty = true94 svc.dirty = true
@@ -120,10 +111,7 @@ func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) {
120 })111 })
121 112
122 uc := usecase.NewSnapshotSessionUseCase(repo)113 uc := usecase.NewSnapshotSessionUseCase(repo)
123- svc := NewService(uc, &testProvider{114+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_ready_flush", nil, 0), 1)
124- sessionID: "20260207_120000_ready_flush",
125- tabList: entity.NewTabList(),
126- }, 1)
127 svc.Start(context.Background())115 svc.Start(context.Background())
128 svc.dirty = true116 svc.dirty = true
129 117
@@ -139,3 +127,111 @@ func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) {
139 svc.mu.Unlock()127 svc.mu.Unlock()
140 assert.False(t, dirty)128 assert.False(t, dirty)
141 }129 }
130+
131+func TestService_SaveNowPassesWindowSnapshots(t *testing.T) {
132+ repo := repomocks.NewMockSessionStateRepository(t)
133+
134+ pane1 := entity.NewPane("p_w1")
135+ pane1.URI = "https://win1.com"
136+ tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1)
137+ tab1_1.Name = "Win1Tab"
138+ tabs1 := entity.NewTabList()
139+ tabs1.Add(tab1_1)
140+
141+ pane2 := entity.NewPane("p_w2")
142+ pane2.URI = "https://win2.com"
143+ tab2_1 := entity.NewTab("t1_w2", "ws1_w2", pane2)
144+ tab2_1.Name = "Win2Tab"
145+ tabs2 := entity.NewTabList()
146+ tabs2.Add(tab2_1)
147+
148+ windows := []entity.WindowTabListState{
149+ {WindowID: "w1", Tabs: tabs1},
150+ {WindowID: "w2", Tabs: tabs2},
151+ }
152+
153+ sessionID := entity.SessionID("20260501_window_snap")
154+
155+ repo.EXPECT().
156+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
157+ Run(func(_ context.Context, state *entity.SessionState) {
158+ require.Equal(t, sessionID, state.SessionID)
159+ require.Equal(t, 2, state.Version)
160+ require.Len(t, state.Windows, 2)
161+ assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID)
162+ assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID)
163+ assert.Equal(t, 1, state.ActiveWindowIndex)
164+ assert.Empty(t, state.Tabs)
165+ }).
166+ Return(nil)
167+
168+ uc := usecase.NewSnapshotSessionUseCase(repo)
169+ provider := newWindowStateProvider(t, sessionID, windows, 1)
170+ svc := NewService(uc, provider, 1)
171+ svc.ready = true
172+ svc.dirty = true
173+
174+ err := svc.saveSnapshot(context.Background())
175+ require.NoError(t, err)
176+ assert.False(t, svc.dirty)
177+}
178+
179+func TestService_SaveNowPersistsEmptyWindowSnapshotAsV2(t *testing.T) {
180+ repo := repomocks.NewMockSessionStateRepository(t)
181+ sessionID := entity.SessionID("20260501_empty_window_snap")
182+
183+ repo.EXPECT().
184+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
185+ Run(func(_ context.Context, state *entity.SessionState) {
186+ require.Equal(t, sessionID, state.SessionID)
187+ require.Equal(t, entity.SessionStateVersion, state.Version)
188+ require.Empty(t, state.Windows)
189+ require.Empty(t, state.Tabs)
190+ assert.Equal(t, 0, state.ActiveWindowIndex)
191+ }).
192+ Return(nil)
193+
194+ uc := usecase.NewSnapshotSessionUseCase(repo)
195+ provider := newWindowStateProvider(t, sessionID, []entity.WindowTabListState{}, 2)
196+ svc := NewService(uc, provider, 1)
197+ svc.ready = true
198+ svc.dirty = true
199+
200+ err := svc.saveSnapshot(context.Background())
201+ require.NoError(t, err)
202+ assert.False(t, svc.dirty)
203+}
204+
205+func TestService_SaveNowPreservesLegacySingleEmptyWindowSentinel(t *testing.T) {
206+ repo := repomocks.NewMockSessionStateRepository(t)
207+ sessionID := entity.SessionID("20260501_legacy_sentinel")
208+
209+ pane := entity.NewPane("p_legacy")
210+ pane.URI = "https://legacy.example"
211+ tab := entity.NewTab("t_legacy", "ws_legacy", pane)
212+ tab.Name = "Legacy Tab"
213+ tabs := entity.NewTabList()
214+ tabs.Add(tab)
215+
216+ repo.EXPECT().
217+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
218+ Run(func(_ context.Context, state *entity.SessionState) {
219+ require.Equal(t, sessionID, state.SessionID)
220+ require.Equal(t, entity.LegacySessionStateVersion, state.Version)
221+ require.Len(t, state.Tabs, 1)
222+ assert.Equal(t, "Legacy Tab", state.Tabs[0].Name)
223+ assert.Empty(t, state.Windows)
224+ assert.Equal(t, 0, state.ActiveWindowIndex)
225+ }).
226+ Return(nil)
227+
228+ uc := usecase.NewSnapshotSessionUseCase(repo)
229+ provider := newWindowStateProvider(t, sessionID, []entity.WindowTabListState{{WindowID: "", Tabs: tabs}}, 3)
230+ svc := NewService(uc, provider, 1)
231+ svc.ready = true
232+ svc.dirty = true
233+
234+ err := svc.saveSnapshot(context.Background())
235+ require.NoError(t, err)
236+ assert.False(t, svc.dirty)
237+}
M internal/ui/app.go +1175 -280
@@ -7,6 +7,7 @@ import (
7 "io"7 "io"
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10+ "sort"
10 "strings"11 "strings"
11 "sync"12 "sync"
12 "unsafe"13 "unsafe"
@@ -76,9 +77,9 @@ type App struct {
76 mainWindow *window.MainWindow77 mainWindow *window.MainWindow
77 78
78 browserWindows map[string]*browserWindow79 browserWindows map[string]*browserWindow
80+ browserWindowOrder []string // registration order of window IDs
79 lastFocusedWindowID string81 lastFocusedWindowID string
80 browserWindowFactory func(context.Context, string) (*browserWindow, error)82 browserWindowFactory func(context.Context, string) (*browserWindow, error)
81- tabCreationWindow *browserWindow
82 dispatchOnMainThread func(func())83 dispatchOnMainThread func(func())
83 84
84 // State85 // State
@@ -109,8 +110,7 @@ type App struct {
109 resizeModeBorderTarget layout.Widget110 resizeModeBorderTarget layout.Widget
110 111
111 // Omnibox configuration (omnibox is created per workspace view)112 // Omnibox configuration (omnibox is created per workspace view)
112- omniboxCfg component.OmniboxConfig113+ omniboxCfg component.OmniboxConfig
113- omniboxNavigateFn func(url string)
114 // Find bar configuration (find bar is created per workspace view)114 // Find bar configuration (find bar is created per workspace view)
115 findBarCfg component.FindBarConfig115 findBarCfg component.FindBarConfig
116 // Floating pane sessions keyed by tab and profile session ID.116 // Floating pane sessions keyed by tab and profile session ID.
@@ -411,6 +411,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow
411 if err != nil {411 if err != nil {
412 return nil, err412 return nil, err
413 }413 }
414+ created.ensureTabs()
414 a.wireBrowserWindowActivationTracking(created)415 a.wireBrowserWindowActivationTracking(created)
415 created.initChrome(ctx, a)416 created.initChrome(ctx, a)
416 return created, nil417 return created, nil
@@ -424,6 +425,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow
424 browserWindow := &browserWindow{425 browserWindow := &browserWindow{
425 id: a.generateWindowID(),426 id: a.generateWindowID(),
426 initialURL: initialURL,427 initialURL: initialURL,
428+ tabs: entity.NewTabList(),
427 mainWindow: mainWindow,429 mainWindow: mainWindow,
428 }430 }
429 a.wireBrowserWindowActivationTracking(browserWindow)431 a.wireBrowserWindowActivationTracking(browserWindow)
@@ -500,56 +502,60 @@ func (a *App) browserWindowForMainWindow(mainWindow *window.MainWindow) *browser
500 }502 }
501 503
502 func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow {504 func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow {
503- if a.windowForTab != nil {505+ if a.windowForTab == nil {
504- if bw := a.windowForTab[tabID]; bw != nil {506+ return nil
505- return bw
506- }
507- }
508- if a.tabCreationWindow != nil {
509- return a.tabCreationWindow
510 }507 }
511- return a.browserWindowForMainWindow(a.mainWindow)508+ return a.windowForTab[tabID]
512 }509 }
513 510
514-func (a *App) browserWindowHasLiveTab(bw *browserWindow, tabID entity.TabID) bool {511+func (a *App) browserWindowForTabTarget(target coordinator.TabTarget) *browserWindow {
515- if a == nil || a.tabs == nil || bw == nil || tabID == "" {512+ if target.Tabs != nil {
516- return false513+ for _, bw := range a.browserWindows {
517- }514+ if bw != nil && bw.tabs == target.Tabs {
518- if a.tabs.Find(tabID) == nil {515+ return bw
519- return false516+ }
517+ }
520 }518 }
521- if a.windowForTab[tabID] != bw {519+ if target.MainWindow != nil {
522- return false520+ return a.browserWindowForMainWindow(target.MainWindow)
523 }521 }
524- return true522+ return nil
525 }523 }
526 524
527 func (a *App) tabCountForBrowserWindow(bw *browserWindow) int {525 func (a *App) tabCountForBrowserWindow(bw *browserWindow) int {
528- if a.tabs == nil || bw == nil {526+ if bw == nil || bw.tabs == nil {
529 return 0527 return 0
530 }528 }
531- count := 0529+ return len(bw.tabs.Tabs)
532- for _, tab := range a.tabs.Tabs {
533- if tab == nil {
534- continue
535- }
536- if owner := a.windowForTab[tab.ID]; owner == bw {
537- count++
538- }
539- }
540- return count
541 }530 }
542 531
543 func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow {532 func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow {
544- if paneID == "" || a.tabs == nil {533+ if paneID == "" {
545 return nil534 return nil
546 }535 }
547- for _, tab := range a.tabs.Tabs {536+ for _, bw := range a.browserWindows {
548- if tab == nil || tab.Workspace == nil {537+ if bw == nil || bw.tabs == nil {
549 continue538 continue
550 }539 }
551- if tab.Workspace.FindPane(paneID) != nil {540+ for _, tab := range bw.tabs.Tabs {
552- return a.browserWindowForTab(tab.ID)541+ if tab == nil || tab.Workspace == nil {
542+ continue
543+ }
544+ if tab.Workspace.FindPane(paneID) != nil {
545+ return bw
546+ }
547+ }
548+ }
549+ return nil
550+}
551+
552+func (a *App) browserWindowForAnyPane(paneID entity.PaneID) *browserWindow {
553+ if bw := a.browserWindowForPane(paneID); bw != nil {
554+ return bw
555+ }
556+ for key, session := range a.floatingSessions {
557+ if session != nil && session.paneID == paneID {
558+ return a.browserWindowForTab(key.tabID)
553 }559 }
554 }560 }
555 return nil561 return nil
@@ -598,6 +604,25 @@ func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity.
598 return a.lastFocusedBrowserWindow()604 return a.lastFocusedBrowserWindow()
599 }605 }
600 606
607+func (a *App) createPopupTab(ctx context.Context, popupInput content.InsertPopupInput) error {
608+ bw := a.browserWindowForAnyPane(popupInput.ParentPaneID)
609+ if bw == nil {
610+ return fmt.Errorf("popup owner window not found for parent pane %q", popupInput.ParentPaneID)
611+ }
612+ tab, err := a.tabCoord.CreateWithPane(
613+ ctx,
614+ a.ensureTabTargetForBrowserWindow(bw),
615+ popupInput.PopupPane,
616+ popupInput.WebView,
617+ popupInput.TargetURI,
618+ )
619+ if err != nil {
620+ return err
621+ }
622+ logging.FromContext(ctx).Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
623+ return nil
624+}
625+
601 func (a *App) handlePaneWindowTitleChanged(paneID entity.PaneID, title string) {626 func (a *App) handlePaneWindowTitleChanged(paneID entity.PaneID, title string) {
602 a.updateWindowTitle(title, a.browserWindowForPane(paneID))627 a.updateWindowTitle(title, a.browserWindowForPane(paneID))
603 }628 }
@@ -609,8 +634,10 @@ func (a *App) handlePaneFullscreenChanged(paneID entity.PaneID, entering bool) {
609 }634 }
610 if entering {635 if entering {
611 bw.mainWindow.TabBar().SetVisible(false)636 bw.mainWindow.TabBar().SetVisible(false)
637+ bw.mainWindow.SetTabBarContentInsetVisible(false)
612 return638 return
613 }639 }
640+ bw.mainWindow.TabBar().SetVisible(true)
614 a.updateBrowserWindowTabBarVisibility(bw)641 a.updateBrowserWindowTabBarVisibility(bw)
615 }642 }
616 643
@@ -619,11 +646,13 @@ func (a *App) updateBrowserWindowTabBarVisibility(bw *browserWindow) {
619 return646 return
620 }647 }
621 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.HideTabBarWhenSingleTab {648 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.HideTabBarWhenSingleTab {
622- bw.mainWindow.TabBar().SetVisible(true)649+ bw.mainWindow.TabBar().SetAutoHidden(false)
650+ bw.mainWindow.SetTabBarContentInsetVisible(true)
623 return651 return
624 }652 }
625 shouldShow := bw.mainWindow.TabBar().Count() > 1653 shouldShow := bw.mainWindow.TabBar().Count() > 1
626- bw.mainWindow.TabBar().SetVisible(shouldShow)654+ bw.mainWindow.TabBar().SetAutoHidden(!shouldShow)
655+ bw.mainWindow.SetTabBarContentInsetVisible(shouldShow)
627 }656 }
628 657
629 func (a *App) activateBrowserWindow(bw *browserWindow) {658 func (a *App) activateBrowserWindow(bw *browserWindow) {
@@ -636,13 +665,30 @@ func (a *App) activateBrowserWindow(bw *browserWindow) {
636 }665 }
637 a.keyboardHandler = bw.keyboardHandler666 a.keyboardHandler = bw.keyboardHandler
638 a.globalShortcutHandler = bw.globalShortcutHandler667 a.globalShortcutHandler = bw.globalShortcutHandler
639- if a.tabCoord != nil && bw.mainWindow != nil {668+ if a.tabCoord != nil {
640- a.tabCoord.SetMainWindow(bw.mainWindow)669+ a.tabCoord.SetCurrentTarget(a.tabTargetForBrowserWindow(bw))
641 }670 }
642- if a.tabs != nil && bw.activeTabID != "" && a.tabs.Find(bw.activeTabID) != nil {671+ // Sync the derived global App.tabs mirror from the active browser window.
643- a.tabs.SetActive(bw.activeTabID)672+ // Some usecase and snapshot/export boundaries still accept a single TabList,
673+ // so the mirror follows per-window active tab state without selecting runtime targets.
674+ activeTab := a.activeTabForBrowserWindow(bw)
675+ activeID := entity.TabID("")
676+ if activeTab != nil {
677+ activeID = activeTab.ID
678+ }
679+ perWinTabs := a.tabListForBrowserWindow(bw)
680+ if a.tabs != nil && activeID != "" {
681+ if a.tabs.Find(activeID) == nil {
682+ if tab := perWinTabs.Find(activeID); tab != nil {
683+ tabSnapshot := *tab
684+ a.tabs.Add(&tabSnapshot)
685+ }
686+ }
687+ if a.tabs.Find(activeID) != nil {
688+ a.tabs.SetActive(activeID)
689+ }
644 }690 }
645- a.updateWindowTitleFromActivePane(bw.activeTabID)691+ a.updateWindowTitleFromActivePane(activeID)
646 }692 }
647 693
648 func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) {694 func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) {
@@ -667,8 +713,9 @@ func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) {
667 }713 }
668 a.ensureWindowForTabMap()714 a.ensureWindowForTabMap()
669 a.windowForTab[tabID] = bw715 a.windowForTab[tabID] = bw
670- if bw.activeTabID == "" {716+ tabs := a.ensureTabListForBrowserWindow(bw)
671- bw.activeTabID = tabID717+ if tabs.ActiveTabID == "" {
718+ tabs.SetActive(tabID)
672 }719 }
673 }720 }
674 721
@@ -711,7 +758,7 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error {
711 }758 }
712 a.registerBrowserWindow(created)759 a.registerBrowserWindow(created)
713 760
714- if a.tabs == nil || a.tabs.Count() == 0 {761+ if len(a.browserWindows) == 1 {
715 a.activateBrowserWindow(created)762 a.activateBrowserWindow(created)
716 return nil763 return nil
717 }764 }
@@ -733,17 +780,13 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error {
733 }780 }
734 781
735 func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, created *browserWindow) error {782 func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, created *browserWindow) error {
736- previousMainWindow := a.mainWindow783+ previousTarget := a.tabCoord.CurrentTarget()
737- if created.mainWindow != nil {784+ a.tabCoord.SetCurrentTarget(a.ensureTabTargetForBrowserWindow(created))
738- a.tabCoord.SetMainWindow(created.mainWindow)
739- }
740- a.tabCreationWindow = created
741 defer func() {785 defer func() {
742- a.tabCreationWindow = nil786+ a.tabCoord.SetCurrentTarget(previousTarget)
743- a.tabCoord.SetMainWindow(previousMainWindow)
744 }()787 }()
745 788
746- createdTab, createErr := a.tabCoord.Create(ctx, url)789+ createdTab, createErr := a.tabCoord.Create(ctx, a.ensureTabTargetForBrowserWindow(created), url)
747 if createErr != nil {790 if createErr != nil {
748 a.removeBrowserWindow(created.id)791 a.removeBrowserWindow(created.id)
749 if created.mainWindow != nil {792 if created.mainWindow != nil {
@@ -752,19 +795,17 @@ func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, creat
752 return createErr795 return createErr
753 }796 }
754 if a.workspaceViews[createdTab.ID] == nil {797 if a.workspaceViews[createdTab.ID] == nil {
755- if a.tabs != nil {798+ if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil {
756- a.tabs.Remove(createdTab.ID)799+ createdTabs.Remove(createdTab.ID)
757 }800 }
758- tabBar := a.mainWindow.TabBar()801+ if created.mainWindow != nil {
759- if created.mainWindow != nil && a.tabCoord != nil {802+ if tabBar := created.mainWindow.TabBar(); tabBar != nil {
760- if targetTabBar := a.tabCoord.GetTabBar(); targetTabBar != nil {803+ tabBar.RemoveTab(createdTab.ID)
761- tabBar = targetTabBar804+ if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil {
805+ tabBar.SetActive(activeTab.ID)
806+ }
762 }807 }
763 }808 }
764- if tabBar != nil {
765- tabBar.RemoveTab(createdTab.ID)
766- tabBar.SetActive(a.tabs.ActiveTabID)
767- }
768 a.removeBrowserWindow(created.id)809 a.removeBrowserWindow(created.id)
769 if created.mainWindow != nil {810 if created.mainWindow != nil {
770 created.mainWindow.Destroy()811 created.mainWindow.Destroy()
@@ -772,7 +813,8 @@ func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, creat
772 return fmt.Errorf("workspace view not created for tab %s", createdTab.ID)813 return fmt.Errorf("workspace view not created for tab %s", createdTab.ID)
773 }814 }
774 a.setBrowserWindowForTab(createdTab.ID, created)815 a.setBrowserWindowForTab(createdTab.ID, created)
775- created.activeTabID = createdTab.ID816+ // Tab was already created in the per-window TabList via tabCoord.Create;
817+ // active state is managed by TabList.SetActive.
776 if created.mainWindow != nil {818 if created.mainWindow != nil {
777 created.mainWindow.Show()819 created.mainWindow.Show()
778 }820 }
@@ -792,30 +834,27 @@ func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created
792 return err834 return err
793 }835 }
794 a.setBrowserWindowForTab(output.Tab.ID, created)836 a.setBrowserWindowForTab(output.Tab.ID, created)
795- created.activeTabID = output.Tab.ID837+ // Mirror the tabsUC-created tab into the new browser window's tab list.
838+ // This path uses the tabs usecase directly instead of tabCoord.Create,
839+ // so the per-window TabList must be populated explicitly.
840+ a.ensureTabListForBrowserWindow(created).Add(output.Tab)
796 if a.widgetFactory != nil {841 if a.widgetFactory != nil {
797- a.tabCreationWindow = created
798- defer func() {
799- a.tabCreationWindow = nil
800- }()
801 a.createWorkspaceView(ctx, output.Tab)842 a.createWorkspaceView(ctx, output.Tab)
802 a.switchWorkspaceView(ctx, output.Tab.ID)843 a.switchWorkspaceView(ctx, output.Tab.ID)
803 }844 }
804 if a.workspaceViews[output.Tab.ID] == nil {845 if a.workspaceViews[output.Tab.ID] == nil {
846+ if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil {
847+ createdTabs.Remove(output.Tab.ID)
848+ }
805 if a.tabs != nil {849 if a.tabs != nil {
806 a.tabs.Remove(output.Tab.ID)850 a.tabs.Remove(output.Tab.ID)
807 }851 }
808- var tabBar *component.TabBar852+ if created.mainWindow != nil {
809- if a.mainWindow != nil {853+ if tabBar := created.mainWindow.TabBar(); tabBar != nil {
810- tabBar = a.mainWindow.TabBar()854+ tabBar.RemoveTab(output.Tab.ID)
811- }855+ if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil {
812- if created.mainWindow != nil && created.mainWindow.TabBar() != nil {856+ tabBar.SetActive(activeTab.ID)
813- tabBar = created.mainWindow.TabBar()857+ }
814- }
815- if tabBar != nil {
816- tabBar.RemoveTab(output.Tab.ID)
817- if a.tabs != nil {
818- tabBar.SetActive(a.tabs.ActiveTabID)
819 }858 }
820 }859 }
821 a.removeBrowserWindow(created.id)860 a.removeBrowserWindow(created.id)
@@ -1118,7 +1157,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1118 return nil1157 return nil
1119 }1158 }
1120 }1159 }
1121- return a.kbDispatcher.Dispatch(actionCtx, action)1160+ return a.dispatchBrowserWindowAction(actionCtx, bw, action)
1122 })1161 })
1123 bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {1162 bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {
1124 a.activateBrowserWindow(bw)1163 a.activateBrowserWindow(bw)
@@ -1165,7 +1204,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1165 bw.keyboardHandler,1204 bw.keyboardHandler,
1166 func(actionCtx context.Context, action input.Action) error {1205 func(actionCtx context.Context, action input.Action) error {
1167 a.activateBrowserWindow(bw)1206 a.activateBrowserWindow(bw)
1168- return a.kbDispatcher.Dispatch(actionCtx, action)1207+ return a.dispatchBrowserWindowAction(actionCtx, bw, action)
1169 },1208 },
1170 )1209 )
1171 if bw.globalShortcutHandler == nil {1210 if bw.globalShortcutHandler == nil {
@@ -1181,11 +1220,11 @@ func (a *App) wireBrowserWindowTabBar(ctx context.Context, bw *browserWindow) {
1181 }1220 }
1182 bw.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {1221 bw.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
1183 a.activateBrowserWindow(bw)1222 a.activateBrowserWindow(bw)
1184- if err := a.tabCoord.Switch(ctx, tabID); err != nil {1223+ if err := a.tabCoord.Switch(ctx, a.ensureTabTargetForBrowserWindow(bw), tabID); err != nil {
1185 logging.FromContext(ctx).Error().Err(err).Str("tab_id", string(tabID)).Str("window_id", bw.id).Msg("tab switch failed")1224 logging.FromContext(ctx).Error().Err(err).Str("tab_id", string(tabID)).Str("window_id", bw.id).Msg("tab switch failed")
1186 return1225 return
1187 }1226 }
1188- bw.activeTabID = tabID1227+ // Active tab state is managed by TabCoordinator.Switch → TabList.SetActive.
1189 })1228 })
1190 }1229 }
1191 1230
@@ -1432,7 +1471,7 @@ func (a *App) attachTabPickerToActivePane() {
1432 return1471 return
1433 }1472 }
1434 1473
1435- activeTab := a.tabs.ActiveTab()1474+ activeTab := a.activeTabForBrowserWindow(bw)
1436 if activeTab == nil || activeTab.Workspace == nil {1475 if activeTab == nil || activeTab.Workspace == nil {
1437 return1476 return
1438 }1477 }
@@ -1488,18 +1527,19 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error {
1488 return nil1527 return nil
1489 }1528 }
1490 1529
1491- sourceTab := a.tabs.ActiveTab()1530+ sourceTabs := a.tabListForBrowserWindow(bw)
1492- if sourceTab == nil || sourceTab.Workspace == nil {1531+ sourceTab := a.activeTabForBrowserWindow(bw)
1532+ if sourceTabs == nil || sourceTab == nil || sourceTab.Workspace == nil {
1493 return nil1533 return nil
1494 }1534 }
1495 1535
1496 // If there is only 1 tab, auto-create a new tab and move there.1536 // If there is only 1 tab, auto-create a new tab and move there.
1497- if a.tabs.Count() <= 1 {1537+ if sourceTabs.Count() <= 1 {
1498 return a.MoveActivePaneToTab(ctx, "")1538 return a.MoveActivePaneToTab(ctx, "")
1499 }1539 }
1500 1540
1501- items := make([]component.TabPickerItem, 0, a.tabs.Count())1541+ items := make([]component.TabPickerItem, 0, sourceTabs.Count())
1502- for _, tab := range a.tabs.Tabs {1542+ for _, tab := range sourceTabs.Tabs {
1503 if tab == nil {1543 if tab == nil {
1504 continue1544 continue
1505 }1545 }
@@ -1524,17 +1564,19 @@ func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
1524 if a.movePaneToTabUC == nil {1564 if a.movePaneToTabUC == nil {
1525 return nil1565 return nil
1526 }1566 }
1527- active := a.tabs.ActiveTab()1567+ bw := a.lastFocusedBrowserWindow()
1528- if active == nil {1568+ sourceTabs := a.tabListForBrowserWindow(bw)
1569+ active := a.activeTabForBrowserWindow(bw)
1570+ if sourceTabs == nil || active == nil {
1529 return nil1571 return nil
1530 }1572 }
1531 1573
1532 nextPos := active.Position + 11574 nextPos := active.Position + 1
1533- if nextPos >= a.tabs.Count() {1575+ if nextPos >= sourceTabs.Count() {
1534 // Create a new tab on the right.1576 // Create a new tab on the right.
1535 return a.MoveActivePaneToTab(ctx, "")1577 return a.MoveActivePaneToTab(ctx, "")
1536 }1578 }
1537- next := a.tabs.TabAt(nextPos)1579+ next := sourceTabs.TabAt(nextPos)
1538 if next == nil {1580 if next == nil {
1539 return nil1581 return nil
1540 }1582 }
@@ -1568,10 +1610,23 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID)
1568 if out.NewTabCreated && targetWindow != nil {1610 if out.NewTabCreated && targetWindow != nil {
1569 a.setBrowserWindowForTab(out.TargetTab.ID, targetWindow)1611 a.setBrowserWindowForTab(out.TargetTab.ID, targetWindow)
1570 }1612 }
1613+ a.syncCrossWindowMovePaneTabLists(out, sourceTab, sourceWindow, targetWindow)
1571 1614
1572 a.applyMovePaneToTabUI(ctx, out, sourceTab, sourceWindow, targetWindow)1615 a.applyMovePaneToTabUI(ctx, out, sourceTab, sourceWindow, targetWindow)
1573 a.switchToTargetTabIfConfigured(ctx, out, targetWindow)1616 a.switchToTargetTabIfConfigured(ctx, out, targetWindow)
1574- a.updateTabBarVisibility(ctx)1617+ if a.tabCoord != nil {
1618+ seen := map[*browserWindow]struct{}{}
1619+ for _, bw := range []*browserWindow{sourceWindow, targetWindow} {
1620+ if bw == nil {
1621+ continue
1622+ }
1623+ if _, ok := seen[bw]; ok {
1624+ continue
1625+ }
1626+ seen[bw] = struct{}{}
1627+ a.tabCoord.UpdateBarVisibility(ctx, a.tabTargetForBrowserWindow(bw))
1628+ }
1629+ }
1575 a.MarkDirty()1630 a.MarkDirty()
1576 return nil1631 return nil
1577 }1632 }
@@ -1587,17 +1642,36 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa
1587 if a.movePaneToTabUC == nil {1642 if a.movePaneToTabUC == nil {
1588 return nil, nil1643 return nil, nil
1589 }1644 }
1590- activeTab := a.tabs.ActiveTab()1645+ sourceWindow := a.lastFocusedBrowserWindow()
1591- if activeTab == nil || activeTab.Workspace == nil {1646+ tabList := a.tabListForBrowserWindow(sourceWindow)
1647+ activeTab := a.activeTabForBrowserWindow(sourceWindow)
1648+ if tabList == nil || activeTab == nil || activeTab.Workspace == nil {
1592 return nil, nil1649 return nil, nil
1593 }1650 }
1651+ if targetTabID != "" {
1652+ if targetWindow := a.browserWindowForTab(targetTabID); targetWindow != nil && targetWindow != sourceWindow {
1653+ // Cross-window pane moves still pass a single derived tab list because
1654+ // MovePaneToTabInput accepts one TabList. Ensure the derived global
1655+ // mirror contains source and target tabs before passing a.tabs.
1656+ a.syncDerivedGlobalTabMirror(activeTab)
1657+ if targetWindowTabs := a.tabListForBrowserWindow(targetWindow); targetWindowTabs != nil {
1658+ if targetTab := targetWindowTabs.Find(targetTabID); targetTab != nil {
1659+ a.syncDerivedGlobalTabMirror(targetTab)
1660+ }
1661+ }
1662+ if a.tabs == nil {
1663+ return nil, nil
1664+ }
1665+ tabList = a.tabs
1666+ }
1667+ }
1594 sourcePaneID := activeTab.Workspace.ActivePaneID1668 sourcePaneID := activeTab.Workspace.ActivePaneID
1595 if sourcePaneID == "" {1669 if sourcePaneID == "" {
1596 return nil, nil1670 return nil, nil
1597 }1671 }
1598 1672
1599 in := &usecase.MovePaneToTabInput{1673 in := &usecase.MovePaneToTabInput{
1600- TabList: a.tabs,1674+ TabList: tabList,
1601 SourceTabID: activeTab.ID,1675 SourceTabID: activeTab.ID,
1602 SourcePaneID: sourcePaneID,1676 SourcePaneID: sourcePaneID,
1603 TargetTabID: targetTabID,1677 TargetTabID: targetTabID,
@@ -1605,6 +1679,41 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa
1605 return in, activeTab1679 return in, activeTab
1606 }1680 }
1607 1681
1682+func (a *App) syncCrossWindowMovePaneTabLists(
1683+ out *usecase.MovePaneToTabOutput,
1684+ sourceTab *entity.Tab,
1685+ sourceWindow *browserWindow,
1686+ targetWindow *browserWindow,
1687+) {
1688+ if out == nil || out.TargetTab == nil || sourceWindow == nil || targetWindow == nil {
1689+ return
1690+ }
1691+
1692+ if out.NewTabCreated {
1693+ targetTabs := a.ensureTabListForBrowserWindow(targetWindow)
1694+ if targetTabs != nil && targetTabs.Find(out.TargetTab.ID) == nil {
1695+ targetTabs.Add(out.TargetTab)
1696+ }
1697+ }
1698+
1699+ if sourceWindow == targetWindow {
1700+ return
1701+ }
1702+
1703+ if out.SourceTabClosed && sourceTab != nil {
1704+ if sourceTabs := a.tabListForBrowserWindow(sourceWindow); sourceTabs != nil {
1705+ sourceTabs.Remove(sourceTab.ID)
1706+ }
1707+ }
1708+
1709+ if owner := a.browserWindowForTab(out.TargetTab.ID); owner == targetWindow {
1710+ targetTabs := a.ensureTabListForBrowserWindow(targetWindow)
1711+ if targetTabs != nil && targetTabs.Find(out.TargetTab.ID) == nil {
1712+ targetTabs.Add(out.TargetTab)
1713+ }
1714+ }
1715+}
1716+
1608 func (a *App) applyMovePaneToTabUI(1717 func (a *App) applyMovePaneToTabUI(
1609 ctx context.Context,1718 ctx context.Context,
1610 out *usecase.MovePaneToTabOutput,1719 out *usecase.MovePaneToTabOutput,
@@ -1639,6 +1748,11 @@ func (a *App) removeSourceTabUI(sourceTabID entity.TabID, sourceWindow *browserW
1639 if tabBar := a.tabBarForBrowserWindow(sourceWindow); tabBar != nil {1748 if tabBar := a.tabBarForBrowserWindow(sourceWindow); tabBar != nil {
1640 tabBar.RemoveTab(sourceTabID)1749 tabBar.RemoveTab(sourceTabID)
1641 }1750 }
1751+ // Remove the tab from the derived global tab mirror after the per-window
1752+ // TabList is modified.
1753+ if a.tabs != nil {
1754+ a.tabs.Remove(sourceTabID)
1755+ }
1642 }1756 }
1643 1757
1644 func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWindow *browserWindow) {1758 func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWindow *browserWindow) {
@@ -1714,9 +1828,16 @@ func (a *App) switchToTargetTabIfConfigured(
1714 return1828 return
1715 }1829 }
1716 1830
1717- a.tabs.SetActive(out.TargetTab.ID)1831+ if targetWindow == nil {
1832+ targetWindow = a.lastFocusedBrowserWindow()
1833+ }
1718 if targetWindow != nil {1834 if targetWindow != nil {
1719- targetWindow.activeTabID = out.TargetTab.ID1835+ a.ensureTabListForBrowserWindow(targetWindow).SetActive(out.TargetTab.ID)
1836+ }
1837+ // Sync derived global active state after the target window is resolved
1838+ // and the per-window TabList has been updated.
1839+ if a.tabs != nil && a.tabs.Find(out.TargetTab.ID) != nil {
1840+ a.tabs.SetActive(out.TargetTab.ID)
1720 }1841 }
1721 if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {1842 if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {
1722 tabBar.SetActive(out.TargetTab.ID)1843 tabBar.SetActive(out.TargetTab.ID)
@@ -1727,12 +1848,6 @@ func (a *App) switchToTargetTabIfConfigured(
1727 a.switchWorkspaceView(ctx, out.TargetTab.ID)1848 a.switchWorkspaceView(ctx, out.TargetTab.ID)
1728 }1849 }
1729 1850
1730-func (a *App) updateTabBarVisibility(ctx context.Context) {
1731- if a.tabCoord != nil {
1732- a.tabCoord.UpdateBarVisibility(ctx)
1733- }
1734-}
1735-
1736 func (a *App) initSnapshotService(ctx context.Context) {1851 func (a *App) initSnapshotService(ctx context.Context) {
1737 log := logging.FromContext(ctx)1852 log := logging.FromContext(ctx)
1738 1853
@@ -1790,7 +1905,7 @@ func (a *App) GetTabList() *entity.TabList {
1790 return a.tabs1905 return a.tabs
1791 }1906 }
1792 1907
1793-// GetSessionID implements port.TabListProvider.1908+// GetSessionID implements port.TabListProvider and port.WindowStateProvider.
1794 func (a *App) GetSessionID() entity.SessionID {1909 func (a *App) GetSessionID() entity.SessionID {
1795 if a.deps == nil {1910 if a.deps == nil {
1796 return ""1911 return ""
@@ -1798,6 +1913,532 @@ func (a *App) GetSessionID() entity.SessionID {
1798 return a.deps.CurrentSessionID1913 return a.deps.CurrentSessionID
1799 }1914 }
1800 1915
1916+// GetWindowSnapshotState implements port.WindowStateProvider.
1917+// Returns per-window tab lists and active window index from one main-thread capture.
1918+func (a *App) GetWindowSnapshotState() ([]entity.WindowTabListState, int) {
1919+ var (
1920+ browserTabs map[string]*entity.TabList
1921+ windowOrder []string
1922+ globalTabs *entity.TabList
1923+ tabOwners map[entity.TabID]string
1924+ lastFocusedWindowID string
1925+ )
1926+
1927+ capture := func() {
1928+ globalTabs = entity.NewTabList()
1929+ if a.tabs != nil {
1930+ globalTabs = a.tabs.Snapshot()
1931+ }
1932+
1933+ browserTabs = make(map[string]*entity.TabList, len(a.browserWindows))
1934+ for id, bw := range a.browserWindows {
1935+ if bw == nil {
1936+ continue
1937+ }
1938+ if bw.tabs != nil {
1939+ browserTabs[id] = bw.tabs.Snapshot()
1940+ } else {
1941+ browserTabs[id] = nil
1942+ }
1943+ }
1944+
1945+ windowOrder = append([]string(nil), a.browserWindowOrder...)
1946+ tabOwners = make(map[entity.TabID]string, len(a.windowForTab))
1947+ for tabID, bw := range a.windowForTab {
1948+ if bw != nil {
1949+ tabOwners[tabID] = bw.id
1950+ }
1951+ }
1952+ lastFocusedWindowID = a.lastFocusedWindowID
1953+ }
1954+ if a.dispatchOnMainThread != nil {
1955+ a.dispatchOnMainThread(capture)
1956+ } else {
1957+ // nil dispatchOnMainThread is for single-threaded tests only;
1958+ // production should marshal capture to GTK main thread.
1959+ capture()
1960+ }
1961+
1962+ if len(browserTabs) == 0 {
1963+ return []entity.WindowTabListState{
1964+ {WindowID: "", Tabs: globalTabs},
1965+ }, 0
1966+ }
1967+
1968+ windowIDs := windowOrderFrom(browserTabs, windowOrder)
1969+ result := make([]entity.WindowTabListState, 0, len(windowIDs))
1970+ activeWindowIndex := 0
1971+ anyOwnedWindow := false
1972+ for i, wid := range windowIDs {
1973+ if wid == lastFocusedWindowID {
1974+ activeWindowIndex = i
1975+ }
1976+
1977+ tabs := browserTabs[wid]
1978+ if tabs == nil {
1979+ tabs = entity.NewTabList()
1980+ for _, tab := range globalTabs.Tabs {
1981+ if tabOwners[tab.ID] == wid {
1982+ tabs.Add(tab)
1983+ }
1984+ }
1985+
1986+ // Derive active tab from the global list when no per-window TabList
1987+ // exists only for snapshot/export compatibility with older flat snapshot state.
1988+ globalActive := globalTabs.ActiveTabID
1989+ if globalActive != "" && tabs.Find(globalActive) != nil {
1990+ tabs.SetActive(globalActive)
1991+ }
1992+ }
1993+ if tabs.Count() > 0 {
1994+ anyOwnedWindow = true
1995+ }
1996+
1997+ result = append(result, entity.WindowTabListState{
1998+ WindowID: entity.WindowID(wid),
1999+ Tabs: tabs,
2000+ })
2001+ }
2002+ if !anyOwnedWindow && globalTabs.Count() > 0 {
2003+ // No window ownership populated; return the flat global list for
2004+ // snapshot/export compatibility rather than snapshotting empty windows.
2005+ return []entity.WindowTabListState{{WindowID: "", Tabs: globalTabs}}, 0
2006+ }
2007+
2008+ return result, activeWindowIndex
2009+}
2010+
2011+// GetWindowTabLists implements port.WindowStateProvider.
2012+// Returns per-window tab lists in browser window registration order.
2013+// Falls back to a single window with the shared tab list when no browser windows exist.
2014+func (a *App) GetWindowTabLists() []entity.WindowTabListState {
2015+ windows, _ := a.GetWindowSnapshotState()
2016+ return windows
2017+}
2018+
2019+// GetActiveWindowIndex implements port.WindowStateProvider.
2020+// Returns the index of the last focused window in registration order.
2021+func (a *App) GetActiveWindowIndex() int {
2022+ _, activeWindowIndex := a.GetWindowSnapshotState()
2023+ return activeWindowIndex
2024+}
2025+
2026+// tabListForBrowserWindow returns the per-window TabList for a browser window.
2027+// Returns nil when bw is nil or bw.tabs has not been initialized.
2028+func (a *App) tabListForBrowserWindow(bw *browserWindow) *entity.TabList {
2029+ if bw == nil {
2030+ return nil
2031+ }
2032+ return bw.tabs
2033+}
2034+
2035+// ensureTabListForBrowserWindow returns the per-window TabList for a browser window,
2036+// lazy-initializing bw.tabs when nil. Returns nil when bw is nil.
2037+func (a *App) ensureTabListForBrowserWindow(bw *browserWindow) *entity.TabList {
2038+ if bw == nil {
2039+ return nil
2040+ }
2041+ if bw.tabs == nil {
2042+ bw.tabs = entity.NewTabList()
2043+ }
2044+ return bw.tabs
2045+}
2046+
2047+// activeTabForBrowserWindow returns the active tab for a browser window.
2048+// Returns nil when bw is nil or the window has no tabs.
2049+func (a *App) activeTabForBrowserWindow(bw *browserWindow) *entity.Tab {
2050+ tabs := a.tabListForBrowserWindow(bw)
2051+ if tabs == nil {
2052+ return nil
2053+ }
2054+ return tabs.ActiveTab()
2055+}
2056+
2057+// activeWorkspaceForBrowserWindow returns the active workspace for a browser window.
2058+// Returns nil when bw is nil or the active tab has no workspace.
2059+func (a *App) activeWorkspaceForBrowserWindow(bw *browserWindow) *entity.Workspace {
2060+ tab := a.activeTabForBrowserWindow(bw)
2061+ if tab == nil {
2062+ return nil
2063+ }
2064+ return tab.Workspace
2065+}
2066+
2067+func (a *App) activeWorkspaceViewForBrowserWindow(bw *browserWindow) *component.WorkspaceView {
2068+ tab := a.activeTabForBrowserWindow(bw)
2069+ if tab == nil {
2070+ return nil
2071+ }
2072+ return a.workspaceViews[tab.ID]
2073+}
2074+
2075+// tabTargetForBrowserWindow returns a coordinator.TabTarget scoped to the given browser window.
2076+// It does not allocate a TabList; callers that mutate tabs should use
2077+// ensureTabTargetForBrowserWindow instead.
2078+func (a *App) tabTargetForBrowserWindow(bw *browserWindow) coordinator.TabTarget {
2079+ if bw == nil {
2080+ return coordinator.TabTarget{}
2081+ }
2082+ return coordinator.TabTarget{
2083+ Tabs: a.tabListForBrowserWindow(bw),
2084+ MainWindow: bw.mainWindow,
2085+ }
2086+}
2087+
2088+func (a *App) ensureTabTargetForBrowserWindow(bw *browserWindow) coordinator.TabTarget {
2089+ if bw == nil {
2090+ return coordinator.TabTarget{}
2091+ }
2092+ return coordinator.TabTarget{
2093+ Tabs: a.ensureTabListForBrowserWindow(bw),
2094+ MainWindow: bw.mainWindow,
2095+ }
2096+}
2097+
2098+// activePaneIDForBrowserWindow resolves the active pane ID for a browser window.
2099+// A content-coordinator override wins so floating panes can receive navigation.
2100+// Returns the empty string when bw is nil or the active tab has no workspace.
2101+func (a *App) activePaneIDForBrowserWindow(bw *browserWindow) entity.PaneID {
2102+ if a.contentCoord != nil && bw != nil {
2103+ if paneID, ok := a.contentCoord.ActivePaneOverrideID(); ok && a.browserWindowForAnyPane(paneID) == bw {
2104+ return paneID
2105+ }
2106+ }
2107+ ws := a.activeWorkspaceForBrowserWindow(bw)
2108+ if ws == nil {
2109+ return ""
2110+ }
2111+ return ws.ActivePaneID
2112+}
2113+
2114+// activeWebViewForBrowserWindow resolves the active WebView for a browser window.
2115+// Uses the active pane ID and contentCoord.GetWebView (public getter) — never
2116+// contentCoord.ActiveWebView — so that stale global focus cannot be picked.
2117+func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, port.WebView) {
2118+ paneID := a.activePaneIDForBrowserWindow(bw)
2119+ if paneID == "" {
2120+ return "", nil
2121+ }
2122+ if a.contentCoord == nil {
2123+ return paneID, nil
2124+ }
2125+ return paneID, a.contentCoord.GetWebView(paneID)
2126+}
2127+
2128+// omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes
2129+// navigation through navigate for the supplied browser window. The closure captures
2130+// bw at creation time, so navigation targets the owning window even when another
2131+// window is globally focused.
2132+func omniboxNavigateForBrowserWindow(
2133+ ctx context.Context,
2134+ bw *browserWindow,
2135+ navigate func(context.Context, *browserWindow, string) error,
2136+) func(string) {
2137+ return func(url string) {
2138+ if err := navigate(ctx, bw, url); err != nil {
2139+ logging.FromContext(ctx).Error().
2140+ Err(err).
2141+ Str("url", url).
2142+ Str("window_id", bw.id).
2143+ Msg("omnibox navigation failed")
2144+ }
2145+ }
2146+}
2147+
2148+// navigateFromBrowserWindow loads a URL in the active pane of the given browser window.
2149+// Uses NavigationCoordinator.NavigateWebView for explicit target resolution.
2150+func (a *App) navigateFromBrowserWindow(ctx context.Context, bw *browserWindow, rawURL string) error {
2151+ if a.navCoord == nil {
2152+ return fmt.Errorf("navigation coordinator not initialized")
2153+ }
2154+ paneID, wv := a.activeWebViewForBrowserWindow(bw)
2155+ return a.navCoord.NavigateWebView(ctx, rawURL, paneID, wv)
2156+}
2157+
2158+// withBrowserWindowWebView resolves the active WebView for bw, checks that navCoord
2159+// is initialized and wv is not nil, then runs fn with the WebView. Avoids nil bw
2160+// panic in error formatting by falling back to empty window id.
2161+func (a *App) withBrowserWindowWebView(_ context.Context, bw *browserWindow, fn func(wv port.WebView) error) error {
2162+ if a.navCoord == nil {
2163+ return fmt.Errorf("navigation coordinator not initialized")
2164+ }
2165+ _, wv := a.activeWebViewForBrowserWindow(bw)
2166+ if wv == nil {
2167+ windowID := ""
2168+ if bw != nil {
2169+ windowID = bw.id
2170+ }
2171+ return fmt.Errorf("no active webview for browser window %s", windowID)
2172+ }
2173+ return fn(wv)
2174+}
2175+
2176+// reloadBrowserWindow reloads the active page in the given browser window.
2177+// When bypassCache is true, bypasses the cache (hard reload).
2178+func (a *App) reloadBrowserWindow(ctx context.Context, bw *browserWindow, bypassCache bool) error {
2179+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2180+ return a.navCoord.ReloadWebView(ctx, wv, bypassCache)
2181+ })
2182+}
2183+
2184+// stopBrowserWindow stops loading in the active pane of the given browser window.
2185+func (a *App) stopBrowserWindow(ctx context.Context, bw *browserWindow) error {
2186+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2187+ return a.navCoord.StopWebView(ctx, wv)
2188+ })
2189+}
2190+
2191+// goBackBrowserWindow navigates back in history for the active pane of the given browser window.
2192+func (a *App) goBackBrowserWindow(ctx context.Context, bw *browserWindow) error {
2193+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2194+ return a.navCoord.GoBackWebView(ctx, wv)
2195+ })
2196+}
2197+
2198+// goForwardBrowserWindow navigates forward in history for the active pane of the given browser window.
2199+func (a *App) goForwardBrowserWindow(ctx context.Context, bw *browserWindow) error {
2200+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2201+ return a.navCoord.GoForwardWebView(ctx, wv)
2202+ })
2203+}
2204+
2205+func (a *App) printBrowserWindow(ctx context.Context, bw *browserWindow) error {
2206+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2207+ return a.navCoord.PrintWebView(ctx, wv)
2208+ })
2209+}
2210+
2211+func (a *App) openDevToolsBrowserWindow(ctx context.Context, bw *browserWindow) error {
2212+ return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error {
2213+ return a.navCoord.OpenDevToolsWebView(ctx, wv)
2214+ })
2215+}
2216+
2217+func (a *App) zoomBrowserWindow(ctx context.Context, bw *browserWindow, action string) error {
2218+ if a.deps == nil || a.deps.ZoomUC == nil {
2219+ logging.FromContext(ctx).Warn().Msg("zoom use case not available")
2220+ return nil
2221+ }
2222+
2223+ paneID, wv := a.activeWebViewForBrowserWindow(bw)
2224+ if wv == nil {
2225+ logging.FromContext(ctx).Debug().Str("action", action).Msg("no active webview for zoom")
2226+ return nil
2227+ }
2228+
2229+ domain, err := usecase.ExtractDomain(wv.URI())
2230+ if err != nil {
2231+ logging.FromContext(ctx).Debug().Str("uri", wv.URI()).Msg("cannot extract domain for zoom")
2232+ return nil
2233+ }
2234+
2235+ current := wv.GetZoomLevel()
2236+ var newZoom *entity.ZoomLevel
2237+ switch action {
2238+ case "in":
2239+ newZoom, err = a.deps.ZoomUC.ZoomIn(ctx, domain, current)
2240+ case "out":
2241+ newZoom, err = a.deps.ZoomUC.ZoomOut(ctx, domain, current)
2242+ case "reset":
2243+ err = a.deps.ZoomUC.ResetZoom(ctx, domain)
2244+ if err == nil {
2245+ newZoom = entity.NewZoomLevel(domain, a.deps.ZoomUC.DefaultZoom())
2246+ }
2247+ default:
2248+ return fmt.Errorf("unknown zoom action %q", action)
2249+ }
2250+ if err != nil {
2251+ return err
2252+ }
2253+ if newZoom == nil {
2254+ return nil
2255+ }
2256+ if err := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); err != nil {
2257+ return err
2258+ }
2259+ if a.navCoord != nil {
2260+ a.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor)
2261+ }
2262+ if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil {
2263+ if paneView := wsView.GetPaneView(paneID); paneView != nil {
2264+ paneView.ShowZoomToast(ctx, int(newZoom.ZoomFactor*100))
2265+ }
2266+ }
2267+ return nil
2268+}
2269+
2270+const (
2271+ tabSwitchIndex0 = iota // 0
2272+ tabSwitchIndex1 // 1
2273+ tabSwitchIndex2 // 2
2274+ tabSwitchIndex3 // 3
2275+ tabSwitchIndex4 // 4
2276+ tabSwitchIndex5 // 5
2277+ tabSwitchIndex6 // 6
2278+ tabSwitchIndex7 // 7
2279+ tabSwitchIndex8 // 8
2280+ tabSwitchIndex9 // 9
2281+)
2282+
2283+// switchTabIndexActionIndex maps ActionSwitchTabIndex1..10 to 0..9.
2284+func switchTabIndexActionIndex(action input.Action) (int, bool) {
2285+ switch action {
2286+ case input.ActionSwitchTabIndex1:
2287+ return tabSwitchIndex0, true
2288+ case input.ActionSwitchTabIndex2:
2289+ return tabSwitchIndex1, true
2290+ case input.ActionSwitchTabIndex3:
2291+ return tabSwitchIndex2, true
2292+ case input.ActionSwitchTabIndex4:
2293+ return tabSwitchIndex3, true
2294+ case input.ActionSwitchTabIndex5:
2295+ return tabSwitchIndex4, true
2296+ case input.ActionSwitchTabIndex6:
2297+ return tabSwitchIndex5, true
2298+ case input.ActionSwitchTabIndex7:
2299+ return tabSwitchIndex6, true
2300+ case input.ActionSwitchTabIndex8:
2301+ return tabSwitchIndex7, true
2302+ case input.ActionSwitchTabIndex9:
2303+ return tabSwitchIndex8, true
2304+ case input.ActionSwitchTabIndex10:
2305+ return tabSwitchIndex9, true
2306+ default:
2307+ return 0, false
2308+ }
2309+}
2310+
2311+// dispatchBrowserWindowAction routes keyboard/global shortcut actions through the
2312+// source browser window helpers instead of falling back to the global active
2313+// webview/tab. Browser-window-owning callbacks (keyboardHandler, globalShortcutHandler)
2314+// must call this instead of activating the window then dispatching globally.
2315+func (a *App) dispatchBrowserWindowAction(ctx context.Context, bw *browserWindow, action input.Action) error {
2316+ switch action {
2317+ case input.ActionReload:
2318+ return a.reloadBrowserWindow(ctx, bw, false)
2319+ case input.ActionHardReload:
2320+ return a.reloadBrowserWindow(ctx, bw, true)
2321+ case input.ActionStop:
2322+ return a.stopBrowserWindow(ctx, bw)
2323+ case input.ActionGoBack:
2324+ return a.goBackBrowserWindow(ctx, bw)
2325+ case input.ActionGoForward:
2326+ return a.goForwardBrowserWindow(ctx, bw)
2327+ case input.ActionPrintPage:
2328+ return a.printBrowserWindow(ctx, bw)
2329+ case input.ActionOpenDevTools:
2330+ return a.openDevToolsBrowserWindow(ctx, bw)
2331+ case input.ActionZoomIn:
2332+ return a.zoomBrowserWindow(ctx, bw, "in")
2333+ case input.ActionZoomOut:
2334+ return a.zoomBrowserWindow(ctx, bw, "out")
2335+ case input.ActionZoomReset:
2336+ return a.zoomBrowserWindow(ctx, bw, "reset")
2337+ case input.ActionSwitchTabIndex1, input.ActionSwitchTabIndex2, input.ActionSwitchTabIndex3,
2338+ input.ActionSwitchTabIndex4, input.ActionSwitchTabIndex5, input.ActionSwitchTabIndex6,
2339+ input.ActionSwitchTabIndex7, input.ActionSwitchTabIndex8, input.ActionSwitchTabIndex9,
2340+ input.ActionSwitchTabIndex10:
2341+ index, ok := switchTabIndexActionIndex(action)
2342+ if !ok {
2343+ return nil
2344+ }
2345+ return a.switchBrowserWindowTabIndex(ctx, bw, index)
2346+ default:
2347+ if a.kbDispatcher != nil {
2348+ return a.kbDispatcher.Dispatch(ctx, action)
2349+ }
2350+ return nil
2351+ }
2352+}
2353+
2354+// switchBrowserWindowTabIndex activates the given browser window and switches to
2355+// the tab at the given 0-based index in that window, creating tabs as needed.
2356+func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow, index int) error {
2357+ if a.tabCoord == nil {
2358+ logging.FromContext(ctx).Warn().Msg("switch tab index ignored: tab coordinator is nil")
2359+ return nil
2360+ }
2361+ if bw == nil {
2362+ logging.FromContext(ctx).Warn().Msg("switch tab index ignored: no browser window")
2363+ return nil
2364+ }
2365+ a.activateBrowserWindow(bw)
2366+ target := a.ensureTabTargetForBrowserWindow(bw)
2367+ initialURL := ""
2368+ if a.deps != nil && a.deps.Config != nil && a.deps.Config.Workspace.NewPaneURL != "" {
2369+ initialURL = urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL)
2370+ }
2371+ return a.tabCoord.SwitchByIndexCreating(ctx, target, index, initialURL)
2372+}
2373+
2374+// windowOrder returns window IDs in registration order.
2375+// Falls back to sorted map keys when no registration order is recorded
2376+// (e.g., in old tests or direct map manipulation).
2377+func (a *App) windowOrder() []string {
2378+ if len(a.browserWindowOrder) > 0 {
2379+ // Deduplicate against browserWindows, skipping stale entries.
2380+ seen := make(map[string]bool, len(a.browserWindowOrder))
2381+ result := make([]string, 0, len(a.browserWindowOrder))
2382+ for _, wid := range a.browserWindowOrder {
2383+ if seen[wid] || a.browserWindows[wid] == nil {
2384+ continue
2385+ }
2386+ seen[wid] = true
2387+ result = append(result, wid)
2388+ }
2389+ // Include any live browserWindows not yet tracked in order
2390+ // (inserted via direct map manipulation) deterministically.
2391+ untracked := make([]string, 0)
2392+ for id, bw := range a.browserWindows {
2393+ if bw != nil && !seen[id] {
2394+ untracked = append(untracked, id)
2395+ }
2396+ }
2397+ sort.Strings(untracked)
2398+ return append(result, untracked...)
2399+ }
2400+
2401+ // Fallback: sort map keys for deterministic output.
2402+ windowIDs := make([]string, 0, len(a.browserWindows))
2403+ for id := range a.browserWindows {
2404+ windowIDs = append(windowIDs, id)
2405+ }
2406+ sort.Strings(windowIDs)
2407+ return windowIDs
2408+}
2409+
2410+func windowOrderFrom(browserTabs map[string]*entity.TabList, browserWindowOrder []string) []string {
2411+ if len(browserWindowOrder) > 0 {
2412+ seen := make(map[string]bool, len(browserWindowOrder))
2413+ result := make([]string, 0, len(browserWindowOrder))
2414+ for _, wid := range browserWindowOrder {
2415+ if seen[wid] {
2416+ continue
2417+ }
2418+ if _, ok := browserTabs[wid]; !ok {
2419+ continue
2420+ }
2421+ seen[wid] = true
2422+ result = append(result, wid)
2423+ }
2424+ untracked := make([]string, 0)
2425+ for id := range browserTabs {
2426+ if !seen[id] {
2427+ untracked = append(untracked, id)
2428+ }
2429+ }
2430+ sort.Strings(untracked)
2431+ return append(result, untracked...)
2432+ }
2433+
2434+ windowIDs := make([]string, 0, len(browserTabs))
2435+ for id := range browserTabs {
2436+ windowIDs = append(windowIDs, id)
2437+ }
2438+ sort.Strings(windowIDs)
2439+ return windowIDs
2440+}
2441+
1801 // MarkDirty signals that session state has changed and should be saved.2442 // MarkDirty signals that session state has changed and should be saved.
1802 func (a *App) MarkDirty() {2443 func (a *App) MarkDirty() {
1803 if a.snapshotService != nil {2444 if a.snapshotService != nil {
@@ -1823,23 +2464,43 @@ func (a *App) createInitialTab(ctx context.Context) {
1823 }2464 }
1824 2465
1825 // Create an initial tab using coordinator.2466 // Create an initial tab using coordinator.
1826- if _, err := a.tabCoord.Create(ctx, a.initialWindowURL()); err != nil {2467+ target := a.ensureTabTargetForBrowserWindow(a.lastFocusedBrowserWindow())
2468+ if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil {
1827 log.Error().Err(err).Msg("failed to create initial tab")2469 log.Error().Err(err).Msg("failed to create initial tab")
1828 }2470 }
1829 }2471 }
1830 2472
2473+func (a *App) clearRuntimeWindowUI(runtimeWindows []*browserWindow) {
2474+ for _, bw := range runtimeWindows {
2475+ if bw == nil || bw.mainWindow == nil {
2476+ continue
2477+ }
2478+ if tabBar := bw.mainWindow.TabBar(); tabBar != nil {
2479+ if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
2480+ for _, tab := range tabs.Tabs {
2481+ if tab != nil {
2482+ tabBar.RemoveTab(tab.ID)
2483+ }
2484+ }
2485+ }
2486+ }
2487+ bw.mainWindow.SetContent(nil)
2488+ }
2489+}
2490+
1831 func (a *App) clearSessionRestoreUIState(ctx context.Context) {2491 func (a *App) clearSessionRestoreUIState(ctx context.Context) {
1832 if a == nil {2492 if a == nil {
1833 return2493 return
1834 }2494 }
1835 2495
1836 tabIDs := make(map[entity.TabID]struct{})2496 tabIDs := make(map[entity.TabID]struct{})
1837- if a.tabs != nil {2497+ for _, bw := range a.browserWindows {
1838- for _, tab := range a.tabs.Tabs {2498+ if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
1839- if tab == nil {2499+ for _, tab := range tabs.Tabs {
1840- continue2500+ if tab != nil {
2501+ tabIDs[tab.ID] = struct{}{}
2502+ }
1841 }2503 }
1842- tabIDs[tab.ID] = struct{}{}
1843 }2504 }
1844 }2505 }
1845 for tabID := range a.workspaceViews {2506 for tabID := range a.workspaceViews {
@@ -1867,94 +2528,290 @@ func (a *App) clearSessionRestoreUIState(ctx context.Context) {
1867 }2528 }
1868 2529
1869 func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {2530 func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {
1870- log := logging.FromContext(ctx)2531+ state, restoredWindows, err := a.loadRestoredWindowStates(ctx, sessionID)
2532+ if err != nil {
2533+ return err
2534+ }
2535+
2536+ logging.FromContext(ctx).Info().
2537+ Int("windows", len(restoredWindows)).
2538+ Int("panes", state.CountPanes()).
2539+ Msg("restoring session state")
2540+
2541+ if len(restoredWindows) == 0 {
2542+ if state.Version >= entity.SessionStateVersion {
2543+ // v2+ empty-window snapshots are valid: replace with a single empty window.
2544+ restoredWindows = []entity.WindowTabListState{{
2545+ WindowID: "",
2546+ Tabs: entity.NewTabList(),
2547+ }}
2548+ } else {
2549+ return fmt.Errorf("restored session contains no windows")
2550+ }
2551+ }
2552+
2553+ runtimeWindows, firstBWReused, err := a.prepareRuntimeWindows(ctx, len(restoredWindows))
2554+ if err != nil {
2555+ return err
2556+ }
2557+ a.clearSessionRestoreUIState(ctx)
2558+ a.clearRuntimeWindowUI(runtimeWindows)
2559+ a.assignRuntimeWindowTabLists(runtimeWindows, restoredWindows, firstBWReused)
2560+ a.pruneStaleBrowserWindows(runtimeWindows)
2561+
2562+ activeBW := runtimeWindows[safeWindowIndex(state.ActiveWindowIndex, len(runtimeWindows))]
2563+ a.activateBrowserWindow(activeBW)
2564+ a.replaceGlobalTabsFromRuntimeWindows(runtimeWindows, activeBW)
2565+ a.buildRestoredWindowUI(ctx, runtimeWindows)
2566+ a.showRestoredRuntimeWindows(runtimeWindows)
2567+
2568+ return nil
2569+}
1871 2570
2571+func (a *App) loadRestoredWindowStates(
2572+ ctx context.Context,
2573+ sessionID entity.SessionID,
2574+) (*entity.SessionState, []entity.WindowTabListState, error) {
1872 if a.deps == nil || a.deps.SessionStateRepo == nil {2575 if a.deps == nil || a.deps.SessionStateRepo == nil {
1873- return fmt.Errorf("session state repo not available")2576+ return nil, nil, fmt.Errorf("session state repo not available")
1874 }2577 }
1875 2578
1876- // Load session state
1877 state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)2579 state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
1878 if err != nil {2580 if err != nil {
1879- return fmt.Errorf("load session state: %w", err)2581+ return nil, nil, fmt.Errorf("load session state: %w", err)
1880 }2582 }
1881 if state == nil {2583 if state == nil {
1882- return fmt.Errorf("session state not found")2584+ return nil, nil, fmt.Errorf("session state not found")
1883 }2585 }
1884 2586
1885- log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state")2587+ restoredWindows := entity.WindowTabListsFromSnapshot(state, a.generateID)
2588+ return state, restoredWindows, nil
2589+}
1886 2590
1887- // Build complete TabList from snapshot (state-first approach)2591+func (a *App) prepareRuntimeWindows(ctx context.Context, windowCount int) ([]*browserWindow, bool, error) {
1888- // This reconstructs the full pane tree with new IDs2592+ firstBW := a.browserWindowForMainWindow(a.mainWindow)
1889- restoredTabs := entity.TabListFromSnapshot(state, a.generateID)2593+ if firstBW == nil {
1890- if restoredTabs == nil || len(restoredTabs.Tabs) == 0 {2594+ firstBW = a.lastFocusedBrowserWindow()
1891- return fmt.Errorf("failed to build tab list from snapshot")
1892 }2595 }
2596+ firstBWReused := firstBW != nil
2597+ createdWindows := make([]*browserWindow, 0)
1893 2598
1894- a.clearSessionRestoreUIState(ctx)2599+ if firstBW == nil {
2600+ if a.mainWindow != nil {
2601+ firstBW = a.wrapExistingMainWindowForRestore(ctx, a.mainWindow)
2602+ } else {
2603+ created, err := a.createBrowserWindow(ctx, "")
2604+ if err != nil {
2605+ return nil, false, fmt.Errorf("create browser window for restore: %w", err)
2606+ }
2607+ firstBW = created
2608+ createdWindows = append(createdWindows, created)
2609+ }
2610+ }
1895 2611
1896- // Replace tabs in-place to preserve references held by TabCoordinator2612+ runtimeWindows := make([]*browserWindow, windowCount)
1897- a.tabs.ReplaceFrom(restoredTabs)2613+ runtimeWindows[0] = firstBW
2614+ for i := 1; i < windowCount; i++ {
2615+ extraBW, err := a.createBrowserWindow(ctx, "")
2616+ if err != nil {
2617+ a.cleanupCreatedBrowserWindows(createdWindows)
2618+ return nil, false, fmt.Errorf("create browser window %d for restore: %w", i, err)
2619+ }
2620+ createdWindows = append(createdWindows, extraBW)
2621+ runtimeWindows[i] = extraBW
2622+ }
1898 2623
1899- a.assignRestoredTabsToCurrentWindow()2624+ return runtimeWindows, firstBWReused, nil
2625+}
1900 2626
1901- // Create UI for each restored tab and add to tab bar2627+func (a *App) wrapExistingMainWindowForRestore(ctx context.Context, mainWindow *window.MainWindow) *browserWindow {
1902- // Don't attach to content area yet - only the active tab should be attached2628+ bw := &browserWindow{id: a.generateWindowID(), tabs: entity.NewTabList(), mainWindow: mainWindow}
1903- tabBar := a.mainWindow.TabBar()2629+ a.wireBrowserWindowActivationTracking(bw)
1904- for _, tab := range a.tabs.Tabs {2630+ if a.kbDispatcher != nil {
1905- a.createWorkspaceViewWithoutAttach(ctx, tab)2631+ a.initBrowserWindowInput(ctx, bw)
1906- wsView := a.workspaceViews[tab.ID]2632+ }
1907- if a.wsCoord != nil && wsView != nil {2633+ if a.tabCoord != nil {
1908- a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)2634+ a.wireBrowserWindowTabBar(ctx, bw)
2635+ }
2636+ bw.initChrome(ctx, a)
2637+ return bw
2638+}
2639+
2640+func (a *App) cleanupCreatedBrowserWindows(createdWindows []*browserWindow) {
2641+ for _, bw := range createdWindows {
2642+ if bw == nil {
2643+ continue
1909 }2644 }
1910- if tabBar != nil {2645+ mainWindow := bw.mainWindow
1911- tabBar.AddTab(tab)2646+ a.removeBrowserWindow(bw.id)
2647+ if mainWindow != nil {
2648+ if a.mainWindow == mainWindow {
2649+ a.mainWindow = nil
2650+ }
2651+ mainWindow.Destroy()
1912 }2652 }
1913- log.Debug().
1914- Str("tab_id", string(tab.ID)).
1915- Str("name", tab.Name).
1916- Int("panes", tab.Workspace.PaneCount()).
1917- Msg("restored tab with workspace")
1918 }2653 }
2654+}
1919 2655
1920- // Switch to active tab - this attaches the active workspace view to content area2656+func (a *App) assignRuntimeWindowTabLists(
1921- activeTab := a.tabs.ActiveTab()2657+ runtimeWindows []*browserWindow,
1922- if activeTab != nil {2658+ restoredWindows []entity.WindowTabListState,
1923- a.switchWorkspaceView(ctx, activeTab.ID)2659+ firstBWReused bool,
1924- if tabBar != nil {2660+) {
1925- tabBar.SetActive(activeTab.ID)2661+ for i, bw := range runtimeWindows {
2662+ if i > 0 || !firstBWReused {
2663+ a.registerBrowserWindow(bw)
1926 }2664 }
2665+ a.assignRestoredWindowTabList(bw, restoredWindows[i])
1927 }2666 }
2667+}
1928 2668
1929- return nil2669+func (a *App) pruneStaleBrowserWindows(runtimeWindows []*browserWindow) {
2670+ runtimeSet := make(map[string]bool, len(runtimeWindows))
2671+ for _, bw := range runtimeWindows {
2672+ runtimeSet[bw.id] = true
2673+ }
2674+ for id, bw := range a.browserWindows {
2675+ if !runtimeSet[id] {
2676+ if bw != nil && bw.mainWindow != nil {
2677+ bw.mainWindow.Destroy()
2678+ }
2679+ a.removeBrowserWindow(id)
2680+ }
2681+ }
2682+
2683+ a.browserWindowOrder = make([]string, len(runtimeWindows))
2684+ for i, bw := range runtimeWindows {
2685+ a.browserWindowOrder[i] = bw.id
2686+ }
1930 }2687 }
1931 2688
1932-func (a *App) assignRestoredTabsToCurrentWindow() {2689+func safeWindowIndex(index, count int) int {
1933- if a == nil || a.tabs == nil {2690+ if index < 0 || index >= count {
1934- return2691+ return 0
1935 }2692 }
2693+ return index
2694+}
1936 2695
1937- restoreWindow := a.browserWindowForMainWindow(a.mainWindow)2696+func (a *App) syncDerivedGlobalTabMirror(tab *entity.Tab) {
1938- if restoreWindow == nil {2697+ if a.tabs != nil && tab != nil && a.tabs.Find(tab.ID) == nil {
1939- restoreWindow = a.lastFocusedBrowserWindow()2698+ a.tabs.Add(cloneTabForGlobalList(tab))
1940 }2699 }
1941- if restoreWindow == nil {2700+}
1942- return2701+
2702+// workspace pointer is intentionally shared because App.tabs is a derived
2703+// snapshot/export mirror, not runtime command target; runtime mutations must
2704+// target browserWindow tabs.
2705+func cloneTabForGlobalList(tab *entity.Tab) *entity.Tab {
2706+ if tab == nil {
2707+ return nil
1943 }2708 }
2709+ return &entity.Tab{
2710+ ID: tab.ID,
2711+ Name: tab.Name,
2712+ Workspace: tab.Workspace,
2713+ IsPinned: tab.IsPinned,
2714+ CreatedAt: tab.CreatedAt,
2715+ }
2716+}
1944 2717
1945- for _, tab := range a.tabs.Tabs {2718+func (a *App) replaceGlobalTabsFromRuntimeWindows(runtimeWindows []*browserWindow, activeBW *browserWindow) {
1946- if tab != nil {2719+ globalTabs := entity.NewTabList()
1947- a.setBrowserWindowForTab(tab.ID, restoreWindow)2720+ for _, bw := range runtimeWindows {
2721+ perWinTabs := a.tabListForBrowserWindow(bw)
2722+ if perWinTabs == nil {
2723+ continue
2724+ }
2725+ for _, tab := range perWinTabs.Tabs {
2726+ if cloned := cloneTabForGlobalList(tab); cloned != nil {
2727+ globalTabs.Add(cloned)
2728+ }
2729+ }
2730+ }
2731+
2732+ if activeTabs := a.tabListForBrowserWindow(activeBW); activeTabs != nil {
2733+ activeID := activeTabs.ActiveTabID
2734+ if activeID != "" && globalTabs.Find(activeID) != nil {
2735+ globalTabs.SetActive(activeID)
2736+ }
2737+ }
2738+ a.tabs.ReplaceFrom(globalTabs)
2739+}
2740+
2741+func (a *App) showRestoredRuntimeWindows(runtimeWindows []*browserWindow) {
2742+ for _, bw := range runtimeWindows {
2743+ if bw != nil && bw.mainWindow != nil {
2744+ bw.mainWindow.Show()
1948 }2745 }
1949 }2746 }
2747+}
2748+
2749+func (a *App) buildRestoredWindowUI(ctx context.Context, runtimeWindows []*browserWindow) {
2750+ for _, bw := range runtimeWindows {
2751+ perWinTabs := a.tabListForBrowserWindow(bw)
2752+ if perWinTabs == nil || perWinTabs.Count() == 0 {
2753+ a.updateBrowserWindowTabBarVisibility(bw)
2754+ continue
2755+ }
2756+
2757+ tabBar := bw.mainWindow.TabBar()
2758+ activeTab := perWinTabs.ActiveTab()
2759+ for _, tab := range perWinTabs.Tabs {
2760+ a.buildRestoredTabUI(ctx, bw, tabBar, tab)
2761+ }
2762+
2763+ if activeTab != nil {
2764+ a.switchWorkspaceView(ctx, activeTab.ID)
2765+ if tabBar != nil {
2766+ tabBar.SetActive(activeTab.ID)
2767+ }
2768+ }
2769+ a.updateBrowserWindowTabBarVisibility(bw)
2770+ }
2771+}
2772+
2773+func (a *App) buildRestoredTabUI(ctx context.Context, bw *browserWindow, tabBar *component.TabBar, tab *entity.Tab) {
2774+ if tab == nil {
2775+ return
2776+ }
2777+ a.createWorkspaceViewWithoutAttach(ctx, tab)
2778+ wsView := a.workspaceViews[tab.ID]
2779+ if a.wsCoord != nil && wsView != nil {
2780+ a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView)
2781+ }
2782+ if tabBar != nil {
2783+ tabBar.AddTab(tab)
2784+ }
2785+ logging.FromContext(ctx).Debug().
2786+ Str("tab_id", string(tab.ID)).
2787+ Str("name", tab.Name).
2788+ Str("window_id", bw.id).
2789+ Int("panes", tab.Workspace.PaneCount()).
2790+ Msg("restored tab with workspace")
2791+}
2792+
2793+// assignRestoredWindowTabList assigns a restored WindowTabListState to a runtime browser window.
2794+// It sets bw.tabs to the restored TabList (or a new empty one if nil) and registers each
2795+// tab's ownership via windowForTab. It never inserts saved window IDs into browserWindows;
2796+// the runtime window ID is owned by the caller.
2797+func (a *App) assignRestoredWindowTabList(bw *browserWindow, restored entity.WindowTabListState) {
2798+ if bw == nil {
2799+ return
2800+ }
1950 2801
1951- restoreWindow.activeTabID = a.tabs.ActiveTabID2802+ a.ensureWindowForTabMap()
1952- previousID := a.tabs.PreviousActiveTabID2803+
1953- if previousID != "" && previousID != a.tabs.ActiveTabID && a.tabs.Find(previousID) != nil {2804+ if restored.Tabs == nil {
1954- restoreWindow.prevActiveTabID = previousID2805+ bw.tabs = entity.NewTabList()
1955 return2806 return
1956 }2807 }
1957- restoreWindow.prevActiveTabID = ""2808+
2809+ bw.tabs = restored.Tabs
2810+ for _, tab := range restored.Tabs.Tabs {
2811+ if tab != nil {
2812+ a.windowForTab[tab.ID] = bw
2813+ }
2814+ }
1958 }2815 }
1959 2816
1960 func (a *App) finalizeActivation(ctx context.Context) {2817 func (a *App) finalizeActivation(ctx context.Context) {
@@ -2126,14 +2983,12 @@ func (a *App) initContentCoordinator(
2126 func (a *App) initTabCoordinator(ctx context.Context) {2983 func (a *App) initTabCoordinator(ctx context.Context) {
2127 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{2984 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
2128 TabsUC: a.tabsUC,2985 TabsUC: a.tabsUC,
2129- Tabs: a.tabs,
2130 MainWindow: a.mainWindow,2986 MainWindow: a.mainWindow,
2131 HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,2987 HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,
2132 })2988 })
2133- a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {2989+ a.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
2134- // Assign ownership BEFORE creating workspace view so windowForTab is set2990+ // Assign ownership from the callback target, not focus/global state.
2135- // for scope filtering and browserWindowForTab resolution.2991+ bw := a.browserWindowForTabTarget(target)
2136- bw := a.browserWindowForTab(tab.ID)
2137 if bw != nil {2992 if bw != nil {
2138 a.setBrowserWindowForTab(tab.ID, bw)2993 a.setBrowserWindowForTab(tab.ID, bw)
2139 }2994 }
@@ -2147,59 +3002,38 @@ func (a *App) initTabCoordinator(ctx context.Context) {
2147 }3002 }
2148 tab.Name = defaultTabName(count)3003 tab.Name = defaultTabName(count)
2149 }3004 }
3005+ // Keep the derived global App.tabs mirror populated from per-window
3006+ // tab creation for usecase and snapshot/export boundaries.
3007+ // This must happen AFTER default tab name assignment so the clone
3008+ // has the final window-scoped default name.
3009+ if a.tabs != nil && a.tabs.Find(tab.ID) == nil {
3010+ a.tabs.Add(cloneTabForGlobalList(tab))
3011+ }
2150 a.createWorkspaceView(ctx, tab)3012 a.createWorkspaceView(ctx, tab)
2151 })3013 })
2152- a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {3014+ a.tabCoord.SetOnTabSwitched(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
2153- if bw := a.browserWindowForTab(tab.ID); bw != nil {3015+ // TabList.SetActive (called by TabCoordinator.Switch → tabsUC.Switch) already
2154- oldActiveID := bw.activeTabID3016+ // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed.
2155- if oldActiveID != "" && oldActiveID != tab.ID && a.tabs.Find(oldActiveID) != nil {3017+ if bw := a.browserWindowForTabTarget(target); bw != nil {
2156- bw.prevActiveTabID = oldActiveID
2157- } else if bw.prevActiveTabID == tab.ID || a.tabs.Find(bw.prevActiveTabID) == nil {
2158- bw.prevActiveTabID = ""
2159- }
2160- bw.activeTabID = tab.ID
2161 a.activateBrowserWindow(bw)3018 a.activateBrowserWindow(bw)
2162 }3019 }
2163 a.switchWorkspaceView(ctx, tab.ID)3020 a.switchWorkspaceView(ctx, tab.ID)
2164 })3021 })
2165- a.tabCoord.SetOnQuit(a.Quit)
2166 a.tabCoord.SetOnStateChanged(a.MarkDirty)3022 a.tabCoord.SetOnStateChanged(a.MarkDirty)
2167- // Wire per-window tab scoping3023+ // Per-window tab ownership is now enforced through explicit TabTarget.
2168- a.tabCoord.SetTabScope(func(tab *entity.Tab, mainWindow *window.MainWindow) bool {3024+ // The per-window TabList (bw.tabs) is the source of truth; no scope filtering needed.
2169- if tab == nil {3025+ a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, target coordinator.TabTarget) {
2170- return false3026+ bw := a.browserWindowForTabTarget(target)
2171- }
2172- bw := a.browserWindowForMainWindow(mainWindow)
2173- if bw == nil {
2174- return true // backward-compatible fallback for single-window / test scenarios
2175- }
2176- owner := a.windowForTab[tab.ID]
2177- if owner == nil {
2178- return false // unowned tabs are out of scope when a real window exists
2179- }
2180- return owner == bw
2181- })
2182- // Wire per-window previous active tab ID provider for scoped Alt+Tab switching
2183- a.tabCoord.SetPreviousActiveTabIDProvider(func(mainWindow *window.MainWindow) entity.TabID {
2184- bw := a.browserWindowForMainWindow(mainWindow)
2185- if bw == nil {
2186- return ""
2187- }
2188- prevID := bw.prevActiveTabID
2189- if !a.browserWindowHasLiveTab(bw, prevID) {
2190- bw.prevActiveTabID = ""
2191- return ""
2192- }
2193- return prevID
2194- })
2195- a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, mainWindow *window.MainWindow) {
2196- bw := a.browserWindowForMainWindow(mainWindow)
2197 if bw != nil {3027 if bw != nil {
2198 a.removeBrowserWindow(bw.id)3028 a.removeBrowserWindow(bw.id)
2199 if bw.mainWindow != nil {3029 if bw.mainWindow != nil {
2200 bw.mainWindow.Destroy()3030 bw.mainWindow.Destroy()
2201 }3031 }
2202 }3032 }
3033+ // Quit the app only when all browser windows are gone.
3034+ if len(a.browserWindows) == 0 {
3035+ a.Quit()
3036+ }
2203 })3037 })
2204 // Wire popup tab WebView attachment3038 // Wire popup tab WebView attachment
2205 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {3039 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
@@ -2216,7 +3050,11 @@ func (a *App) initCoordinators(ctx context.Context) {
2216 log := logging.FromContext(ctx)3050 log := logging.FromContext(ctx)
2217 log.Debug().Msg("initializing coordinators")3051 log.Debug().Msg("initializing coordinators")
2218 3052
2219- // Helper to get active workspace and view3053+ // Coordinators resolve the workspace and view through
3054+ // lastFocusedBrowserWindow via activeWorkspace helpers.
3055+ // Shared coordinator instances defer to window-scoped resolution;
3056+ // the startup edge case (no focused window yet) is handled inside
3057+ // activeWorkspace/activeWorkspaceView.
2220 getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {3058 getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
2221 return a.activeWorkspace(), a.activeWorkspaceView()3059 return a.activeWorkspace(), a.activeWorkspaceView()
2222 }3060 }
@@ -2252,7 +3090,8 @@ func (a *App) initCoordinators(ctx context.Context) {
2252 ResizeMinPanePercent: a.deps.Config.Workspace.ResizeMode.MinPanePercent,3090 ResizeMinPanePercent: a.deps.Config.Workspace.ResizeMode.MinPanePercent,
2253 })3091 })
2254 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {3092 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
2255- return a.tabCoord.Close(ctx)3093+ bw := a.lastFocusedBrowserWindow()
3094+ return a.tabCoord.Close(ctx, a.ensureTabTargetForBrowserWindow(bw))
2256 })3095 })
2257 a.wsCoord.SetOnStateChanged(a.MarkDirty)3096 a.wsCoord.SetOnStateChanged(a.MarkDirty)
2258 3097
@@ -2273,16 +3112,8 @@ func (a *App) initCoordinators(ctx context.Context) {
2273 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {3112 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
2274 return a.wsCoord.ClosePaneByID(ctx, paneID)3113 return a.wsCoord.ClosePaneByID(ctx, paneID)
2275 })3114 })
2276- // Wire tabbed popup behavior to create new tabs3115+ // Wire tabbed popup behavior to create new tabs in the originating window.
2277- a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input content.InsertPopupInput) error {3116+ a.wsCoord.SetOnCreatePopupTab(a.createPopupTab)
2278- // Create a new tab with the popup pane
2279- tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
2280- if err != nil {
2281- return err
2282- }
2283- log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
2284- return nil
2285- })
2286 3117
2287 // Move pane use case (cross-tab)3118 // Move pane use case (cross-tab)
2288 a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)3119 a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)
@@ -2338,7 +3169,10 @@ func (a *App) initCoordinators(ctx context.Context) {
2338 })3169 })
2339 }3170 }
2340 3171
2341- // Set the WebView as the focused input for accent picker (if this is the active pane)3172+ // This only updates focus when the shown pane belongs to the last-focused
3173+ // window's active workspace. activeWorkspace() delegates to
3174+ // lastFocusedBrowserWindow, so the accent-picker does not activate for
3175+ // background windows.
2342 ws := a.activeWorkspace()3176 ws := a.activeWorkspace()
2343 if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {3177 if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
2344 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())3178 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
@@ -2346,14 +3180,16 @@ func (a *App) initCoordinators(ctx context.Context) {
2346 })3180 })
2347 3181
2348 // 5. Keyboard Dispatcher3182 // 5. Keyboard Dispatcher
3183+ // Dispatcher receives an app-level active pane provider (contentCoord.ActivePaneID)
3184+ // and per-window handlers route source-window actions through
3185+ // dispatchBrowserWindowAction for proper multi-window isolation.
2349 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(3186 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
2350 ctx,3187 ctx,
2351- a.tabCoord,
2352 a.wsCoord,3188 a.wsCoord,
2353 a.navCoord,3189 a.navCoord,
2354 a.deps.ZoomUC,3190 a.deps.ZoomUC,
2355 a.deps.CopyURLUC,3191 a.deps.CopyURLUC,
2356- a.deps.Config.Workspace.NewPaneURL,3192+ a.keyboardActions(),
2357 a.contentCoord.ActivePaneID,3193 a.contentCoord.ActivePaneID,
2358 )3194 )
2359 a.wireKeyboardActions()3195 a.wireKeyboardActions()
@@ -2364,6 +3200,65 @@ func (a *App) initCoordinators(ctx context.Context) {
2364 log.Debug().Msg("coordinators initialized")3200 log.Debug().Msg("coordinators initialized")
2365 }3201 }
2366 3202
3203+func (a *App) keyboardActions() dispatcher.KeyboardActions {
3204+ return dispatcher.KeyboardActions{
3205+ NewTab: func(ctx context.Context) error {
3206+ if a.deps == nil || a.deps.Config == nil || a.deps.Config.Workspace.NewPaneURL == "" {
3207+ logging.FromContext(ctx).Warn().Msg("new tab ignored: new pane URL is not configured")
3208+ return fmt.Errorf("newPaneURL is not configured")
3209+ }
3210+ return a.withFocusedTabTarget(ctx, "new tab", true, func(target coordinator.TabTarget) error {
3211+ _, err := a.tabCoord.Create(ctx, target, urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL))
3212+ return err
3213+ })
3214+ },
3215+ CloseTab: func(ctx context.Context) error {
3216+ return a.withFocusedTabTarget(ctx, "close tab", false, func(target coordinator.TabTarget) error {
3217+ return a.tabCoord.Close(ctx, target)
3218+ })
3219+ },
3220+ NextTab: func(ctx context.Context) error {
3221+ return a.withFocusedTabTarget(ctx, "next tab", false, func(target coordinator.TabTarget) error {
3222+ return a.tabCoord.SwitchNext(ctx, target)
3223+ })
3224+ },
3225+ PreviousTab: func(ctx context.Context) error {
3226+ return a.withFocusedTabTarget(ctx, "previous tab", false, func(target coordinator.TabTarget) error {
3227+ return a.tabCoord.SwitchPrev(ctx, target)
3228+ })
3229+ },
3230+ SwitchLastTab: func(ctx context.Context) error {
3231+ return a.withFocusedTabTarget(ctx, "switch last tab", false, func(target coordinator.TabTarget) error {
3232+ return a.tabCoord.SwitchToLastActive(ctx, target)
3233+ })
3234+ },
3235+ SwitchTabIndex: func(ctx context.Context, index int) error {
3236+ return a.switchBrowserWindowTabIndex(ctx, a.lastFocusedBrowserWindow(), index)
3237+ },
3238+ ActiveWebView: func(context.Context) port.WebView {
3239+ _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
3240+ return wv
3241+ },
3242+ }
3243+}
3244+
3245+func (a *App) withFocusedTabTarget(ctx context.Context, action string, ensure bool, fn func(coordinator.TabTarget) error) error {
3246+ if a.tabCoord == nil {
3247+ logging.FromContext(ctx).Warn().Str("action", action).Msg("tab action ignored: tab coordinator is nil")
3248+ return nil
3249+ }
3250+ bw := a.lastFocusedBrowserWindow()
3251+ if bw == nil {
3252+ logging.FromContext(ctx).Warn().Str("action", action).Msg("tab action ignored: no focused browser window")
3253+ return nil
3254+ }
3255+ target := a.tabTargetForBrowserWindow(bw)
3256+ if ensure {
3257+ target = a.ensureTabTargetForBrowserWindow(bw)
3258+ }
3259+ return fn(target)
3260+}
3261+
2367 func (a *App) wireKeyboardActions() {3262 func (a *App) wireKeyboardActions() {
2368 a.kbDispatcher.SetOnQuit(a.Quit)3263 a.kbDispatcher.SetOnQuit(a.Quit)
2369 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {3264 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
@@ -2631,13 +3526,13 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) {
2631 // updateWindowTitleFromActivePane updates the window title based on the current active pane.3526 // updateWindowTitleFromActivePane updates the window title based on the current active pane.
2632 func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {3527 func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
2633 var ws *entity.Workspace3528 var ws *entity.Workspace
2634- if a.tabs != nil {3529+ bw := a.browserWindowForTab(tabID)
2635- if tab := a.tabs.Find(tabID); tab != nil {3530+ if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
3531+ if tab := tabs.Find(tabID); tab != nil {
2636 ws = tab.Workspace3532 ws = tab.Workspace
2637- } else {
2638- ws = a.activeWorkspace()
2639 }3533 }
2640- } else {3534+ }
3535+ if ws == nil {
2641 ws = a.activeWorkspace()3536 ws = a.activeWorkspace()
2642 }3537 }
2643 if ws == nil || a.contentCoord == nil {3538 if ws == nil || a.contentCoord == nil {
@@ -2657,6 +3552,7 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
2657 a.clearResizeModeBorder()3552 a.clearResizeModeBorder()
2658 }3553 }
2659 if to == input.ModeResize {3554 if to == input.ModeResize {
3555+ // Resize mode targets the last-focused browser window's active workspace.
2660 a.applyResizeModeBorder(ctx, a.activeWorkspace())3556 a.applyResizeModeBorder(ctx, a.activeWorkspace())
2661 }3557 }
2662 3558
@@ -2722,6 +3618,8 @@ func (a *App) clearResizeModeBorder() {
2722 }3618 }
2723 3619
2724 func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {3620 func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {
3621+ // activeWorkspaceView resolves via lastFocusedBrowserWindow; resize mode
3622+ // always operates on the last-focused window.
2725 wsView := a.activeWorkspaceView()3623 wsView := a.activeWorkspaceView()
2726 if wsView == nil || ws == nil {3624 if wsView == nil || ws == nil {
2727 a.clearResizeModeBorder()3625 a.clearResizeModeBorder()
@@ -2809,8 +3707,17 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
2809 // Note: Mode borders for tab/pane/session are attached to MainWindow.3707 // Note: Mode borders for tab/pane/session are attached to MainWindow.
2810 // Resize mode border is attached to the active pane's stack container.3708 // Resize mode border is attached to the active pane's stack container.
2811 3709
2812- // Set omnibox config for this workspace view3710+ // Set omnibox config for this workspace view.
2813- wsView.SetOmniboxConfig(a.omniboxCfg)3711+ // When the owning browser window is known, bind navigation to the
3712+ // owning window so that the omnibox always navigates in the correct
3713+ // window, regardless of global focus.
3714+ if owner := a.browserWindowForTab(tab.ID); owner != nil {
3715+ cfg := a.omniboxCfg
3716+ cfg.OnNavigate = omniboxNavigateForBrowserWindow(ctx, owner, a.navigateFromBrowserWindow)
3717+ wsView.SetOmniboxConfig(cfg)
3718+ } else {
3719+ wsView.SetOmniboxConfig(a.omniboxCfg)
3720+ }
2814 // Set find bar config for this workspace view3721 // Set find bar config for this workspace view
2815 wsView.SetFindBarConfig(a.findBarCfg)3722 wsView.SetFindBarConfig(a.findBarCfg)
2816 // Set auto-open omnibox on new pane3723 // Set auto-open omnibox on new pane
@@ -2820,6 +3727,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
2820 3727
2821 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {3728 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
2822 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {3729 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
3730+ // Resize-mode pane focus tracks the last-focused window's workspace.
2823 ws := a.activeWorkspace()3731 ws := a.activeWorkspace()
2824 if ws != nil {3732 if ws != nil {
2825 ws.ActivePaneID = paneID3733 ws.ActivePaneID = paneID
@@ -2842,50 +3750,40 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
2842 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")3750 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")
2843 }3751 }
2844 3752
2845-// activeWorkspace returns the workspace of the active tab.3753+// activeWorkspace returns the workspace of the focused browser window's active tab.
2846 func (a *App) activeWorkspace() *entity.Workspace {3754 func (a *App) activeWorkspace() *entity.Workspace {
2847- if a.tabs == nil {3755+ return a.activeWorkspaceForBrowserWindow(a.lastFocusedBrowserWindow())
2848- return nil
2849- }
2850- activeTab := a.tabs.ActiveTab()
2851- if activeTab == nil {
2852- return nil
2853- }
2854- return activeTab.Workspace
2855 }3756 }
2856 3757
2857 // updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.3758 // updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
2858 // This is necessary because panes in inactive tabs also need URI updates for session snapshots.3759 // This is necessary because panes in inactive tabs also need URI updates for session snapshots.
2859 func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {3760 func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
2860- for _, tab := range a.tabs.Tabs {3761+ for _, bw := range a.browserWindows {
2861- if tab.Workspace == nil {3762+ if bw == nil || bw.tabs == nil {
2862 continue3763 continue
2863 }3764 }
2864- paneNode := tab.Workspace.FindPane(paneID)3765+ for _, tab := range bw.tabs.Tabs {
2865- if paneNode != nil && paneNode.Pane != nil {3766+ if tab == nil || tab.Workspace == nil {
2866- paneNode.Pane.URI = url3767+ continue
2867- return // Pane IDs are unique, no need to continue3768+ }
3769+ paneNode := tab.Workspace.FindPane(paneID)
3770+ if paneNode != nil && paneNode.Pane != nil {
3771+ paneNode.Pane.URI = url
3772+ return // Pane IDs are unique, no need to continue
3773+ }
2868 }3774 }
2869 }3775 }
2870 }3776 }
2871 3777
2872-// activeWorkspaceView returns the workspace view for the active tab.3778+// activeWorkspaceView returns the workspace view for the focused browser window's active tab.
2873 func (a *App) activeWorkspaceView() *component.WorkspaceView {3779 func (a *App) activeWorkspaceView() *component.WorkspaceView {
2874- activeTab := a.tabs.ActiveTab()3780+ return a.activeWorkspaceViewForBrowserWindow(a.lastFocusedBrowserWindow())
2875- if activeTab == nil {
2876- return nil
2877- }
2878- return a.workspaceViews[activeTab.ID]
2879 }3781 }
2880 3782
2881 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.3783 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
2882 // Used by the accent picker to insert accented characters into web content.3784 // Used by the accent picker to insert accented characters into web content.
2883 func (a *App) getActiveWebViewTarget() port.TextInputTarget {3785 func (a *App) getActiveWebViewTarget() port.TextInputTarget {
2884- if a.contentCoord == nil {3786+ _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
2885- return nil
2886- }
2887-
2888- wv := a.contentCoord.ActiveWebView(context.Background())
2889 if wv == nil {3787 if wv == nil {
2890 return nil3788 return nil
2891 }3789 }
@@ -2952,9 +3850,9 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
2952 return3850 return
2953 }3851 }
2954 3852
2955- // Swap content (MainWindow.SetContent now properly removes old content)3853+ // Swap content (MainWindow.SetContent now properly removes old content).
3854+ // Active tab state is managed by TabList.SetActive; no per-window field needed.
2956 if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {3855 if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {
2957- target.activeTabID = tabID
2958 target.mainWindow.SetContent(gtkWidget)3856 target.mainWindow.SetContent(gtkWidget)
2959 } else if a.mainWindow != nil {3857 } else if a.mainWindow != nil {
2960 a.mainWindow.SetContent(gtkWidget)3858 a.mainWindow.SetContent(gtkWidget)
@@ -3292,7 +4190,7 @@ func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.Ta
3292 }4190 }
3293 4191
3294 func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {4192 func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {
3295- activeTab := a.tabs.ActiveTab()4193+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
3296 if activeTab == nil {4194 if activeTab == nil {
3297 return floatingSessionKey{}, nil, false4195 return floatingSessionKey{}, nil, false
3298 }4196 }
@@ -3377,7 +4275,7 @@ func (a *App) syncFloatingFocus() {
3377 return4275 return
3378 }4276 }
3379 4277
3380- activeTab := a.tabs.ActiveTab()4278+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
3381 if activeTab == nil {4279 if activeTab == nil {
3382 a.contentCoord.ClearActivePaneOverride()4280 a.contentCoord.ClearActivePaneOverride()
3383 return4281 return
@@ -3402,6 +4300,14 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac
3402 4300
3403 if session.omnibox == nil {4301 if session.omnibox == nil {
3404 cfg := a.omniboxCfg4302 cfg := a.omniboxCfg
4303+ cfg.OnNavigate = func(url string) {
4304+ if session.pane == nil {
4305+ return
4306+ }
4307+ if err := session.pane.Navigate(ctx, url); err != nil {
4308+ logging.FromContext(ctx).Error().Err(err).Str("url", url).Msg("floating omnibox navigation failed")
4309+ }
4310+ }
3405 cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {4311 cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
3406 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)4312 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
3407 }4313 }
@@ -3619,7 +4525,7 @@ func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool {
3619 4525
3620 // ToggleFloatingPane toggles the active workspace floating pane visibility.4526 // ToggleFloatingPane toggles the active workspace floating pane visibility.
3621 func (a *App) ToggleFloatingPane(ctx context.Context) error {4527 func (a *App) ToggleFloatingPane(ctx context.Context) error {
3622- activeTab := a.tabs.ActiveTab()4528+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
3623 if activeTab == nil {4529 if activeTab == nil {
3624 return nil4530 return nil
3625 }4531 }
@@ -3640,7 +4546,7 @@ func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url str
3640 }4546 }
3641 4547
3642 func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {4548 func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {
3643- activeTab := a.tabs.ActiveTab()4549+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
3644 if activeTab == nil {4550 if activeTab == nil {
3645 return nil4551 return nil
3646 }4552 }
@@ -3694,14 +4600,10 @@ func (a *App) navigateFromOmnibox(ctx context.Context, url string) error {
3694 if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {4600 if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {
3695 return session.pane.Navigate(ctx, url)4601 return session.pane.Navigate(ctx, url)
3696 }4602 }
3697- if a.omniboxNavigateFn != nil {4603+ if bw := a.lastFocusedBrowserWindow(); bw != nil {
3698- a.omniboxNavigateFn(url)4604+ return a.navigateFromBrowserWindow(ctx, bw, url)
3699- return nil
3700 }4605 }
3701- if a.navCoord == nil {4606+ return fmt.Errorf("no focused browser window for omnibox navigation")
3702- return fmt.Errorf("navigation coordinator not initialized")
3703- }
3704- return a.navCoord.Navigate(ctx, url)
3705 }4607 }
3706 4608
3707 // ToggleOmnibox implements OmniboxProvider.4609 // ToggleOmnibox implements OmniboxProvider.
@@ -3802,13 +4704,6 @@ func (a *App) UpdateOmniboxZoom(factor float64) {
3802 }4704 }
3803 }4705 }
3804 4706
3805-// SetOmniboxOnNavigate implements OmniboxProvider.
3806-// This is called to set the navigation callback on new omniboxes.
3807-// Since omniboxes are created per-pane, we store the config with the callback.
3808-func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
3809- a.omniboxNavigateFn = fn
3810-}
3811-
3812 // initFilteringAsync starts background filter loading with toast feedback.4707 // initFilteringAsync starts background filter loading with toast feedback.
3813 func (a *App) initConfigWatcher(ctx context.Context) {4708 func (a *App) initConfigWatcher(ctx context.Context) {
3814 log := logging.FromContext(ctx)4709 log := logging.FromContext(ctx)
M internal/ui/app_browser_launch_test.go +1462 -47
@@ -2,12 +2,15 @@ package ui
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "io"6 "io"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
8 "reflect"9 "reflect"
10+ "strconv"
9 "strings"11 "strings"
10 "testing"12 "testing"
13+ "time"
11 "unsafe"14 "unsafe"
12 15
13 "github.com/bnema/dumber/internal/application/port"16 "github.com/bnema/dumber/internal/application/port"
@@ -153,6 +156,39 @@ func windowForTabCount(t *testing.T, app *App) int {
153 return fv.Len()156 return fv.Len()
154 }157 }
155 158
159+func assertWindowOwnershipInvariant(t *testing.T, app *App) {
160+ t.Helper()
161+
162+ seen := make(map[entity.TabID]string)
163+ for windowID, bw := range app.browserWindows {
164+ if bw == nil {
165+ continue
166+ }
167+ if bw.tabs == nil {
168+ t.Errorf("browser window %s has nil tabs", windowID)
169+ continue
170+ }
171+ for _, tab := range bw.tabs.Tabs {
172+ if tab == nil {
173+ continue
174+ }
175+ if previous, exists := seen[tab.ID]; exists {
176+ t.Errorf("tab %s is owned by both %s and %s", tab.ID, previous, windowID)
177+ continue
178+ }
179+ seen[tab.ID] = windowID
180+ if got := app.windowForTab[tab.ID]; got != bw {
181+ t.Errorf("windowForTab[%s] = %p, want owner window %s (%p)", tab.ID, got, windowID, bw)
182+ }
183+ }
184+ }
185+ for tabID, bw := range app.windowForTab {
186+ if bw == nil || bw.tabs == nil || bw.tabs.Find(tabID) == nil {
187+ t.Errorf("windowForTab[%s] points to stale or non-owning window %p", tabID, bw)
188+ }
189+ }
190+}
191+
156 // tabCoordinatorMainWindowPtr uses reflection to inspect unexported coordinator state in tests.192 // tabCoordinatorMainWindowPtr uses reflection to inspect unexported coordinator state in tests.
157 // Keep this test-only so production visibility stays narrow.193 // Keep this test-only so production visibility stays narrow.
158 func tabCoordinatorMainWindowPtr(t *testing.T, tc *coordinator.TabCoordinator) uintptr {194 func tabCoordinatorMainWindowPtr(t *testing.T, tc *coordinator.TabCoordinator) uintptr {
@@ -177,6 +213,24 @@ func setWindowTabBar(t *testing.T, mw *window.MainWindow, tabBar *component.TabB
177 reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(tabBar))213 reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(tabBar))
178 }214 }
179 215
216+func omniboxNavigateCallbackForTest(t *testing.T, omnibox *component.Omnibox) func(string) {
217+ t.Helper()
218+ if omnibox == nil {
219+ t.Fatal("omnibox is nil")
220+ }
221+
222+ rv := reflect.ValueOf(omnibox).Elem()
223+ fv := rv.FieldByName("onNavigate")
224+ if !fv.IsValid() {
225+ t.Fatal("Omnibox missing onNavigate field")
226+ }
227+ cb, ok := reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Interface().(func(string))
228+ if !ok || cb == nil {
229+ t.Fatal("omnibox onNavigate callback is nil or has unexpected type")
230+ }
231+ return cb
232+}
233+
180 func newTestTabBarShell(t *testing.T, tabIDs ...entity.TabID) *component.TabBar {234 func newTestTabBarShell(t *testing.T, tabIDs ...entity.TabID) *component.TabBar {
181 t.Helper()235 t.Helper()
182 236
@@ -220,6 +274,32 @@ func windowTabBarVisible(t *testing.T, mw *window.MainWindow) bool {
220 return mw.TabBar().Box().GetVisible()274 return mw.TabBar().Box().GetVisible()
221 }275 }
222 276
277+func assertWindowTabBarAutoHidden(t *testing.T, mw *window.MainWindow, wantHidden bool) {
278+ t.Helper()
279+ if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil {
280+ t.Fatal("missing tab bar")
281+ }
282+ box := mw.TabBar().Box()
283+ if got := box.GetVisible(); !got {
284+ t.Fatalf("tab bar visible = %v, want true so allocation is preserved", got)
285+ }
286+ if wantHidden {
287+ if got := box.GetOpacity(); got != 0.0 {
288+ t.Fatalf("tab bar opacity = %v, want 0", got)
289+ }
290+ if got := box.GetCanTarget(); got {
291+ t.Fatalf("tab bar can target = %v, want false while auto-hidden", got)
292+ }
293+ return
294+ }
295+ if got := box.GetOpacity(); got != 1.0 {
296+ t.Fatalf("tab bar opacity = %v, want 1", got)
297+ }
298+ if got := box.GetCanTarget(); !got {
299+ t.Fatalf("tab bar can target = %v, want true while shown", got)
300+ }
301+}
302+
223 func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool {303 func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool {
224 t.Helper()304 t.Helper()
225 if sv == nil {305 if sv == nil {
@@ -320,23 +400,102 @@ func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t
320 }400 }
321 401
322 func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {402 func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
323- created := &browserWindow{id: "window-1"}403+ existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
404+ existingTabs := entity.NewTabList()
405+ existingTabs.Add(existingTab)
406+ existingTabs.SetActive(existingTab.ID)
407+ existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs}
408+ created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
324 app := &App{409 app := &App{
325 tabs: entity.NewTabList(),410 tabs: entity.NewTabList(),
326 tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),411 tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),
327- browserWindows: make(map[string]*browserWindow),412+ browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
413+ windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
328 workspaceViews: map[entity.TabID]*component.WorkspaceView{entity.TabID("id-1"): &component.WorkspaceView{}},414 workspaceViews: map[entity.TabID]*component.WorkspaceView{entity.TabID("id-1"): &component.WorkspaceView{}},
329 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {415 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
330 return created, nil416 return created, nil
331 },417 },
332 }418 }
333- app.tabs.Add(entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
334 419
335 if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {420 if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
336 t.Fatalf("OpenFreshWindow returned error: %v", err)421 t.Fatalf("OpenFreshWindow returned error: %v", err)
337 }422 }
338- if got := windowForTabCount(t, app); got != 1 {423+ if got := windowForTabCount(t, app); got != 2 {
339- t.Fatalf("windowForTab length = %d, want 1", got)424+ t.Fatalf("windowForTab length = %d, want 2 (existing + new tab)", got)
425+ }
426+ assertWindowOwnershipInvariant(t, app)
427+}
428+
429+func TestApp_RestoreSessionHandlesEmptyWindowSnapshotAsSuccess(t *testing.T) {
430+ ctx := context.Background()
431+ sessionID := entity.SessionID("session-empty-windows")
432+ staleTab := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-workspace"), entity.NewPane(entity.PaneID("stale-pane")))
433+ staleTabs := entity.NewTabList()
434+ staleTabs.Add(staleTab)
435+ staleWindow := &browserWindow{id: "window-stale", tabs: staleTabs}
436+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))
437+
438+ app := &App{
439+ deps: &Dependencies{
440+ Config: &config.Config{},
441+ SessionStateRepo: &fakeSessionStateRepo{state: state},
442+ },
443+ tabs: entity.NewTabList(),
444+ browserWindows: map[string]*browserWindow{staleWindow.id: staleWindow},
445+ lastFocusedWindowID: staleWindow.id,
446+ workspaceViews: map[entity.TabID]*component.WorkspaceView{staleTab.ID: {}},
447+ windowForTab: map[entity.TabID]*browserWindow{staleTab.ID: staleWindow},
448+ }
449+ app.tabs.Add(staleTab)
450+
451+ err := app.restoreSession(ctx, sessionID)
452+ if err != nil {
453+ t.Fatalf("restoreSession returned error for valid v2 empty window snapshot: %v", err)
454+ }
455+ // Stale state should be cleared and stale window should have an empty tab list.
456+ if _, ok := app.workspaceViews[staleTab.ID]; ok {
457+ t.Fatal("stale workspace view was not cleared")
458+ }
459+ if _, ok := app.windowForTab[staleTab.ID]; ok {
460+ t.Fatal("stale windowForTab entry was not cleared")
461+ }
462+ if got := app.tabs.Count(); got != 0 {
463+ t.Fatalf("app.tabs.Count() = %d, want 0 after empty restore", got)
464+ }
465+ if got := staleWindow.tabs.Count(); got != 0 {
466+ t.Fatalf("staleWindow.tabs.Count() = %d, want 0 after empty restore", got)
467+ }
468+}
469+
470+func TestApp_CreateInitialTabNoFallbackAfterEmptyWindowRestore(t *testing.T) {
471+ ctx := context.Background()
472+ sessionID := entity.SessionID("session-empty-windows-fallback")
473+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))
474+ tabsUC := usecase.NewManageTabsUseCase(func() string { return "fallback-tab" })
475+ windowTabs := entity.NewTabList()
476+ bw := &browserWindow{id: "window-1", tabs: windowTabs}
477+ app := &App{
478+ deps: &Dependencies{
479+ Config: &config.Config{},
480+ RestoreSessionID: string(sessionID),
481+ InitialURL: "https://fallback.example",
482+ SessionStateRepo: &fakeSessionStateRepo{state: state},
483+ },
484+ tabs: entity.NewTabList(),
485+ tabsUC: tabsUC,
486+ tabCoord: coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: tabsUC, Tabs: entity.NewTabList()}),
487+ browserWindows: map[string]*browserWindow{bw.id: bw},
488+ lastFocusedWindowID: bw.id,
489+ }
490+
491+ app.createInitialTab(ctx)
492+
493+ // For valid v2 empty-window restore, no fallback tab should be created.
494+ if created := windowTabs.Find(entity.TabID("fallback-tab")); created != nil {
495+ t.Fatal("fallback tab was created despite valid v2 empty-window restore")
496+ }
497+ if got := app.tabs.Count(); got != 0 {
498+ t.Fatalf("app.tabs.Count() = %d, want 0 after empty restore", got)
340 }499 }
341 }500 }
342 501
@@ -394,6 +553,7 @@ func TestApp_RestoreSessionClearsStaleUIStateBeforeApplyingRestoredTabs(t *testi
394 if _, ok := app.floatingSessions[staleSessionKey]; ok {553 if _, ok := app.floatingSessions[staleSessionKey]; ok {
395 t.Fatalf("stale floating session was not removed")554 t.Fatalf("stale floating session was not removed")
396 }555 }
556+ assertWindowOwnershipInvariant(t, app)
397 }557 }
398 558
399 func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) {559 func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) {
@@ -489,40 +649,53 @@ func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T
489 }649 }
490 650
491 func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {651 func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
492- created := &browserWindow{id: "window-1"}652+ created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
493 originalWindow := &window.MainWindow{}653 originalWindow := &window.MainWindow{}
494 tabBar := &component.TabBar{}654 tabBar := &component.TabBar{}
495 setWindowTabBar(t, originalWindow, tabBar)655 setWindowTabBar(t, originalWindow, tabBar)
496 existingTabID := entity.TabID("existing-tab")656 existingTabID := entity.TabID("existing-tab")
497 staleTabID := entity.TabID("stale-tab")657 staleTabID := entity.TabID("stale-tab")
498 tabBar.SetActive(staleTabID)658 tabBar.SetActive(staleTabID)
659+
660+ existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
661+ existingTabs := entity.NewTabList()
662+ existingTabs.Add(existingTab)
663+ existingTabs.SetActive(existingTab.ID)
664+ existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs, mainWindow: originalWindow}
665+
499 app := &App{666 app := &App{
500 tabs: entity.NewTabList(),667 tabs: entity.NewTabList(),
501 tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),668 tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),
502- browserWindows: make(map[string]*browserWindow),669+ browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
670+ windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
503 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {671 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
504 return created, nil672 return created, nil
505 },673 },
506 mainWindow: originalWindow,674 mainWindow: originalWindow,
507 }675 }
508- app.tabs.Add(entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
509 app.tabCoord = coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{676 app.tabCoord = coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
510 TabsUC: app.tabsUC,677 TabsUC: app.tabsUC,
511- Tabs: app.tabs,678+ Tabs: entity.NewTabList(),
512 MainWindow: &window.MainWindow{},679 MainWindow: &window.MainWindow{},
513 })680 })
514 681
515 if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil {682 if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil {
516 t.Fatalf("OpenFreshWindow = nil error, want failure")683 t.Fatalf("OpenFreshWindow = nil error, want failure")
517 }684 }
518- if got := len(app.browserWindows); got != 0 {685+ if got := len(app.browserWindows); got != 1 {
519- t.Fatalf("browserWindows length = %d, want 0", got)686+ t.Fatalf("browserWindows length = %d, want 1 (existing window only)", got)
687+ }
688+ if app.browserWindows[existingWindow.id] == nil {
689+ t.Fatal("existing window should remain after rollback")
520 }690 }
521- if got := windowForTabCount(t, app); got != 0 {691+ if app.browserWindows["window-1"] != nil {
522- t.Fatalf("windowForTab length = %d, want 0", got)692+ t.Fatal("created window should be removed after rollback")
693+ }
694+ if got := windowForTabCount(t, app); got != 1 {
695+ t.Fatalf("windowForTab length = %d, want 1 (existing tab only)", got)
523 }696 }
524- if got := windowTabBarActiveID(t, originalWindow); got != existingTabID {697+ if got := windowTabBarActiveID(t, originalWindow); got != staleTabID {
525- t.Fatalf("tab bar active tab = %q, want %q", got, existingTabID)698+ t.Fatalf("tab bar active tab = %q, want %q (original window unchanged)", got, staleTabID)
526 }699 }
527 }700 }
528 701
@@ -536,10 +709,17 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
536 tabs := entity.NewTabList()709 tabs := entity.NewTabList()
537 tabsUC := usecase.NewManageTabsUseCase(func() string { return string(createdTabID) })710 tabsUC := usecase.NewManageTabsUseCase(func() string { return string(createdTabID) })
538 711
712+ existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
713+ existingTabs := entity.NewTabList()
714+ existingTabs.Add(existingTab)
715+ existingTabs.SetActive(existingTab.ID)
716+ existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs, mainWindow: oldWindow}
717+
539 app := &App{718 app := &App{
540 tabs: tabs,719 tabs: tabs,
541 tabsUC: tabsUC,720 tabsUC: tabsUC,
542- browserWindows: make(map[string]*browserWindow),721+ browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
722+ windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
543 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {723 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
544 return &browserWindow{id: "window-1", mainWindow: newWindow}, nil724 return &browserWindow{id: "window-1", mainWindow: newWindow}, nil
545 },725 },
@@ -552,8 +732,7 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
552 }),732 }),
553 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),733 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
554 }734 }
555- app.tabs.Add(entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))735+ app.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
556- app.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
557 app.workspaceViews[tab.ID] = &component.WorkspaceView{}736 app.workspaceViews[tab.ID] = &component.WorkspaceView{}
558 })737 })
559 738
@@ -571,9 +750,8 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
571 if got := windowTabBarActiveID(t, oldWindow); got != "" {750 if got := windowTabBarActiveID(t, oldWindow); got != "" {
572 t.Fatalf("old window tab bar active tab = %q, want empty", got)751 t.Fatalf("old window tab bar active tab = %q, want empty", got)
573 }752 }
574- if got := windowTabBarVisible(t, newWindow); got {753+ // The lightweight test tab bar shell has no GTK box, so allocation state is
575- t.Fatalf("new window tab bar visible = %v, want false", got)754+ // covered by TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation.
576- }
577 }755 }
578 756
579 func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {757 func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {
@@ -584,8 +762,14 @@ func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {
584 tabs.Add(tab2)762 tabs.Add(tab2)
585 ws1 := &component.WorkspaceView{}763 ws1 := &component.WorkspaceView{}
586 ws2 := &component.WorkspaceView{}764 ws2 := &component.WorkspaceView{}
587- first := &browserWindow{id: "window-1", activeTabID: tab1.ID, mainWindow: &window.MainWindow{}}765+ firstTabs := entity.NewTabList()
588- second := &browserWindow{id: "window-2", activeTabID: tab2.ID, mainWindow: &window.MainWindow{}}766+ firstTabs.Add(tab1)
767+ firstTabs.SetActive(tab1.ID)
768+ first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
769+ secondTabs := entity.NewTabList()
770+ secondTabs.Add(tab2)
771+ secondTabs.SetActive(tab2.ID)
772+ second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
589 kh := &input.KeyboardHandler{}773 kh := &input.KeyboardHandler{}
590 gs := &input.GlobalShortcutHandler{}774 gs := &input.GlobalShortcutHandler{}
591 second.keyboardHandler = kh775 second.keyboardHandler = kh
@@ -603,8 +787,8 @@ func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {
603 if app.mainWindow != second.mainWindow {787 if app.mainWindow != second.mainWindow {
604 t.Fatalf("mainWindow = %p, want %p", app.mainWindow, second.mainWindow)788 t.Fatalf("mainWindow = %p, want %p", app.mainWindow, second.mainWindow)
605 }789 }
606- if app.tabs.ActiveTabID != tab2.ID {790+ if second.tabs.ActiveTabID != tab2.ID {
607- t.Fatalf("active tab = %q, want %q", app.tabs.ActiveTabID, tab2.ID)791+ t.Fatalf("active tab = %q, want %q", second.tabs.ActiveTabID, tab2.ID)
608 }792 }
609 if app.activeWorkspaceView() != ws2 {793 if app.activeWorkspaceView() != ws2 {
610 t.Fatalf("active workspace view = %p, want %p", app.activeWorkspaceView(), ws2)794 t.Fatalf("active workspace view = %p, want %p", app.activeWorkspaceView(), ws2)
@@ -623,8 +807,12 @@ func TestApp_BrowserWindowForPaneFindsOwningWindow(t *testing.T) {
623 tabs := entity.NewTabList()807 tabs := entity.NewTabList()
624 tabs.Add(tab1)808 tabs.Add(tab1)
625 tabs.Add(tab2)809 tabs.Add(tab2)
626- first := &browserWindow{id: "window-1"}810+ firstTabs := entity.NewTabList()
627- second := &browserWindow{id: "window-2"}811+ firstTabs.Add(tab1)
812+ secondTabs := entity.NewTabList()
813+ secondTabs.Add(tab2)
814+ first := &browserWindow{id: "window-1", tabs: firstTabs}
815+ second := &browserWindow{id: "window-2", tabs: secondTabs}
628 app := &App{816 app := &App{
629 tabs: tabs,817 tabs: tabs,
630 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},818 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
@@ -644,8 +832,12 @@ func TestApp_OwnerOrLastFocusedBrowserWindowPrefersPaneOwner(t *testing.T) {
644 tabs := entity.NewTabList()832 tabs := entity.NewTabList()
645 tabs.Add(tab1)833 tabs.Add(tab1)
646 tabs.Add(tab2)834 tabs.Add(tab2)
647- first := &browserWindow{id: "window-1"}835+ firstTabs := entity.NewTabList()
648- second := &browserWindow{id: "window-2"}836+ firstTabs.Add(tab1)
837+ secondTabs := entity.NewTabList()
838+ secondTabs.Add(tab2)
839+ first := &browserWindow{id: "window-1", tabs: firstTabs}
840+ second := &browserWindow{id: "window-2", tabs: secondTabs}
649 app := &App{841 app := &App{
650 tabs: tabs,842 tabs: tabs,
651 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},843 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
@@ -680,8 +872,14 @@ func TestApp_HandlePaneWindowTitleChangedTargetsOwningWindow(t *testing.T) {
680 tabs := entity.NewTabList()872 tabs := entity.NewTabList()
681 tabs.Add(tab1)873 tabs.Add(tab1)
682 tabs.Add(tab2)874 tabs.Add(tab2)
683- first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}875+ firstTabs := entity.NewTabList()
684- second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}876+ firstTabs.Add(tab1)
877+ firstTabs.SetActive(tab1.ID)
878+ secondTabs := entity.NewTabList()
879+ secondTabs.Add(tab2)
880+ secondTabs.SetActive(tab2.ID)
881+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
882+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
685 app := &App{883 app := &App{
686 tabs: tabs,884 tabs: tabs,
687 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},885 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
@@ -719,8 +917,14 @@ func TestApp_ActivateBrowserWindowResyncsTitleFromBackgroundPane(t *testing.T) {
719 tabs := entity.NewTabList()917 tabs := entity.NewTabList()
720 tabs.Add(tab1)918 tabs.Add(tab1)
721 tabs.Add(tab2)919 tabs.Add(tab2)
722- first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, activeTabID: tab1.ID}920+ firstTabs := entity.NewTabList()
723- second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, activeTabID: tab2.ID}921+ firstTabs.Add(tab1)
922+ firstTabs.SetActive(tab1.ID)
923+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
924+ secondTabs := entity.NewTabList()
925+ secondTabs.Add(tab2)
926+ secondTabs.SetActive(tab2.ID)
927+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
724 contentCoord := &contentcoord.Coordinator{}928 contentCoord := &contentcoord.Coordinator{}
725 contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles")929 contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles")
726 reflect.NewAt(contentTitles.Type(), unsafe.Pointer(contentTitles.UnsafeAddr())).Elem().Set(reflect.ValueOf(map[entity.PaneID]string{tab2.Workspace.ActivePaneID: "Pane Two"}))930 reflect.NewAt(contentTitles.Type(), unsafe.Pointer(contentTitles.UnsafeAddr())).Elem().Set(reflect.ValueOf(map[entity.PaneID]string{tab2.Workspace.ActivePaneID: "Pane Two"}))
@@ -766,8 +970,14 @@ func TestApp_HandlePaneFullscreenChangedTargetsOwningWindow(t *testing.T) {
766 tabs.Add(tab2)970 tabs.Add(tab2)
767 secondMainWindow.TabBar().AddTab(tab1)971 secondMainWindow.TabBar().AddTab(tab1)
768 secondMainWindow.TabBar().AddTab(tab2)972 secondMainWindow.TabBar().AddTab(tab2)
769- first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}973+ firstTabs := entity.NewTabList()
770- second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}974+ firstTabs.Add(tab1)
975+ firstTabs.SetActive(tab1.ID)
976+ secondTabs := entity.NewTabList()
977+ secondTabs.Add(tab2)
978+ secondTabs.SetActive(tab2.ID)
979+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
980+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
771 app := &App{981 app := &App{
772 tabs: tabs,982 tabs: tabs,
773 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},983 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
@@ -851,8 +1061,14 @@ func TestApp_MoveActivePaneToTabFromBrowserWindowAnchorsOwningWindow(t *testing.
851 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))1061 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
852 tabs.Add(tab1)1062 tabs.Add(tab1)
853 tabs.Add(tab2)1063 tabs.Add(tab2)
854- first := &browserWindow{id: "window-1", activeTabID: tab1.ID}1064+ firstTabs := entity.NewTabList()
855- second := &browserWindow{id: "window-2", activeTabID: tab2.ID}1065+ firstTabs.Add(tab1)
1066+ firstTabs.SetActive(tab1.ID)
1067+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1068+ secondTabs := entity.NewTabList()
1069+ secondTabs.Add(tab2)
1070+ secondTabs.SetActive(tab2.ID)
1071+ second := &browserWindow{id: "window-2", tabs: secondTabs}
856 app := &App{1072 app := &App{
857 tabs: tabs,1073 tabs: tabs,
858 movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }),1074 movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }),
@@ -882,8 +1098,14 @@ func TestApp_MoveActivePaneToTabFromBrowserWindowActivatesTargetOwnerForCrossWin
882 tabs.Add(tab2)1098 tabs.Add(tab2)
883 tabs.Add(tab3)1099 tabs.Add(tab3)
884 tabs.SetActive(tab1.ID)1100 tabs.SetActive(tab1.ID)
885- first := &browserWindow{id: "window-1", activeTabID: tab1.ID}1101+ firstTabs := entity.NewTabList()
886- second := &browserWindow{id: "window-2", activeTabID: tab2.ID}1102+ firstTabs.Add(tab1)
1103+ firstTabs.SetActive(tab1.ID)
1104+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1105+ secondTabs := entity.NewTabList()
1106+ secondTabs.Add(tab2)
1107+ secondTabs.SetActive(tab2.ID)
1108+ second := &browserWindow{id: "window-2", tabs: secondTabs}
887 app := &App{1109 app := &App{
888 tabs: tabs,1110 tabs: tabs,
889 movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),1111 movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),
@@ -914,6 +1136,65 @@ func TestApp_MoveActivePaneToTabFromBrowserWindowActivatesTargetOwnerForCrossWin
914 }1136 }
915 }1137 }
916 1138
1139+func TestApp_MoveActivePaneToTabFromBrowserWindowCrossWindowDerivedMirror(t *testing.T) {
1140+ // app.tabs initially contains ONLY source tab (not target tab) to simulate
1141+ // a target created only in browserWindow.tabs (the real-world scenario that
1142+ // CodeRabbit flagged). The fix ensures buildMovePaneToTabInput syncs the
1143+ // derived global mirror before passing a.tabs to the move usecase.
1144+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1145+ tab3 := entity.NewTab(entity.TabID("tab-3"), entity.WorkspaceID("workspace-3"), entity.NewPane(entity.PaneID("pane-3")))
1146+
1147+ // app.tabs contains only tab1 — tab3 is absent from the mirror.
1148+ tabs := entity.NewTabList()
1149+ tabs.Add(tab1)
1150+ tabs.SetActive(tab1.ID)
1151+
1152+ firstTabs := entity.NewTabList()
1153+ firstTabs.Add(tab1)
1154+ firstTabs.SetActive(tab1.ID)
1155+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1156+
1157+ secondTabs := entity.NewTabList()
1158+ secondTabs.Add(tab3)
1159+ secondTabs.SetActive(tab3.ID)
1160+ second := &browserWindow{id: "window-2", tabs: secondTabs}
1161+
1162+ initialPaneCount := tab3.Workspace.PaneCount()
1163+
1164+ app := &App{
1165+ tabs: tabs,
1166+ movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),
1167+ deps: &Dependencies{Config: &config.Config{}},
1168+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1169+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab3.ID: second},
1170+ lastFocusedWindowID: first.id,
1171+ }
1172+
1173+ if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, tab3.ID); err != nil {
1174+ t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
1175+ }
1176+
1177+ // The derived mirror must now contain tab3 (synced by the fix).
1178+ if app.tabs.Find(tab3.ID) == nil {
1179+ t.Fatalf("tab-3 should have been synced into the derived global mirror")
1180+ }
1181+
1182+ // No new tab should have been generated — the move should target tab3.
1183+ if app.tabs.Find(entity.TabID("generated")) != nil {
1184+ t.Fatalf("a new tab was incorrectly generated; the move should target tab-3")
1185+ }
1186+
1187+ // The moved pane should be in tab3's workspace.
1188+ if got := tab3.Workspace.PaneCount(); got != initialPaneCount+1 {
1189+ t.Fatalf("tab-3 pane count = %d, want %d (original + moved pane)", got, initialPaneCount+1)
1190+ }
1191+
1192+ // windowForTab for tab3 must remain second.
1193+ if app.windowForTab[tab3.ID] != second {
1194+ t.Fatalf("windowForTab[tab-3] = %p, want second window %p", app.windowForTab[tab3.ID], second)
1195+ }
1196+}
1197+
917 func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) {1198 func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) {
918 gtkApp := requireGTKDisplayApp(t)1199 gtkApp := requireGTKDisplayApp(t)
919 defer gtkApp.Unref()1200 defer gtkApp.Unref()
@@ -936,7 +1217,7 @@ func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T)
936 deps: &Dependencies{Config: &config.Config{}},1217 deps: &Dependencies{Config: &config.Config{}},
937 mainWindow: mainWindow,1218 mainWindow: mainWindow,
938 browserWindows: map[string]*browserWindow{bw.id: bw},1219 browserWindows: map[string]*browserWindow{bw.id: bw},
939- kbDispatcher: dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, nil, "", func(context.Context) entity.PaneID { return "" }),1220+ kbDispatcher: dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, dispatcher.KeyboardActions{}, func(context.Context) entity.PaneID { return "" }),
940 }1221 }
941 1222
942 app.initKeyboardHandler(context.Background())1223 app.initKeyboardHandler(context.Background())
@@ -974,7 +1255,7 @@ type testFocusedInputProvider struct{}
974 func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil }1255 func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil }
975 func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget) {}1256 func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget) {}
976 1257
977-func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {1258+func TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation(t *testing.T) {
978 gtkApp := requireGTKDisplayApp(t)1259 gtkApp := requireGTKDisplayApp(t)
979 defer gtkApp.Unref()1260 defer gtkApp.Unref()
980 1261
@@ -983,7 +1264,48 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(
983 if tabBar == nil {1264 if tabBar == nil {
984 t.Fatal("tab bar creation failed")1265 t.Fatal("tab bar creation failed")
985 }1266 }
1267+ setWindowTabBar(t, mainWindow, tabBar)
1268+ bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
1269+ app := &App{
1270+ deps: &Dependencies{Config: &config.Config{}},
1271+ }
1272+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1273+
1274+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1275+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1276+ tabBar.AddTab(tab1)
1277+
1278+ app.updateBrowserWindowTabBarVisibility(bw)
1279+ assertWindowTabBarAutoHidden(t, mainWindow, true)
1280+
1281+ tabBar.AddTab(tab2)
1282+ app.updateBrowserWindowTabBarVisibility(bw)
1283+ assertWindowTabBarAutoHidden(t, mainWindow, false)
1284+
986 tabBar.SetVisible(false)1285 tabBar.SetVisible(false)
1286+ app.updateBrowserWindowTabBarVisibility(bw)
1287+ if got := tabBar.Box().GetVisible(); got {
1288+ t.Fatalf("tab bar visible = %v, want false while explicitly hidden", got)
1289+ }
1290+
1291+ tabBar.SetVisible(true)
1292+ app.updateBrowserWindowTabBarVisibility(bw)
1293+ assertWindowTabBarAutoHidden(t, mainWindow, false)
1294+
1295+ tabBar.RemoveTab(tab2.ID)
1296+ app.updateBrowserWindowTabBarVisibility(bw)
1297+ assertWindowTabBarAutoHidden(t, mainWindow, true)
1298+}
1299+
1300+func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {
1301+ gtkApp := requireGTKDisplayApp(t)
1302+ defer gtkApp.Unref()
1303+
1304+ mainWindow := &window.MainWindow{}
1305+ tabBar := component.NewTabBar()
1306+ if tabBar == nil {
1307+ t.Fatal("tab bar creation failed")
1308+ }
987 setWindowTabBar(t, mainWindow, tabBar)1309 setWindowTabBar(t, mainWindow, tabBar)
988 bw := &browserWindow{id: "window-1", mainWindow: mainWindow}1310 bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
989 app := &App{1311 app := &App{
@@ -993,8 +1315,141 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(
993 1315
994 app.updateBrowserWindowTabBarVisibility(bw)1316 app.updateBrowserWindowTabBarVisibility(bw)
995 1317
996- if got := windowTabBarVisible(t, mainWindow); !got {1318+ assertWindowTabBarAutoHidden(t, mainWindow, false)
997- t.Fatalf("tab bar visible = %v, want true", got)1319+}
1320+
1321+func TestApp_ActivePaneIDForNilBrowserWindowIgnoresStaleOverride(t *testing.T) {
1322+ ctx := context.Background()
1323+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1324+ contentCoord.SetActivePaneOverride(entity.PaneID("stale-pane"))
1325+ app := &App{contentCoord: contentCoord}
1326+
1327+ if got := app.activePaneIDForBrowserWindow(nil); got != "" {
1328+ t.Fatalf("active pane for nil browser window = %q, want empty", got)
1329+ }
1330+}
1331+
1332+func TestApp_BuildRestoredWindowUIUpdatesEmptyWindowTabBarVisibility(t *testing.T) {
1333+ gtkApp := requireGTKDisplayApp(t)
1334+ defer gtkApp.Unref()
1335+
1336+ mw, err := window.New(context.Background(), gtkApp, "bottom")
1337+ if err != nil {
1338+ t.Fatalf("window creation failed: %v", err)
1339+ }
1340+ defer mw.Destroy()
1341+
1342+ mw.SetTabBarContentInsetVisible(true)
1343+ bw := &browserWindow{id: "window-1", mainWindow: mw, tabs: entity.NewTabList()}
1344+ app := &App{deps: &Dependencies{Config: &config.Config{}}}
1345+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1346+
1347+ app.buildRestoredWindowUI(context.Background(), []*browserWindow{bw})
1348+
1349+ assertWindowTabBarAutoHidden(t, mw, true)
1350+ if mw.HasTabBarContentInset() {
1351+ t.Fatal("empty restored bottom window should not keep tab bar content inset")
1352+ }
1353+}
1354+
1355+func TestApp_UpdateBrowserWindowTabBarVisibility_BottomInset(t *testing.T) {
1356+ gtkApp := requireGTKDisplayApp(t)
1357+ defer gtkApp.Unref()
1358+
1359+ // Use a real bottom MainWindow so SetTabBarContentInsetVisible is effective.
1360+ mainWindow, err := window.New(context.Background(), gtkApp, "bottom")
1361+ if err != nil {
1362+ t.Fatalf("window creation failed: %v", err)
1363+ }
1364+ defer mainWindow.Destroy()
1365+
1366+ bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
1367+ app := &App{
1368+ deps: &Dependencies{Config: &config.Config{}},
1369+ }
1370+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1371+
1372+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1373+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1374+
1375+ // One tab + hide enabled: auto-hidden and no inset
1376+ mainWindow.TabBar().AddTab(tab1)
1377+ app.updateBrowserWindowTabBarVisibility(bw)
1378+ assertWindowTabBarAutoHidden(t, mainWindow, true)
1379+ if mainWindow.HasTabBarContentInset() {
1380+ t.Fatal("one tab: expected no content area inset")
1381+ }
1382+
1383+ // Two tabs: visible and inset present
1384+ mainWindow.TabBar().AddTab(tab2)
1385+ app.updateBrowserWindowTabBarVisibility(bw)
1386+ assertWindowTabBarAutoHidden(t, mainWindow, false)
1387+ if !mainWindow.HasTabBarContentInset() {
1388+ t.Fatal("two tabs: expected content area inset")
1389+ }
1390+
1391+ // Remove back to one tab: auto-hidden and no inset
1392+ mainWindow.TabBar().RemoveTab(tab2.ID)
1393+ app.updateBrowserWindowTabBarVisibility(bw)
1394+ assertWindowTabBarAutoHidden(t, mainWindow, true)
1395+ if mainWindow.HasTabBarContentInset() {
1396+ t.Fatal("back to one tab: expected no content area inset")
1397+ }
1398+}
1399+
1400+func TestApp_HandlePaneFullscreenChanged_ClearsBottomInset(t *testing.T) {
1401+ gtkApp := requireGTKDisplayApp(t)
1402+ defer gtkApp.Unref()
1403+
1404+ // Use a bottom MainWindow so SetTabBarContentInsetVisible is effective.
1405+ mainWindow, err := window.New(context.Background(), gtkApp, "bottom")
1406+ if err != nil {
1407+ t.Fatalf("window creation failed: %v", err)
1408+ }
1409+ defer mainWindow.Destroy()
1410+
1411+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1412+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1413+ mainWindow.TabBar().AddTab(tab1)
1414+ mainWindow.TabBar().AddTab(tab2)
1415+
1416+ tabs := entity.NewTabList()
1417+ tabs.Add(tab1)
1418+ tabs.Add(tab2)
1419+ tabs.SetActive(tab2.ID)
1420+
1421+ bw := &browserWindow{id: "window-1", mainWindow: mainWindow, tabs: tabs}
1422+ app := &App{
1423+ tabs: tabs,
1424+ deps: &Dependencies{Config: &config.Config{}},
1425+ browserWindows: map[string]*browserWindow{bw.id: bw},
1426+ lastFocusedWindowID: bw.id,
1427+ }
1428+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1429+
1430+ // Start with two tabs: inset should be present
1431+ app.updateBrowserWindowTabBarVisibility(bw)
1432+ assertWindowTabBarAutoHidden(t, mainWindow, false)
1433+ if !mainWindow.HasTabBarContentInset() {
1434+ t.Fatal("two tabs: expected content area inset before fullscreen")
1435+ }
1436+
1437+ // Enter fullscreen: tab bar hidden, inset cleared
1438+ app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true)
1439+ if windowTabBarVisible(t, mainWindow) {
1440+ t.Fatal("fullscreen: tab bar should be not visible")
1441+ }
1442+ if mainWindow.HasTabBarContentInset() {
1443+ t.Fatal("fullscreen: expected no content area inset")
1444+ }
1445+
1446+ // Exit fullscreen: tab bar restored, inset restored
1447+ app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false)
1448+ if !windowTabBarVisible(t, mainWindow) {
1449+ t.Fatal("exited fullscreen: tab bar should be visible")
1450+ }
1451+ if !mainWindow.HasTabBarContentInset() {
1452+ t.Fatal("exited fullscreen: expected content area inset to be restored")
998 }1453 }
999 }1454 }
1000 1455
@@ -1012,16 +1467,17 @@ func TestApp_RemoveBrowserWindowPromotesDeterministicFallbackWithMainWindow(t *t
1012 firstValid.id: firstValid,1467 firstValid.id: firstValid,
1013 secondValid.id: secondValid,1468 secondValid.id: secondValid,
1014 },1469 },
1470+ browserWindowOrder: []string{removed.id, nilWindow.id, secondValid.id, firstValid.id},
1015 lastFocusedWindowID: removed.id,1471 lastFocusedWindowID: removed.id,
1016 }1472 }
1017 1473
1018 app.removeBrowserWindow(removed.id)1474 app.removeBrowserWindow(removed.id)
1019 1475
1020- if app.mainWindow != firstValid.mainWindow {1476+ if app.mainWindow != secondValid.mainWindow {
1021- t.Fatalf("mainWindow = %p, want %p", app.mainWindow, firstValid.mainWindow)1477+ t.Fatalf("mainWindow = %p, want %p", app.mainWindow, secondValid.mainWindow)
1022 }1478 }
1023- if app.lastFocusedWindowID != firstValid.id {1479+ if app.lastFocusedWindowID != secondValid.id {
1024- t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, firstValid.id)1480+ t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, secondValid.id)
1025 }1481 }
1026 }1482 }
1027 }1483 }
@@ -1070,6 +1526,52 @@ func TestApp_LastFocusedBrowserWindowFallsBackWhenOwnerMissing(t *testing.T) {
1070 }1526 }
1071 }1527 }
1072 1528
1529+func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) {
1530+ ctx := context.Background()
1531+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1532+ secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1533+ globalTabs := entity.NewTabList()
1534+ globalTabs.Add(firstTab)
1535+ globalTabs.Add(secondTab)
1536+ firstTabs := entity.NewTabList()
1537+ firstTabs.Add(firstTab)
1538+ secondTabs := entity.NewTabList()
1539+ secondTabs.Add(secondTab)
1540+ first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1541+ second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1542+ app := &App{
1543+ deps: &Dependencies{Config: &config.Config{}},
1544+ tabs: globalTabs,
1545+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "popup-tab" }),
1546+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1547+ windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
1548+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
1549+ lastFocusedWindowID: first.id,
1550+ mainWindow: first.mainWindow,
1551+ }
1552+ app.initTabCoordinator(ctx)
1553+
1554+ err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{
1555+ ParentPaneID: secondTab.Workspace.ActivePaneID,
1556+ PopupPane: entity.NewPane(entity.PaneID("popup-pane")),
1557+ TargetURI: "https://example.com/popup",
1558+ })
1559+ if err != nil {
1560+ t.Fatalf("createPopupTab returned error: %v", err)
1561+ }
1562+
1563+ if first.tabs.Find(entity.TabID("popup-tab")) != nil {
1564+ t.Fatalf("popup tab was added to stale focused window")
1565+ }
1566+ created := second.tabs.Find(entity.TabID("popup-tab"))
1567+ if created == nil {
1568+ t.Fatalf("popup tab was not added to parent pane owner window")
1569+ }
1570+ if got := app.windowForTab[created.ID]; got != second {
1571+ t.Fatalf("popup tab owner = %p, want %p", got, second)
1572+ }
1573+}
1574+
1073 func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {1575 func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
1074 first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}1576 first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
1075 second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}1577 second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
@@ -1085,3 +1587,916 @@ func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
1085 t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id)1587 t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id)
1086 }1588 }
1087 }1589 }
1590+
1591+func TestApp_TabCoordinatorCallbacksUseTargetWindowWhenFocusIsStale(t *testing.T) {
1592+ ctx := context.Background()
1593+ firstTabs := entity.NewTabList()
1594+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1595+ firstTabs.Add(firstTab)
1596+ secondTabs := entity.NewTabList()
1597+ secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1598+ secondTabs.Add(secondTab)
1599+
1600+ first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1601+ second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1602+ app := &App{
1603+ deps: &Dependencies{Config: &config.Config{}},
1604+ tabs: entity.NewTabList(),
1605+ tabsUC: usecase.NewManageTabsUseCase(func() string { return "created-tab" }),
1606+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1607+ lastFocusedWindowID: first.id,
1608+ mainWindow: first.mainWindow,
1609+ windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
1610+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
1611+ }
1612+ app.tabs.Add(firstTab)
1613+ app.tabs.Add(secondTab)
1614+ app.initTabCoordinator(ctx)
1615+
1616+ created, err := app.tabCoord.Create(ctx, app.tabTargetForBrowserWindow(second), "https://example.com")
1617+ if err != nil {
1618+ t.Fatalf("Create returned error: %v", err)
1619+ }
1620+ if got := app.windowForTab[created.ID]; got != second {
1621+ t.Fatalf("created tab owner = %p, want second window %p", got, second)
1622+ }
1623+ if app.mainWindow != second.mainWindow {
1624+ t.Fatalf("mainWindow after create switch = %p, want %p", app.mainWindow, second.mainWindow)
1625+ }
1626+
1627+ app.mainWindow = first.mainWindow
1628+ app.lastFocusedWindowID = first.id
1629+ if err := app.tabCoord.Switch(ctx, app.tabTargetForBrowserWindow(second), secondTab.ID); err != nil {
1630+ t.Fatalf("Switch returned error: %v", err)
1631+ }
1632+ if app.mainWindow != second.mainWindow {
1633+ t.Fatalf("mainWindow after target switch = %p, want %p", app.mainWindow, second.mainWindow)
1634+ }
1635+}
1636+
1637+func TestApp_ActivateBrowserWindowAddsGlobalSnapshotWithoutChangingWindowTabPosition(t *testing.T) {
1638+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1639+ secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1640+ firstTabs := entity.NewTabList()
1641+ firstTabs.Add(firstTab)
1642+ secondTabs := entity.NewTabList()
1643+ secondTabs.Add(secondTab)
1644+ secondTabs.SetActive(secondTab.ID)
1645+ first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
1646+ second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
1647+ globalTabs := entity.NewTabList()
1648+ globalTabs.Add(firstTab)
1649+ app := &App{
1650+ tabs: globalTabs,
1651+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1652+ }
1653+
1654+ app.activateBrowserWindow(second)
1655+
1656+ if secondTab.Position != 0 {
1657+ t.Fatalf("window tab position = %d, want 0", secondTab.Position)
1658+ }
1659+ globalSecond := app.tabs.Find(secondTab.ID)
1660+ if globalSecond == nil {
1661+ t.Fatalf("global tab snapshot was not added")
1662+ }
1663+ if globalSecond == secondTab {
1664+ t.Fatalf("global tab should be a snapshot, not the live per-window tab")
1665+ }
1666+ if globalSecond.Position != 1 {
1667+ t.Fatalf("global tab position = %d, want 1", globalSecond.Position)
1668+ }
1669+}
1670+
1671+func TestApp_ActiveWorkspaceRequiresFocusedBrowserWindow(t *testing.T) {
1672+ globalTab := entity.NewTab(entity.TabID("global-tab"), entity.WorkspaceID("ws-global"), entity.NewPane(entity.PaneID("pane-global")))
1673+ globalTabs := entity.NewTabList()
1674+ globalTabs.Add(globalTab)
1675+ globalTabs.SetActive(globalTab.ID)
1676+ app := &App{
1677+ tabs: globalTabs,
1678+ browserWindows: map[string]*browserWindow{},
1679+ workspaceViews: map[entity.TabID]*component.WorkspaceView{globalTab.ID: {}},
1680+ }
1681+
1682+ if got := app.activeWorkspace(); got != nil {
1683+ t.Fatalf("activeWorkspace() = %p, want nil without focused browser window", got)
1684+ }
1685+ if got := app.activeWorkspaceView(); got != nil {
1686+ t.Fatalf("activeWorkspaceView() = %p, want nil without focused browser window", got)
1687+ }
1688+}
1689+
1690+func TestApp_ActiveWorkspaceUsesLastFocusedWindowTabsNotGlobalActiveTab(t *testing.T) {
1691+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
1692+ secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
1693+ firstTabs := entity.NewTabList()
1694+ firstTabs.Add(firstTab)
1695+ firstTabs.SetActive(firstTab.ID)
1696+ secondTabs := entity.NewTabList()
1697+ secondTabs.Add(secondTab)
1698+ secondTabs.SetActive(secondTab.ID)
1699+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1700+ second := &browserWindow{id: "window-2", tabs: secondTabs}
1701+ globalTabs := entity.NewTabList()
1702+ globalTabs.Add(firstTab)
1703+ globalTabs.Add(secondTab)
1704+ globalTabs.SetActive(firstTab.ID)
1705+ secondView := &component.WorkspaceView{}
1706+ app := &App{
1707+ tabs: globalTabs,
1708+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1709+ lastFocusedWindowID: second.id,
1710+ workspaceViews: map[entity.TabID]*component.WorkspaceView{secondTab.ID: secondView},
1711+ }
1712+
1713+ if got := app.activeWorkspace(); got != secondTab.Workspace {
1714+ t.Fatalf("activeWorkspace() = %p, want second workspace %p", got, secondTab.Workspace)
1715+ }
1716+ if got := app.activeWorkspaceView(); got != secondView {
1717+ t.Fatalf("activeWorkspaceView() = %p, want second view %p", got, secondView)
1718+ }
1719+}
1720+
1721+// fakeRecordingWebView is a complete port.WebView that records which navigation
1722+// methods were called and with what arguments. It does not use reflect or unsafe.
1723+type fakeRecordingWebView struct {
1724+ id port.WebViewID
1725+
1726+ loadURICalled bool
1727+ loadURILastURI string
1728+ reloadCalls int
1729+ reloadBypassCacheCalls int
1730+ stopCalls int
1731+ goBackCalls int
1732+ goForwardCalls int
1733+ loadHTMLCalls int
1734+ setZoomLevelCalls int
1735+ setZoomLevelLastLevel float64
1736+ openDevToolsCalls int
1737+ printPageCalls int
1738+}
1739+
1740+func (f *fakeRecordingWebView) ID() port.WebViewID { return f.id }
1741+
1742+func (f *fakeRecordingWebView) LoadURI(_ context.Context, uri string) error {
1743+ f.loadURICalled = true
1744+ f.loadURILastURI = uri
1745+ return nil
1746+}
1747+
1748+func (f *fakeRecordingWebView) LoadHTML(_ context.Context, _, _ string) error {
1749+ f.loadHTMLCalls++
1750+ return nil
1751+}
1752+
1753+func (f *fakeRecordingWebView) Reload(_ context.Context) error {
1754+ f.reloadCalls++
1755+ return nil
1756+}
1757+
1758+func (f *fakeRecordingWebView) ReloadBypassCache(_ context.Context) error {
1759+ f.reloadBypassCacheCalls++
1760+ return nil
1761+}
1762+
1763+func (f *fakeRecordingWebView) Stop(_ context.Context) error {
1764+ f.stopCalls++
1765+ return nil
1766+}
1767+
1768+func (f *fakeRecordingWebView) GoBack(_ context.Context) error {
1769+ f.goBackCalls++
1770+ return nil
1771+}
1772+
1773+func (f *fakeRecordingWebView) GoForward(_ context.Context) error {
1774+ f.goForwardCalls++
1775+ return nil
1776+}
1777+
1778+func (f *fakeRecordingWebView) State() port.WebViewState { return port.WebViewState{} }
1779+
1780+func (f *fakeRecordingWebView) URI() string { return f.loadURILastURI }
1781+func (f *fakeRecordingWebView) Title() string { return "" }
1782+
1783+func (f *fakeRecordingWebView) IsLoading() bool { return false }
1784+func (f *fakeRecordingWebView) EstimatedProgress() float64 { return 0 }
1785+func (f *fakeRecordingWebView) CanGoBack() bool { return false }
1786+func (f *fakeRecordingWebView) CanGoForward() bool { return false }
1787+
1788+func (f *fakeRecordingWebView) SetZoomLevel(_ context.Context, level float64) error {
1789+ f.setZoomLevelCalls++
1790+ f.setZoomLevelLastLevel = level
1791+ return nil
1792+}
1793+
1794+func (f *fakeRecordingWebView) OpenDevTools() { f.openDevToolsCalls++ }
1795+func (f *fakeRecordingWebView) PrintPage() { f.printPageCalls++ }
1796+
1797+func (f *fakeRecordingWebView) GetZoomLevel() float64 { return 1.0 }
1798+func (f *fakeRecordingWebView) GetFindController() port.FindController { return nil }
1799+func (f *fakeRecordingWebView) SetCallbacks(_ *port.WebViewCallbacks) {}
1800+func (f *fakeRecordingWebView) RunJavaScript(_ context.Context, _ string) {}
1801+func (f *fakeRecordingWebView) SetBackgroundColor(_, _, _, _ float64) {}
1802+func (f *fakeRecordingWebView) ResetBackgroundToDefault() {}
1803+func (f *fakeRecordingWebView) Favicon() port.Texture { return nil }
1804+func (f *fakeRecordingWebView) Generation() uint64 { return 0 }
1805+func (f *fakeRecordingWebView) IsFullscreen() bool { return false }
1806+func (f *fakeRecordingWebView) IsPlayingAudio() bool { return false }
1807+func (f *fakeRecordingWebView) IsDestroyed() bool { return false }
1808+func (f *fakeRecordingWebView) Destroy() {}
1809+
1810+type fakeZoomRepo struct{}
1811+
1812+func (f *fakeZoomRepo) Get(context.Context, string) (*entity.ZoomLevel, error) { return nil, nil }
1813+func (f *fakeZoomRepo) Set(context.Context, *entity.ZoomLevel) error { return nil }
1814+func (f *fakeZoomRepo) Delete(context.Context, string) error { return nil }
1815+func (f *fakeZoomRepo) GetAll(context.Context) ([]*entity.ZoomLevel, error) { return nil, nil }
1816+
1817+func TestApp_BrowserWindowWebViewActionsIgnoreStaleFocusedWindow(t *testing.T) {
1818+ ctx := context.Background()
1819+
1820+ // Build two browser windows with independent bw.tabs and active tabs/panes.
1821+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
1822+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
1823+
1824+ firstTabs := entity.NewTabList()
1825+ firstTabs.Add(tab1)
1826+ firstTabs.SetActive(tab1.ID)
1827+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1828+
1829+ secondTabs := entity.NewTabList()
1830+ secondTabs.Add(tab2)
1831+ secondTabs.SetActive(tab2.ID)
1832+ second := &browserWindow{id: "window-2", tabs: secondTabs}
1833+
1834+ // Create fake recording webviews.
1835+ fakeWv1 := &fakeRecordingWebView{id: 1}
1836+ fakeWv2 := &fakeRecordingWebView{id: 2}
1837+
1838+ // Create content coordinator with initialized internal maps to avoid
1839+ // nil-map panics in SetNavigationOrigin during NavigateWebView calls.
1840+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
1841+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
1842+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
1843+
1844+ // Create NavigationCoordinator (no navigateUC needed for direct webview calls).
1845+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
1846+
1847+ app := &App{
1848+ tabs: entity.NewTabList(),
1849+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1850+ lastFocusedWindowID: first.id, // stale! should NOT be used
1851+ contentCoord: contentCoord,
1852+ navCoord: navCoord,
1853+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
1854+ tab1.ID: &component.WorkspaceView{},
1855+ tab2.ID: &component.WorkspaceView{},
1856+ },
1857+ }
1858+ app.tabs.Add(tab1)
1859+ app.tabs.Add(tab2)
1860+
1861+ // Call window-scoped app helpers for the second window.
1862+ if err := app.navigateFromBrowserWindow(ctx, second, "https://google.com"); err != nil {
1863+ t.Fatalf("navigateFromBrowserWindow returned error: %v", err)
1864+ }
1865+ if err := app.reloadBrowserWindow(ctx, second, false); err != nil {
1866+ t.Fatalf("reloadBrowserWindow (soft) returned error: %v", err)
1867+ }
1868+ if err := app.reloadBrowserWindow(ctx, second, true); err != nil {
1869+ t.Fatalf("reloadBrowserWindow (hard) returned error: %v", err)
1870+ }
1871+ if err := app.stopBrowserWindow(ctx, second); err != nil {
1872+ t.Fatalf("stopBrowserWindow returned error: %v", err)
1873+ }
1874+ if err := app.goBackBrowserWindow(ctx, second); err != nil {
1875+ t.Fatalf("goBackBrowserWindow returned error: %v", err)
1876+ }
1877+ if err := app.goForwardBrowserWindow(ctx, second); err != nil {
1878+ t.Fatalf("goForwardBrowserWindow returned error: %v", err)
1879+ }
1880+
1881+ assertRecordingWebViewIdle(t, "first", fakeWv1)
1882+ assertRecordingWebViewActions(t, "second", fakeWv2, "https://google.com")
1883+}
1884+
1885+func assertRecordingWebViewIdle(t *testing.T, name string, wv *fakeRecordingWebView) {
1886+ t.Helper()
1887+ if wv.loadURICalled || wv.loadURILastURI != "" {
1888+ t.Errorf("%s webview LoadURI was called, expected zero calls", name)
1889+ }
1890+ if wv.reloadCalls > 0 {
1891+ t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls)
1892+ }
1893+ if wv.reloadBypassCacheCalls > 0 {
1894+ t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls)
1895+ }
1896+ if wv.stopCalls > 0 {
1897+ t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls)
1898+ }
1899+ if wv.goBackCalls > 0 {
1900+ t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls)
1901+ }
1902+ if wv.goForwardCalls > 0 {
1903+ t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls)
1904+ }
1905+}
1906+
1907+func assertRecordingWebViewActions(t *testing.T, name string, wv *fakeRecordingWebView, wantURI string) {
1908+ t.Helper()
1909+ if !wv.loadURICalled {
1910+ t.Errorf("%s webview LoadURI was not called", name)
1911+ }
1912+ if wv.loadURILastURI != wantURI {
1913+ t.Errorf("%s webview LoadURI URI = %q, want %q", name, wv.loadURILastURI, wantURI)
1914+ }
1915+ if wv.reloadCalls != 1 {
1916+ t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls)
1917+ }
1918+ if wv.reloadBypassCacheCalls != 1 {
1919+ t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls)
1920+ }
1921+ if wv.stopCalls != 1 {
1922+ t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls)
1923+ }
1924+ if wv.goBackCalls != 1 {
1925+ t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls)
1926+ }
1927+ if wv.goForwardCalls != 1 {
1928+ t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls)
1929+ }
1930+}
1931+
1932+func assertRecordingWebViewBrowserActionsIdle(t *testing.T, name string, wv *fakeRecordingWebView) {
1933+ t.Helper()
1934+ if wv.reloadCalls > 0 {
1935+ t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls)
1936+ }
1937+ if wv.reloadBypassCacheCalls > 0 {
1938+ t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls)
1939+ }
1940+ if wv.stopCalls > 0 {
1941+ t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls)
1942+ }
1943+ if wv.goBackCalls > 0 {
1944+ t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls)
1945+ }
1946+ if wv.goForwardCalls > 0 {
1947+ t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls)
1948+ }
1949+ if wv.printPageCalls > 0 {
1950+ t.Errorf("%s webview PrintPage calls = %d, want 0", name, wv.printPageCalls)
1951+ }
1952+ if wv.openDevToolsCalls > 0 {
1953+ t.Errorf("%s webview OpenDevTools calls = %d, want 0", name, wv.openDevToolsCalls)
1954+ }
1955+ if wv.setZoomLevelCalls > 0 {
1956+ t.Errorf("%s webview SetZoomLevel calls = %d, want 0", name, wv.setZoomLevelCalls)
1957+ }
1958+}
1959+
1960+func assertRecordingWebViewBrowserActionsCalledOnce(t *testing.T, name string, wv *fakeRecordingWebView) {
1961+ t.Helper()
1962+ if wv.reloadCalls != 1 {
1963+ t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls)
1964+ }
1965+ if wv.reloadBypassCacheCalls != 1 {
1966+ t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls)
1967+ }
1968+ if wv.stopCalls != 1 {
1969+ t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls)
1970+ }
1971+ if wv.goBackCalls != 1 {
1972+ t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls)
1973+ }
1974+ if wv.goForwardCalls != 1 {
1975+ t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls)
1976+ }
1977+ if wv.printPageCalls != 1 {
1978+ t.Errorf("%s webview PrintPage calls = %d, want 1", name, wv.printPageCalls)
1979+ }
1980+ if wv.openDevToolsCalls != 1 {
1981+ t.Errorf("%s webview OpenDevTools calls = %d, want 1", name, wv.openDevToolsCalls)
1982+ }
1983+ if wv.setZoomLevelCalls != 1 {
1984+ t.Errorf("%s webview SetZoomLevel calls = %d, want 1", name, wv.setZoomLevelCalls)
1985+ }
1986+}
1987+
1988+func TestApp_OmniboxNavigateCallbackCapturesBrowserWindow(t *testing.T) {
1989+ second := &browserWindow{id: "window-2"}
1990+
1991+ var capturedBW *browserWindow
1992+ var capturedURL string
1993+ testNavigate := func(_ context.Context, bw *browserWindow, url string) error {
1994+ capturedBW = bw
1995+ capturedURL = url
1996+ return nil
1997+ }
1998+
1999+ ctx := context.Background()
2000+ cb := omniboxNavigateForBrowserWindow(ctx, second, testNavigate)
2001+ cb("https://google.com")
2002+
2003+ if capturedBW != second {
2004+ capturedID := "<nil>"
2005+ if capturedBW != nil {
2006+ capturedID = capturedBW.id
2007+ }
2008+ t.Errorf("captured browser window = %p (id=%s), want %p (id=%s)", capturedBW, capturedID, second, second.id)
2009+ }
2010+ if capturedURL != "https://google.com" {
2011+ t.Errorf("captured URL = %q, want %q", capturedURL, "https://google.com")
2012+ }
2013+}
2014+
2015+func TestApp_DispatchBrowserWindowActionUsesSourceWindow(t *testing.T) {
2016+ ctx := context.Background()
2017+
2018+ // Build two browser windows with independent bw.tabs and active panes.
2019+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2020+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
2021+
2022+ firstTabs := entity.NewTabList()
2023+ firstTabs.Add(tab1)
2024+ firstTabs.SetActive(tab1.ID)
2025+ first := &browserWindow{id: "window-1", tabs: firstTabs}
2026+
2027+ secondTabs := entity.NewTabList()
2028+ secondTabs.Add(tab2)
2029+ secondTabs.SetActive(tab2.ID)
2030+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2031+
2032+ // Create fake recording webviews.
2033+ fakeWv1 := &fakeRecordingWebView{id: 1, loadURILastURI: "https://first.example"}
2034+ fakeWv2 := &fakeRecordingWebView{id: 2, loadURILastURI: "https://second.example"}
2035+
2036+ // Register fake webviews in content coordinator.
2037+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2038+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
2039+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
2040+
2041+ // Create navCoord (no navigateUC needed for direct webview calls).
2042+ navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord)
2043+
2044+ app := &App{
2045+ tabs: entity.NewTabList(),
2046+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2047+ lastFocusedWindowID: first.id, // stale! should NOT be used
2048+ contentCoord: contentCoord,
2049+ navCoord: navCoord,
2050+ deps: &Dependencies{ZoomUC: usecase.NewManageZoomUseCase(&fakeZoomRepo{}, 1.0, nil)},
2051+ workspaceViews: map[entity.TabID]*component.WorkspaceView{
2052+ tab1.ID: &component.WorkspaceView{},
2053+ tab2.ID: &component.WorkspaceView{},
2054+ },
2055+ }
2056+ app.tabs.Add(tab1)
2057+ app.tabs.Add(tab2)
2058+
2059+ // Dispatch browser window actions targeting the second window.
2060+ for _, a := range []input.Action{
2061+ input.ActionReload,
2062+ input.ActionHardReload,
2063+ input.ActionStop,
2064+ input.ActionGoBack,
2065+ input.ActionGoForward,
2066+ input.ActionPrintPage,
2067+ input.ActionOpenDevTools,
2068+ input.ActionZoomIn,
2069+ } {
2070+ if err := app.dispatchBrowserWindowAction(ctx, second, a); err != nil {
2071+ t.Fatalf("dispatchBrowserWindowAction (%s) returned error: %v", a, err)
2072+ }
2073+ }
2074+
2075+ // Assert first fake webview receives zero calls (stale focus is ignored).
2076+ assertRecordingWebViewBrowserActionsIdle(t, "first", fakeWv1)
2077+
2078+ // Assert second fake webview receives exactly one call per action.
2079+ assertRecordingWebViewBrowserActionsCalledOnce(t, "second", fakeWv2)
2080+}
2081+
2082+func counterIDGen() func() string {
2083+ var counter int
2084+ return func() string {
2085+ counter++
2086+ return strconv.Itoa(counter)
2087+ }
2088+}
2089+
2090+func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesInSourceWindow(t *testing.T) {
2091+ ctx := context.Background()
2092+
2093+ // Two windows, each with one tab.
2094+ firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
2095+ firstTabs := entity.NewTabList()
2096+ firstTabs.Add(firstTab)
2097+ firstTabs.SetActive(firstTab.ID)
2098+ first := &browserWindow{id: "window-1", tabs: firstTabs}
2099+
2100+ secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1")))
2101+ secondTabs := entity.NewTabList()
2102+ secondTabs.Add(secondTab)
2103+ secondTabs.SetActive(secondTab.ID)
2104+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2105+
2106+ app := &App{
2107+ deps: &Dependencies{
2108+ Config: &config.Config{
2109+ Workspace: config.WorkspaceConfig{
2110+ NewPaneURL: "about:blank",
2111+ },
2112+ },
2113+ },
2114+ tabsUC: usecase.NewManageTabsUseCase(counterIDGen()),
2115+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2116+ windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second},
2117+ lastFocusedWindowID: first.id, // stale — should NOT be used
2118+ }
2119+ app.initTabCoordinator(ctx)
2120+
2121+ // Dispatch ActionSwitchTabIndex4 targeting the second window.
2122+ err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4)
2123+ if err != nil {
2124+ t.Fatalf("dispatchBrowserWindowAction returned error: %v", err)
2125+ }
2126+
2127+ // secondTabs should have 4 tabs (1 existing + 3 new).
2128+ if got := secondTabs.Count(); got != 4 {
2129+ t.Fatalf("second target tab count = %d, want 4", got)
2130+ }
2131+ if got := secondTabs.ActiveTabID; got != secondTabs.Tabs[3].ID {
2132+ t.Fatalf("active tab = %q, want %q (index 3)", got, secondTabs.Tabs[3].ID)
2133+ }
2134+
2135+ // firstTabs must be completely unchanged.
2136+ if got := firstTabs.Count(); got != 1 {
2137+ t.Fatalf("first target tab count = %d, want 1", got)
2138+ }
2139+
2140+ // windowForTab should map all second window tabs to second.
2141+ for _, tab := range secondTabs.Tabs {
2142+ if got := app.windowForTab[tab.ID]; got != second {
2143+ t.Fatalf("windowForTab[%s] = %p, want second window %p", tab.ID, got, second)
2144+ }
2145+ }
2146+}
2147+
2148+func TestApp_WorkspaceOmniboxNavigateUsesOwnerWindow(t *testing.T) {
2149+ gtkApp := requireGTKDisplayApp(t)
2150+ defer gtkApp.Unref()
2151+
2152+ ctx := context.Background()
2153+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
2154+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
2155+ firstTabs := entity.NewTabList()
2156+ firstTabs.Add(tab1)
2157+ firstTabs.SetActive(tab1.ID)
2158+ secondTabs := entity.NewTabList()
2159+ secondTabs.Add(tab2)
2160+ secondTabs.SetActive(tab2.ID)
2161+ first := &browserWindow{id: "window-1", tabs: firstTabs}
2162+ second := &browserWindow{id: "window-2", tabs: secondTabs}
2163+
2164+ fakeWv1 := &fakeRecordingWebView{id: 1}
2165+ fakeWv2 := &fakeRecordingWebView{id: 2}
2166+ contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil)
2167+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1)
2168+ contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2)
2169+
2170+ app := &App{
2171+ deps: &Dependencies{Config: &config.Config{}},
2172+ widgetFactory: layout.NewGtkWidgetFactory(),
2173+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
2174+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second},
2175+ lastFocusedWindowID: first.id,
2176+ contentCoord: contentCoord,
2177+ navCoord: coordinator.NewNavigationCoordinator(ctx, nil, contentCoord),
2178+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
2179+ }
2180+
2181+ app.createWorkspaceViewWithoutAttach(ctx, tab2)
2182+ wsView := app.workspaceViews[tab2.ID]
2183+ if wsView == nil {
2184+ t.Fatal("workspace view was not created for second tab")
2185+ }
2186+ wsView.ShowOmnibox(ctx, "")
2187+ cb := omniboxNavigateCallbackForTest(t, wsView.GetOmnibox())
2188+ cb("https://example.com")
2189+
2190+ if fakeWv1.loadURICalled {
2191+ t.Errorf("first window webview navigated to %q, want no navigation", fakeWv1.loadURILastURI)
2192+ }
2193+ if !fakeWv2.loadURICalled || fakeWv2.loadURILastURI != "https://example.com" {
2194+ t.Errorf("second window webview navigation = called %v uri %q, want https://example.com", fakeWv2.loadURICalled, fakeWv2.loadURILastURI)
2195+ }
2196+}
2197+
2198+func TestApp_FloatingOmniboxNavigateUsesSessionPane(t *testing.T) {
2199+ gtkApp := requireGTKDisplayApp(t)
2200+ defer gtkApp.Unref()
2201+
2202+ ctx := context.Background()
2203+ factory := layout.NewGtkWidgetFactory()
2204+ overlay := factory.NewOverlay()
2205+
2206+ loadedURL := ""
2207+ pane := component.NewFloatingPane(overlay, component.FloatingPaneOptions{
2208+ OnNavigate: func(_ context.Context, url string) error {
2209+ loadedURL = url
2210+ return nil
2211+ },
2212+ })
2213+ session := &floatingWorkspaceSession{
2214+ paneID: entity.PaneID("floating-pane"),
2215+ pane: pane,
2216+ overlay: overlay,
2217+ }
2218+ app := &App{widgetFactory: factory}
2219+
2220+ app.showFloatingOmnibox(ctx, session)
2221+ cb := omniboxNavigateCallbackForTest(t, session.omnibox)
2222+ cb("https://example.com")
2223+
2224+ if loadedURL != "https://example.com" {
2225+ t.Fatalf("floating pane loaded URL = %q, want https://example.com", loadedURL)
2226+ }
2227+}
2228+
2229+// TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge verifies that existing
2230+// browserWindows not participating in the restore do not leak their tabs into the
2231+// restored global tab list or UI aggregation. Only runtimeWindows (the windows
2232+// actually restored) contribute.
2233+func TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge(t *testing.T) {
2234+ // Two stale browserWindows exist in the map; only one window is restored.
2235+ staleBW1 := &browserWindow{id: "stale-w1"}
2236+ staleBW2 := &browserWindow{id: "stale-w2"}
2237+
2238+ staleTab1 := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-ws"), entity.NewPane(entity.PaneID("stale-pane")))
2239+ staleTab1.Name = "StaleTab"
2240+ staleBW1.tabs = entity.NewTabList()
2241+ staleBW1.tabs.Add(staleTab1)
2242+
2243+ staleBW2.tabs = entity.NewTabList()
2244+
2245+ // One restored window with its own tab. Tab names survive the
2246+ // snapshot/restore cycle even though IDs are regenerated.
2247+ restoredTabs := entity.NewTabList()
2248+ restoredTab := entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-ws"), entity.NewPane(entity.PaneID("restored-pane")))
2249+ restoredTab.Name = "RestoredTab"
2250+ restoredTabs.Add(restoredTab)
2251+
2252+ sessionID := entity.SessionID("test-session")
2253+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
2254+ {WindowID: "saved-w1", Tabs: restoredTabs},
2255+ }, 0, time.Unix(123, 0))
2256+
2257+ mainWindow := &window.MainWindow{}
2258+ runtimeBW := &browserWindow{id: "runtime-w1", mainWindow: mainWindow}
2259+
2260+ app := &App{
2261+ deps: &Dependencies{
2262+ Config: &config.Config{},
2263+ SessionStateRepo: &fakeSessionStateRepo{state: state},
2264+ },
2265+ mainWindow: mainWindow,
2266+ browserWindows: map[string]*browserWindow{
2267+ staleBW1.id: staleBW1,
2268+ staleBW2.id: staleBW2,
2269+ runtimeBW.id: runtimeBW,
2270+ },
2271+ tabs: entity.NewTabList(),
2272+ }
2273+ // Seed global tabs with a stale tab to verify it is replaced.
2274+ app.tabs.Add(staleTab1)
2275+
2276+ if err := app.restoreSession(context.Background(), sessionID); err != nil {
2277+ t.Fatalf("restoreSession returned error: %v", err)
2278+ }
2279+
2280+ // Global tabs must only contain the restored tab, not stale tabs.
2281+ if got := app.tabs.Count(); got != 1 {
2282+ t.Fatalf("tabs.Count() = %d, want 1 (only restored tab)", got)
2283+ }
2284+ // Verify by name since IDs are regenerated during restore.
2285+ restored := app.tabs.Tabs[0]
2286+ if restored == nil || restored.Name != "RestoredTab" {
2287+ t.Fatalf("restored tab name = %q, want RestoredTab", firstTabName(app.tabs))
2288+ }
2289+ if app.tabs.Find(staleTab1.ID) != nil {
2290+ t.Fatal("stale tab leaked into global tabs")
2291+ }
2292+}
2293+
2294+func firstTabName(tl *entity.TabList) string {
2295+ if len(tl.Tabs) == 0 || tl.Tabs[0] == nil {
2296+ return ""
2297+ }
2298+ return tl.Tabs[0].Name
2299+}
2300+
2301+// TestApp_RestoreSessionHonorsActiveWindowIndex verifies that when restoring a
2302+// v2 multi-window session, the ActiveWindowIndex from the snapshot is used to
2303+// determine the focused window and global active tab.
2304+func TestApp_RestoreSessionHonorsActiveWindowIndex(t *testing.T) {
2305+ gtkApp := requireGTKDisplayApp(t)
2306+ defer gtkApp.Unref()
2307+
2308+ mainWindow, err := window.New(context.Background(), gtkApp, "top")
2309+ if err != nil {
2310+ t.Fatalf("window creation failed: %v", err)
2311+ }
2312+ defer mainWindow.Destroy()
2313+
2314+ // Window 0 has a tab named "Tab-W0", window 1 has "Tab-W1" (active).
2315+ // Tab names survive snapshot/restore; IDs are regenerated.
2316+ pane0 := entity.NewPane("pane-w0")
2317+ tabW0 := entity.NewTab("tab-w0", "ws-w0", pane0)
2318+ tabW0.Name = "Tab-W0"
2319+ tabs0 := entity.NewTabList()
2320+ tabs0.Add(tabW0)
2321+
2322+ pane1 := entity.NewPane("pane-w1")
2323+ tabW1 := entity.NewTab("tab-w1", "ws-w1", pane1)
2324+ tabW1.Name = "Tab-W1"
2325+ tabs1 := entity.NewTabList()
2326+ tabs1.Add(tabW1)
2327+ tabs1.SetActive("tab-w1")
2328+
2329+ sessionID := entity.SessionID("test-sess")
2330+ // ActiveWindowIndex = 1: second window should be focused.
2331+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
2332+ {WindowID: "saved-w0", Tabs: tabs0},
2333+ {WindowID: "saved-w1", Tabs: tabs1},
2334+ }, 1, time.Unix(123, 0))
2335+
2336+ firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow}
2337+ // Factory creates the second runtime window; share the mainWindow so the UI
2338+ // restore loop can walk its tab bar without needing a second GTK window.
2339+ var runtimeW1 *browserWindow
2340+ app := &App{
2341+ deps: &Dependencies{
2342+ Config: &config.Config{},
2343+ SessionStateRepo: &fakeSessionStateRepo{state: state},
2344+ },
2345+ mainWindow: mainWindow,
2346+ widgetFactory: layout.NewGtkWidgetFactory(),
2347+ browserWindows: map[string]*browserWindow{firstBW.id: firstBW},
2348+ tabs: entity.NewTabList(),
2349+ windowForTab: map[entity.TabID]*browserWindow{},
2350+ workspaceViews: map[entity.TabID]*component.WorkspaceView{},
2351+ browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) {
2352+ runtimeW1 = &browserWindow{id: "runtime-w1", mainWindow: mainWindow}
2353+ return runtimeW1, nil
2354+ },
2355+ }
2356+
2357+ if err := app.restoreSession(context.Background(), sessionID); err != nil {
2358+ t.Fatalf("restoreSession returned error: %v", err)
2359+ }
2360+
2361+ // lastFocusedWindowID must point to the active window (index 1).
2362+ if app.lastFocusedWindowID != "runtime-w1" {
2363+ t.Errorf("lastFocusedWindowID = %s, want runtime-w1 (active window index 1)", app.lastFocusedWindowID)
2364+ }
2365+
2366+ // Global active tab must come from the active window (Tab-W1), not the first (Tab-W0).
2367+ activeTab := app.tabs.ActiveTab()
2368+ if activeTab == nil {
2369+ t.Fatal("active tab is nil")
2370+ }
2371+ if activeTab.Name != "Tab-W1" {
2372+ t.Errorf("active tab name = %q, want Tab-W1 (from active window)", activeTab.Name)
2373+ }
2374+}
2375+
2376+// TestApp_RestoreSessionFailsOnAdditionalWindowCreationError verifies that
2377+// restoreSession returns an error (not silent partial restore) when creating
2378+// a browser window for an additional restored window fails.
2379+func TestApp_RestoreSessionFailsOnAdditionalWindowCreationError(t *testing.T) {
2380+ gtkApp := requireGTKDisplayApp(t)
2381+ defer gtkApp.Unref()
2382+
2383+ mainWindow, err := window.New(context.Background(), gtkApp, "top")
2384+ if err != nil {
2385+ t.Fatalf("window creation failed: %v", err)
2386+ }
2387+ defer mainWindow.Destroy()
2388+
2389+ pane0 := entity.NewPane("pane-w0")
2390+ tab0 := entity.NewTab("tab-w0", "ws-w0", pane0)
2391+ tabs0 := entity.NewTabList()
2392+ tabs0.Add(tab0)
2393+
2394+ pane1 := entity.NewPane("pane-w1")
2395+ tab1 := entity.NewTab("tab-w1", "ws-w1", pane1)
2396+ tabs1 := entity.NewTabList()
2397+ tabs1.Add(tab1)
2398+
2399+ sessionID := entity.SessionID("test-sess")
2400+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{
2401+ {WindowID: "saved-w0", Tabs: tabs0},
2402+ {WindowID: "saved-w1", Tabs: tabs1},
2403+ }, 0, time.Unix(123, 0))
2404+
2405+ firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow}
2406+ factoryErr := errors.New("window creation failed")
2407+ app := &App{
2408+ deps: &Dependencies{
2409+ Config: &config.Config{},
2410+ SessionStateRepo: &fakeSessionStateRepo{state: state},
2411+ },
2412+ mainWindow: mainWindow,
2413+ browserWindows: map[string]*browserWindow{firstBW.id: firstBW},
2414+ tabs: entity.NewTabList(),
2415+ windowForTab: map[entity.TabID]*browserWindow{},
2416+ browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) {
2417+ return nil, factoryErr
2418+ },
2419+ }
2420+
2421+ gotErr := app.restoreSession(context.Background(), sessionID)
2422+ if gotErr == nil {
2423+ t.Fatal("restoreSession returned nil error, want failure when additional window creation fails")
2424+ }
2425+ if !strings.Contains(gotErr.Error(), "create browser window 1 for restore") {
2426+ t.Errorf("error message should mention window creation for restore, got: %v", gotErr)
2427+ }
2428+ if !errors.Is(gotErr, factoryErr) {
2429+ t.Errorf("error should wrap factoryErr, got: %v", gotErr)
2430+ }
2431+}
2432+
2433+func TestMainWindow_BottomTabBar_ContentAreaHasInset(t *testing.T) {
2434+ gtkApp := requireGTKDisplayApp(t)
2435+ defer gtkApp.Unref()
2436+
2437+ mw, err := window.New(context.Background(), gtkApp, "bottom")
2438+ if err != nil {
2439+ t.Fatalf("window creation failed: %v", err)
2440+ }
2441+ defer mw.Destroy()
2442+
2443+ // Bottom window should start WITHOUT the inset
2444+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2445+ t.Fatal("bottom-tab-bar content area: should NOT have inset at window creation")
2446+ }
2447+
2448+ // HasTabBarContentInset must return false initially
2449+ if mw.HasTabBarContentInset() {
2450+ t.Fatal("HasTabBarContentInset should return false initially")
2451+ }
2452+
2453+ // SetTabBarContentInsetVisible(true) must add the class
2454+ mw.SetTabBarContentInsetVisible(true)
2455+ if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2456+ t.Fatal("bottom-tab-bar content area: expected content-area-tabbar-inset-bottom CSS class after SetTabBarContentInsetVisible(true)")
2457+ }
2458+ if !mw.HasTabBarContentInset() {
2459+ t.Fatal("HasTabBarContentInset should return true after SetTabBarContentInsetVisible(true)")
2460+ }
2461+
2462+ // SetTabBarContentInsetVisible(false) must remove the class
2463+ mw.SetTabBarContentInsetVisible(false)
2464+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2465+ t.Fatal("bottom-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)")
2466+ }
2467+ if mw.HasTabBarContentInset() {
2468+ t.Fatal("HasTabBarContentInset should return false after SetTabBarContentInsetVisible(false)")
2469+ }
2470+
2471+ // Verify tab bar remains a non-measured overlay regardless of inset state
2472+ if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) {
2473+ t.Fatal("bottom-tab-bar: expected tab bar to remain a non-measured overlay")
2474+ }
2475+}
2476+
2477+func TestMainWindow_TopTabBar_NoContentAreaInset(t *testing.T) {
2478+ gtkApp := requireGTKDisplayApp(t)
2479+ defer gtkApp.Unref()
2480+
2481+ mw, err := window.New(context.Background(), gtkApp, "top")
2482+ if err != nil {
2483+ t.Fatalf("window creation failed: %v", err)
2484+ }
2485+ defer mw.Destroy()
2486+
2487+ // Top should never have the bottom inset class
2488+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2489+ t.Fatal("top-tab-bar content area: should NOT have content-area-tabbar-inset-bottom CSS class")
2490+ }
2491+
2492+ // Even after SetTabBarContentInsetVisible(true), top must not get bottom inset
2493+ mw.SetTabBarContentInsetVisible(true)
2494+ if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2495+ t.Fatal("top-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(true)")
2496+ }
2497+
2498+ // HasTabBarContentInset must return false for top
2499+ if mw.HasTabBarContentInset() {
2500+ t.Fatal("HasTabBarContentInset should return false for top tab bar position")
2501+ }
2502+}
M internal/ui/browser_window.go +33 -3
@@ -9,6 +9,7 @@ import (
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/dumber/internal/ui/component"11 "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/bnema/dumber/internal/ui/coordinator"
12 "github.com/bnema/dumber/internal/ui/focus"13 "github.com/bnema/dumber/internal/ui/focus"
13 "github.com/bnema/dumber/internal/ui/input"14 "github.com/bnema/dumber/internal/ui/input"
14 "github.com/bnema/dumber/internal/ui/layout"15 "github.com/bnema/dumber/internal/ui/layout"
@@ -19,8 +20,7 @@ import (
19 type browserWindow struct {20 type browserWindow struct {
20 id string21 id string
21 initialURL string22 initialURL string
22- activeTabID entity.TabID23+ tabs *entity.TabList // per-window tab list (single source of truth for tab state)
23- prevActiveTabID entity.TabID // per-window previous active tab (for Alt+Tab behavior)
24 mainWindow *window.MainWindow24 mainWindow *window.MainWindow
25 appToaster *component.Toaster25 appToaster *component.Toaster
26 modeToaster *component.Toaster26 modeToaster *component.Toaster
@@ -231,14 +231,29 @@ func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) {
231 log.Debug().Msg("tab picker initialized")231 log.Debug().Msg("tab picker initialized")
232 }232 }
233 233
234+func (bw *browserWindow) ensureTabs() {
235+ if bw != nil && bw.tabs == nil {
236+ bw.tabs = entity.NewTabList()
237+ }
238+}
239+
234 func (a *App) registerBrowserWindow(bw *browserWindow) {240 func (a *App) registerBrowserWindow(bw *browserWindow) {
235 if bw == nil {241 if bw == nil {
236 return242 return
237 }243 }
244+ bw.ensureTabs()
238 if a.browserWindows == nil {245 if a.browserWindows == nil {
239 a.browserWindows = make(map[string]*browserWindow)246 a.browserWindows = make(map[string]*browserWindow)
240 }247 }
241 a.browserWindows[bw.id] = bw248 a.browserWindows[bw.id] = bw
249+
250+ // Track registration order, guarding against duplicates.
251+ for _, id := range a.browserWindowOrder {
252+ if id == bw.id {
253+ return
254+ }
255+ }
256+ a.browserWindowOrder = append(a.browserWindowOrder, bw.id)
242 }257 }
243 258
244 func (a *App) removeBrowserWindow(id string) {259 func (a *App) removeBrowserWindow(id string) {
@@ -246,6 +261,14 @@ func (a *App) removeBrowserWindow(id string) {
246 return261 return
247 }262 }
248 removed := a.browserWindows[id]263 removed := a.browserWindows[id]
264+
265+ // Remove from registration order
266+ for i, wid := range a.browserWindowOrder {
267+ if wid == id {
268+ a.browserWindowOrder = append(a.browserWindowOrder[:i], a.browserWindowOrder[i+1:]...)
269+ break
270+ }
271+ }
249 wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow272 wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
250 ownedTabIDs := make([]entity.TabID, 0)273 ownedTabIDs := make([]entity.TabID, 0)
251 for tabID, bw := range a.windowForTab {274 for tabID, bw := range a.windowForTab {
@@ -284,7 +307,7 @@ func (a *App) removeBrowserWindow(id string) {
284 a.keyboardHandler = nil307 a.keyboardHandler = nil
285 a.globalShortcutHandler = nil308 a.globalShortcutHandler = nil
286 if a.tabCoord != nil {309 if a.tabCoord != nil {
287- a.tabCoord.SetMainWindow(nil)310+ a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
288 }311 }
289 }312 }
290 }313 }
@@ -293,6 +316,13 @@ func (a *App) deterministicBrowserWindowFallback() *browserWindow {
293 if a.browserWindows == nil {316 if a.browserWindows == nil {
294 return nil317 return nil
295 }318 }
319+ for _, id := range a.browserWindowOrder {
320+ bw := a.browserWindows[id]
321+ if id == "" || bw == nil || bw.mainWindow == nil {
322+ continue
323+ }
324+ return bw
325+ }
296 ids := make([]string, 0, len(a.browserWindows))326 ids := make([]string, 0, len(a.browserWindows))
297 for id, bw := range a.browserWindows {327 for id, bw := range a.browserWindows {
298 if id == "" || bw == nil || bw.mainWindow == nil {328 if id == "" || bw == nil || bw.mainWindow == nil {
M internal/ui/browser_window_test.go +543 -2
@@ -14,6 +14,8 @@ import (
14 "github.com/bnema/dumber/internal/ui/layout"14 "github.com/bnema/dumber/internal/ui/layout"
15 "github.com/bnema/dumber/internal/ui/window"15 "github.com/bnema/dumber/internal/ui/window"
16 "github.com/bnema/puregotk/v4/gtk"16 "github.com/bnema/puregotk/v4/gtk"
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
17 )19 )
18 20
19 func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {21 func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
@@ -66,8 +68,12 @@ func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
66 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))68 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
67 removedMainWindow := &window.MainWindow{}69 removedMainWindow := &window.MainWindow{}
68 remainingMainWindow := &window.MainWindow{}70 remainingMainWindow := &window.MainWindow{}
69- removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow}71+ removedTabs := entity.NewTabList()
70- remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow}72+ removedTabs.Add(ownedTab)
73+ remainingTabs := entity.NewTabList()
74+ remainingTabs.Add(otherTab)
75+ removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow, tabs: removedTabs}
76+ remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow, tabs: remainingTabs}
71 ownedSessionKey := floatingSessionKey{tabID: ownedTab.ID, sessionID: "profile:owned"}77 ownedSessionKey := floatingSessionKey{tabID: ownedTab.ID, sessionID: "profile:owned"}
72 otherSessionKey := floatingSessionKey{tabID: otherTab.ID, sessionID: "profile:other"}78 otherSessionKey := floatingSessionKey{tabID: otherTab.ID, sessionID: "profile:other"}
73 app := &App{79 app := &App{
@@ -110,6 +116,7 @@ func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
110 if _, ok := app.floatingSessions[otherSessionKey]; !ok {116 if _, ok := app.floatingSessions[otherSessionKey]; !ok {
111 t.Fatalf("other floating session should remain")117 t.Fatalf("other floating session should remain")
112 }118 }
119+ assertWindowOwnershipInvariant(t, app)
113 }120 }
114 121
115 func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {122 func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
@@ -129,6 +136,9 @@ func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
129 if _, ok := app.browserWindows[second.id]; !ok {136 if _, ok := app.browserWindows[second.id]; !ok {
130 t.Fatalf("second browser window was not registered")137 t.Fatalf("second browser window was not registered")
131 }138 }
139+ if first.tabs == nil || second.tabs == nil {
140+ t.Fatalf("registered browser windows must have non-nil tab lists")
141+ }
132 142
133 app.removeBrowserWindow(first.id)143 app.removeBrowserWindow(first.id)
134 144
@@ -309,3 +319,534 @@ type testPermissionDialogPresenter struct{}
309 319
310 func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {320 func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {
311 }321 }
322+
323+// --- Window tab list tests ---
324+
325+func TestGetWindowTabLists_RespectsRegistrationOrder(t *testing.T) {
326+ // Register windows in non-lexicographic order: window-2 first, then window-1.
327+ // The result should preserve creation order, not sorted order.
328+ app := &App{
329+ browserWindows: make(map[string]*browserWindow),
330+ }
331+
332+ first := &browserWindow{id: "window-2"}
333+ second := &browserWindow{id: "window-1"}
334+ app.registerBrowserWindow(first)
335+ app.registerBrowserWindow(second)
336+
337+ // Give each window a tab so they show up in results.
338+ pane1 := entity.NewPane(entity.PaneID("p-w2"))
339+ tab1 := entity.NewTab(entity.TabID("t-w2"), entity.WorkspaceID("ws-w2"), pane1)
340+ pane2 := entity.NewPane(entity.PaneID("p-w1"))
341+ tab2 := entity.NewTab(entity.TabID("t-w1"), entity.WorkspaceID("ws-w1"), pane2)
342+
343+ app.tabs = entity.NewTabList()
344+ app.tabs.Add(tab1)
345+ app.tabs.Add(tab2)
346+ first.tabs.Add(tab1)
347+ second.tabs.Add(tab2)
348+ app.windowForTab = map[entity.TabID]*browserWindow{
349+ tab1.ID: first,
350+ tab2.ID: second,
351+ }
352+
353+ result := app.GetWindowTabLists()
354+
355+ require.Len(t, result, 2)
356+ assert.Equal(t, entity.WindowID("window-2"), result[0].WindowID, "first registered window should be first")
357+ assert.Equal(t, entity.WindowID("window-1"), result[1].WindowID, "second registered window should be second")
358+}
359+
360+func TestGetWindowTabLists_NoBrowserWindowsReturnsGlobalSnapshot(t *testing.T) {
361+ pane := entity.NewPane(entity.PaneID("p1"))
362+ tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
363+ tabs := entity.NewTabList()
364+ tabs.Add(tab)
365+
366+ app := &App{tabs: tabs}
367+
368+ result := app.GetWindowTabLists()
369+
370+ require.Len(t, result, 1)
371+ assert.Equal(t, entity.WindowID(""), result[0].WindowID)
372+ // Snapshot returns a copy so pointer identity is not expected; verify
373+ // structural equality instead.
374+ assert.Equal(t, tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
375+ assert.Len(t, result[0].Tabs.Tabs, len(tabs.Tabs))
376+}
377+
378+func TestGetWindowTabLists_AppliesActiveTabIndex(t *testing.T) {
379+ // A window with its own TabList where the second tab is active.
380+ app := &App{
381+ browserWindows: make(map[string]*browserWindow),
382+ }
383+
384+ pane1 := entity.NewPane(entity.PaneID("p1"))
385+ tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
386+ pane2 := entity.NewPane(entity.PaneID("p2"))
387+ tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
388+
389+ app.tabs = entity.NewTabList()
390+ app.tabs.Add(tab1)
391+ app.tabs.Add(tab2)
392+
393+ bwTabs := entity.NewTabList()
394+ bwTabs.Add(tab1)
395+ bwTabs.Add(tab2)
396+ bwTabs.SetActive(entity.TabID("t2"))
397+ bw := &browserWindow{id: "w1", tabs: bwTabs}
398+ app.registerBrowserWindow(bw)
399+ app.windowForTab = map[entity.TabID]*browserWindow{
400+ tab1.ID: bw,
401+ tab2.ID: bw,
402+ }
403+
404+ result := app.GetWindowTabLists()
405+
406+ require.Len(t, result, 1)
407+ assert.Equal(t, entity.TabID("t2"), result[0].Tabs.ActiveTabID, "should reflect bw.tabs.ActiveTabID")
408+}
409+
410+func TestGetWindowTabLists_PreservesPreviousActiveTabID(t *testing.T) {
411+ app := &App{
412+ browserWindows: make(map[string]*browserWindow),
413+ }
414+
415+ pane1 := entity.NewPane(entity.PaneID("p1"))
416+ tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
417+ pane2 := entity.NewPane(entity.PaneID("p2"))
418+ tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
419+
420+ app.tabs = entity.NewTabList()
421+ app.tabs.Add(tab1)
422+ app.tabs.Add(tab2)
423+
424+ bwTabs := entity.NewTabList()
425+ bwTabs.Add(tab1)
426+ bwTabs.Add(tab2)
427+ bwTabs.SetActive(entity.TabID("t2")) // t2 becomes active, t1 becomes previous
428+
429+ bw := &browserWindow{id: "w1", tabs: bwTabs}
430+ app.registerBrowserWindow(bw)
431+ app.windowForTab = map[entity.TabID]*browserWindow{
432+ tab1.ID: bw,
433+ tab2.ID: bw,
434+ }
435+
436+ result := app.GetWindowTabLists()
437+
438+ require.Len(t, result, 1)
439+ assert.Equal(t, entity.TabID("t1"), result[0].Tabs.PreviousActiveTabID, "should preserve bw.tabs.PreviousActiveTabID")
440+}
441+
442+func TestGetActiveWindowIndex_MatchesRegistrationOrder(t *testing.T) {
443+ // Register windows in non-lexicographic order; lastFocusedWindowID should
444+ // map to the correct index in registration order.
445+ app := &App{
446+ browserWindows: make(map[string]*browserWindow),
447+ }
448+
449+ app.registerBrowserWindow(&browserWindow{id: "z-window"})
450+ app.registerBrowserWindow(&browserWindow{id: "a-window"})
451+ app.lastFocusedWindowID = "a-window"
452+
453+ idx := app.GetActiveWindowIndex()
454+ assert.Equal(t, 1, idx, "a-window is the second registered window, index should be 1")
455+}
456+
457+func TestGetActiveWindowIndex_MatchesGetWindowTabLists(t *testing.T) {
458+ // The order of windows in GetWindowTabLists must match the indices returned
459+ // by GetActiveWindowIndex.
460+ app := &App{
461+ browserWindows: make(map[string]*browserWindow),
462+ }
463+
464+ w1 := &browserWindow{id: "w1"}
465+ w2 := &browserWindow{id: "w2"}
466+ w3 := &browserWindow{id: "w3"}
467+ app.registerBrowserWindow(w1)
468+ app.registerBrowserWindow(w2)
469+ app.registerBrowserWindow(w3)
470+
471+ // Give each window a tab.
472+ p1 := entity.NewPane(entity.PaneID("p1"))
473+ t1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), p1)
474+ p2 := entity.NewPane(entity.PaneID("p2"))
475+ t2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), p2)
476+ p3 := entity.NewPane(entity.PaneID("p3"))
477+ t3 := entity.NewTab(entity.TabID("t3"), entity.WorkspaceID("ws3"), p3)
478+ app.tabs = entity.NewTabList()
479+ app.tabs.Add(t1)
480+ app.tabs.Add(t2)
481+ app.tabs.Add(t3)
482+ w1.tabs.Add(t1)
483+ w2.tabs.Add(t2)
484+ w3.tabs.Add(t3)
485+ app.windowForTab = map[entity.TabID]*browserWindow{
486+ t1.ID: w1,
487+ t2.ID: w2,
488+ t3.ID: w3,
489+ }
490+
491+ app.lastFocusedWindowID = "w2"
492+
493+ lists := app.GetWindowTabLists()
494+ idx := app.GetActiveWindowIndex()
495+
496+ require.Len(t, lists, 3)
497+ assert.Equal(t, entity.WindowID("w2"), lists[idx].WindowID, "active window ID must match the lists index")
498+}
499+
500+func TestWindowOrder_FallbackWhenRegistrationOrderEmpty(t *testing.T) {
501+ // When browserWindowOrder is empty (e.g., old tests / direct map manipulation),
502+ // windowOrder must fall back to a deterministic (sorted) order.
503+ app := &App{
504+ browserWindows: map[string]*browserWindow{
505+ "z-window": {id: "z-window"},
506+ "a-window": {id: "a-window"},
507+ },
508+ }
509+
510+ order := app.windowOrder()
511+
512+ require.Len(t, order, 2)
513+ // Sorted order: "a-window" < "z-window"
514+ assert.Equal(t, "a-window", order[0])
515+ assert.Equal(t, "z-window", order[1])
516+}
517+
518+func TestRemoveBrowserWindow_RemovesFromOrder(t *testing.T) {
519+ app := &App{
520+ browserWindows: make(map[string]*browserWindow),
521+ }
522+
523+ w1 := &browserWindow{id: "w1"}
524+ w2 := &browserWindow{id: "w2"}
525+ w3 := &browserWindow{id: "w3"}
526+ app.registerBrowserWindow(w1)
527+ app.registerBrowserWindow(w2)
528+ app.registerBrowserWindow(w3)
529+
530+ app.removeBrowserWindow("w2")
531+
532+ require.Len(t, app.browserWindowOrder, 2)
533+ assert.Equal(t, "w1", app.browserWindowOrder[0])
534+ assert.Equal(t, "w3", app.browserWindowOrder[1])
535+}
536+
537+func TestRegisterBrowserWindow_DuplicateIgnores(t *testing.T) {
538+ app := &App{
539+ browserWindows: make(map[string]*browserWindow),
540+ }
541+
542+ bw := &browserWindow{id: "w1"}
543+ app.registerBrowserWindow(bw)
544+ app.registerBrowserWindow(bw) // duplicate
545+
546+ require.Len(t, app.browserWindowOrder, 1)
547+}
548+
549+func TestApp_GetWindowTabListsUsesCreationOrderAndActiveIndex(t *testing.T) {
550+ // Create two windows with per-window bw.tabs as the real source of truth.
551+ // Ensure GetWindowTabLists returns them in registration order, with correct
552+ // WindowID, and that the returned Tabs pointers are the per-window bw.tabs
553+ // (not copies or derived from global App.tabs).
554+ firstTabs := entity.NewTabList()
555+ firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
556+ firstTabs.Add(firstTab)
557+ firstTabs.SetActive(entity.TabID("tab-1"))
558+
559+ secondTabs := entity.NewTabList()
560+ secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
561+ secondTabs.Add(secondTab)
562+
563+ first := &browserWindow{id: "window-1", tabs: firstTabs}
564+ second := &browserWindow{id: "window-2", tabs: secondTabs}
565+
566+ app := &App{
567+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
568+ browserWindowOrder: []string{"window-1", "window-2"},
569+ lastFocusedWindowID: "window-2",
570+ }
571+
572+ result := app.GetWindowTabLists()
573+
574+ require.Len(t, result, 2)
575+ assert.Equal(t, entity.WindowID("window-1"), result[0].WindowID)
576+ assert.Equal(t, entity.WindowID("window-2"), result[1].WindowID)
577+
578+ // The returned Tabs are snapshot copies of per-window bw.tabs.
579+ // Verify structural equality (not pointer identity, since
580+ // GetWindowTabLists returns a snapshot for safe concurrent read).
581+ assert.Equal(t, firstTabs.ActiveTabID, result[0].Tabs.ActiveTabID)
582+ assert.Len(t, result[0].Tabs.Tabs, len(firstTabs.Tabs))
583+ assert.Equal(t, secondTabs.ActiveTabID, result[1].Tabs.ActiveTabID)
584+ assert.Len(t, result[1].Tabs.Tabs, len(secondTabs.Tabs))
585+
586+ // Active state reflects per-window bw.tabs.
587+ assert.Equal(t, entity.TabID("tab-1"), result[0].Tabs.ActiveTabID)
588+ assert.Equal(t, entity.TabID("tab-2"), result[1].Tabs.ActiveTabID)
589+
590+ // GetActiveWindowIndex respects lastFocusedWindowID mapped to registration order.
591+ idx := app.GetActiveWindowIndex()
592+ assert.Equal(t, 1, idx, "window-2 is second registered, index should be 1")
593+}
594+
595+func TestApp_AssignRestoredWindowTabListsUsesFreshRuntimeWindowIDs(t *testing.T) {
596+ // Create two restored TabLists with distinct tabs.
597+ firstTabs := entity.NewTabList()
598+ firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
599+ firstTabs.Add(firstTab)
600+ firstTabs.SetActive(entity.TabID("tab-1"))
601+
602+ secondTabs := entity.NewTabList()
603+ secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
604+ secondTabs.Add(secondTab)
605+
606+ // Create two runtime browser windows with fresh IDs distinct from saved IDs.
607+ firstBW := &browserWindow{id: "runtime-w1"}
608+ secondBW := &browserWindow{id: "runtime-w2"}
609+
610+ app := &App{
611+ browserWindows: map[string]*browserWindow{"runtime-w1": firstBW, "runtime-w2": secondBW},
612+ }
613+
614+ // Assign restored state using saved window IDs (simulating session restore).
615+ app.assignRestoredWindowTabList(firstBW, entity.WindowTabListState{
616+ WindowID: "saved-w1",
617+ Tabs: firstTabs,
618+ })
619+ app.assignRestoredWindowTabList(secondBW, entity.WindowTabListState{
620+ WindowID: "saved-w2",
621+ Tabs: secondTabs,
622+ })
623+
624+ // Tabs must be owned by runtime windows, not saved.
625+ require.Same(t, firstTabs, firstBW.tabs, "first window tabs must be assigned")
626+ require.Same(t, secondTabs, secondBW.tabs, "second window tabs must be assigned")
627+
628+ // windowForTab must map tabs to runtime windows.
629+ require.Equal(t, firstBW, app.browserWindowForTab(firstTab.ID), "tab-1 must be owned by runtime-w1")
630+ require.Equal(t, secondBW, app.browserWindowForTab(secondTab.ID), "tab-2 must be owned by runtime-w2")
631+
632+ // Saved window IDs must never leak into browserWindows.
633+ require.Nil(t, app.browserWindows["saved-w1"], "saved-w1 must not appear in browserWindows")
634+ require.Nil(t, app.browserWindows["saved-w2"], "saved-w2 must not appear in browserWindows")
635+ assertWindowOwnershipInvariant(t, app)
636+}
637+
638+func TestApp_BrowserWindowsOwnIndependentTabLists(t *testing.T) {
639+ // Construct first browser window with its own TabList and distinct tabs.
640+ firstTabs := entity.NewTabList()
641+ firstTab := entity.NewTab(entity.TabID("tab-first"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
642+ firstTabs.Add(firstTab)
643+
644+ first := &browserWindow{
645+ id: "window-first",
646+ tabs: firstTabs,
647+ }
648+
649+ // Construct second browser window with its own TabList and distinct tabs.
650+ secondTabs := entity.NewTabList()
651+ secondTab := entity.NewTab(entity.TabID("tab-second"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
652+ secondTabs.Add(secondTab)
653+
654+ second := &browserWindow{
655+ id: "window-second",
656+ tabs: secondTabs,
657+ }
658+
659+ app := &App{
660+ browserWindows: map[string]*browserWindow{
661+ first.id: first,
662+ second.id: second,
663+ },
664+ }
665+
666+ // tabListForBrowserWindow returns the correct TabList for each window.
667+ got := app.tabListForBrowserWindow(first)
668+ if got != firstTabs {
669+ t.Fatalf("tabListForBrowserWindow(first) = %p, want %p", got, firstTabs)
670+ }
671+ got = app.tabListForBrowserWindow(second)
672+ if got != secondTabs {
673+ t.Fatalf("tabListForBrowserWindow(second) = %p, want %p", got, secondTabs)
674+ }
675+
676+ // Each window's tabs do not contain the other window's tabs.
677+ if firstTabs.Find(secondTab.ID) != nil {
678+ t.Fatalf("first window tabs unexpectedly contain second tab")
679+ }
680+ if secondTabs.Find(firstTab.ID) != nil {
681+ t.Fatalf("second window tabs unexpectedly contain first tab")
682+ }
683+
684+ // activeTabForBrowserWindow returns the correct active tab for each window.
685+ gotTab := app.activeTabForBrowserWindow(first)
686+ if gotTab != firstTab {
687+ t.Fatalf("activeTabForBrowserWindow(first) = %p, want %p", gotTab, firstTab)
688+ }
689+ gotTab = app.activeTabForBrowserWindow(second)
690+ if gotTab != secondTab {
691+ t.Fatalf("activeTabForBrowserWindow(second) = %p, want %p", gotTab, secondTab)
692+ }
693+
694+ app.windowForTab = map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second}
695+ assertWindowOwnershipInvariant(t, app)
696+}
697+
698+func TestRestoreSession_RemovesStaleBrowserWindows(t *testing.T) {
699+ // After restore, only runtime windows should remain in browserWindows
700+ // and browserWindowOrder. Stale pre-existing windows must be removed.
701+
702+ // Runtime windows with tabs (what restoreSession produces).
703+ runtime1Tabs := entity.NewTabList()
704+ r1Tab := entity.NewTab(entity.TabID("r1-t1"), entity.WorkspaceID("ws-r1"), entity.NewPane(entity.PaneID("p-r1")))
705+ runtime1Tabs.Add(r1Tab)
706+ runtime1Tabs.SetActive(entity.TabID("r1-t1"))
707+
708+ runtime2Tabs := entity.NewTabList()
709+ r2Tab := entity.NewTab(entity.TabID("r2-t1"), entity.WorkspaceID("ws-r2"), entity.NewPane(entity.PaneID("p-r2")))
710+ runtime2Tabs.Add(r2Tab)
711+ runtime2Tabs.SetActive(entity.TabID("r2-t1"))
712+
713+ runtime1 := &browserWindow{id: "runtime-w1", tabs: runtime1Tabs}
714+ runtime2 := &browserWindow{id: "runtime-w2", tabs: runtime2Tabs}
715+
716+ // Stale pre-existing browser window (no longer in runtime set).
717+ staleTabs := entity.NewTabList()
718+ staleTab := entity.NewTab(entity.TabID("stale-t1"), entity.WorkspaceID("ws-stale"), entity.NewPane(entity.PaneID("p-stale")))
719+ staleTabs.Add(staleTab)
720+ stale := &browserWindow{id: "stale-window", tabs: staleTabs}
721+
722+ app := &App{
723+ browserWindows: map[string]*browserWindow{
724+ "stale-window": stale,
725+ "runtime-w1": runtime1,
726+ "runtime-w2": runtime2,
727+ },
728+ tabs: entity.NewTabList(),
729+ windowForTab: map[entity.TabID]*browserWindow{
730+ staleTab.ID: stale,
731+ r1Tab.ID: runtime1,
732+ r2Tab.ID: runtime2,
733+ },
734+ }
735+
736+ // Simulate post-restore cleanup: remove stale windows not in runtime set.
737+ runtimeIDs := []string{"runtime-w1", "runtime-w2"}
738+ app.pruneStaleBrowserWindows([]*browserWindow{runtime1, runtime2})
739+
740+ // After cleanup, browserWindows contains only runtime windows.
741+ require.Len(t, app.browserWindows, 2)
742+ require.NotNil(t, app.browserWindows["runtime-w1"])
743+ require.NotNil(t, app.browserWindows["runtime-w2"])
744+ require.Nil(t, app.browserWindows["stale-window"])
745+
746+ // browserWindowOrder matches runtime order.
747+ require.Equal(t, runtimeIDs, app.browserWindowOrder)
748+
749+ // GetWindowTabLists returns only runtime windows.
750+ result := app.GetWindowTabLists()
751+ require.Len(t, result, 2)
752+ assert.Equal(t, entity.WindowID("runtime-w1"), result[0].WindowID)
753+ assert.Equal(t, entity.WindowID("runtime-w2"), result[1].WindowID)
754+
755+ // No stale tabs leak into the result.
756+ // Verify structural equality (snapshot copies, not live pointers).
757+ assert.Equal(t, runtime1Tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
758+ assert.Len(t, result[0].Tabs.Tabs, len(runtime1Tabs.Tabs))
759+ assert.Equal(t, runtime2Tabs.ActiveTabID, result[1].Tabs.ActiveTabID)
760+ assert.Len(t, result[1].Tabs.Tabs, len(runtime2Tabs.Tabs))
761+ assertWindowOwnershipInvariant(t, app)
762+}
763+
764+func TestRestoreSession_BrowserWindowOrderStartsWithFirstReusedWindow(t *testing.T) {
765+ // When the first restored window reuses a browser window that was not
766+ // already in browserWindowOrder, the restored order must start with
767+ // the reused window ID followed by newly-created window IDs.
768+
769+ reuseTabs := entity.NewTabList()
770+ reuseTab := entity.NewTab(entity.TabID("reuse-t1"), entity.WorkspaceID("ws-reuse"), entity.NewPane(entity.PaneID("p-reuse")))
771+ reuseTabs.Add(reuseTab)
772+ reuseBW := &browserWindow{id: "reused-window", tabs: reuseTabs}
773+
774+ createdTabs := entity.NewTabList()
775+ createdTab := entity.NewTab(entity.TabID("created-t1"), entity.WorkspaceID("ws-created"), entity.NewPane(entity.PaneID("p-created")))
776+ createdTabs.Add(createdTab)
777+ createdBW := &browserWindow{id: "created-window", tabs: createdTabs}
778+
779+ // Pre-existing browserWindowOrder does NOT contain the reused window.
780+ app := &App{
781+ browserWindows: map[string]*browserWindow{
782+ "reused-window": reuseBW,
783+ "created-window": createdBW,
784+ },
785+ browserWindowOrder: []string{"old-stale-window"}, // reused window not present
786+ tabs: entity.NewTabList(),
787+ windowForTab: map[entity.TabID]*browserWindow{},
788+ }
789+
790+ // Exercise the helper used by restoreSession to prune stale windows and
791+ // replace browserWindowOrder with the runtime restore order.
792+ runtimeWindows := []*browserWindow{reuseBW, createdBW}
793+ app.pruneStaleBrowserWindows(runtimeWindows)
794+
795+ require.Len(t, app.browserWindowOrder, 2)
796+ assert.Equal(t, "reused-window", app.browserWindowOrder[0],
797+ "first reused window must be first in order")
798+ assert.Equal(t, "created-window", app.browserWindowOrder[1],
799+ "newly-created window must follow the reused window")
800+
801+ // GetWindowTabLists respects the order.
802+ result := app.GetWindowTabLists()
803+ require.Len(t, result, 2)
804+ assert.Equal(t, entity.WindowID("reused-window"), result[0].WindowID)
805+ assert.Equal(t, entity.WindowID("created-window"), result[1].WindowID)
806+}
807+
808+func TestRestoreSession_ActiveWindowIndexSyncsState(t *testing.T) {
809+ // After restore, GetActiveWindowIndex must return the restored active index.
810+ // The focused window state must be consistent: lastFocusedWindowID matches
811+ // the window at that index, and the active window's tab target aligns.
812+
813+ runtime1Tabs := entity.NewTabList()
814+ r1Tab := entity.NewTab(entity.TabID("a1-t1"), entity.WorkspaceID("ws-a1"), entity.NewPane(entity.PaneID("p-a1")))
815+ runtime1Tabs.Add(r1Tab)
816+ runtime1Tabs.SetActive(entity.TabID("a1-t1"))
817+
818+ runtime2Tabs := entity.NewTabList()
819+ r2Tab := entity.NewTab(entity.TabID("a2-t1"), entity.WorkspaceID("ws-a2"), entity.NewPane(entity.PaneID("p-a2")))
820+ runtime2Tabs.Add(r2Tab)
821+ runtime2Tabs.SetActive(entity.TabID("a2-t1"))
822+
823+ runtime1 := &browserWindow{id: "active-w1", tabs: runtime1Tabs}
824+ runtime2 := &browserWindow{id: "active-w2", tabs: runtime2Tabs}
825+
826+ app := &App{
827+ browserWindows: map[string]*browserWindow{
828+ "active-w1": runtime1,
829+ "active-w2": runtime2,
830+ },
831+ browserWindowOrder: []string{"active-w1", "active-w2"},
832+ lastFocusedWindowID: "active-w2",
833+ tabs: entity.NewTabList(),
834+ windowForTab: map[entity.TabID]*browserWindow{},
835+ }
836+
837+ // Active index should be 1 (second window in order).
838+ idx := app.GetActiveWindowIndex()
839+ assert.Equal(t, 1, idx)
840+
841+ // The focused window is the one at that index.
842+ focused := app.lastFocusedBrowserWindow()
843+ require.NotNil(t, focused)
844+ assert.Equal(t, "active-w2", focused.id)
845+
846+ // Verify that GetActiveWindowIndex and GetWindowTabLists are consistent:
847+ // the window at the active index matches the focused window.
848+ result := app.GetWindowTabLists()
849+ require.Len(t, result, 2)
850+ assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID,
851+ "window at active index must match focused window ID")
852+}
M internal/ui/component/tab_bar.go +38 -3
@@ -12,6 +12,8 @@ type TabBar struct {
12 box *gtk.Box12 box *gtk.Box
13 buttons map[entity.TabID]*TabButton13 buttons map[entity.TabID]*TabButton
14 14
15+ explicitlyVisible bool
16+
15 // Active tab tracking17 // Active tab tracking
16 activeTabID entity.TabID18 activeTabID entity.TabID
17 19
@@ -26,7 +28,8 @@ type TabBar struct {
26 // NewTabBar creates a new tab bar widget.28 // NewTabBar creates a new tab bar widget.
27 func NewTabBar() *TabBar {29 func NewTabBar() *TabBar {
28 tb := &TabBar{30 tb := &TabBar{
29- buttons: make(map[entity.TabID]*TabButton),31+ buttons: make(map[entity.TabID]*TabButton),
32+ explicitlyVisible: true,
30 }33 }
31 34
32 // Create horizontal box container35 // Create horizontal box container
@@ -157,12 +160,44 @@ func (tb *TabBar) ActiveTabID() entity.TabID {
157 return tb.activeTabID160 return tb.activeTabID
158 }161 }
159 162
160-// SetVisible shows or hides the tab bar.163+// SetVisible explicitly shows or hides the tab bar.
161 func (tb *TabBar) SetVisible(visible bool) {164 func (tb *TabBar) SetVisible(visible bool) {
162- if tb.box == nil {165+ if tb == nil || tb.box == nil {
163 return166 return
164 }167 }
168+ tb.explicitlyVisible = visible
165 tb.box.SetVisible(visible)169 tb.box.SetVisible(visible)
170+ if !visible {
171+ tb.box.SetFocusable(false)
172+ tb.box.SetCanTarget(false)
173+ } else {
174+ tb.box.SetFocusable(true)
175+ tb.box.SetCanTarget(true)
176+ }
177+}
178+
179+// SetAutoHidden hides the tab bar visually while keeping the overlay widget
180+// visible to GTK when the bar has not been explicitly hidden. It is used for
181+// hide_tab_bar_when_single_tab; WebView viewport height stays stable because the
182+// tab bar is mounted as a non-measured overlay above the content.
183+func (tb *TabBar) SetAutoHidden(hidden bool) {
184+ if tb == nil || tb.box == nil {
185+ return
186+ }
187+
188+ // Fullscreen and other explicit hide states must win over auto-hide updates.
189+ tb.box.SetVisible(tb.explicitlyVisible)
190+
191+ if hidden {
192+ tb.box.SetOpacity(0.0)
193+ tb.box.SetCanTarget(false)
194+ tb.box.SetFocusable(false)
195+ return
196+ }
197+
198+ tb.box.SetOpacity(1.0)
199+ tb.box.SetCanTarget(tb.explicitlyVisible)
200+ tb.box.SetFocusable(tb.explicitlyVisible)
166 }201 }
167 202
168 // Destroy cleans up all tab bar resources.203 // Destroy cleans up all tab bar resources.
M internal/ui/coordinator/content/coordinator.go +5 -0
@@ -275,6 +275,11 @@ func (c *Coordinator) ClearActivePaneOverride() {
275 c.activePaneOverrideMu.Unlock()275 c.activePaneOverrideMu.Unlock()
276 }276 }
277 277
278+// ActivePaneOverrideID returns the currently forced active pane override, if any.
279+func (c *Coordinator) ActivePaneOverrideID() (entity.PaneID, bool) {
280+ return c.activePaneOverrideID()
281+}
282+
278 func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {283 func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
279 c.activePaneOverrideMu.RLock()284 c.activePaneOverrideMu.RLock()
280 defer c.activePaneOverrideMu.RUnlock()285 defer c.activePaneOverrideMu.RUnlock()
M internal/ui/coordinator/navigation.go +78 -85
@@ -16,7 +16,6 @@ import (
16 type OmniboxProvider interface {16 type OmniboxProvider interface {
17 ToggleOmnibox(ctx context.Context)17 ToggleOmnibox(ctx context.Context)
18 UpdateOmniboxZoom(factor float64)18 UpdateOmniboxZoom(factor float64)
19- SetOmniboxOnNavigate(fn func(url string))
20 }19 }
21 20
22 // NavigationCoordinator handles URL navigation, history, and browser controls.21 // NavigationCoordinator handles URL navigation, history, and browser controls.
@@ -51,137 +50,138 @@ func NewNavigationCoordinator(
51 // SetOmniboxProvider sets the omnibox provider for toggle/zoom operations.50 // SetOmniboxProvider sets the omnibox provider for toggle/zoom operations.
52 func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) {51 func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) {
53 c.omniboxProvider = provider52 c.omniboxProvider = provider
54- if c.omniboxProvider != nil {
55- c.omniboxProvider.SetOmniboxOnNavigate(func(url string) {
56- navCtx := context.Background()
57- if c.contextProvider != nil {
58- navCtx = c.contextProvider()
59- }
60- if err := c.Navigate(navCtx, url); err != nil {
61- logging.FromContext(navCtx).Warn().Err(err).Str("url", url).Msg("omnibox-initiated navigation failed")
62- }
63- })
64- }
65 }53 }
66 54
67-// Navigate loads a URL in the active pane using NavigateUseCase.55+// requireWebView returns an error if wv is nil, preserving stable error text.
68-// This properly handles history recording and zoom application.56+func requireWebView(wv port.WebView) error {
69-func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error {57+ if wv == nil {
70- log := logging.FromContext(ctx)58+ return fmt.Errorf("no webview provided")
71- if c.contentCoord == nil {
72- log.Warn().Str("url", url).Msg("content coordinator not initialized")
73- return fmt.Errorf("content coordinator not initialized")
74 }59 }
60+ return nil
61+}
75 62
76- wv := c.contentCoord.ActiveWebView(ctx)63+func (c *NavigationCoordinator) trackNavigationOrigin(ctx context.Context, paneID entity.PaneID, url string) {
77- if wv == nil {64+ if c.contentCoord == nil || paneID == "" {
78- log.Warn().Str("url", url).Msg("no active webview for navigation")65+ return
79- return fmt.Errorf("no active webview")
80 }66 }
67+ c.contentCoord.SetNavigationOrigin(paneID, url)
68+ go func() {
69+ preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)
70+ defer cancelPreload()
71+ c.contentCoord.PreloadCachedFavicon(preloadCtx, paneID, url)
72+ }()
73+}
81 74
82- // Get active pane ID for tracking75+// NavigateWebView loads a URL in the specified pane using the provided WebView.
83- activePaneID := c.contentCoord.ActivePaneID(ctx)76+// This is the explicit-target equivalent of Navigate: it uses NavigateUseCase if
84- paneID := string(activePaneID)77+// available, falling back to direct LoadURI. Origin tracking via contentCoord is
85- if activePaneID != "" {78+// performed when contentCoord and paneID are available.
86- // Track original URL for cross-domain redirect favicon caching79+func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string, paneID entity.PaneID, wv port.WebView) error {
87- c.contentCoord.SetNavigationOrigin(activePaneID, url)80+ log := logging.FromContext(ctx)
88- // Pre-load cached favicon asynchronously (don't block navigation start)81+
89- go func() {82+ if err := requireWebView(wv); err != nil {
90- preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)83+ log.Warn().Str("url", url).Msg("NavigateWebView called with nil webview")
91- defer cancelPreload()84+ return err
92- c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url)
93- }()
94 }85 }
95 86
96- // Use NavigateUseCase which handles history + zoom87+ c.trackNavigationOrigin(ctx, paneID, url)
88+
89+ paneIDStr := string(paneID)
90+
97 if c.navigateUC != nil {91 if c.navigateUC != nil {
98 input := usecase.NavigateInput{92 input := usecase.NavigateInput{
99 URL: url,93 URL: url,
100- PaneID: paneID,94+ PaneID: paneIDStr,
101- WebView: wv, // webkit.WebView implements port.WebView95+ WebView: wv,
102 }96 }
103 output, err := c.navigateUC.Execute(ctx, input)97 output, err := c.navigateUC.Execute(ctx, input)
104 if err != nil {98 if err != nil {
105- log.Error().Err(err).Str("url", url).Msg("navigation failed")99+ log.Error().Err(err).Str("url", url).Str("pane_id", paneIDStr).Msg("navigation failed")
106 return err100 return err
107 }101 }
108 log.Debug().102 log.Debug().
109 Str("url", url).103 Str("url", url).
104+ Str("pane_id", paneIDStr).
110 Float64("zoom", output.AppliedZoom).105 Float64("zoom", output.AppliedZoom).
106+ Uint64("webview_id", uint64(wv.ID())).
111 Msg("navigation initiated via usecase")107 Msg("navigation initiated via usecase")
112 return nil108 return nil
113 }109 }
114 110
115- // Fallback: direct navigation without usecase
116 log.Warn().Msg("navigateUC not available, using direct LoadURI")111 log.Warn().Msg("navigateUC not available, using direct LoadURI")
117 if err := wv.LoadURI(ctx, url); err != nil {112 if err := wv.LoadURI(ctx, url); err != nil {
118 log.Error().Err(err).Str("url", url).Msg("failed to navigate")113 log.Error().Err(err).Str("url", url).Msg("failed to navigate")
119 return err114 return err
120 }115 }
121 116
122- log.Debug().Str("url", url).Msg("navigated active pane (direct)")117+ log.Debug().Str("url", url).Uint64("webview_id", uint64(wv.ID())).Msg("navigated explicit webview (direct)")
123 return nil118 return nil
124 }119 }
125 120
126-// Reload reloads the current page.121+// ReloadWebView reloads the page in the provided WebView, optionally bypassing cache.
127-func (c *NavigationCoordinator) Reload(ctx context.Context) error {122+func (c *NavigationCoordinator) ReloadWebView(ctx context.Context, wv port.WebView, bypassCache bool) error {
128 log := logging.FromContext(ctx)123 log := logging.FromContext(ctx)
129 124
130- wv := c.contentCoord.ActiveWebView(ctx)125+ if err := requireWebView(wv); err != nil {
131- if wv == nil {126+ log.Debug().Msg("ReloadWebView called with nil webview")
132- log.Debug().Msg("no active webview for reload")127+ return err
133- return nil
134 }128 }
135 129
136 if c.navigateUC != nil {130 if c.navigateUC != nil {
137- return c.navigateUC.Reload(ctx, wv, false)131+ return c.navigateUC.Reload(ctx, wv, bypassCache)
138 }132 }
139 133
134+ if bypassCache {
135+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("reloading explicit webview bypassing cache")
136+ return wv.ReloadBypassCache(ctx)
137+ }
138+
139+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("reloading explicit webview")
140 return wv.Reload(ctx)140 return wv.Reload(ctx)
141 }141 }
142 142
143-// HardReload reloads the current page bypassing cache.143+// StopWebView stops loading in the provided WebView.
144-func (c *NavigationCoordinator) HardReload(ctx context.Context) error {144+func (c *NavigationCoordinator) StopWebView(ctx context.Context, wv port.WebView) error {
145 log := logging.FromContext(ctx)145 log := logging.FromContext(ctx)
146 146
147- wv := c.contentCoord.ActiveWebView(ctx)147+ if err := requireWebView(wv); err != nil {
148- if wv == nil {148+ log.Debug().Msg("StopWebView called with nil webview")
149- log.Debug().Msg("no active webview for hard reload")149+ return err
150- return nil
151 }150 }
152 151
153 if c.navigateUC != nil {152 if c.navigateUC != nil {
154- return c.navigateUC.Reload(ctx, wv, true)153+ return c.navigateUC.Stop(ctx, wv)
155 }154 }
156 155
157- return wv.ReloadBypassCache(ctx)156+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("stopping explicit webview")
157+ return wv.Stop(ctx)
158 }158 }
159 159
160-// GoBack navigates back in history.160+// GoBackWebView navigates back in history for the provided WebView.
161 // Calls webview directly - the webview layer handles SPA navigation via JS fallback.161 // Calls webview directly - the webview layer handles SPA navigation via JS fallback.
162-func (c *NavigationCoordinator) GoBack(ctx context.Context) error {162+func (c *NavigationCoordinator) GoBackWebView(ctx context.Context, wv port.WebView) error {
163 log := logging.FromContext(ctx)163 log := logging.FromContext(ctx)
164 164
165- wv := c.contentCoord.ActiveWebView(ctx)165+ if err := requireWebView(wv); err != nil {
166- if wv == nil {166+ log.Debug().Msg("GoBackWebView called with nil webview")
167- log.Debug().Msg("no active webview for go back")167+ return err
168- return nil
169 }168 }
170 169
170+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("going back in explicit webview")
171 return wv.GoBack(ctx)171 return wv.GoBack(ctx)
172 }172 }
173 173
174-// GoForward navigates forward in history.174+// GoForwardWebView navigates forward in history for the provided WebView.
175 // Calls webview directly - the webview layer handles SPA navigation via JS fallback.175 // Calls webview directly - the webview layer handles SPA navigation via JS fallback.
176-func (c *NavigationCoordinator) GoForward(ctx context.Context) error {176+func (c *NavigationCoordinator) GoForwardWebView(ctx context.Context, wv port.WebView) error {
177 log := logging.FromContext(ctx)177 log := logging.FromContext(ctx)
178 178
179- wv := c.contentCoord.ActiveWebView(ctx)179+ if err := requireWebView(wv); err != nil {
180- if wv == nil {180+ log.Debug().Msg("GoForwardWebView called with nil webview")
181- log.Debug().Msg("no active webview for go forward")181+ return err
182- return nil
183 }182 }
184 183
184+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("going forward in explicit webview")
185 return wv.GoForward(ctx)185 return wv.GoForward(ctx)
186 }186 }
187 187
@@ -199,14 +199,13 @@ func (c *NavigationCoordinator) OpenOmnibox(ctx context.Context) error {
199 return nil199 return nil
200 }200 }
201 201
202-// OpenDevTools opens the WebKit inspector for the active WebView.202+// OpenDevToolsWebView opens the WebKit inspector for the provided WebView.
203-func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error {203+func (c *NavigationCoordinator) OpenDevToolsWebView(ctx context.Context, wv port.WebView) error {
204 log := logging.FromContext(ctx)204 log := logging.FromContext(ctx)
205 205
206- wv := c.contentCoord.ActiveWebView(ctx)206+ if err := requireWebView(wv); err != nil {
207- if wv == nil {207+ log.Warn().Msg("OpenDevToolsWebView called with nil webview")
208- log.Warn().Msg("no active webview for devtools")208+ return err
209- return fmt.Errorf("no active webview")
210 }209 }
211 210
212 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening devtools")211 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening devtools")
@@ -219,14 +218,13 @@ func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error {
219 return fmt.Errorf("webview does not support devtools")218 return fmt.Errorf("webview does not support devtools")
220 }219 }
221 220
222-// PrintPage opens the print dialog for the active WebView.221+// PrintWebView opens the print dialog for the provided WebView.
223-func (c *NavigationCoordinator) PrintPage(ctx context.Context) error {222+func (c *NavigationCoordinator) PrintWebView(ctx context.Context, wv port.WebView) error {
224 log := logging.FromContext(ctx)223 log := logging.FromContext(ctx)
225 224
226- wv := c.contentCoord.ActiveWebView(ctx)225+ if err := requireWebView(wv); err != nil {
227- if wv == nil {226+ log.Warn().Msg("PrintWebView called with nil webview")
228- log.Warn().Msg("no active webview for print")227+ return err
229- return fmt.Errorf("no active webview")
230 }228 }
231 229
232 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening print dialog")230 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening print dialog")
@@ -265,11 +263,6 @@ func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) {
265 c.navigateUC.ClearPaneHistory(string(paneID))263 c.navigateUC.ClearPaneHistory(string(paneID))
266 }264 }
267 265
268-// ActiveWebView returns the WebView for the active pane (for zoom operations).
269-func (c *NavigationCoordinator) ActiveWebView(ctx context.Context) port.WebView {
270- return c.contentCoord.ActiveWebView(ctx)
271-}
272-
273 // NotifyZoomChanged updates the omnibox zoom indicator.266 // NotifyZoomChanged updates the omnibox zoom indicator.
274 func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) {267 func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) {
275 if c.omniboxProvider != nil {268 if c.omniboxProvider != nil {
M internal/ui/coordinator/navigation_test.go +10 -20
@@ -4,38 +4,28 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7- "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
9 )8 )
10 9
11 type fakeOmniboxProvider struct {10 type fakeOmniboxProvider struct {
12- onNavigate func(string)11+ toggled bool
12+ zoomUpdates []float64
13 }13 }
14 14
15-func (f *fakeOmniboxProvider) ToggleOmnibox(context.Context) {}15+func (f *fakeOmniboxProvider) ToggleOmnibox(context.Context) { f.toggled = true }
16 16
17-func (f *fakeOmniboxProvider) UpdateOmniboxZoom(float64) {}17+func (f *fakeOmniboxProvider) UpdateOmniboxZoom(factor float64) {
18-18+ f.zoomUpdates = append(f.zoomUpdates, factor)
19-func (f *fakeOmniboxProvider) SetOmniboxOnNavigate(fn func(url string)) {
20- f.onNavigate = fn
21-}
22-
23-func TestNavigationCoordinator_NavigateWithoutContentCoordinatorReturnsError(t *testing.T) {
24- c := &NavigationCoordinator{}
25-
26- err := c.Navigate(context.Background(), "https://example.com")
27- require.Error(t, err)
28- assert.Contains(t, err.Error(), "content coordinator")
29 }19 }
30 20
31-func TestNavigationCoordinator_OmniboxCallbackDoesNotPanicOnNavigateError(t *testing.T) {21+func TestNavigationCoordinator_OmniboxProviderOpenAndZoom(t *testing.T) {
32 c := &NavigationCoordinator{}22 c := &NavigationCoordinator{}
33 provider := &fakeOmniboxProvider{}23 provider := &fakeOmniboxProvider{}
34 24
35 c.SetOmniboxProvider(provider)25 c.SetOmniboxProvider(provider)
36- require.NotNil(t, provider.onNavigate)26+ require.NoError(t, c.OpenOmnibox(context.Background()))
27+ c.NotifyZoomChanged(context.Background(), 1.25)
37 28
38- assert.NotPanics(t, func() {29+ require.True(t, provider.toggled)
39- provider.onNavigate("https://example.com")30+ require.Equal(t, []float64{1.25}, provider.zoomUpdates)
40- })
41 }31 }
A internal/ui/coordinator/navigation_webview_targeted_test.go +168 -0
@@ -0,0 +1,168 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+// TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView verifies that
13+// the explicit WebView-targeted navigation methods call the correct methods on the
14+// provided WebView rather than resolving through the content coordinator.
15+func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testing.T) {
16+ ctx := context.Background()
17+
18+ t.Run("NavigateWebView with nil webview returns error", func(t *testing.T) {
19+ testNavigateWebViewNilWebView(t, ctx)
20+ })
21+ t.Run("NavigateWebView targets provided webview", func(t *testing.T) {
22+ testNavigateWebViewTargetsProvidedWebView(t, ctx)
23+ })
24+ t.Run("ReloadWebView with nil webview returns error", func(t *testing.T) {
25+ testReloadWebViewNilWebView(t, ctx)
26+ })
27+ t.Run("ReloadWebView bypassCache=false calls Reload once", func(t *testing.T) {
28+ testReloadWebViewCallsReload(t, ctx)
29+ })
30+ t.Run("ReloadWebView bypassCache=true calls ReloadBypassCache once", func(t *testing.T) {
31+ testReloadWebViewCallsReloadBypassCache(t, ctx)
32+ })
33+ t.Run("StopWebView with nil webview returns error", func(t *testing.T) {
34+ testStopWebViewNilWebView(t, ctx)
35+ })
36+ t.Run("StopWebView calls Stop once", func(t *testing.T) {
37+ testStopWebViewCallsStop(t, ctx)
38+ })
39+ t.Run("GoBackWebView with nil webview returns error", func(t *testing.T) {
40+ testGoBackWebViewNilWebView(t, ctx)
41+ })
42+ t.Run("GoBackWebView calls GoBack once", func(t *testing.T) {
43+ testGoBackWebViewCallsGoBack(t, ctx)
44+ })
45+ t.Run("GoForwardWebView with nil webview returns error", func(t *testing.T) {
46+ testGoForwardWebViewNilWebView(t, ctx)
47+ })
48+ t.Run("GoForwardWebView calls GoForward once", func(t *testing.T) {
49+ testGoForwardWebViewCallsGoForward(t, ctx)
50+ })
51+}
52+
53+func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) {
54+ t.Helper()
55+ c := &NavigationCoordinator{}
56+ err := c.NavigateWebView(ctx, "https://example.com", entity.PaneID("p1"), nil)
57+ if err == nil {
58+ t.Fatal("expected error for nil webview, got nil")
59+ }
60+}
61+
62+func testNavigateWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
63+ t.Helper()
64+ wv := mocks.NewMockWebView(t)
65+ wv.EXPECT().LoadURI(ctx, "https://example.com").Return(nil).Once()
66+ wv.EXPECT().ID().Return(port.WebViewID(42)).Once()
67+ c := &NavigationCoordinator{}
68+ err := c.NavigateWebView(ctx, "https://example.com", entity.PaneID("p1"), wv)
69+ if err != nil {
70+ t.Fatalf("unexpected error: %v", err)
71+ }
72+}
73+
74+func testReloadWebViewNilWebView(t *testing.T, ctx context.Context) {
75+ t.Helper()
76+ c := &NavigationCoordinator{}
77+ err := c.ReloadWebView(ctx, nil, false)
78+ if err == nil {
79+ t.Fatal("expected error for nil webview, got nil")
80+ }
81+}
82+
83+func testReloadWebViewCallsReload(t *testing.T, ctx context.Context) {
84+ t.Helper()
85+ wv := mocks.NewMockWebView(t)
86+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
87+ wv.EXPECT().Reload(ctx).Return(nil).Once()
88+ c := &NavigationCoordinator{}
89+ err := c.ReloadWebView(ctx, wv, false)
90+ if err != nil {
91+ t.Fatalf("unexpected error: %v", err)
92+ }
93+}
94+
95+func testReloadWebViewCallsReloadBypassCache(t *testing.T, ctx context.Context) {
96+ t.Helper()
97+ wv := mocks.NewMockWebView(t)
98+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
99+ wv.EXPECT().ReloadBypassCache(ctx).Return(nil).Once()
100+ c := &NavigationCoordinator{}
101+ err := c.ReloadWebView(ctx, wv, true)
102+ if err != nil {
103+ t.Fatalf("unexpected error: %v", err)
104+ }
105+}
106+
107+func testStopWebViewNilWebView(t *testing.T, ctx context.Context) {
108+ t.Helper()
109+ c := &NavigationCoordinator{}
110+ err := c.StopWebView(ctx, nil)
111+ if err == nil {
112+ t.Fatal("expected error for nil webview, got nil")
113+ }
114+}
115+
116+func testStopWebViewCallsStop(t *testing.T, ctx context.Context) {
117+ t.Helper()
118+ wv := mocks.NewMockWebView(t)
119+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
120+ wv.EXPECT().Stop(ctx).Return(nil).Once()
121+ c := &NavigationCoordinator{}
122+ err := c.StopWebView(ctx, wv)
123+ if err != nil {
124+ t.Fatalf("unexpected error: %v", err)
125+ }
126+}
127+
128+func testGoBackWebViewNilWebView(t *testing.T, ctx context.Context) {
129+ t.Helper()
130+ c := &NavigationCoordinator{}
131+ err := c.GoBackWebView(ctx, nil)
132+ if err == nil {
133+ t.Fatal("expected error for nil webview, got nil")
134+ }
135+}
136+
137+func testGoBackWebViewCallsGoBack(t *testing.T, ctx context.Context) {
138+ t.Helper()
139+ wv := mocks.NewMockWebView(t)
140+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
141+ wv.EXPECT().GoBack(ctx).Return(nil).Once()
142+ c := &NavigationCoordinator{}
143+ err := c.GoBackWebView(ctx, wv)
144+ if err != nil {
145+ t.Fatalf("unexpected error: %v", err)
146+ }
147+}
148+
149+func testGoForwardWebViewNilWebView(t *testing.T, ctx context.Context) {
150+ t.Helper()
151+ c := &NavigationCoordinator{}
152+ err := c.GoForwardWebView(ctx, nil)
153+ if err == nil {
154+ t.Fatal("expected error for nil webview, got nil")
155+ }
156+}
157+
158+func testGoForwardWebViewCallsGoForward(t *testing.T, ctx context.Context) {
159+ t.Helper()
160+ wv := mocks.NewMockWebView(t)
161+ wv.EXPECT().ID().Return(port.WebViewID(1)).Once()
162+ wv.EXPECT().GoForward(ctx).Return(nil).Once()
163+ c := &NavigationCoordinator{}
164+ err := c.GoForwardWebView(ctx, wv)
165+ if err != nil {
166+ t.Fatalf("unexpected error: %v", err)
167+ }
168+}
M internal/ui/coordinator/tab.go +241 -224
@@ -12,37 +12,36 @@ import (
12 "github.com/bnema/dumber/internal/ui/window"12 "github.com/bnema/dumber/internal/ui/window"
13 )13 )
14 14
15-// TabScopeFunc filters whether a tab belongs to the current window scope.15+// TabTarget identifies which TabList and MainWindow a tab operation applies to.
16-// Returns true if the tab should be considered for window-local operations.16+// Each browser window owns a dedicated TabTarget; operations are scoped to that target.
17-type TabScopeFunc func(tab *entity.Tab, mainWindow *window.MainWindow) bool17+type TabTarget struct {
18+ Tabs *entity.TabList
19+ MainWindow *window.MainWindow
20+}
18 21
19-// TabCoordinator manages tab lifecycle operations.22+// TabCoordinator manages tab lifecycle operations within a target window.
23+// Public methods accept an explicit TabTarget so callers control which window's
24+// tabs are mutated. The currentTarget field provides a fallback for callers that
25+// operate on the "currently active" browser window (e.g., the keyboard dispatcher).
20 type TabCoordinator struct {26 type TabCoordinator struct {
21 tabsUC *usecase.ManageTabsUseCase27 tabsUC *usecase.ManageTabsUseCase
22- tabs *entity.TabList28+ currentTarget TabTarget
23- mainWindow *window.MainWindow29+ mainWindow *window.MainWindow // retained for GetTabBar and SetMainWindow
24 hideTabBarWhenSingleTab bool30 hideTabBarWhenSingleTab bool
25 31
26- // Window-scoped tab filtering (per-window tab ownership)
27- tabScope TabScopeFunc
28-
29 // Callbacks to avoid circular dependencies32 // Callbacks to avoid circular dependencies
30- onTabCreated func(ctx context.Context, tab *entity.Tab)33+ onTabCreated func(ctx context.Context, target TabTarget, tab *entity.Tab)
31- onTabSwitched func(ctx context.Context, tab *entity.Tab)34+ onTabSwitched func(ctx context.Context, target TabTarget, tab *entity.Tab)
32 onQuit func()35 onQuit func()
33- onCurrentWindowEmpty func(ctx context.Context, mainWindow *window.MainWindow)36+ onCurrentWindowEmpty func(ctx context.Context, target TabTarget)
34 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs37 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs
35 onStateChanged func() // For session snapshots38 onStateChanged func() // For session snapshots
36-
37- // previousActiveTabID provides the per-window previous active tab ID for Alt+Tab style switching.
38- // When set and tabScope is active, SwitchToLastActive prefers this over the global PreviousActiveTabID.
39- previousActiveTabID func(mainWindow *window.MainWindow) entity.TabID
40 }39 }
41 40
42 // TabCoordinatorConfig holds configuration for TabCoordinator.41 // TabCoordinatorConfig holds configuration for TabCoordinator.
43 type TabCoordinatorConfig struct {42 type TabCoordinatorConfig struct {
44 TabsUC *usecase.ManageTabsUseCase43 TabsUC *usecase.ManageTabsUseCase
45- Tabs *entity.TabList44+ Tabs *entity.TabList // Initial tab list for the default target
46 MainWindow *window.MainWindow45 MainWindow *window.MainWindow
47 HideTabBarWhenSingleTab bool46 HideTabBarWhenSingleTab bool
48 }47 }
@@ -53,21 +52,38 @@ func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordi
53 log.Debug().Msg("creating tab coordinator")52 log.Debug().Msg("creating tab coordinator")
54 53
55 return &TabCoordinator{54 return &TabCoordinator{
56- tabsUC: cfg.TabsUC,55+ tabsUC: cfg.TabsUC,
57- tabs: cfg.Tabs,56+ currentTarget: TabTarget{
57+ Tabs: cfg.Tabs,
58+ MainWindow: cfg.MainWindow,
59+ },
58 mainWindow: cfg.MainWindow,60 mainWindow: cfg.MainWindow,
59 hideTabBarWhenSingleTab: cfg.HideTabBarWhenSingleTab,61 hideTabBarWhenSingleTab: cfg.HideTabBarWhenSingleTab,
60 }62 }
61 }63 }
62 64
65+// SetCurrentTarget updates the fallback target used by methods that don't receive
66+// an explicit TabTarget. This should be called whenever the active browser window changes.
67+func (c *TabCoordinator) SetCurrentTarget(target TabTarget) {
68+ c.currentTarget = target
69+ if target.MainWindow != nil {
70+ c.mainWindow = target.MainWindow
71+ }
72+}
73+
74+// CurrentTarget returns the currently active fallback target.
75+func (c *TabCoordinator) CurrentTarget() TabTarget {
76+ return c.currentTarget
77+}
78+
63 // SetOnTabCreated sets the callback for when a tab is created.79 // SetOnTabCreated sets the callback for when a tab is created.
64-func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, tab *entity.Tab)) {80+func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
65 c.onTabCreated = fn81 c.onTabCreated = fn
66 }82 }
67 83
68 // SetOnTabSwitched sets the callback for when a tab switch occurs.84 // SetOnTabSwitched sets the callback for when a tab switch occurs.
69 // This is used to swap workspace views in the content area.85 // This is used to swap workspace views in the content area.
70-func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, tab *entity.Tab)) {86+func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
71 c.onTabSwitched = fn87 c.onTabSwitched = fn
72 }88 }
73 89
@@ -81,44 +97,44 @@ func (c *TabCoordinator) SetOnStateChanged(fn func()) {
81 c.onStateChanged = fn97 c.onStateChanged = fn
82 }98 }
83 99
84-// SetPreviousActiveTabIDProvider sets the callback for retrieving the per-window
85-// previous active tab ID. When set and tabScope is active, SwitchToLastActive
86-// prefers this provider over the global PreviousActiveTabID.
87-func (c *TabCoordinator) SetPreviousActiveTabIDProvider(fn func(mainWindow *window.MainWindow) entity.TabID) {
88- c.previousActiveTabID = fn
89-}
90-
91-// SetTabScope sets the function used to filter tabs to the current window.
92-// When set, tab operations (switch by index, next/prev, close) only consider
93-// tabs for which the scope function returns true.
94-func (c *TabCoordinator) SetTabScope(fn TabScopeFunc) {
95- c.tabScope = fn
96-}
97-
98 // SetOnCurrentWindowEmpty sets the callback for when the last tab in the current100 // SetOnCurrentWindowEmpty sets the callback for when the last tab in the current
99 // window is closed. This allows the app to close the window gracefully.101 // window is closed. This allows the app to close the window gracefully.
100-func (c *TabCoordinator) SetOnCurrentWindowEmpty(fn func(ctx context.Context, mainWindow *window.MainWindow)) {102+func (c *TabCoordinator) SetOnCurrentWindowEmpty(fn func(ctx context.Context, target TabTarget)) {
101 c.onCurrentWindowEmpty = fn103 c.onCurrentWindowEmpty = fn
102 }104 }
103 105
104-// scopedTabs returns tabs filtered by the tabScope function.106+// SetMainWindow updates only the main window reference for coordinator setup.
105-// When no scope function is set, returns all tabs (backward compatible).107+//
106-func (c *TabCoordinator) scopedTabs() []*entity.Tab {108+// Deprecated: Use SetCurrentTarget for runtime targeting. This method preserves
107- if c.tabs == nil {109+// currentTarget.Tabs and is safe only during app-level setup.
108- return nil110+func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
111+ oldMainWindow := c.mainWindow
112+ c.mainWindow = mainWindow
113+ if c.currentTarget.MainWindow != mainWindow {
114+ c.currentTarget = TabTarget{
115+ Tabs: c.currentTarget.Tabs,
116+ MainWindow: mainWindow,
117+ }
109 }118 }
110-119+ if c.currentTarget.Tabs != nil && c.currentTarget.Tabs.Count() > 0 && oldMainWindow != mainWindow {
111- if c.tabScope == nil {120+ logging.FromContext(context.Background()).Debug().
112- return c.tabs.Tabs121+ Int("tab_count", c.currentTarget.Tabs.Count()).
122+ Str("old_main_window", fmt.Sprintf("%p", oldMainWindow)).
123+ Str("new_main_window", fmt.Sprintf("%p", mainWindow)).
124+ Msg("SetMainWindow: changing mainWindow while tabs exist")
113 }125 }
126+}
114 127
115- scoped := make([]*entity.Tab, 0, len(c.tabs.Tabs))128+// SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.
116- for _, tab := range c.tabs.Tabs {129+func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView)) {
117- if tab != nil && c.tabScope(tab, c.mainWindow) {130+ c.onAttachPopupToTab = fn
118- scoped = append(scoped, tab)131+}
119- }132+
133+// notifyStateChanged triggers the state changed callback if set.
134+func (c *TabCoordinator) notifyStateChanged() {
135+ if c.onStateChanged != nil {
136+ c.onStateChanged()
120 }137 }
121- return scoped
122 }138 }
123 139
124 // indexOfTab returns the 0-based index of the given tab ID in a slice of tabs,140 // indexOfTab returns the 0-based index of the given tab ID in a slice of tabs,
@@ -132,28 +148,20 @@ func indexOfTab(tabs []*entity.Tab, id entity.TabID) int {
132 return -1148 return -1
133 }149 }
134 150
135-// SetMainWindow updates the window targeted by tab UI operations.151+// Create creates a new tab in the given target with the given initial URL.
136-func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {152+func (c *TabCoordinator) Create(ctx context.Context, target TabTarget, initialURL string) (*entity.Tab, error) {
137- c.mainWindow = mainWindow153+ return c.create(ctx, target, initialURL, true)
138-}
139-
140-// notifyStateChanged triggers the state changed callback if set.
141-func (c *TabCoordinator) notifyStateChanged() {
142- if c.onStateChanged != nil {
143- c.onStateChanged()
144- }
145-}
146-
147-// Create creates a new tab with the given initial URL.
148-func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity.Tab, error) {
149- return c.create(ctx, initialURL, true)
150 }154 }
151 155
152-func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate bool) (*entity.Tab, error) {156+func (c *TabCoordinator) create(ctx context.Context, target TabTarget, initialURL string, activate bool) (*entity.Tab, error) {
153 log := logging.FromContext(ctx)157 log := logging.FromContext(ctx)
154 158
159+ if target.Tabs == nil {
160+ return nil, fmt.Errorf("target.Tabs is nil")
161+ }
162+
155 output, err := c.tabsUC.Create(ctx, usecase.CreateTabInput{163 output, err := c.tabsUC.Create(ctx, usecase.CreateTabInput{
156- TabList: c.tabs,164+ TabList: target.Tabs,
157 Name: "",165 Name: "",
158 InitialURL: initialURL,166 InitialURL: initialURL,
159 })167 })
@@ -164,30 +172,30 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
164 172
165 if activate {173 if activate {
166 // Set new tab as active (updates domain state and tracks previous)174 // Set new tab as active (updates domain state and tracks previous)
167- c.tabs.SetActive(output.Tab.ID)175+ target.Tabs.SetActive(output.Tab.ID)
168 }176 }
169 177
170 // Notify app before adding the tab to the visible tab bar.178 // Notify app before adding the tab to the visible tab bar.
171 // The app assigns window ownership and the window-scoped default name here,179 // The app assigns window ownership and the window-scoped default name here,
172 // and TabBar.AddTab reads tab.Title() immediately when creating the button.180 // and TabBar.AddTab reads tab.Title() immediately when creating the button.
173 if c.onTabCreated != nil {181 if c.onTabCreated != nil {
174- c.onTabCreated(ctx, output.Tab)182+ c.onTabCreated(ctx, target, output.Tab)
175 }183 }
176 184
177 // Update tab bar185 // Update tab bar
178- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {186+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
179- c.mainWindow.TabBar().AddTab(output.Tab)187+ target.MainWindow.TabBar().AddTab(output.Tab)
180 if activate {188 if activate {
181- c.mainWindow.TabBar().SetActive(output.Tab.ID)189+ target.MainWindow.TabBar().SetActive(output.Tab.ID)
182 }190 }
183 }191 }
184 192
185 // Update tab bar visibility193 // Update tab bar visibility
186- c.UpdateBarVisibility(ctx)194+ c.UpdateBarVisibility(ctx, target)
187 195
188 // Switch to the new tab's workspace view when activation is requested.196 // Switch to the new tab's workspace view when activation is requested.
189 if activate && c.onTabSwitched != nil {197 if activate && c.onTabSwitched != nil {
190- c.onTabSwitched(ctx, output.Tab)198+ c.onTabSwitched(ctx, target, output.Tab)
191 }199 }
192 200
193 // Notify state change for session snapshots201 // Notify state change for session snapshots
@@ -197,269 +205,280 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
197 return output.Tab, nil205 return output.Tab, nil
198 }206 }
199 207
200-// Close closes the active tab.208+// Close closes the active tab in the given target.
201-// When scoped, prefers switching to a sibling tab in the same window.209+// When the target's TabList becomes empty, fires onCurrentWindowEmpty.
202-// If this was the last tab in the window, fires onCurrentWindowEmpty.210+// The caller (App) is responsible for deciding whether all windows are empty
203-// If this was the last tab globally, fires onQuit.211+// and the process should quit. onQuit is NOT fired by Close.
204-func (c *TabCoordinator) Close(ctx context.Context) error {212+func (c *TabCoordinator) Close(ctx context.Context, target TabTarget) error {
205 log := logging.FromContext(ctx)213 log := logging.FromContext(ctx)
206 214
207- activeID := c.tabs.ActiveTabID215+ if target.Tabs == nil {
216+ return fmt.Errorf("target.Tabs is nil")
217+ }
218+
219+ activeID := target.Tabs.ActiveTabID
208 if activeID == "" {220 if activeID == "" {
209 log.Debug().Msg("no active tab to close")221 log.Debug().Msg("no active tab to close")
210 return nil222 return nil
211 }223 }
212 224
213- // Compute the next scoped tab to activate BEFORE closing225+ tabs := target.Tabs.Tabs
214- scoped := c.scopedTabs()226+
215- activeIdx := indexOfTab(scoped, activeID)227+ // Compute the next tab to activate BEFORE closing
216- if activeIdx < 0 && len(scoped) > 0 {228+ activeIdx := indexOfTab(tabs, activeID)
217- log.Debug().Str("active_tab_id", string(activeID)).Int("scoped_count", len(scoped)).Msg("active tab not in scoped list")
218- }
219 229
220- var nextScopedID entity.TabID230+ var nextID entity.TabID
221- if activeIdx >= 0 && len(scoped) > 1 {231+ if activeIdx >= 0 && len(tabs) > 1 {
222- if activeIdx < len(scoped)-1 {232+ if activeIdx < len(tabs)-1 {
223- nextScopedID = scoped[activeIdx+1].ID233+ nextID = tabs[activeIdx+1].ID
224 } else {234 } else {
225- nextScopedID = scoped[activeIdx-1].ID235+ nextID = tabs[activeIdx-1].ID
226 }236 }
227 }237 }
228 238
229- wasLast, err := c.tabsUC.Close(ctx, c.tabs, activeID)239+ wasLast, err := c.tabsUC.Close(ctx, target.Tabs, activeID)
230 if err != nil {240 if err != nil {
231 log.Error().Err(err).Msg("failed to close tab")241 log.Error().Err(err).Msg("failed to close tab")
232 return err242 return err
233 }243 }
234 244
235- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {245+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
236- c.mainWindow.TabBar().RemoveTab(activeID)246+ target.MainWindow.TabBar().RemoveTab(activeID)
237 }247 }
238 248
239 // Update tab bar visibility249 // Update tab bar visibility
240- c.UpdateBarVisibility(ctx)250+ c.UpdateBarVisibility(ctx, target)
241 251
242- // Quit if no tabs left globally252+ // Target TabList became empty — signal window-empty.
253+ // App decides whether to quit (all windows empty) or only close this window.
243 if wasLast {254 if wasLast {
244- if c.onQuit != nil {255+ c.notifyStateChanged()
245- c.onQuit()256+ if c.onCurrentWindowEmpty != nil {
257+ c.onCurrentWindowEmpty(ctx, target)
246 }258 }
247- log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed, last globally")259+ log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, target window empty")
248 return nil260 return nil
249 }261 }
250 262
251 // If there's a sibling tab in the same window, switch to it263 // If there's a sibling tab in the same window, switch to it
252- if nextScopedID != "" {264+ if nextID != "" {
253- if err := c.tabsUC.Switch(ctx, c.tabs, nextScopedID); err != nil {265+ if err := c.tabsUC.Switch(ctx, target.Tabs, nextID); err != nil {
254- log.Error().Err(err).Str("tab_id", string(nextScopedID)).Msg("failed to switch to scoped sibling")266+ log.Error().Err(err).Str("tab_id", string(nextID)).Msg("failed to switch to sibling")
255 return err267 return err
256 }268 }
257 269
258- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {270+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
259- c.mainWindow.TabBar().SetActive(nextScopedID)271+ target.MainWindow.TabBar().SetActive(nextID)
260 }272 }
261 273
262 if c.onTabSwitched != nil {274 if c.onTabSwitched != nil {
263- if tab := c.tabs.Find(nextScopedID); tab != nil {275+ if tab := target.Tabs.Find(nextID); tab != nil {
264- c.onTabSwitched(ctx, tab)276+ c.onTabSwitched(ctx, target, tab)
265 }277 }
266 }278 }
267 279
268 // Notify state change for session snapshots280 // Notify state change for session snapshots
269 c.notifyStateChanged()281 c.notifyStateChanged()
270 282
271- log.Debug().Str("tab_id", string(activeID)).Str("next", string(nextScopedID)).Msg("tab closed, switched to scoped sibling")283+ log.Debug().Str("tab_id", string(activeID)).Str("next", string(nextID)).Msg("tab closed, switched to sibling")
272 return nil284 return nil
273 }285 }
274 286
275- // Last tab in this window but not globally last — close the window287+ return c.recoverCloseWithoutSibling(ctx, target, activeID)
288+}
289+
290+func (c *TabCoordinator) recoverCloseWithoutSibling(ctx context.Context, target TabTarget, closedID entity.TabID) error {
291+ log := logging.FromContext(ctx)
292+ // Defensive corrupted-state guard: only treat the target as empty if it is
293+ // actually empty after close; otherwise recover by activating a surviving tab.
276 c.notifyStateChanged()294 c.notifyStateChanged()
277- if c.onCurrentWindowEmpty != nil {295+ if len(target.Tabs.Tabs) == 0 {
278- c.onCurrentWindowEmpty(ctx, c.mainWindow)296+ if c.onCurrentWindowEmpty != nil {
297+ c.onCurrentWindowEmpty(ctx, target)
298+ }
299+ log.Debug().Str("tab_id", string(closedID)).Msg("tab closed, target window empty")
300+ return nil
279 }301 }
280-302+ for _, tab := range target.Tabs.Tabs {
281- log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, last in window")303+ if tab != nil {
282- return nil304+ return c.Switch(ctx, target, tab.ID)
305+ }
306+ }
307+ return fmt.Errorf("tab closed but no surviving tab could be activated")
283 }308 }
284 309
285-// Switch switches to a specific tab by ID.310+// Switch switches to a specific tab by ID in the given target.
286-func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {311+func (c *TabCoordinator) Switch(ctx context.Context, target TabTarget, tabID entity.TabID) error {
287 log := logging.FromContext(ctx)312 log := logging.FromContext(ctx)
288 313
314+ if target.Tabs == nil {
315+ return fmt.Errorf("target.Tabs is nil")
316+ }
317+
289 // Skip if already active318 // Skip if already active
290- if tabID == c.tabs.ActiveTabID {319+ if tabID == target.Tabs.ActiveTabID {
291 return nil320 return nil
292 }321 }
293 322
294- // Reject switching to a tab outside the current window scope323+ // Verify the tab exists in this target
295- if tab := c.tabs.Find(tabID); tab != nil && c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {324+ tab := target.Tabs.Find(tabID)
296- log.Debug().325+ if tab == nil {
297- Str("tab_id", string(tabID)).326+ log.Debug().Str("tab_id", string(tabID)).Msg("tab not found in target")
298- Msg("ignoring switch to tab outside current window scope")
299 return nil327 return nil
300 }328 }
301 329
302 log.Debug().330 log.Debug().
303- Str("from", string(c.tabs.ActiveTabID)).331+ Str("from", string(target.Tabs.ActiveTabID)).
304 Str("to", string(tabID)).332 Str("to", string(tabID)).
305 Msg("switching tab")333 Msg("switching tab")
306 334
307 // Update domain state335 // Update domain state
308- if err := c.tabsUC.Switch(ctx, c.tabs, tabID); err != nil {336+ if err := c.tabsUC.Switch(ctx, target.Tabs, tabID); err != nil {
309 log.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")337 log.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")
310 return err338 return err
311 }339 }
312 340
313 // Update tab bar UI341 // Update tab bar UI
314- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {342+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
315- c.mainWindow.TabBar().SetActive(tabID)343+ target.MainWindow.TabBar().SetActive(tabID)
316 }344 }
317 345
318 // Invoke callback for workspace view switching346 // Invoke callback for workspace view switching
319 if c.onTabSwitched != nil {347 if c.onTabSwitched != nil {
320- if tab := c.tabs.Find(tabID); tab != nil {348+ c.onTabSwitched(ctx, target, tab)
321- c.onTabSwitched(ctx, tab)
322- }
323 }349 }
324 350
351+ c.notifyStateChanged()
352+
325 return nil353 return nil
326 }354 }
327 355
328-// switchRelative switches to the tab delta positions away within the current window scope.356+// switchRelative switches to the tab delta positions away within the target.
329-// delta of +1 switches next, -1 switches previous, wrapping within the scoped list.357+// delta of +1 switches next, -1 switches previous, wrapping within the list.
330-func (c *TabCoordinator) switchRelative(ctx context.Context, delta int) error {358+func (c *TabCoordinator) switchRelative(ctx context.Context, target TabTarget, delta int) error {
331- scoped := c.scopedTabs()359+ if target.Tabs == nil {
332- if len(scoped) <= 1 {360+ return fmt.Errorf("target.Tabs is nil")
361+ }
362+ tabs := target.Tabs.Tabs
363+ if len(tabs) <= 1 {
333 return nil364 return nil
334 }365 }
335 366
336- current := indexOfTab(scoped, c.tabs.ActiveTabID)367+ current := indexOfTab(tabs, target.Tabs.ActiveTabID)
337 if current < 0 {368 if current < 0 {
338 current = 0369 current = 0
339 } else {370 } else {
340- current = (current + delta + len(scoped)) % len(scoped)371+ current = (current + delta + len(tabs)) % len(tabs)
341 }372 }
342 373
343- return c.Switch(ctx, scoped[current].ID)374+ return c.Switch(ctx, target, tabs[current].ID)
344 }375 }
345 376
346-// SwitchNext switches to the next tab within the current window scope.377+// SwitchNext switches to the next tab within the given target.
347-func (c *TabCoordinator) SwitchNext(ctx context.Context) error {378+func (c *TabCoordinator) SwitchNext(ctx context.Context, target TabTarget) error {
348- return c.switchRelative(ctx, 1)379+ return c.switchRelative(ctx, target, 1)
349 }380 }
350 381
351-// SwitchPrev switches to the previous tab within the current window scope.382+// SwitchPrev switches to the previous tab within the given target.
352-func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {383+func (c *TabCoordinator) SwitchPrev(ctx context.Context, target TabTarget) error {
353- return c.switchRelative(ctx, -1)384+ return c.switchRelative(ctx, target, -1)
354 }385 }
355 386
356-// SwitchByIndex switches to a tab by 0-based index within the current window scope.387+// SwitchByIndexCreating switches to a tab by 0-based index within the given target,
357-func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {388+// creating new tabs as needed to reach the requested index.
389+// If the requested index is beyond the current tab count and initialURL is empty,
390+// it returns an error without mutating tabs. If index < 0, it logs debug and returns nil.
391+func (c *TabCoordinator) SwitchByIndexCreating(ctx context.Context, target TabTarget, index int, initialURL string) error {
358 log := logging.FromContext(ctx)392 log := logging.FromContext(ctx)
359 393
360- scoped := c.scopedTabs()394+ if target.Tabs == nil {
361- if index < 0 || index >= len(scoped) {395+ return fmt.Errorf("target.Tabs is nil")
362- log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("invalid scoped tab index")
363- return nil
364 }396 }
365-
366- return c.Switch(ctx, scoped[index].ID)
367-}
368-
369-// EnsureTabByIndex creates tabs until the requested index exists within the current
370-// window scope, then switches to it.
371-func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initialURL string) error {
372- log := logging.FromContext(ctx)
373-
374 if index < 0 {397 if index < 0 {
375- log.Debug().Int("index", index).Msg("invalid tab index")398+ log.Debug().Int("index", index).Msg("invalid tab index, ignoring")
376 return nil399 return nil
377 }400 }
378-401+ if target.Tabs.Count() <= index && initialURL == "" {
379- if c.tabs == nil {402+ return fmt.Errorf("initial URL is required to create missing tabs")
380- return fmt.Errorf("tab list is required")
381 }403 }
382-404+ // Intermediate tabs are created inactive (create(..., false)) and the final
383- // Create tabs within the current window scope until we have enough405+ // missing tab is created active (create(..., true)) before SwitchByIndex.
384- scoped := c.scopedTabs()406+ for target.Tabs.Count() < index {
385- if len(scoped) <= index && initialURL == "" {407+ if _, err := c.create(ctx, target, initialURL, false); err != nil {
386- log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("cannot auto-create missing tabs without initial URL")408+ return err
387- return fmt.Errorf("initial URL is required to auto-create missing tabs")409+ }
388 }410 }
389-411+ if target.Tabs.Count() <= index {
390- for len(c.scopedTabs()) <= index {412+ if _, err := c.create(ctx, target, initialURL, true); err != nil {
391- if _, err := c.create(ctx, initialURL, false); err != nil {
392- log.Error().Err(err).Int("index", index).Msg("failed to create tab while ensuring tab index")
393 return err413 return err
394 }414 }
395 }415 }
396-416+ return c.SwitchByIndex(ctx, target, index)
397- return c.SwitchByIndex(ctx, index)
398 }417 }
399 418
400-// SwitchToLastActive switches to the previously active tab (Alt+Tab style).419+// SwitchByIndex switches to a tab by 0-based index within the given target.
401-// When window-scoped (tabScope is set and previousActiveTabID provider is available),420+func (c *TabCoordinator) SwitchByIndex(ctx context.Context, target TabTarget, index int) error {
402-// it uses the per-window previous active tab ID. Falls back to the global
403-// PreviousActiveTabID otherwise.
404-func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {
405 log := logging.FromContext(ctx)421 log := logging.FromContext(ctx)
406 422
407- var prevID entity.TabID423+ if target.Tabs == nil {
408- if c.tabScope != nil && c.previousActiveTabID != nil {424+ return fmt.Errorf("target.Tabs is nil")
409- // Use per-window previous active tab ID when scoped425+ }
410- prevID = c.previousActiveTabID(c.mainWindow)426+ tabs := target.Tabs.Tabs
411- } else {427+ if index < 0 || index >= len(tabs) {
412- // Fall back to global PreviousActiveTabID when not scoped or no provider428+ log.Debug().Int("index", index).Int("count", len(tabs)).Msg("invalid tab index")
413- prevID = c.tabs.PreviousActiveTabID429+ return nil
414 }430 }
415 431
416- if prevID == "" || prevID == c.tabs.ActiveTabID {432+ return c.Switch(ctx, target, tabs[index].ID)
433+}
434+
435+// SwitchToLastActive switches to the previously active tab (Alt+Tab style)
436+// within the given target.
437+func (c *TabCoordinator) SwitchToLastActive(ctx context.Context, target TabTarget) error {
438+ if target.Tabs == nil {
417 return nil439 return nil
418 }440 }
419 441
420- tab := c.tabs.Find(prevID)442+ prevID := target.Tabs.PreviousActiveTabID
421- if tab == nil {443+ if prevID == "" || prevID == target.Tabs.ActiveTabID {
422- // Only clear the global previous active tab ID if that's what we used
423- if c.tabScope == nil || c.previousActiveTabID == nil {
424- c.tabs.PreviousActiveTabID = ""
425- }
426 return nil444 return nil
427 }445 }
428 446
429- // With a scope function, only allow switching to the previous tab if it's in scope.447+ tab := target.Tabs.Find(prevID)
430- if c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {448+ if tab == nil {
431- log.Debug().Str("tab_id", string(prevID)).Msg("previous active tab outside current window scope")449+ target.Tabs.PreviousActiveTabID = ""
432 return nil450 return nil
433 }451 }
434 452
435- return c.Switch(ctx, prevID)453+ return c.Switch(ctx, target, prevID)
436 }454 }
437 455
438-// UpdateBarVisibility shows or hides the tab bar based on tab count.456+// UpdateBarVisibility shows or hides the tab bar based on tab count in the target.
439-func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {457+func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context, target TabTarget) {
440 log := logging.FromContext(ctx)458 log := logging.FromContext(ctx)
441 459
442- // Check if feature is enabled460+ if target.MainWindow == nil || target.MainWindow.TabBar() == nil {
443- hideEnabled := c.hideTabBarWhenSingleTab461+ log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update")
444-
445- if !hideEnabled {
446- log.Debug().Msg("tab bar auto-hide disabled by config")
447 return462 return
448 }463 }
449 464
450- if c.mainWindow == nil || c.mainWindow.TabBar() == nil {465+ // Check if feature is enabled
451- log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update")466+ if !c.hideTabBarWhenSingleTab {
467+ log.Debug().Msg("tab bar auto-hide disabled by config")
468+ target.MainWindow.TabBar().SetAutoHidden(false)
469+ target.MainWindow.SetTabBarContentInsetVisible(true)
452 return470 return
453 }471 }
454 472
455- tabCount := c.mainWindow.TabBar().Count()473+ tabCount := target.MainWindow.TabBar().Count()
456 shouldShow := tabCount > 1474 shouldShow := tabCount > 1
457 475
458 log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")476 log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")
459- c.mainWindow.TabBar().SetVisible(shouldShow)477+ target.MainWindow.TabBar().SetAutoHidden(!shouldShow)
478+ target.MainWindow.SetTabBarContentInsetVisible(shouldShow)
460 }479 }
461 480
462-// GetTabBar returns the tab bar component.481+// GetTabBar returns the tab bar component from the current main window.
463 func (c *TabCoordinator) GetTabBar() *component.TabBar {482 func (c *TabCoordinator) GetTabBar() *component.TabBar {
464 if c.mainWindow != nil {483 if c.mainWindow != nil {
465 return c.mainWindow.TabBar()484 return c.mainWindow.TabBar()
@@ -467,23 +486,23 @@ func (c *TabCoordinator) GetTabBar() *component.TabBar {
467 return nil486 return nil
468 }487 }
469 488
470-// SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.489+// CreateWithPane creates a new tab with a pre-created pane and WebView in the given target.
471-func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView)) {
472- c.onAttachPopupToTab = fn
473-}
474-
475-// CreateWithPane creates a new tab with a pre-created pane and WebView.
476 // This is used for tabbed popup behavior where the popup pane already exists.490 // This is used for tabbed popup behavior where the popup pane already exists.
477 func (c *TabCoordinator) CreateWithPane(491 func (c *TabCoordinator) CreateWithPane(
478 ctx context.Context,492 ctx context.Context,
493+ target TabTarget,
479 pane *entity.Pane,494 pane *entity.Pane,
480 wv port.WebView,495 wv port.WebView,
481 initialURL string,496 initialURL string,
482 ) (*entity.Tab, error) {497 ) (*entity.Tab, error) {
483 log := logging.FromContext(ctx)498 log := logging.FromContext(ctx)
484 499
500+ if target.Tabs == nil {
501+ return nil, fmt.Errorf("target.Tabs is nil")
502+ }
503+
485 output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{504 output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{
486- TabList: c.tabs,505+ TabList: target.Tabs,
487 Name: pane.Title,506 Name: pane.Title,
488 Pane: pane,507 Pane: pane,
489 InitialURL: initialURL,508 InitialURL: initialURL,
@@ -494,23 +513,21 @@ func (c *TabCoordinator) CreateWithPane(
494 }513 }
495 514
496 // Set new tab as active515 // Set new tab as active
497- c.tabs.SetActive(output.Tab.ID)516+ target.Tabs.SetActive(output.Tab.ID)
498 517
499 // Notify app before adding tab to the visible tab bar.518 // Notify app before adding tab to the visible tab bar.
500- // The app assigns window ownership and the scoped default name here,
501- // and TabBar.AddTab reads tab.Title() immediately when creating the button.
502 if c.onTabCreated != nil {519 if c.onTabCreated != nil {
503- c.onTabCreated(ctx, output.Tab)520+ c.onTabCreated(ctx, target, output.Tab)
504 }521 }
505 522
506 // Update tab bar523 // Update tab bar
507- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {524+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
508- c.mainWindow.TabBar().AddTab(output.Tab)525+ target.MainWindow.TabBar().AddTab(output.Tab)
509- c.mainWindow.TabBar().SetActive(output.Tab.ID)526+ target.MainWindow.TabBar().SetActive(output.Tab.ID)
510 }527 }
511 528
512 // Update tab bar visibility529 // Update tab bar visibility
513- c.UpdateBarVisibility(ctx)530+ c.UpdateBarVisibility(ctx, target)
514 531
515 // Attach the popup WebView to the new tab's workspace532 // Attach the popup WebView to the new tab's workspace
516 if c.onAttachPopupToTab != nil {533 if c.onAttachPopupToTab != nil {
@@ -519,7 +536,7 @@ func (c *TabCoordinator) CreateWithPane(
519 536
520 // Switch to the new tab's workspace view537 // Switch to the new tab's workspace view
521 if c.onTabSwitched != nil {538 if c.onTabSwitched != nil {
522- c.onTabSwitched(ctx, output.Tab)539+ c.onTabSwitched(ctx, target, output.Tab)
523 }540 }
524 541
525 // Notify state change for session snapshots542 // Notify state change for session snapshots
A internal/ui/coordinator/tab_test.go +273 -0
@@ -0,0 +1,273 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "strconv"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/ui/window"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// TestTabCoordinator_CloseTargetEmptyDoesNotQuitGlobally verifies that
16+// closing the only tab in one window fires onCurrentWindowEmpty and does NOT
17+// call onQuit when another browser window still has tabs.
18+func TestTabCoordinator_CloseTargetEmptyDoesNotQuitGlobally(t *testing.T) {
19+ ctx := context.Background()
20+
21+ // Two independent TabLists, each with one tab.
22+ firstTabs := entity.NewTabList()
23+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
24+
25+ secondTabs := entity.NewTabList()
26+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second"))))
27+
28+ gen := counterIDGen()
29+ tabsUC := usecase.NewManageTabsUseCase(gen)
30+
31+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
32+ TabsUC: tabsUC,
33+ })
34+
35+ firstMainWindow := &window.MainWindow{}
36+ secondMainWindow := &window.MainWindow{}
37+ firstTarget := TabTarget{Tabs: firstTabs, MainWindow: firstMainWindow}
38+ secondTarget := TabTarget{Tabs: secondTabs, MainWindow: secondMainWindow}
39+
40+ var (
41+ quitCalled bool
42+ windowEmptyCalled bool
43+ windowEmptyTarget *window.MainWindow
44+ )
45+
46+ coord.SetOnQuit(func() {
47+ quitCalled = true
48+ })
49+ coord.SetOnCurrentWindowEmpty(func(_ context.Context, target TabTarget) {
50+ windowEmptyCalled = true
51+ windowEmptyTarget = target.MainWindow
52+ })
53+
54+ // Close the only tab in the first window.
55+ err := coord.Close(ctx, firstTarget)
56+ require.NoError(t, err, "Close on first target returned error")
57+
58+ // onQuit must NOT be called (second window still has tabs).
59+ assert.False(t, quitCalled, "onQuit was called, but second window still has tabs")
60+
61+ // onCurrentWindowEmpty must be called for the first window.
62+ assert.True(t, windowEmptyCalled, "onCurrentWindowEmpty was not called for the emptied window")
63+ assert.Same(t, firstMainWindow, windowEmptyTarget)
64+
65+ // firstTabs must be empty.
66+ assert.Equal(t, 0, firstTabs.Count())
67+
68+ // secondTabs must be unchanged (still has its tab).
69+ assert.Equal(t, 1, secondTabs.Count())
70+ assert.Equal(t, entity.TabID("second-tab"), secondTabs.ActiveTabID)
71+
72+ // Closing the last tab globally (second window's only tab) should also fire window-empty.
73+ windowEmptyCalled = false
74+ windowEmptyTarget = nil
75+ err = coord.Close(ctx, secondTarget)
76+ require.NoError(t, err, "Close on second target returned error")
77+
78+ // onQuit must still NOT be called (App decides when to quit).
79+ assert.False(t, quitCalled, "onQuit was called on last window; onQuit is no longer used by Close")
80+
81+ // onCurrentWindowEmpty must be called for the second window.
82+ assert.True(t, windowEmptyCalled, "onCurrentWindowEmpty was not called for the last window")
83+ assert.Same(t, secondMainWindow, windowEmptyTarget)
84+}
85+
86+// counterIDGen returns a function that generates sequential IDs.
87+func counterIDGen() func() string {
88+ var counter int
89+ return func() string {
90+ counter++
91+ return strconv.Itoa(counter)
92+ }
93+}
94+
95+func TestTabCoordinator_SetMainWindowNilClearsCurrentTargetWindowPreservingTabs(t *testing.T) {
96+ tabs := entity.NewTabList()
97+ mainWindow := &window.MainWindow{}
98+ coord := NewTabCoordinator(context.Background(), TabCoordinatorConfig{
99+ Tabs: tabs,
100+ MainWindow: mainWindow,
101+ })
102+
103+ coord.SetMainWindow(nil)
104+
105+ got := coord.CurrentTarget()
106+ if got.MainWindow != nil {
107+ t.Fatalf("current target main window = %p, want nil", got.MainWindow)
108+ }
109+ if got.Tabs != tabs {
110+ t.Fatalf("current target tabs = %p, want %p", got.Tabs, tabs)
111+ }
112+}
113+
114+func TestTabCoordinator_SwitchByIndexUsesTargetTabListOnly(t *testing.T) {
115+ ctx := context.Background()
116+
117+ // Create two independent TabLists, each with two tabs.
118+ firstTabs := entity.NewTabList()
119+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab-1"), entity.WorkspaceID("ws-first-1"), entity.NewPane(entity.PaneID("pane-first-1"))))
120+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab-2"), entity.WorkspaceID("ws-first-2"), entity.NewPane(entity.PaneID("pane-first-2"))))
121+
122+ secondTabs := entity.NewTabList()
123+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
124+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
125+
126+ gen := counterIDGen()
127+ tabsUC := usecase.NewManageTabsUseCase(gen)
128+
129+ // Create TabCoordinator without global tabs (target-driven).
130+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
131+ TabsUC: tabsUC,
132+ // No Tabs, no MainWindow — target is provided per-call.
133+ })
134+
135+ firstTarget := TabTarget{Tabs: firstTabs}
136+ secondTarget := TabTarget{Tabs: secondTabs}
137+
138+ // Confirm initial active tab IDs are as expected (first tab in each list).
139+ if got := firstTabs.ActiveTabID; got != entity.TabID("first-tab-1") {
140+ t.Fatalf("firstTabs.ActiveTabID = %q, want %q", got, "first-tab-1")
141+ }
142+ if got := secondTabs.ActiveTabID; got != entity.TabID("second-tab-1") {
143+ t.Fatalf("secondTabs.ActiveTabID = %q, want %q", got, "second-tab-1")
144+ }
145+
146+ // Switch second window to its second tab (index 1).
147+ err := coord.SwitchByIndex(ctx, secondTarget, 1)
148+ if err != nil {
149+ t.Fatalf("SwitchByIndex on second target failed: %v", err)
150+ }
151+
152+ _ = firstTarget // first target is deliberately not passed to SwitchByIndex below
153+
154+ // Assert firstTabs.ActiveTabID is unchanged.
155+ if got := firstTabs.ActiveTabID; got != entity.TabID("first-tab-1") {
156+ t.Fatalf("firstTabs.ActiveTabID = %q, want %q (should be unchanged)", got, "first-tab-1")
157+ }
158+
159+ if got := secondTabs.PreviousActiveTabID; got != entity.TabID("second-tab-1") {
160+ t.Fatalf("secondTabs.PreviousActiveTabID = %q, want %q", got, "second-tab-1")
161+ }
162+
163+ // Assert secondTabs.ActiveTabID is now the second window's tab 2.
164+ if got := secondTabs.ActiveTabID; got != entity.TabID("second-tab-2") {
165+ t.Fatalf("secondTabs.ActiveTabID = %q, want %q", got, "second-tab-2")
166+ }
167+}
168+
169+func TestTabCoordinator_CreateAndSwitchCallbacksCarryTarget(t *testing.T) {
170+ ctx := context.Background()
171+
172+ firstTabs := entity.NewTabList()
173+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
174+ secondTabs := entity.NewTabList()
175+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
176+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
177+
178+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})
179+ coord.SetCurrentTarget(TabTarget{Tabs: firstTabs})
180+
181+ secondTarget := TabTarget{Tabs: secondTabs}
182+ var createdTarget TabTarget
183+ var switchedTargets []TabTarget
184+ coord.SetOnTabCreated(func(_ context.Context, target TabTarget, tab *entity.Tab) {
185+ createdTarget = target
186+ })
187+ coord.SetOnTabSwitched(func(_ context.Context, target TabTarget, tab *entity.Tab) {
188+ switchedTargets = append(switchedTargets, target)
189+ })
190+
191+ if _, err := coord.Create(ctx, secondTarget, "https://example.com"); err != nil {
192+ t.Fatalf("Create returned error: %v", err)
193+ }
194+ if createdTarget.Tabs != secondTabs {
195+ t.Fatalf("created callback target tabs = %p, want %p", createdTarget.Tabs, secondTabs)
196+ }
197+ if len(switchedTargets) != 1 || switchedTargets[0].Tabs != secondTabs {
198+ t.Fatalf("create switch callback targets = %#v, want second target", switchedTargets)
199+ }
200+
201+ switchedTargets = nil
202+ if err := coord.Switch(ctx, secondTarget, entity.TabID("second-tab-1")); err != nil {
203+ t.Fatalf("Switch returned error: %v", err)
204+ }
205+ if len(switchedTargets) != 1 || switchedTargets[0].Tabs != secondTabs {
206+ t.Fatalf("switch callback targets = %#v, want second target", switchedTargets)
207+ }
208+ if firstTabs.ActiveTabID != entity.TabID("first-tab") {
209+ t.Fatalf("first target active tab changed to %q", firstTabs.ActiveTabID)
210+ }
211+}
212+
213+// TestTabCoordinator_SwitchByIndexCreatingCreatesMissingTabs verifies that
214+// SwitchByIndexCreating creates new tabs when the requested index exceeds
215+// the current tab count, and only modifies the specified target.
216+func TestTabCoordinator_SwitchByIndexCreatingCreatesMissingTabs(t *testing.T) {
217+ ctx := context.Background()
218+
219+ firstTabs := entity.NewTabList()
220+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
221+
222+ secondTabs := entity.NewTabList()
223+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
224+
225+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})
226+
227+ secondTarget := TabTarget{Tabs: secondTabs}
228+
229+ // Call SwitchByIndexCreating with index 3 (0-based), which is beyond the
230+ // current 1 tab. This should create 3 new tabs (indices 1, 2, 3) and
231+ // activate index 3.
232+ err := coord.SwitchByIndexCreating(ctx, secondTarget, 3, "about:blank")
233+ require.NoError(t, err, "SwitchByIndexCreating should not return error")
234+
235+ // secondTabs should have 4 tabs now (1 existing + 3 created).
236+ require.Equal(t, 4, secondTabs.Count(), "second target should have 4 tabs after creating up to index 3")
237+ require.Greater(t, len(secondTabs.Tabs), 3, "secondTabs.Tabs must have at least 4 elements")
238+ assert.Equal(t, secondTabs.Tabs[3].ID, secondTabs.ActiveTabID, "active tab should be at index 3")
239+
240+ // Verify created tabs/panes have URI "about:blank".
241+ for i := 1; i <= 3; i++ {
242+ require.Less(t, i, len(secondTabs.Tabs), "tab index %d out of range", i)
243+ require.NotNil(t, secondTabs.Tabs[i].Workspace, "tab %d workspace should not be nil", i)
244+ pane := secondTabs.Tabs[i].Workspace.ActivePane()
245+ require.NotNil(t, pane, "tab %d ActivePane should not be nil", i)
246+ require.NotNil(t, pane.Pane, "tab %d pane.Pane should not be nil", i)
247+ assert.Equal(t, "about:blank", pane.Pane.URI, "tab %d pane URI should be about:blank", i)
248+ }
249+
250+ // firstTabs must be completely unchanged.
251+ assert.Equal(t, 1, firstTabs.Count(), "first target should still have 1 tab")
252+ assert.Equal(t, entity.TabID("first-tab"), firstTabs.ActiveTabID, "first target active tab should be unchanged")
253+}
254+
255+// TestTabCoordinator_SwitchByIndexCreatingMissingURLError verifies that
256+// SwitchByIndexCreating returns an error when the initialURL is empty.
257+func TestTabCoordinator_SwitchByIndexCreatingMissingURLError(t *testing.T) {
258+ ctx := context.Background()
259+
260+ targetTabs := entity.NewTabList()
261+ targetTabs.Add(entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1"))))
262+
263+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})
264+
265+ target := TabTarget{Tabs: targetTabs}
266+
267+ // Call with empty URL — should return an error.
268+ err := coord.SwitchByIndexCreating(ctx, target, 1, "")
269+ require.Error(t, err, "SwitchByIndexCreating with empty URL should return error")
270+
271+ // Tab count should remain 1.
272+ assert.Equal(t, 1, targetTabs.Count(), "tab count should remain 1 after error")
273+}
M internal/ui/deps.go +2 -2
@@ -77,9 +77,9 @@ type Dependencies struct {
77 SessionRepo repository.SessionRepository77 SessionRepo repository.SessionRepository
78 CurrentSessionID entity.SessionID78 CurrentSessionID entity.SessionID
79 SnapshotUC *usecase.SnapshotSessionUseCase79 SnapshotUC *usecase.SnapshotSessionUseCase
80- // SnapshotServiceFactory creates a snapshot service bound to the given tab-list provider.80+ // SnapshotServiceFactory creates a snapshot service bound to the given window state provider.
81 // Called after the App is initialized so the App can serve as the provider.81 // Called after the App is initialized so the App can serve as the provider.
82- SnapshotServiceFactory func(provider port.TabListProvider, intervalMs int) port.SnapshotService82+ SnapshotServiceFactory func(provider port.WindowStateProvider, intervalMs int) port.SnapshotService
83 // SessionSpawner spawns a new dumber instance for session restoration.83 // SessionSpawner spawns a new dumber instance for session restoration.
84 SessionSpawner port.SessionSpawner84 SessionSpawner port.SessionSpawner
85 // FileSystem provides file operations (e.g., for download deduplication).85 // FileSystem provides file operations (e.g., for download deduplication).
M internal/ui/dispatcher/keyboard.go +96 -27
@@ -4,9 +4,9 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 6
7+ "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
9- domainurl "github.com/bnema/dumber/internal/domain/url"
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/dumber/internal/ui/component"11 "github.com/bnema/dumber/internal/ui/component"
12 "github.com/bnema/dumber/internal/ui/coordinator"12 "github.com/bnema/dumber/internal/ui/coordinator"
@@ -20,14 +20,23 @@ const (
20 configSystemViewURL = "dumb://config"20 configSystemViewURL = "dumb://config"
21 )21 )
22 22
23+type KeyboardActions struct {
24+ NewTab func(context.Context) error
25+ CloseTab func(context.Context) error
26+ NextTab func(context.Context) error
27+ PreviousTab func(context.Context) error
28+ SwitchLastTab func(context.Context) error
29+ SwitchTabIndex func(context.Context, int) error
30+ ActiveWebView func(context.Context) port.WebView
31+}
32+
23 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.33 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
24 type KeyboardDispatcher struct {34 type KeyboardDispatcher struct {
25- tabCoord *coordinator.TabCoordinator35+ actions KeyboardActions
26 wsCoord *coordinator.WorkspaceCoordinator36 wsCoord *coordinator.WorkspaceCoordinator
27 navCoord *coordinator.NavigationCoordinator37 navCoord *coordinator.NavigationCoordinator
28 zoomUC *usecase.ManageZoomUseCase38 zoomUC *usecase.ManageZoomUseCase
29 copyURLUC *usecase.CopyURLUseCase39 copyURLUC *usecase.CopyURLUseCase
30- newPaneURL string
31 actionHandlers map[input.Action]func(ctx context.Context) error40 actionHandlers map[input.Action]func(ctx context.Context) error
32 onQuit func()41 onQuit func()
33 onFindOpen func(ctx context.Context) error42 onFindOpen func(ctx context.Context) error
@@ -45,24 +54,22 @@ type KeyboardDispatcher struct {
45 // NewKeyboardDispatcher creates a new KeyboardDispatcher.54 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
46 func NewKeyboardDispatcher(55 func NewKeyboardDispatcher(
47 ctx context.Context,56 ctx context.Context,
48- tabCoord *coordinator.TabCoordinator,
49 wsCoord *coordinator.WorkspaceCoordinator,57 wsCoord *coordinator.WorkspaceCoordinator,
50 navCoord *coordinator.NavigationCoordinator,58 navCoord *coordinator.NavigationCoordinator,
51 zoomUC *usecase.ManageZoomUseCase,59 zoomUC *usecase.ManageZoomUseCase,
52 copyURLUC *usecase.CopyURLUseCase,60 copyURLUC *usecase.CopyURLUseCase,
53- newPaneURL string,61+ actions KeyboardActions,
54 activePaneID func(context.Context) entity.PaneID,62 activePaneID func(context.Context) entity.PaneID,
55 ) *KeyboardDispatcher {63 ) *KeyboardDispatcher {
56 log := logging.FromContext(ctx)64 log := logging.FromContext(ctx)
57 log.Debug().Msg("creating keyboard dispatcher")65 log.Debug().Msg("creating keyboard dispatcher")
58 66
59 dispatcher := &KeyboardDispatcher{67 dispatcher := &KeyboardDispatcher{
60- tabCoord: tabCoord,68+ actions: actions,
61 wsCoord: wsCoord,69 wsCoord: wsCoord,
62 navCoord: navCoord,70 navCoord: navCoord,
63 zoomUC: zoomUC,71 zoomUC: zoomUC,
64 copyURLUC: copyURLUC,72 copyURLUC: copyURLUC,
65- newPaneURL: newPaneURL,
66 activePaneID: activePaneID,73 activePaneID: activePaneID,
67 }74 }
68 dispatcher.initActionHandlers()75 dispatcher.initActionHandlers()
@@ -140,18 +147,15 @@ func (d *KeyboardDispatcher) initActionHandlers() {
140 )147 )
141 d.actionHandlers = map[input.Action]func(ctx context.Context) error{148 d.actionHandlers = map[input.Action]func(ctx context.Context) error{
142 // Tab actions149 // Tab actions
143- input.ActionNewTab: func(ctx context.Context) error {150+ input.ActionNewTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "new tab", d.actions.NewTab) },
144- if d.newPaneURL == "" {151+ input.ActionCloseTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "close tab", d.actions.CloseTab) },
145- logging.FromContext(ctx).Warn().Msg("ActionNewTab: newPaneURL is empty, cannot open new tab")152+ input.ActionNextTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "next tab", d.actions.NextTab) },
146- return fmt.Errorf("newPaneURL is not configured")153+ input.ActionPreviousTab: func(ctx context.Context) error {
147- }154+ return d.handleKeyboardAction(ctx, "previous tab", d.actions.PreviousTab)
148- _, err := d.tabCoord.Create(ctx, domainurl.Normalize(d.newPaneURL))155+ },
149- return err156+ input.ActionSwitchLastTab: func(ctx context.Context) error {
157+ return d.handleKeyboardAction(ctx, "switch last tab", d.actions.SwitchLastTab)
150 },158 },
151- input.ActionCloseTab: d.tabCoord.Close,
152- input.ActionNextTab: d.tabCoord.SwitchNext,
153- input.ActionPreviousTab: d.tabCoord.SwitchPrev,
154- input.ActionSwitchLastTab: d.tabCoord.SwitchToLastActive,
155 input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, firstTabIndex) },159 input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, firstTabIndex) },
156 input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, secondTabIndex) },160 input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, secondTabIndex) },
157 input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, thirdTabIndex) },161 input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, thirdTabIndex) },
@@ -209,11 +213,11 @@ func (d *KeyboardDispatcher) initActionHandlers() {
209 input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },213 input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },
210 input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },214 input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },
211 // Navigation215 // Navigation
212- input.ActionGoBack: d.navCoord.GoBack,216+ input.ActionGoBack: d.handleGoBack,
213- input.ActionGoForward: d.navCoord.GoForward,217+ input.ActionGoForward: d.handleGoForward,
214- input.ActionReload: d.navCoord.Reload,218+ input.ActionReload: d.handleReload,
215- input.ActionHardReload: d.navCoord.HardReload,219+ input.ActionHardReload: d.handleHardReload,
216- input.ActionPrintPage: d.navCoord.PrintPage,220+ input.ActionPrintPage: d.handlePrintPage,
217 // Zoom actions221 // Zoom actions
218 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },222 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
219 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },223 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
@@ -224,7 +228,7 @@ func (d *KeyboardDispatcher) initActionHandlers() {
224 input.ActionFindNext: d.handleFindNext,228 input.ActionFindNext: d.handleFindNext,
225 input.ActionFindPrev: d.handleFindPrev,229 input.ActionFindPrev: d.handleFindPrev,
226 input.ActionCloseFind: d.handleFindClose,230 input.ActionCloseFind: d.handleFindClose,
227- input.ActionOpenDevTools: d.navCoord.OpenDevTools,231+ input.ActionOpenDevTools: d.handleOpenDevTools,
228 input.ActionToggleFloatingPane: func(ctx context.Context) error {232 input.ActionToggleFloatingPane: func(ctx context.Context) error {
229 if d.onToggleFloating != nil {233 if d.onToggleFloating != nil {
230 return d.onToggleFloating(ctx)234 return d.onToggleFloating(ctx)
@@ -330,7 +334,27 @@ func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error
330 }334 }
331 335
332 func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {336 func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {
333- return d.tabCoord.EnsureTabByIndex(ctx, index, domainurl.Normalize(d.newPaneURL))337+ if d.actions.SwitchTabIndex == nil {
338+ logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index action ignored (no handler)")
339+ return nil
340+ }
341+ return d.actions.SwitchTabIndex(ctx, index)
342+}
343+
344+func (d *KeyboardDispatcher) handleKeyboardAction(ctx context.Context, name string, fn func(context.Context) error) error {
345+ if fn == nil {
346+ logging.FromContext(ctx).Debug().Str("action", name).Msg("keyboard action ignored (no handler)")
347+ return nil
348+ }
349+ return fn(ctx)
350+}
351+
352+func (d *KeyboardDispatcher) activeWebView(ctx context.Context) port.WebView {
353+ if d.actions.ActiveWebView == nil {
354+ logging.FromContext(ctx).Debug().Msg("active webview unavailable (no handler)")
355+ return nil
356+ }
357+ return d.actions.ActiveWebView(ctx)
334 }358 }
335 359
336 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {360 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
@@ -351,6 +375,51 @@ func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error {
351 return nil375 return nil
352 }376 }
353 377
378+func (d *KeyboardDispatcher) withActiveWebView(ctx context.Context, action string, fn func(port.WebView) error) error {
379+ wv := d.activeWebView(ctx)
380+ if wv == nil {
381+ logging.FromContext(ctx).Debug().Str("action", action).Msg("no active webview for keyboard action")
382+ return nil
383+ }
384+ return fn(wv)
385+}
386+
387+func (d *KeyboardDispatcher) handleReload(ctx context.Context) error {
388+ return d.withActiveWebView(ctx, "reload", func(wv port.WebView) error {
389+ return d.navCoord.ReloadWebView(ctx, wv, false)
390+ })
391+}
392+
393+func (d *KeyboardDispatcher) handleHardReload(ctx context.Context) error {
394+ return d.withActiveWebView(ctx, "hard reload", func(wv port.WebView) error {
395+ return d.navCoord.ReloadWebView(ctx, wv, true)
396+ })
397+}
398+
399+func (d *KeyboardDispatcher) handleGoBack(ctx context.Context) error {
400+ return d.withActiveWebView(ctx, "go back", func(wv port.WebView) error {
401+ return d.navCoord.GoBackWebView(ctx, wv)
402+ })
403+}
404+
405+func (d *KeyboardDispatcher) handleGoForward(ctx context.Context) error {
406+ return d.withActiveWebView(ctx, "go forward", func(wv port.WebView) error {
407+ return d.navCoord.GoForwardWebView(ctx, wv)
408+ })
409+}
410+
411+func (d *KeyboardDispatcher) handlePrintPage(ctx context.Context) error {
412+ return d.withActiveWebView(ctx, "print page", func(wv port.WebView) error {
413+ return d.navCoord.PrintWebView(ctx, wv)
414+ })
415+}
416+
417+func (d *KeyboardDispatcher) handleOpenDevTools(ctx context.Context) error {
418+ return d.withActiveWebView(ctx, "open devtools", func(wv port.WebView) error {
419+ return d.navCoord.OpenDevToolsWebView(ctx, wv)
420+ })
421+}
422+
354 // handleZoom processes zoom in/out/reset actions for the active WebView.423 // handleZoom processes zoom in/out/reset actions for the active WebView.
355 func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error {424 func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error {
356 log := logging.FromContext(ctx)425 log := logging.FromContext(ctx)
@@ -360,7 +429,7 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro
360 return nil429 return nil
361 }430 }
362 431
363- wv := d.navCoord.ActiveWebView(ctx)432+ wv := d.activeWebView(ctx)
364 if wv == nil {433 if wv == nil {
365 log.Debug().Msg("no active webview for zoom")434 log.Debug().Msg("no active webview for zoom")
366 return nil435 return nil
@@ -424,7 +493,7 @@ func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
424 return nil493 return nil
425 }494 }
426 495
427- wv := d.navCoord.ActiveWebView(ctx)496+ wv := d.activeWebView(ctx)
428 if wv == nil {497 if wv == nil {
429 log.Debug().Msg("no active webview for copy URL")498 log.Debug().Msg("no active webview for copy URL")
430 return nil499 return nil
M internal/ui/dispatcher/keyboard_test.go +50 -101
@@ -6,7 +6,6 @@ import (
6 6
7 "github.com/bnema/dumber/internal/application/usecase"7 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9- domainurl "github.com/bnema/dumber/internal/domain/url"
10 "github.com/bnema/dumber/internal/ui/component"9 "github.com/bnema/dumber/internal/ui/component"
11 "github.com/bnema/dumber/internal/ui/coordinator"10 "github.com/bnema/dumber/internal/ui/coordinator"
12 "github.com/bnema/dumber/internal/ui/input"11 "github.com/bnema/dumber/internal/ui/input"
@@ -14,105 +13,55 @@ import (
14 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
15 )14 )
16 15
17-func TestKeyboardDispatcher_AltNumberCreatesTabsUntilRequestedIndexExists(t *testing.T) {16+func TestKeyboardDispatcher_TabActionsUseInjectedKeyboardActions(t *testing.T) {
18 ctx := context.Background()17 ctx := context.Background()
19- ids := []string{18+ called := make([]string, 0)
20- "tab-1", "ws-1", "pane-1",19+ var switchedIndex int
21- "tab-2", "ws-2", "pane-2",20+ d := NewKeyboardDispatcher(
22- "tab-3", "ws-3", "pane-3",21+ ctx,
23- }22+ &coordinator.WorkspaceCoordinator{},
24- idx := 023+ &coordinator.NavigationCoordinator{},
25- tabsUC := usecase.NewManageTabsUseCase(func() string {24+ nil,
26- id := ids[idx]25+ nil,
27- idx++26+ KeyboardActions{
28- return id27+ NewTab: func(context.Context) error {
29- })28+ called = append(called, "new")
30- tabs := entity.NewTabList()29+ return nil
31- tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{30+ },
32- TabsUC: tabsUC,31+ CloseTab: func(context.Context) error {
33- Tabs: tabs,32+ called = append(called, "close")
34- })33+ return nil
35-34+ },
36- _, err := tabCoord.Create(ctx, "https://initial.example")35+ NextTab: func(context.Context) error {
37- require.NoError(t, err)36+ called = append(called, "next")
38-37+ return nil
39- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example", func(context.Context) entity.PaneID { return "" })38+ },
40-39+ PreviousTab: func(context.Context) error {
41- err = d.Dispatch(ctx, input.ActionSwitchTabIndex3)40+ called = append(called, "previous")
42- require.NoError(t, err)41+ return nil
43-42+ },
44- require.Len(t, tabs.Tabs, 3)43+ SwitchLastTab: func(context.Context) error {
45- require.NotNil(t, tabs.ActiveTab())44+ called = append(called, "last")
46- assert.Equal(t, tabs.TabAt(2).ID, tabs.ActiveTabID)45+ return nil
47- assert.Equal(t, tabs.TabAt(0).ID, tabs.PreviousActiveTabID)46+ },
48- assert.Equal(t, 2, tabs.ActiveTab().Position)47+ SwitchTabIndex: func(_ context.Context, index int) error {
49- assert.Equal(t, domainurl.Normalize("https://new.example"), tabs.TabAt(1).Workspace.Root.Pane.URI)48+ called = append(called, "index")
50- assert.Equal(t, domainurl.Normalize("https://new.example"), tabs.TabAt(2).Workspace.Root.Pane.URI)49+ switchedIndex = index
51-50+ return nil
52- err = tabCoord.SwitchToLastActive(ctx)51+ },
53- require.NoError(t, err)52+ },
54- assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)53+ func(context.Context) entity.PaneID { return "" },
55-}54+ )
56-55+
57-func TestKeyboardDispatcher_AltNumberSwitchesToExistingTabWithoutNewPaneURL(t *testing.T) {56+ require.NoError(t, d.Dispatch(ctx, input.ActionNewTab))
58- ctx := context.Background()57+ require.NoError(t, d.Dispatch(ctx, input.ActionCloseTab))
59- ids := []string{58+ require.NoError(t, d.Dispatch(ctx, input.ActionNextTab))
60- "tab-1", "ws-1", "pane-1",59+ require.NoError(t, d.Dispatch(ctx, input.ActionPreviousTab))
61- "tab-2", "ws-2", "pane-2",60+ require.NoError(t, d.Dispatch(ctx, input.ActionSwitchLastTab))
62- }61+ require.NoError(t, d.Dispatch(ctx, input.ActionSwitchTabIndex5))
63- idx := 062+
64- tabsUC := usecase.NewManageTabsUseCase(func() string {63+ assert.Equal(t, []string{"new", "close", "next", "previous", "last", "index"}, called)
65- id := ids[idx]64+ assert.Equal(t, 4, switchedIndex)
66- idx++
67- return id
68- })
69- tabs := entity.NewTabList()
70- tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
71- TabsUC: tabsUC,
72- Tabs: tabs,
73- })
74-
75- _, err := tabCoord.Create(ctx, "https://first.example")
76- require.NoError(t, err)
77- _, err = tabCoord.Create(ctx, "https://second.example")
78- require.NoError(t, err)
79-
80- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
81-
82- err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
83- require.NoError(t, err)
84-
85- require.Len(t, tabs.Tabs, 2)
86- assert.Equal(t, tabs.TabAt(1).ID, tabs.ActiveTabID)
87- assert.Equal(t, domainurl.Normalize("https://second.example"), tabs.TabAt(1).Workspace.Root.Pane.URI)
88-}
89-
90-func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithoutNewPaneURL(t *testing.T) {
91- ctx := context.Background()
92- ids := []string{
93- "tab-1", "ws-1", "pane-1",
94- }
95- idx := 0
96- tabsUC := usecase.NewManageTabsUseCase(func() string {
97- id := ids[idx]
98- idx++
99- return id
100- })
101- tabs := entity.NewTabList()
102- tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
103- TabsUC: tabsUC,
104- Tabs: tabs,
105- })
106-
107- _, err := tabCoord.Create(ctx, "https://first.example")
108- require.NoError(t, err)
109-
110- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
111-
112- err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
113- require.Error(t, err)
114- require.Len(t, tabs.Tabs, 1)
115- assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)
116 }65 }
117 66
118 func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T) {67 func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T) {
@@ -135,7 +84,7 @@ func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T)
135 },84 },
136 })85 })
137 86
138- d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, wsCoord, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })87+ d := NewKeyboardDispatcher(ctx, wsCoord, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
139 88
140 err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)89 err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
141 require.NoError(t, err)90 require.NoError(t, err)
@@ -151,7 +100,7 @@ func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T)
151 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {100 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
152 ctx := context.Background()101 ctx := context.Background()
153 activePaneID := entity.PaneID("pane-1")102 activePaneID := entity.PaneID("pane-1")
154- d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID {103+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID {
155 return activePaneID104 return activePaneID
156 })105 })
157 106
M internal/ui/floating_pane_test.go +9 -3
@@ -365,13 +365,19 @@ func newFloatingPaneTestApp(t *testing.T) (*App, entity.TabID, *floatingWorkspac
365 tabID := entity.TabID("tab-1")365 tabID := entity.TabID("tab-1")
366 workspace := &entity.Workspace{ActivePaneID: entity.PaneID("pane-one")}366 workspace := &entity.Workspace{ActivePaneID: entity.PaneID("pane-one")}
367 tabs := entity.NewTabList()367 tabs := entity.NewTabList()
368- tabs.Add(&entity.Tab{ID: tabID, Workspace: workspace})368+ tab := &entity.Tab{ID: tabID, Workspace: workspace}
369+ tabs.Add(tab)
370+ tabs.SetActive(tabID)
371+ bw := &browserWindow{id: "window-1", tabs: tabs}
369 372
370 session := newFloatingPaneSession(tabID, floatingSessionIDDefault)373 session := newFloatingPaneSession(tabID, floatingSessionIDDefault)
371 374
372 app := &App{375 app := &App{
373- tabs: tabs,376+ tabs: tabs,
374- workspaceViews: make(map[entity.TabID]*component.WorkspaceView),377+ browserWindows: map[string]*browserWindow{bw.id: bw},
378+ lastFocusedWindowID: bw.id,
379+ windowForTab: map[entity.TabID]*browserWindow{tabID: bw},
380+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
375 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{381 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
376 {tabID: tabID, sessionID: floatingSessionIDDefault}: session,382 {tabID: tabID, sessionID: floatingSessionIDDefault}: session,
377 },383 },
M internal/ui/input/global_shortcuts.go +28 -1
@@ -201,6 +201,12 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
201 Msg("stale global shortcut callback ignored")201 Msg("stale global shortcut callback ignored")
202 return true202 return true
203 }203 }
204+ if !h.isActiveWindowShortcutHandler() {
205+ log.Trace().
206+ Str("action", string(actionToDispatch)).
207+ Msg("inactive window global shortcut callback ignored")
208+ return false
209+ }
204 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {210 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
205 log.Trace().211 log.Trace().
206 Str("action", string(actionToDispatch)).212 Str("action", string(actionToDispatch)).
@@ -388,6 +394,10 @@ func (h *GlobalShortcutHandler) isStaleGeneration(generation uint64) bool {
388 return h == nil || generation != h.generation394 return h == nil || generation != h.generation
389 }395 }
390 396
397+func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
398+ return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()
399+}
400+
391 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {401 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
392 if h == nil || !isRepeatedGlobalShortcutSuppressed(action) {402 if h == nil || !isRepeatedGlobalShortcutSuppressed(action) {
393 return false403 return false
@@ -406,7 +416,24 @@ func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time
406 416
407 func isRepeatedGlobalShortcutSuppressed(action Action) bool {417 func isRepeatedGlobalShortcutSuppressed(action Action) bool {
408 switch action {418 switch action {
409- case ActionZoomReset, ActionClosePane, ActionCloseTab, ActionQuit, ActionOpenSessionManager:419+ case ActionGoBack,
420+ ActionGoForward,
421+ ActionZoomReset,
422+ ActionClosePane,
423+ ActionCloseTab,
424+ ActionQuit,
425+ ActionOpenSessionManager,
426+ ActionSwitchTabIndex1,
427+ ActionSwitchTabIndex2,
428+ ActionSwitchTabIndex3,
429+ ActionSwitchTabIndex4,
430+ ActionSwitchTabIndex5,
431+ ActionSwitchTabIndex6,
432+ ActionSwitchTabIndex7,
433+ ActionSwitchTabIndex8,
434+ ActionSwitchTabIndex9,
435+ ActionSwitchTabIndex10,
436+ ActionSwitchLastTab:
410 return true437 return true
411 default:438 default:
412 return false439 return false
M internal/ui/input/global_shortcuts_test.go +77 -1
@@ -20,7 +20,7 @@ func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {
20 }20 }
21 }21 }
22 22
23-func TestGlobalShortcutHandlerAllowsRepeatingNavigationActions(t *testing.T) {23+func TestGlobalShortcutHandlerAllowsRepeatingFocusNavigationActions(t *testing.T) {
24 h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}24 h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
25 now := time.Unix(100, 0)25 now := time.Unix(100, 0)
26 26
@@ -32,6 +32,47 @@ func TestGlobalShortcutHandlerAllowsRepeatingNavigationActions(t *testing.T) {
32 }32 }
33 }33 }
34 34
35+func TestGlobalShortcutHandlerSuppressesRepeatedBrowserNavigationActions(t *testing.T) {
36+ h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
37+ now := time.Unix(100, 0)
38+
39+ if h.suppressRepeatedShortcut(ActionGoBack, now) {
40+ t.Fatal("first go-back dispatch was suppressed")
41+ }
42+ if !h.suppressRepeatedShortcut(ActionGoBack, now.Add(time.Millisecond)) {
43+ t.Fatal("repeated go-back dispatch was not suppressed")
44+ }
45+}
46+
47+func TestGlobalShortcutHandlerSuppressesRepeatedTabIndexSwitchActions(t *testing.T) {
48+ actions := []Action{
49+ ActionSwitchTabIndex1,
50+ ActionSwitchTabIndex2,
51+ ActionSwitchTabIndex3,
52+ ActionSwitchTabIndex4,
53+ ActionSwitchTabIndex5,
54+ ActionSwitchTabIndex6,
55+ ActionSwitchTabIndex7,
56+ ActionSwitchTabIndex8,
57+ ActionSwitchTabIndex9,
58+ ActionSwitchTabIndex10,
59+ }
60+
61+ for _, action := range actions {
62+ t.Run(string(action), func(t *testing.T) {
63+ h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
64+ now := time.Unix(100, 0)
65+
66+ if h.suppressRepeatedShortcut(action, now) {
67+ t.Fatalf("first %s dispatch was suppressed", action)
68+ }
69+ if !h.suppressRepeatedShortcut(action, now.Add(time.Millisecond)) {
70+ t.Fatalf("repeated %s dispatch was not suppressed", action)
71+ }
72+ })
73+ }
74+}
75+
35 func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) {76 func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) {
36 h := &GlobalShortcutHandler{77 h := &GlobalShortcutHandler{
37 registered: make(map[KeyBinding]Action),78 registered: make(map[KeyBinding]Action),
@@ -59,3 +100,38 @@ func TestGlobalShortcutHandlerGenerationMarksOldCallbacksStale(t *testing.T) {
59 t.Fatal("old generation was not marked stale")100 t.Fatal("old generation was not marked stale")
60 }101 }
61 }102 }
103+
104+func TestGlobalShortcutHandlerReturnsInactiveForNilOrDetached(t *testing.T) {
105+ var nilHandler *GlobalShortcutHandler
106+ if nilHandler.isActiveWindowShortcutHandler() {
107+ t.Fatal("nil handler should return inactive")
108+ }
109+ if (&GlobalShortcutHandler{}).isActiveWindowShortcutHandler() {
110+ t.Fatal("handler with nil window should return inactive")
111+ }
112+
113+ h := &GlobalShortcutHandler{
114+ registered: make(map[KeyBinding]Action),
115+ lastDispatchAt: make(map[Action]time.Time),
116+ }
117+ h.Detach()
118+ if h.isActiveWindowShortcutHandler() {
119+ t.Fatal("detached handler with nil controller should return inactive")
120+ }
121+}
122+
123+func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {
124+ if !isRepeatedGlobalShortcutSuppressed(ActionSwitchLastTab) {
125+ t.Fatal("ActionSwitchLastTab should be suppressed")
126+ }
127+
128+ h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
129+ now := time.Unix(100, 0)
130+
131+ if h.suppressRepeatedShortcut(ActionSwitchLastTab, now) {
132+ t.Fatal("first ActionSwitchLastTab dispatch was suppressed")
133+ }
134+ if !h.suppressRepeatedShortcut(ActionSwitchLastTab, now.Add(time.Millisecond)) {
135+ t.Fatal("repeated ActionSwitchLastTab dispatch was not suppressed")
136+ }
137+}
M internal/ui/theme/css.go +6 -0
@@ -144,6 +144,12 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
144 // Uses em units for scalable UI.144 // Uses em units for scalable UI.
145 func generateTabBarCSS(p Palette) string {145 func generateTabBarCSS(p Palette) string {
146 return `/* Tab bar styling */146 return `/* Tab bar styling */
147+/* Content area inset for bottom-positioned tab bar — uses the same 2em token as
148+ .tab-bar min-height and remains static to avoid OSR vertical reallocations. */
149+.content-area-tabbar-inset-bottom {
150+ padding-bottom: 2em;
151+}
152+
147 .tab-bar {153 .tab-bar {
148 background-color: var(--surface);154 background-color: var(--surface);
149 border-top: 0.125em solid var(--border);155 border-top: 0.125em solid var(--border);
M internal/ui/theme/css_test.go +17 -0
@@ -185,6 +185,23 @@ func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testi
185 assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")185 assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")
186 }186 }
187 187
188+func TestGenerateCSS_ContentAreaTabBarInsetBottom(t *testing.T) {
189+ css := GenerateCSS(DefaultDarkPalette())
190+
191+ if !strings.Contains(css, ".content-area-tabbar-inset-bottom") {
192+ t.Fatal("expected .content-area-tabbar-inset-bottom selector in generated CSS")
193+ }
194+ if !strings.Contains(css, "padding-bottom: 2em;") {
195+ t.Fatal("expected padding-bottom: 2em in .content-area-tabbar-inset-bottom block")
196+ }
197+ if !strings.Contains(css, ".tab-bar") {
198+ t.Fatal("expected .tab-bar selector in generated CSS")
199+ }
200+ if !strings.Contains(css, "min-height: 2em;") {
201+ t.Fatal("expected min-height: 2em in .tab-bar block")
202+ }
203+}
204+
188 func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) {205 func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) {
189 css := GenerateCSS(DefaultDarkPalette())206 css := GenerateCSS(DefaultDarkPalette())
190 207
M internal/ui/window/main_window.go +42 -5
@@ -104,13 +104,23 @@ func (mw *MainWindow) assembleLayout() {
104 tabBarPos = mw.tabBarPosition104 tabBarPos = mw.tabBarPosition
105 }105 }
106 106
107+ mw.rootBox.Append(&mw.contentOverlay.Widget)
108+
109+ tabBarWidget := mw.tabBar.Widget()
110+ tabBarWidget.SetHalign(gtk.AlignFillValue)
111+ tabBarWidget.SetValign(gtk.AlignStartValue)
107 if tabBarPos == "bottom" {112 if tabBarPos == "bottom" {
108- mw.rootBox.Append(&mw.contentOverlay.Widget)113+ tabBarWidget.SetValign(gtk.AlignEndValue)
109- mw.rootBox.Append(mw.tabBar.Widget())114+ // Inset is NOT applied unconditionally. See SetTabBarContentInsetVisible
110- } else {115+ // which is called when the bottom tab bar is actually visible.
111- mw.rootBox.Append(mw.tabBar.Widget())
112- mw.rootBox.Append(&mw.contentOverlay.Widget)
113 }116 }
117+ mw.contentOverlay.AddOverlay(tabBarWidget)
118+ mw.contentOverlay.SetClipOverlay(tabBarWidget, false)
119+ // Keep the tab bar out of measurement so showing/hiding it never changes the
120+ // WebView content allocation; the content area reserves a stable inset for
121+ // bottom-position bars while the overlay remains non-measured to avoid
122+ // WebView allocation changes.
123+ mw.contentOverlay.SetMeasureOverlay(tabBarWidget, false)
114 124
115 mw.logger.Debug().125 mw.logger.Debug().
116 Str("tab_bar_position", tabBarPos).126 Str("tab_bar_position", tabBarPos).
@@ -191,6 +201,33 @@ func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
191 return mw.contentOverlay201 return mw.contentOverlay
192 }202 }
193 203
204+// SetTabBarContentInsetVisible adds or removes the bottom tab bar inset CSS class
205+// on the content area. Only effective when tabBarPosition is "bottom".
206+// Avoids duplicate add/remove if already in the desired state.
207+func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) {
208+ if mw.tabBarPosition != "bottom" || mw.contentArea == nil {
209+ return
210+ }
211+ if visible {
212+ if !mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") {
213+ mw.contentArea.AddCssClass("content-area-tabbar-inset-bottom")
214+ }
215+ } else {
216+ if mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") {
217+ mw.contentArea.RemoveCssClass("content-area-tabbar-inset-bottom")
218+ }
219+ }
220+}
221+
222+// HasTabBarContentInset returns whether the bottom tab bar inset CSS class
223+// is currently applied to the content area.
224+func (mw *MainWindow) HasTabBarContentInset() bool {
225+ if mw.tabBarPosition != "bottom" || mw.contentArea == nil {
226+ return false
227+ }
228+ return mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom")
229+}
230+
194 // AddOverlay adds a widget as an overlay above the content area.231 // AddOverlay adds a widget as an overlay above the content area.
195 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {232 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
196 if mw.contentOverlay != nil && widget != nil {233 if mw.contentOverlay != nil && widget != nil {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close