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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
32 files · +5371 -863
M .mockery.yaml +1 -0
| @@ -16,6 +16,7 @@ packages: | |||
| 16 | 16 | SessionLogger: {} | |
| 17 | 17 | SessionSpawner: {} | |
| 18 | 18 | TabListProvider: {} | |
| 19 | + | WindowStateProvider: {} | |
| 19 | 20 | WebView: {} | |
| 20 | 21 | WebViewFactory: {} | |
| 21 | 22 | WebViewPool: {} | |
M cmd/dumber/main.go +1 -1
| @@ -845,7 +845,7 @@ func buildUIDependencies( | |||
| 845 | 845 | SessionStateRepo: repos.sessionState, | |
| 846 | 846 | CurrentSessionID: currentSessionID, | |
| 847 | 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 | 849 | return snapshot.NewService(uc.snapshot, provider, intervalMs) | |
| 850 | 850 | }, | |
| 851 | 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 | 10792 | 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 | 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 | 11012 | // The first argument is typically a *testing.T value. | |
| 10797 | 11013 | func NewMockSessionSpawner(t interface { | |
M internal/application/port/session_state.go +17 -0
| @@ -8,6 +8,8 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | // TabListProvider provides access to the current tab list state. | |
| 10 | 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 | 13 | type TabListProvider interface { | |
| 12 | 14 | // GetTabList returns the current tab list state. | |
| 13 | 15 | GetTabList() *entity.TabList | |
| @@ -15,6 +17,21 @@ type TabListProvider interface { | |||
| 15 | 17 | 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 | 35 | // SessionSpawner spawns a new dumber instance for resurrection. | |
| 19 | 36 | type SessionSpawner interface { | |
| 20 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "time" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 9 | "github.com/bnema/dumber/internal/domain/repository" | |
| @@ -23,23 +24,44 @@ func NewSnapshotSessionUseCase(stateRepo repository.SessionStateRepository) *Sna | |||
| 23 | 24 | type SnapshotInput struct { | |
| 24 | 25 | SessionID entity.SessionID | |
| 25 | 26 | 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 | 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 | 37 | func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error { | |
| 30 | 38 | log := logging.FromContext(ctx) | |
| 31 | 39 | ||
| 32 | 40 | if input.SessionID == "" { | |
| 33 | 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(). | |
| 39 | - | Str("session_id", string(input.SessionID)). | |
| 40 | - | Int("tab_count", len(state.Tabs)). | |
| 41 | - | Int("pane_count", state.CountPanes()). | |
| 42 | - | Msg("creating session snapshot") | |
| 49 | + | if input.Windows != nil { | |
| 50 | + | state = entity.SnapshotFromWindowTabLists(input.SessionID, input.Windows, input.ActiveWindowIndex, time.Now()) | |
| 51 | + | log.Debug(). | |
| 52 | + | Str("session_id", string(input.SessionID)). | |
| 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 | 66 | if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil { | |
| 45 | 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 | 34 | require.Equal(t, sessionID, state.SessionID) | |
| 35 | 35 | require.Len(t, state.Tabs, 1) | |
| 36 | 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 | 39 | Return(nil) | |
| 40 | 40 | ||
| @@ -85,6 +85,47 @@ func TestSnapshotSessionUseCase_Execute_RequiresSessionID(t *testing.T) { | |||
| 85 | 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 | 129 | func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) { | |
| 89 | 130 | sessionID := entity.SessionID("20251224_120000_snap") | |
| 90 | 131 | ||
| @@ -108,7 +149,7 @@ func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) { | |||
| 108 | 149 | state := entity.SnapshotFromTabList(sessionID, tabList) | |
| 109 | 150 | ||
| 110 | 151 | require.NotNil(t, state) | |
| 111 | - | assert.Equal(t, entity.SessionStateVersion, state.Version) | |
| 152 | + | assert.Equal(t, entity.LegacySessionStateVersion, state.Version) | |
| 112 | 153 | assert.Equal(t, sessionID, state.SessionID) | |
| 113 | 154 | assert.Len(t, state.Tabs, 1) | |
| 114 | 155 | assert.Equal(t, "Dev Tab", state.Tabs[0].Name) | |
| @@ -119,6 +160,65 @@ func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) { | |||
| 119 | 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 | 222 | func TestSessionState_CountPanes(t *testing.T) { | |
| 123 | 223 | state := &entity.SessionState{ | |
| 124 | 224 | Tabs: []entity.TabSnapshot{ | |
M internal/domain/entity/session_state.go +67 -16
| @@ -2,18 +2,24 @@ package entity | |||
| 2 | 2 | ||
| 3 | 3 | import "time" | |
| 4 | 4 | ||
| 5 | + | const LegacySessionStateVersion = 1 | |
| 6 | + | ||
| 5 | 7 | // SessionStateVersion is the current schema version for session state. | |
| 6 | 8 | // Increment when making breaking changes to the serialization format. | |
| 7 | - | const SessionStateVersion = 1 | |
| 9 | + | // Version 2: added window-scoped sessions (Windows, ActiveWindowIndex). | |
| 10 | + | const SessionStateVersion = 2 | |
| 8 | 11 | ||
| 9 | 12 | // SessionState represents a complete snapshot of a browser session. | |
| 10 | 13 | // This is serialized to JSON and stored in the database. | |
| 11 | 14 | type SessionState struct { | |
| 12 | 15 | Version int `json:"version"` | |
| 13 | 16 | SessionID SessionID `json:"session_id"` | |
| 14 | - | Tabs []TabSnapshot `json:"tabs"` | |
| 15 | - | ActiveTabIndex int `json:"active_tab_index"` | |
| 16 | - | SavedAt time.Time `json:"saved_at"` | |
| 17 | + | Tabs []TabSnapshot `json:"tabs,omitempty"` | |
| 18 | + | ActiveTabIndex int `json:"active_tab_index,omitempty"` | |
| 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 | 25 | // TabSnapshot captures the state of a single tab. | |
| @@ -55,7 +61,7 @@ type PaneSnapshot struct { | |||
| 55 | 61 | func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState { | |
| 56 | 62 | if tabs == nil { | |
| 57 | 63 | return &SessionState{ | |
| 58 | - | Version: SessionStateVersion, | |
| 64 | + | Version: LegacySessionStateVersion, | |
| 59 | 65 | SessionID: sessionID, | |
| 60 | 66 | Tabs: []TabSnapshot{}, | |
| 61 | 67 | SavedAt: time.Now(), | |
| @@ -73,7 +79,7 @@ func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState { | |||
| 73 | 79 | } | |
| 74 | 80 | ||
| 75 | 81 | return &SessionState{ | |
| 76 | - | Version: SessionStateVersion, | |
| 82 | + | Version: LegacySessionStateVersion, | |
| 77 | 83 | SessionID: sessionID, | |
| 78 | 84 | Tabs: snapTabs, | |
| 79 | 85 | ActiveTabIndex: activeTabIndex, | |
| @@ -147,6 +153,17 @@ type SessionInfo struct { | |||
| 147 | 153 | ||
| 148 | 154 | // CountPanes returns the total number of panes in the session state. | |
| 149 | 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 | 167 | count := 0 | |
| 151 | 168 | for _, tab := range s.Tabs { | |
| 152 | 169 | count += countPanesInNode(tab.Workspace.Root) | |
| @@ -171,17 +188,12 @@ func countPanesInNode(node *PaneNodeSnapshot) int { | |||
| 171 | 188 | // IDGenerator is a function that generates unique IDs. | |
| 172 | 189 | type IDGenerator func() string | |
| 173 | 190 | ||
| 174 | - | // TabListFromSnapshot reconstructs a TabList from a SessionState snapshot. | |
| 175 | - | // Generates new IDs for all entities using the provided generator. | |
| 176 | - | // This is the inverse of SnapshotFromTabList. | |
| 177 | - | func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList { | |
| 178 | - | if state == nil { | |
| 179 | - | return NewTabList() | |
| 180 | - | } | |
| 181 | - | ||
| 191 | + | // tabListFromSnapshots converts a slice of TabSnapshots to a live TabList. | |
| 192 | + | // activeIndex is the index in snapshots that should become the active tab. | |
| 193 | + | func tabListFromSnapshots(snaps []TabSnapshot, activeIndex int, idGen IDGenerator) *TabList { | |
| 182 | 194 | tabs := NewTabList() | |
| 183 | 195 | ||
| 184 | - | for i, tabSnap := range state.Tabs { | |
| 196 | + | for i, tabSnap := range snaps { | |
| 185 | 197 | tab := tabFromSnapshot(&tabSnap, idGen) | |
| 186 | 198 | if tab == nil { | |
| 187 | 199 | continue | |
| @@ -189,7 +201,7 @@ func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList { | |||
| 189 | 201 | tabs.Tabs = append(tabs.Tabs, tab) | |
| 190 | 202 | tab.Position = len(tabs.Tabs) - 1 | |
| 191 | 203 | ||
| 192 | - | if i == state.ActiveTabIndex { | |
| 204 | + | if i == activeIndex { | |
| 193 | 205 | tabs.ActiveTabID = tab.ID | |
| 194 | 206 | } | |
| 195 | 207 | } | |
| @@ -202,6 +214,45 @@ func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList { | |||
| 202 | 214 | 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 | 256 | func tabFromSnapshot(snap *TabSnapshot, idGen IDGenerator) *Tab { | |
| 206 | 257 | if snap == nil { | |
| 207 | 258 | return nil | |
M internal/domain/entity/session_state_test.go +323 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package entity_test | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "encoding/json" | |
| 4 | 5 | "sync/atomic" | |
| 5 | 6 | "testing" | |
| 6 | 7 | "time" | |
| @@ -380,6 +381,252 @@ func TestFindPaneAcrossTabs(t *testing.T) { | |||
| 380 | 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 | 630 | func TestFindPaneInNestedStructure(t *testing.T) { | |
| 384 | 631 | // Create a tab with split panes | |
| 385 | 632 | pane1 := entity.NewPane("left_pane") | |
| @@ -428,3 +675,79 @@ func TestFindPaneInNestedStructure(t *testing.T) { | |||
| 428 | 675 | rightNodeAgain := tab.Workspace.FindPane("right_pane") | |
| 429 | 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 | 161 | 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 | 181 | // ReplaceFrom replaces this TabList's contents with those from another TabList. | |
| 165 | 182 | // This modifies in-place so existing references to this TabList remain valid. | |
| 166 | 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 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | 12 | "github.com/bnema/dumber/internal/application/usecase" | |
| 13 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 13 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| @@ -25,7 +26,7 @@ var _ port.SnapshotService = (*Service)(nil) | |||
| 25 | 26 | // Service handles debounced session state snapshots. | |
| 26 | 27 | type Service struct { | |
| 27 | 28 | snapshotUC *usecase.SnapshotSessionUseCase | |
| 28 | - | provider port.TabListProvider | |
| 29 | + | provider port.WindowStateProvider | |
| 29 | 30 | interval time.Duration | |
| 30 | 31 | retries int | |
| 31 | 32 | retryDelay time.Duration | |
| @@ -41,7 +42,7 @@ type Service struct { | |||
| 41 | 42 | // NewService creates a new snapshot service. | |
| 42 | 43 | func NewService( | |
| 43 | 44 | snapshotUC *usecase.SnapshotSessionUseCase, | |
| 44 | - | provider port.TabListProvider, | |
| 45 | + | provider port.WindowStateProvider, | |
| 45 | 46 | intervalMs int, | |
| 46 | 47 | ) *Service { | |
| 47 | 48 | if intervalMs <= 0 { | |
| @@ -163,7 +164,6 @@ func (s *Service) saveSnapshot(ctx context.Context) error { | |||
| 163 | 164 | s.dirty = false | |
| 164 | 165 | s.mu.Unlock() | |
| 165 | 166 | ||
| 166 | - | tabList := s.provider.GetTabList() | |
| 167 | 167 | sessionID := s.provider.GetSessionID() | |
| 168 | 168 | ||
| 169 | 169 | if sessionID == "" { | |
| @@ -171,10 +171,23 @@ func (s *Service) saveSnapshot(ctx context.Context) error { | |||
| 171 | 171 | return nil | |
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | - | if err := s.executeWithRetry(ctx, usecase.SnapshotInput{ | |
| 175 | - | SessionID: sessionID, | |
| 176 | - | TabList: tabList, | |
| 177 | - | }); err != nil { | |
| 174 | + | windows, activeWindowIndex := s.provider.GetWindowSnapshotState() | |
| 175 | + | if windows == nil { | |
| 176 | + | windows = make([]entity.WindowTabListState, 0) | |
| 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 | 191 | s.markDirty() | |
| 179 | 192 | return err | |
| 180 | 193 | } | |
M internal/infrastructure/snapshot/service_test.go +123 -27
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "testing" | |
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 9 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | 12 | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| @@ -14,17 +15,16 @@ import ( | |||
| 14 | 15 | "github.com/stretchr/testify/require" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 17 | - | type testProvider struct { | |
| 18 | - | sessionID entity.SessionID | |
| 19 | - | tabList *entity.TabList | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | func (p *testProvider) GetTabList() *entity.TabList { | |
| 23 | - | return p.tabList | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | func (p *testProvider) GetSessionID() entity.SessionID { | |
| 27 | - | return p.sessionID | |
| 18 | + | func newWindowStateProvider( | |
| 19 | + | t *testing.T, | |
| 20 | + | sessionID entity.SessionID, | |
| 21 | + | windows []entity.WindowTabListState, | |
| 22 | + | activeWindowIndex int, | |
| 23 | + | ) *mocks.MockWindowStateProvider { | |
| 24 | + | provider := mocks.NewMockWindowStateProvider(t) | |
| 25 | + | provider.EXPECT().GetSessionID().Return(sessionID).Once() | |
| 26 | + | provider.EXPECT().GetWindowSnapshotState().Return(windows, activeWindowIndex).Once() | |
| 27 | + | return provider | |
| 28 | 28 | } | |
| 29 | 29 | ||
| 30 | 30 | func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) { | |
| @@ -41,10 +41,7 @@ func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) { | |||
| 41 | 41 | }) | |
| 42 | 42 | ||
| 43 | 43 | uc := usecase.NewSnapshotSessionUseCase(repo) | |
| 44 | - | svc := NewService(uc, &testProvider{ | |
| 45 | - | sessionID: "20260207_120000_fk_retry_ok", | |
| 46 | - | tabList: entity.NewTabList(), | |
| 47 | - | }, 1) | |
| 44 | + | svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fk_retry_ok", nil, 0), 1) | |
| 48 | 45 | svc.retryDelay = time.Millisecond | |
| 49 | 46 | svc.ready = true | |
| 50 | 47 | svc.dirty = true | |
| @@ -67,10 +64,7 @@ func TestService_SaveSnapshot_RetriesTransientFKAndFails(t *testing.T) { | |||
| 67 | 64 | }) | |
| 68 | 65 | ||
| 69 | 66 | uc := usecase.NewSnapshotSessionUseCase(repo) | |
| 70 | - | svc := NewService(uc, &testProvider{ | |
| 71 | - | sessionID: "20260207_120000_fk_retry_fail", | |
| 72 | - | tabList: entity.NewTabList(), | |
| 73 | - | }, 1) | |
| 67 | + | svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fk_retry_fail", nil, 0), 1) | |
| 74 | 68 | svc.retryDelay = time.Millisecond | |
| 75 | 69 | svc.ready = true | |
| 76 | 70 | svc.dirty = true | |
| @@ -94,10 +88,7 @@ func TestService_SaveSnapshot_DoesNotRetryNonFKError(t *testing.T) { | |||
| 94 | 88 | }) | |
| 95 | 89 | ||
| 96 | 90 | uc := usecase.NewSnapshotSessionUseCase(repo) | |
| 97 | - | svc := NewService(uc, &testProvider{ | |
| 98 | - | sessionID: "20260207_120000_non_fk", | |
| 99 | - | tabList: entity.NewTabList(), | |
| 100 | - | }, 1) | |
| 91 | + | svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_non_fk", nil, 0), 1) | |
| 101 | 92 | svc.retryDelay = time.Millisecond | |
| 102 | 93 | svc.ready = true | |
| 103 | 94 | svc.dirty = true | |
| @@ -120,10 +111,7 @@ func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) { | |||
| 120 | 111 | }) | |
| 121 | 112 | ||
| 122 | 113 | uc := usecase.NewSnapshotSessionUseCase(repo) | |
| 123 | - | svc := NewService(uc, &testProvider{ | |
| 124 | - | sessionID: "20260207_120000_ready_flush", | |
| 125 | - | tabList: entity.NewTabList(), | |
| 126 | - | }, 1) | |
| 114 | + | svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_ready_flush", nil, 0), 1) | |
| 127 | 115 | svc.Start(context.Background()) | |
| 128 | 116 | svc.dirty = true | |
| 129 | 117 | ||
| @@ -139,3 +127,111 @@ func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) { | |||
| 139 | 127 | svc.mu.Unlock() | |
| 140 | 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 | 7 | "io" | |
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | + | "sort" | |
| 10 | 11 | "strings" | |
| 11 | 12 | "sync" | |
| 12 | 13 | "unsafe" | |
| @@ -76,9 +77,9 @@ type App struct { | |||
| 76 | 77 | mainWindow *window.MainWindow | |
| 77 | 78 | ||
| 78 | 79 | browserWindows map[string]*browserWindow | |
| 80 | + | browserWindowOrder []string // registration order of window IDs | |
| 79 | 81 | lastFocusedWindowID string | |
| 80 | 82 | browserWindowFactory func(context.Context, string) (*browserWindow, error) | |
| 81 | - | tabCreationWindow *browserWindow | |
| 82 | 83 | dispatchOnMainThread func(func()) | |
| 83 | 84 | ||
| 84 | 85 | // State | |
| @@ -109,8 +110,7 @@ type App struct { | |||
| 109 | 110 | resizeModeBorderTarget layout.Widget | |
| 110 | 111 | ||
| 111 | 112 | // Omnibox configuration (omnibox is created per workspace view) | |
| 112 | - | omniboxCfg component.OmniboxConfig | |
| 113 | - | omniboxNavigateFn func(url string) | |
| 113 | + | omniboxCfg component.OmniboxConfig | |
| 114 | 114 | // Find bar configuration (find bar is created per workspace view) | |
| 115 | 115 | findBarCfg component.FindBarConfig | |
| 116 | 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 | 411 | if err != nil { | |
| 412 | 412 | return nil, err | |
| 413 | 413 | } | |
| 414 | + | created.ensureTabs() | |
| 414 | 415 | a.wireBrowserWindowActivationTracking(created) | |
| 415 | 416 | created.initChrome(ctx, a) | |
| 416 | 417 | return created, nil | |
| @@ -424,6 +425,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow | |||
| 424 | 425 | browserWindow := &browserWindow{ | |
| 425 | 426 | id: a.generateWindowID(), | |
| 426 | 427 | initialURL: initialURL, | |
| 428 | + | tabs: entity.NewTabList(), | |
| 427 | 429 | mainWindow: mainWindow, | |
| 428 | 430 | } | |
| 429 | 431 | a.wireBrowserWindowActivationTracking(browserWindow) | |
| @@ -500,56 +502,60 @@ func (a *App) browserWindowForMainWindow(mainWindow *window.MainWindow) *browser | |||
| 500 | 502 | } | |
| 501 | 503 | ||
| 502 | 504 | func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow { | |
| 503 | - | if a.windowForTab != nil { | |
| 504 | - | if bw := a.windowForTab[tabID]; bw != nil { | |
| 505 | - | return bw | |
| 506 | - | } | |
| 507 | - | } | |
| 508 | - | if a.tabCreationWindow != nil { | |
| 509 | - | return a.tabCreationWindow | |
| 505 | + | if a.windowForTab == nil { | |
| 506 | + | return nil | |
| 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 { | |
| 515 | - | if a == nil || a.tabs == nil || bw == nil || tabID == "" { | |
| 516 | - | return false | |
| 517 | - | } | |
| 518 | - | if a.tabs.Find(tabID) == nil { | |
| 519 | - | return false | |
| 511 | + | func (a *App) browserWindowForTabTarget(target coordinator.TabTarget) *browserWindow { | |
| 512 | + | if target.Tabs != nil { | |
| 513 | + | for _, bw := range a.browserWindows { | |
| 514 | + | if bw != nil && bw.tabs == target.Tabs { | |
| 515 | + | return bw | |
| 516 | + | } | |
| 517 | + | } | |
| 520 | 518 | } | |
| 521 | - | if a.windowForTab[tabID] != bw { | |
| 522 | - | return false | |
| 519 | + | if target.MainWindow != nil { | |
| 520 | + | return a.browserWindowForMainWindow(target.MainWindow) | |
| 523 | 521 | } | |
| 524 | - | return true | |
| 522 | + | return nil | |
| 525 | 523 | } | |
| 526 | 524 | ||
| 527 | 525 | func (a *App) tabCountForBrowserWindow(bw *browserWindow) int { | |
| 528 | - | if a.tabs == nil || bw == nil { | |
| 526 | + | if bw == nil || bw.tabs == nil { | |
| 529 | 527 | return 0 | |
| 530 | 528 | } | |
| 531 | - | count := 0 | |
| 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 | |
| 529 | + | return len(bw.tabs.Tabs) | |
| 541 | 530 | } | |
| 542 | 531 | ||
| 543 | 532 | func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow { | |
| 544 | - | if paneID == "" || a.tabs == nil { | |
| 533 | + | if paneID == "" { | |
| 545 | 534 | return nil | |
| 546 | 535 | } | |
| 547 | - | for _, tab := range a.tabs.Tabs { | |
| 548 | - | if tab == nil || tab.Workspace == nil { | |
| 536 | + | for _, bw := range a.browserWindows { | |
| 537 | + | if bw == nil || bw.tabs == nil { | |
| 549 | 538 | continue | |
| 550 | 539 | } | |
| 551 | - | if tab.Workspace.FindPane(paneID) != nil { | |
| 552 | - | return a.browserWindowForTab(tab.ID) | |
| 540 | + | for _, tab := range bw.tabs.Tabs { | |
| 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 | 561 | return nil | |
| @@ -598,6 +604,25 @@ func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity. | |||
| 598 | 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 | 626 | func (a *App) handlePaneWindowTitleChanged(paneID entity.PaneID, title string) { | |
| 602 | 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 | 635 | if entering { | |
| 611 | 636 | bw.mainWindow.TabBar().SetVisible(false) | |
| 637 | + | bw.mainWindow.SetTabBarContentInsetVisible(false) | |
| 612 | 638 | return | |
| 613 | 639 | } | |
| 640 | + | bw.mainWindow.TabBar().SetVisible(true) | |
| 614 | 641 | a.updateBrowserWindowTabBarVisibility(bw) | |
| 615 | 642 | } | |
| 616 | 643 | ||
| @@ -619,11 +646,13 @@ func (a *App) updateBrowserWindowTabBarVisibility(bw *browserWindow) { | |||
| 619 | 646 | return | |
| 620 | 647 | } | |
| 621 | 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 | 651 | return | |
| 624 | 652 | } | |
| 625 | 653 | 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 | 658 | func (a *App) activateBrowserWindow(bw *browserWindow) { | |
| @@ -636,13 +665,30 @@ func (a *App) activateBrowserWindow(bw *browserWindow) { | |||
| 636 | 665 | } | |
| 637 | 666 | a.keyboardHandler = bw.keyboardHandler | |
| 638 | 667 | a.globalShortcutHandler = bw.globalShortcutHandler | |
| 639 | - | if a.tabCoord != nil && bw.mainWindow != nil { | |
| 640 | - | a.tabCoord.SetMainWindow(bw.mainWindow) | |
| 668 | + | if a.tabCoord != nil { | |
| 669 | + | a.tabCoord.SetCurrentTarget(a.tabTargetForBrowserWindow(bw)) | |
| 641 | 670 | } | |
| 642 | - | if a.tabs != nil && bw.activeTabID != "" && a.tabs.Find(bw.activeTabID) != nil { | |
| 643 | - | a.tabs.SetActive(bw.activeTabID) | |
| 671 | + | // Sync the derived global App.tabs mirror from the active browser window. | |
| 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 | 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 | 714 | a.ensureWindowForTabMap() | |
| 669 | 715 | a.windowForTab[tabID] = bw | |
| 670 | - | if bw.activeTabID == "" { | |
| 671 | - | bw.activeTabID = tabID | |
| 716 | + | tabs := a.ensureTabListForBrowserWindow(bw) | |
| 717 | + | 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 | 759 | a.registerBrowserWindow(created) | |
| 713 | 760 | ||
| 714 | - | if a.tabs == nil || a.tabs.Count() == 0 { | |
| 761 | + | if len(a.browserWindows) == 1 { | |
| 715 | 762 | a.activateBrowserWindow(created) | |
| 716 | 763 | return nil | |
| 717 | 764 | } | |
| @@ -733,17 +780,13 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error { | |||
| 733 | 780 | } | |
| 734 | 781 | ||
| 735 | 782 | func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, created *browserWindow) error { | |
| 736 | - | previousMainWindow := a.mainWindow | |
| 737 | - | if created.mainWindow != nil { | |
| 738 | - | a.tabCoord.SetMainWindow(created.mainWindow) | |
| 739 | - | } | |
| 740 | - | a.tabCreationWindow = created | |
| 783 | + | previousTarget := a.tabCoord.CurrentTarget() | |
| 784 | + | a.tabCoord.SetCurrentTarget(a.ensureTabTargetForBrowserWindow(created)) | |
| 741 | 785 | defer func() { | |
| 742 | - | a.tabCreationWindow = nil | |
| 743 | - | a.tabCoord.SetMainWindow(previousMainWindow) | |
| 786 | + | a.tabCoord.SetCurrentTarget(previousTarget) | |
| 744 | 787 | }() | |
| 745 | 788 | ||
| 746 | - | createdTab, createErr := a.tabCoord.Create(ctx, url) | |
| 789 | + | createdTab, createErr := a.tabCoord.Create(ctx, a.ensureTabTargetForBrowserWindow(created), url) | |
| 747 | 790 | if createErr != nil { | |
| 748 | 791 | a.removeBrowserWindow(created.id) | |
| 749 | 792 | if created.mainWindow != nil { | |
| @@ -752,19 +795,17 @@ func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, creat | |||
| 752 | 795 | return createErr | |
| 753 | 796 | } | |
| 754 | 797 | if a.workspaceViews[createdTab.ID] == nil { | |
| 755 | - | if a.tabs != nil { | |
| 756 | - | a.tabs.Remove(createdTab.ID) | |
| 798 | + | if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil { | |
| 799 | + | createdTabs.Remove(createdTab.ID) | |
| 757 | 800 | } | |
| 758 | - | tabBar := a.mainWindow.TabBar() | |
| 759 | - | if created.mainWindow != nil && a.tabCoord != nil { | |
| 760 | - | if targetTabBar := a.tabCoord.GetTabBar(); targetTabBar != nil { | |
| 761 | - | tabBar = targetTabBar | |
| 801 | + | if created.mainWindow != nil { | |
| 802 | + | if tabBar := created.mainWindow.TabBar(); tabBar != nil { | |
| 803 | + | tabBar.RemoveTab(createdTab.ID) | |
| 804 | + | 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 | 809 | a.removeBrowserWindow(created.id) | |
| 769 | 810 | if created.mainWindow != nil { | |
| 770 | 811 | created.mainWindow.Destroy() | |
| @@ -772,7 +813,8 @@ func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, creat | |||
| 772 | 813 | return fmt.Errorf("workspace view not created for tab %s", createdTab.ID) | |
| 773 | 814 | } | |
| 774 | 815 | a.setBrowserWindowForTab(createdTab.ID, created) | |
| 775 | - | created.activeTabID = createdTab.ID | |
| 816 | + | // Tab was already created in the per-window TabList via tabCoord.Create; | |
| 817 | + | // active state is managed by TabList.SetActive. | |
| 776 | 818 | if created.mainWindow != nil { | |
| 777 | 819 | created.mainWindow.Show() | |
| 778 | 820 | } | |
| @@ -792,30 +834,27 @@ func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created | |||
| 792 | 834 | return err | |
| 793 | 835 | } | |
| 794 | 836 | a.setBrowserWindowForTab(output.Tab.ID, created) | |
| 795 | - | created.activeTabID = output.Tab.ID | |
| 837 | + | // 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 | 841 | if a.widgetFactory != nil { | |
| 797 | - | a.tabCreationWindow = created | |
| 798 | - | defer func() { | |
| 799 | - | a.tabCreationWindow = nil | |
| 800 | - | }() | |
| 801 | 842 | a.createWorkspaceView(ctx, output.Tab) | |
| 802 | 843 | a.switchWorkspaceView(ctx, output.Tab.ID) | |
| 803 | 844 | } | |
| 804 | 845 | if a.workspaceViews[output.Tab.ID] == nil { | |
| 846 | + | if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil { | |
| 847 | + | createdTabs.Remove(output.Tab.ID) | |
| 848 | + | } | |
| 805 | 849 | if a.tabs != nil { | |
| 806 | 850 | a.tabs.Remove(output.Tab.ID) | |
| 807 | 851 | } | |
| 808 | - | var tabBar *component.TabBar | |
| 809 | - | if a.mainWindow != nil { | |
| 810 | - | tabBar = a.mainWindow.TabBar() | |
| 811 | - | } | |
| 812 | - | if created.mainWindow != nil && created.mainWindow.TabBar() != nil { | |
| 813 | - | tabBar = created.mainWindow.TabBar() | |
| 814 | - | } | |
| 815 | - | if tabBar != nil { | |
| 816 | - | tabBar.RemoveTab(output.Tab.ID) | |
| 817 | - | if a.tabs != nil { | |
| 818 | - | tabBar.SetActive(a.tabs.ActiveTabID) | |
| 852 | + | if created.mainWindow != nil { | |
| 853 | + | if tabBar := created.mainWindow.TabBar(); tabBar != nil { | |
| 854 | + | tabBar.RemoveTab(output.Tab.ID) | |
| 855 | + | if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil { | |
| 856 | + | tabBar.SetActive(activeTab.ID) | |
| 857 | + | } | |
| 819 | 858 | } | |
| 820 | 859 | } | |
| 821 | 860 | a.removeBrowserWindow(created.id) | |
| @@ -1118,7 +1157,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 1118 | 1157 | return nil | |
| 1119 | 1158 | } | |
| 1120 | 1159 | } | |
| 1121 | - | return a.kbDispatcher.Dispatch(actionCtx, action) | |
| 1160 | + | return a.dispatchBrowserWindowAction(actionCtx, bw, action) | |
| 1122 | 1161 | }) | |
| 1123 | 1162 | bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool { | |
| 1124 | 1163 | a.activateBrowserWindow(bw) | |
| @@ -1165,7 +1204,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 1165 | 1204 | bw.keyboardHandler, | |
| 1166 | 1205 | func(actionCtx context.Context, action input.Action) error { | |
| 1167 | 1206 | a.activateBrowserWindow(bw) | |
| 1168 | - | return a.kbDispatcher.Dispatch(actionCtx, action) | |
| 1207 | + | return a.dispatchBrowserWindowAction(actionCtx, bw, action) | |
| 1169 | 1208 | }, | |
| 1170 | 1209 | ) | |
| 1171 | 1210 | if bw.globalShortcutHandler == nil { | |
| @@ -1181,11 +1220,11 @@ func (a *App) wireBrowserWindowTabBar(ctx context.Context, bw *browserWindow) { | |||
| 1181 | 1220 | } | |
| 1182 | 1221 | bw.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) { | |
| 1183 | 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 | 1224 | logging.FromContext(ctx).Error().Err(err).Str("tab_id", string(tabID)).Str("window_id", bw.id).Msg("tab switch failed") | |
| 1186 | 1225 | return | |
| 1187 | 1226 | } | |
| 1188 | - | bw.activeTabID = tabID | |
| 1227 | + | // 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 | 1471 | return | |
| 1433 | 1472 | } | |
| 1434 | 1473 | ||
| 1435 | - | activeTab := a.tabs.ActiveTab() | |
| 1474 | + | activeTab := a.activeTabForBrowserWindow(bw) | |
| 1436 | 1475 | if activeTab == nil || activeTab.Workspace == nil { | |
| 1437 | 1476 | return | |
| 1438 | 1477 | } | |
| @@ -1488,18 +1527,19 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error { | |||
| 1488 | 1527 | return nil | |
| 1489 | 1528 | } | |
| 1490 | 1529 | ||
| 1491 | - | sourceTab := a.tabs.ActiveTab() | |
| 1492 | - | if sourceTab == nil || sourceTab.Workspace == nil { | |
| 1530 | + | sourceTabs := a.tabListForBrowserWindow(bw) | |
| 1531 | + | sourceTab := a.activeTabForBrowserWindow(bw) | |
| 1532 | + | if sourceTabs == nil || sourceTab == nil || sourceTab.Workspace == nil { | |
| 1493 | 1533 | return nil | |
| 1494 | 1534 | } | |
| 1495 | 1535 | ||
| 1496 | 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 | 1538 | return a.MoveActivePaneToTab(ctx, "") | |
| 1499 | 1539 | } | |
| 1500 | 1540 | ||
| 1501 | - | items := make([]component.TabPickerItem, 0, a.tabs.Count()) | |
| 1502 | - | for _, tab := range a.tabs.Tabs { | |
| 1541 | + | items := make([]component.TabPickerItem, 0, sourceTabs.Count()) | |
| 1542 | + | for _, tab := range sourceTabs.Tabs { | |
| 1503 | 1543 | if tab == nil { | |
| 1504 | 1544 | continue | |
| 1505 | 1545 | } | |
| @@ -1524,17 +1564,19 @@ func (a *App) HandleMovePaneToNextTab(ctx context.Context) error { | |||
| 1524 | 1564 | if a.movePaneToTabUC == nil { | |
| 1525 | 1565 | return nil | |
| 1526 | 1566 | } | |
| 1527 | - | active := a.tabs.ActiveTab() | |
| 1528 | - | if active == nil { | |
| 1567 | + | bw := a.lastFocusedBrowserWindow() | |
| 1568 | + | sourceTabs := a.tabListForBrowserWindow(bw) | |
| 1569 | + | active := a.activeTabForBrowserWindow(bw) | |
| 1570 | + | if sourceTabs == nil || active == nil { | |
| 1529 | 1571 | return nil | |
| 1530 | 1572 | } | |
| 1531 | 1573 | ||
| 1532 | 1574 | nextPos := active.Position + 1 | |
| 1533 | - | if nextPos >= a.tabs.Count() { | |
| 1575 | + | if nextPos >= sourceTabs.Count() { | |
| 1534 | 1576 | // Create a new tab on the right. | |
| 1535 | 1577 | return a.MoveActivePaneToTab(ctx, "") | |
| 1536 | 1578 | } | |
| 1537 | - | next := a.tabs.TabAt(nextPos) | |
| 1579 | + | next := sourceTabs.TabAt(nextPos) | |
| 1538 | 1580 | if next == nil { | |
| 1539 | 1581 | return nil | |
| 1540 | 1582 | } | |
| @@ -1568,10 +1610,23 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) | |||
| 1568 | 1610 | if out.NewTabCreated && targetWindow != nil { | |
| 1569 | 1611 | a.setBrowserWindowForTab(out.TargetTab.ID, targetWindow) | |
| 1570 | 1612 | } | |
| 1613 | + | a.syncCrossWindowMovePaneTabLists(out, sourceTab, sourceWindow, targetWindow) | |
| 1571 | 1614 | ||
| 1572 | 1615 | a.applyMovePaneToTabUI(ctx, out, sourceTab, sourceWindow, targetWindow) | |
| 1573 | 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 | 1630 | a.MarkDirty() | |
| 1576 | 1631 | return nil | |
| 1577 | 1632 | } | |
| @@ -1587,17 +1642,36 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa | |||
| 1587 | 1642 | if a.movePaneToTabUC == nil { | |
| 1588 | 1643 | return nil, nil | |
| 1589 | 1644 | } | |
| 1590 | - | activeTab := a.tabs.ActiveTab() | |
| 1591 | - | if activeTab == nil || activeTab.Workspace == nil { | |
| 1645 | + | sourceWindow := a.lastFocusedBrowserWindow() | |
| 1646 | + | tabList := a.tabListForBrowserWindow(sourceWindow) | |
| 1647 | + | activeTab := a.activeTabForBrowserWindow(sourceWindow) | |
| 1648 | + | if tabList == nil || activeTab == nil || activeTab.Workspace == nil { | |
| 1592 | 1649 | 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 | 1668 | sourcePaneID := activeTab.Workspace.ActivePaneID | |
| 1595 | 1669 | if sourcePaneID == "" { | |
| 1596 | 1670 | return nil, nil | |
| 1597 | 1671 | } | |
| 1598 | 1672 | ||
| 1599 | 1673 | in := &usecase.MovePaneToTabInput{ | |
| 1600 | - | TabList: a.tabs, | |
| 1674 | + | TabList: tabList, | |
| 1601 | 1675 | SourceTabID: activeTab.ID, | |
| 1602 | 1676 | SourcePaneID: sourcePaneID, | |
| 1603 | 1677 | TargetTabID: targetTabID, | |
| @@ -1605,6 +1679,41 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa | |||
| 1605 | 1679 | 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 | 1717 | func (a *App) applyMovePaneToTabUI( | |
| 1609 | 1718 | ctx context.Context, | |
| 1610 | 1719 | out *usecase.MovePaneToTabOutput, | |
| @@ -1639,6 +1748,11 @@ func (a *App) removeSourceTabUI(sourceTabID entity.TabID, sourceWindow *browserW | |||
| 1639 | 1748 | if tabBar := a.tabBarForBrowserWindow(sourceWindow); tabBar != nil { | |
| 1640 | 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 | 1758 | func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWindow *browserWindow) { | |
| @@ -1714,9 +1828,16 @@ func (a *App) switchToTargetTabIfConfigured( | |||
| 1714 | 1828 | return | |
| 1715 | 1829 | } | |
| 1716 | 1830 | ||
| 1717 | - | a.tabs.SetActive(out.TargetTab.ID) | |
| 1831 | + | if targetWindow == nil { | |
| 1832 | + | targetWindow = a.lastFocusedBrowserWindow() | |
| 1833 | + | } | |
| 1718 | 1834 | if targetWindow != nil { | |
| 1719 | - | targetWindow.activeTabID = out.TargetTab.ID | |
| 1835 | + | 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 | 1842 | if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil { | |
| 1722 | 1843 | tabBar.SetActive(out.TargetTab.ID) | |
| @@ -1727,12 +1848,6 @@ func (a *App) switchToTargetTabIfConfigured( | |||
| 1727 | 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 | 1851 | func (a *App) initSnapshotService(ctx context.Context) { | |
| 1737 | 1852 | log := logging.FromContext(ctx) | |
| 1738 | 1853 | ||
| @@ -1790,7 +1905,7 @@ func (a *App) GetTabList() *entity.TabList { | |||
| 1790 | 1905 | return a.tabs | |
| 1791 | 1906 | } | |
| 1792 | 1907 | ||
| 1793 | - | // GetSessionID implements port.TabListProvider. | |
| 1908 | + | // GetSessionID implements port.TabListProvider and port.WindowStateProvider. | |
| 1794 | 1909 | func (a *App) GetSessionID() entity.SessionID { | |
| 1795 | 1910 | if a.deps == nil { | |
| 1796 | 1911 | return "" | |
| @@ -1798,6 +1913,532 @@ func (a *App) GetSessionID() entity.SessionID { | |||
| 1798 | 1913 | 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 | 2442 | // MarkDirty signals that session state has changed and should be saved. | |
| 1802 | 2443 | func (a *App) MarkDirty() { | |
| 1803 | 2444 | if a.snapshotService != nil { | |
| @@ -1823,23 +2464,43 @@ func (a *App) createInitialTab(ctx context.Context) { | |||
| 1823 | 2464 | } | |
| 1824 | 2465 | ||
| 1825 | 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 | 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 | 2491 | func (a *App) clearSessionRestoreUIState(ctx context.Context) { | |
| 1832 | 2492 | if a == nil { | |
| 1833 | 2493 | return | |
| 1834 | 2494 | } | |
| 1835 | 2495 | ||
| 1836 | 2496 | tabIDs := make(map[entity.TabID]struct{}) | |
| 1837 | - | if a.tabs != nil { | |
| 1838 | - | for _, tab := range a.tabs.Tabs { | |
| 1839 | - | if tab == nil { | |
| 1840 | - | continue | |
| 2497 | + | for _, bw := range a.browserWindows { | |
| 2498 | + | if tabs := a.tabListForBrowserWindow(bw); tabs != nil { | |
| 2499 | + | for _, tab := range tabs.Tabs { | |
| 2500 | + | if tab != nil { | |
| 2501 | + | tabIDs[tab.ID] = struct{}{} | |
| 2502 | + | } | |
| 1841 | 2503 | } | |
| 1842 | - | tabIDs[tab.ID] = struct{}{} | |
| 1843 | 2504 | } | |
| 1844 | 2505 | } | |
| 1845 | 2506 | for tabID := range a.workspaceViews { | |
| @@ -1867,94 +2528,290 @@ func (a *App) clearSessionRestoreUIState(ctx context.Context) { | |||
| 1867 | 2528 | } | |
| 1868 | 2529 | ||
| 1869 | 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 | 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 | 2579 | state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID) | |
| 1878 | 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 | 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) | |
| 1888 | - | // This reconstructs the full pane tree with new IDs | |
| 1889 | - | restoredTabs := entity.TabListFromSnapshot(state, a.generateID) | |
| 1890 | - | if restoredTabs == nil || len(restoredTabs.Tabs) == 0 { | |
| 1891 | - | return fmt.Errorf("failed to build tab list from snapshot") | |
| 2591 | + | func (a *App) prepareRuntimeWindows(ctx context.Context, windowCount int) ([]*browserWindow, bool, error) { | |
| 2592 | + | firstBW := a.browserWindowForMainWindow(a.mainWindow) | |
| 2593 | + | if firstBW == nil { | |
| 2594 | + | firstBW = a.lastFocusedBrowserWindow() | |
| 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 TabCoordinator | |
| 1897 | - | a.tabs.ReplaceFrom(restoredTabs) | |
| 2612 | + | runtimeWindows := make([]*browserWindow, windowCount) | |
| 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 bar | |
| 1902 | - | // Don't attach to content area yet - only the active tab should be attached | |
| 1903 | - | tabBar := a.mainWindow.TabBar() | |
| 1904 | - | for _, tab := range a.tabs.Tabs { | |
| 1905 | - | a.createWorkspaceViewWithoutAttach(ctx, tab) | |
| 1906 | - | wsView := a.workspaceViews[tab.ID] | |
| 1907 | - | if a.wsCoord != nil && wsView != nil { | |
| 1908 | - | a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView) | |
| 2627 | + | func (a *App) wrapExistingMainWindowForRestore(ctx context.Context, mainWindow *window.MainWindow) *browserWindow { | |
| 2628 | + | bw := &browserWindow{id: a.generateWindowID(), tabs: entity.NewTabList(), mainWindow: mainWindow} | |
| 2629 | + | a.wireBrowserWindowActivationTracking(bw) | |
| 2630 | + | if a.kbDispatcher != nil { | |
| 2631 | + | a.initBrowserWindowInput(ctx, bw) | |
| 2632 | + | } | |
| 2633 | + | if a.tabCoord != nil { | |
| 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 { | |
| 1911 | - | tabBar.AddTab(tab) | |
| 2645 | + | mainWindow := bw.mainWindow | |
| 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 area | |
| 1921 | - | activeTab := a.tabs.ActiveTab() | |
| 1922 | - | if activeTab != nil { | |
| 1923 | - | a.switchWorkspaceView(ctx, activeTab.ID) | |
| 1924 | - | if tabBar != nil { | |
| 1925 | - | tabBar.SetActive(activeTab.ID) | |
| 2656 | + | func (a *App) assignRuntimeWindowTabLists( | |
| 2657 | + | runtimeWindows []*browserWindow, | |
| 2658 | + | restoredWindows []entity.WindowTabListState, | |
| 2659 | + | firstBWReused bool, | |
| 2660 | + | ) { | |
| 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 nil | |
| 2669 | + | 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() { | |
| 1933 | - | if a == nil || a.tabs == nil { | |
| 1934 | - | return | |
| 2689 | + | func safeWindowIndex(index, count int) int { | |
| 2690 | + | if index < 0 || index >= count { | |
| 2691 | + | return 0 | |
| 1935 | 2692 | } | |
| 2693 | + | return index | |
| 2694 | + | } | |
| 1936 | 2695 | ||
| 1937 | - | restoreWindow := a.browserWindowForMainWindow(a.mainWindow) | |
| 1938 | - | if restoreWindow == nil { | |
| 1939 | - | restoreWindow = a.lastFocusedBrowserWindow() | |
| 2696 | + | func (a *App) syncDerivedGlobalTabMirror(tab *entity.Tab) { | |
| 2697 | + | if a.tabs != nil && tab != nil && a.tabs.Find(tab.ID) == nil { | |
| 2698 | + | a.tabs.Add(cloneTabForGlobalList(tab)) | |
| 1940 | 2699 | } | |
| 1941 | - | if restoreWindow == nil { | |
| 1942 | - | return | |
| 2700 | + | } | |
| 2701 | + | ||
| 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 { | |
| 1946 | - | if tab != nil { | |
| 1947 | - | a.setBrowserWindowForTab(tab.ID, restoreWindow) | |
| 2718 | + | func (a *App) replaceGlobalTabsFromRuntimeWindows(runtimeWindows []*browserWindow, activeBW *browserWindow) { | |
| 2719 | + | globalTabs := entity.NewTabList() | |
| 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.ActiveTabID | |
| 1952 | - | previousID := a.tabs.PreviousActiveTabID | |
| 1953 | - | if previousID != "" && previousID != a.tabs.ActiveTabID && a.tabs.Find(previousID) != nil { | |
| 1954 | - | restoreWindow.prevActiveTabID = previousID | |
| 2802 | + | a.ensureWindowForTabMap() | |
| 2803 | + | ||
| 2804 | + | if restored.Tabs == nil { | |
| 2805 | + | bw.tabs = entity.NewTabList() | |
| 1955 | 2806 | 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 | 2817 | func (a *App) finalizeActivation(ctx context.Context) { | |
| @@ -2126,14 +2983,12 @@ func (a *App) initContentCoordinator( | |||
| 2126 | 2983 | func (a *App) initTabCoordinator(ctx context.Context) { | |
| 2127 | 2984 | a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{ | |
| 2128 | 2985 | TabsUC: a.tabsUC, | |
| 2129 | - | Tabs: a.tabs, | |
| 2130 | 2986 | MainWindow: a.mainWindow, | |
| 2131 | 2987 | HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab, | |
| 2132 | 2988 | }) | |
| 2133 | - | a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) { | |
| 2134 | - | // Assign ownership BEFORE creating workspace view so windowForTab is set | |
| 2135 | - | // for scope filtering and browserWindowForTab resolution. | |
| 2136 | - | bw := a.browserWindowForTab(tab.ID) | |
| 2989 | + | a.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) { | |
| 2990 | + | // Assign ownership from the callback target, not focus/global state. | |
| 2991 | + | bw := a.browserWindowForTabTarget(target) | |
| 2137 | 2992 | if bw != nil { | |
| 2138 | 2993 | a.setBrowserWindowForTab(tab.ID, bw) | |
| 2139 | 2994 | } | |
| @@ -2147,59 +3002,38 @@ func (a *App) initTabCoordinator(ctx context.Context) { | |||
| 2147 | 3002 | } | |
| 2148 | 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 | 3012 | a.createWorkspaceView(ctx, tab) | |
| 2151 | 3013 | }) | |
| 2152 | - | a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) { | |
| 2153 | - | if bw := a.browserWindowForTab(tab.ID); bw != nil { | |
| 2154 | - | oldActiveID := bw.activeTabID | |
| 2155 | - | if oldActiveID != "" && oldActiveID != tab.ID && a.tabs.Find(oldActiveID) != 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 | |
| 3014 | + | a.tabCoord.SetOnTabSwitched(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) { | |
| 3015 | + | // TabList.SetActive (called by TabCoordinator.Switch → tabsUC.Switch) already | |
| 3016 | + | // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed. | |
| 3017 | + | if bw := a.browserWindowForTabTarget(target); bw != nil { | |
| 2161 | 3018 | a.activateBrowserWindow(bw) | |
| 2162 | 3019 | } | |
| 2163 | 3020 | a.switchWorkspaceView(ctx, tab.ID) | |
| 2164 | 3021 | }) | |
| 2165 | - | a.tabCoord.SetOnQuit(a.Quit) | |
| 2166 | 3022 | a.tabCoord.SetOnStateChanged(a.MarkDirty) | |
| 2167 | - | // Wire per-window tab scoping | |
| 2168 | - | a.tabCoord.SetTabScope(func(tab *entity.Tab, mainWindow *window.MainWindow) bool { | |
| 2169 | - | if tab == nil { | |
| 2170 | - | return false | |
| 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) | |
| 3023 | + | // Per-window tab ownership is now enforced through explicit TabTarget. | |
| 3024 | + | // The per-window TabList (bw.tabs) is the source of truth; no scope filtering needed. | |
| 3025 | + | a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, target coordinator.TabTarget) { | |
| 3026 | + | bw := a.browserWindowForTabTarget(target) | |
| 2197 | 3027 | if bw != nil { | |
| 2198 | 3028 | a.removeBrowserWindow(bw.id) | |
| 2199 | 3029 | if bw.mainWindow != nil { | |
| 2200 | 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 | 3038 | // Wire popup tab WebView attachment | |
| 2205 | 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 | 3050 | log := logging.FromContext(ctx) | |
| 2217 | 3051 | log.Debug().Msg("initializing coordinators") | |
| 2218 | 3052 | ||
| 2219 | - | // Helper to get active workspace and view | |
| 3053 | + | // 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 | 3058 | getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) { | |
| 2221 | 3059 | return a.activeWorkspace(), a.activeWorkspaceView() | |
| 2222 | 3060 | } | |
| @@ -2252,7 +3090,8 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 2252 | 3090 | ResizeMinPanePercent: a.deps.Config.Workspace.ResizeMode.MinPanePercent, | |
| 2253 | 3091 | }) | |
| 2254 | 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 | 3096 | a.wsCoord.SetOnStateChanged(a.MarkDirty) | |
| 2258 | 3097 | ||
| @@ -2273,16 +3112,8 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 2273 | 3112 | a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error { | |
| 2274 | 3113 | return a.wsCoord.ClosePaneByID(ctx, paneID) | |
| 2275 | 3114 | }) | |
| 2276 | - | // Wire tabbed popup behavior to create new tabs | |
| 2277 | - | a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input content.InsertPopupInput) error { | |
| 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 | - | }) | |
| 3115 | + | // Wire tabbed popup behavior to create new tabs in the originating window. | |
| 3116 | + | a.wsCoord.SetOnCreatePopupTab(a.createPopupTab) | |
| 2286 | 3117 | ||
| 2287 | 3118 | // Move pane use case (cross-tab) | |
| 2288 | 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 | 3176 | ws := a.activeWorkspace() | |
| 2343 | 3177 | if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil { | |
| 2344 | 3178 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | |
| @@ -2346,14 +3180,16 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 2346 | 3180 | }) | |
| 2347 | 3181 | ||
| 2348 | 3182 | // 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 | 3186 | a.kbDispatcher = dispatcher.NewKeyboardDispatcher( | |
| 2350 | 3187 | ctx, | |
| 2351 | - | a.tabCoord, | |
| 2352 | 3188 | a.wsCoord, | |
| 2353 | 3189 | a.navCoord, | |
| 2354 | 3190 | a.deps.ZoomUC, | |
| 2355 | 3191 | a.deps.CopyURLUC, | |
| 2356 | - | a.deps.Config.Workspace.NewPaneURL, | |
| 3192 | + | a.keyboardActions(), | |
| 2357 | 3193 | a.contentCoord.ActivePaneID, | |
| 2358 | 3194 | ) | |
| 2359 | 3195 | a.wireKeyboardActions() | |
| @@ -2364,6 +3200,65 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 2364 | 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 | 3262 | func (a *App) wireKeyboardActions() { | |
| 2368 | 3263 | a.kbDispatcher.SetOnQuit(a.Quit) | |
| 2369 | 3264 | a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error { | |
| @@ -2631,13 +3526,13 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) { | |||
| 2631 | 3526 | // updateWindowTitleFromActivePane updates the window title based on the current active pane. | |
| 2632 | 3527 | func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { | |
| 2633 | 3528 | var ws *entity.Workspace | |
| 2634 | - | if a.tabs != nil { | |
| 2635 | - | if tab := a.tabs.Find(tabID); tab != nil { | |
| 3529 | + | bw := a.browserWindowForTab(tabID) | |
| 3530 | + | if tabs := a.tabListForBrowserWindow(bw); tabs != nil { | |
| 3531 | + | if tab := tabs.Find(tabID); tab != nil { | |
| 2636 | 3532 | ws = tab.Workspace | |
| 2637 | - | } else { | |
| 2638 | - | ws = a.activeWorkspace() | |
| 2639 | 3533 | } | |
| 2640 | - | } else { | |
| 3534 | + | } | |
| 3535 | + | if ws == nil { | |
| 2641 | 3536 | ws = a.activeWorkspace() | |
| 2642 | 3537 | } | |
| 2643 | 3538 | if ws == nil || a.contentCoord == nil { | |
| @@ -2657,6 +3552,7 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||
| 2657 | 3552 | a.clearResizeModeBorder() | |
| 2658 | 3553 | } | |
| 2659 | 3554 | if to == input.ModeResize { | |
| 3555 | + | // Resize mode targets the last-focused browser window's active workspace. | |
| 2660 | 3556 | a.applyResizeModeBorder(ctx, a.activeWorkspace()) | |
| 2661 | 3557 | } | |
| 2662 | 3558 | ||
| @@ -2722,6 +3618,8 @@ func (a *App) clearResizeModeBorder() { | |||
| 2722 | 3618 | } | |
| 2723 | 3619 | ||
| 2724 | 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 | 3623 | wsView := a.activeWorkspaceView() | |
| 2726 | 3624 | if wsView == nil || ws == nil { | |
| 2727 | 3625 | a.clearResizeModeBorder() | |
| @@ -2809,8 +3707,17 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 2809 | 3707 | // Note: Mode borders for tab/pane/session are attached to MainWindow. | |
| 2810 | 3708 | // Resize mode border is attached to the active pane's stack container. | |
| 2811 | 3709 | ||
| 2812 | - | // Set omnibox config for this workspace view | |
| 2813 | - | wsView.SetOmniboxConfig(a.omniboxCfg) | |
| 3710 | + | // Set omnibox config for this workspace view. | |
| 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 | 3721 | // Set find bar config for this workspace view | |
| 2815 | 3722 | wsView.SetFindBarConfig(a.findBarCfg) | |
| 2816 | 3723 | // Set auto-open omnibox on new pane | |
| @@ -2820,6 +3727,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 2820 | 3727 | ||
| 2821 | 3728 | wsView.SetOnPaneFocused(func(paneID entity.PaneID) { | |
| 2822 | 3729 | if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize { | |
| 3730 | + | // Resize-mode pane focus tracks the last-focused window's workspace. | |
| 2823 | 3731 | ws := a.activeWorkspace() | |
| 2824 | 3732 | if ws != nil { | |
| 2825 | 3733 | ws.ActivePaneID = paneID | |
| @@ -2842,50 +3750,40 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 2842 | 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 | 3754 | func (a *App) activeWorkspace() *entity.Workspace { | |
| 2847 | - | if a.tabs == nil { | |
| 2848 | - | return nil | |
| 2849 | - | } | |
| 2850 | - | activeTab := a.tabs.ActiveTab() | |
| 2851 | - | if activeTab == nil { | |
| 2852 | - | return nil | |
| 2853 | - | } | |
| 2854 | - | return activeTab.Workspace | |
| 3755 | + | return a.activeWorkspaceForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 2855 | 3756 | } | |
| 2856 | 3757 | ||
| 2857 | 3758 | // updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI. | |
| 2858 | 3759 | // This is necessary because panes in inactive tabs also need URI updates for session snapshots. | |
| 2859 | 3760 | func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) { | |
| 2860 | - | for _, tab := range a.tabs.Tabs { | |
| 2861 | - | if tab.Workspace == nil { | |
| 3761 | + | for _, bw := range a.browserWindows { | |
| 3762 | + | if bw == nil || bw.tabs == nil { | |
| 2862 | 3763 | continue | |
| 2863 | 3764 | } | |
| 2864 | - | paneNode := tab.Workspace.FindPane(paneID) | |
| 2865 | - | if paneNode != nil && paneNode.Pane != nil { | |
| 2866 | - | paneNode.Pane.URI = url | |
| 2867 | - | return // Pane IDs are unique, no need to continue | |
| 3765 | + | for _, tab := range bw.tabs.Tabs { | |
| 3766 | + | if tab == nil || tab.Workspace == nil { | |
| 3767 | + | continue | |
| 3768 | + | } | |
| 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 | 3779 | func (a *App) activeWorkspaceView() *component.WorkspaceView { | |
| 2874 | - | activeTab := a.tabs.ActiveTab() | |
| 2875 | - | if activeTab == nil { | |
| 2876 | - | return nil | |
| 2877 | - | } | |
| 2878 | - | return a.workspaceViews[activeTab.ID] | |
| 3780 | + | return a.activeWorkspaceViewForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 2879 | 3781 | } | |
| 2880 | 3782 | ||
| 2881 | 3783 | // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView. | |
| 2882 | 3784 | // Used by the accent picker to insert accented characters into web content. | |
| 2883 | 3785 | func (a *App) getActiveWebViewTarget() port.TextInputTarget { | |
| 2884 | - | if a.contentCoord == nil { | |
| 2885 | - | return nil | |
| 2886 | - | } | |
| 2887 | - | ||
| 2888 | - | wv := a.contentCoord.ActiveWebView(context.Background()) | |
| 3786 | + | _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 2889 | 3787 | if wv == nil { | |
| 2890 | 3788 | return nil | |
| 2891 | 3789 | } | |
| @@ -2952,9 +3850,9 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | |||
| 2952 | 3850 | 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 | 3855 | if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil { | |
| 2957 | - | target.activeTabID = tabID | |
| 2958 | 3856 | target.mainWindow.SetContent(gtkWidget) | |
| 2959 | 3857 | } else if a.mainWindow != nil { | |
| 2960 | 3858 | a.mainWindow.SetContent(gtkWidget) | |
| @@ -3292,7 +4190,7 @@ func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.Ta | |||
| 3292 | 4190 | } | |
| 3293 | 4191 | ||
| 3294 | 4192 | func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) { | |
| 3295 | - | activeTab := a.tabs.ActiveTab() | |
| 4193 | + | activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 3296 | 4194 | if activeTab == nil { | |
| 3297 | 4195 | return floatingSessionKey{}, nil, false | |
| 3298 | 4196 | } | |
| @@ -3377,7 +4275,7 @@ func (a *App) syncFloatingFocus() { | |||
| 3377 | 4275 | return | |
| 3378 | 4276 | } | |
| 3379 | 4277 | ||
| 3380 | - | activeTab := a.tabs.ActiveTab() | |
| 4278 | + | activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 3381 | 4279 | if activeTab == nil { | |
| 3382 | 4280 | a.contentCoord.ClearActivePaneOverride() | |
| 3383 | 4281 | return | |
| @@ -3402,6 +4300,14 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac | |||
| 3402 | 4300 | ||
| 3403 | 4301 | if session.omnibox == nil { | |
| 3404 | 4302 | 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 | 4311 | cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) { | |
| 3406 | 4312 | a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level) | |
| 3407 | 4313 | } | |
| @@ -3619,7 +4525,7 @@ func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool { | |||
| 3619 | 4525 | ||
| 3620 | 4526 | // ToggleFloatingPane toggles the active workspace floating pane visibility. | |
| 3621 | 4527 | func (a *App) ToggleFloatingPane(ctx context.Context) error { | |
| 3622 | - | activeTab := a.tabs.ActiveTab() | |
| 4528 | + | activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 3623 | 4529 | if activeTab == nil { | |
| 3624 | 4530 | return nil | |
| 3625 | 4531 | } | |
| @@ -3640,7 +4546,7 @@ func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url str | |||
| 3640 | 4546 | } | |
| 3641 | 4547 | ||
| 3642 | 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 | 4550 | if activeTab == nil { | |
| 3645 | 4551 | return nil | |
| 3646 | 4552 | } | |
| @@ -3694,14 +4600,10 @@ func (a *App) navigateFromOmnibox(ctx context.Context, url string) error { | |||
| 3694 | 4600 | if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() { | |
| 3695 | 4601 | return session.pane.Navigate(ctx, url) | |
| 3696 | 4602 | } | |
| 3697 | - | if a.omniboxNavigateFn != nil { | |
| 3698 | - | a.omniboxNavigateFn(url) | |
| 3699 | - | return nil | |
| 4603 | + | if bw := a.lastFocusedBrowserWindow(); bw != nil { | |
| 4604 | + | return a.navigateFromBrowserWindow(ctx, bw, url) | |
| 3700 | 4605 | } | |
| 3701 | - | if a.navCoord == nil { | |
| 3702 | - | return fmt.Errorf("navigation coordinator not initialized") | |
| 3703 | - | } | |
| 3704 | - | return a.navCoord.Navigate(ctx, url) | |
| 4606 | + | return fmt.Errorf("no focused browser window for omnibox navigation") | |
| 3705 | 4607 | } | |
| 3706 | 4608 | ||
| 3707 | 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 | 4707 | // initFilteringAsync starts background filter loading with toast feedback. | |
| 3813 | 4708 | func (a *App) initConfigWatcher(ctx context.Context) { | |
| 3814 | 4709 | log := logging.FromContext(ctx) | |
M internal/ui/app_browser_launch_test.go +1462 -47
| @@ -2,12 +2,15 @@ package ui | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "errors" | |
| 5 | 6 | "io" | |
| 6 | 7 | "os" | |
| 7 | 8 | "path/filepath" | |
| 8 | 9 | "reflect" | |
| 10 | + | "strconv" | |
| 9 | 11 | "strings" | |
| 10 | 12 | "testing" | |
| 13 | + | "time" | |
| 11 | 14 | "unsafe" | |
| 12 | 15 | ||
| 13 | 16 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -153,6 +156,39 @@ func windowForTabCount(t *testing.T, app *App) int { | |||
| 153 | 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 | 192 | // tabCoordinatorMainWindowPtr uses reflection to inspect unexported coordinator state in tests. | |
| 157 | 193 | // Keep this test-only so production visibility stays narrow. | |
| 158 | 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 | 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 | 234 | func newTestTabBarShell(t *testing.T, tabIDs ...entity.TabID) *component.TabBar { | |
| 181 | 235 | t.Helper() | |
| 182 | 236 | ||
| @@ -220,6 +274,32 @@ func windowTabBarVisible(t *testing.T, mw *window.MainWindow) bool { | |||
| 220 | 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 | 303 | func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool { | |
| 224 | 304 | t.Helper() | |
| 225 | 305 | if sv == nil { | |
| @@ -320,23 +400,102 @@ func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t | |||
| 320 | 400 | } | |
| 321 | 401 | ||
| 322 | 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 | 409 | app := &App{ | |
| 325 | 410 | tabs: entity.NewTabList(), | |
| 326 | 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 | 414 | workspaceViews: map[entity.TabID]*component.WorkspaceView{entity.TabID("id-1"): &component.WorkspaceView{}}, | |
| 329 | 415 | browserWindowFactory: func(context.Context, string) (*browserWindow, error) { | |
| 330 | 416 | 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 | 420 | if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil { | |
| 336 | 421 | t.Fatalf("OpenFreshWindow returned error: %v", err) | |
| 337 | 422 | } | |
| 338 | - | if got := windowForTabCount(t, app); got != 1 { | |
| 339 | - | t.Fatalf("windowForTab length = %d, want 1", got) | |
| 423 | + | if got := windowForTabCount(t, app); got != 2 { | |
| 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 | 553 | if _, ok := app.floatingSessions[staleSessionKey]; ok { | |
| 395 | 554 | t.Fatalf("stale floating session was not removed") | |
| 396 | 555 | } | |
| 556 | + | assertWindowOwnershipInvariant(t, app) | |
| 397 | 557 | } | |
| 398 | 558 | ||
| 399 | 559 | func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) { | |
| @@ -489,40 +649,53 @@ func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T | |||
| 489 | 649 | } | |
| 490 | 650 | ||
| 491 | 651 | func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) { | |
| 492 | - | created := &browserWindow{id: "window-1"} | |
| 652 | + | created := &browserWindow{id: "window-1", tabs: entity.NewTabList()} | |
| 493 | 653 | originalWindow := &window.MainWindow{} | |
| 494 | 654 | tabBar := &component.TabBar{} | |
| 495 | 655 | setWindowTabBar(t, originalWindow, tabBar) | |
| 496 | 656 | existingTabID := entity.TabID("existing-tab") | |
| 497 | 657 | staleTabID := entity.TabID("stale-tab") | |
| 498 | 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 | 666 | app := &App{ | |
| 500 | 667 | tabs: entity.NewTabList(), | |
| 501 | 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 | 671 | browserWindowFactory: func(context.Context, string) (*browserWindow, error) { | |
| 504 | 672 | return created, nil | |
| 505 | 673 | }, | |
| 506 | 674 | mainWindow: originalWindow, | |
| 507 | 675 | } | |
| 508 | - | app.tabs.Add(entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))) | |
| 509 | 676 | app.tabCoord = coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{ | |
| 510 | 677 | TabsUC: app.tabsUC, | |
| 511 | - | Tabs: app.tabs, | |
| 678 | + | Tabs: entity.NewTabList(), | |
| 512 | 679 | MainWindow: &window.MainWindow{}, | |
| 513 | 680 | }) | |
| 514 | 681 | ||
| 515 | 682 | if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil { | |
| 516 | 683 | t.Fatalf("OpenFreshWindow = nil error, want failure") | |
| 517 | 684 | } | |
| 518 | - | if got := len(app.browserWindows); got != 0 { | |
| 519 | - | t.Fatalf("browserWindows length = %d, want 0", got) | |
| 685 | + | if got := len(app.browserWindows); got != 1 { | |
| 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 { | |
| 522 | - | t.Fatalf("windowForTab length = %d, want 0", got) | |
| 691 | + | if app.browserWindows["window-1"] != nil { | |
| 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 { | |
| 525 | - | t.Fatalf("tab bar active tab = %q, want %q", got, existingTabID) | |
| 697 | + | if got := windowTabBarActiveID(t, originalWindow); got != staleTabID { | |
| 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 | 709 | tabs := entity.NewTabList() | |
| 537 | 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 | 718 | app := &App{ | |
| 540 | 719 | tabs: tabs, | |
| 541 | 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 | 723 | browserWindowFactory: func(context.Context, string) (*browserWindow, error) { | |
| 544 | 724 | return &browserWindow{id: "window-1", mainWindow: newWindow}, nil | |
| 545 | 725 | }, | |
| @@ -552,8 +732,7 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) { | |||
| 552 | 732 | }), | |
| 553 | 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")))) | |
| 556 | - | app.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) { | |
| 735 | + | app.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) { | |
| 557 | 736 | app.workspaceViews[tab.ID] = &component.WorkspaceView{} | |
| 558 | 737 | }) | |
| 559 | 738 | ||
| @@ -571,9 +750,8 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) { | |||
| 571 | 750 | if got := windowTabBarActiveID(t, oldWindow); got != "" { | |
| 572 | 751 | t.Fatalf("old window tab bar active tab = %q, want empty", got) | |
| 573 | 752 | } | |
| 574 | - | if got := windowTabBarVisible(t, newWindow); got { | |
| 575 | - | t.Fatalf("new window tab bar visible = %v, want false", got) | |
| 576 | - | } | |
| 753 | + | // The lightweight test tab bar shell has no GTK box, so allocation state is | |
| 754 | + | // covered by TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation. | |
| 577 | 755 | } | |
| 578 | 756 | ||
| 579 | 757 | func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) { | |
| @@ -584,8 +762,14 @@ func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) { | |||
| 584 | 762 | tabs.Add(tab2) | |
| 585 | 763 | ws1 := &component.WorkspaceView{} | |
| 586 | 764 | ws2 := &component.WorkspaceView{} | |
| 587 | - | first := &browserWindow{id: "window-1", activeTabID: tab1.ID, mainWindow: &window.MainWindow{}} | |
| 588 | - | second := &browserWindow{id: "window-2", activeTabID: tab2.ID, mainWindow: &window.MainWindow{}} | |
| 765 | + | firstTabs := entity.NewTabList() | |
| 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 | 773 | kh := &input.KeyboardHandler{} | |
| 590 | 774 | gs := &input.GlobalShortcutHandler{} | |
| 591 | 775 | second.keyboardHandler = kh | |
| @@ -603,8 +787,8 @@ func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) { | |||
| 603 | 787 | if app.mainWindow != second.mainWindow { | |
| 604 | 788 | t.Fatalf("mainWindow = %p, want %p", app.mainWindow, second.mainWindow) | |
| 605 | 789 | } | |
| 606 | - | if app.tabs.ActiveTabID != tab2.ID { | |
| 607 | - | t.Fatalf("active tab = %q, want %q", app.tabs.ActiveTabID, tab2.ID) | |
| 790 | + | if second.tabs.ActiveTabID != tab2.ID { | |
| 791 | + | t.Fatalf("active tab = %q, want %q", second.tabs.ActiveTabID, tab2.ID) | |
| 608 | 792 | } | |
| 609 | 793 | if app.activeWorkspaceView() != ws2 { | |
| 610 | 794 | t.Fatalf("active workspace view = %p, want %p", app.activeWorkspaceView(), ws2) | |
| @@ -623,8 +807,12 @@ func TestApp_BrowserWindowForPaneFindsOwningWindow(t *testing.T) { | |||
| 623 | 807 | tabs := entity.NewTabList() | |
| 624 | 808 | tabs.Add(tab1) | |
| 625 | 809 | tabs.Add(tab2) | |
| 626 | - | first := &browserWindow{id: "window-1"} | |
| 627 | - | second := &browserWindow{id: "window-2"} | |
| 810 | + | firstTabs := entity.NewTabList() | |
| 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 | 816 | app := &App{ | |
| 629 | 817 | tabs: tabs, | |
| 630 | 818 | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| @@ -644,8 +832,12 @@ func TestApp_OwnerOrLastFocusedBrowserWindowPrefersPaneOwner(t *testing.T) { | |||
| 644 | 832 | tabs := entity.NewTabList() | |
| 645 | 833 | tabs.Add(tab1) | |
| 646 | 834 | tabs.Add(tab2) | |
| 647 | - | first := &browserWindow{id: "window-1"} | |
| 648 | - | second := &browserWindow{id: "window-2"} | |
| 835 | + | firstTabs := entity.NewTabList() | |
| 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 | 841 | app := &App{ | |
| 650 | 842 | tabs: tabs, | |
| 651 | 843 | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| @@ -680,8 +872,14 @@ func TestApp_HandlePaneWindowTitleChangedTargetsOwningWindow(t *testing.T) { | |||
| 680 | 872 | tabs := entity.NewTabList() | |
| 681 | 873 | tabs.Add(tab1) | |
| 682 | 874 | tabs.Add(tab2) | |
| 683 | - | first := &browserWindow{id: "window-1", mainWindow: firstMainWindow} | |
| 684 | - | second := &browserWindow{id: "window-2", mainWindow: secondMainWindow} | |
| 875 | + | firstTabs := entity.NewTabList() | |
| 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 | 883 | app := &App{ | |
| 686 | 884 | tabs: tabs, | |
| 687 | 885 | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| @@ -719,8 +917,14 @@ func TestApp_ActivateBrowserWindowResyncsTitleFromBackgroundPane(t *testing.T) { | |||
| 719 | 917 | tabs := entity.NewTabList() | |
| 720 | 918 | tabs.Add(tab1) | |
| 721 | 919 | tabs.Add(tab2) | |
| 722 | - | first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, activeTabID: tab1.ID} | |
| 723 | - | second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, activeTabID: tab2.ID} | |
| 920 | + | firstTabs := entity.NewTabList() | |
| 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 | 928 | contentCoord := &contentcoord.Coordinator{} | |
| 725 | 929 | contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles") | |
| 726 | 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 | 970 | tabs.Add(tab2) | |
| 767 | 971 | secondMainWindow.TabBar().AddTab(tab1) | |
| 768 | 972 | secondMainWindow.TabBar().AddTab(tab2) | |
| 769 | - | first := &browserWindow{id: "window-1", mainWindow: firstMainWindow} | |
| 770 | - | second := &browserWindow{id: "window-2", mainWindow: secondMainWindow} | |
| 973 | + | firstTabs := entity.NewTabList() | |
| 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 | 981 | app := &App{ | |
| 772 | 982 | tabs: tabs, | |
| 773 | 983 | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| @@ -851,8 +1061,14 @@ func TestApp_MoveActivePaneToTabFromBrowserWindowAnchorsOwningWindow(t *testing. | |||
| 851 | 1061 | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2"))) | |
| 852 | 1062 | tabs.Add(tab1) | |
| 853 | 1063 | tabs.Add(tab2) | |
| 854 | - | first := &browserWindow{id: "window-1", activeTabID: tab1.ID} | |
| 855 | - | second := &browserWindow{id: "window-2", activeTabID: tab2.ID} | |
| 1064 | + | firstTabs := entity.NewTabList() | |
| 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 | 1072 | app := &App{ | |
| 857 | 1073 | tabs: tabs, | |
| 858 | 1074 | movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }), | |
| @@ -882,8 +1098,14 @@ func TestApp_MoveActivePaneToTabFromBrowserWindowActivatesTargetOwnerForCrossWin | |||
| 882 | 1098 | tabs.Add(tab2) | |
| 883 | 1099 | tabs.Add(tab3) | |
| 884 | 1100 | tabs.SetActive(tab1.ID) | |
| 885 | - | first := &browserWindow{id: "window-1", activeTabID: tab1.ID} | |
| 886 | - | second := &browserWindow{id: "window-2", activeTabID: tab2.ID} | |
| 1101 | + | firstTabs := entity.NewTabList() | |
| 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 | 1109 | app := &App{ | |
| 888 | 1110 | tabs: tabs, | |
| 889 | 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 | 1198 | func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) { | |
| 918 | 1199 | gtkApp := requireGTKDisplayApp(t) | |
| 919 | 1200 | defer gtkApp.Unref() | |
| @@ -936,7 +1217,7 @@ func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) | |||
| 936 | 1217 | deps: &Dependencies{Config: &config.Config{}}, | |
| 937 | 1218 | mainWindow: mainWindow, | |
| 938 | 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 | 1223 | app.initKeyboardHandler(context.Background()) | |
| @@ -974,7 +1255,7 @@ type testFocusedInputProvider struct{} | |||
| 974 | 1255 | func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil } | |
| 975 | 1256 | func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget) {} | |
| 976 | 1257 | ||
| 977 | - | func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) { | |
| 1258 | + | func TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation(t *testing.T) { | |
| 978 | 1259 | gtkApp := requireGTKDisplayApp(t) | |
| 979 | 1260 | defer gtkApp.Unref() | |
| 980 | 1261 | ||
| @@ -983,7 +1264,48 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled( | |||
| 983 | 1264 | if tabBar == nil { | |
| 984 | 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 | 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 | 1309 | setWindowTabBar(t, mainWindow, tabBar) | |
| 988 | 1310 | bw := &browserWindow{id: "window-1", mainWindow: mainWindow} | |
| 989 | 1311 | app := &App{ | |
| @@ -993,8 +1315,141 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled( | |||
| 993 | 1315 | ||
| 994 | 1316 | app.updateBrowserWindowTabBarVisibility(bw) | |
| 995 | 1317 | ||
| 996 | - | if got := windowTabBarVisible(t, mainWindow); !got { | |
| 997 | - | t.Fatalf("tab bar visible = %v, want true", got) | |
| 1318 | + | assertWindowTabBarAutoHidden(t, mainWindow, false) | |
| 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 | 1467 | firstValid.id: firstValid, | |
| 1013 | 1468 | secondValid.id: secondValid, | |
| 1014 | 1469 | }, | |
| 1470 | + | browserWindowOrder: []string{removed.id, nilWindow.id, secondValid.id, firstValid.id}, | |
| 1015 | 1471 | lastFocusedWindowID: removed.id, | |
| 1016 | 1472 | } | |
| 1017 | 1473 | ||
| 1018 | 1474 | app.removeBrowserWindow(removed.id) | |
| 1019 | 1475 | ||
| 1020 | - | if app.mainWindow != firstValid.mainWindow { | |
| 1021 | - | t.Fatalf("mainWindow = %p, want %p", app.mainWindow, firstValid.mainWindow) | |
| 1476 | + | if app.mainWindow != secondValid.mainWindow { | |
| 1477 | + | t.Fatalf("mainWindow = %p, want %p", app.mainWindow, secondValid.mainWindow) | |
| 1022 | 1478 | } | |
| 1023 | - | if app.lastFocusedWindowID != firstValid.id { | |
| 1024 | - | t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, firstValid.id) | |
| 1479 | + | if app.lastFocusedWindowID != secondValid.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 | 1575 | func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) { | |
| 1074 | 1576 | first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | |
| 1075 | 1577 | second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | |
| @@ -1085,3 +1587,916 @@ func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) { | |||
| 1085 | 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 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 11 | "github.com/bnema/dumber/internal/ui/component" | |
| 12 | + | "github.com/bnema/dumber/internal/ui/coordinator" | |
| 12 | 13 | "github.com/bnema/dumber/internal/ui/focus" | |
| 13 | 14 | "github.com/bnema/dumber/internal/ui/input" | |
| 14 | 15 | "github.com/bnema/dumber/internal/ui/layout" | |
| @@ -19,8 +20,7 @@ import ( | |||
| 19 | 20 | type browserWindow struct { | |
| 20 | 21 | id string | |
| 21 | 22 | initialURL string | |
| 22 | - | activeTabID entity.TabID | |
| 23 | - | prevActiveTabID entity.TabID // per-window previous active tab (for Alt+Tab behavior) | |
| 23 | + | tabs *entity.TabList // per-window tab list (single source of truth for tab state) | |
| 24 | 24 | mainWindow *window.MainWindow | |
| 25 | 25 | appToaster *component.Toaster | |
| 26 | 26 | modeToaster *component.Toaster | |
| @@ -231,14 +231,29 @@ func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) { | |||
| 231 | 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 | 240 | func (a *App) registerBrowserWindow(bw *browserWindow) { | |
| 235 | 241 | if bw == nil { | |
| 236 | 242 | return | |
| 237 | 243 | } | |
| 244 | + | bw.ensureTabs() | |
| 238 | 245 | if a.browserWindows == nil { | |
| 239 | 246 | a.browserWindows = make(map[string]*browserWindow) | |
| 240 | 247 | } | |
| 241 | 248 | 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 | 259 | func (a *App) removeBrowserWindow(id string) { | |
| @@ -246,6 +261,14 @@ func (a *App) removeBrowserWindow(id string) { | |||
| 246 | 261 | return | |
| 247 | 262 | } | |
| 248 | 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 | 272 | wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow | |
| 250 | 273 | ownedTabIDs := make([]entity.TabID, 0) | |
| 251 | 274 | for tabID, bw := range a.windowForTab { | |
| @@ -284,7 +307,7 @@ func (a *App) removeBrowserWindow(id string) { | |||
| 284 | 307 | a.keyboardHandler = nil | |
| 285 | 308 | a.globalShortcutHandler = nil | |
| 286 | 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 | 316 | if a.browserWindows == nil { | |
| 294 | 317 | 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 | 326 | ids := make([]string, 0, len(a.browserWindows)) | |
| 297 | 327 | for id, bw := range a.browserWindows { | |
| 298 | 328 | if id == "" || bw == nil || bw.mainWindow == nil { | |
M internal/ui/browser_window_test.go +543 -2
| @@ -14,6 +14,8 @@ import ( | |||
| 14 | 14 | "github.com/bnema/dumber/internal/ui/layout" | |
| 15 | 15 | "github.com/bnema/dumber/internal/ui/window" | |
| 16 | 16 | "github.com/bnema/puregotk/v4/gtk" | |
| 17 | + | "github.com/stretchr/testify/assert" | |
| 18 | + | "github.com/stretchr/testify/require" | |
| 17 | 19 | ) | |
| 18 | 20 | ||
| 19 | 21 | func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) { | |
| @@ -66,8 +68,12 @@ func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) { | |||
| 66 | 68 | otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other"))) | |
| 67 | 69 | removedMainWindow := &window.MainWindow{} | |
| 68 | 70 | remainingMainWindow := &window.MainWindow{} | |
| 69 | - | removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow} | |
| 70 | - | remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow} | |
| 71 | + | removedTabs := entity.NewTabList() | |
| 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 | 77 | ownedSessionKey := floatingSessionKey{tabID: ownedTab.ID, sessionID: "profile:owned"} | |
| 72 | 78 | otherSessionKey := floatingSessionKey{tabID: otherTab.ID, sessionID: "profile:other"} | |
| 73 | 79 | app := &App{ | |
| @@ -110,6 +116,7 @@ func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) { | |||
| 110 | 116 | if _, ok := app.floatingSessions[otherSessionKey]; !ok { | |
| 111 | 117 | t.Fatalf("other floating session should remain") | |
| 112 | 118 | } | |
| 119 | + | assertWindowOwnershipInvariant(t, app) | |
| 113 | 120 | } | |
| 114 | 121 | ||
| 115 | 122 | func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) { | |
| @@ -129,6 +136,9 @@ func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) { | |||
| 129 | 136 | if _, ok := app.browserWindows[second.id]; !ok { | |
| 130 | 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 | 143 | app.removeBrowserWindow(first.id) | |
| 134 | 144 | ||
| @@ -309,3 +319,534 @@ type testPermissionDialogPresenter struct{} | |||
| 309 | 319 | ||
| 310 | 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 | 12 | box *gtk.Box | |
| 13 | 13 | buttons map[entity.TabID]*TabButton | |
| 14 | 14 | ||
| 15 | + | explicitlyVisible bool | |
| 16 | + | ||
| 15 | 17 | // Active tab tracking | |
| 16 | 18 | activeTabID entity.TabID | |
| 17 | 19 | ||
| @@ -26,7 +28,8 @@ type TabBar struct { | |||
| 26 | 28 | // NewTabBar creates a new tab bar widget. | |
| 27 | 29 | func NewTabBar() *TabBar { | |
| 28 | 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 | 35 | // Create horizontal box container | |
| @@ -157,12 +160,44 @@ func (tb *TabBar) ActiveTabID() entity.TabID { | |||
| 157 | 160 | 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 | 164 | func (tb *TabBar) SetVisible(visible bool) { | |
| 162 | - | if tb.box == nil { | |
| 165 | + | if tb == nil || tb.box == nil { | |
| 163 | 166 | return | |
| 164 | 167 | } | |
| 168 | + | tb.explicitlyVisible = visible | |
| 165 | 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 | 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 | 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 | 283 | func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) { | |
| 279 | 284 | c.activePaneOverrideMu.RLock() | |
| 280 | 285 | defer c.activePaneOverrideMu.RUnlock() | |
M internal/ui/coordinator/navigation.go +78 -85
| @@ -16,7 +16,6 @@ import ( | |||
| 16 | 16 | type OmniboxProvider interface { | |
| 17 | 17 | ToggleOmnibox(ctx context.Context) | |
| 18 | 18 | UpdateOmniboxZoom(factor float64) | |
| 19 | - | SetOmniboxOnNavigate(fn func(url string)) | |
| 20 | 19 | } | |
| 21 | 20 | ||
| 22 | 21 | // NavigationCoordinator handles URL navigation, history, and browser controls. | |
| @@ -51,137 +50,138 @@ func NewNavigationCoordinator( | |||
| 51 | 50 | // SetOmniboxProvider sets the omnibox provider for toggle/zoom operations. | |
| 52 | 51 | func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) { | |
| 53 | 52 | 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. | |
| 68 | - | // This properly handles history recording and zoom application. | |
| 69 | - | func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error { | |
| 70 | - | log := logging.FromContext(ctx) | |
| 71 | - | if c.contentCoord == nil { | |
| 72 | - | log.Warn().Str("url", url).Msg("content coordinator not initialized") | |
| 73 | - | return fmt.Errorf("content coordinator not initialized") | |
| 55 | + | // requireWebView returns an error if wv is nil, preserving stable error text. | |
| 56 | + | func requireWebView(wv port.WebView) error { | |
| 57 | + | if wv == nil { | |
| 58 | + | return fmt.Errorf("no webview provided") | |
| 74 | 59 | } | |
| 60 | + | return nil | |
| 61 | + | } | |
| 75 | 62 | ||
| 76 | - | wv := c.contentCoord.ActiveWebView(ctx) | |
| 77 | - | if wv == nil { | |
| 78 | - | log.Warn().Str("url", url).Msg("no active webview for navigation") | |
| 79 | - | return fmt.Errorf("no active webview") | |
| 63 | + | func (c *NavigationCoordinator) trackNavigationOrigin(ctx context.Context, paneID entity.PaneID, url string) { | |
| 64 | + | if c.contentCoord == nil || paneID == "" { | |
| 65 | + | return | |
| 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 tracking | |
| 83 | - | activePaneID := c.contentCoord.ActivePaneID(ctx) | |
| 84 | - | paneID := string(activePaneID) | |
| 85 | - | if activePaneID != "" { | |
| 86 | - | // Track original URL for cross-domain redirect favicon caching | |
| 87 | - | c.contentCoord.SetNavigationOrigin(activePaneID, url) | |
| 88 | - | // Pre-load cached favicon asynchronously (don't block navigation start) | |
| 89 | - | go func() { | |
| 90 | - | preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout) | |
| 91 | - | defer cancelPreload() | |
| 92 | - | c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url) | |
| 93 | - | }() | |
| 75 | + | // NavigateWebView loads a URL in the specified pane using the provided WebView. | |
| 76 | + | // This is the explicit-target equivalent of Navigate: it uses NavigateUseCase if | |
| 77 | + | // available, falling back to direct LoadURI. Origin tracking via contentCoord is | |
| 78 | + | // performed when contentCoord and paneID are available. | |
| 79 | + | func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string, paneID entity.PaneID, wv port.WebView) error { | |
| 80 | + | log := logging.FromContext(ctx) | |
| 81 | + | ||
| 82 | + | if err := requireWebView(wv); err != nil { | |
| 83 | + | log.Warn().Str("url", url).Msg("NavigateWebView called with nil webview") | |
| 84 | + | return err | |
| 94 | 85 | } | |
| 95 | 86 | ||
| 96 | - | // Use NavigateUseCase which handles history + zoom | |
| 87 | + | c.trackNavigationOrigin(ctx, paneID, url) | |
| 88 | + | ||
| 89 | + | paneIDStr := string(paneID) | |
| 90 | + | ||
| 97 | 91 | if c.navigateUC != nil { | |
| 98 | 92 | input := usecase.NavigateInput{ | |
| 99 | 93 | URL: url, | |
| 100 | - | PaneID: paneID, | |
| 101 | - | WebView: wv, // webkit.WebView implements port.WebView | |
| 94 | + | PaneID: paneIDStr, | |
| 95 | + | WebView: wv, | |
| 102 | 96 | } | |
| 103 | 97 | output, err := c.navigateUC.Execute(ctx, input) | |
| 104 | 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 | 100 | return err | |
| 107 | 101 | } | |
| 108 | 102 | log.Debug(). | |
| 109 | 103 | Str("url", url). | |
| 104 | + | Str("pane_id", paneIDStr). | |
| 110 | 105 | Float64("zoom", output.AppliedZoom). | |
| 106 | + | Uint64("webview_id", uint64(wv.ID())). | |
| 111 | 107 | Msg("navigation initiated via usecase") | |
| 112 | 108 | return nil | |
| 113 | 109 | } | |
| 114 | 110 | ||
| 115 | - | // Fallback: direct navigation without usecase | |
| 116 | 111 | log.Warn().Msg("navigateUC not available, using direct LoadURI") | |
| 117 | 112 | if err := wv.LoadURI(ctx, url); err != nil { | |
| 118 | 113 | log.Error().Err(err).Str("url", url).Msg("failed to navigate") | |
| 119 | 114 | 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 | 118 | return nil | |
| 124 | 119 | } | |
| 125 | 120 | ||
| 126 | - | // Reload reloads the current page. | |
| 127 | - | func (c *NavigationCoordinator) Reload(ctx context.Context) error { | |
| 121 | + | // ReloadWebView reloads the page in the provided WebView, optionally bypassing cache. | |
| 122 | + | func (c *NavigationCoordinator) ReloadWebView(ctx context.Context, wv port.WebView, bypassCache bool) error { | |
| 128 | 123 | log := logging.FromContext(ctx) | |
| 129 | 124 | ||
| 130 | - | wv := c.contentCoord.ActiveWebView(ctx) | |
| 131 | - | if wv == nil { | |
| 132 | - | log.Debug().Msg("no active webview for reload") | |
| 133 | - | return nil | |
| 125 | + | if err := requireWebView(wv); err != nil { | |
| 126 | + | log.Debug().Msg("ReloadWebView called with nil webview") | |
| 127 | + | return err | |
| 134 | 128 | } | |
| 135 | 129 | ||
| 136 | 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 | 140 | return wv.Reload(ctx) | |
| 141 | 141 | } | |
| 142 | 142 | ||
| 143 | - | // HardReload reloads the current page bypassing cache. | |
| 144 | - | func (c *NavigationCoordinator) HardReload(ctx context.Context) error { | |
| 143 | + | // StopWebView stops loading in the provided WebView. | |
| 144 | + | func (c *NavigationCoordinator) StopWebView(ctx context.Context, wv port.WebView) error { | |
| 145 | 145 | log := logging.FromContext(ctx) | |
| 146 | 146 | ||
| 147 | - | wv := c.contentCoord.ActiveWebView(ctx) | |
| 148 | - | if wv == nil { | |
| 149 | - | log.Debug().Msg("no active webview for hard reload") | |
| 150 | - | return nil | |
| 147 | + | if err := requireWebView(wv); err != nil { | |
| 148 | + | log.Debug().Msg("StopWebView called with nil webview") | |
| 149 | + | return err | |
| 151 | 150 | } | |
| 152 | 151 | ||
| 153 | 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 | 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 | 163 | log := logging.FromContext(ctx) | |
| 164 | 164 | ||
| 165 | - | wv := c.contentCoord.ActiveWebView(ctx) | |
| 166 | - | if wv == nil { | |
| 167 | - | log.Debug().Msg("no active webview for go back") | |
| 168 | - | return nil | |
| 165 | + | if err := requireWebView(wv); err != nil { | |
| 166 | + | log.Debug().Msg("GoBackWebView called with nil webview") | |
| 167 | + | return err | |
| 169 | 168 | } | |
| 170 | 169 | ||
| 170 | + | log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("going back in explicit webview") | |
| 171 | 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 | 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 | 177 | log := logging.FromContext(ctx) | |
| 178 | 178 | ||
| 179 | - | wv := c.contentCoord.ActiveWebView(ctx) | |
| 180 | - | if wv == nil { | |
| 181 | - | log.Debug().Msg("no active webview for go forward") | |
| 182 | - | return nil | |
| 179 | + | if err := requireWebView(wv); err != nil { | |
| 180 | + | log.Debug().Msg("GoForwardWebView called with nil webview") | |
| 181 | + | return err | |
| 183 | 182 | } | |
| 184 | 183 | ||
| 184 | + | log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("going forward in explicit webview") | |
| 185 | 185 | return wv.GoForward(ctx) | |
| 186 | 186 | } | |
| 187 | 187 | ||
| @@ -199,14 +199,13 @@ func (c *NavigationCoordinator) OpenOmnibox(ctx context.Context) error { | |||
| 199 | 199 | return nil | |
| 200 | 200 | } | |
| 201 | 201 | ||
| 202 | - | // OpenDevTools opens the WebKit inspector for the active WebView. | |
| 203 | - | func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error { | |
| 202 | + | // OpenDevToolsWebView opens the WebKit inspector for the provided WebView. | |
| 203 | + | func (c *NavigationCoordinator) OpenDevToolsWebView(ctx context.Context, wv port.WebView) error { | |
| 204 | 204 | log := logging.FromContext(ctx) | |
| 205 | 205 | ||
| 206 | - | wv := c.contentCoord.ActiveWebView(ctx) | |
| 207 | - | if wv == nil { | |
| 208 | - | log.Warn().Msg("no active webview for devtools") | |
| 209 | - | return fmt.Errorf("no active webview") | |
| 206 | + | if err := requireWebView(wv); err != nil { | |
| 207 | + | log.Warn().Msg("OpenDevToolsWebView called with nil webview") | |
| 208 | + | return err | |
| 210 | 209 | } | |
| 211 | 210 | ||
| 212 | 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 | 218 | return fmt.Errorf("webview does not support devtools") | |
| 220 | 219 | } | |
| 221 | 220 | ||
| 222 | - | // PrintPage opens the print dialog for the active WebView. | |
| 223 | - | func (c *NavigationCoordinator) PrintPage(ctx context.Context) error { | |
| 221 | + | // PrintWebView opens the print dialog for the provided WebView. | |
| 222 | + | func (c *NavigationCoordinator) PrintWebView(ctx context.Context, wv port.WebView) error { | |
| 224 | 223 | log := logging.FromContext(ctx) | |
| 225 | 224 | ||
| 226 | - | wv := c.contentCoord.ActiveWebView(ctx) | |
| 227 | - | if wv == nil { | |
| 228 | - | log.Warn().Msg("no active webview for print") | |
| 229 | - | return fmt.Errorf("no active webview") | |
| 225 | + | if err := requireWebView(wv); err != nil { | |
| 226 | + | log.Warn().Msg("PrintWebView called with nil webview") | |
| 227 | + | return err | |
| 230 | 228 | } | |
| 231 | 229 | ||
| 232 | 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 | 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 | 266 | // NotifyZoomChanged updates the omnibox zoom indicator. | |
| 274 | 267 | func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) { | |
| 275 | 268 | if c.omniboxProvider != nil { | |
M internal/ui/coordinator/navigation_test.go +10 -20
| @@ -4,38 +4,28 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | - | "github.com/stretchr/testify/assert" | |
| 8 | 7 | "github.com/stretchr/testify/require" | |
| 9 | 8 | ) | |
| 10 | 9 | ||
| 11 | 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) {} | |
| 18 | - | ||
| 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") | |
| 17 | + | func (f *fakeOmniboxProvider) UpdateOmniboxZoom(factor float64) { | |
| 18 | + | f.zoomUpdates = append(f.zoomUpdates, factor) | |
| 29 | 19 | } | |
| 30 | 20 | ||
| 31 | - | func TestNavigationCoordinator_OmniboxCallbackDoesNotPanicOnNavigateError(t *testing.T) { | |
| 21 | + | func TestNavigationCoordinator_OmniboxProviderOpenAndZoom(t *testing.T) { | |
| 32 | 22 | c := &NavigationCoordinator{} | |
| 33 | 23 | provider := &fakeOmniboxProvider{} | |
| 34 | 24 | ||
| 35 | 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() { | |
| 39 | - | provider.onNavigate("https://example.com") | |
| 40 | - | }) | |
| 29 | + | require.True(t, provider.toggled) | |
| 30 | + | require.Equal(t, []float64{1.25}, provider.zoomUpdates) | |
| 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 | 12 | "github.com/bnema/dumber/internal/ui/window" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | - | // TabScopeFunc filters whether a tab belongs to the current window scope. | |
| 16 | - | // Returns true if the tab should be considered for window-local operations. | |
| 17 | - | type TabScopeFunc func(tab *entity.Tab, mainWindow *window.MainWindow) bool | |
| 15 | + | // TabTarget identifies which TabList and MainWindow a tab operation applies to. | |
| 16 | + | // Each browser window owns a dedicated TabTarget; operations are scoped to that target. | |
| 17 | + | 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 | 26 | type TabCoordinator struct { | |
| 21 | 27 | tabsUC *usecase.ManageTabsUseCase | |
| 22 | - | tabs *entity.TabList | |
| 23 | - | mainWindow *window.MainWindow | |
| 28 | + | currentTarget TabTarget | |
| 29 | + | mainWindow *window.MainWindow // retained for GetTabBar and SetMainWindow | |
| 24 | 30 | hideTabBarWhenSingleTab bool | |
| 25 | 31 | ||
| 26 | - | // Window-scoped tab filtering (per-window tab ownership) | |
| 27 | - | tabScope TabScopeFunc | |
| 28 | - | ||
| 29 | 32 | // Callbacks to avoid circular dependencies | |
| 30 | - | onTabCreated func(ctx context.Context, tab *entity.Tab) | |
| 31 | - | onTabSwitched func(ctx context.Context, tab *entity.Tab) | |
| 33 | + | onTabCreated func(ctx context.Context, target TabTarget, tab *entity.Tab) | |
| 34 | + | onTabSwitched func(ctx context.Context, target TabTarget, tab *entity.Tab) | |
| 32 | 35 | onQuit func() | |
| 33 | - | onCurrentWindowEmpty func(ctx context.Context, mainWindow *window.MainWindow) | |
| 36 | + | onCurrentWindowEmpty func(ctx context.Context, target TabTarget) | |
| 34 | 37 | onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs | |
| 35 | 38 | 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 | 41 | // TabCoordinatorConfig holds configuration for TabCoordinator. | |
| 43 | 42 | type TabCoordinatorConfig struct { | |
| 44 | 43 | TabsUC *usecase.ManageTabsUseCase | |
| 45 | - | Tabs *entity.TabList | |
| 44 | + | Tabs *entity.TabList // Initial tab list for the default target | |
| 46 | 45 | MainWindow *window.MainWindow | |
| 47 | 46 | HideTabBarWhenSingleTab bool | |
| 48 | 47 | } | |
| @@ -53,21 +52,38 @@ func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordi | |||
| 53 | 52 | log.Debug().Msg("creating tab coordinator") | |
| 54 | 53 | ||
| 55 | 54 | return &TabCoordinator{ | |
| 56 | - | tabsUC: cfg.TabsUC, | |
| 57 | - | tabs: cfg.Tabs, | |
| 55 | + | tabsUC: cfg.TabsUC, | |
| 56 | + | currentTarget: TabTarget{ | |
| 57 | + | Tabs: cfg.Tabs, | |
| 58 | + | MainWindow: cfg.MainWindow, | |
| 59 | + | }, | |
| 58 | 60 | mainWindow: cfg.MainWindow, | |
| 59 | 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 | 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 | 81 | c.onTabCreated = fn | |
| 66 | 82 | } | |
| 67 | 83 | ||
| 68 | 84 | // SetOnTabSwitched sets the callback for when a tab switch occurs. | |
| 69 | 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 | 87 | c.onTabSwitched = fn | |
| 72 | 88 | } | |
| 73 | 89 | ||
| @@ -81,44 +97,44 @@ func (c *TabCoordinator) SetOnStateChanged(fn func()) { | |||
| 81 | 97 | 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 | 100 | // SetOnCurrentWindowEmpty sets the callback for when the last tab in the current | |
| 99 | 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 | 103 | c.onCurrentWindowEmpty = fn | |
| 102 | 104 | } | |
| 103 | 105 | ||
| 104 | - | // scopedTabs returns tabs filtered by the tabScope function. | |
| 105 | - | // When no scope function is set, returns all tabs (backward compatible). | |
| 106 | - | func (c *TabCoordinator) scopedTabs() []*entity.Tab { | |
| 107 | - | if c.tabs == nil { | |
| 108 | - | return nil | |
| 106 | + | // SetMainWindow updates only the main window reference for coordinator setup. | |
| 107 | + | // | |
| 108 | + | // Deprecated: Use SetCurrentTarget for runtime targeting. This method preserves | |
| 109 | + | // currentTarget.Tabs and is safe only during app-level setup. | |
| 110 | + | 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 | - | ||
| 111 | - | if c.tabScope == nil { | |
| 112 | - | return c.tabs.Tabs | |
| 119 | + | if c.currentTarget.Tabs != nil && c.currentTarget.Tabs.Count() > 0 && oldMainWindow != mainWindow { | |
| 120 | + | logging.FromContext(context.Background()).Debug(). | |
| 121 | + | 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)) | |
| 116 | - | for _, tab := range c.tabs.Tabs { | |
| 117 | - | if tab != nil && c.tabScope(tab, c.mainWindow) { | |
| 118 | - | scoped = append(scoped, tab) | |
| 119 | - | } | |
| 128 | + | // SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs. | |
| 129 | + | func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView)) { | |
| 130 | + | c.onAttachPopupToTab = fn | |
| 131 | + | } | |
| 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 | 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 | 148 | return -1 | |
| 133 | 149 | } | |
| 134 | 150 | ||
| 135 | - | // SetMainWindow updates the window targeted by tab UI operations. | |
| 136 | - | func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) { | |
| 137 | - | c.mainWindow = mainWindow | |
| 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) | |
| 151 | + | // Create creates a new tab in the given target with the given initial URL. | |
| 152 | + | func (c *TabCoordinator) Create(ctx context.Context, target TabTarget, initialURL string) (*entity.Tab, error) { | |
| 153 | + | return c.create(ctx, target, 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 | 157 | log := logging.FromContext(ctx) | |
| 154 | 158 | ||
| 159 | + | if target.Tabs == nil { | |
| 160 | + | return nil, fmt.Errorf("target.Tabs is nil") | |
| 161 | + | } | |
| 162 | + | ||
| 155 | 163 | output, err := c.tabsUC.Create(ctx, usecase.CreateTabInput{ | |
| 156 | - | TabList: c.tabs, | |
| 164 | + | TabList: target.Tabs, | |
| 157 | 165 | Name: "", | |
| 158 | 166 | InitialURL: initialURL, | |
| 159 | 167 | }) | |
| @@ -164,30 +172,30 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate | |||
| 164 | 172 | ||
| 165 | 173 | if activate { | |
| 166 | 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 | 178 | // Notify app before adding the tab to the visible tab bar. | |
| 171 | 179 | // The app assigns window ownership and the window-scoped default name here, | |
| 172 | 180 | // and TabBar.AddTab reads tab.Title() immediately when creating the button. | |
| 173 | 181 | if c.onTabCreated != nil { | |
| 174 | - | c.onTabCreated(ctx, output.Tab) | |
| 182 | + | c.onTabCreated(ctx, target, output.Tab) | |
| 175 | 183 | } | |
| 176 | 184 | ||
| 177 | 185 | // Update tab bar | |
| 178 | - | if c.mainWindow != nil && c.mainWindow.TabBar() != nil { | |
| 179 | - | c.mainWindow.TabBar().AddTab(output.Tab) | |
| 186 | + | if target.MainWindow != nil && target.MainWindow.TabBar() != nil { | |
| 187 | + | target.MainWindow.TabBar().AddTab(output.Tab) | |
| 180 | 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 | 193 | // Update tab bar visibility | |
| 186 | - | c.UpdateBarVisibility(ctx) | |
| 194 | + | c.UpdateBarVisibility(ctx, target) | |
| 187 | 195 | ||
| 188 | 196 | // Switch to the new tab's workspace view when activation is requested. | |
| 189 | 197 | if activate && c.onTabSwitched != nil { | |
| 190 | - | c.onTabSwitched(ctx, output.Tab) | |
| 198 | + | c.onTabSwitched(ctx, target, output.Tab) | |
| 191 | 199 | } | |
| 192 | 200 | ||
| 193 | 201 | // Notify state change for session snapshots | |
| @@ -197,269 +205,280 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate | |||
| 197 | 205 | return output.Tab, nil | |
| 198 | 206 | } | |
| 199 | 207 | ||
| 200 | - | // Close closes the active tab. | |
| 201 | - | // When scoped, prefers switching to a sibling tab in the same window. | |
| 202 | - | // If this was the last tab in the window, fires onCurrentWindowEmpty. | |
| 203 | - | // If this was the last tab globally, fires onQuit. | |
| 204 | - | func (c *TabCoordinator) Close(ctx context.Context) error { | |
| 208 | + | // Close closes the active tab in the given target. | |
| 209 | + | // When the target's TabList becomes empty, fires onCurrentWindowEmpty. | |
| 210 | + | // The caller (App) is responsible for deciding whether all windows are empty | |
| 211 | + | // and the process should quit. onQuit is NOT fired by Close. | |
| 212 | + | func (c *TabCoordinator) Close(ctx context.Context, target TabTarget) error { | |
| 205 | 213 | log := logging.FromContext(ctx) | |
| 206 | 214 | ||
| 207 | - | activeID := c.tabs.ActiveTabID | |
| 215 | + | if target.Tabs == nil { | |
| 216 | + | return fmt.Errorf("target.Tabs is nil") | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | activeID := target.Tabs.ActiveTabID | |
| 208 | 220 | if activeID == "" { | |
| 209 | 221 | log.Debug().Msg("no active tab to close") | |
| 210 | 222 | return nil | |
| 211 | 223 | } | |
| 212 | 224 | ||
| 213 | - | // Compute the next scoped tab to activate BEFORE closing | |
| 214 | - | scoped := c.scopedTabs() | |
| 215 | - | activeIdx := indexOfTab(scoped, activeID) | |
| 216 | - | if activeIdx < 0 && len(scoped) > 0 { | |
| 217 | - | log.Debug().Str("active_tab_id", string(activeID)).Int("scoped_count", len(scoped)).Msg("active tab not in scoped list") | |
| 218 | - | } | |
| 225 | + | tabs := target.Tabs.Tabs | |
| 226 | + | ||
| 227 | + | // Compute the next tab to activate BEFORE closing | |
| 228 | + | activeIdx := indexOfTab(tabs, activeID) | |
| 219 | 229 | ||
| 220 | - | var nextScopedID entity.TabID | |
| 221 | - | if activeIdx >= 0 && len(scoped) > 1 { | |
| 222 | - | if activeIdx < len(scoped)-1 { | |
| 223 | - | nextScopedID = scoped[activeIdx+1].ID | |
| 230 | + | var nextID entity.TabID | |
| 231 | + | if activeIdx >= 0 && len(tabs) > 1 { | |
| 232 | + | if activeIdx < len(tabs)-1 { | |
| 233 | + | nextID = tabs[activeIdx+1].ID | |
| 224 | 234 | } else { | |
| 225 | - | nextScopedID = scoped[activeIdx-1].ID | |
| 235 | + | 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 | 240 | if err != nil { | |
| 231 | 241 | log.Error().Err(err).Msg("failed to close tab") | |
| 232 | 242 | return err | |
| 233 | 243 | } | |
| 234 | 244 | ||
| 235 | - | if c.mainWindow != nil && c.mainWindow.TabBar() != nil { | |
| 236 | - | c.mainWindow.TabBar().RemoveTab(activeID) | |
| 245 | + | if target.MainWindow != nil && target.MainWindow.TabBar() != nil { | |
| 246 | + | target.MainWindow.TabBar().RemoveTab(activeID) | |
| 237 | 247 | } | |
| 238 | 248 | ||
| 239 | 249 | // Update tab bar visibility | |
| 240 | - | c.UpdateBarVisibility(ctx) | |
| 250 | + | c.UpdateBarVisibility(ctx, target) | |
| 241 | 251 | ||
| 242 | - | // Quit if no tabs left globally | |
| 252 | + | // Target TabList became empty — signal window-empty. | |
| 253 | + | // App decides whether to quit (all windows empty) or only close this window. | |
| 243 | 254 | if wasLast { | |
| 244 | - | if c.onQuit != nil { | |
| 245 | - | c.onQuit() | |
| 255 | + | c.notifyStateChanged() | |
| 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 | 260 | return nil | |
| 249 | 261 | } | |
| 250 | 262 | ||
| 251 | 263 | // If there's a sibling tab in the same window, switch to it | |
| 252 | - | if nextScopedID != "" { | |
| 253 | - | if err := c.tabsUC.Switch(ctx, c.tabs, nextScopedID); err != nil { | |
| 254 | - | log.Error().Err(err).Str("tab_id", string(nextScopedID)).Msg("failed to switch to scoped sibling") | |
| 264 | + | if nextID != "" { | |
| 265 | + | if err := c.tabsUC.Switch(ctx, target.Tabs, nextID); err != nil { | |
| 266 | + | log.Error().Err(err).Str("tab_id", string(nextID)).Msg("failed to switch to sibling") | |
| 255 | 267 | return err | |
| 256 | 268 | } | |
| 257 | 269 | ||
| 258 | - | if c.mainWindow != nil && c.mainWindow.TabBar() != nil { | |
| 259 | - | c.mainWindow.TabBar().SetActive(nextScopedID) | |
| 270 | + | if target.MainWindow != nil && target.MainWindow.TabBar() != nil { | |
| 271 | + | target.MainWindow.TabBar().SetActive(nextID) | |
| 260 | 272 | } | |
| 261 | 273 | ||
| 262 | 274 | if c.onTabSwitched != nil { | |
| 263 | - | if tab := c.tabs.Find(nextScopedID); tab != nil { | |
| 264 | - | c.onTabSwitched(ctx, tab) | |
| 275 | + | if tab := target.Tabs.Find(nextID); tab != nil { | |
| 276 | + | c.onTabSwitched(ctx, target, tab) | |
| 265 | 277 | } | |
| 266 | 278 | } | |
| 267 | 279 | ||
| 268 | 280 | // Notify state change for session snapshots | |
| 269 | 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 | 284 | return nil | |
| 273 | 285 | } | |
| 274 | 286 | ||
| 275 | - | // Last tab in this window but not globally last — close the window | |
| 287 | + | 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 | 294 | c.notifyStateChanged() | |
| 277 | - | if c.onCurrentWindowEmpty != nil { | |
| 278 | - | c.onCurrentWindowEmpty(ctx, c.mainWindow) | |
| 295 | + | if len(target.Tabs.Tabs) == 0 { | |
| 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 | - | ||
| 281 | - | log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, last in window") | |
| 282 | - | return nil | |
| 302 | + | for _, tab := range target.Tabs.Tabs { | |
| 303 | + | if tab != nil { | |
| 304 | + | 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. | |
| 286 | - | func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error { | |
| 310 | + | // Switch switches to a specific tab by ID in the given target. | |
| 311 | + | func (c *TabCoordinator) Switch(ctx context.Context, target TabTarget, tabID entity.TabID) error { | |
| 287 | 312 | log := logging.FromContext(ctx) | |
| 288 | 313 | ||
| 314 | + | if target.Tabs == nil { | |
| 315 | + | return fmt.Errorf("target.Tabs is nil") | |
| 316 | + | } | |
| 317 | + | ||
| 289 | 318 | // Skip if already active | |
| 290 | - | if tabID == c.tabs.ActiveTabID { | |
| 319 | + | if tabID == target.Tabs.ActiveTabID { | |
| 291 | 320 | return nil | |
| 292 | 321 | } | |
| 293 | 322 | ||
| 294 | - | // Reject switching to a tab outside the current window scope | |
| 295 | - | if tab := c.tabs.Find(tabID); tab != nil && c.tabScope != nil && !c.tabScope(tab, c.mainWindow) { | |
| 296 | - | log.Debug(). | |
| 297 | - | Str("tab_id", string(tabID)). | |
| 298 | - | Msg("ignoring switch to tab outside current window scope") | |
| 323 | + | // Verify the tab exists in this target | |
| 324 | + | tab := target.Tabs.Find(tabID) | |
| 325 | + | if tab == nil { | |
| 326 | + | log.Debug().Str("tab_id", string(tabID)).Msg("tab not found in target") | |
| 299 | 327 | return nil | |
| 300 | 328 | } | |
| 301 | 329 | ||
| 302 | 330 | log.Debug(). | |
| 303 | - | Str("from", string(c.tabs.ActiveTabID)). | |
| 331 | + | Str("from", string(target.Tabs.ActiveTabID)). | |
| 304 | 332 | Str("to", string(tabID)). | |
| 305 | 333 | Msg("switching tab") | |
| 306 | 334 | ||
| 307 | 335 | // 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 | 337 | log.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab") | |
| 310 | 338 | return err | |
| 311 | 339 | } | |
| 312 | 340 | ||
| 313 | 341 | // Update tab bar UI | |
| 314 | - | if c.mainWindow != nil && c.mainWindow.TabBar() != nil { | |
| 315 | - | c.mainWindow.TabBar().SetActive(tabID) | |
| 342 | + | if target.MainWindow != nil && target.MainWindow.TabBar() != nil { | |
| 343 | + | target.MainWindow.TabBar().SetActive(tabID) | |
| 316 | 344 | } | |
| 317 | 345 | ||
| 318 | 346 | // Invoke callback for workspace view switching | |
| 319 | 347 | if c.onTabSwitched != nil { | |
| 320 | - | if tab := c.tabs.Find(tabID); tab != nil { | |
| 321 | - | c.onTabSwitched(ctx, tab) | |
| 322 | - | } | |
| 348 | + | c.onTabSwitched(ctx, target, tab) | |
| 323 | 349 | } | |
| 324 | 350 | ||
| 351 | + | c.notifyStateChanged() | |
| 352 | + | ||
| 325 | 353 | return nil | |
| 326 | 354 | } | |
| 327 | 355 | ||
| 328 | - | // switchRelative switches to the tab delta positions away within the current window scope. | |
| 329 | - | // delta of +1 switches next, -1 switches previous, wrapping within the scoped list. | |
| 330 | - | func (c *TabCoordinator) switchRelative(ctx context.Context, delta int) error { | |
| 331 | - | scoped := c.scopedTabs() | |
| 332 | - | if len(scoped) <= 1 { | |
| 356 | + | // switchRelative switches to the tab delta positions away within the target. | |
| 357 | + | // delta of +1 switches next, -1 switches previous, wrapping within the list. | |
| 358 | + | func (c *TabCoordinator) switchRelative(ctx context.Context, target TabTarget, delta int) error { | |
| 359 | + | if target.Tabs == nil { | |
| 360 | + | return fmt.Errorf("target.Tabs is nil") | |
| 361 | + | } | |
| 362 | + | tabs := target.Tabs.Tabs | |
| 363 | + | if len(tabs) <= 1 { | |
| 333 | 364 | return nil | |
| 334 | 365 | } | |
| 335 | 366 | ||
| 336 | - | current := indexOfTab(scoped, c.tabs.ActiveTabID) | |
| 367 | + | current := indexOfTab(tabs, target.Tabs.ActiveTabID) | |
| 337 | 368 | if current < 0 { | |
| 338 | 369 | current = 0 | |
| 339 | 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. | |
| 347 | - | func (c *TabCoordinator) SwitchNext(ctx context.Context) error { | |
| 348 | - | return c.switchRelative(ctx, 1) | |
| 377 | + | // SwitchNext switches to the next tab within the given target. | |
| 378 | + | func (c *TabCoordinator) SwitchNext(ctx context.Context, target TabTarget) error { | |
| 379 | + | return c.switchRelative(ctx, target, 1) | |
| 349 | 380 | } | |
| 350 | 381 | ||
| 351 | - | // SwitchPrev switches to the previous tab within the current window scope. | |
| 352 | - | func (c *TabCoordinator) SwitchPrev(ctx context.Context) error { | |
| 353 | - | return c.switchRelative(ctx, -1) | |
| 382 | + | // SwitchPrev switches to the previous tab within the given target. | |
| 383 | + | func (c *TabCoordinator) SwitchPrev(ctx context.Context, target TabTarget) error { | |
| 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. | |
| 357 | - | func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error { | |
| 387 | + | // SwitchByIndexCreating switches to a tab by 0-based index within the given target, | |
| 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 | 392 | log := logging.FromContext(ctx) | |
| 359 | 393 | ||
| 360 | - | scoped := c.scopedTabs() | |
| 361 | - | if index < 0 || index >= len(scoped) { | |
| 362 | - | log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("invalid scoped tab index") | |
| 363 | - | return nil | |
| 394 | + | if target.Tabs == nil { | |
| 395 | + | return fmt.Errorf("target.Tabs is 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 | 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 | 399 | return nil | |
| 377 | 400 | } | |
| 378 | - | ||
| 379 | - | if c.tabs == nil { | |
| 380 | - | return fmt.Errorf("tab list is required") | |
| 401 | + | if target.Tabs.Count() <= index && initialURL == "" { | |
| 402 | + | return fmt.Errorf("initial URL is required to create missing tabs") | |
| 381 | 403 | } | |
| 382 | - | ||
| 383 | - | // Create tabs within the current window scope until we have enough | |
| 384 | - | scoped := c.scopedTabs() | |
| 385 | - | if len(scoped) <= index && initialURL == "" { | |
| 386 | - | log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("cannot auto-create missing tabs without initial URL") | |
| 387 | - | return fmt.Errorf("initial URL is required to auto-create missing tabs") | |
| 404 | + | // Intermediate tabs are created inactive (create(..., false)) and the final | |
| 405 | + | // missing tab is created active (create(..., true)) before SwitchByIndex. | |
| 406 | + | for target.Tabs.Count() < index { | |
| 407 | + | if _, err := c.create(ctx, target, initialURL, false); err != nil { | |
| 408 | + | return err | |
| 409 | + | } | |
| 388 | 410 | } | |
| 389 | - | ||
| 390 | - | for len(c.scopedTabs()) <= index { | |
| 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") | |
| 411 | + | if target.Tabs.Count() <= index { | |
| 412 | + | if _, err := c.create(ctx, target, initialURL, true); err != nil { | |
| 393 | 413 | return err | |
| 394 | 414 | } | |
| 395 | 415 | } | |
| 396 | - | ||
| 397 | - | return c.SwitchByIndex(ctx, index) | |
| 416 | + | return c.SwitchByIndex(ctx, target, index) | |
| 398 | 417 | } | |
| 399 | 418 | ||
| 400 | - | // SwitchToLastActive switches to the previously active tab (Alt+Tab style). | |
| 401 | - | // When window-scoped (tabScope is set and previousActiveTabID provider is available), | |
| 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 { | |
| 419 | + | // SwitchByIndex switches to a tab by 0-based index within the given target. | |
| 420 | + | func (c *TabCoordinator) SwitchByIndex(ctx context.Context, target TabTarget, index int) error { | |
| 405 | 421 | log := logging.FromContext(ctx) | |
| 406 | 422 | ||
| 407 | - | var prevID entity.TabID | |
| 408 | - | if c.tabScope != nil && c.previousActiveTabID != nil { | |
| 409 | - | // Use per-window previous active tab ID when scoped | |
| 410 | - | prevID = c.previousActiveTabID(c.mainWindow) | |
| 411 | - | } else { | |
| 412 | - | // Fall back to global PreviousActiveTabID when not scoped or no provider | |
| 413 | - | prevID = c.tabs.PreviousActiveTabID | |
| 423 | + | if target.Tabs == nil { | |
| 424 | + | return fmt.Errorf("target.Tabs is nil") | |
| 425 | + | } | |
| 426 | + | tabs := target.Tabs.Tabs | |
| 427 | + | if index < 0 || index >= len(tabs) { | |
| 428 | + | log.Debug().Int("index", index).Int("count", len(tabs)).Msg("invalid tab index") | |
| 429 | + | 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 | 439 | return nil | |
| 418 | 440 | } | |
| 419 | 441 | ||
| 420 | - | tab := c.tabs.Find(prevID) | |
| 421 | - | if tab == nil { | |
| 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 | - | } | |
| 442 | + | prevID := target.Tabs.PreviousActiveTabID | |
| 443 | + | if prevID == "" || prevID == target.Tabs.ActiveTabID { | |
| 426 | 444 | return nil | |
| 427 | 445 | } | |
| 428 | 446 | ||
| 429 | - | // With a scope function, only allow switching to the previous tab if it's in scope. | |
| 430 | - | if c.tabScope != nil && !c.tabScope(tab, c.mainWindow) { | |
| 431 | - | log.Debug().Str("tab_id", string(prevID)).Msg("previous active tab outside current window scope") | |
| 447 | + | tab := target.Tabs.Find(prevID) | |
| 448 | + | if tab == nil { | |
| 449 | + | target.Tabs.PreviousActiveTabID = "" | |
| 432 | 450 | 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. | |
| 439 | - | func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) { | |
| 456 | + | // UpdateBarVisibility shows or hides the tab bar based on tab count in the target. | |
| 457 | + | func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context, target TabTarget) { | |
| 440 | 458 | log := logging.FromContext(ctx) | |
| 441 | 459 | ||
| 442 | - | // Check if feature is enabled | |
| 443 | - | hideEnabled := c.hideTabBarWhenSingleTab | |
| 444 | - | ||
| 445 | - | if !hideEnabled { | |
| 446 | - | log.Debug().Msg("tab bar auto-hide disabled by config") | |
| 460 | + | if target.MainWindow == nil || target.MainWindow.TabBar() == nil { | |
| 461 | + | log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update") | |
| 447 | 462 | return | |
| 448 | 463 | } | |
| 449 | 464 | ||
| 450 | - | if c.mainWindow == nil || c.mainWindow.TabBar() == nil { | |
| 451 | - | log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update") | |
| 465 | + | // Check if feature is enabled | |
| 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 | 470 | return | |
| 453 | 471 | } | |
| 454 | 472 | ||
| 455 | - | tabCount := c.mainWindow.TabBar().Count() | |
| 473 | + | tabCount := target.MainWindow.TabBar().Count() | |
| 456 | 474 | shouldShow := tabCount > 1 | |
| 457 | 475 | ||
| 458 | 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 | 482 | func (c *TabCoordinator) GetTabBar() *component.TabBar { | |
| 464 | 483 | if c.mainWindow != nil { | |
| 465 | 484 | return c.mainWindow.TabBar() | |
| @@ -467,23 +486,23 @@ func (c *TabCoordinator) GetTabBar() *component.TabBar { | |||
| 467 | 486 | return nil | |
| 468 | 487 | } | |
| 469 | 488 | ||
| 470 | - | // SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs. | |
| 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. | |
| 489 | + | // CreateWithPane creates a new tab with a pre-created pane and WebView in the given target. | |
| 476 | 490 | // This is used for tabbed popup behavior where the popup pane already exists. | |
| 477 | 491 | func (c *TabCoordinator) CreateWithPane( | |
| 478 | 492 | ctx context.Context, | |
| 493 | + | target TabTarget, | |
| 479 | 494 | pane *entity.Pane, | |
| 480 | 495 | wv port.WebView, | |
| 481 | 496 | initialURL string, | |
| 482 | 497 | ) (*entity.Tab, error) { | |
| 483 | 498 | log := logging.FromContext(ctx) | |
| 484 | 499 | ||
| 500 | + | if target.Tabs == nil { | |
| 501 | + | return nil, fmt.Errorf("target.Tabs is nil") | |
| 502 | + | } | |
| 503 | + | ||
| 485 | 504 | output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{ | |
| 486 | - | TabList: c.tabs, | |
| 505 | + | TabList: target.Tabs, | |
| 487 | 506 | Name: pane.Title, | |
| 488 | 507 | Pane: pane, | |
| 489 | 508 | InitialURL: initialURL, | |
| @@ -494,23 +513,21 @@ func (c *TabCoordinator) CreateWithPane( | |||
| 494 | 513 | } | |
| 495 | 514 | ||
| 496 | 515 | // Set new tab as active | |
| 497 | - | c.tabs.SetActive(output.Tab.ID) | |
| 516 | + | target.Tabs.SetActive(output.Tab.ID) | |
| 498 | 517 | ||
| 499 | 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 | 519 | if c.onTabCreated != nil { | |
| 503 | - | c.onTabCreated(ctx, output.Tab) | |
| 520 | + | c.onTabCreated(ctx, target, output.Tab) | |
| 504 | 521 | } | |
| 505 | 522 | ||
| 506 | 523 | // Update tab bar | |
| 507 | - | if c.mainWindow != nil && c.mainWindow.TabBar() != nil { | |
| 508 | - | c.mainWindow.TabBar().AddTab(output.Tab) | |
| 509 | - | c.mainWindow.TabBar().SetActive(output.Tab.ID) | |
| 524 | + | if target.MainWindow != nil && target.MainWindow.TabBar() != nil { | |
| 525 | + | target.MainWindow.TabBar().AddTab(output.Tab) | |
| 526 | + | target.MainWindow.TabBar().SetActive(output.Tab.ID) | |
| 510 | 527 | } | |
| 511 | 528 | ||
| 512 | 529 | // Update tab bar visibility | |
| 513 | - | c.UpdateBarVisibility(ctx) | |
| 530 | + | c.UpdateBarVisibility(ctx, target) | |
| 514 | 531 | ||
| 515 | 532 | // Attach the popup WebView to the new tab's workspace | |
| 516 | 533 | if c.onAttachPopupToTab != nil { | |
| @@ -519,7 +536,7 @@ func (c *TabCoordinator) CreateWithPane( | |||
| 519 | 536 | ||
| 520 | 537 | // Switch to the new tab's workspace view | |
| 521 | 538 | if c.onTabSwitched != nil { | |
| 522 | - | c.onTabSwitched(ctx, output.Tab) | |
| 539 | + | c.onTabSwitched(ctx, target, output.Tab) | |
| 523 | 540 | } | |
| 524 | 541 | ||
| 525 | 542 | // 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 | 77 | SessionRepo repository.SessionRepository | |
| 78 | 78 | CurrentSessionID entity.SessionID | |
| 79 | 79 | 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 | 81 | // Called after the App is initialized so the App can serve as the provider. | |
| 82 | - | SnapshotServiceFactory func(provider port.TabListProvider, intervalMs int) port.SnapshotService | |
| 82 | + | SnapshotServiceFactory func(provider port.WindowStateProvider, intervalMs int) port.SnapshotService | |
| 83 | 83 | // SessionSpawner spawns a new dumber instance for session restoration. | |
| 84 | 84 | SessionSpawner port.SessionSpawner | |
| 85 | 85 | // FileSystem provides file operations (e.g., for download deduplication). | |
M internal/ui/dispatcher/keyboard.go +96 -27
| @@ -4,9 +4,9 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | - | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 10 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 11 | "github.com/bnema/dumber/internal/ui/component" | |
| 12 | 12 | "github.com/bnema/dumber/internal/ui/coordinator" | |
| @@ -20,14 +20,23 @@ const ( | |||
| 20 | 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 | 33 | // KeyboardDispatcher routes keyboard actions to appropriate coordinators. | |
| 24 | 34 | type KeyboardDispatcher struct { | |
| 25 | - | tabCoord *coordinator.TabCoordinator | |
| 35 | + | actions KeyboardActions | |
| 26 | 36 | wsCoord *coordinator.WorkspaceCoordinator | |
| 27 | 37 | navCoord *coordinator.NavigationCoordinator | |
| 28 | 38 | zoomUC *usecase.ManageZoomUseCase | |
| 29 | 39 | copyURLUC *usecase.CopyURLUseCase | |
| 30 | - | newPaneURL string | |
| 31 | 40 | actionHandlers map[input.Action]func(ctx context.Context) error | |
| 32 | 41 | onQuit func() | |
| 33 | 42 | onFindOpen func(ctx context.Context) error | |
| @@ -45,24 +54,22 @@ type KeyboardDispatcher struct { | |||
| 45 | 54 | // NewKeyboardDispatcher creates a new KeyboardDispatcher. | |
| 46 | 55 | func NewKeyboardDispatcher( | |
| 47 | 56 | ctx context.Context, | |
| 48 | - | tabCoord *coordinator.TabCoordinator, | |
| 49 | 57 | wsCoord *coordinator.WorkspaceCoordinator, | |
| 50 | 58 | navCoord *coordinator.NavigationCoordinator, | |
| 51 | 59 | zoomUC *usecase.ManageZoomUseCase, | |
| 52 | 60 | copyURLUC *usecase.CopyURLUseCase, | |
| 53 | - | newPaneURL string, | |
| 61 | + | actions KeyboardActions, | |
| 54 | 62 | activePaneID func(context.Context) entity.PaneID, | |
| 55 | 63 | ) *KeyboardDispatcher { | |
| 56 | 64 | log := logging.FromContext(ctx) | |
| 57 | 65 | log.Debug().Msg("creating keyboard dispatcher") | |
| 58 | 66 | ||
| 59 | 67 | dispatcher := &KeyboardDispatcher{ | |
| 60 | - | tabCoord: tabCoord, | |
| 68 | + | actions: actions, | |
| 61 | 69 | wsCoord: wsCoord, | |
| 62 | 70 | navCoord: navCoord, | |
| 63 | 71 | zoomUC: zoomUC, | |
| 64 | 72 | copyURLUC: copyURLUC, | |
| 65 | - | newPaneURL: newPaneURL, | |
| 66 | 73 | activePaneID: activePaneID, | |
| 67 | 74 | } | |
| 68 | 75 | dispatcher.initActionHandlers() | |
| @@ -140,18 +147,15 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 140 | 147 | ) | |
| 141 | 148 | d.actionHandlers = map[input.Action]func(ctx context.Context) error{ | |
| 142 | 149 | // Tab actions | |
| 143 | - | input.ActionNewTab: func(ctx context.Context) error { | |
| 144 | - | if d.newPaneURL == "" { | |
| 145 | - | logging.FromContext(ctx).Warn().Msg("ActionNewTab: newPaneURL is empty, cannot open new tab") | |
| 146 | - | return fmt.Errorf("newPaneURL is not configured") | |
| 147 | - | } | |
| 148 | - | _, err := d.tabCoord.Create(ctx, domainurl.Normalize(d.newPaneURL)) | |
| 149 | - | return err | |
| 150 | + | input.ActionNewTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "new tab", d.actions.NewTab) }, | |
| 151 | + | input.ActionCloseTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "close tab", d.actions.CloseTab) }, | |
| 152 | + | input.ActionNextTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "next tab", d.actions.NextTab) }, | |
| 153 | + | input.ActionPreviousTab: func(ctx context.Context) error { | |
| 154 | + | return d.handleKeyboardAction(ctx, "previous tab", d.actions.PreviousTab) | |
| 155 | + | }, | |
| 156 | + | 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 | 159 | input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, firstTabIndex) }, | |
| 156 | 160 | input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, secondTabIndex) }, | |
| 157 | 161 | input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, thirdTabIndex) }, | |
| @@ -209,11 +213,11 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 209 | 213 | input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") }, | |
| 210 | 214 | input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") }, | |
| 211 | 215 | // Navigation | |
| 212 | - | input.ActionGoBack: d.navCoord.GoBack, | |
| 213 | - | input.ActionGoForward: d.navCoord.GoForward, | |
| 214 | - | input.ActionReload: d.navCoord.Reload, | |
| 215 | - | input.ActionHardReload: d.navCoord.HardReload, | |
| 216 | - | input.ActionPrintPage: d.navCoord.PrintPage, | |
| 216 | + | input.ActionGoBack: d.handleGoBack, | |
| 217 | + | input.ActionGoForward: d.handleGoForward, | |
| 218 | + | input.ActionReload: d.handleReload, | |
| 219 | + | input.ActionHardReload: d.handleHardReload, | |
| 220 | + | input.ActionPrintPage: d.handlePrintPage, | |
| 217 | 221 | // Zoom actions | |
| 218 | 222 | input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") }, | |
| 219 | 223 | input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") }, | |
| @@ -224,7 +228,7 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 224 | 228 | input.ActionFindNext: d.handleFindNext, | |
| 225 | 229 | input.ActionFindPrev: d.handleFindPrev, | |
| 226 | 230 | input.ActionCloseFind: d.handleFindClose, | |
| 227 | - | input.ActionOpenDevTools: d.navCoord.OpenDevTools, | |
| 231 | + | input.ActionOpenDevTools: d.handleOpenDevTools, | |
| 228 | 232 | input.ActionToggleFloatingPane: func(ctx context.Context) error { | |
| 229 | 233 | if d.onToggleFloating != nil { | |
| 230 | 234 | return d.onToggleFloating(ctx) | |
| @@ -330,7 +334,27 @@ func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error | |||
| 330 | 334 | } | |
| 331 | 335 | ||
| 332 | 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 | 360 | func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error { | |
| @@ -351,6 +375,51 @@ func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error { | |||
| 351 | 375 | 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 | 423 | // handleZoom processes zoom in/out/reset actions for the active WebView. | |
| 355 | 424 | func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error { | |
| 356 | 425 | log := logging.FromContext(ctx) | |
| @@ -360,7 +429,7 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro | |||
| 360 | 429 | return nil | |
| 361 | 430 | } | |
| 362 | 431 | ||
| 363 | - | wv := d.navCoord.ActiveWebView(ctx) | |
| 432 | + | wv := d.activeWebView(ctx) | |
| 364 | 433 | if wv == nil { | |
| 365 | 434 | log.Debug().Msg("no active webview for zoom") | |
| 366 | 435 | return nil | |
| @@ -424,7 +493,7 @@ func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error { | |||
| 424 | 493 | return nil | |
| 425 | 494 | } | |
| 426 | 495 | ||
| 427 | - | wv := d.navCoord.ActiveWebView(ctx) | |
| 496 | + | wv := d.activeWebView(ctx) | |
| 428 | 497 | if wv == nil { | |
| 429 | 498 | log.Debug().Msg("no active webview for copy URL") | |
| 430 | 499 | return nil | |
M internal/ui/dispatcher/keyboard_test.go +50 -101
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | - | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 10 | 9 | "github.com/bnema/dumber/internal/ui/component" | |
| 11 | 10 | "github.com/bnema/dumber/internal/ui/coordinator" | |
| 12 | 11 | "github.com/bnema/dumber/internal/ui/input" | |
| @@ -14,105 +13,55 @@ import ( | |||
| 14 | 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 | 17 | ctx := context.Background() | |
| 19 | - | ids := []string{ | |
| 20 | - | "tab-1", "ws-1", "pane-1", | |
| 21 | - | "tab-2", "ws-2", "pane-2", | |
| 22 | - | "tab-3", "ws-3", "pane-3", | |
| 23 | - | } | |
| 24 | - | idx := 0 | |
| 25 | - | tabsUC := usecase.NewManageTabsUseCase(func() string { | |
| 26 | - | id := ids[idx] | |
| 27 | - | idx++ | |
| 28 | - | return id | |
| 29 | - | }) | |
| 30 | - | tabs := entity.NewTabList() | |
| 31 | - | tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{ | |
| 32 | - | TabsUC: tabsUC, | |
| 33 | - | Tabs: tabs, | |
| 34 | - | }) | |
| 35 | - | ||
| 36 | - | _, err := tabCoord.Create(ctx, "https://initial.example") | |
| 37 | - | require.NoError(t, err) | |
| 38 | - | ||
| 39 | - | d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example", func(context.Context) entity.PaneID { return "" }) | |
| 40 | - | ||
| 41 | - | err = d.Dispatch(ctx, input.ActionSwitchTabIndex3) | |
| 42 | - | require.NoError(t, err) | |
| 43 | - | ||
| 44 | - | require.Len(t, tabs.Tabs, 3) | |
| 45 | - | require.NotNil(t, tabs.ActiveTab()) | |
| 46 | - | assert.Equal(t, tabs.TabAt(2).ID, tabs.ActiveTabID) | |
| 47 | - | assert.Equal(t, tabs.TabAt(0).ID, tabs.PreviousActiveTabID) | |
| 48 | - | assert.Equal(t, 2, tabs.ActiveTab().Position) | |
| 49 | - | assert.Equal(t, domainurl.Normalize("https://new.example"), tabs.TabAt(1).Workspace.Root.Pane.URI) | |
| 50 | - | assert.Equal(t, domainurl.Normalize("https://new.example"), tabs.TabAt(2).Workspace.Root.Pane.URI) | |
| 51 | - | ||
| 52 | - | err = tabCoord.SwitchToLastActive(ctx) | |
| 53 | - | require.NoError(t, err) | |
| 54 | - | assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID) | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | func TestKeyboardDispatcher_AltNumberSwitchesToExistingTabWithoutNewPaneURL(t *testing.T) { | |
| 58 | - | ctx := context.Background() | |
| 59 | - | ids := []string{ | |
| 60 | - | "tab-1", "ws-1", "pane-1", | |
| 61 | - | "tab-2", "ws-2", "pane-2", | |
| 62 | - | } | |
| 63 | - | idx := 0 | |
| 64 | - | tabsUC := usecase.NewManageTabsUseCase(func() string { | |
| 65 | - | id := ids[idx] | |
| 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) | |
| 18 | + | called := make([]string, 0) | |
| 19 | + | var switchedIndex int | |
| 20 | + | d := NewKeyboardDispatcher( | |
| 21 | + | ctx, | |
| 22 | + | &coordinator.WorkspaceCoordinator{}, | |
| 23 | + | &coordinator.NavigationCoordinator{}, | |
| 24 | + | nil, | |
| 25 | + | nil, | |
| 26 | + | KeyboardActions{ | |
| 27 | + | NewTab: func(context.Context) error { | |
| 28 | + | called = append(called, "new") | |
| 29 | + | return nil | |
| 30 | + | }, | |
| 31 | + | CloseTab: func(context.Context) error { | |
| 32 | + | called = append(called, "close") | |
| 33 | + | return nil | |
| 34 | + | }, | |
| 35 | + | NextTab: func(context.Context) error { | |
| 36 | + | called = append(called, "next") | |
| 37 | + | return nil | |
| 38 | + | }, | |
| 39 | + | PreviousTab: func(context.Context) error { | |
| 40 | + | called = append(called, "previous") | |
| 41 | + | return nil | |
| 42 | + | }, | |
| 43 | + | SwitchLastTab: func(context.Context) error { | |
| 44 | + | called = append(called, "last") | |
| 45 | + | return nil | |
| 46 | + | }, | |
| 47 | + | SwitchTabIndex: func(_ context.Context, index int) error { | |
| 48 | + | called = append(called, "index") | |
| 49 | + | switchedIndex = index | |
| 50 | + | return nil | |
| 51 | + | }, | |
| 52 | + | }, | |
| 53 | + | func(context.Context) entity.PaneID { return "" }, | |
| 54 | + | ) | |
| 55 | + | ||
| 56 | + | require.NoError(t, d.Dispatch(ctx, input.ActionNewTab)) | |
| 57 | + | require.NoError(t, d.Dispatch(ctx, input.ActionCloseTab)) | |
| 58 | + | require.NoError(t, d.Dispatch(ctx, input.ActionNextTab)) | |
| 59 | + | require.NoError(t, d.Dispatch(ctx, input.ActionPreviousTab)) | |
| 60 | + | require.NoError(t, d.Dispatch(ctx, input.ActionSwitchLastTab)) | |
| 61 | + | require.NoError(t, d.Dispatch(ctx, input.ActionSwitchTabIndex5)) | |
| 62 | + | ||
| 63 | + | assert.Equal(t, []string{"new", "close", "next", "previous", "last", "index"}, called) | |
| 64 | + | assert.Equal(t, 4, switchedIndex) | |
| 116 | 65 | } | |
| 117 | 66 | ||
| 118 | 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 | 89 | err := d.Dispatch(ctx, input.ActionToggleHistorySystemView) | |
| 141 | 90 | require.NoError(t, err) | |
| @@ -151,7 +100,7 @@ func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T) | |||
| 151 | 100 | func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) { | |
| 152 | 101 | ctx := context.Background() | |
| 153 | 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 | 104 | 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 | 365 | tabID := entity.TabID("tab-1") | |
| 366 | 366 | workspace := &entity.Workspace{ActivePaneID: entity.PaneID("pane-one")} | |
| 367 | 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 | 373 | session := newFloatingPaneSession(tabID, floatingSessionIDDefault) | |
| 371 | 374 | ||
| 372 | 375 | app := &App{ | |
| 373 | - | tabs: tabs, | |
| 374 | - | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | |
| 376 | + | tabs: tabs, | |
| 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 | 381 | floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{ | |
| 376 | 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 | 201 | Msg("stale global shortcut callback ignored") | |
| 202 | 202 | 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 | 210 | if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) { | |
| 205 | 211 | log.Trace(). | |
| 206 | 212 | Str("action", string(actionToDispatch)). | |
| @@ -388,6 +394,10 @@ func (h *GlobalShortcutHandler) isStaleGeneration(generation uint64) bool { | |||
| 388 | 394 | 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 | 401 | func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool { | |
| 392 | 402 | if h == nil || !isRepeatedGlobalShortcutSuppressed(action) { | |
| 393 | 403 | return false | |
| @@ -406,7 +416,24 @@ func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time | |||
| 406 | 416 | ||
| 407 | 417 | func isRepeatedGlobalShortcutSuppressed(action Action) bool { | |
| 408 | 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 | 437 | return true | |
| 411 | 438 | default: | |
| 412 | 439 | 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 | 24 | h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)} | |
| 25 | 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 | 76 | func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) { | |
| 36 | 77 | h := &GlobalShortcutHandler{ | |
| 37 | 78 | registered: make(map[KeyBinding]Action), | |
| @@ -59,3 +100,38 @@ func TestGlobalShortcutHandlerGenerationMarksOldCallbacksStale(t *testing.T) { | |||
| 59 | 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 | 144 | // Uses em units for scalable UI. | |
| 145 | 145 | func generateTabBarCSS(p Palette) string { | |
| 146 | 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 | 153 | .tab-bar { | |
| 148 | 154 | background-color: var(--surface); | |
| 149 | 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 | 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 | 205 | func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) { | |
| 189 | 206 | css := GenerateCSS(DefaultDarkPalette()) | |
| 190 | 207 | ||
M internal/ui/window/main_window.go +42 -5
| @@ -104,13 +104,23 @@ func (mw *MainWindow) assembleLayout() { | |||
| 104 | 104 | 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 | 112 | if tabBarPos == "bottom" { | |
| 108 | - | mw.rootBox.Append(&mw.contentOverlay.Widget) | |
| 109 | - | mw.rootBox.Append(mw.tabBar.Widget()) | |
| 110 | - | } else { | |
| 111 | - | mw.rootBox.Append(mw.tabBar.Widget()) | |
| 112 | - | mw.rootBox.Append(&mw.contentOverlay.Widget) | |
| 113 | + | tabBarWidget.SetValign(gtk.AlignEndValue) | |
| 114 | + | // Inset is NOT applied unconditionally. See SetTabBarContentInsetVisible | |
| 115 | + | // which is called when the bottom tab bar is actually visible. | |
| 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 | 125 | mw.logger.Debug(). | |
| 116 | 126 | Str("tab_bar_position", tabBarPos). | |
| @@ -191,6 +201,33 @@ func (mw *MainWindow) ContentOverlay() *gtk.Overlay { | |||
| 191 | 201 | 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 | 231 | // AddOverlay adds a widget as an overlay above the content area. | |
| 195 | 232 | func (mw *MainWindow) AddOverlay(widget *gtk.Widget) { | |
| 196 | 233 | if mw.contentOverlay != nil && widget != nil { | |