Merge pull request #242 from bnema/fix/window-tab-navigation-isolation
Refactor browser tabs to be window-scoped
36 files · +5623 -923
M .github/workflows/aur.yml +3 -1
| @@ -81,7 +81,9 @@ jobs: | |||
| 81 | 81 | 'build() {' \ | |
| 82 | 82 | ' cd dumber' \ | |
| 83 | 83 | ' export CGO_ENABLED=0' \ | |
| 84 | - | ' export GOFLAGS="-buildmode=pie -trimpath -mod=readonly -modcacherw"' \ | |
| 84 | + | ' # Keep global GOFLAGS wasm-safe; apply PIE only to native Go builds.' \ | |
| 85 | + | ' export GOFLAGS="-trimpath -mod=readonly -modcacherw"' \ | |
| 86 | + | ' export NATIVE_GOFLAGS="${GOFLAGS} -buildmode=pie"' \ | |
| 85 | 87 | ' make build' \ | |
| 86 | 88 | '}' \ | |
| 87 | 89 | '' \ | |
M .mockery.yaml +1 -1
| @@ -15,7 +15,7 @@ packages: | |||
| 15 | 15 | DiffFormatter: {} | |
| 16 | 16 | SessionLogger: {} | |
| 17 | 17 | SessionSpawner: {} | |
| 18 | - | TabListProvider: {} | |
| 18 | + | WindowStateProvider: {} | |
| 19 | 19 | WebView: {} | |
| 20 | 20 | WebViewFactory: {} | |
| 21 | 21 | WebViewPool: {} | |
M Makefile +9 -3
| @@ -22,6 +22,12 @@ VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0 | |||
| 22 | 22 | COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") | |
| 23 | 23 | BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") | |
| 24 | 24 | ||
| 25 | + | # Go flags. NATIVE_GOFLAGS can add native-only hardening such as | |
| 26 | + | # -buildmode=pie without breaking js/wasm systemviews builds. | |
| 27 | + | GOFLAGS?=-mod=mod | |
| 28 | + | NATIVE_GOFLAGS?=$(GOFLAGS) | |
| 29 | + | WASM_GOFLAGS?=$(GOFLAGS) | |
| 30 | + | ||
| 25 | 31 | # Linker flags | |
| 26 | 32 | LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)" | |
| 27 | 33 | ||
| @@ -42,7 +48,7 @@ help: ## Show this help message | |||
| 42 | 48 | build: build-systemviews ## Build the application (pure Go, no CGO) | |
| 43 | 49 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | |
| 44 | 50 | @mkdir -p $(DIST_DIR) | |
| 45 | - | GOFLAGS=-mod=mod CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 51 | + | GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 46 | 52 | @rm -f $(DIST_DIR)/cef-helper | |
| 47 | 53 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 48 | 54 | ||
| @@ -56,14 +62,14 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime | |||
| 56 | 62 | @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; } | |
| 57 | 63 | @mkdir -p assets/systemviews | |
| 58 | 64 | @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js | |
| 59 | - | GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews | |
| 65 | + | GOFLAGS="$(WASM_GOFLAGS)" GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews | |
| 60 | 66 | brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm | |
| 61 | 67 | @echo "Systemviews build complete" | |
| 62 | 68 | ||
| 63 | 69 | build-quick: ## Build quickly for backend development | |
| 64 | 70 | @echo "Building $(BINARY_NAME) $(VERSION) (quick)..." | |
| 65 | 71 | @mkdir -p $(DIST_DIR) | |
| 66 | - | GOFLAGS=-mod=mod CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 72 | + | GOFLAGS="$(NATIVE_GOFLAGS)" CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 67 | 73 | @rm -f $(DIST_DIR)/cef-helper | |
| 68 | 74 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 69 | 75 | ||
M aur/dumber-browser-git/.SRCINFO +4 -3
| @@ -1,12 +1,13 @@ | |||
| 1 | 1 | pkgbase = dumber-browser-git | |
| 2 | 2 | pkgdesc = A minimal keyboard-driven browser for tiling WMs (git version) | |
| 3 | - | pkgver = 0.23.1.r0.g6c872d5 | |
| 4 | - | pkgrel = 1 | |
| 3 | + | pkgver = 0.28.0.r367.g6066b2a7 | |
| 4 | + | pkgrel = 2 | |
| 5 | 5 | url = https://github.com/bnema/dumber | |
| 6 | 6 | arch = x86_64 | |
| 7 | 7 | license = MIT | |
| 8 | - | makedepends = go>=1.25 | |
| 8 | + | makedepends = go>=1.26 | |
| 9 | 9 | makedepends = git | |
| 10 | + | makedepends = brotli | |
| 10 | 11 | depends = gtk4 | |
| 11 | 12 | depends = webkitgtk-6.0 | |
| 12 | 13 | optdepends = gst-plugins-base: Base media codecs | |
M aur/dumber-browser-git/PKGBUILD +5 -3
| @@ -1,7 +1,7 @@ | |||
| 1 | 1 | # Maintainer: bnema <b at bnema dot dev> | |
| 2 | 2 | pkgname=dumber-browser-git | |
| 3 | - | pkgver=0.23.1.r0.g6c872d5 | |
| 4 | - | pkgrel=1 | |
| 3 | + | pkgver=0.28.0.r367.g6066b2a7 | |
| 4 | + | pkgrel=2 | |
| 5 | 5 | pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)" | |
| 6 | 6 | arch=('x86_64') | |
| 7 | 7 | url="https://github.com/bnema/dumber" | |
| @@ -36,7 +36,9 @@ pkgver() { | |||
| 36 | 36 | build() { | |
| 37 | 37 | cd dumber | |
| 38 | 38 | export CGO_ENABLED=0 | |
| 39 | - | export GOFLAGS="-buildmode=pie -trimpath -mod=readonly -modcacherw" | |
| 39 | + | # Keep global GOFLAGS wasm-safe; apply PIE only to native Go builds. | |
| 40 | + | export GOFLAGS="-trimpath -mod=readonly -modcacherw" | |
| 41 | + | export NATIVE_GOFLAGS="${GOFLAGS} -buildmode=pie" | |
| 40 | 42 | make build | |
| 41 | 43 | } | |
| 42 | 44 | ||
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 +132 -33
| @@ -10675,13 +10675,13 @@ func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context | |||
| 10675 | 10675 | return _c | |
| 10676 | 10676 | } | |
| 10677 | 10677 | ||
| 10678 | - | // NewMockTabListProvider creates a new instance of MockTabListProvider. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | |
| 10678 | + | // 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. | |
| 10679 | 10679 | // The first argument is typically a *testing.T value. | |
| 10680 | - | func NewMockTabListProvider(t interface { | |
| 10680 | + | func NewMockWindowStateProvider(t interface { | |
| 10681 | 10681 | mock.TestingT | |
| 10682 | 10682 | Cleanup(func()) | |
| 10683 | - | }) *MockTabListProvider { | |
| 10684 | - | mock := &MockTabListProvider{} | |
| 10683 | + | }) *MockWindowStateProvider { | |
| 10684 | + | mock := &MockWindowStateProvider{} | |
| 10685 | 10685 | mock.Mock.Test(t) | |
| 10686 | 10686 | ||
| 10687 | 10687 | t.Cleanup(func() { mock.AssertExpectations(t) }) | |
| @@ -10689,21 +10689,65 @@ func NewMockTabListProvider(t interface { | |||
| 10689 | 10689 | return mock | |
| 10690 | 10690 | } | |
| 10691 | 10691 | ||
| 10692 | - | // MockTabListProvider is an autogenerated mock type for the TabListProvider type | |
| 10693 | - | type MockTabListProvider struct { | |
| 10692 | + | // MockWindowStateProvider is an autogenerated mock type for the WindowStateProvider type | |
| 10693 | + | type MockWindowStateProvider struct { | |
| 10694 | 10694 | mock.Mock | |
| 10695 | 10695 | } | |
| 10696 | 10696 | ||
| 10697 | - | type MockTabListProvider_Expecter struct { | |
| 10697 | + | type MockWindowStateProvider_Expecter struct { | |
| 10698 | 10698 | mock *mock.Mock | |
| 10699 | 10699 | } | |
| 10700 | 10700 | ||
| 10701 | - | func (_m *MockTabListProvider) EXPECT() *MockTabListProvider_Expecter { | |
| 10702 | - | return &MockTabListProvider_Expecter{mock: &_m.Mock} | |
| 10701 | + | func (_m *MockWindowStateProvider) EXPECT() *MockWindowStateProvider_Expecter { | |
| 10702 | + | return &MockWindowStateProvider_Expecter{mock: &_m.Mock} | |
| 10703 | 10703 | } | |
| 10704 | 10704 | ||
| 10705 | - | // GetSessionID provides a mock function for the type MockTabListProvider | |
| 10706 | - | func (_mock *MockTabListProvider) GetSessionID() entity.SessionID { | |
| 10705 | + | // GetActiveWindowIndex provides a mock function for the type MockWindowStateProvider | |
| 10706 | + | func (_mock *MockWindowStateProvider) GetActiveWindowIndex() int { | |
| 10707 | + | ret := _mock.Called() | |
| 10708 | + | ||
| 10709 | + | if len(ret) == 0 { | |
| 10710 | + | panic("no return value specified for GetActiveWindowIndex") | |
| 10711 | + | } | |
| 10712 | + | ||
| 10713 | + | var r0 int | |
| 10714 | + | if returnFunc, ok := ret.Get(0).(func() int); ok { | |
| 10715 | + | r0 = returnFunc() | |
| 10716 | + | } else { | |
| 10717 | + | r0 = ret.Get(0).(int) | |
| 10718 | + | } | |
| 10719 | + | return r0 | |
| 10720 | + | } | |
| 10721 | + | ||
| 10722 | + | // MockWindowStateProvider_GetActiveWindowIndex_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetActiveWindowIndex' | |
| 10723 | + | type MockWindowStateProvider_GetActiveWindowIndex_Call struct { | |
| 10724 | + | *mock.Call | |
| 10725 | + | } | |
| 10726 | + | ||
| 10727 | + | // GetActiveWindowIndex is a helper method to define mock.On call | |
| 10728 | + | func (_e *MockWindowStateProvider_Expecter) GetActiveWindowIndex() *MockWindowStateProvider_GetActiveWindowIndex_Call { | |
| 10729 | + | return &MockWindowStateProvider_GetActiveWindowIndex_Call{Call: _e.mock.On("GetActiveWindowIndex")} | |
| 10730 | + | } | |
| 10731 | + | ||
| 10732 | + | func (_c *MockWindowStateProvider_GetActiveWindowIndex_Call) Run(run func()) *MockWindowStateProvider_GetActiveWindowIndex_Call { | |
| 10733 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 10734 | + | run() | |
| 10735 | + | }) | |
| 10736 | + | return _c | |
| 10737 | + | } | |
| 10738 | + | ||
| 10739 | + | func (_c *MockWindowStateProvider_GetActiveWindowIndex_Call) Return(n int) *MockWindowStateProvider_GetActiveWindowIndex_Call { | |
| 10740 | + | _c.Call.Return(n) | |
| 10741 | + | return _c | |
| 10742 | + | } | |
| 10743 | + | ||
| 10744 | + | func (_c *MockWindowStateProvider_GetActiveWindowIndex_Call) RunAndReturn(run func() int) *MockWindowStateProvider_GetActiveWindowIndex_Call { | |
| 10745 | + | _c.Call.Return(run) | |
| 10746 | + | return _c | |
| 10747 | + | } | |
| 10748 | + | ||
| 10749 | + | // GetSessionID provides a mock function for the type MockWindowStateProvider | |
| 10750 | + | func (_mock *MockWindowStateProvider) GetSessionID() entity.SessionID { | |
| 10707 | 10751 | ret := _mock.Called() | |
| 10708 | 10752 | ||
| 10709 | 10753 | if len(ret) == 0 { | |
| @@ -10719,75 +10763,130 @@ func (_mock *MockTabListProvider) GetSessionID() entity.SessionID { | |||
| 10719 | 10763 | return r0 | |
| 10720 | 10764 | } | |
| 10721 | 10765 | ||
| 10722 | - | // MockTabListProvider_GetSessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSessionID' | |
| 10723 | - | type MockTabListProvider_GetSessionID_Call struct { | |
| 10766 | + | // MockWindowStateProvider_GetSessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSessionID' | |
| 10767 | + | type MockWindowStateProvider_GetSessionID_Call struct { | |
| 10724 | 10768 | *mock.Call | |
| 10725 | 10769 | } | |
| 10726 | 10770 | ||
| 10727 | 10771 | // GetSessionID is a helper method to define mock.On call | |
| 10728 | - | func (_e *MockTabListProvider_Expecter) GetSessionID() *MockTabListProvider_GetSessionID_Call { | |
| 10729 | - | return &MockTabListProvider_GetSessionID_Call{Call: _e.mock.On("GetSessionID")} | |
| 10772 | + | func (_e *MockWindowStateProvider_Expecter) GetSessionID() *MockWindowStateProvider_GetSessionID_Call { | |
| 10773 | + | return &MockWindowStateProvider_GetSessionID_Call{Call: _e.mock.On("GetSessionID")} | |
| 10730 | 10774 | } | |
| 10731 | 10775 | ||
| 10732 | - | func (_c *MockTabListProvider_GetSessionID_Call) Run(run func()) *MockTabListProvider_GetSessionID_Call { | |
| 10776 | + | func (_c *MockWindowStateProvider_GetSessionID_Call) Run(run func()) *MockWindowStateProvider_GetSessionID_Call { | |
| 10733 | 10777 | _c.Call.Run(func(args mock.Arguments) { | |
| 10734 | 10778 | run() | |
| 10735 | 10779 | }) | |
| 10736 | 10780 | return _c | |
| 10737 | 10781 | } | |
| 10738 | 10782 | ||
| 10739 | - | func (_c *MockTabListProvider_GetSessionID_Call) Return(sessionID entity.SessionID) *MockTabListProvider_GetSessionID_Call { | |
| 10783 | + | func (_c *MockWindowStateProvider_GetSessionID_Call) Return(sessionID entity.SessionID) *MockWindowStateProvider_GetSessionID_Call { | |
| 10740 | 10784 | _c.Call.Return(sessionID) | |
| 10741 | 10785 | return _c | |
| 10742 | 10786 | } | |
| 10743 | 10787 | ||
| 10744 | - | func (_c *MockTabListProvider_GetSessionID_Call) RunAndReturn(run func() entity.SessionID) *MockTabListProvider_GetSessionID_Call { | |
| 10788 | + | func (_c *MockWindowStateProvider_GetSessionID_Call) RunAndReturn(run func() entity.SessionID) *MockWindowStateProvider_GetSessionID_Call { | |
| 10789 | + | _c.Call.Return(run) | |
| 10790 | + | return _c | |
| 10791 | + | } | |
| 10792 | + | ||
| 10793 | + | // GetWindowSnapshotState provides a mock function for the type MockWindowStateProvider | |
| 10794 | + | func (_mock *MockWindowStateProvider) GetWindowSnapshotState() ([]entity.WindowTabListState, int) { | |
| 10795 | + | ret := _mock.Called() | |
| 10796 | + | ||
| 10797 | + | if len(ret) == 0 { | |
| 10798 | + | panic("no return value specified for GetWindowSnapshotState") | |
| 10799 | + | } | |
| 10800 | + | ||
| 10801 | + | var r0 []entity.WindowTabListState | |
| 10802 | + | var r1 int | |
| 10803 | + | if returnFunc, ok := ret.Get(0).(func() ([]entity.WindowTabListState, int)); ok { | |
| 10804 | + | return returnFunc() | |
| 10805 | + | } | |
| 10806 | + | if returnFunc, ok := ret.Get(0).(func() []entity.WindowTabListState); ok { | |
| 10807 | + | r0 = returnFunc() | |
| 10808 | + | } else { | |
| 10809 | + | if ret.Get(0) != nil { | |
| 10810 | + | r0 = ret.Get(0).([]entity.WindowTabListState) | |
| 10811 | + | } | |
| 10812 | + | } | |
| 10813 | + | if returnFunc, ok := ret.Get(1).(func() int); ok { | |
| 10814 | + | r1 = returnFunc() | |
| 10815 | + | } else { | |
| 10816 | + | r1 = ret.Get(1).(int) | |
| 10817 | + | } | |
| 10818 | + | return r0, r1 | |
| 10819 | + | } | |
| 10820 | + | ||
| 10821 | + | // MockWindowStateProvider_GetWindowSnapshotState_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWindowSnapshotState' | |
| 10822 | + | type MockWindowStateProvider_GetWindowSnapshotState_Call struct { | |
| 10823 | + | *mock.Call | |
| 10824 | + | } | |
| 10825 | + | ||
| 10826 | + | // GetWindowSnapshotState is a helper method to define mock.On call | |
| 10827 | + | func (_e *MockWindowStateProvider_Expecter) GetWindowSnapshotState() *MockWindowStateProvider_GetWindowSnapshotState_Call { | |
| 10828 | + | return &MockWindowStateProvider_GetWindowSnapshotState_Call{Call: _e.mock.On("GetWindowSnapshotState")} | |
| 10829 | + | } | |
| 10830 | + | ||
| 10831 | + | func (_c *MockWindowStateProvider_GetWindowSnapshotState_Call) Run(run func()) *MockWindowStateProvider_GetWindowSnapshotState_Call { | |
| 10832 | + | _c.Call.Run(func(args mock.Arguments) { | |
| 10833 | + | run() | |
| 10834 | + | }) | |
| 10835 | + | return _c | |
| 10836 | + | } | |
| 10837 | + | ||
| 10838 | + | func (_c *MockWindowStateProvider_GetWindowSnapshotState_Call) Return(windowTabListStates []entity.WindowTabListState, n int) *MockWindowStateProvider_GetWindowSnapshotState_Call { | |
| 10839 | + | _c.Call.Return(windowTabListStates, n) | |
| 10840 | + | return _c | |
| 10841 | + | } | |
| 10842 | + | ||
| 10843 | + | func (_c *MockWindowStateProvider_GetWindowSnapshotState_Call) RunAndReturn(run func() ([]entity.WindowTabListState, int)) *MockWindowStateProvider_GetWindowSnapshotState_Call { | |
| 10745 | 10844 | _c.Call.Return(run) | |
| 10746 | 10845 | return _c | |
| 10747 | 10846 | } | |
| 10748 | 10847 | ||
| 10749 | - | // GetTabList provides a mock function for the type MockTabListProvider | |
| 10750 | - | func (_mock *MockTabListProvider) GetTabList() *entity.TabList { | |
| 10848 | + | // GetWindowTabLists provides a mock function for the type MockWindowStateProvider | |
| 10849 | + | func (_mock *MockWindowStateProvider) GetWindowTabLists() []entity.WindowTabListState { | |
| 10751 | 10850 | ret := _mock.Called() | |
| 10752 | 10851 | ||
| 10753 | 10852 | if len(ret) == 0 { | |
| 10754 | - | panic("no return value specified for GetTabList") | |
| 10853 | + | panic("no return value specified for GetWindowTabLists") | |
| 10755 | 10854 | } | |
| 10756 | 10855 | ||
| 10757 | - | var r0 *entity.TabList | |
| 10758 | - | if returnFunc, ok := ret.Get(0).(func() *entity.TabList); ok { | |
| 10856 | + | var r0 []entity.WindowTabListState | |
| 10857 | + | if returnFunc, ok := ret.Get(0).(func() []entity.WindowTabListState); ok { | |
| 10759 | 10858 | r0 = returnFunc() | |
| 10760 | 10859 | } else { | |
| 10761 | 10860 | if ret.Get(0) != nil { | |
| 10762 | - | r0 = ret.Get(0).(*entity.TabList) | |
| 10861 | + | r0 = ret.Get(0).([]entity.WindowTabListState) | |
| 10763 | 10862 | } | |
| 10764 | 10863 | } | |
| 10765 | 10864 | return r0 | |
| 10766 | 10865 | } | |
| 10767 | 10866 | ||
| 10768 | - | // MockTabListProvider_GetTabList_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTabList' | |
| 10769 | - | type MockTabListProvider_GetTabList_Call struct { | |
| 10867 | + | // MockWindowStateProvider_GetWindowTabLists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWindowTabLists' | |
| 10868 | + | type MockWindowStateProvider_GetWindowTabLists_Call struct { | |
| 10770 | 10869 | *mock.Call | |
| 10771 | 10870 | } | |
| 10772 | 10871 | ||
| 10773 | - | // GetTabList is a helper method to define mock.On call | |
| 10774 | - | func (_e *MockTabListProvider_Expecter) GetTabList() *MockTabListProvider_GetTabList_Call { | |
| 10775 | - | return &MockTabListProvider_GetTabList_Call{Call: _e.mock.On("GetTabList")} | |
| 10872 | + | // GetWindowTabLists is a helper method to define mock.On call | |
| 10873 | + | func (_e *MockWindowStateProvider_Expecter) GetWindowTabLists() *MockWindowStateProvider_GetWindowTabLists_Call { | |
| 10874 | + | return &MockWindowStateProvider_GetWindowTabLists_Call{Call: _e.mock.On("GetWindowTabLists")} | |
| 10776 | 10875 | } | |
| 10777 | 10876 | ||
| 10778 | - | func (_c *MockTabListProvider_GetTabList_Call) Run(run func()) *MockTabListProvider_GetTabList_Call { | |
| 10877 | + | func (_c *MockWindowStateProvider_GetWindowTabLists_Call) Run(run func()) *MockWindowStateProvider_GetWindowTabLists_Call { | |
| 10779 | 10878 | _c.Call.Run(func(args mock.Arguments) { | |
| 10780 | 10879 | run() | |
| 10781 | 10880 | }) | |
| 10782 | 10881 | return _c | |
| 10783 | 10882 | } | |
| 10784 | 10883 | ||
| 10785 | - | func (_c *MockTabListProvider_GetTabList_Call) Return(tabList *entity.TabList) *MockTabListProvider_GetTabList_Call { | |
| 10786 | - | _c.Call.Return(tabList) | |
| 10884 | + | func (_c *MockWindowStateProvider_GetWindowTabLists_Call) Return(windowTabListStates []entity.WindowTabListState) *MockWindowStateProvider_GetWindowTabLists_Call { | |
| 10885 | + | _c.Call.Return(windowTabListStates) | |
| 10787 | 10886 | return _c | |
| 10788 | 10887 | } | |
| 10789 | 10888 | ||
| 10790 | - | func (_c *MockTabListProvider_GetTabList_Call) RunAndReturn(run func() *entity.TabList) *MockTabListProvider_GetTabList_Call { | |
| 10889 | + | func (_c *MockWindowStateProvider_GetWindowTabLists_Call) RunAndReturn(run func() []entity.WindowTabListState) *MockWindowStateProvider_GetWindowTabLists_Call { | |
| 10791 | 10890 | _c.Call.Return(run) | |
| 10792 | 10891 | return _c | |
| 10793 | 10892 | } | |
M internal/application/port/session_state.go +11 -5
| @@ -6,11 +6,17 @@ import ( | |||
| 6 | 6 | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | // TabListProvider provides access to the current tab list state. | |
| 10 | - | // Implemented by the UI layer to allow the snapshot service to read state. | |
| 11 | - | type TabListProvider interface { | |
| 12 | - | // GetTabList returns the current tab list state. | |
| 13 | - | GetTabList() *entity.TabList | |
| 9 | + | // WindowStateProvider provides window-scoped browser state for v2 snapshots. | |
| 10 | + | // Implemented by the UI layer and consumed by the snapshot service. | |
| 11 | + | type WindowStateProvider interface { | |
| 12 | + | // GetWindowSnapshotState returns the ordered window tab lists and active window index atomically. | |
| 13 | + | GetWindowSnapshotState() ([]entity.WindowTabListState, int) | |
| 14 | + | // GetWindowTabLists returns the ordered list of windows with their tab lists. | |
| 15 | + | // For consistent reads alongside GetActiveWindowIndex, prefer GetWindowSnapshotState. | |
| 16 | + | GetWindowTabLists() []entity.WindowTabListState | |
| 17 | + | // GetActiveWindowIndex returns the index of the currently focused window. | |
| 18 | + | // For consistent reads alongside GetWindowTabLists, prefer GetWindowSnapshotState. | |
| 19 | + | GetActiveWindowIndex() int | |
| 14 | 20 | // GetSessionID returns the current session ID. | |
| 15 | 21 | GetSessionID() entity.SessionID | |
| 16 | 22 | } | |
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 s.Version >= SessionStateVersion { | |
| 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 +387 -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" | |
| @@ -261,6 +262,59 @@ func TestTabList_ReplaceFrom(t *testing.T) { | |||
| 261 | 262 | assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI) | |
| 262 | 263 | } | |
| 263 | 264 | ||
| 265 | + | func TestTabList_SnapshotClonesTabsAndWorkspace(t *testing.T) { | |
| 266 | + | parentPaneID := entity.PaneID("parent-pane") | |
| 267 | + | pane := entity.NewPane("pane-1") | |
| 268 | + | pane.URI = "https://original.example" | |
| 269 | + | pane.ParentPaneID = &parentPaneID | |
| 270 | + | tab := entity.NewTab("tab-1", "workspace-1", pane) | |
| 271 | + | tab.Name = "Original" | |
| 272 | + | tabs := entity.NewTabList() | |
| 273 | + | tabs.Add(tab) | |
| 274 | + | ||
| 275 | + | snapshot := tabs.Snapshot() | |
| 276 | + | ||
| 277 | + | tab.Position = 99 | |
| 278 | + | tab.Name = "Mutated" | |
| 279 | + | tab.Workspace.Root.Pane.URI = "https://mutated.example" | |
| 280 | + | *tab.Workspace.Root.Pane.ParentPaneID = entity.PaneID("mutated-parent") | |
| 281 | + | ||
| 282 | + | require.Len(t, snapshot.Tabs, 1) | |
| 283 | + | clonedTab := snapshot.Tabs[0] | |
| 284 | + | assert.NotSame(t, tab, clonedTab) | |
| 285 | + | assert.Equal(t, 0, clonedTab.Position) | |
| 286 | + | assert.Equal(t, "Original", clonedTab.Name) | |
| 287 | + | require.NotNil(t, clonedTab.Workspace) | |
| 288 | + | assert.NotSame(t, tab.Workspace, clonedTab.Workspace) | |
| 289 | + | require.NotNil(t, clonedTab.Workspace.Root) | |
| 290 | + | assert.NotSame(t, tab.Workspace.Root, clonedTab.Workspace.Root) | |
| 291 | + | require.NotNil(t, clonedTab.Workspace.Root.Pane) | |
| 292 | + | assert.NotSame(t, tab.Workspace.Root.Pane, clonedTab.Workspace.Root.Pane) | |
| 293 | + | assert.Equal(t, "https://original.example", clonedTab.Workspace.Root.Pane.URI) | |
| 294 | + | require.NotNil(t, clonedTab.Workspace.Root.Pane.ParentPaneID) | |
| 295 | + | assert.Equal(t, entity.PaneID("parent-pane"), *clonedTab.Workspace.Root.Pane.ParentPaneID) | |
| 296 | + | } | |
| 297 | + | ||
| 298 | + | func TestTabList_ReplaceFromClonesSnapshotSource(t *testing.T) { | |
| 299 | + | pane := entity.NewPane("pane-1") | |
| 300 | + | pane.URI = "https://original.example" | |
| 301 | + | tab := entity.NewTab("tab-1", "workspace-1", pane) | |
| 302 | + | other := entity.NewTabList() | |
| 303 | + | other.Add(tab) | |
| 304 | + | ||
| 305 | + | original := entity.NewTabList() | |
| 306 | + | original.ReplaceFrom(other) | |
| 307 | + | ||
| 308 | + | tab.Name = "Mutated" | |
| 309 | + | tab.Workspace.Root.Pane.URI = "https://mutated.example" | |
| 310 | + | ||
| 311 | + | restored := original.Find("tab-1") | |
| 312 | + | require.NotNil(t, restored) | |
| 313 | + | assert.NotSame(t, tab, restored) | |
| 314 | + | assert.Empty(t, restored.Name) | |
| 315 | + | assert.Equal(t, "https://original.example", restored.Workspace.Root.Pane.URI) | |
| 316 | + | } | |
| 317 | + | ||
| 264 | 318 | func TestTabList_ReplaceFrom_Nil(t *testing.T) { | |
| 265 | 319 | original := entity.NewTabList() | |
| 266 | 320 | pane := entity.NewPane("pane1") | |
| @@ -380,6 +434,263 @@ func TestFindPaneAcrossTabs(t *testing.T) { | |||
| 380 | 434 | assert.Nil(t, notFound) | |
| 381 | 435 | } | |
| 382 | 436 | ||
| 437 | + | // --- Window-scoped snapshot tests --- | |
| 438 | + | ||
| 439 | + | func TestSnapshotFromWindowTabLists_V2State(t *testing.T) { | |
| 440 | + | // Create two windows, each with tabs | |
| 441 | + | pane1_1 := entity.NewPane("p1_w1") | |
| 442 | + | pane1_1.URI = "https://window1.com" | |
| 443 | + | tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1_1) | |
| 444 | + | tab1_1.Name = "Window1 Tab1" | |
| 445 | + | ||
| 446 | + | pane1_2 := entity.NewPane("p1_w2") | |
| 447 | + | pane1_2.URI = "https://window2.com" | |
| 448 | + | tab1_2 := entity.NewTab("t1_w2", "ws1_w2", pane1_2) | |
| 449 | + | tab1_2.Name = "Window2 Tab1" | |
| 450 | + | ||
| 451 | + | pane1_2b := entity.NewPane("p2_w2") | |
| 452 | + | pane1_2b.URI = "https://window2-tab2.com" | |
| 453 | + | tab1_2b := entity.NewTab("t2_w2", "ws2_w2", pane1_2b) | |
| 454 | + | tab1_2b.Name = "Window2 Tab2" | |
| 455 | + | ||
| 456 | + | tabs1 := entity.NewTabList() | |
| 457 | + | tabs1.Add(tab1_1) | |
| 458 | + | ||
| 459 | + | tabs2 := entity.NewTabList() | |
| 460 | + | tabs2.Add(tab1_2) | |
| 461 | + | tabs2.Add(tab1_2b) | |
| 462 | + | tabs2.SetActive("t2_w2") | |
| 463 | + | ||
| 464 | + | windows := []entity.WindowTabListState{ | |
| 465 | + | {WindowID: "w1", Tabs: tabs1}, | |
| 466 | + | {WindowID: "w2", Tabs: tabs2}, | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | sessionID := entity.SessionID("test_v2") | |
| 470 | + | state := entity.SnapshotFromWindowTabLists(sessionID, windows, 1, time.Unix(123, 0)) | |
| 471 | + | ||
| 472 | + | require.NotNil(t, state) | |
| 473 | + | assert.Equal(t, 2, state.Version) | |
| 474 | + | assert.Equal(t, sessionID, state.SessionID) | |
| 475 | + | ||
| 476 | + | // Windows field populated | |
| 477 | + | require.Len(t, state.Windows, 2) | |
| 478 | + | assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID) | |
| 479 | + | assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID) | |
| 480 | + | ||
| 481 | + | // Active window index set | |
| 482 | + | assert.Equal(t, 1, state.ActiveWindowIndex) | |
| 483 | + | ||
| 484 | + | // No legacy Tabs when using windows | |
| 485 | + | assert.Empty(t, state.Tabs) | |
| 486 | + | ||
| 487 | + | // CountPanes across windows | |
| 488 | + | assert.Equal(t, 3, state.CountPanes()) | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | func TestWindowTabListsFromSnapshot_V2Restore(t *testing.T) { | |
| 492 | + | // Create a v2 snapshot from windows, then restore | |
| 493 | + | pane := entity.NewPane("p_w1_t1") | |
| 494 | + | pane.URI = "https://example.com" | |
| 495 | + | tab := entity.NewTab("t_w1_t1", "ws_w1", pane) | |
| 496 | + | tab.Name = "MyTab" | |
| 497 | + | ||
| 498 | + | tabs := entity.NewTabList() | |
| 499 | + | tabs.Add(tab) | |
| 500 | + | ||
| 501 | + | windows := []entity.WindowTabListState{ | |
| 502 | + | {WindowID: "window-1", Tabs: tabs}, | |
| 503 | + | } | |
| 504 | + | ||
| 505 | + | state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0)) | |
| 506 | + | ||
| 507 | + | idGen := mockIDGenerator() | |
| 508 | + | restored := entity.WindowTabListsFromSnapshot(state, idGen) | |
| 509 | + | ||
| 510 | + | require.Len(t, restored, 1) | |
| 511 | + | assert.Equal(t, entity.WindowID("window-1"), restored[0].WindowID) | |
| 512 | + | require.NotNil(t, restored[0].Tabs) | |
| 513 | + | require.Len(t, restored[0].Tabs.Tabs, 1) | |
| 514 | + | assert.Equal(t, "MyTab", restored[0].Tabs.Tabs[0].Name) | |
| 515 | + | assert.Equal(t, "https://example.com", restored[0].Tabs.Tabs[0].Workspace.Root.Pane.URI) | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | func TestWindowTabListsFromSnapshot_V1LegacySingleWindow(t *testing.T) { | |
| 519 | + | // Legacy v1 flat tabs should be restored as a single window with empty WindowID | |
| 520 | + | state := &entity.SessionState{ | |
| 521 | + | Version: 1, | |
| 522 | + | SessionID: "old_sess", | |
| 523 | + | ActiveTabIndex: 1, | |
| 524 | + | Tabs: []entity.TabSnapshot{ | |
| 525 | + | { | |
| 526 | + | ID: "tab1", | |
| 527 | + | Name: "First", | |
| 528 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 529 | + | Root: &entity.PaneNodeSnapshot{ | |
| 530 | + | Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.com"}, | |
| 531 | + | }, | |
| 532 | + | }, | |
| 533 | + | }, | |
| 534 | + | { | |
| 535 | + | ID: "tab2", | |
| 536 | + | Name: "Second", | |
| 537 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 538 | + | Root: &entity.PaneNodeSnapshot{ | |
| 539 | + | Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://second.com"}, | |
| 540 | + | }, | |
| 541 | + | }, | |
| 542 | + | }, | |
| 543 | + | }, | |
| 544 | + | } | |
| 545 | + | ||
| 546 | + | idGen := mockIDGenerator() | |
| 547 | + | restored := entity.WindowTabListsFromSnapshot(state, idGen) | |
| 548 | + | ||
| 549 | + | require.Len(t, restored, 1) | |
| 550 | + | // Legacy window gets empty WindowID | |
| 551 | + | assert.Equal(t, entity.WindowID(""), restored[0].WindowID) | |
| 552 | + | require.NotNil(t, restored[0].Tabs) | |
| 553 | + | require.Len(t, restored[0].Tabs.Tabs, 2) | |
| 554 | + | assert.Equal(t, "Second", restored[0].Tabs.Tabs[1].Name) | |
| 555 | + | // Active tab should be second (index 1) | |
| 556 | + | assert.Equal(t, restored[0].Tabs.Tabs[1].ID, restored[0].Tabs.ActiveTabID) | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | func TestWindowTabListsFromSnapshot_NilState(t *testing.T) { | |
| 560 | + | idGen := mockIDGenerator() | |
| 561 | + | restored := entity.WindowTabListsFromSnapshot(nil, idGen) | |
| 562 | + | assert.Nil(t, restored) | |
| 563 | + | } | |
| 564 | + | ||
| 565 | + | func TestWindowTabListsFromSnapshot_InvalidIndexesFallback(t *testing.T) { | |
| 566 | + | // Tab with active tab index out of range should fall back to first tab | |
| 567 | + | state := &entity.SessionState{ | |
| 568 | + | Version: 1, | |
| 569 | + | ActiveTabIndex: 999, // beyond bounds | |
| 570 | + | Tabs: []entity.TabSnapshot{ | |
| 571 | + | { | |
| 572 | + | ID: "tab_a", | |
| 573 | + | Name: "TabA", | |
| 574 | + | Workspace: entity.WorkspaceSnapshot{ | |
| 575 | + | Root: &entity.PaneNodeSnapshot{ | |
| 576 | + | Pane: &entity.PaneSnapshot{ID: "pa", URI: "https://a.com"}, | |
| 577 | + | }, | |
| 578 | + | }, | |
| 579 | + | }, | |
| 580 | + | }, | |
| 581 | + | } | |
| 582 | + | ||
| 583 | + | idGen := mockIDGenerator() | |
| 584 | + | restored := entity.WindowTabListsFromSnapshot(state, idGen) | |
| 585 | + | ||
| 586 | + | require.Len(t, restored, 1) | |
| 587 | + | require.NotNil(t, restored[0].Tabs) | |
| 588 | + | // Should have fallen back to first tab | |
| 589 | + | assert.Equal(t, restored[0].Tabs.Tabs[0].ID, restored[0].Tabs.ActiveTabID) | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | func TestTabListFromSnapshot_ActiveWindowWithZeroTabsFallsBackToFirstOverallTab(t *testing.T) { | |
| 593 | + | // Regression: when the active window has zero tabs, the active tab should | |
| 594 | + | // be the first tab overall (window 0's tab), not the third window's tab. | |
| 595 | + | state := &entity.SessionState{ | |
| 596 | + | Version: entity.SessionStateVersion, | |
| 597 | + | ActiveWindowIndex: 1, | |
| 598 | + | Windows: []entity.WindowSnapshot{ | |
| 599 | + | {ID: "w1", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{ | |
| 600 | + | {Name: "First", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.example"}}}}, | |
| 601 | + | }}, | |
| 602 | + | {ID: "w2", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{}}, | |
| 603 | + | {ID: "w3", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{ | |
| 604 | + | {Name: "Third", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://third.example"}}}}, | |
| 605 | + | }}, | |
| 606 | + | }, | |
| 607 | + | } | |
| 608 | + | ||
| 609 | + | tabs := entity.TabListFromSnapshot(state, mockIDGenerator()) | |
| 610 | + | ||
| 611 | + | require.Len(t, tabs.Tabs, 2) | |
| 612 | + | assert.Equal(t, "First", tabs.ActiveTab().Name, "active tab should be the first overall tab, not the third window's tab") | |
| 613 | + | } | |
| 614 | + | ||
| 615 | + | func TestTabListFromSnapshot_FlattensV2WindowSnapshots(t *testing.T) { | |
| 616 | + | state := &entity.SessionState{ | |
| 617 | + | Version: entity.SessionStateVersion, | |
| 618 | + | ActiveWindowIndex: 1, | |
| 619 | + | Windows: []entity.WindowSnapshot{ | |
| 620 | + | {ID: "w1", ActiveTabIndex: 0, Tabs: []entity.TabSnapshot{ | |
| 621 | + | {Name: "First", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p1", URI: "https://first.example"}}}}, | |
| 622 | + | }}, | |
| 623 | + | {ID: "w2", ActiveTabIndex: 1, Tabs: []entity.TabSnapshot{ | |
| 624 | + | {Name: "Second", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p2", URI: "https://second.example"}}}}, | |
| 625 | + | {Name: "Active", Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "p3", URI: "https://active.example"}}}}, | |
| 626 | + | }}, | |
| 627 | + | }, | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | tabs := entity.TabListFromSnapshot(state, mockIDGenerator()) | |
| 631 | + | ||
| 632 | + | require.Len(t, tabs.Tabs, 3) | |
| 633 | + | assert.Equal(t, "Active", tabs.ActiveTab().Name) | |
| 634 | + | } | |
| 635 | + | ||
| 636 | + | func TestWindowTabListsFromSnapshot_FutureV2VersionWithEmptyWindowsStaysEmpty(t *testing.T) { | |
| 637 | + | state := &entity.SessionState{Version: entity.SessionStateVersion + 1, Windows: []entity.WindowSnapshot{}} | |
| 638 | + | ||
| 639 | + | restored := entity.WindowTabListsFromSnapshot(state, mockIDGenerator()) | |
| 640 | + | ||
| 641 | + | assert.Empty(t, restored) | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | func TestSnapshotFromWindowTabLists_NilTabs(t *testing.T) { | |
| 645 | + | windows := []entity.WindowTabListState{ | |
| 646 | + | {WindowID: "w1", Tabs: nil}, | |
| 647 | + | } | |
| 648 | + | ||
| 649 | + | state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0)) | |
| 650 | + | ||
| 651 | + | require.Len(t, state.Windows, 1) | |
| 652 | + | assert.Empty(t, state.Windows[0].Tabs) | |
| 653 | + | assert.Equal(t, 0, state.CountPanes()) | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | func TestSnapshotFromWindowTabLists_EmptyWindows(t *testing.T) { | |
| 657 | + | state := entity.SnapshotFromWindowTabLists("sess", nil, 0, time.Unix(123, 0)) | |
| 658 | + | ||
| 659 | + | require.NotNil(t, state) | |
| 660 | + | assert.Equal(t, 2, state.Version) | |
| 661 | + | assert.Empty(t, state.Windows) | |
| 662 | + | assert.Equal(t, 0, state.ActiveWindowIndex) | |
| 663 | + | } | |
| 664 | + | ||
| 665 | + | func TestSessionStateCountPanesUsesVersionForWindowSnapshots(t *testing.T) { | |
| 666 | + | state := &entity.SessionState{ | |
| 667 | + | Version: entity.SessionStateVersion, | |
| 668 | + | Tabs: []entity.TabSnapshot{{ | |
| 669 | + | Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "legacy-pane"}}}, | |
| 670 | + | }}, | |
| 671 | + | } | |
| 672 | + | ||
| 673 | + | assert.Equal(t, 0, state.CountPanes()) | |
| 674 | + | } | |
| 675 | + | ||
| 676 | + | func TestSnapshotFromWindowTabLists_InvalidWindowIndex(t *testing.T) { | |
| 677 | + | pane := entity.NewPane("p1") | |
| 678 | + | pane.URI = "https://x.com" | |
| 679 | + | tab := entity.NewTab("t1", "ws1", pane) | |
| 680 | + | tabs := entity.NewTabList() | |
| 681 | + | tabs.Add(tab) | |
| 682 | + | ||
| 683 | + | windows := []entity.WindowTabListState{ | |
| 684 | + | {WindowID: "w1", Tabs: tabs}, | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | // Active window index out of range | |
| 688 | + | state := entity.SnapshotFromWindowTabLists("sess", windows, 999, time.Unix(123, 0)) | |
| 689 | + | ||
| 690 | + | // Should clamp to 0 | |
| 691 | + | assert.Equal(t, 0, state.ActiveWindowIndex) | |
| 692 | + | } | |
| 693 | + | ||
| 383 | 694 | func TestFindPaneInNestedStructure(t *testing.T) { | |
| 384 | 695 | // Create a tab with split panes | |
| 385 | 696 | pane1 := entity.NewPane("left_pane") | |
| @@ -428,3 +739,79 @@ func TestFindPaneInNestedStructure(t *testing.T) { | |||
| 428 | 739 | rightNodeAgain := tab.Workspace.FindPane("right_pane") | |
| 429 | 740 | assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI) | |
| 430 | 741 | } | |
| 742 | + | ||
| 743 | + | func TestV2SnapshotJSON_OmitEmptyLegacyFields(t *testing.T) { | |
| 744 | + | // When using SnapshotFromWindowTabLists (v2 path), the JSON output must | |
| 745 | + | // NOT include top-level legacy "tabs" or "active_tab_index" fields. | |
| 746 | + | // They should be omitted by omitempty. | |
| 747 | + | pane := entity.NewPane(entity.PaneID("p1")) | |
| 748 | + | pane.URI = "https://example.com" | |
| 749 | + | tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane) | |
| 750 | + | tabs := entity.NewTabList() | |
| 751 | + | tabs.Add(tab) | |
| 752 | + | ||
| 753 | + | windows := []entity.WindowTabListState{ | |
| 754 | + | {WindowID: entity.WindowID("w1"), Tabs: tabs}, | |
| 755 | + | } | |
| 756 | + | ||
| 757 | + | state := entity.SnapshotFromWindowTabLists("sess", windows, 0, time.Unix(123, 0)) | |
| 758 | + | ||
| 759 | + | data, err := json.Marshal(state) | |
| 760 | + | require.NoError(t, err) | |
| 761 | + | ||
| 762 | + | var raw map[string]any | |
| 763 | + | err = json.Unmarshal(data, &raw) | |
| 764 | + | require.NoError(t, err) | |
| 765 | + | ||
| 766 | + | // v2 JSON must not contain top-level legacy fields. | |
| 767 | + | _, hasTabs := raw["tabs"] | |
| 768 | + | _, hasActiveTabIdx := raw["active_tab_index"] | |
| 769 | + | assert.False(t, hasTabs, "v2 JSON must not include legacy 'tabs' field") | |
| 770 | + | assert.False(t, hasActiveTabIdx, "v2 JSON must not include legacy 'active_tab_index' field") | |
| 771 | + | ||
| 772 | + | // But must still have the v2 windows field. | |
| 773 | + | windowsRaw, ok := raw["windows"] | |
| 774 | + | require.True(t, ok, "v2 JSON must include 'windows' field") | |
| 775 | + | windowsArr, _ := windowsRaw.([]any) | |
| 776 | + | require.Len(t, windowsArr, 1) | |
| 777 | + | } | |
| 778 | + | ||
| 779 | + | func TestV2SnapshotJSON_CanStillReadV1(t *testing.T) { | |
| 780 | + | // When reading a v1 JSON with the legacy fields, they must still be | |
| 781 | + | // populated correctly (omitempty is only for writes). | |
| 782 | + | v1JSON := `{ | |
| 783 | + | "version": 1, | |
| 784 | + | "session_id": "old-sess", | |
| 785 | + | "tabs": [{ | |
| 786 | + | "id": "tab1", | |
| 787 | + | "name": "MyTab", | |
| 788 | + | "position": 0, | |
| 789 | + | "is_pinned": false, | |
| 790 | + | "workspace": { | |
| 791 | + | "id": "ws1", | |
| 792 | + | "root": { | |
| 793 | + | "id": "node1", | |
| 794 | + | "pane": {"id": "p1", "uri": "https://example.com", "title": "", "zoom_factor": 0}, | |
| 795 | + | "split_dir": 0, | |
| 796 | + | "split_ratio": 0, | |
| 797 | + | "is_stacked": false, | |
| 798 | + | "active_stack_index": 0 | |
| 799 | + | }, | |
| 800 | + | "active_pane_id": "" | |
| 801 | + | } | |
| 802 | + | }], | |
| 803 | + | "active_tab_index": 0, | |
| 804 | + | "saved_at": "2026-01-01T00:00:00Z" | |
| 805 | + | }` | |
| 806 | + | ||
| 807 | + | var state entity.SessionState | |
| 808 | + | err := json.Unmarshal([]byte(v1JSON), &state) | |
| 809 | + | require.NoError(t, err) | |
| 810 | + | ||
| 811 | + | assert.Equal(t, 1, state.Version) | |
| 812 | + | assert.Equal(t, entity.SessionID("old-sess"), state.SessionID) | |
| 813 | + | require.Len(t, state.Tabs, 1) | |
| 814 | + | assert.Equal(t, entity.TabID("tab1"), state.Tabs[0].ID) | |
| 815 | + | assert.Equal(t, "MyTab", state.Tabs[0].Name) | |
| 816 | + | assert.Equal(t, 0, state.ActiveTabIndex) | |
| 817 | + | } | |
M internal/domain/entity/tab.go +109 -5
| @@ -2,6 +2,7 @@ package entity | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "sync" | |
| 5 | 6 | "time" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| @@ -51,6 +52,8 @@ type TabList struct { | |||
| 51 | 52 | Tabs []*Tab | |
| 52 | 53 | ActiveTabID TabID | |
| 53 | 54 | PreviousActiveTabID TabID // Tracks last active tab for Alt+Tab style switching | |
| 55 | + | ||
| 56 | + | mu sync.RWMutex | |
| 54 | 57 | } | |
| 55 | 58 | ||
| 56 | 59 | // NewTabList creates an empty tab list. | |
| @@ -62,6 +65,9 @@ func NewTabList() *TabList { | |||
| 62 | 65 | ||
| 63 | 66 | // Add appends a tab to the list. | |
| 64 | 67 | func (tl *TabList) Add(tab *Tab) { | |
| 68 | + | tl.mu.Lock() | |
| 69 | + | defer tl.mu.Unlock() | |
| 70 | + | ||
| 65 | 71 | tab.Position = len(tl.Tabs) | |
| 66 | 72 | tl.Tabs = append(tl.Tabs, tab) | |
| 67 | 73 | if tl.ActiveTabID == "" { | |
| @@ -71,6 +77,9 @@ func (tl *TabList) Add(tab *Tab) { | |||
| 71 | 77 | ||
| 72 | 78 | // Remove removes a tab by ID and reindexes positions. | |
| 73 | 79 | func (tl *TabList) Remove(id TabID) bool { | |
| 80 | + | tl.mu.Lock() | |
| 81 | + | defer tl.mu.Unlock() | |
| 82 | + | ||
| 74 | 83 | for i, tab := range tl.Tabs { | |
| 75 | 84 | if tab.ID == id { | |
| 76 | 85 | tl.Tabs = append(tl.Tabs[:i], tl.Tabs[i+1:]...) | |
| @@ -99,6 +108,13 @@ func (tl *TabList) Remove(id TabID) bool { | |||
| 99 | 108 | ||
| 100 | 109 | // Find returns a tab by ID. | |
| 101 | 110 | func (tl *TabList) Find(id TabID) *Tab { | |
| 111 | + | tl.mu.RLock() | |
| 112 | + | defer tl.mu.RUnlock() | |
| 113 | + | ||
| 114 | + | return tl.findNoLock(id) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func (tl *TabList) findNoLock(id TabID) *Tab { | |
| 102 | 118 | for _, tab := range tl.Tabs { | |
| 103 | 119 | if tab.ID == id { | |
| 104 | 120 | return tab | |
| @@ -109,16 +125,25 @@ func (tl *TabList) Find(id TabID) *Tab { | |||
| 109 | 125 | ||
| 110 | 126 | // ActiveTab returns the currently active tab. | |
| 111 | 127 | func (tl *TabList) ActiveTab() *Tab { | |
| 112 | - | return tl.Find(tl.ActiveTabID) | |
| 128 | + | tl.mu.RLock() | |
| 129 | + | defer tl.mu.RUnlock() | |
| 130 | + | ||
| 131 | + | return tl.findNoLock(tl.ActiveTabID) | |
| 113 | 132 | } | |
| 114 | 133 | ||
| 115 | 134 | // Count returns the number of tabs. | |
| 116 | 135 | func (tl *TabList) Count() int { | |
| 136 | + | tl.mu.RLock() | |
| 137 | + | defer tl.mu.RUnlock() | |
| 138 | + | ||
| 117 | 139 | return len(tl.Tabs) | |
| 118 | 140 | } | |
| 119 | 141 | ||
| 120 | 142 | // SetActive sets the active tab and updates the previous active tab. | |
| 121 | 143 | func (tl *TabList) SetActive(id TabID) { | |
| 144 | + | tl.mu.Lock() | |
| 145 | + | defer tl.mu.Unlock() | |
| 146 | + | ||
| 122 | 147 | if id != tl.ActiveTabID && tl.ActiveTabID != "" { | |
| 123 | 148 | tl.PreviousActiveTabID = tl.ActiveTabID | |
| 124 | 149 | } | |
| @@ -127,6 +152,9 @@ func (tl *TabList) SetActive(id TabID) { | |||
| 127 | 152 | ||
| 128 | 153 | // TabAt returns the tab at the given 0-based index. | |
| 129 | 154 | func (tl *TabList) TabAt(index int) *Tab { | |
| 155 | + | tl.mu.RLock() | |
| 156 | + | defer tl.mu.RUnlock() | |
| 157 | + | ||
| 130 | 158 | if index < 0 || index >= len(tl.Tabs) { | |
| 131 | 159 | return nil | |
| 132 | 160 | } | |
| @@ -135,6 +163,9 @@ func (tl *TabList) TabAt(index int) *Tab { | |||
| 135 | 163 | ||
| 136 | 164 | // Move moves a tab to a new position. | |
| 137 | 165 | func (tl *TabList) Move(id TabID, newPos int) bool { | |
| 166 | + | tl.mu.Lock() | |
| 167 | + | defer tl.mu.Unlock() | |
| 168 | + | ||
| 138 | 169 | if newPos < 0 || newPos >= len(tl.Tabs) { | |
| 139 | 170 | return false | |
| 140 | 171 | } | |
| @@ -161,16 +192,89 @@ func (tl *TabList) Move(id TabID, newPos int) bool { | |||
| 161 | 192 | return true | |
| 162 | 193 | } | |
| 163 | 194 | ||
| 195 | + | // Snapshot returns an isolated copy of the TabList safe for concurrent read. | |
| 196 | + | // The returned TabList has its own slice, Tab values, and workspace tree. | |
| 197 | + | func (tl *TabList) Snapshot() *TabList { | |
| 198 | + | if tl == nil { | |
| 199 | + | return nil | |
| 200 | + | } | |
| 201 | + | tl.mu.RLock() | |
| 202 | + | defer tl.mu.RUnlock() | |
| 203 | + | ||
| 204 | + | tabs := make([]*Tab, 0, len(tl.Tabs)) | |
| 205 | + | for _, tab := range tl.Tabs { | |
| 206 | + | tabs = append(tabs, cloneTab(tab)) | |
| 207 | + | } | |
| 208 | + | return &TabList{ | |
| 209 | + | Tabs: tabs, | |
| 210 | + | ActiveTabID: tl.ActiveTabID, | |
| 211 | + | PreviousActiveTabID: tl.PreviousActiveTabID, | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | func cloneTab(tab *Tab) *Tab { | |
| 216 | + | if tab == nil { | |
| 217 | + | return nil | |
| 218 | + | } | |
| 219 | + | cloned := *tab | |
| 220 | + | cloned.Workspace = cloneWorkspace(tab.Workspace) | |
| 221 | + | return &cloned | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func cloneWorkspace(workspace *Workspace) *Workspace { | |
| 225 | + | if workspace == nil { | |
| 226 | + | return nil | |
| 227 | + | } | |
| 228 | + | cloned := *workspace | |
| 229 | + | cloned.Root = clonePaneNode(workspace.Root, nil) | |
| 230 | + | return &cloned | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | func clonePaneNode(node, parent *PaneNode) *PaneNode { | |
| 234 | + | if node == nil { | |
| 235 | + | return nil | |
| 236 | + | } | |
| 237 | + | cloned := *node | |
| 238 | + | cloned.Parent = parent | |
| 239 | + | cloned.Pane = clonePane(node.Pane) | |
| 240 | + | if len(node.Children) > 0 { | |
| 241 | + | cloned.Children = make([]*PaneNode, 0, len(node.Children)) | |
| 242 | + | for _, child := range node.Children { | |
| 243 | + | cloned.Children = append(cloned.Children, clonePaneNode(child, &cloned)) | |
| 244 | + | } | |
| 245 | + | } else { | |
| 246 | + | cloned.Children = nil | |
| 247 | + | } | |
| 248 | + | return &cloned | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | func clonePane(pane *Pane) *Pane { | |
| 252 | + | if pane == nil { | |
| 253 | + | return nil | |
| 254 | + | } | |
| 255 | + | cloned := *pane | |
| 256 | + | if pane.ParentPaneID != nil { | |
| 257 | + | parentPaneID := *pane.ParentPaneID | |
| 258 | + | cloned.ParentPaneID = &parentPaneID | |
| 259 | + | } | |
| 260 | + | return &cloned | |
| 261 | + | } | |
| 262 | + | ||
| 164 | 263 | // ReplaceFrom replaces this TabList's contents with those from another TabList. | |
| 165 | 264 | // This modifies in-place so existing references to this TabList remain valid. | |
| 166 | 265 | func (tl *TabList) ReplaceFrom(other *TabList) { | |
| 167 | - | if other == nil { | |
| 266 | + | snapshot := other.Snapshot() | |
| 267 | + | ||
| 268 | + | tl.mu.Lock() | |
| 269 | + | defer tl.mu.Unlock() | |
| 270 | + | ||
| 271 | + | if snapshot == nil { | |
| 168 | 272 | tl.Tabs = make([]*Tab, 0) | |
| 169 | 273 | tl.ActiveTabID = "" | |
| 170 | 274 | tl.PreviousActiveTabID = "" | |
| 171 | 275 | return | |
| 172 | 276 | } | |
| 173 | - | tl.Tabs = other.Tabs | |
| 174 | - | tl.ActiveTabID = other.ActiveTabID | |
| 175 | - | tl.PreviousActiveTabID = other.PreviousActiveTabID | |
| 277 | + | tl.Tabs = snapshot.Tabs | |
| 278 | + | tl.ActiveTabID = snapshot.ActiveTabID | |
| 279 | + | tl.PreviousActiveTabID = snapshot.PreviousActiveTabID | |
| 176 | 280 | } | |
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 +1174 -285
| @@ -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,29 @@ 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)) | |
| 670 | + | } | |
| 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 | |
| 641 | 678 | } | |
| 642 | - | if a.tabs != nil && bw.activeTabID != "" && a.tabs.Find(bw.activeTabID) != nil { | |
| 643 | - | a.tabs.SetActive(bw.activeTabID) | |
| 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 | + | a.tabs.Add(cloneTabForGlobalList(tab)) | |
| 684 | + | } | |
| 685 | + | } | |
| 686 | + | if a.tabs.Find(activeID) != nil { | |
| 687 | + | a.tabs.SetActive(activeID) | |
| 688 | + | } | |
| 644 | 689 | } | |
| 645 | - | a.updateWindowTitleFromActivePane(bw.activeTabID) | |
| 690 | + | a.updateWindowTitleFromActivePane(activeID) | |
| 646 | 691 | } | |
| 647 | 692 | ||
| 648 | 693 | func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) { | |
| @@ -667,8 +712,9 @@ func (a *App) setBrowserWindowForTab(tabID entity.TabID, bw *browserWindow) { | |||
| 667 | 712 | } | |
| 668 | 713 | a.ensureWindowForTabMap() | |
| 669 | 714 | a.windowForTab[tabID] = bw | |
| 670 | - | if bw.activeTabID == "" { | |
| 671 | - | bw.activeTabID = tabID | |
| 715 | + | tabs := a.ensureTabListForBrowserWindow(bw) | |
| 716 | + | if tabs.ActiveTabID == "" { | |
| 717 | + | tabs.SetActive(tabID) | |
| 672 | 718 | } | |
| 673 | 719 | } | |
| 674 | 720 | ||
| @@ -711,7 +757,7 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error { | |||
| 711 | 757 | } | |
| 712 | 758 | a.registerBrowserWindow(created) | |
| 713 | 759 | ||
| 714 | - | if a.tabs == nil || a.tabs.Count() == 0 { | |
| 760 | + | if len(a.browserWindows) == 1 { | |
| 715 | 761 | a.activateBrowserWindow(created) | |
| 716 | 762 | return nil | |
| 717 | 763 | } | |
| @@ -733,17 +779,13 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error { | |||
| 733 | 779 | } | |
| 734 | 780 | ||
| 735 | 781 | 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 | |
| 782 | + | previousTarget := a.tabCoord.CurrentTarget() | |
| 783 | + | a.tabCoord.SetCurrentTarget(a.ensureTabTargetForBrowserWindow(created)) | |
| 741 | 784 | defer func() { | |
| 742 | - | a.tabCreationWindow = nil | |
| 743 | - | a.tabCoord.SetMainWindow(previousMainWindow) | |
| 785 | + | a.tabCoord.SetCurrentTarget(previousTarget) | |
| 744 | 786 | }() | |
| 745 | 787 | ||
| 746 | - | createdTab, createErr := a.tabCoord.Create(ctx, url) | |
| 788 | + | createdTab, createErr := a.tabCoord.Create(ctx, a.ensureTabTargetForBrowserWindow(created), url) | |
| 747 | 789 | if createErr != nil { | |
| 748 | 790 | a.removeBrowserWindow(created.id) | |
| 749 | 791 | if created.mainWindow != nil { | |
| @@ -752,19 +794,17 @@ func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, creat | |||
| 752 | 794 | return createErr | |
| 753 | 795 | } | |
| 754 | 796 | if a.workspaceViews[createdTab.ID] == nil { | |
| 755 | - | if a.tabs != nil { | |
| 756 | - | a.tabs.Remove(createdTab.ID) | |
| 797 | + | if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil { | |
| 798 | + | createdTabs.Remove(createdTab.ID) | |
| 757 | 799 | } | |
| 758 | - | tabBar := a.mainWindow.TabBar() | |
| 759 | - | if created.mainWindow != nil && a.tabCoord != nil { | |
| 760 | - | if targetTabBar := a.tabCoord.GetTabBar(); targetTabBar != nil { | |
| 761 | - | tabBar = targetTabBar | |
| 800 | + | if created.mainWindow != nil { | |
| 801 | + | if tabBar := created.mainWindow.TabBar(); tabBar != nil { | |
| 802 | + | tabBar.RemoveTab(createdTab.ID) | |
| 803 | + | if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil { | |
| 804 | + | tabBar.SetActive(activeTab.ID) | |
| 805 | + | } | |
| 762 | 806 | } | |
| 763 | 807 | } | |
| 764 | - | if tabBar != nil { | |
| 765 | - | tabBar.RemoveTab(createdTab.ID) | |
| 766 | - | tabBar.SetActive(a.tabs.ActiveTabID) | |
| 767 | - | } | |
| 768 | 808 | a.removeBrowserWindow(created.id) | |
| 769 | 809 | if created.mainWindow != nil { | |
| 770 | 810 | created.mainWindow.Destroy() | |
| @@ -772,7 +812,8 @@ func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, creat | |||
| 772 | 812 | return fmt.Errorf("workspace view not created for tab %s", createdTab.ID) | |
| 773 | 813 | } | |
| 774 | 814 | a.setBrowserWindowForTab(createdTab.ID, created) | |
| 775 | - | created.activeTabID = createdTab.ID | |
| 815 | + | // Tab was already created in the per-window TabList via tabCoord.Create; | |
| 816 | + | // active state is managed by TabList.SetActive. | |
| 776 | 817 | if created.mainWindow != nil { | |
| 777 | 818 | created.mainWindow.Show() | |
| 778 | 819 | } | |
| @@ -792,30 +833,27 @@ func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created | |||
| 792 | 833 | return err | |
| 793 | 834 | } | |
| 794 | 835 | a.setBrowserWindowForTab(output.Tab.ID, created) | |
| 795 | - | created.activeTabID = output.Tab.ID | |
| 836 | + | // Mirror the tabsUC-created tab into the new browser window's tab list. | |
| 837 | + | // This path uses the tabs usecase directly instead of tabCoord.Create, | |
| 838 | + | // so the per-window TabList must be populated explicitly. | |
| 839 | + | a.ensureTabListForBrowserWindow(created).Add(output.Tab) | |
| 796 | 840 | if a.widgetFactory != nil { | |
| 797 | - | a.tabCreationWindow = created | |
| 798 | - | defer func() { | |
| 799 | - | a.tabCreationWindow = nil | |
| 800 | - | }() | |
| 801 | 841 | a.createWorkspaceView(ctx, output.Tab) | |
| 802 | 842 | a.switchWorkspaceView(ctx, output.Tab.ID) | |
| 803 | 843 | } | |
| 804 | 844 | if a.workspaceViews[output.Tab.ID] == nil { | |
| 845 | + | if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil { | |
| 846 | + | createdTabs.Remove(output.Tab.ID) | |
| 847 | + | } | |
| 805 | 848 | if a.tabs != nil { | |
| 806 | 849 | a.tabs.Remove(output.Tab.ID) | |
| 807 | 850 | } | |
| 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) | |
| 851 | + | if created.mainWindow != nil { | |
| 852 | + | if tabBar := created.mainWindow.TabBar(); tabBar != nil { | |
| 853 | + | tabBar.RemoveTab(output.Tab.ID) | |
| 854 | + | if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil { | |
| 855 | + | tabBar.SetActive(activeTab.ID) | |
| 856 | + | } | |
| 819 | 857 | } | |
| 820 | 858 | } | |
| 821 | 859 | a.removeBrowserWindow(created.id) | |
| @@ -1118,7 +1156,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 1118 | 1156 | return nil | |
| 1119 | 1157 | } | |
| 1120 | 1158 | } | |
| 1121 | - | return a.kbDispatcher.Dispatch(actionCtx, action) | |
| 1159 | + | return a.dispatchBrowserWindowAction(actionCtx, bw, action) | |
| 1122 | 1160 | }) | |
| 1123 | 1161 | bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool { | |
| 1124 | 1162 | a.activateBrowserWindow(bw) | |
| @@ -1165,7 +1203,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) { | |||
| 1165 | 1203 | bw.keyboardHandler, | |
| 1166 | 1204 | func(actionCtx context.Context, action input.Action) error { | |
| 1167 | 1205 | a.activateBrowserWindow(bw) | |
| 1168 | - | return a.kbDispatcher.Dispatch(actionCtx, action) | |
| 1206 | + | return a.dispatchBrowserWindowAction(actionCtx, bw, action) | |
| 1169 | 1207 | }, | |
| 1170 | 1208 | ) | |
| 1171 | 1209 | if bw.globalShortcutHandler == nil { | |
| @@ -1181,11 +1219,11 @@ func (a *App) wireBrowserWindowTabBar(ctx context.Context, bw *browserWindow) { | |||
| 1181 | 1219 | } | |
| 1182 | 1220 | bw.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) { | |
| 1183 | 1221 | a.activateBrowserWindow(bw) | |
| 1184 | - | if err := a.tabCoord.Switch(ctx, tabID); err != nil { | |
| 1222 | + | if err := a.tabCoord.Switch(ctx, a.ensureTabTargetForBrowserWindow(bw), tabID); err != nil { | |
| 1185 | 1223 | logging.FromContext(ctx).Error().Err(err).Str("tab_id", string(tabID)).Str("window_id", bw.id).Msg("tab switch failed") | |
| 1186 | 1224 | return | |
| 1187 | 1225 | } | |
| 1188 | - | bw.activeTabID = tabID | |
| 1226 | + | // Active tab state is managed by TabCoordinator.Switch → TabList.SetActive. | |
| 1189 | 1227 | }) | |
| 1190 | 1228 | } | |
| 1191 | 1229 | ||
| @@ -1432,7 +1470,7 @@ func (a *App) attachTabPickerToActivePane() { | |||
| 1432 | 1470 | return | |
| 1433 | 1471 | } | |
| 1434 | 1472 | ||
| 1435 | - | activeTab := a.tabs.ActiveTab() | |
| 1473 | + | activeTab := a.activeTabForBrowserWindow(bw) | |
| 1436 | 1474 | if activeTab == nil || activeTab.Workspace == nil { | |
| 1437 | 1475 | return | |
| 1438 | 1476 | } | |
| @@ -1488,18 +1526,19 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error { | |||
| 1488 | 1526 | return nil | |
| 1489 | 1527 | } | |
| 1490 | 1528 | ||
| 1491 | - | sourceTab := a.tabs.ActiveTab() | |
| 1492 | - | if sourceTab == nil || sourceTab.Workspace == nil { | |
| 1529 | + | sourceTabs := a.tabListForBrowserWindow(bw) | |
| 1530 | + | sourceTab := a.activeTabForBrowserWindow(bw) | |
| 1531 | + | if sourceTabs == nil || sourceTab == nil || sourceTab.Workspace == nil { | |
| 1493 | 1532 | return nil | |
| 1494 | 1533 | } | |
| 1495 | 1534 | ||
| 1496 | 1535 | // If there is only 1 tab, auto-create a new tab and move there. | |
| 1497 | - | if a.tabs.Count() <= 1 { | |
| 1536 | + | if sourceTabs.Count() <= 1 { | |
| 1498 | 1537 | return a.MoveActivePaneToTab(ctx, "") | |
| 1499 | 1538 | } | |
| 1500 | 1539 | ||
| 1501 | - | items := make([]component.TabPickerItem, 0, a.tabs.Count()) | |
| 1502 | - | for _, tab := range a.tabs.Tabs { | |
| 1540 | + | items := make([]component.TabPickerItem, 0, sourceTabs.Count()) | |
| 1541 | + | for _, tab := range sourceTabs.Tabs { | |
| 1503 | 1542 | if tab == nil { | |
| 1504 | 1543 | continue | |
| 1505 | 1544 | } | |
| @@ -1524,17 +1563,19 @@ func (a *App) HandleMovePaneToNextTab(ctx context.Context) error { | |||
| 1524 | 1563 | if a.movePaneToTabUC == nil { | |
| 1525 | 1564 | return nil | |
| 1526 | 1565 | } | |
| 1527 | - | active := a.tabs.ActiveTab() | |
| 1528 | - | if active == nil { | |
| 1566 | + | bw := a.lastFocusedBrowserWindow() | |
| 1567 | + | sourceTabs := a.tabListForBrowserWindow(bw) | |
| 1568 | + | active := a.activeTabForBrowserWindow(bw) | |
| 1569 | + | if sourceTabs == nil || active == nil { | |
| 1529 | 1570 | return nil | |
| 1530 | 1571 | } | |
| 1531 | 1572 | ||
| 1532 | 1573 | nextPos := active.Position + 1 | |
| 1533 | - | if nextPos >= a.tabs.Count() { | |
| 1574 | + | if nextPos >= sourceTabs.Count() { | |
| 1534 | 1575 | // Create a new tab on the right. | |
| 1535 | 1576 | return a.MoveActivePaneToTab(ctx, "") | |
| 1536 | 1577 | } | |
| 1537 | - | next := a.tabs.TabAt(nextPos) | |
| 1578 | + | next := sourceTabs.TabAt(nextPos) | |
| 1538 | 1579 | if next == nil { | |
| 1539 | 1580 | return nil | |
| 1540 | 1581 | } | |
| @@ -1568,10 +1609,23 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID) | |||
| 1568 | 1609 | if out.NewTabCreated && targetWindow != nil { | |
| 1569 | 1610 | a.setBrowserWindowForTab(out.TargetTab.ID, targetWindow) | |
| 1570 | 1611 | } | |
| 1612 | + | a.syncCrossWindowMovePaneTabLists(out, sourceTab, sourceWindow, targetWindow) | |
| 1571 | 1613 | ||
| 1572 | 1614 | a.applyMovePaneToTabUI(ctx, out, sourceTab, sourceWindow, targetWindow) | |
| 1573 | 1615 | a.switchToTargetTabIfConfigured(ctx, out, targetWindow) | |
| 1574 | - | a.updateTabBarVisibility(ctx) | |
| 1616 | + | if a.tabCoord != nil { | |
| 1617 | + | seen := map[*browserWindow]struct{}{} | |
| 1618 | + | for _, bw := range []*browserWindow{sourceWindow, targetWindow} { | |
| 1619 | + | if bw == nil { | |
| 1620 | + | continue | |
| 1621 | + | } | |
| 1622 | + | if _, ok := seen[bw]; ok { | |
| 1623 | + | continue | |
| 1624 | + | } | |
| 1625 | + | seen[bw] = struct{}{} | |
| 1626 | + | a.tabCoord.UpdateBarVisibility(ctx, a.tabTargetForBrowserWindow(bw)) | |
| 1627 | + | } | |
| 1628 | + | } | |
| 1575 | 1629 | a.MarkDirty() | |
| 1576 | 1630 | return nil | |
| 1577 | 1631 | } | |
| @@ -1587,17 +1641,36 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa | |||
| 1587 | 1641 | if a.movePaneToTabUC == nil { | |
| 1588 | 1642 | return nil, nil | |
| 1589 | 1643 | } | |
| 1590 | - | activeTab := a.tabs.ActiveTab() | |
| 1591 | - | if activeTab == nil || activeTab.Workspace == nil { | |
| 1644 | + | sourceWindow := a.lastFocusedBrowserWindow() | |
| 1645 | + | tabList := a.tabListForBrowserWindow(sourceWindow) | |
| 1646 | + | activeTab := a.activeTabForBrowserWindow(sourceWindow) | |
| 1647 | + | if tabList == nil || activeTab == nil || activeTab.Workspace == nil { | |
| 1592 | 1648 | return nil, nil | |
| 1593 | 1649 | } | |
| 1650 | + | if targetTabID != "" { | |
| 1651 | + | if targetWindow := a.browserWindowForTab(targetTabID); targetWindow != nil && targetWindow != sourceWindow { | |
| 1652 | + | // Cross-window pane moves still pass a single derived tab list because | |
| 1653 | + | // MovePaneToTabInput accepts one TabList. Ensure the derived global | |
| 1654 | + | // mirror contains source and target tabs before passing a.tabs. | |
| 1655 | + | a.syncDerivedGlobalTabMirror(activeTab) | |
| 1656 | + | if targetWindowTabs := a.tabListForBrowserWindow(targetWindow); targetWindowTabs != nil { | |
| 1657 | + | if targetTab := targetWindowTabs.Find(targetTabID); targetTab != nil { | |
| 1658 | + | a.syncDerivedGlobalTabMirror(targetTab) | |
| 1659 | + | } | |
| 1660 | + | } | |
| 1661 | + | if a.tabs == nil { | |
| 1662 | + | return nil, nil | |
| 1663 | + | } | |
| 1664 | + | tabList = a.tabs | |
| 1665 | + | } | |
| 1666 | + | } | |
| 1594 | 1667 | sourcePaneID := activeTab.Workspace.ActivePaneID | |
| 1595 | 1668 | if sourcePaneID == "" { | |
| 1596 | 1669 | return nil, nil | |
| 1597 | 1670 | } | |
| 1598 | 1671 | ||
| 1599 | 1672 | in := &usecase.MovePaneToTabInput{ | |
| 1600 | - | TabList: a.tabs, | |
| 1673 | + | TabList: tabList, | |
| 1601 | 1674 | SourceTabID: activeTab.ID, | |
| 1602 | 1675 | SourcePaneID: sourcePaneID, | |
| 1603 | 1676 | TargetTabID: targetTabID, | |
| @@ -1605,6 +1678,41 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa | |||
| 1605 | 1678 | return in, activeTab | |
| 1606 | 1679 | } | |
| 1607 | 1680 | ||
| 1681 | + | func (a *App) syncCrossWindowMovePaneTabLists( | |
| 1682 | + | out *usecase.MovePaneToTabOutput, | |
| 1683 | + | sourceTab *entity.Tab, | |
| 1684 | + | sourceWindow *browserWindow, | |
| 1685 | + | targetWindow *browserWindow, | |
| 1686 | + | ) { | |
| 1687 | + | if out == nil || out.TargetTab == nil || sourceWindow == nil || targetWindow == nil { | |
| 1688 | + | return | |
| 1689 | + | } | |
| 1690 | + | ||
| 1691 | + | if out.NewTabCreated { | |
| 1692 | + | targetTabs := a.ensureTabListForBrowserWindow(targetWindow) | |
| 1693 | + | if targetTabs != nil && targetTabs.Find(out.TargetTab.ID) == nil { | |
| 1694 | + | targetTabs.Add(out.TargetTab) | |
| 1695 | + | } | |
| 1696 | + | } | |
| 1697 | + | ||
| 1698 | + | if sourceWindow == targetWindow { | |
| 1699 | + | return | |
| 1700 | + | } | |
| 1701 | + | ||
| 1702 | + | if out.SourceTabClosed && sourceTab != nil { | |
| 1703 | + | if sourceTabs := a.tabListForBrowserWindow(sourceWindow); sourceTabs != nil { | |
| 1704 | + | sourceTabs.Remove(sourceTab.ID) | |
| 1705 | + | } | |
| 1706 | + | } | |
| 1707 | + | ||
| 1708 | + | if owner := a.browserWindowForTab(out.TargetTab.ID); owner == targetWindow { | |
| 1709 | + | targetTabs := a.ensureTabListForBrowserWindow(targetWindow) | |
| 1710 | + | if targetTabs != nil && targetTabs.Find(out.TargetTab.ID) == nil { | |
| 1711 | + | targetTabs.Add(out.TargetTab) | |
| 1712 | + | } | |
| 1713 | + | } | |
| 1714 | + | } | |
| 1715 | + | ||
| 1608 | 1716 | func (a *App) applyMovePaneToTabUI( | |
| 1609 | 1717 | ctx context.Context, | |
| 1610 | 1718 | out *usecase.MovePaneToTabOutput, | |
| @@ -1639,6 +1747,11 @@ func (a *App) removeSourceTabUI(sourceTabID entity.TabID, sourceWindow *browserW | |||
| 1639 | 1747 | if tabBar := a.tabBarForBrowserWindow(sourceWindow); tabBar != nil { | |
| 1640 | 1748 | tabBar.RemoveTab(sourceTabID) | |
| 1641 | 1749 | } | |
| 1750 | + | // Remove the tab from the derived global tab mirror after the per-window | |
| 1751 | + | // TabList is modified. | |
| 1752 | + | if a.tabs != nil { | |
| 1753 | + | a.tabs.Remove(sourceTabID) | |
| 1754 | + | } | |
| 1642 | 1755 | } | |
| 1643 | 1756 | ||
| 1644 | 1757 | func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWindow *browserWindow) { | |
| @@ -1714,9 +1827,16 @@ func (a *App) switchToTargetTabIfConfigured( | |||
| 1714 | 1827 | return | |
| 1715 | 1828 | } | |
| 1716 | 1829 | ||
| 1717 | - | a.tabs.SetActive(out.TargetTab.ID) | |
| 1830 | + | if targetWindow == nil { | |
| 1831 | + | targetWindow = a.lastFocusedBrowserWindow() | |
| 1832 | + | } | |
| 1718 | 1833 | if targetWindow != nil { | |
| 1719 | - | targetWindow.activeTabID = out.TargetTab.ID | |
| 1834 | + | a.ensureTabListForBrowserWindow(targetWindow).SetActive(out.TargetTab.ID) | |
| 1835 | + | } | |
| 1836 | + | // Sync derived global active state after the target window is resolved | |
| 1837 | + | // and the per-window TabList has been updated. | |
| 1838 | + | if a.tabs != nil && a.tabs.Find(out.TargetTab.ID) != nil { | |
| 1839 | + | a.tabs.SetActive(out.TargetTab.ID) | |
| 1720 | 1840 | } | |
| 1721 | 1841 | if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil { | |
| 1722 | 1842 | tabBar.SetActive(out.TargetTab.ID) | |
| @@ -1727,12 +1847,6 @@ func (a *App) switchToTargetTabIfConfigured( | |||
| 1727 | 1847 | a.switchWorkspaceView(ctx, out.TargetTab.ID) | |
| 1728 | 1848 | } | |
| 1729 | 1849 | ||
| 1730 | - | func (a *App) updateTabBarVisibility(ctx context.Context) { | |
| 1731 | - | if a.tabCoord != nil { | |
| 1732 | - | a.tabCoord.UpdateBarVisibility(ctx) | |
| 1733 | - | } | |
| 1734 | - | } | |
| 1735 | - | ||
| 1736 | 1850 | func (a *App) initSnapshotService(ctx context.Context) { | |
| 1737 | 1851 | log := logging.FromContext(ctx) | |
| 1738 | 1852 | ||
| @@ -1785,12 +1899,7 @@ func (a *App) initUpdateCoordinator(ctx context.Context) { | |||
| 1785 | 1899 | log.Debug().Msg("update coordinator initialized") | |
| 1786 | 1900 | } | |
| 1787 | 1901 | ||
| 1788 | - | // GetTabList implements port.TabListProvider. | |
| 1789 | - | func (a *App) GetTabList() *entity.TabList { | |
| 1790 | - | return a.tabs | |
| 1791 | - | } | |
| 1792 | - | ||
| 1793 | - | // GetSessionID implements port.TabListProvider. | |
| 1902 | + | // GetSessionID implements port.WindowStateProvider. | |
| 1794 | 1903 | func (a *App) GetSessionID() entity.SessionID { | |
| 1795 | 1904 | if a.deps == nil { | |
| 1796 | 1905 | return "" | |
| @@ -1798,6 +1907,532 @@ func (a *App) GetSessionID() entity.SessionID { | |||
| 1798 | 1907 | return a.deps.CurrentSessionID | |
| 1799 | 1908 | } | |
| 1800 | 1909 | ||
| 1910 | + | // GetWindowSnapshotState implements port.WindowStateProvider. | |
| 1911 | + | // Returns per-window tab lists and active window index from one main-thread capture. | |
| 1912 | + | func (a *App) GetWindowSnapshotState() ([]entity.WindowTabListState, int) { | |
| 1913 | + | var ( | |
| 1914 | + | browserTabs map[string]*entity.TabList | |
| 1915 | + | windowOrder []string | |
| 1916 | + | globalTabs *entity.TabList | |
| 1917 | + | tabOwners map[entity.TabID]string | |
| 1918 | + | lastFocusedWindowID string | |
| 1919 | + | ) | |
| 1920 | + | ||
| 1921 | + | capture := func() { | |
| 1922 | + | globalTabs = entity.NewTabList() | |
| 1923 | + | if a.tabs != nil { | |
| 1924 | + | globalTabs = a.tabs.Snapshot() | |
| 1925 | + | } | |
| 1926 | + | ||
| 1927 | + | browserTabs = make(map[string]*entity.TabList, len(a.browserWindows)) | |
| 1928 | + | for id, bw := range a.browserWindows { | |
| 1929 | + | if bw == nil { | |
| 1930 | + | continue | |
| 1931 | + | } | |
| 1932 | + | if bw.tabs != nil { | |
| 1933 | + | browserTabs[id] = bw.tabs.Snapshot() | |
| 1934 | + | } else { | |
| 1935 | + | browserTabs[id] = nil | |
| 1936 | + | } | |
| 1937 | + | } | |
| 1938 | + | ||
| 1939 | + | windowOrder = append([]string(nil), a.browserWindowOrder...) | |
| 1940 | + | tabOwners = make(map[entity.TabID]string, len(a.windowForTab)) | |
| 1941 | + | for tabID, bw := range a.windowForTab { | |
| 1942 | + | if bw != nil { | |
| 1943 | + | tabOwners[tabID] = bw.id | |
| 1944 | + | } | |
| 1945 | + | } | |
| 1946 | + | lastFocusedWindowID = a.lastFocusedWindowID | |
| 1947 | + | } | |
| 1948 | + | if a.dispatchOnMainThread != nil { | |
| 1949 | + | a.dispatchOnMainThread(capture) | |
| 1950 | + | } else { | |
| 1951 | + | // nil dispatchOnMainThread is for single-threaded tests only; | |
| 1952 | + | // production should marshal capture to GTK main thread. | |
| 1953 | + | capture() | |
| 1954 | + | } | |
| 1955 | + | ||
| 1956 | + | if len(browserTabs) == 0 { | |
| 1957 | + | return []entity.WindowTabListState{ | |
| 1958 | + | {WindowID: "", Tabs: globalTabs}, | |
| 1959 | + | }, 0 | |
| 1960 | + | } | |
| 1961 | + | ||
| 1962 | + | windowIDs := windowOrderFrom(browserTabs, windowOrder) | |
| 1963 | + | result := make([]entity.WindowTabListState, 0, len(windowIDs)) | |
| 1964 | + | activeWindowIndex := 0 | |
| 1965 | + | anyOwnedWindow := false | |
| 1966 | + | for i, wid := range windowIDs { | |
| 1967 | + | if wid == lastFocusedWindowID { | |
| 1968 | + | activeWindowIndex = i | |
| 1969 | + | } | |
| 1970 | + | ||
| 1971 | + | tabs := browserTabs[wid] | |
| 1972 | + | if tabs == nil { | |
| 1973 | + | tabs = entity.NewTabList() | |
| 1974 | + | for _, tab := range globalTabs.Tabs { | |
| 1975 | + | if tabOwners[tab.ID] == wid { | |
| 1976 | + | tabs.Add(cloneTabForGlobalList(tab)) | |
| 1977 | + | } | |
| 1978 | + | } | |
| 1979 | + | ||
| 1980 | + | // Derive active tab from the global list when no per-window TabList | |
| 1981 | + | // exists only for snapshot/export compatibility with older flat snapshot state. | |
| 1982 | + | globalActive := globalTabs.ActiveTabID | |
| 1983 | + | if globalActive != "" && tabs.Find(globalActive) != nil { | |
| 1984 | + | tabs.SetActive(globalActive) | |
| 1985 | + | } | |
| 1986 | + | } | |
| 1987 | + | if tabs.Count() > 0 { | |
| 1988 | + | anyOwnedWindow = true | |
| 1989 | + | } | |
| 1990 | + | ||
| 1991 | + | result = append(result, entity.WindowTabListState{ | |
| 1992 | + | WindowID: entity.WindowID(wid), | |
| 1993 | + | Tabs: tabs, | |
| 1994 | + | }) | |
| 1995 | + | } | |
| 1996 | + | if !anyOwnedWindow && globalTabs.Count() > 0 { | |
| 1997 | + | // No window ownership populated; return the flat global list for | |
| 1998 | + | // snapshot/export compatibility rather than snapshotting empty windows. | |
| 1999 | + | return []entity.WindowTabListState{{WindowID: "", Tabs: globalTabs}}, 0 | |
| 2000 | + | } | |
| 2001 | + | ||
| 2002 | + | return result, activeWindowIndex | |
| 2003 | + | } | |
| 2004 | + | ||
| 2005 | + | // GetWindowTabLists implements port.WindowStateProvider. | |
| 2006 | + | // Returns per-window tab lists in browser window registration order. | |
| 2007 | + | // Falls back to a single window with the shared tab list when no browser windows exist. | |
| 2008 | + | func (a *App) GetWindowTabLists() []entity.WindowTabListState { | |
| 2009 | + | windows, _ := a.GetWindowSnapshotState() | |
| 2010 | + | return windows | |
| 2011 | + | } | |
| 2012 | + | ||
| 2013 | + | // GetActiveWindowIndex implements port.WindowStateProvider. | |
| 2014 | + | // Returns the index of the last focused window in registration order. | |
| 2015 | + | func (a *App) GetActiveWindowIndex() int { | |
| 2016 | + | _, activeWindowIndex := a.GetWindowSnapshotState() | |
| 2017 | + | return activeWindowIndex | |
| 2018 | + | } | |
| 2019 | + | ||
| 2020 | + | // tabListForBrowserWindow returns the per-window TabList for a browser window. | |
| 2021 | + | // Returns nil when bw is nil or bw.tabs has not been initialized. | |
| 2022 | + | func (a *App) tabListForBrowserWindow(bw *browserWindow) *entity.TabList { | |
| 2023 | + | if bw == nil { | |
| 2024 | + | return nil | |
| 2025 | + | } | |
| 2026 | + | return bw.tabs | |
| 2027 | + | } | |
| 2028 | + | ||
| 2029 | + | // ensureTabListForBrowserWindow returns the per-window TabList for a browser window, | |
| 2030 | + | // lazy-initializing bw.tabs when nil. Returns nil when bw is nil. | |
| 2031 | + | func (a *App) ensureTabListForBrowserWindow(bw *browserWindow) *entity.TabList { | |
| 2032 | + | if bw == nil { | |
| 2033 | + | return nil | |
| 2034 | + | } | |
| 2035 | + | if bw.tabs == nil { | |
| 2036 | + | bw.tabs = entity.NewTabList() | |
| 2037 | + | } | |
| 2038 | + | return bw.tabs | |
| 2039 | + | } | |
| 2040 | + | ||
| 2041 | + | // activeTabForBrowserWindow returns the active tab for a browser window. | |
| 2042 | + | // Returns nil when bw is nil or the window has no tabs. | |
| 2043 | + | func (a *App) activeTabForBrowserWindow(bw *browserWindow) *entity.Tab { | |
| 2044 | + | tabs := a.tabListForBrowserWindow(bw) | |
| 2045 | + | if tabs == nil { | |
| 2046 | + | return nil | |
| 2047 | + | } | |
| 2048 | + | return tabs.ActiveTab() | |
| 2049 | + | } | |
| 2050 | + | ||
| 2051 | + | // activeWorkspaceForBrowserWindow returns the active workspace for a browser window. | |
| 2052 | + | // Returns nil when bw is nil or the active tab has no workspace. | |
| 2053 | + | func (a *App) activeWorkspaceForBrowserWindow(bw *browserWindow) *entity.Workspace { | |
| 2054 | + | tab := a.activeTabForBrowserWindow(bw) | |
| 2055 | + | if tab == nil { | |
| 2056 | + | return nil | |
| 2057 | + | } | |
| 2058 | + | return tab.Workspace | |
| 2059 | + | } | |
| 2060 | + | ||
| 2061 | + | func (a *App) activeWorkspaceViewForBrowserWindow(bw *browserWindow) *component.WorkspaceView { | |
| 2062 | + | tab := a.activeTabForBrowserWindow(bw) | |
| 2063 | + | if tab == nil { | |
| 2064 | + | return nil | |
| 2065 | + | } | |
| 2066 | + | return a.workspaceViews[tab.ID] | |
| 2067 | + | } | |
| 2068 | + | ||
| 2069 | + | // tabTargetForBrowserWindow returns a coordinator.TabTarget scoped to the given browser window. | |
| 2070 | + | // It does not allocate a TabList; callers that mutate tabs should use | |
| 2071 | + | // ensureTabTargetForBrowserWindow instead. | |
| 2072 | + | func (a *App) tabTargetForBrowserWindow(bw *browserWindow) coordinator.TabTarget { | |
| 2073 | + | if bw == nil { | |
| 2074 | + | return coordinator.TabTarget{} | |
| 2075 | + | } | |
| 2076 | + | return coordinator.TabTarget{ | |
| 2077 | + | Tabs: a.tabListForBrowserWindow(bw), | |
| 2078 | + | MainWindow: bw.mainWindow, | |
| 2079 | + | } | |
| 2080 | + | } | |
| 2081 | + | ||
| 2082 | + | func (a *App) ensureTabTargetForBrowserWindow(bw *browserWindow) coordinator.TabTarget { | |
| 2083 | + | if bw == nil { | |
| 2084 | + | return coordinator.TabTarget{} | |
| 2085 | + | } | |
| 2086 | + | return coordinator.TabTarget{ | |
| 2087 | + | Tabs: a.ensureTabListForBrowserWindow(bw), | |
| 2088 | + | MainWindow: bw.mainWindow, | |
| 2089 | + | } | |
| 2090 | + | } | |
| 2091 | + | ||
| 2092 | + | // activePaneIDForBrowserWindow resolves the active pane ID for a browser window. | |
| 2093 | + | // A content-coordinator override wins so floating panes can receive navigation. | |
| 2094 | + | // Returns the empty string when bw is nil or the active tab has no workspace. | |
| 2095 | + | func (a *App) activePaneIDForBrowserWindow(bw *browserWindow) entity.PaneID { | |
| 2096 | + | if a.contentCoord != nil && bw != nil { | |
| 2097 | + | if paneID, ok := a.contentCoord.ActivePaneOverrideID(); ok && a.browserWindowForAnyPane(paneID) == bw { | |
| 2098 | + | return paneID | |
| 2099 | + | } | |
| 2100 | + | } | |
| 2101 | + | ws := a.activeWorkspaceForBrowserWindow(bw) | |
| 2102 | + | if ws == nil { | |
| 2103 | + | return "" | |
| 2104 | + | } | |
| 2105 | + | return ws.ActivePaneID | |
| 2106 | + | } | |
| 2107 | + | ||
| 2108 | + | // activeWebViewForBrowserWindow resolves the active WebView for a browser window. | |
| 2109 | + | // Uses the active pane ID and contentCoord.GetWebView (public getter) — never | |
| 2110 | + | // contentCoord.ActiveWebView — so that stale global focus cannot be picked. | |
| 2111 | + | func (a *App) activeWebViewForBrowserWindow(bw *browserWindow) (entity.PaneID, port.WebView) { | |
| 2112 | + | paneID := a.activePaneIDForBrowserWindow(bw) | |
| 2113 | + | if paneID == "" { | |
| 2114 | + | return "", nil | |
| 2115 | + | } | |
| 2116 | + | if a.contentCoord == nil { | |
| 2117 | + | return paneID, nil | |
| 2118 | + | } | |
| 2119 | + | return paneID, a.contentCoord.GetWebView(paneID) | |
| 2120 | + | } | |
| 2121 | + | ||
| 2122 | + | // omniboxNavigateForBrowserWindow returns an omnibox OnNavigate callback that routes | |
| 2123 | + | // navigation through navigate for the supplied browser window. The closure captures | |
| 2124 | + | // bw at creation time, so navigation targets the owning window even when another | |
| 2125 | + | // window is globally focused. | |
| 2126 | + | func omniboxNavigateForBrowserWindow( | |
| 2127 | + | ctx context.Context, | |
| 2128 | + | bw *browserWindow, | |
| 2129 | + | navigate func(context.Context, *browserWindow, string) error, | |
| 2130 | + | ) func(string) { | |
| 2131 | + | return func(url string) { | |
| 2132 | + | if err := navigate(ctx, bw, url); err != nil { | |
| 2133 | + | logging.FromContext(ctx).Error(). | |
| 2134 | + | Err(err). | |
| 2135 | + | Str("url", url). | |
| 2136 | + | Str("window_id", bw.id). | |
| 2137 | + | Msg("omnibox navigation failed") | |
| 2138 | + | } | |
| 2139 | + | } | |
| 2140 | + | } | |
| 2141 | + | ||
| 2142 | + | // navigateFromBrowserWindow loads a URL in the active pane of the given browser window. | |
| 2143 | + | // Uses NavigationCoordinator.NavigateWebView for explicit target resolution. | |
| 2144 | + | func (a *App) navigateFromBrowserWindow(ctx context.Context, bw *browserWindow, rawURL string) error { | |
| 2145 | + | if a.navCoord == nil { | |
| 2146 | + | return fmt.Errorf("navigation coordinator not initialized") | |
| 2147 | + | } | |
| 2148 | + | paneID, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2149 | + | return a.navCoord.NavigateWebView(ctx, rawURL, paneID, wv) | |
| 2150 | + | } | |
| 2151 | + | ||
| 2152 | + | // withBrowserWindowWebView resolves the active WebView for bw, checks that navCoord | |
| 2153 | + | // is initialized and wv is not nil, then runs fn with the WebView. Avoids nil bw | |
| 2154 | + | // panic in error formatting by falling back to empty window id. | |
| 2155 | + | func (a *App) withBrowserWindowWebView(_ context.Context, bw *browserWindow, fn func(wv port.WebView) error) error { | |
| 2156 | + | if a.navCoord == nil { | |
| 2157 | + | return fmt.Errorf("navigation coordinator not initialized") | |
| 2158 | + | } | |
| 2159 | + | _, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2160 | + | if wv == nil { | |
| 2161 | + | windowID := "" | |
| 2162 | + | if bw != nil { | |
| 2163 | + | windowID = bw.id | |
| 2164 | + | } | |
| 2165 | + | return fmt.Errorf("no active webview for browser window %s", windowID) | |
| 2166 | + | } | |
| 2167 | + | return fn(wv) | |
| 2168 | + | } | |
| 2169 | + | ||
| 2170 | + | // reloadBrowserWindow reloads the active page in the given browser window. | |
| 2171 | + | // When bypassCache is true, bypasses the cache (hard reload). | |
| 2172 | + | func (a *App) reloadBrowserWindow(ctx context.Context, bw *browserWindow, bypassCache bool) error { | |
| 2173 | + | return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error { | |
| 2174 | + | return a.navCoord.ReloadWebView(ctx, wv, bypassCache) | |
| 2175 | + | }) | |
| 2176 | + | } | |
| 2177 | + | ||
| 2178 | + | // stopBrowserWindow stops loading in the active pane of the given browser window. | |
| 2179 | + | func (a *App) stopBrowserWindow(ctx context.Context, bw *browserWindow) error { | |
| 2180 | + | return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error { | |
| 2181 | + | return a.navCoord.StopWebView(ctx, wv) | |
| 2182 | + | }) | |
| 2183 | + | } | |
| 2184 | + | ||
| 2185 | + | // goBackBrowserWindow navigates back in history for the active pane of the given browser window. | |
| 2186 | + | func (a *App) goBackBrowserWindow(ctx context.Context, bw *browserWindow) error { | |
| 2187 | + | return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error { | |
| 2188 | + | return a.navCoord.GoBackWebView(ctx, wv) | |
| 2189 | + | }) | |
| 2190 | + | } | |
| 2191 | + | ||
| 2192 | + | // goForwardBrowserWindow navigates forward in history for the active pane of the given browser window. | |
| 2193 | + | func (a *App) goForwardBrowserWindow(ctx context.Context, bw *browserWindow) error { | |
| 2194 | + | return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error { | |
| 2195 | + | return a.navCoord.GoForwardWebView(ctx, wv) | |
| 2196 | + | }) | |
| 2197 | + | } | |
| 2198 | + | ||
| 2199 | + | func (a *App) printBrowserWindow(ctx context.Context, bw *browserWindow) error { | |
| 2200 | + | return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error { | |
| 2201 | + | return a.navCoord.PrintWebView(ctx, wv) | |
| 2202 | + | }) | |
| 2203 | + | } | |
| 2204 | + | ||
| 2205 | + | func (a *App) openDevToolsBrowserWindow(ctx context.Context, bw *browserWindow) error { | |
| 2206 | + | return a.withBrowserWindowWebView(ctx, bw, func(wv port.WebView) error { | |
| 2207 | + | return a.navCoord.OpenDevToolsWebView(ctx, wv) | |
| 2208 | + | }) | |
| 2209 | + | } | |
| 2210 | + | ||
| 2211 | + | func (a *App) zoomBrowserWindow(ctx context.Context, bw *browserWindow, action string) error { | |
| 2212 | + | if a.deps == nil || a.deps.ZoomUC == nil { | |
| 2213 | + | logging.FromContext(ctx).Warn().Msg("zoom use case not available") | |
| 2214 | + | return nil | |
| 2215 | + | } | |
| 2216 | + | ||
| 2217 | + | paneID, wv := a.activeWebViewForBrowserWindow(bw) | |
| 2218 | + | if wv == nil { | |
| 2219 | + | logging.FromContext(ctx).Debug().Str("action", action).Msg("no active webview for zoom") | |
| 2220 | + | return nil | |
| 2221 | + | } | |
| 2222 | + | ||
| 2223 | + | domain, err := usecase.ExtractDomain(wv.URI()) | |
| 2224 | + | if err != nil { | |
| 2225 | + | logging.FromContext(ctx).Debug().Str("uri", wv.URI()).Msg("cannot extract domain for zoom") | |
| 2226 | + | return nil | |
| 2227 | + | } | |
| 2228 | + | ||
| 2229 | + | current := wv.GetZoomLevel() | |
| 2230 | + | var newZoom *entity.ZoomLevel | |
| 2231 | + | switch action { | |
| 2232 | + | case "in": | |
| 2233 | + | newZoom, err = a.deps.ZoomUC.ZoomIn(ctx, domain, current) | |
| 2234 | + | case "out": | |
| 2235 | + | newZoom, err = a.deps.ZoomUC.ZoomOut(ctx, domain, current) | |
| 2236 | + | case "reset": | |
| 2237 | + | err = a.deps.ZoomUC.ResetZoom(ctx, domain) | |
| 2238 | + | if err == nil { | |
| 2239 | + | newZoom = entity.NewZoomLevel(domain, a.deps.ZoomUC.DefaultZoom()) | |
| 2240 | + | } | |
| 2241 | + | default: | |
| 2242 | + | return fmt.Errorf("unknown zoom action %q", action) | |
| 2243 | + | } | |
| 2244 | + | if err != nil { | |
| 2245 | + | return err | |
| 2246 | + | } | |
| 2247 | + | if newZoom == nil { | |
| 2248 | + | return nil | |
| 2249 | + | } | |
| 2250 | + | if err := wv.SetZoomLevel(ctx, newZoom.ZoomFactor); err != nil { | |
| 2251 | + | return err | |
| 2252 | + | } | |
| 2253 | + | if a.navCoord != nil { | |
| 2254 | + | a.navCoord.NotifyZoomChanged(ctx, newZoom.ZoomFactor) | |
| 2255 | + | } | |
| 2256 | + | if wsView := a.activeWorkspaceViewForBrowserWindow(bw); wsView != nil { | |
| 2257 | + | if paneView := wsView.GetPaneView(paneID); paneView != nil { | |
| 2258 | + | paneView.ShowZoomToast(ctx, int(newZoom.ZoomFactor*100)) | |
| 2259 | + | } | |
| 2260 | + | } | |
| 2261 | + | return nil | |
| 2262 | + | } | |
| 2263 | + | ||
| 2264 | + | const ( | |
| 2265 | + | tabSwitchIndex0 = iota // 0 | |
| 2266 | + | tabSwitchIndex1 // 1 | |
| 2267 | + | tabSwitchIndex2 // 2 | |
| 2268 | + | tabSwitchIndex3 // 3 | |
| 2269 | + | tabSwitchIndex4 // 4 | |
| 2270 | + | tabSwitchIndex5 // 5 | |
| 2271 | + | tabSwitchIndex6 // 6 | |
| 2272 | + | tabSwitchIndex7 // 7 | |
| 2273 | + | tabSwitchIndex8 // 8 | |
| 2274 | + | tabSwitchIndex9 // 9 | |
| 2275 | + | ) | |
| 2276 | + | ||
| 2277 | + | // switchTabIndexActionIndex maps ActionSwitchTabIndex1..10 to 0..9. | |
| 2278 | + | func switchTabIndexActionIndex(action input.Action) (int, bool) { | |
| 2279 | + | switch action { | |
| 2280 | + | case input.ActionSwitchTabIndex1: | |
| 2281 | + | return tabSwitchIndex0, true | |
| 2282 | + | case input.ActionSwitchTabIndex2: | |
| 2283 | + | return tabSwitchIndex1, true | |
| 2284 | + | case input.ActionSwitchTabIndex3: | |
| 2285 | + | return tabSwitchIndex2, true | |
| 2286 | + | case input.ActionSwitchTabIndex4: | |
| 2287 | + | return tabSwitchIndex3, true | |
| 2288 | + | case input.ActionSwitchTabIndex5: | |
| 2289 | + | return tabSwitchIndex4, true | |
| 2290 | + | case input.ActionSwitchTabIndex6: | |
| 2291 | + | return tabSwitchIndex5, true | |
| 2292 | + | case input.ActionSwitchTabIndex7: | |
| 2293 | + | return tabSwitchIndex6, true | |
| 2294 | + | case input.ActionSwitchTabIndex8: | |
| 2295 | + | return tabSwitchIndex7, true | |
| 2296 | + | case input.ActionSwitchTabIndex9: | |
| 2297 | + | return tabSwitchIndex8, true | |
| 2298 | + | case input.ActionSwitchTabIndex10: | |
| 2299 | + | return tabSwitchIndex9, true | |
| 2300 | + | default: | |
| 2301 | + | return 0, false | |
| 2302 | + | } | |
| 2303 | + | } | |
| 2304 | + | ||
| 2305 | + | // dispatchBrowserWindowAction routes keyboard/global shortcut actions through the | |
| 2306 | + | // source browser window helpers instead of falling back to the global active | |
| 2307 | + | // webview/tab. Browser-window-owning callbacks (keyboardHandler, globalShortcutHandler) | |
| 2308 | + | // must call this instead of activating the window then dispatching globally. | |
| 2309 | + | func (a *App) dispatchBrowserWindowAction(ctx context.Context, bw *browserWindow, action input.Action) error { | |
| 2310 | + | switch action { | |
| 2311 | + | case input.ActionReload: | |
| 2312 | + | return a.reloadBrowserWindow(ctx, bw, false) | |
| 2313 | + | case input.ActionHardReload: | |
| 2314 | + | return a.reloadBrowserWindow(ctx, bw, true) | |
| 2315 | + | case input.ActionStop: | |
| 2316 | + | return a.stopBrowserWindow(ctx, bw) | |
| 2317 | + | case input.ActionGoBack: | |
| 2318 | + | return a.goBackBrowserWindow(ctx, bw) | |
| 2319 | + | case input.ActionGoForward: | |
| 2320 | + | return a.goForwardBrowserWindow(ctx, bw) | |
| 2321 | + | case input.ActionPrintPage: | |
| 2322 | + | return a.printBrowserWindow(ctx, bw) | |
| 2323 | + | case input.ActionOpenDevTools: | |
| 2324 | + | return a.openDevToolsBrowserWindow(ctx, bw) | |
| 2325 | + | case input.ActionZoomIn: | |
| 2326 | + | return a.zoomBrowserWindow(ctx, bw, "in") | |
| 2327 | + | case input.ActionZoomOut: | |
| 2328 | + | return a.zoomBrowserWindow(ctx, bw, "out") | |
| 2329 | + | case input.ActionZoomReset: | |
| 2330 | + | return a.zoomBrowserWindow(ctx, bw, "reset") | |
| 2331 | + | case input.ActionSwitchTabIndex1, input.ActionSwitchTabIndex2, input.ActionSwitchTabIndex3, | |
| 2332 | + | input.ActionSwitchTabIndex4, input.ActionSwitchTabIndex5, input.ActionSwitchTabIndex6, | |
| 2333 | + | input.ActionSwitchTabIndex7, input.ActionSwitchTabIndex8, input.ActionSwitchTabIndex9, | |
| 2334 | + | input.ActionSwitchTabIndex10: | |
| 2335 | + | index, ok := switchTabIndexActionIndex(action) | |
| 2336 | + | if !ok { | |
| 2337 | + | return nil | |
| 2338 | + | } | |
| 2339 | + | return a.switchBrowserWindowTabIndex(ctx, bw, index) | |
| 2340 | + | default: | |
| 2341 | + | if a.kbDispatcher != nil { | |
| 2342 | + | return a.kbDispatcher.Dispatch(ctx, action) | |
| 2343 | + | } | |
| 2344 | + | return nil | |
| 2345 | + | } | |
| 2346 | + | } | |
| 2347 | + | ||
| 2348 | + | // switchBrowserWindowTabIndex activates the given browser window and switches to | |
| 2349 | + | // the tab at the given 0-based index in that window, creating tabs as needed. | |
| 2350 | + | func (a *App) switchBrowserWindowTabIndex(ctx context.Context, bw *browserWindow, index int) error { | |
| 2351 | + | if a.tabCoord == nil { | |
| 2352 | + | logging.FromContext(ctx).Warn().Msg("switch tab index ignored: tab coordinator is nil") | |
| 2353 | + | return nil | |
| 2354 | + | } | |
| 2355 | + | if bw == nil { | |
| 2356 | + | logging.FromContext(ctx).Warn().Msg("switch tab index ignored: no browser window") | |
| 2357 | + | return nil | |
| 2358 | + | } | |
| 2359 | + | a.activateBrowserWindow(bw) | |
| 2360 | + | target := a.ensureTabTargetForBrowserWindow(bw) | |
| 2361 | + | initialURL := "" | |
| 2362 | + | if a.deps != nil && a.deps.Config != nil && a.deps.Config.Workspace.NewPaneURL != "" { | |
| 2363 | + | initialURL = urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL) | |
| 2364 | + | } | |
| 2365 | + | return a.tabCoord.SwitchByIndexCreating(ctx, target, index, initialURL) | |
| 2366 | + | } | |
| 2367 | + | ||
| 2368 | + | // windowOrder returns window IDs in registration order. | |
| 2369 | + | // Falls back to sorted map keys when no registration order is recorded | |
| 2370 | + | // (e.g., in old tests or direct map manipulation). | |
| 2371 | + | func (a *App) windowOrder() []string { | |
| 2372 | + | if len(a.browserWindowOrder) > 0 { | |
| 2373 | + | // Deduplicate against browserWindows, skipping stale entries. | |
| 2374 | + | seen := make(map[string]bool, len(a.browserWindowOrder)) | |
| 2375 | + | result := make([]string, 0, len(a.browserWindowOrder)) | |
| 2376 | + | for _, wid := range a.browserWindowOrder { | |
| 2377 | + | if seen[wid] || a.browserWindows[wid] == nil { | |
| 2378 | + | continue | |
| 2379 | + | } | |
| 2380 | + | seen[wid] = true | |
| 2381 | + | result = append(result, wid) | |
| 2382 | + | } | |
| 2383 | + | // Include any live browserWindows not yet tracked in order | |
| 2384 | + | // (inserted via direct map manipulation) deterministically. | |
| 2385 | + | untracked := make([]string, 0) | |
| 2386 | + | for id, bw := range a.browserWindows { | |
| 2387 | + | if bw != nil && !seen[id] { | |
| 2388 | + | untracked = append(untracked, id) | |
| 2389 | + | } | |
| 2390 | + | } | |
| 2391 | + | sort.Strings(untracked) | |
| 2392 | + | return append(result, untracked...) | |
| 2393 | + | } | |
| 2394 | + | ||
| 2395 | + | // Fallback: sort map keys for deterministic output. | |
| 2396 | + | windowIDs := make([]string, 0, len(a.browserWindows)) | |
| 2397 | + | for id := range a.browserWindows { | |
| 2398 | + | windowIDs = append(windowIDs, id) | |
| 2399 | + | } | |
| 2400 | + | sort.Strings(windowIDs) | |
| 2401 | + | return windowIDs | |
| 2402 | + | } | |
| 2403 | + | ||
| 2404 | + | func windowOrderFrom(browserTabs map[string]*entity.TabList, browserWindowOrder []string) []string { | |
| 2405 | + | if len(browserWindowOrder) > 0 { | |
| 2406 | + | seen := make(map[string]bool, len(browserWindowOrder)) | |
| 2407 | + | result := make([]string, 0, len(browserWindowOrder)) | |
| 2408 | + | for _, wid := range browserWindowOrder { | |
| 2409 | + | if seen[wid] { | |
| 2410 | + | continue | |
| 2411 | + | } | |
| 2412 | + | if _, ok := browserTabs[wid]; !ok { | |
| 2413 | + | continue | |
| 2414 | + | } | |
| 2415 | + | seen[wid] = true | |
| 2416 | + | result = append(result, wid) | |
| 2417 | + | } | |
| 2418 | + | untracked := make([]string, 0) | |
| 2419 | + | for id := range browserTabs { | |
| 2420 | + | if !seen[id] { | |
| 2421 | + | untracked = append(untracked, id) | |
| 2422 | + | } | |
| 2423 | + | } | |
| 2424 | + | sort.Strings(untracked) | |
| 2425 | + | return append(result, untracked...) | |
| 2426 | + | } | |
| 2427 | + | ||
| 2428 | + | windowIDs := make([]string, 0, len(browserTabs)) | |
| 2429 | + | for id := range browserTabs { | |
| 2430 | + | windowIDs = append(windowIDs, id) | |
| 2431 | + | } | |
| 2432 | + | sort.Strings(windowIDs) | |
| 2433 | + | return windowIDs | |
| 2434 | + | } | |
| 2435 | + | ||
| 1801 | 2436 | // MarkDirty signals that session state has changed and should be saved. | |
| 1802 | 2437 | func (a *App) MarkDirty() { | |
| 1803 | 2438 | if a.snapshotService != nil { | |
| @@ -1823,23 +2458,43 @@ func (a *App) createInitialTab(ctx context.Context) { | |||
| 1823 | 2458 | } | |
| 1824 | 2459 | ||
| 1825 | 2460 | // Create an initial tab using coordinator. | |
| 1826 | - | if _, err := a.tabCoord.Create(ctx, a.initialWindowURL()); err != nil { | |
| 2461 | + | target := a.ensureTabTargetForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 2462 | + | if _, err := a.tabCoord.Create(ctx, target, a.initialWindowURL()); err != nil { | |
| 1827 | 2463 | log.Error().Err(err).Msg("failed to create initial tab") | |
| 1828 | 2464 | } | |
| 1829 | 2465 | } | |
| 1830 | 2466 | ||
| 2467 | + | func (a *App) clearRuntimeWindowUI(runtimeWindows []*browserWindow) { | |
| 2468 | + | for _, bw := range runtimeWindows { | |
| 2469 | + | if bw == nil || bw.mainWindow == nil { | |
| 2470 | + | continue | |
| 2471 | + | } | |
| 2472 | + | if tabBar := bw.mainWindow.TabBar(); tabBar != nil { | |
| 2473 | + | if tabs := a.tabListForBrowserWindow(bw); tabs != nil { | |
| 2474 | + | for _, tab := range tabs.Tabs { | |
| 2475 | + | if tab != nil { | |
| 2476 | + | tabBar.RemoveTab(tab.ID) | |
| 2477 | + | } | |
| 2478 | + | } | |
| 2479 | + | } | |
| 2480 | + | } | |
| 2481 | + | bw.mainWindow.SetContent(nil) | |
| 2482 | + | } | |
| 2483 | + | } | |
| 2484 | + | ||
| 1831 | 2485 | func (a *App) clearSessionRestoreUIState(ctx context.Context) { | |
| 1832 | 2486 | if a == nil { | |
| 1833 | 2487 | return | |
| 1834 | 2488 | } | |
| 1835 | 2489 | ||
| 1836 | 2490 | tabIDs := make(map[entity.TabID]struct{}) | |
| 1837 | - | if a.tabs != nil { | |
| 1838 | - | for _, tab := range a.tabs.Tabs { | |
| 1839 | - | if tab == nil { | |
| 1840 | - | continue | |
| 2491 | + | for _, bw := range a.browserWindows { | |
| 2492 | + | if tabs := a.tabListForBrowserWindow(bw); tabs != nil { | |
| 2493 | + | for _, tab := range tabs.Tabs { | |
| 2494 | + | if tab != nil { | |
| 2495 | + | tabIDs[tab.ID] = struct{}{} | |
| 2496 | + | } | |
| 1841 | 2497 | } | |
| 1842 | - | tabIDs[tab.ID] = struct{}{} | |
| 1843 | 2498 | } | |
| 1844 | 2499 | } | |
| 1845 | 2500 | for tabID := range a.workspaceViews { | |
| @@ -1867,94 +2522,290 @@ func (a *App) clearSessionRestoreUIState(ctx context.Context) { | |||
| 1867 | 2522 | } | |
| 1868 | 2523 | ||
| 1869 | 2524 | func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error { | |
| 1870 | - | log := logging.FromContext(ctx) | |
| 2525 | + | state, restoredWindows, err := a.loadRestoredWindowStates(ctx, sessionID) | |
| 2526 | + | if err != nil { | |
| 2527 | + | return err | |
| 2528 | + | } | |
| 2529 | + | ||
| 2530 | + | logging.FromContext(ctx).Info(). | |
| 2531 | + | Int("windows", len(restoredWindows)). | |
| 2532 | + | Int("panes", state.CountPanes()). | |
| 2533 | + | Msg("restoring session state") | |
| 2534 | + | ||
| 2535 | + | if len(restoredWindows) == 0 { | |
| 2536 | + | if state.Version >= entity.SessionStateVersion { | |
| 2537 | + | // v2+ empty-window snapshots are valid: replace with a single empty window. | |
| 2538 | + | restoredWindows = []entity.WindowTabListState{{ | |
| 2539 | + | WindowID: "", | |
| 2540 | + | Tabs: entity.NewTabList(), | |
| 2541 | + | }} | |
| 2542 | + | } else { | |
| 2543 | + | return fmt.Errorf("restored session contains no windows") | |
| 2544 | + | } | |
| 2545 | + | } | |
| 2546 | + | ||
| 2547 | + | runtimeWindows, firstBWReused, err := a.prepareRuntimeWindows(ctx, len(restoredWindows)) | |
| 2548 | + | if err != nil { | |
| 2549 | + | return err | |
| 2550 | + | } | |
| 2551 | + | a.clearSessionRestoreUIState(ctx) | |
| 2552 | + | a.clearRuntimeWindowUI(runtimeWindows) | |
| 2553 | + | a.assignRuntimeWindowTabLists(runtimeWindows, restoredWindows, firstBWReused) | |
| 2554 | + | a.pruneStaleBrowserWindows(runtimeWindows) | |
| 1871 | 2555 | ||
| 2556 | + | activeBW := runtimeWindows[safeWindowIndex(state.ActiveWindowIndex, len(runtimeWindows))] | |
| 2557 | + | a.activateBrowserWindow(activeBW) | |
| 2558 | + | a.replaceGlobalTabsFromRuntimeWindows(runtimeWindows, activeBW) | |
| 2559 | + | a.buildRestoredWindowUI(ctx, runtimeWindows) | |
| 2560 | + | a.showRestoredRuntimeWindows(runtimeWindows) | |
| 2561 | + | ||
| 2562 | + | return nil | |
| 2563 | + | } | |
| 2564 | + | ||
| 2565 | + | func (a *App) loadRestoredWindowStates( | |
| 2566 | + | ctx context.Context, | |
| 2567 | + | sessionID entity.SessionID, | |
| 2568 | + | ) (*entity.SessionState, []entity.WindowTabListState, error) { | |
| 1872 | 2569 | if a.deps == nil || a.deps.SessionStateRepo == nil { | |
| 1873 | - | return fmt.Errorf("session state repo not available") | |
| 2570 | + | return nil, nil, fmt.Errorf("session state repo not available") | |
| 1874 | 2571 | } | |
| 1875 | 2572 | ||
| 1876 | - | // Load session state | |
| 1877 | 2573 | state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID) | |
| 1878 | 2574 | if err != nil { | |
| 1879 | - | return fmt.Errorf("load session state: %w", err) | |
| 2575 | + | return nil, nil, fmt.Errorf("load session state: %w", err) | |
| 1880 | 2576 | } | |
| 1881 | 2577 | if state == nil { | |
| 1882 | - | return fmt.Errorf("session state not found") | |
| 2578 | + | return nil, nil, fmt.Errorf("session state not found") | |
| 1883 | 2579 | } | |
| 1884 | 2580 | ||
| 1885 | - | log.Info().Int("tabs", len(state.Tabs)).Int("panes", state.CountPanes()).Msg("restoring session state") | |
| 2581 | + | restoredWindows := entity.WindowTabListsFromSnapshot(state, a.generateID) | |
| 2582 | + | return state, restoredWindows, nil | |
| 2583 | + | } | |
| 1886 | 2584 | ||
| 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") | |
| 2585 | + | func (a *App) prepareRuntimeWindows(ctx context.Context, windowCount int) ([]*browserWindow, bool, error) { | |
| 2586 | + | firstBW := a.browserWindowForMainWindow(a.mainWindow) | |
| 2587 | + | if firstBW == nil { | |
| 2588 | + | firstBW = a.lastFocusedBrowserWindow() | |
| 1892 | 2589 | } | |
| 2590 | + | firstBWReused := firstBW != nil | |
| 2591 | + | createdWindows := make([]*browserWindow, 0) | |
| 1893 | 2592 | ||
| 1894 | - | a.clearSessionRestoreUIState(ctx) | |
| 2593 | + | if firstBW == nil { | |
| 2594 | + | if a.mainWindow != nil { | |
| 2595 | + | firstBW = a.wrapExistingMainWindowForRestore(ctx, a.mainWindow) | |
| 2596 | + | } else { | |
| 2597 | + | created, err := a.createBrowserWindow(ctx, "") | |
| 2598 | + | if err != nil { | |
| 2599 | + | return nil, false, fmt.Errorf("create browser window for restore: %w", err) | |
| 2600 | + | } | |
| 2601 | + | firstBW = created | |
| 2602 | + | createdWindows = append(createdWindows, created) | |
| 2603 | + | } | |
| 2604 | + | } | |
| 1895 | 2605 | ||
| 1896 | - | // Replace tabs in-place to preserve references held by TabCoordinator | |
| 1897 | - | a.tabs.ReplaceFrom(restoredTabs) | |
| 2606 | + | runtimeWindows := make([]*browserWindow, windowCount) | |
| 2607 | + | runtimeWindows[0] = firstBW | |
| 2608 | + | for i := 1; i < windowCount; i++ { | |
| 2609 | + | extraBW, err := a.createBrowserWindow(ctx, "") | |
| 2610 | + | if err != nil { | |
| 2611 | + | a.cleanupCreatedBrowserWindows(createdWindows) | |
| 2612 | + | return nil, false, fmt.Errorf("create browser window %d for restore: %w", i, err) | |
| 2613 | + | } | |
| 2614 | + | createdWindows = append(createdWindows, extraBW) | |
| 2615 | + | runtimeWindows[i] = extraBW | |
| 2616 | + | } | |
| 2617 | + | ||
| 2618 | + | return runtimeWindows, firstBWReused, nil | |
| 2619 | + | } | |
| 1898 | 2620 | ||
| 1899 | - | a.assignRestoredTabsToCurrentWindow() | |
| 2621 | + | func (a *App) wrapExistingMainWindowForRestore(ctx context.Context, mainWindow *window.MainWindow) *browserWindow { | |
| 2622 | + | bw := &browserWindow{id: a.generateWindowID(), tabs: entity.NewTabList(), mainWindow: mainWindow} | |
| 2623 | + | a.wireBrowserWindowActivationTracking(bw) | |
| 2624 | + | if a.kbDispatcher != nil { | |
| 2625 | + | a.initBrowserWindowInput(ctx, bw) | |
| 2626 | + | } | |
| 2627 | + | if a.tabCoord != nil { | |
| 2628 | + | a.wireBrowserWindowTabBar(ctx, bw) | |
| 2629 | + | } | |
| 2630 | + | bw.initChrome(ctx, a) | |
| 2631 | + | return bw | |
| 2632 | + | } | |
| 1900 | 2633 | ||
| 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) | |
| 2634 | + | func (a *App) cleanupCreatedBrowserWindows(createdWindows []*browserWindow) { | |
| 2635 | + | for _, bw := range createdWindows { | |
| 2636 | + | if bw == nil { | |
| 2637 | + | continue | |
| 1909 | 2638 | } | |
| 1910 | - | if tabBar != nil { | |
| 1911 | - | tabBar.AddTab(tab) | |
| 2639 | + | mainWindow := bw.mainWindow | |
| 2640 | + | a.removeBrowserWindow(bw.id) | |
| 2641 | + | if mainWindow != nil { | |
| 2642 | + | if a.mainWindow == mainWindow { | |
| 2643 | + | a.mainWindow = nil | |
| 2644 | + | } | |
| 2645 | + | mainWindow.Destroy() | |
| 1912 | 2646 | } | |
| 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 | 2647 | } | |
| 2648 | + | } | |
| 1919 | 2649 | ||
| 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) | |
| 2650 | + | func (a *App) assignRuntimeWindowTabLists( | |
| 2651 | + | runtimeWindows []*browserWindow, | |
| 2652 | + | restoredWindows []entity.WindowTabListState, | |
| 2653 | + | firstBWReused bool, | |
| 2654 | + | ) { | |
| 2655 | + | for i, bw := range runtimeWindows { | |
| 2656 | + | if i > 0 || !firstBWReused { | |
| 2657 | + | a.registerBrowserWindow(bw) | |
| 1926 | 2658 | } | |
| 2659 | + | a.assignRestoredWindowTabList(bw, restoredWindows[i]) | |
| 1927 | 2660 | } | |
| 2661 | + | } | |
| 1928 | 2662 | ||
| 1929 | - | return nil | |
| 2663 | + | func (a *App) pruneStaleBrowserWindows(runtimeWindows []*browserWindow) { | |
| 2664 | + | runtimeSet := make(map[string]bool, len(runtimeWindows)) | |
| 2665 | + | for _, bw := range runtimeWindows { | |
| 2666 | + | runtimeSet[bw.id] = true | |
| 2667 | + | } | |
| 2668 | + | for id, bw := range a.browserWindows { | |
| 2669 | + | if !runtimeSet[id] { | |
| 2670 | + | if bw != nil && bw.mainWindow != nil { | |
| 2671 | + | bw.mainWindow.Destroy() | |
| 2672 | + | } | |
| 2673 | + | a.removeBrowserWindow(id) | |
| 2674 | + | } | |
| 2675 | + | } | |
| 2676 | + | ||
| 2677 | + | a.browserWindowOrder = make([]string, len(runtimeWindows)) | |
| 2678 | + | for i, bw := range runtimeWindows { | |
| 2679 | + | a.browserWindowOrder[i] = bw.id | |
| 2680 | + | } | |
| 1930 | 2681 | } | |
| 1931 | 2682 | ||
| 1932 | - | func (a *App) assignRestoredTabsToCurrentWindow() { | |
| 1933 | - | if a == nil || a.tabs == nil { | |
| 1934 | - | return | |
| 2683 | + | func safeWindowIndex(index, count int) int { | |
| 2684 | + | if index < 0 || index >= count { | |
| 2685 | + | return 0 | |
| 1935 | 2686 | } | |
| 2687 | + | return index | |
| 2688 | + | } | |
| 1936 | 2689 | ||
| 1937 | - | restoreWindow := a.browserWindowForMainWindow(a.mainWindow) | |
| 1938 | - | if restoreWindow == nil { | |
| 1939 | - | restoreWindow = a.lastFocusedBrowserWindow() | |
| 2690 | + | func (a *App) syncDerivedGlobalTabMirror(tab *entity.Tab) { | |
| 2691 | + | if a.tabs != nil && tab != nil && a.tabs.Find(tab.ID) == nil { | |
| 2692 | + | a.tabs.Add(cloneTabForGlobalList(tab)) | |
| 1940 | 2693 | } | |
| 1941 | - | if restoreWindow == nil { | |
| 1942 | - | return | |
| 2694 | + | } | |
| 2695 | + | ||
| 2696 | + | // workspace pointer is intentionally shared because App.tabs is a derived | |
| 2697 | + | // snapshot/export mirror, not runtime command target; runtime mutations must | |
| 2698 | + | // target browserWindow tabs. | |
| 2699 | + | func cloneTabForGlobalList(tab *entity.Tab) *entity.Tab { | |
| 2700 | + | if tab == nil { | |
| 2701 | + | return nil | |
| 2702 | + | } | |
| 2703 | + | return &entity.Tab{ | |
| 2704 | + | ID: tab.ID, | |
| 2705 | + | Name: tab.Name, | |
| 2706 | + | Workspace: tab.Workspace, | |
| 2707 | + | IsPinned: tab.IsPinned, | |
| 2708 | + | CreatedAt: tab.CreatedAt, | |
| 1943 | 2709 | } | |
| 2710 | + | } | |
| 1944 | 2711 | ||
| 1945 | - | for _, tab := range a.tabs.Tabs { | |
| 1946 | - | if tab != nil { | |
| 1947 | - | a.setBrowserWindowForTab(tab.ID, restoreWindow) | |
| 2712 | + | func (a *App) replaceGlobalTabsFromRuntimeWindows(runtimeWindows []*browserWindow, activeBW *browserWindow) { | |
| 2713 | + | globalTabs := entity.NewTabList() | |
| 2714 | + | for _, bw := range runtimeWindows { | |
| 2715 | + | perWinTabs := a.tabListForBrowserWindow(bw) | |
| 2716 | + | if perWinTabs == nil { | |
| 2717 | + | continue | |
| 2718 | + | } | |
| 2719 | + | for _, tab := range perWinTabs.Tabs { | |
| 2720 | + | if cloned := cloneTabForGlobalList(tab); cloned != nil { | |
| 2721 | + | globalTabs.Add(cloned) | |
| 2722 | + | } | |
| 2723 | + | } | |
| 2724 | + | } | |
| 2725 | + | ||
| 2726 | + | if activeTabs := a.tabListForBrowserWindow(activeBW); activeTabs != nil { | |
| 2727 | + | activeID := activeTabs.ActiveTabID | |
| 2728 | + | if activeID != "" && globalTabs.Find(activeID) != nil { | |
| 2729 | + | globalTabs.SetActive(activeID) | |
| 2730 | + | } | |
| 2731 | + | } | |
| 2732 | + | a.tabs.ReplaceFrom(globalTabs) | |
| 2733 | + | } | |
| 2734 | + | ||
| 2735 | + | func (a *App) showRestoredRuntimeWindows(runtimeWindows []*browserWindow) { | |
| 2736 | + | for _, bw := range runtimeWindows { | |
| 2737 | + | if bw != nil && bw.mainWindow != nil { | |
| 2738 | + | bw.mainWindow.Show() | |
| 1948 | 2739 | } | |
| 1949 | 2740 | } | |
| 2741 | + | } | |
| 2742 | + | ||
| 2743 | + | func (a *App) buildRestoredWindowUI(ctx context.Context, runtimeWindows []*browserWindow) { | |
| 2744 | + | for _, bw := range runtimeWindows { | |
| 2745 | + | perWinTabs := a.tabListForBrowserWindow(bw) | |
| 2746 | + | if perWinTabs == nil || perWinTabs.Count() == 0 { | |
| 2747 | + | a.updateBrowserWindowTabBarVisibility(bw) | |
| 2748 | + | continue | |
| 2749 | + | } | |
| 2750 | + | ||
| 2751 | + | tabBar := bw.mainWindow.TabBar() | |
| 2752 | + | activeTab := perWinTabs.ActiveTab() | |
| 2753 | + | for _, tab := range perWinTabs.Tabs { | |
| 2754 | + | a.buildRestoredTabUI(ctx, bw, tabBar, tab) | |
| 2755 | + | } | |
| 2756 | + | ||
| 2757 | + | if activeTab != nil { | |
| 2758 | + | a.switchWorkspaceView(ctx, activeTab.ID) | |
| 2759 | + | if tabBar != nil { | |
| 2760 | + | tabBar.SetActive(activeTab.ID) | |
| 2761 | + | } | |
| 2762 | + | } | |
| 2763 | + | a.updateBrowserWindowTabBarVisibility(bw) | |
| 2764 | + | } | |
| 2765 | + | } | |
| 2766 | + | ||
| 2767 | + | func (a *App) buildRestoredTabUI(ctx context.Context, bw *browserWindow, tabBar *component.TabBar, tab *entity.Tab) { | |
| 2768 | + | if tab == nil { | |
| 2769 | + | return | |
| 2770 | + | } | |
| 2771 | + | a.createWorkspaceViewWithoutAttach(ctx, tab) | |
| 2772 | + | wsView := a.workspaceViews[tab.ID] | |
| 2773 | + | if a.wsCoord != nil && wsView != nil { | |
| 2774 | + | a.wsCoord.SetupStackedPaneCallbacks(ctx, tab.Workspace, wsView) | |
| 2775 | + | } | |
| 2776 | + | if tabBar != nil { | |
| 2777 | + | tabBar.AddTab(tab) | |
| 2778 | + | } | |
| 2779 | + | logging.FromContext(ctx).Debug(). | |
| 2780 | + | Str("tab_id", string(tab.ID)). | |
| 2781 | + | Str("name", tab.Name). | |
| 2782 | + | Str("window_id", bw.id). | |
| 2783 | + | Int("panes", tab.Workspace.PaneCount()). | |
| 2784 | + | Msg("restored tab with workspace") | |
| 2785 | + | } | |
| 2786 | + | ||
| 2787 | + | // assignRestoredWindowTabList assigns a restored WindowTabListState to a runtime browser window. | |
| 2788 | + | // It sets bw.tabs to the restored TabList (or a new empty one if nil) and registers each | |
| 2789 | + | // tab's ownership via windowForTab. It never inserts saved window IDs into browserWindows; | |
| 2790 | + | // the runtime window ID is owned by the caller. | |
| 2791 | + | func (a *App) assignRestoredWindowTabList(bw *browserWindow, restored entity.WindowTabListState) { | |
| 2792 | + | if bw == nil { | |
| 2793 | + | return | |
| 2794 | + | } | |
| 2795 | + | ||
| 2796 | + | a.ensureWindowForTabMap() | |
| 1950 | 2797 | ||
| 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 | |
| 2798 | + | if restored.Tabs == nil { | |
| 2799 | + | bw.tabs = entity.NewTabList() | |
| 1955 | 2800 | return | |
| 1956 | 2801 | } | |
| 1957 | - | restoreWindow.prevActiveTabID = "" | |
| 2802 | + | ||
| 2803 | + | bw.tabs = restored.Tabs | |
| 2804 | + | for _, tab := range restored.Tabs.Tabs { | |
| 2805 | + | if tab != nil { | |
| 2806 | + | a.windowForTab[tab.ID] = bw | |
| 2807 | + | } | |
| 2808 | + | } | |
| 1958 | 2809 | } | |
| 1959 | 2810 | ||
| 1960 | 2811 | func (a *App) finalizeActivation(ctx context.Context) { | |
| @@ -2126,14 +2977,12 @@ func (a *App) initContentCoordinator( | |||
| 2126 | 2977 | func (a *App) initTabCoordinator(ctx context.Context) { | |
| 2127 | 2978 | a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{ | |
| 2128 | 2979 | TabsUC: a.tabsUC, | |
| 2129 | - | Tabs: a.tabs, | |
| 2130 | 2980 | MainWindow: a.mainWindow, | |
| 2131 | 2981 | HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab, | |
| 2132 | 2982 | }) | |
| 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) | |
| 2983 | + | a.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) { | |
| 2984 | + | // Assign ownership from the callback target, not focus/global state. | |
| 2985 | + | bw := a.browserWindowForTabTarget(target) | |
| 2137 | 2986 | if bw != nil { | |
| 2138 | 2987 | a.setBrowserWindowForTab(tab.ID, bw) | |
| 2139 | 2988 | } | |
| @@ -2147,59 +2996,38 @@ func (a *App) initTabCoordinator(ctx context.Context) { | |||
| 2147 | 2996 | } | |
| 2148 | 2997 | tab.Name = defaultTabName(count) | |
| 2149 | 2998 | } | |
| 2999 | + | // Keep the derived global App.tabs mirror populated from per-window | |
| 3000 | + | // tab creation for usecase and snapshot/export boundaries. | |
| 3001 | + | // This must happen AFTER default tab name assignment so the clone | |
| 3002 | + | // has the final window-scoped default name. | |
| 3003 | + | if a.tabs != nil && a.tabs.Find(tab.ID) == nil { | |
| 3004 | + | a.tabs.Add(cloneTabForGlobalList(tab)) | |
| 3005 | + | } | |
| 2150 | 3006 | a.createWorkspaceView(ctx, tab) | |
| 2151 | 3007 | }) | |
| 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 | |
| 3008 | + | a.tabCoord.SetOnTabSwitched(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) { | |
| 3009 | + | // TabList.SetActive (called by TabCoordinator.Switch → tabsUC.Switch) already | |
| 3010 | + | // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed. | |
| 3011 | + | if bw := a.browserWindowForTabTarget(target); bw != nil { | |
| 2161 | 3012 | a.activateBrowserWindow(bw) | |
| 2162 | 3013 | } | |
| 2163 | 3014 | a.switchWorkspaceView(ctx, tab.ID) | |
| 2164 | 3015 | }) | |
| 2165 | - | a.tabCoord.SetOnQuit(a.Quit) | |
| 2166 | 3016 | 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) | |
| 3017 | + | // Per-window tab ownership is now enforced through explicit TabTarget. | |
| 3018 | + | // The per-window TabList (bw.tabs) is the source of truth; no scope filtering needed. | |
| 3019 | + | a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, target coordinator.TabTarget) { | |
| 3020 | + | bw := a.browserWindowForTabTarget(target) | |
| 2197 | 3021 | if bw != nil { | |
| 2198 | 3022 | a.removeBrowserWindow(bw.id) | |
| 2199 | 3023 | if bw.mainWindow != nil { | |
| 2200 | 3024 | bw.mainWindow.Destroy() | |
| 2201 | 3025 | } | |
| 2202 | 3026 | } | |
| 3027 | + | // Quit the app only when all browser windows are gone. | |
| 3028 | + | if len(a.browserWindows) == 0 { | |
| 3029 | + | a.Quit() | |
| 3030 | + | } | |
| 2203 | 3031 | }) | |
| 2204 | 3032 | // Wire popup tab WebView attachment | |
| 2205 | 3033 | a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) { | |
| @@ -2216,7 +3044,11 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 2216 | 3044 | log := logging.FromContext(ctx) | |
| 2217 | 3045 | log.Debug().Msg("initializing coordinators") | |
| 2218 | 3046 | ||
| 2219 | - | // Helper to get active workspace and view | |
| 3047 | + | // Coordinators resolve the workspace and view through | |
| 3048 | + | // lastFocusedBrowserWindow via activeWorkspace helpers. | |
| 3049 | + | // Shared coordinator instances defer to window-scoped resolution; | |
| 3050 | + | // the startup edge case (no focused window yet) is handled inside | |
| 3051 | + | // activeWorkspace/activeWorkspaceView. | |
| 2220 | 3052 | getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) { | |
| 2221 | 3053 | return a.activeWorkspace(), a.activeWorkspaceView() | |
| 2222 | 3054 | } | |
| @@ -2252,7 +3084,8 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 2252 | 3084 | ResizeMinPanePercent: a.deps.Config.Workspace.ResizeMode.MinPanePercent, | |
| 2253 | 3085 | }) | |
| 2254 | 3086 | a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error { | |
| 2255 | - | return a.tabCoord.Close(ctx) | |
| 3087 | + | bw := a.lastFocusedBrowserWindow() | |
| 3088 | + | return a.tabCoord.Close(ctx, a.ensureTabTargetForBrowserWindow(bw)) | |
| 2256 | 3089 | }) | |
| 2257 | 3090 | a.wsCoord.SetOnStateChanged(a.MarkDirty) | |
| 2258 | 3091 | ||
| @@ -2273,16 +3106,8 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 2273 | 3106 | a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error { | |
| 2274 | 3107 | return a.wsCoord.ClosePaneByID(ctx, paneID) | |
| 2275 | 3108 | }) | |
| 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 | - | }) | |
| 3109 | + | // Wire tabbed popup behavior to create new tabs in the originating window. | |
| 3110 | + | a.wsCoord.SetOnCreatePopupTab(a.createPopupTab) | |
| 2286 | 3111 | ||
| 2287 | 3112 | // Move pane use case (cross-tab) | |
| 2288 | 3113 | a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID) | |
| @@ -2338,7 +3163,10 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 2338 | 3163 | }) | |
| 2339 | 3164 | } | |
| 2340 | 3165 | ||
| 2341 | - | // Set the WebView as the focused input for accent picker (if this is the active pane) | |
| 3166 | + | // This only updates focus when the shown pane belongs to the last-focused | |
| 3167 | + | // window's active workspace. activeWorkspace() delegates to | |
| 3168 | + | // lastFocusedBrowserWindow, so the accent-picker does not activate for | |
| 3169 | + | // background windows. | |
| 2342 | 3170 | ws := a.activeWorkspace() | |
| 2343 | 3171 | if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil { | |
| 2344 | 3172 | a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget()) | |
| @@ -2346,14 +3174,16 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 2346 | 3174 | }) | |
| 2347 | 3175 | ||
| 2348 | 3176 | // 5. Keyboard Dispatcher | |
| 3177 | + | // Dispatcher receives an app-level active pane provider (contentCoord.ActivePaneID) | |
| 3178 | + | // and per-window handlers route source-window actions through | |
| 3179 | + | // dispatchBrowserWindowAction for proper multi-window isolation. | |
| 2349 | 3180 | a.kbDispatcher = dispatcher.NewKeyboardDispatcher( | |
| 2350 | 3181 | ctx, | |
| 2351 | - | a.tabCoord, | |
| 2352 | 3182 | a.wsCoord, | |
| 2353 | 3183 | a.navCoord, | |
| 2354 | 3184 | a.deps.ZoomUC, | |
| 2355 | 3185 | a.deps.CopyURLUC, | |
| 2356 | - | a.deps.Config.Workspace.NewPaneURL, | |
| 3186 | + | a.keyboardActions(), | |
| 2357 | 3187 | a.contentCoord.ActivePaneID, | |
| 2358 | 3188 | ) | |
| 2359 | 3189 | a.wireKeyboardActions() | |
| @@ -2364,6 +3194,65 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 2364 | 3194 | log.Debug().Msg("coordinators initialized") | |
| 2365 | 3195 | } | |
| 2366 | 3196 | ||
| 3197 | + | func (a *App) keyboardActions() dispatcher.KeyboardActions { | |
| 3198 | + | return dispatcher.KeyboardActions{ | |
| 3199 | + | NewTab: func(ctx context.Context) error { | |
| 3200 | + | if a.deps == nil || a.deps.Config == nil || a.deps.Config.Workspace.NewPaneURL == "" { | |
| 3201 | + | logging.FromContext(ctx).Warn().Msg("new tab ignored: new pane URL is not configured") | |
| 3202 | + | return fmt.Errorf("newPaneURL is not configured") | |
| 3203 | + | } | |
| 3204 | + | return a.withFocusedTabTarget(ctx, "new tab", true, func(target coordinator.TabTarget) error { | |
| 3205 | + | _, err := a.tabCoord.Create(ctx, target, urlutil.Normalize(a.deps.Config.Workspace.NewPaneURL)) | |
| 3206 | + | return err | |
| 3207 | + | }) | |
| 3208 | + | }, | |
| 3209 | + | CloseTab: func(ctx context.Context) error { | |
| 3210 | + | return a.withFocusedTabTarget(ctx, "close tab", false, func(target coordinator.TabTarget) error { | |
| 3211 | + | return a.tabCoord.Close(ctx, target) | |
| 3212 | + | }) | |
| 3213 | + | }, | |
| 3214 | + | NextTab: func(ctx context.Context) error { | |
| 3215 | + | return a.withFocusedTabTarget(ctx, "next tab", false, func(target coordinator.TabTarget) error { | |
| 3216 | + | return a.tabCoord.SwitchNext(ctx, target) | |
| 3217 | + | }) | |
| 3218 | + | }, | |
| 3219 | + | PreviousTab: func(ctx context.Context) error { | |
| 3220 | + | return a.withFocusedTabTarget(ctx, "previous tab", false, func(target coordinator.TabTarget) error { | |
| 3221 | + | return a.tabCoord.SwitchPrev(ctx, target) | |
| 3222 | + | }) | |
| 3223 | + | }, | |
| 3224 | + | SwitchLastTab: func(ctx context.Context) error { | |
| 3225 | + | return a.withFocusedTabTarget(ctx, "switch last tab", false, func(target coordinator.TabTarget) error { | |
| 3226 | + | return a.tabCoord.SwitchToLastActive(ctx, target) | |
| 3227 | + | }) | |
| 3228 | + | }, | |
| 3229 | + | SwitchTabIndex: func(ctx context.Context, index int) error { | |
| 3230 | + | return a.switchBrowserWindowTabIndex(ctx, a.lastFocusedBrowserWindow(), index) | |
| 3231 | + | }, | |
| 3232 | + | ActiveWebView: func(context.Context) port.WebView { | |
| 3233 | + | _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 3234 | + | return wv | |
| 3235 | + | }, | |
| 3236 | + | } | |
| 3237 | + | } | |
| 3238 | + | ||
| 3239 | + | func (a *App) withFocusedTabTarget(ctx context.Context, action string, ensure bool, fn func(coordinator.TabTarget) error) error { | |
| 3240 | + | if a.tabCoord == nil { | |
| 3241 | + | logging.FromContext(ctx).Warn().Str("action", action).Msg("tab action ignored: tab coordinator is nil") | |
| 3242 | + | return nil | |
| 3243 | + | } | |
| 3244 | + | bw := a.lastFocusedBrowserWindow() | |
| 3245 | + | if bw == nil { | |
| 3246 | + | logging.FromContext(ctx).Warn().Str("action", action).Msg("tab action ignored: no focused browser window") | |
| 3247 | + | return nil | |
| 3248 | + | } | |
| 3249 | + | target := a.tabTargetForBrowserWindow(bw) | |
| 3250 | + | if ensure { | |
| 3251 | + | target = a.ensureTabTargetForBrowserWindow(bw) | |
| 3252 | + | } | |
| 3253 | + | return fn(target) | |
| 3254 | + | } | |
| 3255 | + | ||
| 2367 | 3256 | func (a *App) wireKeyboardActions() { | |
| 2368 | 3257 | a.kbDispatcher.SetOnQuit(a.Quit) | |
| 2369 | 3258 | a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error { | |
| @@ -2631,13 +3520,13 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) { | |||
| 2631 | 3520 | // updateWindowTitleFromActivePane updates the window title based on the current active pane. | |
| 2632 | 3521 | func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) { | |
| 2633 | 3522 | var ws *entity.Workspace | |
| 2634 | - | if a.tabs != nil { | |
| 2635 | - | if tab := a.tabs.Find(tabID); tab != nil { | |
| 3523 | + | bw := a.browserWindowForTab(tabID) | |
| 3524 | + | if tabs := a.tabListForBrowserWindow(bw); tabs != nil { | |
| 3525 | + | if tab := tabs.Find(tabID); tab != nil { | |
| 2636 | 3526 | ws = tab.Workspace | |
| 2637 | - | } else { | |
| 2638 | - | ws = a.activeWorkspace() | |
| 2639 | 3527 | } | |
| 2640 | - | } else { | |
| 3528 | + | } | |
| 3529 | + | if ws == nil { | |
| 2641 | 3530 | ws = a.activeWorkspace() | |
| 2642 | 3531 | } | |
| 2643 | 3532 | if ws == nil || a.contentCoord == nil { | |
| @@ -2657,6 +3546,7 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) { | |||
| 2657 | 3546 | a.clearResizeModeBorder() | |
| 2658 | 3547 | } | |
| 2659 | 3548 | if to == input.ModeResize { | |
| 3549 | + | // Resize mode targets the last-focused browser window's active workspace. | |
| 2660 | 3550 | a.applyResizeModeBorder(ctx, a.activeWorkspace()) | |
| 2661 | 3551 | } | |
| 2662 | 3552 | ||
| @@ -2722,6 +3612,8 @@ func (a *App) clearResizeModeBorder() { | |||
| 2722 | 3612 | } | |
| 2723 | 3613 | ||
| 2724 | 3614 | func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) { | |
| 3615 | + | // activeWorkspaceView resolves via lastFocusedBrowserWindow; resize mode | |
| 3616 | + | // always operates on the last-focused window. | |
| 2725 | 3617 | wsView := a.activeWorkspaceView() | |
| 2726 | 3618 | if wsView == nil || ws == nil { | |
| 2727 | 3619 | a.clearResizeModeBorder() | |
| @@ -2809,8 +3701,17 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 2809 | 3701 | // Note: Mode borders for tab/pane/session are attached to MainWindow. | |
| 2810 | 3702 | // Resize mode border is attached to the active pane's stack container. | |
| 2811 | 3703 | ||
| 2812 | - | // Set omnibox config for this workspace view | |
| 2813 | - | wsView.SetOmniboxConfig(a.omniboxCfg) | |
| 3704 | + | // Set omnibox config for this workspace view. | |
| 3705 | + | // When the owning browser window is known, bind navigation to the | |
| 3706 | + | // owning window so that the omnibox always navigates in the correct | |
| 3707 | + | // window, regardless of global focus. | |
| 3708 | + | if owner := a.browserWindowForTab(tab.ID); owner != nil { | |
| 3709 | + | cfg := a.omniboxCfg | |
| 3710 | + | cfg.OnNavigate = omniboxNavigateForBrowserWindow(ctx, owner, a.navigateFromBrowserWindow) | |
| 3711 | + | wsView.SetOmniboxConfig(cfg) | |
| 3712 | + | } else { | |
| 3713 | + | wsView.SetOmniboxConfig(a.omniboxCfg) | |
| 3714 | + | } | |
| 2814 | 3715 | // Set find bar config for this workspace view | |
| 2815 | 3716 | wsView.SetFindBarConfig(a.findBarCfg) | |
| 2816 | 3717 | // Set auto-open omnibox on new pane | |
| @@ -2820,6 +3721,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 2820 | 3721 | ||
| 2821 | 3722 | wsView.SetOnPaneFocused(func(paneID entity.PaneID) { | |
| 2822 | 3723 | if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize { | |
| 3724 | + | // Resize-mode pane focus tracks the last-focused window's workspace. | |
| 2823 | 3725 | ws := a.activeWorkspace() | |
| 2824 | 3726 | if ws != nil { | |
| 2825 | 3727 | ws.ActivePaneID = paneID | |
| @@ -2842,50 +3744,40 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity. | |||
| 2842 | 3744 | log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created") | |
| 2843 | 3745 | } | |
| 2844 | 3746 | ||
| 2845 | - | // activeWorkspace returns the workspace of the active tab. | |
| 3747 | + | // activeWorkspace returns the workspace of the focused browser window's active tab. | |
| 2846 | 3748 | 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 | |
| 3749 | + | return a.activeWorkspaceForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 2855 | 3750 | } | |
| 2856 | 3751 | ||
| 2857 | 3752 | // updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI. | |
| 2858 | 3753 | // This is necessary because panes in inactive tabs also need URI updates for session snapshots. | |
| 2859 | 3754 | func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) { | |
| 2860 | - | for _, tab := range a.tabs.Tabs { | |
| 2861 | - | if tab.Workspace == nil { | |
| 3755 | + | for _, bw := range a.browserWindows { | |
| 3756 | + | if bw == nil || bw.tabs == nil { | |
| 2862 | 3757 | continue | |
| 2863 | 3758 | } | |
| 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 | |
| 3759 | + | for _, tab := range bw.tabs.Tabs { | |
| 3760 | + | if tab == nil || tab.Workspace == nil { | |
| 3761 | + | continue | |
| 3762 | + | } | |
| 3763 | + | paneNode := tab.Workspace.FindPane(paneID) | |
| 3764 | + | if paneNode != nil && paneNode.Pane != nil { | |
| 3765 | + | paneNode.Pane.URI = url | |
| 3766 | + | return // Pane IDs are unique, no need to continue | |
| 3767 | + | } | |
| 2868 | 3768 | } | |
| 2869 | 3769 | } | |
| 2870 | 3770 | } | |
| 2871 | 3771 | ||
| 2872 | - | // activeWorkspaceView returns the workspace view for the active tab. | |
| 3772 | + | // activeWorkspaceView returns the workspace view for the focused browser window's active tab. | |
| 2873 | 3773 | 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] | |
| 3774 | + | return a.activeWorkspaceViewForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 2879 | 3775 | } | |
| 2880 | 3776 | ||
| 2881 | 3777 | // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView. | |
| 2882 | 3778 | // Used by the accent picker to insert accented characters into web content. | |
| 2883 | 3779 | func (a *App) getActiveWebViewTarget() port.TextInputTarget { | |
| 2884 | - | if a.contentCoord == nil { | |
| 2885 | - | return nil | |
| 2886 | - | } | |
| 2887 | - | ||
| 2888 | - | wv := a.contentCoord.ActiveWebView(context.Background()) | |
| 3780 | + | _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 2889 | 3781 | if wv == nil { | |
| 2890 | 3782 | return nil | |
| 2891 | 3783 | } | |
| @@ -2952,9 +3844,9 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) { | |||
| 2952 | 3844 | return | |
| 2953 | 3845 | } | |
| 2954 | 3846 | ||
| 2955 | - | // Swap content (MainWindow.SetContent now properly removes old content) | |
| 3847 | + | // Swap content (MainWindow.SetContent now properly removes old content). | |
| 3848 | + | // Active tab state is managed by TabList.SetActive; no per-window field needed. | |
| 2956 | 3849 | if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil { | |
| 2957 | - | target.activeTabID = tabID | |
| 2958 | 3850 | target.mainWindow.SetContent(gtkWidget) | |
| 2959 | 3851 | } else if a.mainWindow != nil { | |
| 2960 | 3852 | a.mainWindow.SetContent(gtkWidget) | |
| @@ -3292,7 +4184,7 @@ func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.Ta | |||
| 3292 | 4184 | } | |
| 3293 | 4185 | ||
| 3294 | 4186 | func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) { | |
| 3295 | - | activeTab := a.tabs.ActiveTab() | |
| 4187 | + | activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 3296 | 4188 | if activeTab == nil { | |
| 3297 | 4189 | return floatingSessionKey{}, nil, false | |
| 3298 | 4190 | } | |
| @@ -3377,7 +4269,7 @@ func (a *App) syncFloatingFocus() { | |||
| 3377 | 4269 | return | |
| 3378 | 4270 | } | |
| 3379 | 4271 | ||
| 3380 | - | activeTab := a.tabs.ActiveTab() | |
| 4272 | + | activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 3381 | 4273 | if activeTab == nil { | |
| 3382 | 4274 | a.contentCoord.ClearActivePaneOverride() | |
| 3383 | 4275 | return | |
| @@ -3402,6 +4294,14 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac | |||
| 3402 | 4294 | ||
| 3403 | 4295 | if session.omnibox == nil { | |
| 3404 | 4296 | cfg := a.omniboxCfg | |
| 4297 | + | cfg.OnNavigate = func(url string) { | |
| 4298 | + | if session.pane == nil { | |
| 4299 | + | return | |
| 4300 | + | } | |
| 4301 | + | if err := session.pane.Navigate(ctx, url); err != nil { | |
| 4302 | + | logging.FromContext(ctx).Error().Err(err).Str("url", url).Msg("floating omnibox navigation failed") | |
| 4303 | + | } | |
| 4304 | + | } | |
| 3405 | 4305 | cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) { | |
| 3406 | 4306 | a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level) | |
| 3407 | 4307 | } | |
| @@ -3619,7 +4519,7 @@ func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool { | |||
| 3619 | 4519 | ||
| 3620 | 4520 | // ToggleFloatingPane toggles the active workspace floating pane visibility. | |
| 3621 | 4521 | func (a *App) ToggleFloatingPane(ctx context.Context) error { | |
| 3622 | - | activeTab := a.tabs.ActiveTab() | |
| 4522 | + | activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 3623 | 4523 | if activeTab == nil { | |
| 3624 | 4524 | return nil | |
| 3625 | 4525 | } | |
| @@ -3640,7 +4540,7 @@ func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url str | |||
| 3640 | 4540 | } | |
| 3641 | 4541 | ||
| 3642 | 4542 | func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error { | |
| 3643 | - | activeTab := a.tabs.ActiveTab() | |
| 4543 | + | activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow()) | |
| 3644 | 4544 | if activeTab == nil { | |
| 3645 | 4545 | return nil | |
| 3646 | 4546 | } | |
| @@ -3694,14 +4594,10 @@ func (a *App) navigateFromOmnibox(ctx context.Context, url string) error { | |||
| 3694 | 4594 | if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() { | |
| 3695 | 4595 | return session.pane.Navigate(ctx, url) | |
| 3696 | 4596 | } | |
| 3697 | - | if a.omniboxNavigateFn != nil { | |
| 3698 | - | a.omniboxNavigateFn(url) | |
| 3699 | - | return nil | |
| 4597 | + | if bw := a.lastFocusedBrowserWindow(); bw != nil { | |
| 4598 | + | return a.navigateFromBrowserWindow(ctx, bw, url) | |
| 3700 | 4599 | } | |
| 3701 | - | if a.navCoord == nil { | |
| 3702 | - | return fmt.Errorf("navigation coordinator not initialized") | |
| 3703 | - | } | |
| 3704 | - | return a.navCoord.Navigate(ctx, url) | |
| 4600 | + | return fmt.Errorf("no focused browser window for omnibox navigation") | |
| 3705 | 4601 | } | |
| 3706 | 4602 | ||
| 3707 | 4603 | // ToggleOmnibox implements OmniboxProvider. | |
| @@ -3802,13 +4698,6 @@ func (a *App) UpdateOmniboxZoom(factor float64) { | |||
| 3802 | 4698 | } | |
| 3803 | 4699 | } | |
| 3804 | 4700 | ||
| 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 | 4701 | // initFilteringAsync starts background filter loading with toast feedback. | |
| 3813 | 4702 | func (a *App) initConfigWatcher(ctx context.Context) { | |
| 3814 | 4703 | log := logging.FromContext(ctx) | |
M internal/ui/app_browser_launch_test.go +1504 -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,76 @@ 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 | + | ||
| 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 TestTabBarSetVisibleDoesNotClearAutoHiddenInteractionState(t *testing.T) { | |
| 1301 | + | gtkApp := requireGTKDisplayApp(t) | |
| 1302 | + | defer gtkApp.Unref() | |
| 1303 | + | ||
| 1304 | + | tabBar := component.NewTabBar() | |
| 1305 | + | if tabBar == nil { | |
| 1306 | + | t.Fatal("tab bar creation failed") | |
| 1307 | + | } | |
| 1308 | + | ||
| 1309 | + | tabBar.SetAutoHidden(true) | |
| 986 | 1310 | tabBar.SetVisible(false) | |
| 1311 | + | tabBar.SetVisible(true) | |
| 1312 | + | ||
| 1313 | + | box := tabBar.Box() | |
| 1314 | + | if !box.GetVisible() { | |
| 1315 | + | t.Fatal("tab bar should stay mounted when explicitly visible") | |
| 1316 | + | } | |
| 1317 | + | if got := box.GetOpacity(); got != 0.0 { | |
| 1318 | + | t.Fatalf("tab bar opacity = %v, want 0 while auto-hidden", got) | |
| 1319 | + | } | |
| 1320 | + | if got := box.GetCanTarget(); got { | |
| 1321 | + | t.Fatalf("tab bar can target = %v, want false while auto-hidden", got) | |
| 1322 | + | } | |
| 1323 | + | if got := box.GetFocusable(); got { | |
| 1324 | + | t.Fatalf("tab bar focusable = %v, want false while auto-hidden", got) | |
| 1325 | + | } | |
| 1326 | + | } | |
| 1327 | + | ||
| 1328 | + | func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) { | |
| 1329 | + | gtkApp := requireGTKDisplayApp(t) | |
| 1330 | + | defer gtkApp.Unref() | |
| 1331 | + | ||
| 1332 | + | mainWindow := &window.MainWindow{} | |
| 1333 | + | tabBar := component.NewTabBar() | |
| 1334 | + | if tabBar == nil { | |
| 1335 | + | t.Fatal("tab bar creation failed") | |
| 1336 | + | } | |
| 987 | 1337 | setWindowTabBar(t, mainWindow, tabBar) | |
| 988 | 1338 | bw := &browserWindow{id: "window-1", mainWindow: mainWindow} | |
| 989 | 1339 | app := &App{ | |
| @@ -993,8 +1343,141 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled( | |||
| 993 | 1343 | ||
| 994 | 1344 | app.updateBrowserWindowTabBarVisibility(bw) | |
| 995 | 1345 | ||
| 996 | - | if got := windowTabBarVisible(t, mainWindow); !got { | |
| 997 | - | t.Fatalf("tab bar visible = %v, want true", got) | |
| 1346 | + | assertWindowTabBarAutoHidden(t, mainWindow, false) | |
| 1347 | + | } | |
| 1348 | + | ||
| 1349 | + | func TestApp_ActivePaneIDForNilBrowserWindowIgnoresStaleOverride(t *testing.T) { | |
| 1350 | + | ctx := context.Background() | |
| 1351 | + | contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil) | |
| 1352 | + | contentCoord.SetActivePaneOverride(entity.PaneID("stale-pane")) | |
| 1353 | + | app := &App{contentCoord: contentCoord} | |
| 1354 | + | ||
| 1355 | + | if got := app.activePaneIDForBrowserWindow(nil); got != "" { | |
| 1356 | + | t.Fatalf("active pane for nil browser window = %q, want empty", got) | |
| 1357 | + | } | |
| 1358 | + | } | |
| 1359 | + | ||
| 1360 | + | func TestApp_BuildRestoredWindowUIUpdatesEmptyWindowTabBarVisibility(t *testing.T) { | |
| 1361 | + | gtkApp := requireGTKDisplayApp(t) | |
| 1362 | + | defer gtkApp.Unref() | |
| 1363 | + | ||
| 1364 | + | mw, err := window.New(context.Background(), gtkApp, "bottom") | |
| 1365 | + | if err != nil { | |
| 1366 | + | t.Fatalf("window creation failed: %v", err) | |
| 1367 | + | } | |
| 1368 | + | defer mw.Destroy() | |
| 1369 | + | ||
| 1370 | + | mw.SetTabBarContentInsetVisible(true) | |
| 1371 | + | bw := &browserWindow{id: "window-1", mainWindow: mw, tabs: entity.NewTabList()} | |
| 1372 | + | app := &App{deps: &Dependencies{Config: &config.Config{}}} | |
| 1373 | + | app.deps.Config.Workspace.HideTabBarWhenSingleTab = true | |
| 1374 | + | ||
| 1375 | + | app.buildRestoredWindowUI(context.Background(), []*browserWindow{bw}) | |
| 1376 | + | ||
| 1377 | + | assertWindowTabBarAutoHidden(t, mw, true) | |
| 1378 | + | if mw.HasTabBarContentInset() { | |
| 1379 | + | t.Fatal("empty restored bottom window should not keep tab bar content inset") | |
| 1380 | + | } | |
| 1381 | + | } | |
| 1382 | + | ||
| 1383 | + | func TestApp_UpdateBrowserWindowTabBarVisibility_BottomInset(t *testing.T) { | |
| 1384 | + | gtkApp := requireGTKDisplayApp(t) | |
| 1385 | + | defer gtkApp.Unref() | |
| 1386 | + | ||
| 1387 | + | // Use a real bottom MainWindow so SetTabBarContentInsetVisible is effective. | |
| 1388 | + | mainWindow, err := window.New(context.Background(), gtkApp, "bottom") | |
| 1389 | + | if err != nil { | |
| 1390 | + | t.Fatalf("window creation failed: %v", err) | |
| 1391 | + | } | |
| 1392 | + | defer mainWindow.Destroy() | |
| 1393 | + | ||
| 1394 | + | bw := &browserWindow{id: "window-1", mainWindow: mainWindow} | |
| 1395 | + | app := &App{ | |
| 1396 | + | deps: &Dependencies{Config: &config.Config{}}, | |
| 1397 | + | } | |
| 1398 | + | app.deps.Config.Workspace.HideTabBarWhenSingleTab = true | |
| 1399 | + | ||
| 1400 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))) | |
| 1401 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2"))) | |
| 1402 | + | ||
| 1403 | + | // One tab + hide enabled: auto-hidden and no inset | |
| 1404 | + | mainWindow.TabBar().AddTab(tab1) | |
| 1405 | + | app.updateBrowserWindowTabBarVisibility(bw) | |
| 1406 | + | assertWindowTabBarAutoHidden(t, mainWindow, true) | |
| 1407 | + | if mainWindow.HasTabBarContentInset() { | |
| 1408 | + | t.Fatal("one tab: expected no content area inset") | |
| 1409 | + | } | |
| 1410 | + | ||
| 1411 | + | // Two tabs: visible and inset present | |
| 1412 | + | mainWindow.TabBar().AddTab(tab2) | |
| 1413 | + | app.updateBrowserWindowTabBarVisibility(bw) | |
| 1414 | + | assertWindowTabBarAutoHidden(t, mainWindow, false) | |
| 1415 | + | if !mainWindow.HasTabBarContentInset() { | |
| 1416 | + | t.Fatal("two tabs: expected content area inset") | |
| 1417 | + | } | |
| 1418 | + | ||
| 1419 | + | // Remove back to one tab: auto-hidden and no inset | |
| 1420 | + | mainWindow.TabBar().RemoveTab(tab2.ID) | |
| 1421 | + | app.updateBrowserWindowTabBarVisibility(bw) | |
| 1422 | + | assertWindowTabBarAutoHidden(t, mainWindow, true) | |
| 1423 | + | if mainWindow.HasTabBarContentInset() { | |
| 1424 | + | t.Fatal("back to one tab: expected no content area inset") | |
| 1425 | + | } | |
| 1426 | + | } | |
| 1427 | + | ||
| 1428 | + | func TestApp_HandlePaneFullscreenChanged_ClearsBottomInset(t *testing.T) { | |
| 1429 | + | gtkApp := requireGTKDisplayApp(t) | |
| 1430 | + | defer gtkApp.Unref() | |
| 1431 | + | ||
| 1432 | + | // Use a bottom MainWindow so SetTabBarContentInsetVisible is effective. | |
| 1433 | + | mainWindow, err := window.New(context.Background(), gtkApp, "bottom") | |
| 1434 | + | if err != nil { | |
| 1435 | + | t.Fatalf("window creation failed: %v", err) | |
| 1436 | + | } | |
| 1437 | + | defer mainWindow.Destroy() | |
| 1438 | + | ||
| 1439 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1"))) | |
| 1440 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2"))) | |
| 1441 | + | mainWindow.TabBar().AddTab(tab1) | |
| 1442 | + | mainWindow.TabBar().AddTab(tab2) | |
| 1443 | + | ||
| 1444 | + | tabs := entity.NewTabList() | |
| 1445 | + | tabs.Add(tab1) | |
| 1446 | + | tabs.Add(tab2) | |
| 1447 | + | tabs.SetActive(tab2.ID) | |
| 1448 | + | ||
| 1449 | + | bw := &browserWindow{id: "window-1", mainWindow: mainWindow, tabs: tabs} | |
| 1450 | + | app := &App{ | |
| 1451 | + | tabs: tabs, | |
| 1452 | + | deps: &Dependencies{Config: &config.Config{}}, | |
| 1453 | + | browserWindows: map[string]*browserWindow{bw.id: bw}, | |
| 1454 | + | lastFocusedWindowID: bw.id, | |
| 1455 | + | } | |
| 1456 | + | app.deps.Config.Workspace.HideTabBarWhenSingleTab = true | |
| 1457 | + | ||
| 1458 | + | // Start with two tabs: inset should be present | |
| 1459 | + | app.updateBrowserWindowTabBarVisibility(bw) | |
| 1460 | + | assertWindowTabBarAutoHidden(t, mainWindow, false) | |
| 1461 | + | if !mainWindow.HasTabBarContentInset() { | |
| 1462 | + | t.Fatal("two tabs: expected content area inset before fullscreen") | |
| 1463 | + | } | |
| 1464 | + | ||
| 1465 | + | // Enter fullscreen: tab bar hidden, inset cleared | |
| 1466 | + | app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), true) | |
| 1467 | + | if windowTabBarVisible(t, mainWindow) { | |
| 1468 | + | t.Fatal("fullscreen: tab bar should be not visible") | |
| 1469 | + | } | |
| 1470 | + | if mainWindow.HasTabBarContentInset() { | |
| 1471 | + | t.Fatal("fullscreen: expected no content area inset") | |
| 1472 | + | } | |
| 1473 | + | ||
| 1474 | + | // Exit fullscreen: tab bar restored, inset restored | |
| 1475 | + | app.handlePaneFullscreenChanged(entity.PaneID("pane-2"), false) | |
| 1476 | + | if !windowTabBarVisible(t, mainWindow) { | |
| 1477 | + | t.Fatal("exited fullscreen: tab bar should be visible") | |
| 1478 | + | } | |
| 1479 | + | if !mainWindow.HasTabBarContentInset() { | |
| 1480 | + | t.Fatal("exited fullscreen: expected content area inset to be restored") | |
| 998 | 1481 | } | |
| 999 | 1482 | } | |
| 1000 | 1483 | ||
| @@ -1012,16 +1495,17 @@ func TestApp_RemoveBrowserWindowPromotesDeterministicFallbackWithMainWindow(t *t | |||
| 1012 | 1495 | firstValid.id: firstValid, | |
| 1013 | 1496 | secondValid.id: secondValid, | |
| 1014 | 1497 | }, | |
| 1498 | + | browserWindowOrder: []string{removed.id, nilWindow.id, secondValid.id, firstValid.id}, | |
| 1015 | 1499 | lastFocusedWindowID: removed.id, | |
| 1016 | 1500 | } | |
| 1017 | 1501 | ||
| 1018 | 1502 | app.removeBrowserWindow(removed.id) | |
| 1019 | 1503 | ||
| 1020 | - | if app.mainWindow != firstValid.mainWindow { | |
| 1021 | - | t.Fatalf("mainWindow = %p, want %p", app.mainWindow, firstValid.mainWindow) | |
| 1504 | + | if app.mainWindow != secondValid.mainWindow { | |
| 1505 | + | t.Fatalf("mainWindow = %p, want %p", app.mainWindow, secondValid.mainWindow) | |
| 1022 | 1506 | } | |
| 1023 | - | if app.lastFocusedWindowID != firstValid.id { | |
| 1024 | - | t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, firstValid.id) | |
| 1507 | + | if app.lastFocusedWindowID != secondValid.id { | |
| 1508 | + | t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, secondValid.id) | |
| 1025 | 1509 | } | |
| 1026 | 1510 | } | |
| 1027 | 1511 | } | |
| @@ -1070,6 +1554,52 @@ func TestApp_LastFocusedBrowserWindowFallsBackWhenOwnerMissing(t *testing.T) { | |||
| 1070 | 1554 | } | |
| 1071 | 1555 | } | |
| 1072 | 1556 | ||
| 1557 | + | func TestApp_CreatePopupTabUsesParentPaneOwnerWhenFocusIsStale(t *testing.T) { | |
| 1558 | + | ctx := context.Background() | |
| 1559 | + | firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))) | |
| 1560 | + | secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second"))) | |
| 1561 | + | globalTabs := entity.NewTabList() | |
| 1562 | + | globalTabs.Add(firstTab) | |
| 1563 | + | globalTabs.Add(secondTab) | |
| 1564 | + | firstTabs := entity.NewTabList() | |
| 1565 | + | firstTabs.Add(firstTab) | |
| 1566 | + | secondTabs := entity.NewTabList() | |
| 1567 | + | secondTabs.Add(secondTab) | |
| 1568 | + | first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}} | |
| 1569 | + | second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}} | |
| 1570 | + | app := &App{ | |
| 1571 | + | deps: &Dependencies{Config: &config.Config{}}, | |
| 1572 | + | tabs: globalTabs, | |
| 1573 | + | tabsUC: usecase.NewManageTabsUseCase(func() string { return "popup-tab" }), | |
| 1574 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 1575 | + | windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second}, | |
| 1576 | + | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | |
| 1577 | + | lastFocusedWindowID: first.id, | |
| 1578 | + | mainWindow: first.mainWindow, | |
| 1579 | + | } | |
| 1580 | + | app.initTabCoordinator(ctx) | |
| 1581 | + | ||
| 1582 | + | err := app.createPopupTab(ctx, contentcoord.InsertPopupInput{ | |
| 1583 | + | ParentPaneID: secondTab.Workspace.ActivePaneID, | |
| 1584 | + | PopupPane: entity.NewPane(entity.PaneID("popup-pane")), | |
| 1585 | + | TargetURI: "https://example.com/popup", | |
| 1586 | + | }) | |
| 1587 | + | if err != nil { | |
| 1588 | + | t.Fatalf("createPopupTab returned error: %v", err) | |
| 1589 | + | } | |
| 1590 | + | ||
| 1591 | + | if first.tabs.Find(entity.TabID("popup-tab")) != nil { | |
| 1592 | + | t.Fatalf("popup tab was added to stale focused window") | |
| 1593 | + | } | |
| 1594 | + | created := second.tabs.Find(entity.TabID("popup-tab")) | |
| 1595 | + | if created == nil { | |
| 1596 | + | t.Fatalf("popup tab was not added to parent pane owner window") | |
| 1597 | + | } | |
| 1598 | + | if got := app.windowForTab[created.ID]; got != second { | |
| 1599 | + | t.Fatalf("popup tab owner = %p, want %p", got, second) | |
| 1600 | + | } | |
| 1601 | + | } | |
| 1602 | + | ||
| 1073 | 1603 | func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) { | |
| 1074 | 1604 | first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}} | |
| 1075 | 1605 | second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}} | |
| @@ -1085,3 +1615,930 @@ func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) { | |||
| 1085 | 1615 | t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id) | |
| 1086 | 1616 | } | |
| 1087 | 1617 | } | |
| 1618 | + | ||
| 1619 | + | func TestApp_TabCoordinatorCallbacksUseTargetWindowWhenFocusIsStale(t *testing.T) { | |
| 1620 | + | ctx := context.Background() | |
| 1621 | + | firstTabs := entity.NewTabList() | |
| 1622 | + | firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))) | |
| 1623 | + | firstTabs.Add(firstTab) | |
| 1624 | + | secondTabs := entity.NewTabList() | |
| 1625 | + | secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second"))) | |
| 1626 | + | secondTabs.Add(secondTab) | |
| 1627 | + | ||
| 1628 | + | first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}} | |
| 1629 | + | second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}} | |
| 1630 | + | app := &App{ | |
| 1631 | + | deps: &Dependencies{Config: &config.Config{}}, | |
| 1632 | + | tabs: entity.NewTabList(), | |
| 1633 | + | tabsUC: usecase.NewManageTabsUseCase(func() string { return "created-tab" }), | |
| 1634 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 1635 | + | lastFocusedWindowID: first.id, | |
| 1636 | + | mainWindow: first.mainWindow, | |
| 1637 | + | windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second}, | |
| 1638 | + | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | |
| 1639 | + | } | |
| 1640 | + | app.tabs.Add(firstTab) | |
| 1641 | + | app.tabs.Add(secondTab) | |
| 1642 | + | app.initTabCoordinator(ctx) | |
| 1643 | + | ||
| 1644 | + | created, err := app.tabCoord.Create(ctx, app.tabTargetForBrowserWindow(second), "https://example.com") | |
| 1645 | + | if err != nil { | |
| 1646 | + | t.Fatalf("Create returned error: %v", err) | |
| 1647 | + | } | |
| 1648 | + | if got := app.windowForTab[created.ID]; got != second { | |
| 1649 | + | t.Fatalf("created tab owner = %p, want second window %p", got, second) | |
| 1650 | + | } | |
| 1651 | + | if app.mainWindow != second.mainWindow { | |
| 1652 | + | t.Fatalf("mainWindow after create switch = %p, want %p", app.mainWindow, second.mainWindow) | |
| 1653 | + | } | |
| 1654 | + | ||
| 1655 | + | app.mainWindow = first.mainWindow | |
| 1656 | + | app.lastFocusedWindowID = first.id | |
| 1657 | + | if err := app.tabCoord.Switch(ctx, app.tabTargetForBrowserWindow(second), secondTab.ID); err != nil { | |
| 1658 | + | t.Fatalf("Switch returned error: %v", err) | |
| 1659 | + | } | |
| 1660 | + | if app.mainWindow != second.mainWindow { | |
| 1661 | + | t.Fatalf("mainWindow after target switch = %p, want %p", app.mainWindow, second.mainWindow) | |
| 1662 | + | } | |
| 1663 | + | } | |
| 1664 | + | ||
| 1665 | + | func TestApp_ActivateBrowserWindowAddsGlobalSnapshotWithoutChangingWindowTabPosition(t *testing.T) { | |
| 1666 | + | firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))) | |
| 1667 | + | secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second"))) | |
| 1668 | + | firstTabs := entity.NewTabList() | |
| 1669 | + | firstTabs.Add(firstTab) | |
| 1670 | + | secondTabs := entity.NewTabList() | |
| 1671 | + | secondTabs.Add(secondTab) | |
| 1672 | + | secondTabs.SetActive(secondTab.ID) | |
| 1673 | + | first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}} | |
| 1674 | + | second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}} | |
| 1675 | + | globalTabs := entity.NewTabList() | |
| 1676 | + | globalTabs.Add(firstTab) | |
| 1677 | + | app := &App{ | |
| 1678 | + | tabs: globalTabs, | |
| 1679 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 1680 | + | } | |
| 1681 | + | ||
| 1682 | + | app.activateBrowserWindow(second) | |
| 1683 | + | ||
| 1684 | + | if secondTab.Position != 0 { | |
| 1685 | + | t.Fatalf("window tab position = %d, want 0", secondTab.Position) | |
| 1686 | + | } | |
| 1687 | + | globalSecond := app.tabs.Find(secondTab.ID) | |
| 1688 | + | if globalSecond == nil { | |
| 1689 | + | t.Fatalf("global tab snapshot was not added") | |
| 1690 | + | } | |
| 1691 | + | if globalSecond == secondTab { | |
| 1692 | + | t.Fatalf("global tab should be a snapshot, not the live per-window tab") | |
| 1693 | + | } | |
| 1694 | + | if globalSecond.Position != 1 { | |
| 1695 | + | t.Fatalf("global tab position = %d, want 1", globalSecond.Position) | |
| 1696 | + | } | |
| 1697 | + | } | |
| 1698 | + | ||
| 1699 | + | func TestApp_ActiveWorkspaceRequiresFocusedBrowserWindow(t *testing.T) { | |
| 1700 | + | globalTab := entity.NewTab(entity.TabID("global-tab"), entity.WorkspaceID("ws-global"), entity.NewPane(entity.PaneID("pane-global"))) | |
| 1701 | + | globalTabs := entity.NewTabList() | |
| 1702 | + | globalTabs.Add(globalTab) | |
| 1703 | + | globalTabs.SetActive(globalTab.ID) | |
| 1704 | + | app := &App{ | |
| 1705 | + | tabs: globalTabs, | |
| 1706 | + | browserWindows: map[string]*browserWindow{}, | |
| 1707 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{globalTab.ID: {}}, | |
| 1708 | + | } | |
| 1709 | + | ||
| 1710 | + | if got := app.activeWorkspace(); got != nil { | |
| 1711 | + | t.Fatalf("activeWorkspace() = %p, want nil without focused browser window", got) | |
| 1712 | + | } | |
| 1713 | + | if got := app.activeWorkspaceView(); got != nil { | |
| 1714 | + | t.Fatalf("activeWorkspaceView() = %p, want nil without focused browser window", got) | |
| 1715 | + | } | |
| 1716 | + | } | |
| 1717 | + | ||
| 1718 | + | func TestApp_ActiveWorkspaceUsesLastFocusedWindowTabsNotGlobalActiveTab(t *testing.T) { | |
| 1719 | + | firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))) | |
| 1720 | + | secondTab := entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second"))) | |
| 1721 | + | firstTabs := entity.NewTabList() | |
| 1722 | + | firstTabs.Add(firstTab) | |
| 1723 | + | firstTabs.SetActive(firstTab.ID) | |
| 1724 | + | secondTabs := entity.NewTabList() | |
| 1725 | + | secondTabs.Add(secondTab) | |
| 1726 | + | secondTabs.SetActive(secondTab.ID) | |
| 1727 | + | first := &browserWindow{id: "window-1", tabs: firstTabs} | |
| 1728 | + | second := &browserWindow{id: "window-2", tabs: secondTabs} | |
| 1729 | + | globalTabs := entity.NewTabList() | |
| 1730 | + | globalTabs.Add(firstTab) | |
| 1731 | + | globalTabs.Add(secondTab) | |
| 1732 | + | globalTabs.SetActive(firstTab.ID) | |
| 1733 | + | secondView := &component.WorkspaceView{} | |
| 1734 | + | app := &App{ | |
| 1735 | + | tabs: globalTabs, | |
| 1736 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 1737 | + | lastFocusedWindowID: second.id, | |
| 1738 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{secondTab.ID: secondView}, | |
| 1739 | + | } | |
| 1740 | + | ||
| 1741 | + | if got := app.activeWorkspace(); got != secondTab.Workspace { | |
| 1742 | + | t.Fatalf("activeWorkspace() = %p, want second workspace %p", got, secondTab.Workspace) | |
| 1743 | + | } | |
| 1744 | + | if got := app.activeWorkspaceView(); got != secondView { | |
| 1745 | + | t.Fatalf("activeWorkspaceView() = %p, want second view %p", got, secondView) | |
| 1746 | + | } | |
| 1747 | + | } | |
| 1748 | + | ||
| 1749 | + | // fakeRecordingWebView is a complete port.WebView that records which navigation | |
| 1750 | + | // methods were called and with what arguments. It does not use reflect or unsafe. | |
| 1751 | + | type fakeRecordingWebView struct { | |
| 1752 | + | id port.WebViewID | |
| 1753 | + | ||
| 1754 | + | loadURICalled bool | |
| 1755 | + | loadURILastURI string | |
| 1756 | + | reloadCalls int | |
| 1757 | + | reloadBypassCacheCalls int | |
| 1758 | + | stopCalls int | |
| 1759 | + | goBackCalls int | |
| 1760 | + | goForwardCalls int | |
| 1761 | + | loadHTMLCalls int | |
| 1762 | + | setZoomLevelCalls int | |
| 1763 | + | setZoomLevelLastLevel float64 | |
| 1764 | + | openDevToolsCalls int | |
| 1765 | + | printPageCalls int | |
| 1766 | + | } | |
| 1767 | + | ||
| 1768 | + | func (f *fakeRecordingWebView) ID() port.WebViewID { return f.id } | |
| 1769 | + | ||
| 1770 | + | func (f *fakeRecordingWebView) LoadURI(_ context.Context, uri string) error { | |
| 1771 | + | f.loadURICalled = true | |
| 1772 | + | f.loadURILastURI = uri | |
| 1773 | + | return nil | |
| 1774 | + | } | |
| 1775 | + | ||
| 1776 | + | func (f *fakeRecordingWebView) LoadHTML(_ context.Context, _, _ string) error { | |
| 1777 | + | f.loadHTMLCalls++ | |
| 1778 | + | return nil | |
| 1779 | + | } | |
| 1780 | + | ||
| 1781 | + | func (f *fakeRecordingWebView) Reload(_ context.Context) error { | |
| 1782 | + | f.reloadCalls++ | |
| 1783 | + | return nil | |
| 1784 | + | } | |
| 1785 | + | ||
| 1786 | + | func (f *fakeRecordingWebView) ReloadBypassCache(_ context.Context) error { | |
| 1787 | + | f.reloadBypassCacheCalls++ | |
| 1788 | + | return nil | |
| 1789 | + | } | |
| 1790 | + | ||
| 1791 | + | func (f *fakeRecordingWebView) Stop(_ context.Context) error { | |
| 1792 | + | f.stopCalls++ | |
| 1793 | + | return nil | |
| 1794 | + | } | |
| 1795 | + | ||
| 1796 | + | func (f *fakeRecordingWebView) GoBack(_ context.Context) error { | |
| 1797 | + | f.goBackCalls++ | |
| 1798 | + | return nil | |
| 1799 | + | } | |
| 1800 | + | ||
| 1801 | + | func (f *fakeRecordingWebView) GoForward(_ context.Context) error { | |
| 1802 | + | f.goForwardCalls++ | |
| 1803 | + | return nil | |
| 1804 | + | } | |
| 1805 | + | ||
| 1806 | + | func (f *fakeRecordingWebView) State() port.WebViewState { return port.WebViewState{} } | |
| 1807 | + | ||
| 1808 | + | func (f *fakeRecordingWebView) URI() string { return f.loadURILastURI } | |
| 1809 | + | func (f *fakeRecordingWebView) Title() string { return "" } | |
| 1810 | + | ||
| 1811 | + | func (f *fakeRecordingWebView) IsLoading() bool { return false } | |
| 1812 | + | func (f *fakeRecordingWebView) EstimatedProgress() float64 { return 0 } | |
| 1813 | + | func (f *fakeRecordingWebView) CanGoBack() bool { return false } | |
| 1814 | + | func (f *fakeRecordingWebView) CanGoForward() bool { return false } | |
| 1815 | + | ||
| 1816 | + | func (f *fakeRecordingWebView) SetZoomLevel(_ context.Context, level float64) error { | |
| 1817 | + | f.setZoomLevelCalls++ | |
| 1818 | + | f.setZoomLevelLastLevel = level | |
| 1819 | + | return nil | |
| 1820 | + | } | |
| 1821 | + | ||
| 1822 | + | func (f *fakeRecordingWebView) OpenDevTools() { f.openDevToolsCalls++ } | |
| 1823 | + | func (f *fakeRecordingWebView) PrintPage() { f.printPageCalls++ } | |
| 1824 | + | ||
| 1825 | + | func (f *fakeRecordingWebView) GetZoomLevel() float64 { return 1.0 } | |
| 1826 | + | func (f *fakeRecordingWebView) GetFindController() port.FindController { return nil } | |
| 1827 | + | func (f *fakeRecordingWebView) SetCallbacks(_ *port.WebViewCallbacks) {} | |
| 1828 | + | func (f *fakeRecordingWebView) RunJavaScript(_ context.Context, _ string) {} | |
| 1829 | + | func (f *fakeRecordingWebView) SetBackgroundColor(_, _, _, _ float64) {} | |
| 1830 | + | func (f *fakeRecordingWebView) ResetBackgroundToDefault() {} | |
| 1831 | + | func (f *fakeRecordingWebView) Favicon() port.Texture { return nil } | |
| 1832 | + | func (f *fakeRecordingWebView) Generation() uint64 { return 0 } | |
| 1833 | + | func (f *fakeRecordingWebView) IsFullscreen() bool { return false } | |
| 1834 | + | func (f *fakeRecordingWebView) IsPlayingAudio() bool { return false } | |
| 1835 | + | func (f *fakeRecordingWebView) IsDestroyed() bool { return false } | |
| 1836 | + | func (f *fakeRecordingWebView) Destroy() {} | |
| 1837 | + | ||
| 1838 | + | type fakeZoomRepo struct{} | |
| 1839 | + | ||
| 1840 | + | func (f *fakeZoomRepo) Get(context.Context, string) (*entity.ZoomLevel, error) { return nil, nil } | |
| 1841 | + | func (f *fakeZoomRepo) Set(context.Context, *entity.ZoomLevel) error { return nil } | |
| 1842 | + | func (f *fakeZoomRepo) Delete(context.Context, string) error { return nil } | |
| 1843 | + | func (f *fakeZoomRepo) GetAll(context.Context) ([]*entity.ZoomLevel, error) { return nil, nil } | |
| 1844 | + | ||
| 1845 | + | func TestApp_BrowserWindowWebViewActionsIgnoreStaleFocusedWindow(t *testing.T) { | |
| 1846 | + | ctx := context.Background() | |
| 1847 | + | ||
| 1848 | + | // Build two browser windows with independent bw.tabs and active tabs/panes. | |
| 1849 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1"))) | |
| 1850 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2"))) | |
| 1851 | + | ||
| 1852 | + | firstTabs := entity.NewTabList() | |
| 1853 | + | firstTabs.Add(tab1) | |
| 1854 | + | firstTabs.SetActive(tab1.ID) | |
| 1855 | + | first := &browserWindow{id: "window-1", tabs: firstTabs} | |
| 1856 | + | ||
| 1857 | + | secondTabs := entity.NewTabList() | |
| 1858 | + | secondTabs.Add(tab2) | |
| 1859 | + | secondTabs.SetActive(tab2.ID) | |
| 1860 | + | second := &browserWindow{id: "window-2", tabs: secondTabs} | |
| 1861 | + | ||
| 1862 | + | // Create fake recording webviews. | |
| 1863 | + | fakeWv1 := &fakeRecordingWebView{id: 1} | |
| 1864 | + | fakeWv2 := &fakeRecordingWebView{id: 2} | |
| 1865 | + | ||
| 1866 | + | // Create content coordinator with initialized internal maps to avoid | |
| 1867 | + | // nil-map panics in SetNavigationOrigin during NavigateWebView calls. | |
| 1868 | + | contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil) | |
| 1869 | + | contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1) | |
| 1870 | + | contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2) | |
| 1871 | + | ||
| 1872 | + | // Create NavigationCoordinator (no navigateUC needed for direct webview calls). | |
| 1873 | + | navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord) | |
| 1874 | + | ||
| 1875 | + | app := &App{ | |
| 1876 | + | tabs: entity.NewTabList(), | |
| 1877 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 1878 | + | lastFocusedWindowID: first.id, // stale! should NOT be used | |
| 1879 | + | contentCoord: contentCoord, | |
| 1880 | + | navCoord: navCoord, | |
| 1881 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{ | |
| 1882 | + | tab1.ID: &component.WorkspaceView{}, | |
| 1883 | + | tab2.ID: &component.WorkspaceView{}, | |
| 1884 | + | }, | |
| 1885 | + | } | |
| 1886 | + | app.tabs.Add(tab1) | |
| 1887 | + | app.tabs.Add(tab2) | |
| 1888 | + | ||
| 1889 | + | // Call window-scoped app helpers for the second window. | |
| 1890 | + | if err := app.navigateFromBrowserWindow(ctx, second, "https://google.com"); err != nil { | |
| 1891 | + | t.Fatalf("navigateFromBrowserWindow returned error: %v", err) | |
| 1892 | + | } | |
| 1893 | + | if err := app.reloadBrowserWindow(ctx, second, false); err != nil { | |
| 1894 | + | t.Fatalf("reloadBrowserWindow (soft) returned error: %v", err) | |
| 1895 | + | } | |
| 1896 | + | if err := app.reloadBrowserWindow(ctx, second, true); err != nil { | |
| 1897 | + | t.Fatalf("reloadBrowserWindow (hard) returned error: %v", err) | |
| 1898 | + | } | |
| 1899 | + | if err := app.stopBrowserWindow(ctx, second); err != nil { | |
| 1900 | + | t.Fatalf("stopBrowserWindow returned error: %v", err) | |
| 1901 | + | } | |
| 1902 | + | if err := app.goBackBrowserWindow(ctx, second); err != nil { | |
| 1903 | + | t.Fatalf("goBackBrowserWindow returned error: %v", err) | |
| 1904 | + | } | |
| 1905 | + | if err := app.goForwardBrowserWindow(ctx, second); err != nil { | |
| 1906 | + | t.Fatalf("goForwardBrowserWindow returned error: %v", err) | |
| 1907 | + | } | |
| 1908 | + | ||
| 1909 | + | assertRecordingWebViewIdle(t, "first", fakeWv1) | |
| 1910 | + | assertRecordingWebViewActions(t, "second", fakeWv2, "https://google.com") | |
| 1911 | + | } | |
| 1912 | + | ||
| 1913 | + | func assertRecordingWebViewIdle(t *testing.T, name string, wv *fakeRecordingWebView) { | |
| 1914 | + | t.Helper() | |
| 1915 | + | if wv.loadURICalled || wv.loadURILastURI != "" { | |
| 1916 | + | t.Errorf("%s webview LoadURI was called, expected zero calls", name) | |
| 1917 | + | } | |
| 1918 | + | if wv.reloadCalls > 0 { | |
| 1919 | + | t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls) | |
| 1920 | + | } | |
| 1921 | + | if wv.reloadBypassCacheCalls > 0 { | |
| 1922 | + | t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls) | |
| 1923 | + | } | |
| 1924 | + | if wv.stopCalls > 0 { | |
| 1925 | + | t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls) | |
| 1926 | + | } | |
| 1927 | + | if wv.goBackCalls > 0 { | |
| 1928 | + | t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls) | |
| 1929 | + | } | |
| 1930 | + | if wv.goForwardCalls > 0 { | |
| 1931 | + | t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls) | |
| 1932 | + | } | |
| 1933 | + | } | |
| 1934 | + | ||
| 1935 | + | func assertRecordingWebViewActions(t *testing.T, name string, wv *fakeRecordingWebView, wantURI string) { | |
| 1936 | + | t.Helper() | |
| 1937 | + | if !wv.loadURICalled { | |
| 1938 | + | t.Errorf("%s webview LoadURI was not called", name) | |
| 1939 | + | } | |
| 1940 | + | if wv.loadURILastURI != wantURI { | |
| 1941 | + | t.Errorf("%s webview LoadURI URI = %q, want %q", name, wv.loadURILastURI, wantURI) | |
| 1942 | + | } | |
| 1943 | + | if wv.reloadCalls != 1 { | |
| 1944 | + | t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls) | |
| 1945 | + | } | |
| 1946 | + | if wv.reloadBypassCacheCalls != 1 { | |
| 1947 | + | t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls) | |
| 1948 | + | } | |
| 1949 | + | if wv.stopCalls != 1 { | |
| 1950 | + | t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls) | |
| 1951 | + | } | |
| 1952 | + | if wv.goBackCalls != 1 { | |
| 1953 | + | t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls) | |
| 1954 | + | } | |
| 1955 | + | if wv.goForwardCalls != 1 { | |
| 1956 | + | t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls) | |
| 1957 | + | } | |
| 1958 | + | } | |
| 1959 | + | ||
| 1960 | + | func assertRecordingWebViewBrowserActionsIdle(t *testing.T, name string, wv *fakeRecordingWebView) { | |
| 1961 | + | t.Helper() | |
| 1962 | + | if wv.reloadCalls > 0 { | |
| 1963 | + | t.Errorf("%s webview Reload calls = %d, want 0", name, wv.reloadCalls) | |
| 1964 | + | } | |
| 1965 | + | if wv.reloadBypassCacheCalls > 0 { | |
| 1966 | + | t.Errorf("%s webview ReloadBypassCache calls = %d, want 0", name, wv.reloadBypassCacheCalls) | |
| 1967 | + | } | |
| 1968 | + | if wv.stopCalls > 0 { | |
| 1969 | + | t.Errorf("%s webview Stop calls = %d, want 0", name, wv.stopCalls) | |
| 1970 | + | } | |
| 1971 | + | if wv.goBackCalls > 0 { | |
| 1972 | + | t.Errorf("%s webview GoBack calls = %d, want 0", name, wv.goBackCalls) | |
| 1973 | + | } | |
| 1974 | + | if wv.goForwardCalls > 0 { | |
| 1975 | + | t.Errorf("%s webview GoForward calls = %d, want 0", name, wv.goForwardCalls) | |
| 1976 | + | } | |
| 1977 | + | if wv.printPageCalls > 0 { | |
| 1978 | + | t.Errorf("%s webview PrintPage calls = %d, want 0", name, wv.printPageCalls) | |
| 1979 | + | } | |
| 1980 | + | if wv.openDevToolsCalls > 0 { | |
| 1981 | + | t.Errorf("%s webview OpenDevTools calls = %d, want 0", name, wv.openDevToolsCalls) | |
| 1982 | + | } | |
| 1983 | + | if wv.setZoomLevelCalls > 0 { | |
| 1984 | + | t.Errorf("%s webview SetZoomLevel calls = %d, want 0", name, wv.setZoomLevelCalls) | |
| 1985 | + | } | |
| 1986 | + | } | |
| 1987 | + | ||
| 1988 | + | func assertRecordingWebViewBrowserActionsCalledOnce(t *testing.T, name string, wv *fakeRecordingWebView) { | |
| 1989 | + | t.Helper() | |
| 1990 | + | if wv.reloadCalls != 1 { | |
| 1991 | + | t.Errorf("%s webview Reload calls = %d, want 1", name, wv.reloadCalls) | |
| 1992 | + | } | |
| 1993 | + | if wv.reloadBypassCacheCalls != 1 { | |
| 1994 | + | t.Errorf("%s webview ReloadBypassCache calls = %d, want 1", name, wv.reloadBypassCacheCalls) | |
| 1995 | + | } | |
| 1996 | + | if wv.stopCalls != 1 { | |
| 1997 | + | t.Errorf("%s webview Stop calls = %d, want 1", name, wv.stopCalls) | |
| 1998 | + | } | |
| 1999 | + | if wv.goBackCalls != 1 { | |
| 2000 | + | t.Errorf("%s webview GoBack calls = %d, want 1", name, wv.goBackCalls) | |
| 2001 | + | } | |
| 2002 | + | if wv.goForwardCalls != 1 { | |
| 2003 | + | t.Errorf("%s webview GoForward calls = %d, want 1", name, wv.goForwardCalls) | |
| 2004 | + | } | |
| 2005 | + | if wv.printPageCalls != 1 { | |
| 2006 | + | t.Errorf("%s webview PrintPage calls = %d, want 1", name, wv.printPageCalls) | |
| 2007 | + | } | |
| 2008 | + | if wv.openDevToolsCalls != 1 { | |
| 2009 | + | t.Errorf("%s webview OpenDevTools calls = %d, want 1", name, wv.openDevToolsCalls) | |
| 2010 | + | } | |
| 2011 | + | if wv.setZoomLevelCalls != 1 { | |
| 2012 | + | t.Errorf("%s webview SetZoomLevel calls = %d, want 1", name, wv.setZoomLevelCalls) | |
| 2013 | + | } | |
| 2014 | + | } | |
| 2015 | + | ||
| 2016 | + | func TestApp_OmniboxNavigateCallbackCapturesBrowserWindow(t *testing.T) { | |
| 2017 | + | second := &browserWindow{id: "window-2"} | |
| 2018 | + | ||
| 2019 | + | var capturedBW *browserWindow | |
| 2020 | + | var capturedURL string | |
| 2021 | + | testNavigate := func(_ context.Context, bw *browserWindow, url string) error { | |
| 2022 | + | capturedBW = bw | |
| 2023 | + | capturedURL = url | |
| 2024 | + | return nil | |
| 2025 | + | } | |
| 2026 | + | ||
| 2027 | + | ctx := context.Background() | |
| 2028 | + | cb := omniboxNavigateForBrowserWindow(ctx, second, testNavigate) | |
| 2029 | + | cb("https://google.com") | |
| 2030 | + | ||
| 2031 | + | if capturedBW != second { | |
| 2032 | + | capturedID := "<nil>" | |
| 2033 | + | if capturedBW != nil { | |
| 2034 | + | capturedID = capturedBW.id | |
| 2035 | + | } | |
| 2036 | + | t.Errorf("captured browser window = %p (id=%s), want %p (id=%s)", capturedBW, capturedID, second, second.id) | |
| 2037 | + | } | |
| 2038 | + | if capturedURL != "https://google.com" { | |
| 2039 | + | t.Errorf("captured URL = %q, want %q", capturedURL, "https://google.com") | |
| 2040 | + | } | |
| 2041 | + | } | |
| 2042 | + | ||
| 2043 | + | func TestApp_DispatchBrowserWindowActionUsesSourceWindow(t *testing.T) { | |
| 2044 | + | ctx := context.Background() | |
| 2045 | + | ||
| 2046 | + | // Build two browser windows with independent bw.tabs and active panes. | |
| 2047 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1"))) | |
| 2048 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2"))) | |
| 2049 | + | ||
| 2050 | + | firstTabs := entity.NewTabList() | |
| 2051 | + | firstTabs.Add(tab1) | |
| 2052 | + | firstTabs.SetActive(tab1.ID) | |
| 2053 | + | first := &browserWindow{id: "window-1", tabs: firstTabs} | |
| 2054 | + | ||
| 2055 | + | secondTabs := entity.NewTabList() | |
| 2056 | + | secondTabs.Add(tab2) | |
| 2057 | + | secondTabs.SetActive(tab2.ID) | |
| 2058 | + | second := &browserWindow{id: "window-2", tabs: secondTabs} | |
| 2059 | + | ||
| 2060 | + | // Create fake recording webviews. | |
| 2061 | + | fakeWv1 := &fakeRecordingWebView{id: 1, loadURILastURI: "https://first.example"} | |
| 2062 | + | fakeWv2 := &fakeRecordingWebView{id: 2, loadURILastURI: "https://second.example"} | |
| 2063 | + | ||
| 2064 | + | // Register fake webviews in content coordinator. | |
| 2065 | + | contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil) | |
| 2066 | + | contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1) | |
| 2067 | + | contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2) | |
| 2068 | + | ||
| 2069 | + | // Create navCoord (no navigateUC needed for direct webview calls). | |
| 2070 | + | navCoord := coordinator.NewNavigationCoordinator(ctx, nil, contentCoord) | |
| 2071 | + | ||
| 2072 | + | app := &App{ | |
| 2073 | + | tabs: entity.NewTabList(), | |
| 2074 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 2075 | + | lastFocusedWindowID: first.id, // stale! should NOT be used | |
| 2076 | + | contentCoord: contentCoord, | |
| 2077 | + | navCoord: navCoord, | |
| 2078 | + | deps: &Dependencies{ZoomUC: usecase.NewManageZoomUseCase(&fakeZoomRepo{}, 1.0, nil)}, | |
| 2079 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{ | |
| 2080 | + | tab1.ID: &component.WorkspaceView{}, | |
| 2081 | + | tab2.ID: &component.WorkspaceView{}, | |
| 2082 | + | }, | |
| 2083 | + | } | |
| 2084 | + | app.tabs.Add(tab1) | |
| 2085 | + | app.tabs.Add(tab2) | |
| 2086 | + | ||
| 2087 | + | // Dispatch browser window actions targeting the second window. | |
| 2088 | + | for _, a := range []input.Action{ | |
| 2089 | + | input.ActionReload, | |
| 2090 | + | input.ActionHardReload, | |
| 2091 | + | input.ActionStop, | |
| 2092 | + | input.ActionGoBack, | |
| 2093 | + | input.ActionGoForward, | |
| 2094 | + | input.ActionPrintPage, | |
| 2095 | + | input.ActionOpenDevTools, | |
| 2096 | + | input.ActionZoomIn, | |
| 2097 | + | } { | |
| 2098 | + | if err := app.dispatchBrowserWindowAction(ctx, second, a); err != nil { | |
| 2099 | + | t.Fatalf("dispatchBrowserWindowAction (%s) returned error: %v", a, err) | |
| 2100 | + | } | |
| 2101 | + | } | |
| 2102 | + | ||
| 2103 | + | // Assert first fake webview receives zero calls (stale focus is ignored). | |
| 2104 | + | assertRecordingWebViewBrowserActionsIdle(t, "first", fakeWv1) | |
| 2105 | + | ||
| 2106 | + | // Assert second fake webview receives exactly one call per action. | |
| 2107 | + | assertRecordingWebViewBrowserActionsCalledOnce(t, "second", fakeWv2) | |
| 2108 | + | } | |
| 2109 | + | ||
| 2110 | + | func counterIDGen() func() string { | |
| 2111 | + | var counter int | |
| 2112 | + | return func() string { | |
| 2113 | + | counter++ | |
| 2114 | + | return strconv.Itoa(counter) | |
| 2115 | + | } | |
| 2116 | + | } | |
| 2117 | + | ||
| 2118 | + | func TestApp_DispatchBrowserWindowActionSwitchTabIndexCreatesInSourceWindow(t *testing.T) { | |
| 2119 | + | ctx := context.Background() | |
| 2120 | + | ||
| 2121 | + | // Two windows, each with one tab. | |
| 2122 | + | firstTab := entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))) | |
| 2123 | + | firstTabs := entity.NewTabList() | |
| 2124 | + | firstTabs.Add(firstTab) | |
| 2125 | + | firstTabs.SetActive(firstTab.ID) | |
| 2126 | + | first := &browserWindow{id: "window-1", tabs: firstTabs} | |
| 2127 | + | ||
| 2128 | + | secondTab := entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))) | |
| 2129 | + | secondTabs := entity.NewTabList() | |
| 2130 | + | secondTabs.Add(secondTab) | |
| 2131 | + | secondTabs.SetActive(secondTab.ID) | |
| 2132 | + | second := &browserWindow{id: "window-2", tabs: secondTabs} | |
| 2133 | + | ||
| 2134 | + | app := &App{ | |
| 2135 | + | deps: &Dependencies{ | |
| 2136 | + | Config: &config.Config{ | |
| 2137 | + | Workspace: config.WorkspaceConfig{ | |
| 2138 | + | NewPaneURL: "about:blank", | |
| 2139 | + | }, | |
| 2140 | + | }, | |
| 2141 | + | }, | |
| 2142 | + | tabsUC: usecase.NewManageTabsUseCase(counterIDGen()), | |
| 2143 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 2144 | + | windowForTab: map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second}, | |
| 2145 | + | lastFocusedWindowID: first.id, // stale — should NOT be used | |
| 2146 | + | } | |
| 2147 | + | app.initTabCoordinator(ctx) | |
| 2148 | + | ||
| 2149 | + | // Dispatch ActionSwitchTabIndex4 targeting the second window. | |
| 2150 | + | err := app.dispatchBrowserWindowAction(ctx, second, input.ActionSwitchTabIndex4) | |
| 2151 | + | if err != nil { | |
| 2152 | + | t.Fatalf("dispatchBrowserWindowAction returned error: %v", err) | |
| 2153 | + | } | |
| 2154 | + | ||
| 2155 | + | // secondTabs should have 4 tabs (1 existing + 3 new). | |
| 2156 | + | if got := secondTabs.Count(); got != 4 { | |
| 2157 | + | t.Fatalf("second target tab count = %d, want 4", got) | |
| 2158 | + | } | |
| 2159 | + | if got := secondTabs.ActiveTabID; got != secondTabs.Tabs[3].ID { | |
| 2160 | + | t.Fatalf("active tab = %q, want %q (index 3)", got, secondTabs.Tabs[3].ID) | |
| 2161 | + | } | |
| 2162 | + | ||
| 2163 | + | // firstTabs must be completely unchanged. | |
| 2164 | + | if got := firstTabs.Count(); got != 1 { | |
| 2165 | + | t.Fatalf("first target tab count = %d, want 1", got) | |
| 2166 | + | } | |
| 2167 | + | ||
| 2168 | + | // windowForTab should map all second window tabs to second. | |
| 2169 | + | for _, tab := range secondTabs.Tabs { | |
| 2170 | + | if got := app.windowForTab[tab.ID]; got != second { | |
| 2171 | + | t.Fatalf("windowForTab[%s] = %p, want second window %p", tab.ID, got, second) | |
| 2172 | + | } | |
| 2173 | + | } | |
| 2174 | + | } | |
| 2175 | + | ||
| 2176 | + | func TestApp_WorkspaceOmniboxNavigateUsesOwnerWindow(t *testing.T) { | |
| 2177 | + | gtkApp := requireGTKDisplayApp(t) | |
| 2178 | + | defer gtkApp.Unref() | |
| 2179 | + | ||
| 2180 | + | ctx := context.Background() | |
| 2181 | + | tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1"))) | |
| 2182 | + | tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2"))) | |
| 2183 | + | firstTabs := entity.NewTabList() | |
| 2184 | + | firstTabs.Add(tab1) | |
| 2185 | + | firstTabs.SetActive(tab1.ID) | |
| 2186 | + | secondTabs := entity.NewTabList() | |
| 2187 | + | secondTabs.Add(tab2) | |
| 2188 | + | secondTabs.SetActive(tab2.ID) | |
| 2189 | + | first := &browserWindow{id: "window-1", tabs: firstTabs} | |
| 2190 | + | second := &browserWindow{id: "window-2", tabs: secondTabs} | |
| 2191 | + | ||
| 2192 | + | fakeWv1 := &fakeRecordingWebView{id: 1} | |
| 2193 | + | fakeWv2 := &fakeRecordingWebView{id: 2} | |
| 2194 | + | contentCoord := contentcoord.NewCoordinator(ctx, nil, nil, nil, nil, nil, nil, nil) | |
| 2195 | + | contentCoord.RegisterPopupWebView(entity.PaneID("pane-1"), fakeWv1) | |
| 2196 | + | contentCoord.RegisterPopupWebView(entity.PaneID("pane-2"), fakeWv2) | |
| 2197 | + | ||
| 2198 | + | app := &App{ | |
| 2199 | + | deps: &Dependencies{Config: &config.Config{}}, | |
| 2200 | + | widgetFactory: layout.NewGtkWidgetFactory(), | |
| 2201 | + | browserWindows: map[string]*browserWindow{first.id: first, second.id: second}, | |
| 2202 | + | windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab2.ID: second}, | |
| 2203 | + | lastFocusedWindowID: first.id, | |
| 2204 | + | contentCoord: contentCoord, | |
| 2205 | + | navCoord: coordinator.NewNavigationCoordinator(ctx, nil, contentCoord), | |
| 2206 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{}, | |
| 2207 | + | } | |
| 2208 | + | ||
| 2209 | + | app.createWorkspaceViewWithoutAttach(ctx, tab2) | |
| 2210 | + | wsView := app.workspaceViews[tab2.ID] | |
| 2211 | + | if wsView == nil { | |
| 2212 | + | t.Fatal("workspace view was not created for second tab") | |
| 2213 | + | } | |
| 2214 | + | wsView.ShowOmnibox(ctx, "") | |
| 2215 | + | cb := omniboxNavigateCallbackForTest(t, wsView.GetOmnibox()) | |
| 2216 | + | cb("https://example.com") | |
| 2217 | + | ||
| 2218 | + | if fakeWv1.loadURICalled { | |
| 2219 | + | t.Errorf("first window webview navigated to %q, want no navigation", fakeWv1.loadURILastURI) | |
| 2220 | + | } | |
| 2221 | + | if !fakeWv2.loadURICalled || fakeWv2.loadURILastURI != "https://example.com" { | |
| 2222 | + | t.Errorf("second window webview navigation = called %v uri %q, want https://example.com", fakeWv2.loadURICalled, fakeWv2.loadURILastURI) | |
| 2223 | + | } | |
| 2224 | + | } | |
| 2225 | + | ||
| 2226 | + | func TestApp_FloatingOmniboxNavigateUsesSessionPane(t *testing.T) { | |
| 2227 | + | gtkApp := requireGTKDisplayApp(t) | |
| 2228 | + | defer gtkApp.Unref() | |
| 2229 | + | ||
| 2230 | + | ctx := context.Background() | |
| 2231 | + | factory := layout.NewGtkWidgetFactory() | |
| 2232 | + | overlay := factory.NewOverlay() | |
| 2233 | + | ||
| 2234 | + | loadedURL := "" | |
| 2235 | + | pane := component.NewFloatingPane(overlay, component.FloatingPaneOptions{ | |
| 2236 | + | OnNavigate: func(_ context.Context, url string) error { | |
| 2237 | + | loadedURL = url | |
| 2238 | + | return nil | |
| 2239 | + | }, | |
| 2240 | + | }) | |
| 2241 | + | session := &floatingWorkspaceSession{ | |
| 2242 | + | paneID: entity.PaneID("floating-pane"), | |
| 2243 | + | pane: pane, | |
| 2244 | + | overlay: overlay, | |
| 2245 | + | } | |
| 2246 | + | app := &App{widgetFactory: factory} | |
| 2247 | + | ||
| 2248 | + | app.showFloatingOmnibox(ctx, session) | |
| 2249 | + | cb := omniboxNavigateCallbackForTest(t, session.omnibox) | |
| 2250 | + | cb("https://example.com") | |
| 2251 | + | ||
| 2252 | + | if loadedURL != "https://example.com" { | |
| 2253 | + | t.Fatalf("floating pane loaded URL = %q, want https://example.com", loadedURL) | |
| 2254 | + | } | |
| 2255 | + | } | |
| 2256 | + | ||
| 2257 | + | // TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge verifies that existing | |
| 2258 | + | // browserWindows not participating in the restore do not leak their tabs into the | |
| 2259 | + | // restored global tab list or UI aggregation. Only runtimeWindows (the windows | |
| 2260 | + | // actually restored) contribute. | |
| 2261 | + | func TestApp_RestoreSessionDoesNotLeakStaleWindowsIntoTabMerge(t *testing.T) { | |
| 2262 | + | // Two stale browserWindows exist in the map; only one window is restored. | |
| 2263 | + | staleBW1 := &browserWindow{id: "stale-w1"} | |
| 2264 | + | staleBW2 := &browserWindow{id: "stale-w2"} | |
| 2265 | + | ||
| 2266 | + | staleTab1 := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-ws"), entity.NewPane(entity.PaneID("stale-pane"))) | |
| 2267 | + | staleTab1.Name = "StaleTab" | |
| 2268 | + | staleBW1.tabs = entity.NewTabList() | |
| 2269 | + | staleBW1.tabs.Add(staleTab1) | |
| 2270 | + | ||
| 2271 | + | staleBW2.tabs = entity.NewTabList() | |
| 2272 | + | ||
| 2273 | + | // One restored window with its own tab. Tab names survive the | |
| 2274 | + | // snapshot/restore cycle even though IDs are regenerated. | |
| 2275 | + | restoredTabs := entity.NewTabList() | |
| 2276 | + | restoredTab := entity.NewTab(entity.TabID("restored-tab"), entity.WorkspaceID("restored-ws"), entity.NewPane(entity.PaneID("restored-pane"))) | |
| 2277 | + | restoredTab.Name = "RestoredTab" | |
| 2278 | + | restoredTabs.Add(restoredTab) | |
| 2279 | + | ||
| 2280 | + | sessionID := entity.SessionID("test-session") | |
| 2281 | + | state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{ | |
| 2282 | + | {WindowID: "saved-w1", Tabs: restoredTabs}, | |
| 2283 | + | }, 0, time.Unix(123, 0)) | |
| 2284 | + | ||
| 2285 | + | mainWindow := &window.MainWindow{} | |
| 2286 | + | runtimeBW := &browserWindow{id: "runtime-w1", mainWindow: mainWindow} | |
| 2287 | + | ||
| 2288 | + | app := &App{ | |
| 2289 | + | deps: &Dependencies{ | |
| 2290 | + | Config: &config.Config{}, | |
| 2291 | + | SessionStateRepo: &fakeSessionStateRepo{state: state}, | |
| 2292 | + | }, | |
| 2293 | + | mainWindow: mainWindow, | |
| 2294 | + | browserWindows: map[string]*browserWindow{ | |
| 2295 | + | staleBW1.id: staleBW1, | |
| 2296 | + | staleBW2.id: staleBW2, | |
| 2297 | + | runtimeBW.id: runtimeBW, | |
| 2298 | + | }, | |
| 2299 | + | tabs: entity.NewTabList(), | |
| 2300 | + | } | |
| 2301 | + | // Seed global tabs with a stale tab to verify it is replaced. | |
| 2302 | + | app.tabs.Add(staleTab1) | |
| 2303 | + | ||
| 2304 | + | if err := app.restoreSession(context.Background(), sessionID); err != nil { | |
| 2305 | + | t.Fatalf("restoreSession returned error: %v", err) | |
| 2306 | + | } | |
| 2307 | + | ||
| 2308 | + | // Global tabs must only contain the restored tab, not stale tabs. | |
| 2309 | + | if got := app.tabs.Count(); got != 1 { | |
| 2310 | + | t.Fatalf("tabs.Count() = %d, want 1 (only restored tab)", got) | |
| 2311 | + | } | |
| 2312 | + | // Verify by name since IDs are regenerated during restore. | |
| 2313 | + | restored := app.tabs.Tabs[0] | |
| 2314 | + | if restored == nil || restored.Name != "RestoredTab" { | |
| 2315 | + | t.Fatalf("restored tab name = %q, want RestoredTab", firstTabName(app.tabs)) | |
| 2316 | + | } | |
| 2317 | + | if app.tabs.Find(staleTab1.ID) != nil { | |
| 2318 | + | t.Fatal("stale tab leaked into global tabs") | |
| 2319 | + | } | |
| 2320 | + | } | |
| 2321 | + | ||
| 2322 | + | func firstTabName(tl *entity.TabList) string { | |
| 2323 | + | if len(tl.Tabs) == 0 || tl.Tabs[0] == nil { | |
| 2324 | + | return "" | |
| 2325 | + | } | |
| 2326 | + | return tl.Tabs[0].Name | |
| 2327 | + | } | |
| 2328 | + | ||
| 2329 | + | // TestApp_RestoreSessionHonorsActiveWindowIndex verifies that when restoring a | |
| 2330 | + | // v2 multi-window session, the ActiveWindowIndex from the snapshot is used to | |
| 2331 | + | // determine the focused window and global active tab. | |
| 2332 | + | func TestApp_RestoreSessionHonorsActiveWindowIndex(t *testing.T) { | |
| 2333 | + | gtkApp := requireGTKDisplayApp(t) | |
| 2334 | + | defer gtkApp.Unref() | |
| 2335 | + | ||
| 2336 | + | mainWindow, err := window.New(context.Background(), gtkApp, "top") | |
| 2337 | + | if err != nil { | |
| 2338 | + | t.Fatalf("window creation failed: %v", err) | |
| 2339 | + | } | |
| 2340 | + | defer mainWindow.Destroy() | |
| 2341 | + | ||
| 2342 | + | // Window 0 has a tab named "Tab-W0", window 1 has "Tab-W1" (active). | |
| 2343 | + | // Tab names survive snapshot/restore; IDs are regenerated. | |
| 2344 | + | pane0 := entity.NewPane("pane-w0") | |
| 2345 | + | tabW0 := entity.NewTab("tab-w0", "ws-w0", pane0) | |
| 2346 | + | tabW0.Name = "Tab-W0" | |
| 2347 | + | tabs0 := entity.NewTabList() | |
| 2348 | + | tabs0.Add(tabW0) | |
| 2349 | + | ||
| 2350 | + | pane1 := entity.NewPane("pane-w1") | |
| 2351 | + | tabW1 := entity.NewTab("tab-w1", "ws-w1", pane1) | |
| 2352 | + | tabW1.Name = "Tab-W1" | |
| 2353 | + | tabs1 := entity.NewTabList() | |
| 2354 | + | tabs1.Add(tabW1) | |
| 2355 | + | tabs1.SetActive("tab-w1") | |
| 2356 | + | ||
| 2357 | + | sessionID := entity.SessionID("test-sess") | |
| 2358 | + | // ActiveWindowIndex = 1: second window should be focused. | |
| 2359 | + | state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{ | |
| 2360 | + | {WindowID: "saved-w0", Tabs: tabs0}, | |
| 2361 | + | {WindowID: "saved-w1", Tabs: tabs1}, | |
| 2362 | + | }, 1, time.Unix(123, 0)) | |
| 2363 | + | ||
| 2364 | + | firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow} | |
| 2365 | + | // Factory creates the second runtime window; share the mainWindow so the UI | |
| 2366 | + | // restore loop can walk its tab bar without needing a second GTK window. | |
| 2367 | + | var runtimeW1 *browserWindow | |
| 2368 | + | app := &App{ | |
| 2369 | + | deps: &Dependencies{ | |
| 2370 | + | Config: &config.Config{}, | |
| 2371 | + | SessionStateRepo: &fakeSessionStateRepo{state: state}, | |
| 2372 | + | }, | |
| 2373 | + | mainWindow: mainWindow, | |
| 2374 | + | widgetFactory: layout.NewGtkWidgetFactory(), | |
| 2375 | + | browserWindows: map[string]*browserWindow{firstBW.id: firstBW}, | |
| 2376 | + | tabs: entity.NewTabList(), | |
| 2377 | + | windowForTab: map[entity.TabID]*browserWindow{}, | |
| 2378 | + | workspaceViews: map[entity.TabID]*component.WorkspaceView{}, | |
| 2379 | + | browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) { | |
| 2380 | + | runtimeW1 = &browserWindow{id: "runtime-w1", mainWindow: mainWindow} | |
| 2381 | + | return runtimeW1, nil | |
| 2382 | + | }, | |
| 2383 | + | } | |
| 2384 | + | ||
| 2385 | + | if err := app.restoreSession(context.Background(), sessionID); err != nil { | |
| 2386 | + | t.Fatalf("restoreSession returned error: %v", err) | |
| 2387 | + | } | |
| 2388 | + | ||
| 2389 | + | // lastFocusedWindowID must point to the active window (index 1). | |
| 2390 | + | if app.lastFocusedWindowID != "runtime-w1" { | |
| 2391 | + | t.Errorf("lastFocusedWindowID = %s, want runtime-w1 (active window index 1)", app.lastFocusedWindowID) | |
| 2392 | + | } | |
| 2393 | + | ||
| 2394 | + | // Global active tab must come from the active window (Tab-W1), not the first (Tab-W0). | |
| 2395 | + | activeTab := app.tabs.ActiveTab() | |
| 2396 | + | if activeTab == nil { | |
| 2397 | + | t.Fatal("active tab is nil") | |
| 2398 | + | } | |
| 2399 | + | if activeTab.Name != "Tab-W1" { | |
| 2400 | + | t.Errorf("active tab name = %q, want Tab-W1 (from active window)", activeTab.Name) | |
| 2401 | + | } | |
| 2402 | + | } | |
| 2403 | + | ||
| 2404 | + | // TestApp_RestoreSessionFailsOnAdditionalWindowCreationError verifies that | |
| 2405 | + | // restoreSession returns an error (not silent partial restore) when creating | |
| 2406 | + | // a browser window for an additional restored window fails. | |
| 2407 | + | func TestApp_RestoreSessionFailsOnAdditionalWindowCreationError(t *testing.T) { | |
| 2408 | + | gtkApp := requireGTKDisplayApp(t) | |
| 2409 | + | defer gtkApp.Unref() | |
| 2410 | + | ||
| 2411 | + | mainWindow, err := window.New(context.Background(), gtkApp, "top") | |
| 2412 | + | if err != nil { | |
| 2413 | + | t.Fatalf("window creation failed: %v", err) | |
| 2414 | + | } | |
| 2415 | + | defer mainWindow.Destroy() | |
| 2416 | + | ||
| 2417 | + | pane0 := entity.NewPane("pane-w0") | |
| 2418 | + | tab0 := entity.NewTab("tab-w0", "ws-w0", pane0) | |
| 2419 | + | tabs0 := entity.NewTabList() | |
| 2420 | + | tabs0.Add(tab0) | |
| 2421 | + | ||
| 2422 | + | pane1 := entity.NewPane("pane-w1") | |
| 2423 | + | tab1 := entity.NewTab("tab-w1", "ws-w1", pane1) | |
| 2424 | + | tabs1 := entity.NewTabList() | |
| 2425 | + | tabs1.Add(tab1) | |
| 2426 | + | ||
| 2427 | + | sessionID := entity.SessionID("test-sess") | |
| 2428 | + | state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{ | |
| 2429 | + | {WindowID: "saved-w0", Tabs: tabs0}, | |
| 2430 | + | {WindowID: "saved-w1", Tabs: tabs1}, | |
| 2431 | + | }, 0, time.Unix(123, 0)) | |
| 2432 | + | ||
| 2433 | + | firstBW := &browserWindow{id: "runtime-w0", mainWindow: mainWindow} | |
| 2434 | + | factoryErr := errors.New("window creation failed") | |
| 2435 | + | app := &App{ | |
| 2436 | + | deps: &Dependencies{ | |
| 2437 | + | Config: &config.Config{}, | |
| 2438 | + | SessionStateRepo: &fakeSessionStateRepo{state: state}, | |
| 2439 | + | }, | |
| 2440 | + | mainWindow: mainWindow, | |
| 2441 | + | browserWindows: map[string]*browserWindow{firstBW.id: firstBW}, | |
| 2442 | + | tabs: entity.NewTabList(), | |
| 2443 | + | windowForTab: map[entity.TabID]*browserWindow{}, | |
| 2444 | + | browserWindowFactory: func(ctx context.Context, url string) (*browserWindow, error) { | |
| 2445 | + | return nil, factoryErr | |
| 2446 | + | }, | |
| 2447 | + | } | |
| 2448 | + | ||
| 2449 | + | gotErr := app.restoreSession(context.Background(), sessionID) | |
| 2450 | + | if gotErr == nil { | |
| 2451 | + | t.Fatal("restoreSession returned nil error, want failure when additional window creation fails") | |
| 2452 | + | } | |
| 2453 | + | if !strings.Contains(gotErr.Error(), "create browser window 1 for restore") { | |
| 2454 | + | t.Errorf("error message should mention window creation for restore, got: %v", gotErr) | |
| 2455 | + | } | |
| 2456 | + | if !errors.Is(gotErr, factoryErr) { | |
| 2457 | + | t.Errorf("error should wrap factoryErr, got: %v", gotErr) | |
| 2458 | + | } | |
| 2459 | + | } | |
| 2460 | + | ||
| 2461 | + | func TestMainWindow_BottomTabBar_ContentAreaHasInset(t *testing.T) { | |
| 2462 | + | gtkApp := requireGTKDisplayApp(t) | |
| 2463 | + | defer gtkApp.Unref() | |
| 2464 | + | ||
| 2465 | + | mw, err := window.New(context.Background(), gtkApp, "bottom") | |
| 2466 | + | if err != nil { | |
| 2467 | + | t.Fatalf("window creation failed: %v", err) | |
| 2468 | + | } | |
| 2469 | + | defer mw.Destroy() | |
| 2470 | + | ||
| 2471 | + | // Bottom window should start WITHOUT the inset | |
| 2472 | + | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") { | |
| 2473 | + | t.Fatal("bottom-tab-bar content area: should NOT have inset at window creation") | |
| 2474 | + | } | |
| 2475 | + | ||
| 2476 | + | // HasTabBarContentInset must return false initially | |
| 2477 | + | if mw.HasTabBarContentInset() { | |
| 2478 | + | t.Fatal("HasTabBarContentInset should return false initially") | |
| 2479 | + | } | |
| 2480 | + | ||
| 2481 | + | // SetTabBarContentInsetVisible(true) must add the class | |
| 2482 | + | mw.SetTabBarContentInsetVisible(true) | |
| 2483 | + | if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") { | |
| 2484 | + | t.Fatal("bottom-tab-bar content area: expected content-area-tabbar-inset-bottom CSS class after SetTabBarContentInsetVisible(true)") | |
| 2485 | + | } | |
| 2486 | + | if !mw.HasTabBarContentInset() { | |
| 2487 | + | t.Fatal("HasTabBarContentInset should return true after SetTabBarContentInsetVisible(true)") | |
| 2488 | + | } | |
| 2489 | + | ||
| 2490 | + | // SetTabBarContentInsetVisible(false) must remove the class | |
| 2491 | + | mw.SetTabBarContentInsetVisible(false) | |
| 2492 | + | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") { | |
| 2493 | + | t.Fatal("bottom-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)") | |
| 2494 | + | } | |
| 2495 | + | if mw.HasTabBarContentInset() { | |
| 2496 | + | t.Fatal("HasTabBarContentInset should return false after SetTabBarContentInsetVisible(false)") | |
| 2497 | + | } | |
| 2498 | + | ||
| 2499 | + | // Verify tab bar remains a non-measured overlay regardless of inset state | |
| 2500 | + | if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) { | |
| 2501 | + | t.Fatal("bottom-tab-bar: expected tab bar to remain a non-measured overlay") | |
| 2502 | + | } | |
| 2503 | + | } | |
| 2504 | + | ||
| 2505 | + | func TestMainWindow_TopTabBar_ContentAreaHasTopInset(t *testing.T) { | |
| 2506 | + | gtkApp := requireGTKDisplayApp(t) | |
| 2507 | + | defer gtkApp.Unref() | |
| 2508 | + | ||
| 2509 | + | mw, err := window.New(context.Background(), gtkApp, "top") | |
| 2510 | + | if err != nil { | |
| 2511 | + | t.Fatalf("window creation failed: %v", err) | |
| 2512 | + | } | |
| 2513 | + | defer mw.Destroy() | |
| 2514 | + | ||
| 2515 | + | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") { | |
| 2516 | + | t.Fatal("top-tab-bar content area: should NOT have inset at window creation") | |
| 2517 | + | } | |
| 2518 | + | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") { | |
| 2519 | + | t.Fatal("top-tab-bar content area: should NOT have content-area-tabbar-inset-bottom CSS class") | |
| 2520 | + | } | |
| 2521 | + | ||
| 2522 | + | mw.SetTabBarContentInsetVisible(true) | |
| 2523 | + | if !mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") { | |
| 2524 | + | t.Fatal("top-tab-bar content area: expected content-area-tabbar-inset-top CSS class after SetTabBarContentInsetVisible(true)") | |
| 2525 | + | } | |
| 2526 | + | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") { | |
| 2527 | + | t.Fatal("top-tab-bar content area: should NOT have bottom inset after SetTabBarContentInsetVisible(true)") | |
| 2528 | + | } | |
| 2529 | + | if !mw.HasTabBarContentInset() { | |
| 2530 | + | t.Fatal("HasTabBarContentInset should return true for top inset") | |
| 2531 | + | } | |
| 2532 | + | ||
| 2533 | + | mw.SetTabBarContentInsetVisible(false) | |
| 2534 | + | if mw.ContentArea().HasCssClass("content-area-tabbar-inset-top") { | |
| 2535 | + | t.Fatal("top-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(false)") | |
| 2536 | + | } | |
| 2537 | + | if mw.HasTabBarContentInset() { | |
| 2538 | + | t.Fatal("HasTabBarContentInset should return false after removing top inset") | |
| 2539 | + | } | |
| 2540 | + | ||
| 2541 | + | if mw.ContentOverlay().GetMeasureOverlay(mw.TabBar().Widget()) { | |
| 2542 | + | t.Fatal("top-tab-bar: expected tab bar to remain a non-measured overlay") | |
| 2543 | + | } | |
| 2544 | + | } | |
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 -4
| @@ -12,6 +12,9 @@ type TabBar struct { | |||
| 12 | 12 | box *gtk.Box | |
| 13 | 13 | buttons map[entity.TabID]*TabButton | |
| 14 | 14 | ||
| 15 | + | explicitlyVisible bool | |
| 16 | + | autoHidden bool | |
| 17 | + | ||
| 15 | 18 | // Active tab tracking | |
| 16 | 19 | activeTabID entity.TabID | |
| 17 | 20 | ||
| @@ -26,7 +29,8 @@ type TabBar struct { | |||
| 26 | 29 | // NewTabBar creates a new tab bar widget. | |
| 27 | 30 | func NewTabBar() *TabBar { | |
| 28 | 31 | tb := &TabBar{ | |
| 29 | - | buttons: make(map[entity.TabID]*TabButton), | |
| 32 | + | buttons: make(map[entity.TabID]*TabButton), | |
| 33 | + | explicitlyVisible: true, | |
| 30 | 34 | } | |
| 31 | 35 | ||
| 32 | 36 | // Create horizontal box container | |
| @@ -157,12 +161,42 @@ func (tb *TabBar) ActiveTabID() entity.TabID { | |||
| 157 | 161 | return tb.activeTabID | |
| 158 | 162 | } | |
| 159 | 163 | ||
| 160 | - | // SetVisible shows or hides the tab bar. | |
| 164 | + | // SetVisible explicitly shows or hides the tab bar. | |
| 161 | 165 | func (tb *TabBar) SetVisible(visible bool) { | |
| 162 | - | if tb.box == nil { | |
| 166 | + | if tb == nil || tb.box == nil { | |
| 163 | 167 | return | |
| 164 | 168 | } | |
| 165 | - | tb.box.SetVisible(visible) | |
| 169 | + | tb.explicitlyVisible = visible | |
| 170 | + | tb.applyVisibilityState() | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // SetAutoHidden hides the tab bar visually while keeping the overlay widget | |
| 174 | + | // visible to GTK when the bar has not been explicitly hidden. It is used for | |
| 175 | + | // hide_tab_bar_when_single_tab; WebView viewport height stays stable because the | |
| 176 | + | // tab bar is mounted as a non-measured overlay above the content. | |
| 177 | + | func (tb *TabBar) SetAutoHidden(hidden bool) { | |
| 178 | + | if tb == nil || tb.box == nil { | |
| 179 | + | return | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | tb.autoHidden = hidden | |
| 183 | + | tb.applyVisibilityState() | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (tb *TabBar) applyVisibilityState() { | |
| 187 | + | if tb == nil || tb.box == nil { | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | interactive := tb.explicitlyVisible && !tb.autoHidden | |
| 192 | + | tb.box.SetVisible(tb.explicitlyVisible) | |
| 193 | + | if interactive { | |
| 194 | + | tb.box.SetOpacity(1.0) | |
| 195 | + | } else { | |
| 196 | + | tb.box.SetOpacity(0.0) | |
| 197 | + | } | |
| 198 | + | tb.box.SetCanTarget(interactive) | |
| 199 | + | tb.box.SetFocusable(interactive) | |
| 166 | 200 | } | |
| 167 | 201 | ||
| 168 | 202 | // 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 | + | paneIDStr := string(paneID) | |
| 88 | + | ||
| 97 | 89 | if c.navigateUC != nil { | |
| 98 | 90 | input := usecase.NavigateInput{ | |
| 99 | 91 | URL: url, | |
| 100 | - | PaneID: paneID, | |
| 101 | - | WebView: wv, // webkit.WebView implements port.WebView | |
| 92 | + | PaneID: paneIDStr, | |
| 93 | + | WebView: wv, | |
| 102 | 94 | } | |
| 103 | 95 | output, err := c.navigateUC.Execute(ctx, input) | |
| 104 | 96 | if err != nil { | |
| 105 | - | log.Error().Err(err).Str("url", url).Msg("navigation failed") | |
| 97 | + | log.Error().Err(err).Str("url", url).Str("pane_id", paneIDStr).Msg("navigation failed") | |
| 106 | 98 | return err | |
| 107 | 99 | } | |
| 100 | + | c.trackNavigationOrigin(ctx, paneID, url) | |
| 108 | 101 | log.Debug(). | |
| 109 | 102 | Str("url", url). | |
| 103 | + | Str("pane_id", paneIDStr). | |
| 110 | 104 | Float64("zoom", output.AppliedZoom). | |
| 105 | + | Uint64("webview_id", uint64(wv.ID())). | |
| 111 | 106 | Msg("navigation initiated via usecase") | |
| 112 | 107 | return nil | |
| 113 | 108 | } | |
| 114 | 109 | ||
| 115 | - | // Fallback: direct navigation without usecase | |
| 116 | 110 | log.Warn().Msg("navigateUC not available, using direct LoadURI") | |
| 117 | 111 | if err := wv.LoadURI(ctx, url); err != nil { | |
| 118 | 112 | log.Error().Err(err).Str("url", url).Msg("failed to navigate") | |
| 119 | 113 | return err | |
| 120 | 114 | } | |
| 121 | 115 | ||
| 122 | - | log.Debug().Str("url", url).Msg("navigated active pane (direct)") | |
| 116 | + | c.trackNavigationOrigin(ctx, paneID, url) | |
| 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 +270 -0
| @@ -0,0 +1,270 @@ | |||
| 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 | + | type mockDevToolsWebView struct { | |
| 13 | + | *mocks.MockWebView | |
| 14 | + | *mocks.MockDevToolsOpener | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | type mockPrinterWebView struct { | |
| 18 | + | *mocks.MockWebView | |
| 19 | + | *mocks.MockPrinter | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView verifies that | |
| 23 | + | // the explicit WebView-targeted navigation methods call the correct methods on the | |
| 24 | + | // provided WebView rather than resolving through the content coordinator. | |
| 25 | + | func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testing.T) { | |
| 26 | + | ctx := context.Background() | |
| 27 | + | ||
| 28 | + | t.Run("NavigateWebView with nil webview returns error", func(t *testing.T) { | |
| 29 | + | testNavigateWebViewNilWebView(t, ctx) | |
| 30 | + | }) | |
| 31 | + | t.Run("NavigateWebView targets provided webview", func(t *testing.T) { | |
| 32 | + | testNavigateWebViewTargetsProvidedWebView(t, ctx) | |
| 33 | + | }) | |
| 34 | + | t.Run("ReloadWebView with nil webview returns error", func(t *testing.T) { | |
| 35 | + | testReloadWebViewNilWebView(t, ctx) | |
| 36 | + | }) | |
| 37 | + | t.Run("ReloadWebView bypassCache=false calls Reload once", func(t *testing.T) { | |
| 38 | + | testReloadWebViewCallsReload(t, ctx) | |
| 39 | + | }) | |
| 40 | + | t.Run("ReloadWebView bypassCache=true calls ReloadBypassCache once", func(t *testing.T) { | |
| 41 | + | testReloadWebViewCallsReloadBypassCache(t, ctx) | |
| 42 | + | }) | |
| 43 | + | t.Run("StopWebView with nil webview returns error", func(t *testing.T) { | |
| 44 | + | testStopWebViewNilWebView(t, ctx) | |
| 45 | + | }) | |
| 46 | + | t.Run("StopWebView calls Stop once", func(t *testing.T) { | |
| 47 | + | testStopWebViewCallsStop(t, ctx) | |
| 48 | + | }) | |
| 49 | + | t.Run("GoBackWebView with nil webview returns error", func(t *testing.T) { | |
| 50 | + | testGoBackWebViewNilWebView(t, ctx) | |
| 51 | + | }) | |
| 52 | + | t.Run("GoBackWebView calls GoBack once", func(t *testing.T) { | |
| 53 | + | testGoBackWebViewCallsGoBack(t, ctx) | |
| 54 | + | }) | |
| 55 | + | t.Run("GoForwardWebView with nil webview returns error", func(t *testing.T) { | |
| 56 | + | testGoForwardWebViewNilWebView(t, ctx) | |
| 57 | + | }) | |
| 58 | + | t.Run("GoForwardWebView calls GoForward once", func(t *testing.T) { | |
| 59 | + | testGoForwardWebViewCallsGoForward(t, ctx) | |
| 60 | + | }) | |
| 61 | + | t.Run("OpenDevToolsWebView with nil webview returns error", func(t *testing.T) { | |
| 62 | + | testOpenDevToolsWebViewNilWebView(t, ctx) | |
| 63 | + | }) | |
| 64 | + | t.Run("OpenDevToolsWebView unsupported capability returns error", func(t *testing.T) { | |
| 65 | + | testOpenDevToolsWebViewUnsupportedCapability(t, ctx) | |
| 66 | + | }) | |
| 67 | + | t.Run("OpenDevToolsWebView calls OpenDevTools once", func(t *testing.T) { | |
| 68 | + | testOpenDevToolsWebViewTargetsProvidedWebView(t, ctx) | |
| 69 | + | }) | |
| 70 | + | t.Run("PrintWebView with nil webview returns error", func(t *testing.T) { | |
| 71 | + | testPrintWebViewNilWebView(t, ctx) | |
| 72 | + | }) | |
| 73 | + | t.Run("PrintWebView unsupported capability returns error", func(t *testing.T) { | |
| 74 | + | testPrintWebViewUnsupportedCapability(t, ctx) | |
| 75 | + | }) | |
| 76 | + | t.Run("PrintWebView calls PrintPage once", func(t *testing.T) { | |
| 77 | + | testPrintWebViewTargetsProvidedWebView(t, ctx) | |
| 78 | + | }) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) { | |
| 82 | + | t.Helper() | |
| 83 | + | c := &NavigationCoordinator{} | |
| 84 | + | err := c.NavigateWebView(ctx, "https://example.com", entity.PaneID("p1"), nil) | |
| 85 | + | if err == nil { | |
| 86 | + | t.Fatal("expected error for nil webview, got nil") | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func testNavigateWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | |
| 91 | + | t.Helper() | |
| 92 | + | wv := mocks.NewMockWebView(t) | |
| 93 | + | wv.EXPECT().LoadURI(ctx, "https://example.com").Return(nil).Once() | |
| 94 | + | wv.EXPECT().ID().Return(port.WebViewID(42)).Once() | |
| 95 | + | c := &NavigationCoordinator{} | |
| 96 | + | err := c.NavigateWebView(ctx, "https://example.com", entity.PaneID("p1"), wv) | |
| 97 | + | if err != nil { | |
| 98 | + | t.Fatalf("unexpected error: %v", err) | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func testReloadWebViewNilWebView(t *testing.T, ctx context.Context) { | |
| 103 | + | t.Helper() | |
| 104 | + | c := &NavigationCoordinator{} | |
| 105 | + | err := c.ReloadWebView(ctx, nil, false) | |
| 106 | + | if err == nil { | |
| 107 | + | t.Fatal("expected error for nil webview, got nil") | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func testReloadWebViewCallsReload(t *testing.T, ctx context.Context) { | |
| 112 | + | t.Helper() | |
| 113 | + | wv := mocks.NewMockWebView(t) | |
| 114 | + | wv.EXPECT().ID().Return(port.WebViewID(1)).Once() | |
| 115 | + | wv.EXPECT().Reload(ctx).Return(nil).Once() | |
| 116 | + | c := &NavigationCoordinator{} | |
| 117 | + | err := c.ReloadWebView(ctx, wv, false) | |
| 118 | + | if err != nil { | |
| 119 | + | t.Fatalf("unexpected error: %v", err) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | func testReloadWebViewCallsReloadBypassCache(t *testing.T, ctx context.Context) { | |
| 124 | + | t.Helper() | |
| 125 | + | wv := mocks.NewMockWebView(t) | |
| 126 | + | wv.EXPECT().ID().Return(port.WebViewID(1)).Once() | |
| 127 | + | wv.EXPECT().ReloadBypassCache(ctx).Return(nil).Once() | |
| 128 | + | c := &NavigationCoordinator{} | |
| 129 | + | err := c.ReloadWebView(ctx, wv, true) | |
| 130 | + | if err != nil { | |
| 131 | + | t.Fatalf("unexpected error: %v", err) | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func testStopWebViewNilWebView(t *testing.T, ctx context.Context) { | |
| 136 | + | t.Helper() | |
| 137 | + | c := &NavigationCoordinator{} | |
| 138 | + | err := c.StopWebView(ctx, nil) | |
| 139 | + | if err == nil { | |
| 140 | + | t.Fatal("expected error for nil webview, got nil") | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func testStopWebViewCallsStop(t *testing.T, ctx context.Context) { | |
| 145 | + | t.Helper() | |
| 146 | + | wv := mocks.NewMockWebView(t) | |
| 147 | + | wv.EXPECT().ID().Return(port.WebViewID(1)).Once() | |
| 148 | + | wv.EXPECT().Stop(ctx).Return(nil).Once() | |
| 149 | + | c := &NavigationCoordinator{} | |
| 150 | + | err := c.StopWebView(ctx, wv) | |
| 151 | + | if err != nil { | |
| 152 | + | t.Fatalf("unexpected error: %v", err) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func testGoBackWebViewNilWebView(t *testing.T, ctx context.Context) { | |
| 157 | + | t.Helper() | |
| 158 | + | c := &NavigationCoordinator{} | |
| 159 | + | err := c.GoBackWebView(ctx, nil) | |
| 160 | + | if err == nil { | |
| 161 | + | t.Fatal("expected error for nil webview, got nil") | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func testGoBackWebViewCallsGoBack(t *testing.T, ctx context.Context) { | |
| 166 | + | t.Helper() | |
| 167 | + | wv := mocks.NewMockWebView(t) | |
| 168 | + | wv.EXPECT().ID().Return(port.WebViewID(1)).Once() | |
| 169 | + | wv.EXPECT().GoBack(ctx).Return(nil).Once() | |
| 170 | + | c := &NavigationCoordinator{} | |
| 171 | + | err := c.GoBackWebView(ctx, wv) | |
| 172 | + | if err != nil { | |
| 173 | + | t.Fatalf("unexpected error: %v", err) | |
| 174 | + | } | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | func testGoForwardWebViewNilWebView(t *testing.T, ctx context.Context) { | |
| 178 | + | t.Helper() | |
| 179 | + | c := &NavigationCoordinator{} | |
| 180 | + | err := c.GoForwardWebView(ctx, nil) | |
| 181 | + | if err == nil { | |
| 182 | + | t.Fatal("expected error for nil webview, got nil") | |
| 183 | + | } | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func testGoForwardWebViewCallsGoForward(t *testing.T, ctx context.Context) { | |
| 187 | + | t.Helper() | |
| 188 | + | wv := mocks.NewMockWebView(t) | |
| 189 | + | wv.EXPECT().ID().Return(port.WebViewID(1)).Once() | |
| 190 | + | wv.EXPECT().GoForward(ctx).Return(nil).Once() | |
| 191 | + | c := &NavigationCoordinator{} | |
| 192 | + | err := c.GoForwardWebView(ctx, wv) | |
| 193 | + | if err != nil { | |
| 194 | + | t.Fatalf("unexpected error: %v", err) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | func testOpenDevToolsWebViewNilWebView(t *testing.T, ctx context.Context) { | |
| 199 | + | t.Helper() | |
| 200 | + | c := &NavigationCoordinator{} | |
| 201 | + | err := c.OpenDevToolsWebView(ctx, nil) | |
| 202 | + | if err == nil { | |
| 203 | + | t.Fatal("expected error for nil webview, got nil") | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func testOpenDevToolsWebViewUnsupportedCapability(t *testing.T, ctx context.Context) { | |
| 208 | + | t.Helper() | |
| 209 | + | wv := mocks.NewMockWebView(t) | |
| 210 | + | wv.EXPECT().ID().Return(port.WebViewID(1)).Once() | |
| 211 | + | c := &NavigationCoordinator{} | |
| 212 | + | err := c.OpenDevToolsWebView(ctx, wv) | |
| 213 | + | if err == nil { | |
| 214 | + | t.Fatal("expected unsupported devtools error, got nil") | |
| 215 | + | } | |
| 216 | + | if got, want := err.Error(), "webview does not support devtools"; got != want { | |
| 217 | + | t.Fatalf("error = %q, want %q", got, want) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | func testOpenDevToolsWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | |
| 222 | + | t.Helper() | |
| 223 | + | base := mocks.NewMockWebView(t) | |
| 224 | + | opener := mocks.NewMockDevToolsOpener(t) | |
| 225 | + | wv := &mockDevToolsWebView{MockWebView: base, MockDevToolsOpener: opener} | |
| 226 | + | base.EXPECT().ID().Return(port.WebViewID(1)).Once() | |
| 227 | + | opener.EXPECT().OpenDevTools().Return().Once() | |
| 228 | + | c := &NavigationCoordinator{} | |
| 229 | + | err := c.OpenDevToolsWebView(ctx, wv) | |
| 230 | + | if err != nil { | |
| 231 | + | t.Fatalf("unexpected error: %v", err) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | func testPrintWebViewNilWebView(t *testing.T, ctx context.Context) { | |
| 236 | + | t.Helper() | |
| 237 | + | c := &NavigationCoordinator{} | |
| 238 | + | err := c.PrintWebView(ctx, nil) | |
| 239 | + | if err == nil { | |
| 240 | + | t.Fatal("expected error for nil webview, got nil") | |
| 241 | + | } | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func testPrintWebViewUnsupportedCapability(t *testing.T, ctx context.Context) { | |
| 245 | + | t.Helper() | |
| 246 | + | wv := mocks.NewMockWebView(t) | |
| 247 | + | wv.EXPECT().ID().Return(port.WebViewID(1)).Once() | |
| 248 | + | c := &NavigationCoordinator{} | |
| 249 | + | err := c.PrintWebView(ctx, wv) | |
| 250 | + | if err == nil { | |
| 251 | + | t.Fatal("expected unsupported printing error, got nil") | |
| 252 | + | } | |
| 253 | + | if got, want := err.Error(), "webview does not support printing"; got != want { | |
| 254 | + | t.Fatalf("error = %q, want %q", got, want) | |
| 255 | + | } | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) { | |
| 259 | + | t.Helper() | |
| 260 | + | base := mocks.NewMockWebView(t) | |
| 261 | + | printer := mocks.NewMockPrinter(t) | |
| 262 | + | wv := &mockPrinterWebView{MockWebView: base, MockPrinter: printer} | |
| 263 | + | base.EXPECT().ID().Return(port.WebViewID(1)).Once() | |
| 264 | + | printer.EXPECT().PrintPage().Return().Once() | |
| 265 | + | c := &NavigationCoordinator{} | |
| 266 | + | err := c.PrintWebView(ctx, wv) | |
| 267 | + | if err != nil { | |
| 268 | + | t.Fatalf("unexpected error: %v", err) | |
| 269 | + | } | |
| 270 | + | } | |
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 +10 -0
| @@ -144,6 +144,16 @@ 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 overlaid tab bars — uses the same 2em token as | |
| 148 | + | .tab-bar min-height and remains static to avoid OSR vertical reallocations. */ | |
| 149 | + | .content-area-tabbar-inset-top { | |
| 150 | + | padding-top: 2em; | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | .content-area-tabbar-inset-bottom { | |
| 154 | + | padding-bottom: 2em; | |
| 155 | + | } | |
| 156 | + | ||
| 147 | 157 | .tab-bar { | |
| 148 | 158 | background-color: var(--surface); | |
| 149 | 159 | border-top: 0.125em solid var(--border); | |
M internal/ui/theme/css_test.go +29 -0
| @@ -185,6 +185,35 @@ 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_ContentAreaTabBarInset(t *testing.T) { | |
| 189 | + | css := GenerateCSS(DefaultDarkPalette()) | |
| 190 | + | ||
| 191 | + | topBlock := cssRuleBlock(t, css, ".content-area-tabbar-inset-top") | |
| 192 | + | if !strings.Contains(topBlock, "padding-top: 2em;") { | |
| 193 | + | t.Fatal("expected padding-top: 2em in .content-area-tabbar-inset-top block") | |
| 194 | + | } | |
| 195 | + | bottomBlock := cssRuleBlock(t, css, ".content-area-tabbar-inset-bottom") | |
| 196 | + | if !strings.Contains(bottomBlock, "padding-bottom: 2em;") { | |
| 197 | + | t.Fatal("expected padding-bottom: 2em in .content-area-tabbar-inset-bottom block") | |
| 198 | + | } | |
| 199 | + | if !strings.Contains(css, ".tab-bar") { | |
| 200 | + | t.Fatal("expected .tab-bar selector in generated CSS") | |
| 201 | + | } | |
| 202 | + | if !strings.Contains(css, "min-height: 2em;") { | |
| 203 | + | t.Fatal("expected min-height: 2em in .tab-bar block") | |
| 204 | + | } | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | func cssRuleBlock(t *testing.T, css, selector string) string { | |
| 208 | + | t.Helper() | |
| 209 | + | re := regexp.MustCompile(regexp.QuoteMeta(selector) + `\s*\{([^}]*)\}`) | |
| 210 | + | matches := re.FindStringSubmatch(css) | |
| 211 | + | if len(matches) != 2 { | |
| 212 | + | t.Fatalf("expected %s selector in generated CSS", selector) | |
| 213 | + | } | |
| 214 | + | return matches[1] | |
| 215 | + | } | |
| 216 | + | ||
| 188 | 217 | func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) { | |
| 189 | 218 | css := GenerateCSS(DefaultDarkPalette()) | |
| 190 | 219 | ||
M internal/ui/window/main_window.go +48 -5
| @@ -104,13 +104,22 @@ 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 while | |
| 121 | + | // the overlay remains non-measured to avoid WebView allocation changes. | |
| 122 | + | mw.contentOverlay.SetMeasureOverlay(tabBarWidget, false) | |
| 114 | 123 | ||
| 115 | 124 | mw.logger.Debug(). | |
| 116 | 125 | Str("tab_bar_position", tabBarPos). | |
| @@ -191,6 +200,40 @@ func (mw *MainWindow) ContentOverlay() *gtk.Overlay { | |||
| 191 | 200 | return mw.contentOverlay | |
| 192 | 201 | } | |
| 193 | 202 | ||
| 203 | + | // SetTabBarContentInsetVisible adds or removes the tab bar inset CSS class | |
| 204 | + | // on the content area. Avoids duplicate add/remove if already in the desired state. | |
| 205 | + | func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) { | |
| 206 | + | if mw.contentArea == nil { | |
| 207 | + | return | |
| 208 | + | } | |
| 209 | + | class := mw.tabBarContentInsetClass() | |
| 210 | + | if visible { | |
| 211 | + | if !mw.contentArea.HasCssClass(class) { | |
| 212 | + | mw.contentArea.AddCssClass(class) | |
| 213 | + | } | |
| 214 | + | } else { | |
| 215 | + | if mw.contentArea.HasCssClass(class) { | |
| 216 | + | mw.contentArea.RemoveCssClass(class) | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // HasTabBarContentInset returns whether the tab bar inset CSS class is currently | |
| 222 | + | // applied to the content area. | |
| 223 | + | func (mw *MainWindow) HasTabBarContentInset() bool { | |
| 224 | + | if mw.contentArea == nil { | |
| 225 | + | return false | |
| 226 | + | } | |
| 227 | + | return mw.contentArea.HasCssClass(mw.tabBarContentInsetClass()) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | func (mw *MainWindow) tabBarContentInsetClass() string { | |
| 231 | + | if mw.tabBarPosition == "bottom" { | |
| 232 | + | return "content-area-tabbar-inset-bottom" | |
| 233 | + | } | |
| 234 | + | return "content-area-tabbar-inset-top" | |
| 235 | + | } | |
| 236 | + | ||
| 194 | 237 | // AddOverlay adds a widget as an overlay above the content area. | |
| 195 | 238 | func (mw *MainWindow) AddOverlay(widget *gtk.Widget) { | |
| 196 | 239 | if mw.contentOverlay != nil && widget != nil { | |