detached 4069a5d

Merge pull request #242 from bnema/fix/window-tab-navigation-isolation

Refactor browser tabs to be window-scoped
commit
4069a5d3bce0a52d35e89adfc087d665b1f8149a
author
bnema · · 2026-05-05 17:57 UTC
committer
GitHub · · 2026-05-05 17:57 UTC
merge of
6066b2a + 3183bf0 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
36 files · +5623 -923
M .github/workflows/aur.yml +3 -1
@@ -81,7 +81,9 @@ jobs:
81 'build() {' \81 'build() {' \
82 ' cd dumber' \82 ' cd dumber' \
83 ' export CGO_ENABLED=0' \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 ' make build' \87 ' make build' \
86 '}' \88 '}' \
87 '' \89 '' \
M .mockery.yaml +1 -1
@@ -15,7 +15,7 @@ packages:
15 DiffFormatter: {}15 DiffFormatter: {}
16 SessionLogger: {}16 SessionLogger: {}
17 SessionSpawner: {}17 SessionSpawner: {}
18- TabListProvider: {}18+ WindowStateProvider: {}
19 WebView: {}19 WebView: {}
20 WebViewFactory: {}20 WebViewFactory: {}
21 WebViewPool: {}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 COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")22 COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
23 BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")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 # Linker flags31 # Linker flags
26 LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)"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 build: build-systemviews ## Build the application (pure Go, no CGO)48 build: build-systemviews ## Build the application (pure Go, no CGO)
43 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."49 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
44 @mkdir -p $(DIST_DIR)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 @rm -f $(DIST_DIR)/cef-helper52 @rm -f $(DIST_DIR)/cef-helper
47 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"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 @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; }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 @mkdir -p assets/systemviews63 @mkdir -p assets/systemviews
58 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js64 @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/systemviews65+ GOFLAGS="$(WASM_GOFLAGS)" GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
60 brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm66 brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm
61 @echo "Systemviews build complete"67 @echo "Systemviews build complete"
62 68
63 build-quick: ## Build quickly for backend development69 build-quick: ## Build quickly for backend development
64 @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."70 @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."
65 @mkdir -p $(DIST_DIR)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 @rm -f $(DIST_DIR)/cef-helper73 @rm -f $(DIST_DIR)/cef-helper
68 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"74 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
69 75
M aur/dumber-browser-git/.SRCINFO +4 -3
@@ -1,12 +1,13 @@
1 pkgbase = dumber-browser-git1 pkgbase = dumber-browser-git
2 pkgdesc = A minimal keyboard-driven browser for tiling WMs (git version)2 pkgdesc = A minimal keyboard-driven browser for tiling WMs (git version)
3- pkgver = 0.23.1.r0.g6c872d53+ pkgver = 0.28.0.r367.g6066b2a7
4- pkgrel = 14+ pkgrel = 2
5 url = https://github.com/bnema/dumber5 url = https://github.com/bnema/dumber
6 arch = x86_646 arch = x86_64
7 license = MIT7 license = MIT
8- makedepends = go>=1.258+ makedepends = go>=1.26
9 makedepends = git9 makedepends = git
10+ makedepends = brotli
10 depends = gtk411 depends = gtk4
11 depends = webkitgtk-6.012 depends = webkitgtk-6.0
12 optdepends = gst-plugins-base: Base media codecs13 optdepends = gst-plugins-base: Base media codecs
M aur/dumber-browser-git/PKGBUILD +5 -3
@@ -1,7 +1,7 @@
1 # Maintainer: bnema <b at bnema dot dev>1 # Maintainer: bnema <b at bnema dot dev>
2 pkgname=dumber-browser-git2 pkgname=dumber-browser-git
3-pkgver=0.23.1.r0.g6c872d53+pkgver=0.28.0.r367.g6066b2a7
4-pkgrel=14+pkgrel=2
5 pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)"5 pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)"
6 arch=('x86_64')6 arch=('x86_64')
7 url="https://github.com/bnema/dumber"7 url="https://github.com/bnema/dumber"
@@ -36,7 +36,9 @@ pkgver() {
36 build() {36 build() {
37 cd dumber37 cd dumber
38 export CGO_ENABLED=038 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 make build42 make build
41 }43 }
42 44
M cmd/dumber/main.go +1 -1
@@ -845,7 +845,7 @@ func buildUIDependencies(
845 SessionStateRepo: repos.sessionState,845 SessionStateRepo: repos.sessionState,
846 CurrentSessionID: currentSessionID,846 CurrentSessionID: currentSessionID,
847 SnapshotUC: uc.snapshot,847 SnapshotUC: uc.snapshot,
848- SnapshotServiceFactory: func(provider port.TabListProvider, intervalMs int) port.SnapshotService {848+ SnapshotServiceFactory: func(provider port.WindowStateProvider, intervalMs int) port.SnapshotService {
849 return snapshot.NewService(uc.snapshot, provider, intervalMs)849 return snapshot.NewService(uc.snapshot, provider, intervalMs)
850 },850 },
851 CheckUpdateUC: uc.checkUpdate,851 CheckUpdateUC: uc.checkUpdate,
M internal/application/port/mocks/mocks.go +132 -33
@@ -10675,13 +10675,13 @@ func (_c *MockSessionLogger_CreateLogger_Call) RunAndReturn(run func(ctx context
10675 return _c10675 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 // The first argument is typically a *testing.T value.10679 // The first argument is typically a *testing.T value.
10680-func NewMockTabListProvider(t interface {10680+func NewMockWindowStateProvider(t interface {
10681 mock.TestingT10681 mock.TestingT
10682 Cleanup(func())10682 Cleanup(func())
10683-}) *MockTabListProvider {10683+}) *MockWindowStateProvider {
10684- mock := &MockTabListProvider{}10684+ mock := &MockWindowStateProvider{}
10685 mock.Mock.Test(t)10685 mock.Mock.Test(t)
10686 10686
10687 t.Cleanup(func() { mock.AssertExpectations(t) })10687 t.Cleanup(func() { mock.AssertExpectations(t) })
@@ -10689,21 +10689,65 @@ func NewMockTabListProvider(t interface {
10689 return mock10689 return mock
10690 }10690 }
10691 10691
10692-// MockTabListProvider is an autogenerated mock type for the TabListProvider type10692+// MockWindowStateProvider is an autogenerated mock type for the WindowStateProvider type
10693-type MockTabListProvider struct {10693+type MockWindowStateProvider struct {
10694 mock.Mock10694 mock.Mock
10695 }10695 }
10696 10696
10697-type MockTabListProvider_Expecter struct {10697+type MockWindowStateProvider_Expecter struct {
10698 mock *mock.Mock10698 mock *mock.Mock
10699 }10699 }
10700 10700
10701-func (_m *MockTabListProvider) EXPECT() *MockTabListProvider_Expecter {10701+func (_m *MockWindowStateProvider) EXPECT() *MockWindowStateProvider_Expecter {
10702- return &MockTabListProvider_Expecter{mock: &_m.Mock}10702+ return &MockWindowStateProvider_Expecter{mock: &_m.Mock}
10703 }10703 }
10704 10704
10705-// GetSessionID provides a mock function for the type MockTabListProvider10705+// GetActiveWindowIndex provides a mock function for the type MockWindowStateProvider
10706-func (_mock *MockTabListProvider) GetSessionID() entity.SessionID {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 ret := _mock.Called()10751 ret := _mock.Called()
10708 10752
10709 if len(ret) == 0 {10753 if len(ret) == 0 {
@@ -10719,75 +10763,130 @@ func (_mock *MockTabListProvider) GetSessionID() entity.SessionID {
10719 return r010763 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'10766+// MockWindowStateProvider_GetSessionID_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetSessionID'
10723-type MockTabListProvider_GetSessionID_Call struct {10767+type MockWindowStateProvider_GetSessionID_Call struct {
10724 *mock.Call10768 *mock.Call
10725 }10769 }
10726 10770
10727 // GetSessionID is a helper method to define mock.On call10771 // GetSessionID is a helper method to define mock.On call
10728-func (_e *MockTabListProvider_Expecter) GetSessionID() *MockTabListProvider_GetSessionID_Call {10772+func (_e *MockWindowStateProvider_Expecter) GetSessionID() *MockWindowStateProvider_GetSessionID_Call {
10729- return &MockTabListProvider_GetSessionID_Call{Call: _e.mock.On("GetSessionID")}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 _c.Call.Run(func(args mock.Arguments) {10777 _c.Call.Run(func(args mock.Arguments) {
10734 run()10778 run()
10735 })10779 })
10736 return _c10780 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 _c.Call.Return(sessionID)10784 _c.Call.Return(sessionID)
10741 return _c10785 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 _c.Call.Return(run)10844 _c.Call.Return(run)
10746 return _c10845 return _c
10747 }10846 }
10748 10847
10749-// GetTabList provides a mock function for the type MockTabListProvider10848+// GetWindowTabLists provides a mock function for the type MockWindowStateProvider
10750-func (_mock *MockTabListProvider) GetTabList() *entity.TabList {10849+func (_mock *MockWindowStateProvider) GetWindowTabLists() []entity.WindowTabListState {
10751 ret := _mock.Called()10850 ret := _mock.Called()
10752 10851
10753 if len(ret) == 0 {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.TabList10856+ var r0 []entity.WindowTabListState
10758- if returnFunc, ok := ret.Get(0).(func() *entity.TabList); ok {10857+ if returnFunc, ok := ret.Get(0).(func() []entity.WindowTabListState); ok {
10759 r0 = returnFunc()10858 r0 = returnFunc()
10760 } else {10859 } else {
10761 if ret.Get(0) != nil {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 return r010864 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'10867+// MockWindowStateProvider_GetWindowTabLists_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetWindowTabLists'
10769-type MockTabListProvider_GetTabList_Call struct {10868+type MockWindowStateProvider_GetWindowTabLists_Call struct {
10770 *mock.Call10869 *mock.Call
10771 }10870 }
10772 10871
10773-// GetTabList is a helper method to define mock.On call10872+// GetWindowTabLists is a helper method to define mock.On call
10774-func (_e *MockTabListProvider_Expecter) GetTabList() *MockTabListProvider_GetTabList_Call {10873+func (_e *MockWindowStateProvider_Expecter) GetWindowTabLists() *MockWindowStateProvider_GetWindowTabLists_Call {
10775- return &MockTabListProvider_GetTabList_Call{Call: _e.mock.On("GetTabList")}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 _c.Call.Run(func(args mock.Arguments) {10878 _c.Call.Run(func(args mock.Arguments) {
10780 run()10879 run()
10781 })10880 })
10782 return _c10881 return _c
10783 }10882 }
10784 10883
10785-func (_c *MockTabListProvider_GetTabList_Call) Return(tabList *entity.TabList) *MockTabListProvider_GetTabList_Call {10884+func (_c *MockWindowStateProvider_GetWindowTabLists_Call) Return(windowTabListStates []entity.WindowTabListState) *MockWindowStateProvider_GetWindowTabLists_Call {
10786- _c.Call.Return(tabList)10885+ _c.Call.Return(windowTabListStates)
10787 return _c10886 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 _c.Call.Return(run)10890 _c.Call.Return(run)
10792 return _c10891 return _c
10793 }10892 }
M internal/application/port/session_state.go +11 -5
@@ -6,11 +6,17 @@ import (
6 "github.com/bnema/dumber/internal/domain/entity"6 "github.com/bnema/dumber/internal/domain/entity"
7 )7 )
8 8
9-// TabListProvider provides access to the current tab list state.9+// WindowStateProvider provides window-scoped browser state for v2 snapshots.
10-// Implemented by the UI layer to allow the snapshot service to read state.10+// Implemented by the UI layer and consumed by the snapshot service.
11-type TabListProvider interface {11+type WindowStateProvider interface {
12- // GetTabList returns the current tab list state.12+ // GetWindowSnapshotState returns the ordered window tab lists and active window index atomically.
13- GetTabList() *entity.TabList13+ 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 // GetSessionID returns the current session ID.20 // GetSessionID returns the current session ID.
15 GetSessionID() entity.SessionID21 GetSessionID() entity.SessionID
16 }22 }
M internal/application/usecase/snapshot_session.go +28 -6
@@ -3,6 +3,7 @@ package usecase
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "time"
6 7
7 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
8 "github.com/bnema/dumber/internal/domain/repository"9 "github.com/bnema/dumber/internal/domain/repository"
@@ -23,23 +24,44 @@ func NewSnapshotSessionUseCase(stateRepo repository.SessionStateRepository) *Sna
23 type SnapshotInput struct {24 type SnapshotInput struct {
24 SessionID entity.SessionID25 SessionID entity.SessionID
25 TabList *entity.TabList26 TabList *entity.TabList
27+
28+ // v2: window-scoped snapshot. A non-nil slice, including an empty one,
29+ // requests the v2 path; leave nil when using the legacy TabList path.
30+ Windows []entity.WindowTabListState
31+ ActiveWindowIndex int
26 }32 }
27 33
28 // Execute creates a snapshot of the current session state and saves it.34 // Execute creates a snapshot of the current session state and saves it.
35+// When Windows is non-nil, uses the v2 window-scoped path, including explicit
36+// zero-window snapshots. Otherwise falls back to the legacy flat TabList path.
29 func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error {37 func (uc *SnapshotSessionUseCase) Execute(ctx context.Context, input SnapshotInput) error {
30 log := logging.FromContext(ctx)38 log := logging.FromContext(ctx)
31 39
32 if input.SessionID == "" {40 if input.SessionID == "" {
33 return fmt.Errorf("session id required")41 return fmt.Errorf("session id required")
34 }42 }
43+ if input.Windows != nil && input.TabList != nil {
44+ return fmt.Errorf("windows and tab list are mutually exclusive")
45+ }
35 46
36- state := entity.SnapshotFromTabList(input.SessionID, input.TabList)47+ var state *entity.SessionState
37 48
38- log.Debug().49+ if input.Windows != nil {
39- Str("session_id", string(input.SessionID)).50+ state = entity.SnapshotFromWindowTabLists(input.SessionID, input.Windows, input.ActiveWindowIndex, time.Now())
40- Int("tab_count", len(state.Tabs)).51+ log.Debug().
41- Int("pane_count", state.CountPanes()).52+ Str("session_id", string(input.SessionID)).
42- Msg("creating session snapshot")53+ Int("window_count", len(state.Windows)).
54+ Int("active_window", state.ActiveWindowIndex).
55+ Int("pane_count", state.CountPanes()).
56+ Msg("creating window-scoped session snapshot")
57+ } else {
58+ state = entity.SnapshotFromTabList(input.SessionID, input.TabList)
59+ log.Debug().
60+ Str("session_id", string(input.SessionID)).
61+ Int("tab_count", len(state.Tabs)).
62+ Int("pane_count", state.CountPanes()).
63+ Msg("creating session snapshot")
64+ }
43 65
44 if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil {66 if err := uc.stateRepo.SaveSnapshot(ctx, state); err != nil {
45 return fmt.Errorf("save session snapshot: %w", err)67 return fmt.Errorf("save session snapshot: %w", err)
M internal/application/usecase/snapshot_session_test.go +102 -2
@@ -34,7 +34,7 @@ func TestSnapshotSessionUseCase_Execute_SavesSnapshot(t *testing.T) {
34 require.Equal(t, sessionID, state.SessionID)34 require.Equal(t, sessionID, state.SessionID)
35 require.Len(t, state.Tabs, 1)35 require.Len(t, state.Tabs, 1)
36 require.Equal(t, "Test Tab", state.Tabs[0].Name)36 require.Equal(t, "Test Tab", state.Tabs[0].Name)
37- require.Equal(t, entity.SessionStateVersion, state.Version)37+ require.Equal(t, entity.LegacySessionStateVersion, state.Version)
38 }).38 }).
39 Return(nil)39 Return(nil)
40 40
@@ -85,6 +85,47 @@ func TestSnapshotSessionUseCase_Execute_RequiresSessionID(t *testing.T) {
85 assert.Contains(t, err.Error(), "session id required")85 assert.Contains(t, err.Error(), "session id required")
86 }86 }
87 87
88+func TestSnapshotSessionUseCase_Execute_RejectsAmbiguousWindowAndTabListInput(t *testing.T) {
89+ ctx := testContext()
90+
91+ stateRepo := repomocks.NewMockSessionStateRepository(t)
92+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
93+
94+ err := uc.Execute(ctx, usecase.SnapshotInput{
95+ SessionID: entity.SessionID("20251224_120000_ambiguous"),
96+ TabList: entity.NewTabList(),
97+ Windows: []entity.WindowTabListState{},
98+ })
99+ require.Error(t, err)
100+ assert.Contains(t, err.Error(), "windows and tab list are mutually exclusive")
101+}
102+
103+func TestSnapshotSessionUseCase_Execute_SavesEmptyWindowSnapshot(t *testing.T) {
104+ ctx := testContext()
105+
106+ stateRepo := repomocks.NewMockSessionStateRepository(t)
107+ sessionID := entity.SessionID("20251224_120000_empty_v2_windows")
108+
109+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
110+ Run(func(_ context.Context, state *entity.SessionState) {
111+ require.Equal(t, sessionID, state.SessionID)
112+ require.Equal(t, entity.SessionStateVersion, state.Version)
113+ require.Empty(t, state.Windows)
114+ require.Empty(t, state.Tabs)
115+ assert.Equal(t, 0, state.ActiveWindowIndex)
116+ }).
117+ Return(nil)
118+
119+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
120+
121+ err := uc.Execute(ctx, usecase.SnapshotInput{
122+ SessionID: sessionID,
123+ Windows: []entity.WindowTabListState{},
124+ ActiveWindowIndex: 7,
125+ })
126+ require.NoError(t, err)
127+}
128+
88 func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {129 func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {
89 sessionID := entity.SessionID("20251224_120000_snap")130 sessionID := entity.SessionID("20251224_120000_snap")
90 131
@@ -108,7 +149,7 @@ func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {
108 state := entity.SnapshotFromTabList(sessionID, tabList)149 state := entity.SnapshotFromTabList(sessionID, tabList)
109 150
110 require.NotNil(t, state)151 require.NotNil(t, state)
111- assert.Equal(t, entity.SessionStateVersion, state.Version)152+ assert.Equal(t, entity.LegacySessionStateVersion, state.Version)
112 assert.Equal(t, sessionID, state.SessionID)153 assert.Equal(t, sessionID, state.SessionID)
113 assert.Len(t, state.Tabs, 1)154 assert.Len(t, state.Tabs, 1)
114 assert.Equal(t, "Dev Tab", state.Tabs[0].Name)155 assert.Equal(t, "Dev Tab", state.Tabs[0].Name)
@@ -119,6 +160,65 @@ func TestSnapshotFromTabList_CreatesCorrectSnapshot(t *testing.T) {
119 assert.False(t, state.SavedAt.IsZero())160 assert.False(t, state.SavedAt.IsZero())
120 }161 }
121 162
163+func TestSnapshotSessionUseCase_Execute_SavesWindowSnapshots(t *testing.T) {
164+ ctx := testContext()
165+
166+ stateRepo := repomocks.NewMockSessionStateRepository(t)
167+
168+ // Create two windows, each with tabs
169+ pane1_1 := entity.NewPane("p1_w1")
170+ pane1_1.URI = "https://window1.com"
171+ tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1_1)
172+ tab1_1.Name = "Window1 Tab1"
173+
174+ pane1_2 := entity.NewPane("p1_w2")
175+ pane1_2.URI = "https://window2.com"
176+ tab1_2 := entity.NewTab("t1_w2", "ws1_w2", pane1_2)
177+ tab1_2.Name = "Window2 Tab1"
178+
179+ tabs1 := entity.NewTabList()
180+ tabs1.Add(tab1_1)
181+ tabs2 := entity.NewTabList()
182+ tabs2.Add(tab1_2)
183+
184+ sessionID := entity.SessionID("20251224_120000_v2_windows")
185+
186+ windows := []entity.WindowTabListState{
187+ {WindowID: "w1", Tabs: tabs1},
188+ {WindowID: "w2", Tabs: tabs2},
189+ }
190+
191+ stateRepo.EXPECT().SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
192+ Run(func(_ context.Context, state *entity.SessionState) {
193+ require.Equal(t, sessionID, state.SessionID)
194+ require.Equal(t, 2, state.Version)
195+ // Windows field populated
196+ require.Len(t, state.Windows, 2)
197+ assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID)
198+ assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID)
199+ // Window 1 has 1 tab
200+ require.Len(t, state.Windows[0].Tabs, 1)
201+ assert.Equal(t, "Window1 Tab1", state.Windows[0].Tabs[0].Name)
202+ // Window 2 has 1 tab
203+ require.Len(t, state.Windows[1].Tabs, 1)
204+ assert.Equal(t, "Window2 Tab1", state.Windows[1].Tabs[0].Name)
205+ // Active window index set
206+ assert.Equal(t, 0, state.ActiveWindowIndex)
207+ // Legacy Tabs empty when windows are provided
208+ assert.Empty(t, state.Tabs)
209+ }).
210+ Return(nil)
211+
212+ uc := usecase.NewSnapshotSessionUseCase(stateRepo)
213+
214+ err := uc.Execute(ctx, usecase.SnapshotInput{
215+ SessionID: sessionID,
216+ Windows: windows,
217+ ActiveWindowIndex: 0,
218+ })
219+ require.NoError(t, err)
220+}
221+
122 func TestSessionState_CountPanes(t *testing.T) {222 func TestSessionState_CountPanes(t *testing.T) {
123 state := &entity.SessionState{223 state := &entity.SessionState{
124 Tabs: []entity.TabSnapshot{224 Tabs: []entity.TabSnapshot{
M internal/domain/entity/session_state.go +67 -16
@@ -2,18 +2,24 @@ package entity
2 2
3 import "time"3 import "time"
4 4
5+const LegacySessionStateVersion = 1
6+
5 // SessionStateVersion is the current schema version for session state.7 // SessionStateVersion is the current schema version for session state.
6 // Increment when making breaking changes to the serialization format.8 // Increment when making breaking changes to the serialization format.
7-const SessionStateVersion = 19+// Version 2: added window-scoped sessions (Windows, ActiveWindowIndex).
10+const SessionStateVersion = 2
8 11
9 // SessionState represents a complete snapshot of a browser session.12 // SessionState represents a complete snapshot of a browser session.
10 // This is serialized to JSON and stored in the database.13 // This is serialized to JSON and stored in the database.
11 type SessionState struct {14 type SessionState struct {
12 Version int `json:"version"`15 Version int `json:"version"`
13 SessionID SessionID `json:"session_id"`16 SessionID SessionID `json:"session_id"`
14- Tabs []TabSnapshot `json:"tabs"`17+ Tabs []TabSnapshot `json:"tabs,omitempty"`
15- ActiveTabIndex int `json:"active_tab_index"`18+ ActiveTabIndex int `json:"active_tab_index,omitempty"`
16- SavedAt time.Time `json:"saved_at"`19+ // v2: window-scoped sessions
20+ Windows []WindowSnapshot `json:"windows,omitempty"`
21+ ActiveWindowIndex int `json:"active_window_index,omitempty"`
22+ SavedAt time.Time `json:"saved_at"`
17 }23 }
18 24
19 // TabSnapshot captures the state of a single tab.25 // TabSnapshot captures the state of a single tab.
@@ -55,7 +61,7 @@ type PaneSnapshot struct {
55 func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState {61 func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState {
56 if tabs == nil {62 if tabs == nil {
57 return &SessionState{63 return &SessionState{
58- Version: SessionStateVersion,64+ Version: LegacySessionStateVersion,
59 SessionID: sessionID,65 SessionID: sessionID,
60 Tabs: []TabSnapshot{},66 Tabs: []TabSnapshot{},
61 SavedAt: time.Now(),67 SavedAt: time.Now(),
@@ -73,7 +79,7 @@ func SnapshotFromTabList(sessionID SessionID, tabs *TabList) *SessionState {
73 }79 }
74 80
75 return &SessionState{81 return &SessionState{
76- Version: SessionStateVersion,82+ Version: LegacySessionStateVersion,
77 SessionID: sessionID,83 SessionID: sessionID,
78 Tabs: snapTabs,84 Tabs: snapTabs,
79 ActiveTabIndex: activeTabIndex,85 ActiveTabIndex: activeTabIndex,
@@ -147,6 +153,17 @@ type SessionInfo struct {
147 153
148 // CountPanes returns the total number of panes in the session state.154 // CountPanes returns the total number of panes in the session state.
149 func (s *SessionState) CountPanes() int {155 func (s *SessionState) CountPanes() int {
156+ // v2: count across windows
157+ if 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 count := 0167 count := 0
151 for _, tab := range s.Tabs {168 for _, tab := range s.Tabs {
152 count += countPanesInNode(tab.Workspace.Root)169 count += countPanesInNode(tab.Workspace.Root)
@@ -171,17 +188,12 @@ func countPanesInNode(node *PaneNodeSnapshot) int {
171 // IDGenerator is a function that generates unique IDs.188 // IDGenerator is a function that generates unique IDs.
172 type IDGenerator func() string189 type IDGenerator func() string
173 190
174-// TabListFromSnapshot reconstructs a TabList from a SessionState snapshot.191+// tabListFromSnapshots converts a slice of TabSnapshots to a live TabList.
175-// Generates new IDs for all entities using the provided generator.192+// activeIndex is the index in snapshots that should become the active tab.
176-// This is the inverse of SnapshotFromTabList.193+func tabListFromSnapshots(snaps []TabSnapshot, activeIndex int, idGen IDGenerator) *TabList {
177-func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
178- if state == nil {
179- return NewTabList()
180- }
181-
182 tabs := NewTabList()194 tabs := NewTabList()
183 195
184- for i, tabSnap := range state.Tabs {196+ for i, tabSnap := range snaps {
185 tab := tabFromSnapshot(&tabSnap, idGen)197 tab := tabFromSnapshot(&tabSnap, idGen)
186 if tab == nil {198 if tab == nil {
187 continue199 continue
@@ -189,7 +201,7 @@ func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
189 tabs.Tabs = append(tabs.Tabs, tab)201 tabs.Tabs = append(tabs.Tabs, tab)
190 tab.Position = len(tabs.Tabs) - 1202 tab.Position = len(tabs.Tabs) - 1
191 203
192- if i == state.ActiveTabIndex {204+ if i == activeIndex {
193 tabs.ActiveTabID = tab.ID205 tabs.ActiveTabID = tab.ID
194 }206 }
195 }207 }
@@ -202,6 +214,45 @@ func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
202 return tabs214 return tabs
203 }215 }
204 216
217+// TabListFromSnapshot reconstructs a TabList from a SessionState snapshot.
218+// Generates new IDs for all entities using the provided generator.
219+// This is the inverse of SnapshotFromTabList.
220+func TabListFromSnapshot(state *SessionState, idGen IDGenerator) *TabList {
221+ if state == nil {
222+ return NewTabList()
223+ }
224+ if state.Version >= SessionStateVersion {
225+ tabs, activeIndex := flattenWindowTabSnapshots(state.Windows, state.ActiveWindowIndex)
226+ return tabListFromSnapshots(tabs, activeIndex, idGen)
227+ }
228+
229+ return tabListFromSnapshots(state.Tabs, state.ActiveTabIndex, idGen)
230+}
231+
232+func flattenWindowTabSnapshots(windows []WindowSnapshot, activeWindowIndex int) ([]TabSnapshot, int) {
233+ if activeWindowIndex < 0 || activeWindowIndex >= len(windows) {
234+ activeWindowIndex = 0
235+ }
236+ flattened := make([]TabSnapshot, 0)
237+ activeTabIndex := 0
238+ for i, win := range windows {
239+ if i == activeWindowIndex {
240+ if len(win.Tabs) == 0 {
241+ // Active window has zero tabs; activeTabIndex stays at 0
242+ // (first tab overall if any), not len(flattened).
243+ } else {
244+ winActiveIndex := win.ActiveTabIndex
245+ if winActiveIndex < 0 || winActiveIndex >= len(win.Tabs) {
246+ winActiveIndex = 0
247+ }
248+ activeTabIndex = len(flattened) + winActiveIndex
249+ }
250+ }
251+ flattened = append(flattened, win.Tabs...)
252+ }
253+ return flattened, activeTabIndex
254+}
255+
205 func tabFromSnapshot(snap *TabSnapshot, idGen IDGenerator) *Tab {256 func tabFromSnapshot(snap *TabSnapshot, idGen IDGenerator) *Tab {
206 if snap == nil {257 if snap == nil {
207 return nil258 return nil
M internal/domain/entity/session_state_test.go +387 -0
@@ -1,6 +1,7 @@
1 package entity_test1 package entity_test
2 2
3 import (3 import (
4+ "encoding/json"
4 "sync/atomic"5 "sync/atomic"
5 "testing"6 "testing"
6 "time"7 "time"
@@ -261,6 +262,59 @@ func TestTabList_ReplaceFrom(t *testing.T) {
261 assert.Equal(t, "https://new1.com", ref.Tabs[0].Workspace.Root.Pane.URI)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 func TestTabList_ReplaceFrom_Nil(t *testing.T) {318 func TestTabList_ReplaceFrom_Nil(t *testing.T) {
265 original := entity.NewTabList()319 original := entity.NewTabList()
266 pane := entity.NewPane("pane1")320 pane := entity.NewPane("pane1")
@@ -380,6 +434,263 @@ func TestFindPaneAcrossTabs(t *testing.T) {
380 assert.Nil(t, notFound)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 func TestFindPaneInNestedStructure(t *testing.T) {694 func TestFindPaneInNestedStructure(t *testing.T) {
384 // Create a tab with split panes695 // Create a tab with split panes
385 pane1 := entity.NewPane("left_pane")696 pane1 := entity.NewPane("left_pane")
@@ -428,3 +739,79 @@ func TestFindPaneInNestedStructure(t *testing.T) {
428 rightNodeAgain := tab.Workspace.FindPane("right_pane")739 rightNodeAgain := tab.Workspace.FindPane("right_pane")
429 assert.Equal(t, "https://right-updated.com", rightNodeAgain.Pane.URI)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 import (3 import (
4 "fmt"4 "fmt"
5+ "sync"
5 "time"6 "time"
6 )7 )
7 8
@@ -51,6 +52,8 @@ type TabList struct {
51 Tabs []*Tab52 Tabs []*Tab
52 ActiveTabID TabID53 ActiveTabID TabID
53 PreviousActiveTabID TabID // Tracks last active tab for Alt+Tab style switching54 PreviousActiveTabID TabID // Tracks last active tab for Alt+Tab style switching
55+
56+ mu sync.RWMutex
54 }57 }
55 58
56 // NewTabList creates an empty tab list.59 // NewTabList creates an empty tab list.
@@ -62,6 +65,9 @@ func NewTabList() *TabList {
62 65
63 // Add appends a tab to the list.66 // Add appends a tab to the list.
64 func (tl *TabList) Add(tab *Tab) {67 func (tl *TabList) Add(tab *Tab) {
68+ tl.mu.Lock()
69+ defer tl.mu.Unlock()
70+
65 tab.Position = len(tl.Tabs)71 tab.Position = len(tl.Tabs)
66 tl.Tabs = append(tl.Tabs, tab)72 tl.Tabs = append(tl.Tabs, tab)
67 if tl.ActiveTabID == "" {73 if tl.ActiveTabID == "" {
@@ -71,6 +77,9 @@ func (tl *TabList) Add(tab *Tab) {
71 77
72 // Remove removes a tab by ID and reindexes positions.78 // Remove removes a tab by ID and reindexes positions.
73 func (tl *TabList) Remove(id TabID) bool {79 func (tl *TabList) Remove(id TabID) bool {
80+ tl.mu.Lock()
81+ defer tl.mu.Unlock()
82+
74 for i, tab := range tl.Tabs {83 for i, tab := range tl.Tabs {
75 if tab.ID == id {84 if tab.ID == id {
76 tl.Tabs = append(tl.Tabs[:i], tl.Tabs[i+1:]...)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 // Find returns a tab by ID.109 // Find returns a tab by ID.
101 func (tl *TabList) Find(id TabID) *Tab {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 for _, tab := range tl.Tabs {118 for _, tab := range tl.Tabs {
103 if tab.ID == id {119 if tab.ID == id {
104 return tab120 return tab
@@ -109,16 +125,25 @@ func (tl *TabList) Find(id TabID) *Tab {
109 125
110 // ActiveTab returns the currently active tab.126 // ActiveTab returns the currently active tab.
111 func (tl *TabList) ActiveTab() *Tab {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 // Count returns the number of tabs.134 // Count returns the number of tabs.
116 func (tl *TabList) Count() int {135 func (tl *TabList) Count() int {
136+ tl.mu.RLock()
137+ defer tl.mu.RUnlock()
138+
117 return len(tl.Tabs)139 return len(tl.Tabs)
118 }140 }
119 141
120 // SetActive sets the active tab and updates the previous active tab.142 // SetActive sets the active tab and updates the previous active tab.
121 func (tl *TabList) SetActive(id TabID) {143 func (tl *TabList) SetActive(id TabID) {
144+ tl.mu.Lock()
145+ defer tl.mu.Unlock()
146+
122 if id != tl.ActiveTabID && tl.ActiveTabID != "" {147 if id != tl.ActiveTabID && tl.ActiveTabID != "" {
123 tl.PreviousActiveTabID = tl.ActiveTabID148 tl.PreviousActiveTabID = tl.ActiveTabID
124 }149 }
@@ -127,6 +152,9 @@ func (tl *TabList) SetActive(id TabID) {
127 152
128 // TabAt returns the tab at the given 0-based index.153 // TabAt returns the tab at the given 0-based index.
129 func (tl *TabList) TabAt(index int) *Tab {154 func (tl *TabList) TabAt(index int) *Tab {
155+ tl.mu.RLock()
156+ defer tl.mu.RUnlock()
157+
130 if index < 0 || index >= len(tl.Tabs) {158 if index < 0 || index >= len(tl.Tabs) {
131 return nil159 return nil
132 }160 }
@@ -135,6 +163,9 @@ func (tl *TabList) TabAt(index int) *Tab {
135 163
136 // Move moves a tab to a new position.164 // Move moves a tab to a new position.
137 func (tl *TabList) Move(id TabID, newPos int) bool {165 func (tl *TabList) Move(id TabID, newPos int) bool {
166+ tl.mu.Lock()
167+ defer tl.mu.Unlock()
168+
138 if newPos < 0 || newPos >= len(tl.Tabs) {169 if newPos < 0 || newPos >= len(tl.Tabs) {
139 return false170 return false
140 }171 }
@@ -161,16 +192,89 @@ func (tl *TabList) Move(id TabID, newPos int) bool {
161 return true192 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 // ReplaceFrom replaces this TabList's contents with those from another TabList.263 // ReplaceFrom replaces this TabList's contents with those from another TabList.
165 // This modifies in-place so existing references to this TabList remain valid.264 // This modifies in-place so existing references to this TabList remain valid.
166 func (tl *TabList) ReplaceFrom(other *TabList) {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 tl.Tabs = make([]*Tab, 0)272 tl.Tabs = make([]*Tab, 0)
169 tl.ActiveTabID = ""273 tl.ActiveTabID = ""
170 tl.PreviousActiveTabID = ""274 tl.PreviousActiveTabID = ""
171 return275 return
172 }276 }
173- tl.Tabs = other.Tabs277+ tl.Tabs = snapshot.Tabs
174- tl.ActiveTabID = other.ActiveTabID278+ tl.ActiveTabID = snapshot.ActiveTabID
175- tl.PreviousActiveTabID = other.PreviousActiveTabID279+ 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 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"12 "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
14 )15 )
15 16
@@ -25,7 +26,7 @@ var _ port.SnapshotService = (*Service)(nil)
25 // Service handles debounced session state snapshots.26 // Service handles debounced session state snapshots.
26 type Service struct {27 type Service struct {
27 snapshotUC *usecase.SnapshotSessionUseCase28 snapshotUC *usecase.SnapshotSessionUseCase
28- provider port.TabListProvider29+ provider port.WindowStateProvider
29 interval time.Duration30 interval time.Duration
30 retries int31 retries int
31 retryDelay time.Duration32 retryDelay time.Duration
@@ -41,7 +42,7 @@ type Service struct {
41 // NewService creates a new snapshot service.42 // NewService creates a new snapshot service.
42 func NewService(43 func NewService(
43 snapshotUC *usecase.SnapshotSessionUseCase,44 snapshotUC *usecase.SnapshotSessionUseCase,
44- provider port.TabListProvider,45+ provider port.WindowStateProvider,
45 intervalMs int,46 intervalMs int,
46 ) *Service {47 ) *Service {
47 if intervalMs <= 0 {48 if intervalMs <= 0 {
@@ -163,7 +164,6 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
163 s.dirty = false164 s.dirty = false
164 s.mu.Unlock()165 s.mu.Unlock()
165 166
166- tabList := s.provider.GetTabList()
167 sessionID := s.provider.GetSessionID()167 sessionID := s.provider.GetSessionID()
168 168
169 if sessionID == "" {169 if sessionID == "" {
@@ -171,10 +171,23 @@ func (s *Service) saveSnapshot(ctx context.Context) error {
171 return nil171 return nil
172 }172 }
173 173
174- if err := s.executeWithRetry(ctx, usecase.SnapshotInput{174+ windows, activeWindowIndex := s.provider.GetWindowSnapshotState()
175- SessionID: sessionID,175+ if windows == nil {
176- TabList: tabList,176+ windows = make([]entity.WindowTabListState, 0)
177- }); err != nil {177+ }
178+ input := usecase.SnapshotInput{
179+ SessionID: sessionID,
180+ Windows: windows,
181+ ActiveWindowIndex: activeWindowIndex,
182+ }
183+ // single window with WindowID == "" is legacy v1 sentinel; populate TabList and clear Windows/ActiveWindowIndex.
184+ if len(windows) == 1 && windows[0].WindowID == "" {
185+ input.Windows = nil
186+ input.ActiveWindowIndex = 0
187+ input.TabList = windows[0].Tabs
188+ }
189+
190+ if err := s.executeWithRetry(ctx, input); err != nil {
178 s.markDirty()191 s.markDirty()
179 return err192 return err
180 }193 }
M internal/infrastructure/snapshot/service_test.go +123 -27
@@ -6,6 +6,7 @@ import (
6 "testing"6 "testing"
7 "time"7 "time"
8 8
9+ "github.com/bnema/dumber/internal/application/port/mocks"
9 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
11 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"12 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
@@ -14,17 +15,16 @@ import (
14 "github.com/stretchr/testify/require"15 "github.com/stretchr/testify/require"
15 )16 )
16 17
17-type testProvider struct {18+func newWindowStateProvider(
18- sessionID entity.SessionID19+ t *testing.T,
19- tabList *entity.TabList20+ sessionID entity.SessionID,
20-}21+ windows []entity.WindowTabListState,
21-22+ activeWindowIndex int,
22-func (p *testProvider) GetTabList() *entity.TabList {23+) *mocks.MockWindowStateProvider {
23- return p.tabList24+ provider := mocks.NewMockWindowStateProvider(t)
24-}25+ provider.EXPECT().GetSessionID().Return(sessionID).Once()
25-26+ provider.EXPECT().GetWindowSnapshotState().Return(windows, activeWindowIndex).Once()
26-func (p *testProvider) GetSessionID() entity.SessionID {27+ return provider
27- return p.sessionID
28 }28 }
29 29
30 func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {30 func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {
@@ -41,10 +41,7 @@ func TestService_SaveSnapshot_RetriesTransientFKAndSucceeds(t *testing.T) {
41 })41 })
42 42
43 uc := usecase.NewSnapshotSessionUseCase(repo)43 uc := usecase.NewSnapshotSessionUseCase(repo)
44- svc := NewService(uc, &testProvider{44+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fk_retry_ok", nil, 0), 1)
45- sessionID: "20260207_120000_fk_retry_ok",
46- tabList: entity.NewTabList(),
47- }, 1)
48 svc.retryDelay = time.Millisecond45 svc.retryDelay = time.Millisecond
49 svc.ready = true46 svc.ready = true
50 svc.dirty = true47 svc.dirty = true
@@ -67,10 +64,7 @@ func TestService_SaveSnapshot_RetriesTransientFKAndFails(t *testing.T) {
67 })64 })
68 65
69 uc := usecase.NewSnapshotSessionUseCase(repo)66 uc := usecase.NewSnapshotSessionUseCase(repo)
70- svc := NewService(uc, &testProvider{67+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_fk_retry_fail", nil, 0), 1)
71- sessionID: "20260207_120000_fk_retry_fail",
72- tabList: entity.NewTabList(),
73- }, 1)
74 svc.retryDelay = time.Millisecond68 svc.retryDelay = time.Millisecond
75 svc.ready = true69 svc.ready = true
76 svc.dirty = true70 svc.dirty = true
@@ -94,10 +88,7 @@ func TestService_SaveSnapshot_DoesNotRetryNonFKError(t *testing.T) {
94 })88 })
95 89
96 uc := usecase.NewSnapshotSessionUseCase(repo)90 uc := usecase.NewSnapshotSessionUseCase(repo)
97- svc := NewService(uc, &testProvider{91+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_non_fk", nil, 0), 1)
98- sessionID: "20260207_120000_non_fk",
99- tabList: entity.NewTabList(),
100- }, 1)
101 svc.retryDelay = time.Millisecond92 svc.retryDelay = time.Millisecond
102 svc.ready = true93 svc.ready = true
103 svc.dirty = true94 svc.dirty = true
@@ -120,10 +111,7 @@ func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) {
120 })111 })
121 112
122 uc := usecase.NewSnapshotSessionUseCase(repo)113 uc := usecase.NewSnapshotSessionUseCase(repo)
123- svc := NewService(uc, &testProvider{114+ svc := NewService(uc, newWindowStateProvider(t, "20260207_120000_ready_flush", nil, 0), 1)
124- sessionID: "20260207_120000_ready_flush",
125- tabList: entity.NewTabList(),
126- }, 1)
127 svc.Start(context.Background())115 svc.Start(context.Background())
128 svc.dirty = true116 svc.dirty = true
129 117
@@ -139,3 +127,111 @@ func TestService_SetReady_SavesPendingDirtySnapshot(t *testing.T) {
139 svc.mu.Unlock()127 svc.mu.Unlock()
140 assert.False(t, dirty)128 assert.False(t, dirty)
141 }129 }
130+
131+func TestService_SaveNowPassesWindowSnapshots(t *testing.T) {
132+ repo := repomocks.NewMockSessionStateRepository(t)
133+
134+ pane1 := entity.NewPane("p_w1")
135+ pane1.URI = "https://win1.com"
136+ tab1_1 := entity.NewTab("t1_w1", "ws1_w1", pane1)
137+ tab1_1.Name = "Win1Tab"
138+ tabs1 := entity.NewTabList()
139+ tabs1.Add(tab1_1)
140+
141+ pane2 := entity.NewPane("p_w2")
142+ pane2.URI = "https://win2.com"
143+ tab2_1 := entity.NewTab("t1_w2", "ws1_w2", pane2)
144+ tab2_1.Name = "Win2Tab"
145+ tabs2 := entity.NewTabList()
146+ tabs2.Add(tab2_1)
147+
148+ windows := []entity.WindowTabListState{
149+ {WindowID: "w1", Tabs: tabs1},
150+ {WindowID: "w2", Tabs: tabs2},
151+ }
152+
153+ sessionID := entity.SessionID("20260501_window_snap")
154+
155+ repo.EXPECT().
156+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
157+ Run(func(_ context.Context, state *entity.SessionState) {
158+ require.Equal(t, sessionID, state.SessionID)
159+ require.Equal(t, 2, state.Version)
160+ require.Len(t, state.Windows, 2)
161+ assert.Equal(t, entity.WindowID("w1"), state.Windows[0].ID)
162+ assert.Equal(t, entity.WindowID("w2"), state.Windows[1].ID)
163+ assert.Equal(t, 1, state.ActiveWindowIndex)
164+ assert.Empty(t, state.Tabs)
165+ }).
166+ Return(nil)
167+
168+ uc := usecase.NewSnapshotSessionUseCase(repo)
169+ provider := newWindowStateProvider(t, sessionID, windows, 1)
170+ svc := NewService(uc, provider, 1)
171+ svc.ready = true
172+ svc.dirty = true
173+
174+ err := svc.saveSnapshot(context.Background())
175+ require.NoError(t, err)
176+ assert.False(t, svc.dirty)
177+}
178+
179+func TestService_SaveNowPersistsEmptyWindowSnapshotAsV2(t *testing.T) {
180+ repo := repomocks.NewMockSessionStateRepository(t)
181+ sessionID := entity.SessionID("20260501_empty_window_snap")
182+
183+ repo.EXPECT().
184+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
185+ Run(func(_ context.Context, state *entity.SessionState) {
186+ require.Equal(t, sessionID, state.SessionID)
187+ require.Equal(t, entity.SessionStateVersion, state.Version)
188+ require.Empty(t, state.Windows)
189+ require.Empty(t, state.Tabs)
190+ assert.Equal(t, 0, state.ActiveWindowIndex)
191+ }).
192+ Return(nil)
193+
194+ uc := usecase.NewSnapshotSessionUseCase(repo)
195+ provider := newWindowStateProvider(t, sessionID, []entity.WindowTabListState{}, 2)
196+ svc := NewService(uc, provider, 1)
197+ svc.ready = true
198+ svc.dirty = true
199+
200+ err := svc.saveSnapshot(context.Background())
201+ require.NoError(t, err)
202+ assert.False(t, svc.dirty)
203+}
204+
205+func TestService_SaveNowPreservesLegacySingleEmptyWindowSentinel(t *testing.T) {
206+ repo := repomocks.NewMockSessionStateRepository(t)
207+ sessionID := entity.SessionID("20260501_legacy_sentinel")
208+
209+ pane := entity.NewPane("p_legacy")
210+ pane.URI = "https://legacy.example"
211+ tab := entity.NewTab("t_legacy", "ws_legacy", pane)
212+ tab.Name = "Legacy Tab"
213+ tabs := entity.NewTabList()
214+ tabs.Add(tab)
215+
216+ repo.EXPECT().
217+ SaveSnapshot(mock.Anything, mock.AnythingOfType("*entity.SessionState")).
218+ Run(func(_ context.Context, state *entity.SessionState) {
219+ require.Equal(t, sessionID, state.SessionID)
220+ require.Equal(t, entity.LegacySessionStateVersion, state.Version)
221+ require.Len(t, state.Tabs, 1)
222+ assert.Equal(t, "Legacy Tab", state.Tabs[0].Name)
223+ assert.Empty(t, state.Windows)
224+ assert.Equal(t, 0, state.ActiveWindowIndex)
225+ }).
226+ Return(nil)
227+
228+ uc := usecase.NewSnapshotSessionUseCase(repo)
229+ provider := newWindowStateProvider(t, sessionID, []entity.WindowTabListState{{WindowID: "", Tabs: tabs}}, 3)
230+ svc := NewService(uc, provider, 1)
231+ svc.ready = true
232+ svc.dirty = true
233+
234+ err := svc.saveSnapshot(context.Background())
235+ require.NoError(t, err)
236+ assert.False(t, svc.dirty)
237+}
M internal/ui/app.go +1174 -285
@@ -7,6 +7,7 @@ import (
7 "io"7 "io"
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10+ "sort"
10 "strings"11 "strings"
11 "sync"12 "sync"
12 "unsafe"13 "unsafe"
@@ -76,9 +77,9 @@ type App struct {
76 mainWindow *window.MainWindow77 mainWindow *window.MainWindow
77 78
78 browserWindows map[string]*browserWindow79 browserWindows map[string]*browserWindow
80+ browserWindowOrder []string // registration order of window IDs
79 lastFocusedWindowID string81 lastFocusedWindowID string
80 browserWindowFactory func(context.Context, string) (*browserWindow, error)82 browserWindowFactory func(context.Context, string) (*browserWindow, error)
81- tabCreationWindow *browserWindow
82 dispatchOnMainThread func(func())83 dispatchOnMainThread func(func())
83 84
84 // State85 // State
@@ -109,8 +110,7 @@ type App struct {
109 resizeModeBorderTarget layout.Widget110 resizeModeBorderTarget layout.Widget
110 111
111 // Omnibox configuration (omnibox is created per workspace view)112 // Omnibox configuration (omnibox is created per workspace view)
112- omniboxCfg component.OmniboxConfig113+ omniboxCfg component.OmniboxConfig
113- omniboxNavigateFn func(url string)
114 // Find bar configuration (find bar is created per workspace view)114 // Find bar configuration (find bar is created per workspace view)
115 findBarCfg component.FindBarConfig115 findBarCfg component.FindBarConfig
116 // Floating pane sessions keyed by tab and profile session ID.116 // Floating pane sessions keyed by tab and profile session ID.
@@ -411,6 +411,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow
411 if err != nil {411 if err != nil {
412 return nil, err412 return nil, err
413 }413 }
414+ created.ensureTabs()
414 a.wireBrowserWindowActivationTracking(created)415 a.wireBrowserWindowActivationTracking(created)
415 created.initChrome(ctx, a)416 created.initChrome(ctx, a)
416 return created, nil417 return created, nil
@@ -424,6 +425,7 @@ func (a *App) createBrowserWindow(ctx context.Context, initialURL string) (*brow
424 browserWindow := &browserWindow{425 browserWindow := &browserWindow{
425 id: a.generateWindowID(),426 id: a.generateWindowID(),
426 initialURL: initialURL,427 initialURL: initialURL,
428+ tabs: entity.NewTabList(),
427 mainWindow: mainWindow,429 mainWindow: mainWindow,
428 }430 }
429 a.wireBrowserWindowActivationTracking(browserWindow)431 a.wireBrowserWindowActivationTracking(browserWindow)
@@ -500,56 +502,60 @@ func (a *App) browserWindowForMainWindow(mainWindow *window.MainWindow) *browser
500 }502 }
501 503
502 func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow {504 func (a *App) browserWindowForTab(tabID entity.TabID) *browserWindow {
503- if a.windowForTab != nil {505+ if a.windowForTab == nil {
504- if bw := a.windowForTab[tabID]; bw != nil {506+ return nil
505- return bw
506- }
507- }
508- if a.tabCreationWindow != nil {
509- return a.tabCreationWindow
510 }507 }
511- return a.browserWindowForMainWindow(a.mainWindow)508+ return a.windowForTab[tabID]
512 }509 }
513 510
514-func (a *App) browserWindowHasLiveTab(bw *browserWindow, tabID entity.TabID) bool {511+func (a *App) browserWindowForTabTarget(target coordinator.TabTarget) *browserWindow {
515- if a == nil || a.tabs == nil || bw == nil || tabID == "" {512+ if target.Tabs != nil {
516- return false513+ for _, bw := range a.browserWindows {
517- }514+ if bw != nil && bw.tabs == target.Tabs {
518- if a.tabs.Find(tabID) == nil {515+ return bw
519- return false516+ }
517+ }
520 }518 }
521- if a.windowForTab[tabID] != bw {519+ if target.MainWindow != nil {
522- return false520+ return a.browserWindowForMainWindow(target.MainWindow)
523 }521 }
524- return true522+ return nil
525 }523 }
526 524
527 func (a *App) tabCountForBrowserWindow(bw *browserWindow) int {525 func (a *App) tabCountForBrowserWindow(bw *browserWindow) int {
528- if a.tabs == nil || bw == nil {526+ if bw == nil || bw.tabs == nil {
529 return 0527 return 0
530 }528 }
531- count := 0529+ return len(bw.tabs.Tabs)
532- for _, tab := range a.tabs.Tabs {
533- if tab == nil {
534- continue
535- }
536- if owner := a.windowForTab[tab.ID]; owner == bw {
537- count++
538- }
539- }
540- return count
541 }530 }
542 531
543 func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow {532 func (a *App) browserWindowForPane(paneID entity.PaneID) *browserWindow {
544- if paneID == "" || a.tabs == nil {533+ if paneID == "" {
545 return nil534 return nil
546 }535 }
547- for _, tab := range a.tabs.Tabs {536+ for _, bw := range a.browserWindows {
548- if tab == nil || tab.Workspace == nil {537+ if bw == nil || bw.tabs == nil {
549 continue538 continue
550 }539 }
551- if tab.Workspace.FindPane(paneID) != nil {540+ for _, tab := range bw.tabs.Tabs {
552- return a.browserWindowForTab(tab.ID)541+ if tab == nil || tab.Workspace == nil {
542+ continue
543+ }
544+ if tab.Workspace.FindPane(paneID) != nil {
545+ return bw
546+ }
547+ }
548+ }
549+ return nil
550+}
551+
552+func (a *App) browserWindowForAnyPane(paneID entity.PaneID) *browserWindow {
553+ if bw := a.browserWindowForPane(paneID); bw != nil {
554+ return bw
555+ }
556+ for key, session := range a.floatingSessions {
557+ if session != nil && session.paneID == paneID {
558+ return a.browserWindowForTab(key.tabID)
553 }559 }
554 }560 }
555 return nil561 return nil
@@ -598,6 +604,25 @@ func (a *App) ownerOrLastFocusedBrowserWindow(tabID entity.TabID, paneID entity.
598 return a.lastFocusedBrowserWindow()604 return a.lastFocusedBrowserWindow()
599 }605 }
600 606
607+func (a *App) createPopupTab(ctx context.Context, popupInput content.InsertPopupInput) error {
608+ bw := a.browserWindowForAnyPane(popupInput.ParentPaneID)
609+ if bw == nil {
610+ return fmt.Errorf("popup owner window not found for parent pane %q", popupInput.ParentPaneID)
611+ }
612+ tab, err := a.tabCoord.CreateWithPane(
613+ ctx,
614+ a.ensureTabTargetForBrowserWindow(bw),
615+ popupInput.PopupPane,
616+ popupInput.WebView,
617+ popupInput.TargetURI,
618+ )
619+ if err != nil {
620+ return err
621+ }
622+ logging.FromContext(ctx).Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
623+ return nil
624+}
625+
601 func (a *App) handlePaneWindowTitleChanged(paneID entity.PaneID, title string) {626 func (a *App) handlePaneWindowTitleChanged(paneID entity.PaneID, title string) {
602 a.updateWindowTitle(title, a.browserWindowForPane(paneID))627 a.updateWindowTitle(title, a.browserWindowForPane(paneID))
603 }628 }
@@ -609,8 +634,10 @@ func (a *App) handlePaneFullscreenChanged(paneID entity.PaneID, entering bool) {
609 }634 }
610 if entering {635 if entering {
611 bw.mainWindow.TabBar().SetVisible(false)636 bw.mainWindow.TabBar().SetVisible(false)
637+ bw.mainWindow.SetTabBarContentInsetVisible(false)
612 return638 return
613 }639 }
640+ bw.mainWindow.TabBar().SetVisible(true)
614 a.updateBrowserWindowTabBarVisibility(bw)641 a.updateBrowserWindowTabBarVisibility(bw)
615 }642 }
616 643
@@ -619,11 +646,13 @@ func (a *App) updateBrowserWindowTabBarVisibility(bw *browserWindow) {
619 return646 return
620 }647 }
621 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.HideTabBarWhenSingleTab {648 if a.deps == nil || a.deps.Config == nil || !a.deps.Config.Workspace.HideTabBarWhenSingleTab {
622- bw.mainWindow.TabBar().SetVisible(true)649+ bw.mainWindow.TabBar().SetAutoHidden(false)
650+ bw.mainWindow.SetTabBarContentInsetVisible(true)
623 return651 return
624 }652 }
625 shouldShow := bw.mainWindow.TabBar().Count() > 1653 shouldShow := bw.mainWindow.TabBar().Count() > 1
626- bw.mainWindow.TabBar().SetVisible(shouldShow)654+ bw.mainWindow.TabBar().SetAutoHidden(!shouldShow)
655+ bw.mainWindow.SetTabBarContentInsetVisible(shouldShow)
627 }656 }
628 657
629 func (a *App) activateBrowserWindow(bw *browserWindow) {658 func (a *App) activateBrowserWindow(bw *browserWindow) {
@@ -636,13 +665,29 @@ func (a *App) activateBrowserWindow(bw *browserWindow) {
636 }665 }
637 a.keyboardHandler = bw.keyboardHandler666 a.keyboardHandler = bw.keyboardHandler
638 a.globalShortcutHandler = bw.globalShortcutHandler667 a.globalShortcutHandler = bw.globalShortcutHandler
639- if a.tabCoord != nil && bw.mainWindow != nil {668+ if a.tabCoord != nil {
640- a.tabCoord.SetMainWindow(bw.mainWindow)669+ a.tabCoord.SetCurrentTarget(a.tabTargetForBrowserWindow(bw))
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 {679+ perWinTabs := a.tabListForBrowserWindow(bw)
643- a.tabs.SetActive(bw.activeTabID)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 func (a *App) handleBrowserWindowActivationChanged(bw *browserWindow, active bool) {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 a.ensureWindowForTabMap()713 a.ensureWindowForTabMap()
669 a.windowForTab[tabID] = bw714 a.windowForTab[tabID] = bw
670- if bw.activeTabID == "" {715+ tabs := a.ensureTabListForBrowserWindow(bw)
671- bw.activeTabID = tabID716+ 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 a.registerBrowserWindow(created)758 a.registerBrowserWindow(created)
713 759
714- if a.tabs == nil || a.tabs.Count() == 0 {760+ if len(a.browserWindows) == 1 {
715 a.activateBrowserWindow(created)761 a.activateBrowserWindow(created)
716 return nil762 return nil
717 }763 }
@@ -733,17 +779,13 @@ func (a *App) openFreshWindow(ctx context.Context, url string) error {
733 }779 }
734 780
735 func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, created *browserWindow) error {781 func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, created *browserWindow) error {
736- previousMainWindow := a.mainWindow782+ previousTarget := a.tabCoord.CurrentTarget()
737- if created.mainWindow != nil {783+ a.tabCoord.SetCurrentTarget(a.ensureTabTargetForBrowserWindow(created))
738- a.tabCoord.SetMainWindow(created.mainWindow)
739- }
740- a.tabCreationWindow = created
741 defer func() {784 defer func() {
742- a.tabCreationWindow = nil785+ a.tabCoord.SetCurrentTarget(previousTarget)
743- a.tabCoord.SetMainWindow(previousMainWindow)
744 }()786 }()
745 787
746- createdTab, createErr := a.tabCoord.Create(ctx, url)788+ createdTab, createErr := a.tabCoord.Create(ctx, a.ensureTabTargetForBrowserWindow(created), url)
747 if createErr != nil {789 if createErr != nil {
748 a.removeBrowserWindow(created.id)790 a.removeBrowserWindow(created.id)
749 if created.mainWindow != nil {791 if created.mainWindow != nil {
@@ -752,19 +794,17 @@ func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, creat
752 return createErr794 return createErr
753 }795 }
754 if a.workspaceViews[createdTab.ID] == nil {796 if a.workspaceViews[createdTab.ID] == nil {
755- if a.tabs != nil {797+ if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil {
756- a.tabs.Remove(createdTab.ID)798+ createdTabs.Remove(createdTab.ID)
757 }799 }
758- tabBar := a.mainWindow.TabBar()800+ if created.mainWindow != nil {
759- if created.mainWindow != nil && a.tabCoord != nil {801+ if tabBar := created.mainWindow.TabBar(); tabBar != nil {
760- if targetTabBar := a.tabCoord.GetTabBar(); targetTabBar != nil {802+ tabBar.RemoveTab(createdTab.ID)
761- tabBar = targetTabBar803+ 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 a.removeBrowserWindow(created.id)808 a.removeBrowserWindow(created.id)
769 if created.mainWindow != nil {809 if created.mainWindow != nil {
770 created.mainWindow.Destroy()810 created.mainWindow.Destroy()
@@ -772,7 +812,8 @@ func (a *App) openFreshWindowWithTabCoord(ctx context.Context, url string, creat
772 return fmt.Errorf("workspace view not created for tab %s", createdTab.ID)812 return fmt.Errorf("workspace view not created for tab %s", createdTab.ID)
773 }813 }
774 a.setBrowserWindowForTab(createdTab.ID, created)814 a.setBrowserWindowForTab(createdTab.ID, created)
775- created.activeTabID = createdTab.ID815+ // Tab was already created in the per-window TabList via tabCoord.Create;
816+ // active state is managed by TabList.SetActive.
776 if created.mainWindow != nil {817 if created.mainWindow != nil {
777 created.mainWindow.Show()818 created.mainWindow.Show()
778 }819 }
@@ -792,30 +833,27 @@ func (a *App) openFreshWindowWithTabsUC(ctx context.Context, url string, created
792 return err833 return err
793 }834 }
794 a.setBrowserWindowForTab(output.Tab.ID, created)835 a.setBrowserWindowForTab(output.Tab.ID, created)
795- created.activeTabID = output.Tab.ID836+ // 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 if a.widgetFactory != nil {840 if a.widgetFactory != nil {
797- a.tabCreationWindow = created
798- defer func() {
799- a.tabCreationWindow = nil
800- }()
801 a.createWorkspaceView(ctx, output.Tab)841 a.createWorkspaceView(ctx, output.Tab)
802 a.switchWorkspaceView(ctx, output.Tab.ID)842 a.switchWorkspaceView(ctx, output.Tab.ID)
803 }843 }
804 if a.workspaceViews[output.Tab.ID] == nil {844 if a.workspaceViews[output.Tab.ID] == nil {
845+ if createdTabs := a.tabListForBrowserWindow(created); createdTabs != nil {
846+ createdTabs.Remove(output.Tab.ID)
847+ }
805 if a.tabs != nil {848 if a.tabs != nil {
806 a.tabs.Remove(output.Tab.ID)849 a.tabs.Remove(output.Tab.ID)
807 }850 }
808- var tabBar *component.TabBar851+ if created.mainWindow != nil {
809- if a.mainWindow != nil {852+ if tabBar := created.mainWindow.TabBar(); tabBar != nil {
810- tabBar = a.mainWindow.TabBar()853+ tabBar.RemoveTab(output.Tab.ID)
811- }854+ if activeTab := a.activeTabForBrowserWindow(created); activeTab != nil {
812- if created.mainWindow != nil && created.mainWindow.TabBar() != nil {855+ tabBar.SetActive(activeTab.ID)
813- tabBar = created.mainWindow.TabBar()856+ }
814- }
815- if tabBar != nil {
816- tabBar.RemoveTab(output.Tab.ID)
817- if a.tabs != nil {
818- tabBar.SetActive(a.tabs.ActiveTabID)
819 }857 }
820 }858 }
821 a.removeBrowserWindow(created.id)859 a.removeBrowserWindow(created.id)
@@ -1118,7 +1156,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1118 return nil1156 return nil
1119 }1157 }
1120 }1158 }
1121- return a.kbDispatcher.Dispatch(actionCtx, action)1159+ return a.dispatchBrowserWindowAction(actionCtx, bw, action)
1122 })1160 })
1123 bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {1161 bw.keyboardHandler.SetOnEscape(func(escapeCtx context.Context) bool {
1124 a.activateBrowserWindow(bw)1162 a.activateBrowserWindow(bw)
@@ -1165,7 +1203,7 @@ func (a *App) initBrowserWindowInput(ctx context.Context, bw *browserWindow) {
1165 bw.keyboardHandler,1203 bw.keyboardHandler,
1166 func(actionCtx context.Context, action input.Action) error {1204 func(actionCtx context.Context, action input.Action) error {
1167 a.activateBrowserWindow(bw)1205 a.activateBrowserWindow(bw)
1168- return a.kbDispatcher.Dispatch(actionCtx, action)1206+ return a.dispatchBrowserWindowAction(actionCtx, bw, action)
1169 },1207 },
1170 )1208 )
1171 if bw.globalShortcutHandler == nil {1209 if bw.globalShortcutHandler == nil {
@@ -1181,11 +1219,11 @@ func (a *App) wireBrowserWindowTabBar(ctx context.Context, bw *browserWindow) {
1181 }1219 }
1182 bw.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {1220 bw.mainWindow.TabBar().SetOnSwitch(func(tabID entity.TabID) {
1183 a.activateBrowserWindow(bw)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 logging.FromContext(ctx).Error().Err(err).Str("tab_id", string(tabID)).Str("window_id", bw.id).Msg("tab switch failed")1223 logging.FromContext(ctx).Error().Err(err).Str("tab_id", string(tabID)).Str("window_id", bw.id).Msg("tab switch failed")
1186 return1224 return
1187 }1225 }
1188- bw.activeTabID = tabID1226+ // 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 return1470 return
1433 }1471 }
1434 1472
1435- activeTab := a.tabs.ActiveTab()1473+ activeTab := a.activeTabForBrowserWindow(bw)
1436 if activeTab == nil || activeTab.Workspace == nil {1474 if activeTab == nil || activeTab.Workspace == nil {
1437 return1475 return
1438 }1476 }
@@ -1488,18 +1526,19 @@ func (a *App) HandleMovePaneToTab(ctx context.Context) error {
1488 return nil1526 return nil
1489 }1527 }
1490 1528
1491- sourceTab := a.tabs.ActiveTab()1529+ sourceTabs := a.tabListForBrowserWindow(bw)
1492- if sourceTab == nil || sourceTab.Workspace == nil {1530+ sourceTab := a.activeTabForBrowserWindow(bw)
1531+ if sourceTabs == nil || sourceTab == nil || sourceTab.Workspace == nil {
1493 return nil1532 return nil
1494 }1533 }
1495 1534
1496 // If there is only 1 tab, auto-create a new tab and move there.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 return a.MoveActivePaneToTab(ctx, "")1537 return a.MoveActivePaneToTab(ctx, "")
1499 }1538 }
1500 1539
1501- items := make([]component.TabPickerItem, 0, a.tabs.Count())1540+ items := make([]component.TabPickerItem, 0, sourceTabs.Count())
1502- for _, tab := range a.tabs.Tabs {1541+ for _, tab := range sourceTabs.Tabs {
1503 if tab == nil {1542 if tab == nil {
1504 continue1543 continue
1505 }1544 }
@@ -1524,17 +1563,19 @@ func (a *App) HandleMovePaneToNextTab(ctx context.Context) error {
1524 if a.movePaneToTabUC == nil {1563 if a.movePaneToTabUC == nil {
1525 return nil1564 return nil
1526 }1565 }
1527- active := a.tabs.ActiveTab()1566+ bw := a.lastFocusedBrowserWindow()
1528- if active == nil {1567+ sourceTabs := a.tabListForBrowserWindow(bw)
1568+ active := a.activeTabForBrowserWindow(bw)
1569+ if sourceTabs == nil || active == nil {
1529 return nil1570 return nil
1530 }1571 }
1531 1572
1532 nextPos := active.Position + 11573 nextPos := active.Position + 1
1533- if nextPos >= a.tabs.Count() {1574+ if nextPos >= sourceTabs.Count() {
1534 // Create a new tab on the right.1575 // Create a new tab on the right.
1535 return a.MoveActivePaneToTab(ctx, "")1576 return a.MoveActivePaneToTab(ctx, "")
1536 }1577 }
1537- next := a.tabs.TabAt(nextPos)1578+ next := sourceTabs.TabAt(nextPos)
1538 if next == nil {1579 if next == nil {
1539 return nil1580 return nil
1540 }1581 }
@@ -1568,10 +1609,23 @@ func (a *App) MoveActivePaneToTab(ctx context.Context, targetTabID entity.TabID)
1568 if out.NewTabCreated && targetWindow != nil {1609 if out.NewTabCreated && targetWindow != nil {
1569 a.setBrowserWindowForTab(out.TargetTab.ID, targetWindow)1610 a.setBrowserWindowForTab(out.TargetTab.ID, targetWindow)
1570 }1611 }
1612+ a.syncCrossWindowMovePaneTabLists(out, sourceTab, sourceWindow, targetWindow)
1571 1613
1572 a.applyMovePaneToTabUI(ctx, out, sourceTab, sourceWindow, targetWindow)1614 a.applyMovePaneToTabUI(ctx, out, sourceTab, sourceWindow, targetWindow)
1573 a.switchToTargetTabIfConfigured(ctx, out, targetWindow)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 a.MarkDirty()1629 a.MarkDirty()
1576 return nil1630 return nil
1577 }1631 }
@@ -1587,17 +1641,36 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa
1587 if a.movePaneToTabUC == nil {1641 if a.movePaneToTabUC == nil {
1588 return nil, nil1642 return nil, nil
1589 }1643 }
1590- activeTab := a.tabs.ActiveTab()1644+ sourceWindow := a.lastFocusedBrowserWindow()
1591- if activeTab == nil || activeTab.Workspace == nil {1645+ tabList := a.tabListForBrowserWindow(sourceWindow)
1646+ activeTab := a.activeTabForBrowserWindow(sourceWindow)
1647+ if tabList == nil || activeTab == nil || activeTab.Workspace == nil {
1592 return nil, nil1648 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 sourcePaneID := activeTab.Workspace.ActivePaneID1667 sourcePaneID := activeTab.Workspace.ActivePaneID
1595 if sourcePaneID == "" {1668 if sourcePaneID == "" {
1596 return nil, nil1669 return nil, nil
1597 }1670 }
1598 1671
1599 in := &usecase.MovePaneToTabInput{1672 in := &usecase.MovePaneToTabInput{
1600- TabList: a.tabs,1673+ TabList: tabList,
1601 SourceTabID: activeTab.ID,1674 SourceTabID: activeTab.ID,
1602 SourcePaneID: sourcePaneID,1675 SourcePaneID: sourcePaneID,
1603 TargetTabID: targetTabID,1676 TargetTabID: targetTabID,
@@ -1605,6 +1678,41 @@ func (a *App) buildMovePaneToTabInput(targetTabID entity.TabID) (*usecase.MovePa
1605 return in, activeTab1678 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 func (a *App) applyMovePaneToTabUI(1716 func (a *App) applyMovePaneToTabUI(
1609 ctx context.Context,1717 ctx context.Context,
1610 out *usecase.MovePaneToTabOutput,1718 out *usecase.MovePaneToTabOutput,
@@ -1639,6 +1747,11 @@ func (a *App) removeSourceTabUI(sourceTabID entity.TabID, sourceWindow *browserW
1639 if tabBar := a.tabBarForBrowserWindow(sourceWindow); tabBar != nil {1747 if tabBar := a.tabBarForBrowserWindow(sourceWindow); tabBar != nil {
1640 tabBar.RemoveTab(sourceTabID)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 func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWindow *browserWindow) {1757 func (a *App) ensureTargetTabUI(ctx context.Context, tab *entity.Tab, targetWindow *browserWindow) {
@@ -1714,9 +1827,16 @@ func (a *App) switchToTargetTabIfConfigured(
1714 return1827 return
1715 }1828 }
1716 1829
1717- a.tabs.SetActive(out.TargetTab.ID)1830+ if targetWindow == nil {
1831+ targetWindow = a.lastFocusedBrowserWindow()
1832+ }
1718 if targetWindow != nil {1833 if targetWindow != nil {
1719- targetWindow.activeTabID = out.TargetTab.ID1834+ 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 if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {1841 if tabBar := a.tabBarForBrowserWindow(targetWindow); tabBar != nil {
1722 tabBar.SetActive(out.TargetTab.ID)1842 tabBar.SetActive(out.TargetTab.ID)
@@ -1727,12 +1847,6 @@ func (a *App) switchToTargetTabIfConfigured(
1727 a.switchWorkspaceView(ctx, out.TargetTab.ID)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 func (a *App) initSnapshotService(ctx context.Context) {1850 func (a *App) initSnapshotService(ctx context.Context) {
1737 log := logging.FromContext(ctx)1851 log := logging.FromContext(ctx)
1738 1852
@@ -1785,12 +1899,7 @@ func (a *App) initUpdateCoordinator(ctx context.Context) {
1785 log.Debug().Msg("update coordinator initialized")1899 log.Debug().Msg("update coordinator initialized")
1786 }1900 }
1787 1901
1788-// GetTabList implements port.TabListProvider.1902+// GetSessionID implements port.WindowStateProvider.
1789-func (a *App) GetTabList() *entity.TabList {
1790- return a.tabs
1791-}
1792-
1793-// GetSessionID implements port.TabListProvider.
1794 func (a *App) GetSessionID() entity.SessionID {1903 func (a *App) GetSessionID() entity.SessionID {
1795 if a.deps == nil {1904 if a.deps == nil {
1796 return ""1905 return ""
@@ -1798,6 +1907,532 @@ func (a *App) GetSessionID() entity.SessionID {
1798 return a.deps.CurrentSessionID1907 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 // MarkDirty signals that session state has changed and should be saved.2436 // MarkDirty signals that session state has changed and should be saved.
1802 func (a *App) MarkDirty() {2437 func (a *App) MarkDirty() {
1803 if a.snapshotService != nil {2438 if a.snapshotService != nil {
@@ -1823,23 +2458,43 @@ func (a *App) createInitialTab(ctx context.Context) {
1823 }2458 }
1824 2459
1825 // Create an initial tab using coordinator.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 log.Error().Err(err).Msg("failed to create initial tab")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 func (a *App) clearSessionRestoreUIState(ctx context.Context) {2485 func (a *App) clearSessionRestoreUIState(ctx context.Context) {
1832 if a == nil {2486 if a == nil {
1833 return2487 return
1834 }2488 }
1835 2489
1836 tabIDs := make(map[entity.TabID]struct{})2490 tabIDs := make(map[entity.TabID]struct{})
1837- if a.tabs != nil {2491+ for _, bw := range a.browserWindows {
1838- for _, tab := range a.tabs.Tabs {2492+ if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
1839- if tab == nil {2493+ for _, tab := range tabs.Tabs {
1840- continue2494+ if tab != nil {
2495+ tabIDs[tab.ID] = struct{}{}
2496+ }
1841 }2497 }
1842- tabIDs[tab.ID] = struct{}{}
1843 }2498 }
1844 }2499 }
1845 for tabID := range a.workspaceViews {2500 for tabID := range a.workspaceViews {
@@ -1867,94 +2522,290 @@ func (a *App) clearSessionRestoreUIState(ctx context.Context) {
1867 }2522 }
1868 2523
1869 func (a *App) restoreSession(ctx context.Context, sessionID entity.SessionID) error {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 if a.deps == nil || a.deps.SessionStateRepo == nil {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 state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)2573 state, err := a.deps.SessionStateRepo.GetSnapshot(ctx, sessionID)
1878 if err != nil {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 if state == nil {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)2585+func (a *App) prepareRuntimeWindows(ctx context.Context, windowCount int) ([]*browserWindow, bool, error) {
1888- // This reconstructs the full pane tree with new IDs2586+ firstBW := a.browserWindowForMainWindow(a.mainWindow)
1889- restoredTabs := entity.TabListFromSnapshot(state, a.generateID)2587+ if firstBW == nil {
1890- if restoredTabs == nil || len(restoredTabs.Tabs) == 0 {2588+ firstBW = a.lastFocusedBrowserWindow()
1891- return fmt.Errorf("failed to build tab list from snapshot")
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 TabCoordinator2606+ runtimeWindows := make([]*browserWindow, windowCount)
1897- a.tabs.ReplaceFrom(restoredTabs)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 bar2634+func (a *App) cleanupCreatedBrowserWindows(createdWindows []*browserWindow) {
1902- // Don't attach to content area yet - only the active tab should be attached2635+ for _, bw := range createdWindows {
1903- tabBar := a.mainWindow.TabBar()2636+ if bw == nil {
1904- for _, tab := range a.tabs.Tabs {2637+ continue
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)
1909 }2638 }
1910- if tabBar != nil {2639+ mainWindow := bw.mainWindow
1911- tabBar.AddTab(tab)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 area2650+func (a *App) assignRuntimeWindowTabLists(
1921- activeTab := a.tabs.ActiveTab()2651+ runtimeWindows []*browserWindow,
1922- if activeTab != nil {2652+ restoredWindows []entity.WindowTabListState,
1923- a.switchWorkspaceView(ctx, activeTab.ID)2653+ firstBWReused bool,
1924- if tabBar != nil {2654+) {
1925- tabBar.SetActive(activeTab.ID)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 nil2663+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() {2683+func safeWindowIndex(index, count int) int {
1933- if a == nil || a.tabs == nil {2684+ if index < 0 || index >= count {
1934- return2685+ return 0
1935 }2686 }
2687+ return index
2688+}
1936 2689
1937- restoreWindow := a.browserWindowForMainWindow(a.mainWindow)2690+func (a *App) syncDerivedGlobalTabMirror(tab *entity.Tab) {
1938- if restoreWindow == nil {2691+ if a.tabs != nil && tab != nil && a.tabs.Find(tab.ID) == nil {
1939- restoreWindow = a.lastFocusedBrowserWindow()2692+ a.tabs.Add(cloneTabForGlobalList(tab))
1940 }2693 }
1941- if restoreWindow == nil {2694+}
1942- return2695+
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 {2712+func (a *App) replaceGlobalTabsFromRuntimeWindows(runtimeWindows []*browserWindow, activeBW *browserWindow) {
1946- if tab != nil {2713+ globalTabs := entity.NewTabList()
1947- a.setBrowserWindowForTab(tab.ID, restoreWindow)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.ActiveTabID2798+ if restored.Tabs == nil {
1952- previousID := a.tabs.PreviousActiveTabID2799+ bw.tabs = entity.NewTabList()
1953- if previousID != "" && previousID != a.tabs.ActiveTabID && a.tabs.Find(previousID) != nil {
1954- restoreWindow.prevActiveTabID = previousID
1955 return2800 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 func (a *App) finalizeActivation(ctx context.Context) {2811 func (a *App) finalizeActivation(ctx context.Context) {
@@ -2126,14 +2977,12 @@ func (a *App) initContentCoordinator(
2126 func (a *App) initTabCoordinator(ctx context.Context) {2977 func (a *App) initTabCoordinator(ctx context.Context) {
2127 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{2978 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
2128 TabsUC: a.tabsUC,2979 TabsUC: a.tabsUC,
2129- Tabs: a.tabs,
2130 MainWindow: a.mainWindow,2980 MainWindow: a.mainWindow,
2131 HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,2981 HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,
2132 })2982 })
2133- a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {2983+ a.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
2134- // Assign ownership BEFORE creating workspace view so windowForTab is set2984+ // Assign ownership from the callback target, not focus/global state.
2135- // for scope filtering and browserWindowForTab resolution.2985+ bw := a.browserWindowForTabTarget(target)
2136- bw := a.browserWindowForTab(tab.ID)
2137 if bw != nil {2986 if bw != nil {
2138 a.setBrowserWindowForTab(tab.ID, bw)2987 a.setBrowserWindowForTab(tab.ID, bw)
2139 }2988 }
@@ -2147,59 +2996,38 @@ func (a *App) initTabCoordinator(ctx context.Context) {
2147 }2996 }
2148 tab.Name = defaultTabName(count)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 a.createWorkspaceView(ctx, tab)3006 a.createWorkspaceView(ctx, tab)
2151 })3007 })
2152- a.tabCoord.SetOnTabSwitched(func(ctx context.Context, tab *entity.Tab) {3008+ a.tabCoord.SetOnTabSwitched(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
2153- if bw := a.browserWindowForTab(tab.ID); bw != nil {3009+ // TabList.SetActive (called by TabCoordinator.Switch → tabsUC.Switch) already
2154- oldActiveID := bw.activeTabID3010+ // tracks ActiveTabID and PreviousActiveTabID on bw.tabs. No manual bw state needed.
2155- if oldActiveID != "" && oldActiveID != tab.ID && a.tabs.Find(oldActiveID) != nil {3011+ if bw := a.browserWindowForTabTarget(target); bw != nil {
2156- bw.prevActiveTabID = oldActiveID
2157- } else if bw.prevActiveTabID == tab.ID || a.tabs.Find(bw.prevActiveTabID) == nil {
2158- bw.prevActiveTabID = ""
2159- }
2160- bw.activeTabID = tab.ID
2161 a.activateBrowserWindow(bw)3012 a.activateBrowserWindow(bw)
2162 }3013 }
2163 a.switchWorkspaceView(ctx, tab.ID)3014 a.switchWorkspaceView(ctx, tab.ID)
2164 })3015 })
2165- a.tabCoord.SetOnQuit(a.Quit)
2166 a.tabCoord.SetOnStateChanged(a.MarkDirty)3016 a.tabCoord.SetOnStateChanged(a.MarkDirty)
2167- // Wire per-window tab scoping3017+ // Per-window tab ownership is now enforced through explicit TabTarget.
2168- a.tabCoord.SetTabScope(func(tab *entity.Tab, mainWindow *window.MainWindow) bool {3018+ // The per-window TabList (bw.tabs) is the source of truth; no scope filtering needed.
2169- if tab == nil {3019+ a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, target coordinator.TabTarget) {
2170- return false3020+ bw := a.browserWindowForTabTarget(target)
2171- }
2172- bw := a.browserWindowForMainWindow(mainWindow)
2173- if bw == nil {
2174- return true // backward-compatible fallback for single-window / test scenarios
2175- }
2176- owner := a.windowForTab[tab.ID]
2177- if owner == nil {
2178- return false // unowned tabs are out of scope when a real window exists
2179- }
2180- return owner == bw
2181- })
2182- // Wire per-window previous active tab ID provider for scoped Alt+Tab switching
2183- a.tabCoord.SetPreviousActiveTabIDProvider(func(mainWindow *window.MainWindow) entity.TabID {
2184- bw := a.browserWindowForMainWindow(mainWindow)
2185- if bw == nil {
2186- return ""
2187- }
2188- prevID := bw.prevActiveTabID
2189- if !a.browserWindowHasLiveTab(bw, prevID) {
2190- bw.prevActiveTabID = ""
2191- return ""
2192- }
2193- return prevID
2194- })
2195- a.tabCoord.SetOnCurrentWindowEmpty(func(ctx context.Context, mainWindow *window.MainWindow) {
2196- bw := a.browserWindowForMainWindow(mainWindow)
2197 if bw != nil {3021 if bw != nil {
2198 a.removeBrowserWindow(bw.id)3022 a.removeBrowserWindow(bw.id)
2199 if bw.mainWindow != nil {3023 if bw.mainWindow != nil {
2200 bw.mainWindow.Destroy()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 // Wire popup tab WebView attachment3032 // Wire popup tab WebView attachment
2205 a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {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 log := logging.FromContext(ctx)3044 log := logging.FromContext(ctx)
2217 log.Debug().Msg("initializing coordinators")3045 log.Debug().Msg("initializing coordinators")
2218 3046
2219- // Helper to get active workspace and view3047+ // 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 getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {3052 getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
2221 return a.activeWorkspace(), a.activeWorkspaceView()3053 return a.activeWorkspace(), a.activeWorkspaceView()
2222 }3054 }
@@ -2252,7 +3084,8 @@ func (a *App) initCoordinators(ctx context.Context) {
2252 ResizeMinPanePercent: a.deps.Config.Workspace.ResizeMode.MinPanePercent,3084 ResizeMinPanePercent: a.deps.Config.Workspace.ResizeMode.MinPanePercent,
2253 })3085 })
2254 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {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 a.wsCoord.SetOnStateChanged(a.MarkDirty)3090 a.wsCoord.SetOnStateChanged(a.MarkDirty)
2258 3091
@@ -2273,16 +3106,8 @@ func (a *App) initCoordinators(ctx context.Context) {
2273 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {3106 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
2274 return a.wsCoord.ClosePaneByID(ctx, paneID)3107 return a.wsCoord.ClosePaneByID(ctx, paneID)
2275 })3108 })
2276- // Wire tabbed popup behavior to create new tabs3109+ // Wire tabbed popup behavior to create new tabs in the originating window.
2277- a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input content.InsertPopupInput) error {3110+ a.wsCoord.SetOnCreatePopupTab(a.createPopupTab)
2278- // Create a new tab with the popup pane
2279- tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
2280- if err != nil {
2281- return err
2282- }
2283- log.Debug().Str("tab_id", string(tab.ID)).Msg("created tab for popup")
2284- return nil
2285- })
2286 3111
2287 // Move pane use case (cross-tab)3112 // Move pane use case (cross-tab)
2288 a.movePaneToTabUC = usecase.NewMovePaneToTabUseCase(a.generateID)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 ws := a.activeWorkspace()3170 ws := a.activeWorkspace()
2343 if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {3171 if ws != nil && ws.ActivePaneID == paneID && a.accentFocusProvider != nil {
2344 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())3172 a.accentFocusProvider.SetFocusedInput(a.getActiveWebViewTarget())
@@ -2346,14 +3174,16 @@ func (a *App) initCoordinators(ctx context.Context) {
2346 })3174 })
2347 3175
2348 // 5. Keyboard Dispatcher3176 // 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 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(3180 a.kbDispatcher = dispatcher.NewKeyboardDispatcher(
2350 ctx,3181 ctx,
2351- a.tabCoord,
2352 a.wsCoord,3182 a.wsCoord,
2353 a.navCoord,3183 a.navCoord,
2354 a.deps.ZoomUC,3184 a.deps.ZoomUC,
2355 a.deps.CopyURLUC,3185 a.deps.CopyURLUC,
2356- a.deps.Config.Workspace.NewPaneURL,3186+ a.keyboardActions(),
2357 a.contentCoord.ActivePaneID,3187 a.contentCoord.ActivePaneID,
2358 )3188 )
2359 a.wireKeyboardActions()3189 a.wireKeyboardActions()
@@ -2364,6 +3194,65 @@ func (a *App) initCoordinators(ctx context.Context) {
2364 log.Debug().Msg("coordinators initialized")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 func (a *App) wireKeyboardActions() {3256 func (a *App) wireKeyboardActions() {
2368 a.kbDispatcher.SetOnQuit(a.Quit)3257 a.kbDispatcher.SetOnQuit(a.Quit)
2369 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {3258 a.kbDispatcher.SetOnFindOpen(func(ctx context.Context) error {
@@ -2631,13 +3520,13 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) {
2631 // updateWindowTitleFromActivePane updates the window title based on the current active pane.3520 // updateWindowTitleFromActivePane updates the window title based on the current active pane.
2632 func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {3521 func (a *App) updateWindowTitleFromActivePane(tabID entity.TabID) {
2633 var ws *entity.Workspace3522 var ws *entity.Workspace
2634- if a.tabs != nil {3523+ bw := a.browserWindowForTab(tabID)
2635- if tab := a.tabs.Find(tabID); tab != nil {3524+ if tabs := a.tabListForBrowserWindow(bw); tabs != nil {
3525+ if tab := tabs.Find(tabID); tab != nil {
2636 ws = tab.Workspace3526 ws = tab.Workspace
2637- } else {
2638- ws = a.activeWorkspace()
2639 }3527 }
2640- } else {3528+ }
3529+ if ws == nil {
2641 ws = a.activeWorkspace()3530 ws = a.activeWorkspace()
2642 }3531 }
2643 if ws == nil || a.contentCoord == nil {3532 if ws == nil || a.contentCoord == nil {
@@ -2657,6 +3546,7 @@ func (a *App) handleModeChange(ctx context.Context, from, to input.Mode) {
2657 a.clearResizeModeBorder()3546 a.clearResizeModeBorder()
2658 }3547 }
2659 if to == input.ModeResize {3548 if to == input.ModeResize {
3549+ // Resize mode targets the last-focused browser window's active workspace.
2660 a.applyResizeModeBorder(ctx, a.activeWorkspace())3550 a.applyResizeModeBorder(ctx, a.activeWorkspace())
2661 }3551 }
2662 3552
@@ -2722,6 +3612,8 @@ func (a *App) clearResizeModeBorder() {
2722 }3612 }
2723 3613
2724 func (a *App) applyResizeModeBorder(ctx context.Context, ws *entity.Workspace) {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 wsView := a.activeWorkspaceView()3617 wsView := a.activeWorkspaceView()
2726 if wsView == nil || ws == nil {3618 if wsView == nil || ws == nil {
2727 a.clearResizeModeBorder()3619 a.clearResizeModeBorder()
@@ -2809,8 +3701,17 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
2809 // Note: Mode borders for tab/pane/session are attached to MainWindow.3701 // Note: Mode borders for tab/pane/session are attached to MainWindow.
2810 // Resize mode border is attached to the active pane's stack container.3702 // Resize mode border is attached to the active pane's stack container.
2811 3703
2812- // Set omnibox config for this workspace view3704+ // Set omnibox config for this workspace view.
2813- wsView.SetOmniboxConfig(a.omniboxCfg)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 // Set find bar config for this workspace view3715 // Set find bar config for this workspace view
2815 wsView.SetFindBarConfig(a.findBarCfg)3716 wsView.SetFindBarConfig(a.findBarCfg)
2816 // Set auto-open omnibox on new pane3717 // Set auto-open omnibox on new pane
@@ -2820,6 +3721,7 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
2820 3721
2821 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {3722 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
2822 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {3723 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
3724+ // Resize-mode pane focus tracks the last-focused window's workspace.
2823 ws := a.activeWorkspace()3725 ws := a.activeWorkspace()
2824 if ws != nil {3726 if ws != nil {
2825 ws.ActivePaneID = paneID3727 ws.ActivePaneID = paneID
@@ -2842,50 +3744,40 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
2842 log.Debug().Str("tab_id", string(tab.ID)).Msg("workspace view created")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 func (a *App) activeWorkspace() *entity.Workspace {3748 func (a *App) activeWorkspace() *entity.Workspace {
2847- if a.tabs == nil {3749+ return a.activeWorkspaceForBrowserWindow(a.lastFocusedBrowserWindow())
2848- return nil
2849- }
2850- activeTab := a.tabs.ActiveTab()
2851- if activeTab == nil {
2852- return nil
2853- }
2854- return activeTab.Workspace
2855 }3750 }
2856 3751
2857 // updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.3752 // updatePaneURIInAllTabs finds a pane by ID across all tabs and updates its URI.
2858 // This is necessary because panes in inactive tabs also need URI updates for session snapshots.3753 // This is necessary because panes in inactive tabs also need URI updates for session snapshots.
2859 func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {3754 func (a *App) updatePaneURIInAllTabs(paneID entity.PaneID, url string) {
2860- for _, tab := range a.tabs.Tabs {3755+ for _, bw := range a.browserWindows {
2861- if tab.Workspace == nil {3756+ if bw == nil || bw.tabs == nil {
2862 continue3757 continue
2863 }3758 }
2864- paneNode := tab.Workspace.FindPane(paneID)3759+ for _, tab := range bw.tabs.Tabs {
2865- if paneNode != nil && paneNode.Pane != nil {3760+ if tab == nil || tab.Workspace == nil {
2866- paneNode.Pane.URI = url3761+ continue
2867- return // Pane IDs are unique, no need to continue3762+ }
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 func (a *App) activeWorkspaceView() *component.WorkspaceView {3773 func (a *App) activeWorkspaceView() *component.WorkspaceView {
2874- activeTab := a.tabs.ActiveTab()3774+ return a.activeWorkspaceViewForBrowserWindow(a.lastFocusedBrowserWindow())
2875- if activeTab == nil {
2876- return nil
2877- }
2878- return a.workspaceViews[activeTab.ID]
2879 }3775 }
2880 3776
2881 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.3777 // getActiveWebViewTarget returns a TextInputTarget for the active pane's WebView.
2882 // Used by the accent picker to insert accented characters into web content.3778 // Used by the accent picker to insert accented characters into web content.
2883 func (a *App) getActiveWebViewTarget() port.TextInputTarget {3779 func (a *App) getActiveWebViewTarget() port.TextInputTarget {
2884- if a.contentCoord == nil {3780+ _, wv := a.activeWebViewForBrowserWindow(a.lastFocusedBrowserWindow())
2885- return nil
2886- }
2887-
2888- wv := a.contentCoord.ActiveWebView(context.Background())
2889 if wv == nil {3781 if wv == nil {
2890 return nil3782 return nil
2891 }3783 }
@@ -2952,9 +3844,9 @@ func (a *App) switchWorkspaceView(ctx context.Context, tabID entity.TabID) {
2952 return3844 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 if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {3849 if target := a.browserWindowForTab(tabID); target != nil && target.mainWindow != nil {
2957- target.activeTabID = tabID
2958 target.mainWindow.SetContent(gtkWidget)3850 target.mainWindow.SetContent(gtkWidget)
2959 } else if a.mainWindow != nil {3851 } else if a.mainWindow != nil {
2960 a.mainWindow.SetContent(gtkWidget)3852 a.mainWindow.SetContent(gtkWidget)
@@ -3292,7 +4184,7 @@ func (a *App) releaseFloatingSessionsForTab(ctx context.Context, tabID entity.Ta
3292 }4184 }
3293 4185
3294 func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {4186 func (a *App) activeFloatingSessionEntry() (floatingSessionKey, *floatingWorkspaceSession, bool) {
3295- activeTab := a.tabs.ActiveTab()4187+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
3296 if activeTab == nil {4188 if activeTab == nil {
3297 return floatingSessionKey{}, nil, false4189 return floatingSessionKey{}, nil, false
3298 }4190 }
@@ -3377,7 +4269,7 @@ func (a *App) syncFloatingFocus() {
3377 return4269 return
3378 }4270 }
3379 4271
3380- activeTab := a.tabs.ActiveTab()4272+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
3381 if activeTab == nil {4273 if activeTab == nil {
3382 a.contentCoord.ClearActivePaneOverride()4274 a.contentCoord.ClearActivePaneOverride()
3383 return4275 return
@@ -3402,6 +4294,14 @@ func (a *App) showFloatingOmnibox(ctx context.Context, session *floatingWorkspac
3402 4294
3403 if session.omnibox == nil {4295 if session.omnibox == nil {
3404 cfg := a.omniboxCfg4296 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 cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {4305 cfg.OnToast = func(toastCtx context.Context, message string, level component.ToastLevel) {
3406 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)4306 a.showToastOnLastFocusedBrowserWindow(toastCtx, message, level)
3407 }4307 }
@@ -3619,7 +4519,7 @@ func (a *App) closeAndReleaseActiveFloatingPane(ctx context.Context) bool {
3619 4519
3620 // ToggleFloatingPane toggles the active workspace floating pane visibility.4520 // ToggleFloatingPane toggles the active workspace floating pane visibility.
3621 func (a *App) ToggleFloatingPane(ctx context.Context) error {4521 func (a *App) ToggleFloatingPane(ctx context.Context) error {
3622- activeTab := a.tabs.ActiveTab()4522+ activeTab := a.activeTabForBrowserWindow(a.lastFocusedBrowserWindow())
3623 if activeTab == nil {4523 if activeTab == nil {
3624 return nil4524 return nil
3625 }4525 }
@@ -3640,7 +4540,7 @@ func (a *App) OpenFloatingPaneProfileURL(ctx context.Context, sessionID, url str
3640 }4540 }
3641 4541
3642 func (a *App) openFloatingPaneSession(ctx context.Context, sessionID string, url ...string) error {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 if activeTab == nil {4544 if activeTab == nil {
3645 return nil4545 return nil
3646 }4546 }
@@ -3694,14 +4594,10 @@ func (a *App) navigateFromOmnibox(ctx context.Context, url string) error {
3694 if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {4594 if session != nil && session.pane != nil && session.pane.IsVisible() && session.pane.IsOmniboxVisible() {
3695 return session.pane.Navigate(ctx, url)4595 return session.pane.Navigate(ctx, url)
3696 }4596 }
3697- if a.omniboxNavigateFn != nil {4597+ if bw := a.lastFocusedBrowserWindow(); bw != nil {
3698- a.omniboxNavigateFn(url)4598+ return a.navigateFromBrowserWindow(ctx, bw, url)
3699- return nil
3700 }4599 }
3701- if a.navCoord == nil {4600+ return fmt.Errorf("no focused browser window for omnibox navigation")
3702- return fmt.Errorf("navigation coordinator not initialized")
3703- }
3704- return a.navCoord.Navigate(ctx, url)
3705 }4601 }
3706 4602
3707 // ToggleOmnibox implements OmniboxProvider.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 // initFilteringAsync starts background filter loading with toast feedback.4701 // initFilteringAsync starts background filter loading with toast feedback.
3813 func (a *App) initConfigWatcher(ctx context.Context) {4702 func (a *App) initConfigWatcher(ctx context.Context) {
3814 log := logging.FromContext(ctx)4703 log := logging.FromContext(ctx)
M internal/ui/app_browser_launch_test.go +1504 -47
@@ -2,12 +2,15 @@ package ui
2 2
3 import (3 import (
4 "context"4 "context"
5+ "errors"
5 "io"6 "io"
6 "os"7 "os"
7 "path/filepath"8 "path/filepath"
8 "reflect"9 "reflect"
10+ "strconv"
9 "strings"11 "strings"
10 "testing"12 "testing"
13+ "time"
11 "unsafe"14 "unsafe"
12 15
13 "github.com/bnema/dumber/internal/application/port"16 "github.com/bnema/dumber/internal/application/port"
@@ -153,6 +156,39 @@ func windowForTabCount(t *testing.T, app *App) int {
153 return fv.Len()156 return fv.Len()
154 }157 }
155 158
159+func assertWindowOwnershipInvariant(t *testing.T, app *App) {
160+ t.Helper()
161+
162+ seen := make(map[entity.TabID]string)
163+ for windowID, bw := range app.browserWindows {
164+ if bw == nil {
165+ continue
166+ }
167+ if bw.tabs == nil {
168+ t.Errorf("browser window %s has nil tabs", windowID)
169+ continue
170+ }
171+ for _, tab := range bw.tabs.Tabs {
172+ if tab == nil {
173+ continue
174+ }
175+ if previous, exists := seen[tab.ID]; exists {
176+ t.Errorf("tab %s is owned by both %s and %s", tab.ID, previous, windowID)
177+ continue
178+ }
179+ seen[tab.ID] = windowID
180+ if got := app.windowForTab[tab.ID]; got != bw {
181+ t.Errorf("windowForTab[%s] = %p, want owner window %s (%p)", tab.ID, got, windowID, bw)
182+ }
183+ }
184+ }
185+ for tabID, bw := range app.windowForTab {
186+ if bw == nil || bw.tabs == nil || bw.tabs.Find(tabID) == nil {
187+ t.Errorf("windowForTab[%s] points to stale or non-owning window %p", tabID, bw)
188+ }
189+ }
190+}
191+
156 // tabCoordinatorMainWindowPtr uses reflection to inspect unexported coordinator state in tests.192 // tabCoordinatorMainWindowPtr uses reflection to inspect unexported coordinator state in tests.
157 // Keep this test-only so production visibility stays narrow.193 // Keep this test-only so production visibility stays narrow.
158 func tabCoordinatorMainWindowPtr(t *testing.T, tc *coordinator.TabCoordinator) uintptr {194 func tabCoordinatorMainWindowPtr(t *testing.T, tc *coordinator.TabCoordinator) uintptr {
@@ -177,6 +213,24 @@ func setWindowTabBar(t *testing.T, mw *window.MainWindow, tabBar *component.TabB
177 reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(tabBar))213 reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Set(reflect.ValueOf(tabBar))
178 }214 }
179 215
216+func omniboxNavigateCallbackForTest(t *testing.T, omnibox *component.Omnibox) func(string) {
217+ t.Helper()
218+ if omnibox == nil {
219+ t.Fatal("omnibox is nil")
220+ }
221+
222+ rv := reflect.ValueOf(omnibox).Elem()
223+ fv := rv.FieldByName("onNavigate")
224+ if !fv.IsValid() {
225+ t.Fatal("Omnibox missing onNavigate field")
226+ }
227+ cb, ok := reflect.NewAt(fv.Type(), unsafe.Pointer(fv.UnsafeAddr())).Elem().Interface().(func(string))
228+ if !ok || cb == nil {
229+ t.Fatal("omnibox onNavigate callback is nil or has unexpected type")
230+ }
231+ return cb
232+}
233+
180 func newTestTabBarShell(t *testing.T, tabIDs ...entity.TabID) *component.TabBar {234 func newTestTabBarShell(t *testing.T, tabIDs ...entity.TabID) *component.TabBar {
181 t.Helper()235 t.Helper()
182 236
@@ -220,6 +274,32 @@ func windowTabBarVisible(t *testing.T, mw *window.MainWindow) bool {
220 return mw.TabBar().Box().GetVisible()274 return mw.TabBar().Box().GetVisible()
221 }275 }
222 276
277+func assertWindowTabBarAutoHidden(t *testing.T, mw *window.MainWindow, wantHidden bool) {
278+ t.Helper()
279+ if mw == nil || mw.TabBar() == nil || mw.TabBar().Box() == nil {
280+ t.Fatal("missing tab bar")
281+ }
282+ box := mw.TabBar().Box()
283+ if got := box.GetVisible(); !got {
284+ t.Fatalf("tab bar visible = %v, want true so allocation is preserved", got)
285+ }
286+ if wantHidden {
287+ if got := box.GetOpacity(); got != 0.0 {
288+ t.Fatalf("tab bar opacity = %v, want 0", got)
289+ }
290+ if got := box.GetCanTarget(); got {
291+ t.Fatalf("tab bar can target = %v, want false while auto-hidden", got)
292+ }
293+ return
294+ }
295+ if got := box.GetOpacity(); got != 1.0 {
296+ t.Fatalf("tab bar opacity = %v, want 1", got)
297+ }
298+ if got := box.GetCanTarget(); !got {
299+ t.Fatalf("tab bar can target = %v, want true while shown", got)
300+ }
301+}
302+
223 func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool {303 func stackedViewOnActivateIsNil(t *testing.T, sv *layout.StackedView) bool {
224 t.Helper()304 t.Helper()
225 if sv == nil {305 if sv == nil {
@@ -320,23 +400,102 @@ func TestApp_FinalizeActivationStartsBrowserLaunchRelayOnceAndClosesOnShutdown(t
320 }400 }
321 401
322 func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {402 func TestApp_OpenFreshWindowRecordsTabOwnership(t *testing.T) {
323- created := &browserWindow{id: "window-1"}403+ existingTab := entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
404+ existingTabs := entity.NewTabList()
405+ existingTabs.Add(existingTab)
406+ existingTabs.SetActive(existingTab.ID)
407+ existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs}
408+ created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
324 app := &App{409 app := &App{
325 tabs: entity.NewTabList(),410 tabs: entity.NewTabList(),
326 tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),411 tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),
327- browserWindows: make(map[string]*browserWindow),412+ browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
413+ windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
328 workspaceViews: map[entity.TabID]*component.WorkspaceView{entity.TabID("id-1"): &component.WorkspaceView{}},414 workspaceViews: map[entity.TabID]*component.WorkspaceView{entity.TabID("id-1"): &component.WorkspaceView{}},
329 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {415 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
330 return created, nil416 return created, nil
331 },417 },
332 }418 }
333- app.tabs.Add(entity.NewTab(entity.TabID("existing-tab"), entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
334 419
335 if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {420 if err := app.OpenFreshWindow(context.Background(), "https://example.com"); err != nil {
336 t.Fatalf("OpenFreshWindow returned error: %v", err)421 t.Fatalf("OpenFreshWindow returned error: %v", err)
337 }422 }
338- if got := windowForTabCount(t, app); got != 1 {423+ if got := windowForTabCount(t, app); got != 2 {
339- t.Fatalf("windowForTab length = %d, want 1", got)424+ t.Fatalf("windowForTab length = %d, want 2 (existing + new tab)", got)
425+ }
426+ assertWindowOwnershipInvariant(t, app)
427+}
428+
429+func TestApp_RestoreSessionHandlesEmptyWindowSnapshotAsSuccess(t *testing.T) {
430+ ctx := context.Background()
431+ sessionID := entity.SessionID("session-empty-windows")
432+ staleTab := entity.NewTab(entity.TabID("stale-tab"), entity.WorkspaceID("stale-workspace"), entity.NewPane(entity.PaneID("stale-pane")))
433+ staleTabs := entity.NewTabList()
434+ staleTabs.Add(staleTab)
435+ staleWindow := &browserWindow{id: "window-stale", tabs: staleTabs}
436+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))
437+
438+ app := &App{
439+ deps: &Dependencies{
440+ Config: &config.Config{},
441+ SessionStateRepo: &fakeSessionStateRepo{state: state},
442+ },
443+ tabs: entity.NewTabList(),
444+ browserWindows: map[string]*browserWindow{staleWindow.id: staleWindow},
445+ lastFocusedWindowID: staleWindow.id,
446+ workspaceViews: map[entity.TabID]*component.WorkspaceView{staleTab.ID: {}},
447+ windowForTab: map[entity.TabID]*browserWindow{staleTab.ID: staleWindow},
448+ }
449+ app.tabs.Add(staleTab)
450+
451+ err := app.restoreSession(ctx, sessionID)
452+ if err != nil {
453+ t.Fatalf("restoreSession returned error for valid v2 empty window snapshot: %v", err)
454+ }
455+ // Stale state should be cleared and stale window should have an empty tab list.
456+ if _, ok := app.workspaceViews[staleTab.ID]; ok {
457+ t.Fatal("stale workspace view was not cleared")
458+ }
459+ if _, ok := app.windowForTab[staleTab.ID]; ok {
460+ t.Fatal("stale windowForTab entry was not cleared")
461+ }
462+ if got := app.tabs.Count(); got != 0 {
463+ t.Fatalf("app.tabs.Count() = %d, want 0 after empty restore", got)
464+ }
465+ if got := staleWindow.tabs.Count(); got != 0 {
466+ t.Fatalf("staleWindow.tabs.Count() = %d, want 0 after empty restore", got)
467+ }
468+}
469+
470+func TestApp_CreateInitialTabNoFallbackAfterEmptyWindowRestore(t *testing.T) {
471+ ctx := context.Background()
472+ sessionID := entity.SessionID("session-empty-windows-fallback")
473+ state := entity.SnapshotFromWindowTabLists(sessionID, []entity.WindowTabListState{}, 0, time.Unix(123, 0))
474+ tabsUC := usecase.NewManageTabsUseCase(func() string { return "fallback-tab" })
475+ windowTabs := entity.NewTabList()
476+ bw := &browserWindow{id: "window-1", tabs: windowTabs}
477+ app := &App{
478+ deps: &Dependencies{
479+ Config: &config.Config{},
480+ RestoreSessionID: string(sessionID),
481+ InitialURL: "https://fallback.example",
482+ SessionStateRepo: &fakeSessionStateRepo{state: state},
483+ },
484+ tabs: entity.NewTabList(),
485+ tabsUC: tabsUC,
486+ tabCoord: coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{TabsUC: tabsUC, Tabs: entity.NewTabList()}),
487+ browserWindows: map[string]*browserWindow{bw.id: bw},
488+ lastFocusedWindowID: bw.id,
489+ }
490+
491+ app.createInitialTab(ctx)
492+
493+ // For valid v2 empty-window restore, no fallback tab should be created.
494+ if created := windowTabs.Find(entity.TabID("fallback-tab")); created != nil {
495+ t.Fatal("fallback tab was created despite valid v2 empty-window restore")
496+ }
497+ if got := app.tabs.Count(); got != 0 {
498+ t.Fatalf("app.tabs.Count() = %d, want 0 after empty restore", got)
340 }499 }
341 }500 }
342 501
@@ -394,6 +553,7 @@ func TestApp_RestoreSessionClearsStaleUIStateBeforeApplyingRestoredTabs(t *testi
394 if _, ok := app.floatingSessions[staleSessionKey]; ok {553 if _, ok := app.floatingSessions[staleSessionKey]; ok {
395 t.Fatalf("stale floating session was not removed")554 t.Fatalf("stale floating session was not removed")
396 }555 }
556+ assertWindowOwnershipInvariant(t, app)
397 }557 }
398 558
399 func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) {559 func TestApp_RestoreSessionWiresStackedPaneTitleBarCallbacks(t *testing.T) {
@@ -489,40 +649,53 @@ func TestApp_RemoveBrowserWindowRebindsPromotedTabCoordinatorWindow(t *testing.T
489 }649 }
490 650
491 func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {651 func TestApp_OpenFreshWindowRollsBackOnTabCreationFailure(t *testing.T) {
492- created := &browserWindow{id: "window-1"}652+ created := &browserWindow{id: "window-1", tabs: entity.NewTabList()}
493 originalWindow := &window.MainWindow{}653 originalWindow := &window.MainWindow{}
494 tabBar := &component.TabBar{}654 tabBar := &component.TabBar{}
495 setWindowTabBar(t, originalWindow, tabBar)655 setWindowTabBar(t, originalWindow, tabBar)
496 existingTabID := entity.TabID("existing-tab")656 existingTabID := entity.TabID("existing-tab")
497 staleTabID := entity.TabID("stale-tab")657 staleTabID := entity.TabID("stale-tab")
498 tabBar.SetActive(staleTabID)658 tabBar.SetActive(staleTabID)
659+
660+ existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
661+ existingTabs := entity.NewTabList()
662+ existingTabs.Add(existingTab)
663+ existingTabs.SetActive(existingTab.ID)
664+ existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs, mainWindow: originalWindow}
665+
499 app := &App{666 app := &App{
500 tabs: entity.NewTabList(),667 tabs: entity.NewTabList(),
501 tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),668 tabsUC: usecase.NewManageTabsUseCase(func() string { return "id-1" }),
502- browserWindows: make(map[string]*browserWindow),669+ browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
670+ windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
503 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {671 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
504 return created, nil672 return created, nil
505 },673 },
506 mainWindow: originalWindow,674 mainWindow: originalWindow,
507 }675 }
508- app.tabs.Add(entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))
509 app.tabCoord = coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{676 app.tabCoord = coordinator.NewTabCoordinator(context.Background(), coordinator.TabCoordinatorConfig{
510 TabsUC: app.tabsUC,677 TabsUC: app.tabsUC,
511- Tabs: app.tabs,678+ Tabs: entity.NewTabList(),
512 MainWindow: &window.MainWindow{},679 MainWindow: &window.MainWindow{},
513 })680 })
514 681
515 if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil {682 if err := app.OpenFreshWindow(context.Background(), "https://example.com/fail"); err == nil {
516 t.Fatalf("OpenFreshWindow = nil error, want failure")683 t.Fatalf("OpenFreshWindow = nil error, want failure")
517 }684 }
518- if got := len(app.browserWindows); got != 0 {685+ if got := len(app.browserWindows); got != 1 {
519- t.Fatalf("browserWindows length = %d, want 0", got)686+ t.Fatalf("browserWindows length = %d, want 1 (existing window only)", got)
687+ }
688+ if app.browserWindows[existingWindow.id] == nil {
689+ t.Fatal("existing window should remain after rollback")
520 }690 }
521- if got := windowForTabCount(t, app); got != 0 {691+ if app.browserWindows["window-1"] != nil {
522- t.Fatalf("windowForTab length = %d, want 0", got)692+ t.Fatal("created window should be removed after rollback")
693+ }
694+ if got := windowForTabCount(t, app); got != 1 {
695+ t.Fatalf("windowForTab length = %d, want 1 (existing tab only)", got)
523 }696 }
524- if got := windowTabBarActiveID(t, originalWindow); got != existingTabID {697+ if got := windowTabBarActiveID(t, originalWindow); got != staleTabID {
525- t.Fatalf("tab bar active tab = %q, want %q", got, existingTabID)698+ t.Fatalf("tab bar active tab = %q, want %q (original window unchanged)", got, staleTabID)
526 }699 }
527 }700 }
528 701
@@ -536,10 +709,17 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
536 tabs := entity.NewTabList()709 tabs := entity.NewTabList()
537 tabsUC := usecase.NewManageTabsUseCase(func() string { return string(createdTabID) })710 tabsUC := usecase.NewManageTabsUseCase(func() string { return string(createdTabID) })
538 711
712+ existingTab := entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane")))
713+ existingTabs := entity.NewTabList()
714+ existingTabs.Add(existingTab)
715+ existingTabs.SetActive(existingTab.ID)
716+ existingWindow := &browserWindow{id: "existing-window", tabs: existingTabs, mainWindow: oldWindow}
717+
539 app := &App{718 app := &App{
540 tabs: tabs,719 tabs: tabs,
541 tabsUC: tabsUC,720 tabsUC: tabsUC,
542- browserWindows: make(map[string]*browserWindow),721+ browserWindows: map[string]*browserWindow{existingWindow.id: existingWindow},
722+ windowForTab: map[entity.TabID]*browserWindow{existingTab.ID: existingWindow},
543 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {723 browserWindowFactory: func(context.Context, string) (*browserWindow, error) {
544 return &browserWindow{id: "window-1", mainWindow: newWindow}, nil724 return &browserWindow{id: "window-1", mainWindow: newWindow}, nil
545 },725 },
@@ -552,8 +732,7 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
552 }),732 }),
553 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),733 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
554 }734 }
555- app.tabs.Add(entity.NewTab(existingTabID, entity.WorkspaceID("existing-workspace"), entity.NewPane(entity.PaneID("existing-pane"))))735+ app.tabCoord.SetOnTabCreated(func(ctx context.Context, target coordinator.TabTarget, tab *entity.Tab) {
556- app.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
557 app.workspaceViews[tab.ID] = &component.WorkspaceView{}736 app.workspaceViews[tab.ID] = &component.WorkspaceView{}
558 })737 })
559 738
@@ -571,9 +750,8 @@ func TestApp_OpenFreshWindowTargetsNewWindowTabBar(t *testing.T) {
571 if got := windowTabBarActiveID(t, oldWindow); got != "" {750 if got := windowTabBarActiveID(t, oldWindow); got != "" {
572 t.Fatalf("old window tab bar active tab = %q, want empty", got)751 t.Fatalf("old window tab bar active tab = %q, want empty", got)
573 }752 }
574- if got := windowTabBarVisible(t, newWindow); got {753+ // The lightweight test tab bar shell has no GTK box, so allocation state is
575- t.Fatalf("new window tab bar visible = %v, want false", got)754+ // covered by TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation.
576- }
577 }755 }
578 756
579 func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {757 func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {
@@ -584,8 +762,14 @@ func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {
584 tabs.Add(tab2)762 tabs.Add(tab2)
585 ws1 := &component.WorkspaceView{}763 ws1 := &component.WorkspaceView{}
586 ws2 := &component.WorkspaceView{}764 ws2 := &component.WorkspaceView{}
587- first := &browserWindow{id: "window-1", activeTabID: tab1.ID, mainWindow: &window.MainWindow{}}765+ firstTabs := entity.NewTabList()
588- second := &browserWindow{id: "window-2", activeTabID: tab2.ID, mainWindow: &window.MainWindow{}}766+ firstTabs.Add(tab1)
767+ firstTabs.SetActive(tab1.ID)
768+ first := &browserWindow{id: "window-1", tabs: firstTabs, mainWindow: &window.MainWindow{}}
769+ secondTabs := entity.NewTabList()
770+ secondTabs.Add(tab2)
771+ secondTabs.SetActive(tab2.ID)
772+ second := &browserWindow{id: "window-2", tabs: secondTabs, mainWindow: &window.MainWindow{}}
589 kh := &input.KeyboardHandler{}773 kh := &input.KeyboardHandler{}
590 gs := &input.GlobalShortcutHandler{}774 gs := &input.GlobalShortcutHandler{}
591 second.keyboardHandler = kh775 second.keyboardHandler = kh
@@ -603,8 +787,8 @@ func TestApp_ActivateBrowserWindowSwitchesActiveWorkspace(t *testing.T) {
603 if app.mainWindow != second.mainWindow {787 if app.mainWindow != second.mainWindow {
604 t.Fatalf("mainWindow = %p, want %p", app.mainWindow, second.mainWindow)788 t.Fatalf("mainWindow = %p, want %p", app.mainWindow, second.mainWindow)
605 }789 }
606- if app.tabs.ActiveTabID != tab2.ID {790+ if second.tabs.ActiveTabID != tab2.ID {
607- t.Fatalf("active tab = %q, want %q", app.tabs.ActiveTabID, tab2.ID)791+ t.Fatalf("active tab = %q, want %q", second.tabs.ActiveTabID, tab2.ID)
608 }792 }
609 if app.activeWorkspaceView() != ws2 {793 if app.activeWorkspaceView() != ws2 {
610 t.Fatalf("active workspace view = %p, want %p", app.activeWorkspaceView(), ws2)794 t.Fatalf("active workspace view = %p, want %p", app.activeWorkspaceView(), ws2)
@@ -623,8 +807,12 @@ func TestApp_BrowserWindowForPaneFindsOwningWindow(t *testing.T) {
623 tabs := entity.NewTabList()807 tabs := entity.NewTabList()
624 tabs.Add(tab1)808 tabs.Add(tab1)
625 tabs.Add(tab2)809 tabs.Add(tab2)
626- first := &browserWindow{id: "window-1"}810+ firstTabs := entity.NewTabList()
627- second := &browserWindow{id: "window-2"}811+ firstTabs.Add(tab1)
812+ secondTabs := entity.NewTabList()
813+ secondTabs.Add(tab2)
814+ first := &browserWindow{id: "window-1", tabs: firstTabs}
815+ second := &browserWindow{id: "window-2", tabs: secondTabs}
628 app := &App{816 app := &App{
629 tabs: tabs,817 tabs: tabs,
630 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},818 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
@@ -644,8 +832,12 @@ func TestApp_OwnerOrLastFocusedBrowserWindowPrefersPaneOwner(t *testing.T) {
644 tabs := entity.NewTabList()832 tabs := entity.NewTabList()
645 tabs.Add(tab1)833 tabs.Add(tab1)
646 tabs.Add(tab2)834 tabs.Add(tab2)
647- first := &browserWindow{id: "window-1"}835+ firstTabs := entity.NewTabList()
648- second := &browserWindow{id: "window-2"}836+ firstTabs.Add(tab1)
837+ secondTabs := entity.NewTabList()
838+ secondTabs.Add(tab2)
839+ first := &browserWindow{id: "window-1", tabs: firstTabs}
840+ second := &browserWindow{id: "window-2", tabs: secondTabs}
649 app := &App{841 app := &App{
650 tabs: tabs,842 tabs: tabs,
651 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},843 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
@@ -680,8 +872,14 @@ func TestApp_HandlePaneWindowTitleChangedTargetsOwningWindow(t *testing.T) {
680 tabs := entity.NewTabList()872 tabs := entity.NewTabList()
681 tabs.Add(tab1)873 tabs.Add(tab1)
682 tabs.Add(tab2)874 tabs.Add(tab2)
683- first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}875+ firstTabs := entity.NewTabList()
684- second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}876+ firstTabs.Add(tab1)
877+ firstTabs.SetActive(tab1.ID)
878+ secondTabs := entity.NewTabList()
879+ secondTabs.Add(tab2)
880+ secondTabs.SetActive(tab2.ID)
881+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
882+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
685 app := &App{883 app := &App{
686 tabs: tabs,884 tabs: tabs,
687 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},885 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
@@ -719,8 +917,14 @@ func TestApp_ActivateBrowserWindowResyncsTitleFromBackgroundPane(t *testing.T) {
719 tabs := entity.NewTabList()917 tabs := entity.NewTabList()
720 tabs.Add(tab1)918 tabs.Add(tab1)
721 tabs.Add(tab2)919 tabs.Add(tab2)
722- first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, activeTabID: tab1.ID}920+ firstTabs := entity.NewTabList()
723- second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, activeTabID: tab2.ID}921+ firstTabs.Add(tab1)
922+ firstTabs.SetActive(tab1.ID)
923+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
924+ secondTabs := entity.NewTabList()
925+ secondTabs.Add(tab2)
926+ secondTabs.SetActive(tab2.ID)
927+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
724 contentCoord := &contentcoord.Coordinator{}928 contentCoord := &contentcoord.Coordinator{}
725 contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles")929 contentTitles := reflect.ValueOf(contentCoord).Elem().FieldByName("paneTitles")
726 reflect.NewAt(contentTitles.Type(), unsafe.Pointer(contentTitles.UnsafeAddr())).Elem().Set(reflect.ValueOf(map[entity.PaneID]string{tab2.Workspace.ActivePaneID: "Pane Two"}))930 reflect.NewAt(contentTitles.Type(), unsafe.Pointer(contentTitles.UnsafeAddr())).Elem().Set(reflect.ValueOf(map[entity.PaneID]string{tab2.Workspace.ActivePaneID: "Pane Two"}))
@@ -766,8 +970,14 @@ func TestApp_HandlePaneFullscreenChangedTargetsOwningWindow(t *testing.T) {
766 tabs.Add(tab2)970 tabs.Add(tab2)
767 secondMainWindow.TabBar().AddTab(tab1)971 secondMainWindow.TabBar().AddTab(tab1)
768 secondMainWindow.TabBar().AddTab(tab2)972 secondMainWindow.TabBar().AddTab(tab2)
769- first := &browserWindow{id: "window-1", mainWindow: firstMainWindow}973+ firstTabs := entity.NewTabList()
770- second := &browserWindow{id: "window-2", mainWindow: secondMainWindow}974+ firstTabs.Add(tab1)
975+ firstTabs.SetActive(tab1.ID)
976+ secondTabs := entity.NewTabList()
977+ secondTabs.Add(tab2)
978+ secondTabs.SetActive(tab2.ID)
979+ first := &browserWindow{id: "window-1", mainWindow: firstMainWindow, tabs: firstTabs}
980+ second := &browserWindow{id: "window-2", mainWindow: secondMainWindow, tabs: secondTabs}
771 app := &App{981 app := &App{
772 tabs: tabs,982 tabs: tabs,
773 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},983 browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
@@ -851,8 +1061,14 @@ func TestApp_MoveActivePaneToTabFromBrowserWindowAnchorsOwningWindow(t *testing.
851 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))1061 tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
852 tabs.Add(tab1)1062 tabs.Add(tab1)
853 tabs.Add(tab2)1063 tabs.Add(tab2)
854- first := &browserWindow{id: "window-1", activeTabID: tab1.ID}1064+ firstTabs := entity.NewTabList()
855- second := &browserWindow{id: "window-2", activeTabID: tab2.ID}1065+ firstTabs.Add(tab1)
1066+ firstTabs.SetActive(tab1.ID)
1067+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1068+ secondTabs := entity.NewTabList()
1069+ secondTabs.Add(tab2)
1070+ secondTabs.SetActive(tab2.ID)
1071+ second := &browserWindow{id: "window-2", tabs: secondTabs}
856 app := &App{1072 app := &App{
857 tabs: tabs,1073 tabs: tabs,
858 movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }),1074 movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "new-tab" }),
@@ -882,8 +1098,14 @@ func TestApp_MoveActivePaneToTabFromBrowserWindowActivatesTargetOwnerForCrossWin
882 tabs.Add(tab2)1098 tabs.Add(tab2)
883 tabs.Add(tab3)1099 tabs.Add(tab3)
884 tabs.SetActive(tab1.ID)1100 tabs.SetActive(tab1.ID)
885- first := &browserWindow{id: "window-1", activeTabID: tab1.ID}1101+ firstTabs := entity.NewTabList()
886- second := &browserWindow{id: "window-2", activeTabID: tab2.ID}1102+ firstTabs.Add(tab1)
1103+ firstTabs.SetActive(tab1.ID)
1104+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1105+ secondTabs := entity.NewTabList()
1106+ secondTabs.Add(tab2)
1107+ secondTabs.SetActive(tab2.ID)
1108+ second := &browserWindow{id: "window-2", tabs: secondTabs}
887 app := &App{1109 app := &App{
888 tabs: tabs,1110 tabs: tabs,
889 movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),1111 movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),
@@ -914,6 +1136,65 @@ func TestApp_MoveActivePaneToTabFromBrowserWindowActivatesTargetOwnerForCrossWin
914 }1136 }
915 }1137 }
916 1138
1139+func TestApp_MoveActivePaneToTabFromBrowserWindowCrossWindowDerivedMirror(t *testing.T) {
1140+ // app.tabs initially contains ONLY source tab (not target tab) to simulate
1141+ // a target created only in browserWindow.tabs (the real-world scenario that
1142+ // CodeRabbit flagged). The fix ensures buildMovePaneToTabInput syncs the
1143+ // derived global mirror before passing a.tabs to the move usecase.
1144+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1145+ tab3 := entity.NewTab(entity.TabID("tab-3"), entity.WorkspaceID("workspace-3"), entity.NewPane(entity.PaneID("pane-3")))
1146+
1147+ // app.tabs contains only tab1 — tab3 is absent from the mirror.
1148+ tabs := entity.NewTabList()
1149+ tabs.Add(tab1)
1150+ tabs.SetActive(tab1.ID)
1151+
1152+ firstTabs := entity.NewTabList()
1153+ firstTabs.Add(tab1)
1154+ firstTabs.SetActive(tab1.ID)
1155+ first := &browserWindow{id: "window-1", tabs: firstTabs}
1156+
1157+ secondTabs := entity.NewTabList()
1158+ secondTabs.Add(tab3)
1159+ secondTabs.SetActive(tab3.ID)
1160+ second := &browserWindow{id: "window-2", tabs: secondTabs}
1161+
1162+ initialPaneCount := tab3.Workspace.PaneCount()
1163+
1164+ app := &App{
1165+ tabs: tabs,
1166+ movePaneToTabUC: usecase.NewMovePaneToTabUseCase(func() string { return "generated" }),
1167+ deps: &Dependencies{Config: &config.Config{}},
1168+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
1169+ windowForTab: map[entity.TabID]*browserWindow{tab1.ID: first, tab3.ID: second},
1170+ lastFocusedWindowID: first.id,
1171+ }
1172+
1173+ if err := app.moveActivePaneToTabFromBrowserWindow(context.Background(), first, tab3.ID); err != nil {
1174+ t.Fatalf("moveActivePaneToTabFromBrowserWindow returned error: %v", err)
1175+ }
1176+
1177+ // The derived mirror must now contain tab3 (synced by the fix).
1178+ if app.tabs.Find(tab3.ID) == nil {
1179+ t.Fatalf("tab-3 should have been synced into the derived global mirror")
1180+ }
1181+
1182+ // No new tab should have been generated — the move should target tab3.
1183+ if app.tabs.Find(entity.TabID("generated")) != nil {
1184+ t.Fatalf("a new tab was incorrectly generated; the move should target tab-3")
1185+ }
1186+
1187+ // The moved pane should be in tab3's workspace.
1188+ if got := tab3.Workspace.PaneCount(); got != initialPaneCount+1 {
1189+ t.Fatalf("tab-3 pane count = %d, want %d (original + moved pane)", got, initialPaneCount+1)
1190+ }
1191+
1192+ // windowForTab for tab3 must remain second.
1193+ if app.windowForTab[tab3.ID] != second {
1194+ t.Fatalf("windowForTab[tab-3] = %p, want second window %p", app.windowForTab[tab3.ID], second)
1195+ }
1196+}
1197+
917 func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) {1198 func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T) {
918 gtkApp := requireGTKDisplayApp(t)1199 gtkApp := requireGTKDisplayApp(t)
919 defer gtkApp.Unref()1200 defer gtkApp.Unref()
@@ -936,7 +1217,7 @@ func TestApp_InitKeyboardHandlerDoesNotReattachExistingWindowInput(t *testing.T)
936 deps: &Dependencies{Config: &config.Config{}},1217 deps: &Dependencies{Config: &config.Config{}},
937 mainWindow: mainWindow,1218 mainWindow: mainWindow,
938 browserWindows: map[string]*browserWindow{bw.id: bw},1219 browserWindows: map[string]*browserWindow{bw.id: bw},
939- kbDispatcher: dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, nil, "", func(context.Context) entity.PaneID { return "" }),1220+ kbDispatcher: dispatcher.NewKeyboardDispatcher(context.Background(), nil, nil, nil, nil, dispatcher.KeyboardActions{}, func(context.Context) entity.PaneID { return "" }),
940 }1221 }
941 1222
942 app.initKeyboardHandler(context.Background())1223 app.initKeyboardHandler(context.Background())
@@ -974,7 +1255,7 @@ type testFocusedInputProvider struct{}
974 func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil }1255 func (*testFocusedInputProvider) GetFocusedInput() port.TextInputTarget { return nil }
975 func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget) {}1256 func (*testFocusedInputProvider) SetFocusedInput(port.TextInputTarget) {}
976 1257
977-func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {1258+func TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAllocation(t *testing.T) {
978 gtkApp := requireGTKDisplayApp(t)1259 gtkApp := requireGTKDisplayApp(t)
979 defer gtkApp.Unref()1260 defer gtkApp.Unref()
980 1261
@@ -983,7 +1264,76 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(
983 if tabBar == nil {1264 if tabBar == nil {
984 t.Fatal("tab bar creation failed")1265 t.Fatal("tab bar creation failed")
985 }1266 }
1267+ setWindowTabBar(t, mainWindow, tabBar)
1268+ bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
1269+ app := &App{
1270+ deps: &Dependencies{Config: &config.Config{}},
1271+ }
1272+ app.deps.Config.Workspace.HideTabBarWhenSingleTab = true
1273+
1274+ tab1 := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("workspace-1"), entity.NewPane(entity.PaneID("pane-1")))
1275+ tab2 := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("workspace-2"), entity.NewPane(entity.PaneID("pane-2")))
1276+ tabBar.AddTab(tab1)
1277+
1278+ app.updateBrowserWindowTabBarVisibility(bw)
1279+ assertWindowTabBarAutoHidden(t, mainWindow, true)
1280+
1281+ tabBar.AddTab(tab2)
1282+ app.updateBrowserWindowTabBarVisibility(bw)
1283+ assertWindowTabBarAutoHidden(t, mainWindow, false)
1284+
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 tabBar.SetVisible(false)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 setWindowTabBar(t, mainWindow, tabBar)1337 setWindowTabBar(t, mainWindow, tabBar)
988 bw := &browserWindow{id: "window-1", mainWindow: mainWindow}1338 bw := &browserWindow{id: "window-1", mainWindow: mainWindow}
989 app := &App{1339 app := &App{
@@ -993,8 +1343,141 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(
993 1343
994 app.updateBrowserWindowTabBarVisibility(bw)1344 app.updateBrowserWindowTabBarVisibility(bw)
995 1345
996- if got := windowTabBarVisible(t, mainWindow); !got {1346+ assertWindowTabBarAutoHidden(t, mainWindow, false)
997- t.Fatalf("tab bar visible = %v, want true", got)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 firstValid.id: firstValid,1495 firstValid.id: firstValid,
1013 secondValid.id: secondValid,1496 secondValid.id: secondValid,
1014 },1497 },
1498+ browserWindowOrder: []string{removed.id, nilWindow.id, secondValid.id, firstValid.id},
1015 lastFocusedWindowID: removed.id,1499 lastFocusedWindowID: removed.id,
1016 }1500 }
1017 1501
1018 app.removeBrowserWindow(removed.id)1502 app.removeBrowserWindow(removed.id)
1019 1503
1020- if app.mainWindow != firstValid.mainWindow {1504+ if app.mainWindow != secondValid.mainWindow {
1021- t.Fatalf("mainWindow = %p, want %p", app.mainWindow, firstValid.mainWindow)1505+ t.Fatalf("mainWindow = %p, want %p", app.mainWindow, secondValid.mainWindow)
1022 }1506 }
1023- if app.lastFocusedWindowID != firstValid.id {1507+ if app.lastFocusedWindowID != secondValid.id {
1024- t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, firstValid.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 func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {1603 func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
1074 first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}1604 first := &browserWindow{id: "window-1", mainWindow: &window.MainWindow{}}
1075 second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}1605 second := &browserWindow{id: "window-2", mainWindow: &window.MainWindow{}}
@@ -1085,3 +1615,930 @@ func TestApp_RemoveBrowserWindowClearsLastFocusedFallback(t *testing.T) {
1085 t.Fatalf("lastFocusedWindowID = %q, want %q", app.lastFocusedWindowID, first.id)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 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/dumber/internal/ui/component"11 "github.com/bnema/dumber/internal/ui/component"
12+ "github.com/bnema/dumber/internal/ui/coordinator"
12 "github.com/bnema/dumber/internal/ui/focus"13 "github.com/bnema/dumber/internal/ui/focus"
13 "github.com/bnema/dumber/internal/ui/input"14 "github.com/bnema/dumber/internal/ui/input"
14 "github.com/bnema/dumber/internal/ui/layout"15 "github.com/bnema/dumber/internal/ui/layout"
@@ -19,8 +20,7 @@ import (
19 type browserWindow struct {20 type browserWindow struct {
20 id string21 id string
21 initialURL string22 initialURL string
22- activeTabID entity.TabID23+ tabs *entity.TabList // per-window tab list (single source of truth for tab state)
23- prevActiveTabID entity.TabID // per-window previous active tab (for Alt+Tab behavior)
24 mainWindow *window.MainWindow24 mainWindow *window.MainWindow
25 appToaster *component.Toaster25 appToaster *component.Toaster
26 modeToaster *component.Toaster26 modeToaster *component.Toaster
@@ -231,14 +231,29 @@ func (bw *browserWindow) initTabPicker(ctx context.Context, a *App) {
231 log.Debug().Msg("tab picker initialized")231 log.Debug().Msg("tab picker initialized")
232 }232 }
233 233
234+func (bw *browserWindow) ensureTabs() {
235+ if bw != nil && bw.tabs == nil {
236+ bw.tabs = entity.NewTabList()
237+ }
238+}
239+
234 func (a *App) registerBrowserWindow(bw *browserWindow) {240 func (a *App) registerBrowserWindow(bw *browserWindow) {
235 if bw == nil {241 if bw == nil {
236 return242 return
237 }243 }
244+ bw.ensureTabs()
238 if a.browserWindows == nil {245 if a.browserWindows == nil {
239 a.browserWindows = make(map[string]*browserWindow)246 a.browserWindows = make(map[string]*browserWindow)
240 }247 }
241 a.browserWindows[bw.id] = bw248 a.browserWindows[bw.id] = bw
249+
250+ // Track registration order, guarding against duplicates.
251+ for _, id := range a.browserWindowOrder {
252+ if id == bw.id {
253+ return
254+ }
255+ }
256+ a.browserWindowOrder = append(a.browserWindowOrder, bw.id)
242 }257 }
243 258
244 func (a *App) removeBrowserWindow(id string) {259 func (a *App) removeBrowserWindow(id string) {
@@ -246,6 +261,14 @@ func (a *App) removeBrowserWindow(id string) {
246 return261 return
247 }262 }
248 removed := a.browserWindows[id]263 removed := a.browserWindows[id]
264+
265+ // Remove from registration order
266+ for i, wid := range a.browserWindowOrder {
267+ if wid == id {
268+ a.browserWindowOrder = append(a.browserWindowOrder[:i], a.browserWindowOrder[i+1:]...)
269+ break
270+ }
271+ }
249 wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow272 wasMainWindow := removed != nil && removed.mainWindow == a.mainWindow
250 ownedTabIDs := make([]entity.TabID, 0)273 ownedTabIDs := make([]entity.TabID, 0)
251 for tabID, bw := range a.windowForTab {274 for tabID, bw := range a.windowForTab {
@@ -284,7 +307,7 @@ func (a *App) removeBrowserWindow(id string) {
284 a.keyboardHandler = nil307 a.keyboardHandler = nil
285 a.globalShortcutHandler = nil308 a.globalShortcutHandler = nil
286 if a.tabCoord != nil {309 if a.tabCoord != nil {
287- a.tabCoord.SetMainWindow(nil)310+ a.tabCoord.SetCurrentTarget(coordinator.TabTarget{})
288 }311 }
289 }312 }
290 }313 }
@@ -293,6 +316,13 @@ func (a *App) deterministicBrowserWindowFallback() *browserWindow {
293 if a.browserWindows == nil {316 if a.browserWindows == nil {
294 return nil317 return nil
295 }318 }
319+ for _, id := range a.browserWindowOrder {
320+ bw := a.browserWindows[id]
321+ if id == "" || bw == nil || bw.mainWindow == nil {
322+ continue
323+ }
324+ return bw
325+ }
296 ids := make([]string, 0, len(a.browserWindows))326 ids := make([]string, 0, len(a.browserWindows))
297 for id, bw := range a.browserWindows {327 for id, bw := range a.browserWindows {
298 if id == "" || bw == nil || bw.mainWindow == nil {328 if id == "" || bw == nil || bw.mainWindow == nil {
M internal/ui/browser_window_test.go +543 -2
@@ -14,6 +14,8 @@ import (
14 "github.com/bnema/dumber/internal/ui/layout"14 "github.com/bnema/dumber/internal/ui/layout"
15 "github.com/bnema/dumber/internal/ui/window"15 "github.com/bnema/dumber/internal/ui/window"
16 "github.com/bnema/puregotk/v4/gtk"16 "github.com/bnema/puregotk/v4/gtk"
17+ "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/require"
17 )19 )
18 20
19 func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {21 func TestBrowserWindow_RemoveBrowserWindowClearsShellState(t *testing.T) {
@@ -66,8 +68,12 @@ func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
66 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))68 otherTab := entity.NewTab(entity.TabID("tab-other"), entity.WorkspaceID("workspace-other"), entity.NewPane(entity.PaneID("pane-other")))
67 removedMainWindow := &window.MainWindow{}69 removedMainWindow := &window.MainWindow{}
68 remainingMainWindow := &window.MainWindow{}70 remainingMainWindow := &window.MainWindow{}
69- removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow}71+ removedTabs := entity.NewTabList()
70- remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow}72+ removedTabs.Add(ownedTab)
73+ remainingTabs := entity.NewTabList()
74+ remainingTabs.Add(otherTab)
75+ removed := &browserWindow{id: "window-1", mainWindow: removedMainWindow, tabs: removedTabs}
76+ remaining := &browserWindow{id: "window-2", mainWindow: remainingMainWindow, tabs: remainingTabs}
71 ownedSessionKey := floatingSessionKey{tabID: ownedTab.ID, sessionID: "profile:owned"}77 ownedSessionKey := floatingSessionKey{tabID: ownedTab.ID, sessionID: "profile:owned"}
72 otherSessionKey := floatingSessionKey{tabID: otherTab.ID, sessionID: "profile:other"}78 otherSessionKey := floatingSessionKey{tabID: otherTab.ID, sessionID: "profile:other"}
73 app := &App{79 app := &App{
@@ -110,6 +116,7 @@ func TestBrowserWindow_RemoveBrowserWindowCleansOwnedTabState(t *testing.T) {
110 if _, ok := app.floatingSessions[otherSessionKey]; !ok {116 if _, ok := app.floatingSessions[otherSessionKey]; !ok {
111 t.Fatalf("other floating session should remain")117 t.Fatalf("other floating session should remain")
112 }118 }
119+ assertWindowOwnershipInvariant(t, app)
113 }120 }
114 121
115 func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {122 func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
@@ -129,6 +136,9 @@ func TestBrowserWindow_RegisterAndRemoveTrackCollection(t *testing.T) {
129 if _, ok := app.browserWindows[second.id]; !ok {136 if _, ok := app.browserWindows[second.id]; !ok {
130 t.Fatalf("second browser window was not registered")137 t.Fatalf("second browser window was not registered")
131 }138 }
139+ if first.tabs == nil || second.tabs == nil {
140+ t.Fatalf("registered browser windows must have non-nil tab lists")
141+ }
132 142
133 app.removeBrowserWindow(first.id)143 app.removeBrowserWindow(first.id)
134 144
@@ -309,3 +319,534 @@ type testPermissionDialogPresenter struct{}
309 319
310 func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {320 func (*testPermissionDialogPresenter) ShowPermissionDialog(context.Context, string, []entity.PermissionType, entity.PermissionMetadata, func(port.PermissionDialogResult)) {
311 }321 }
322+
323+// --- Window tab list tests ---
324+
325+func TestGetWindowTabLists_RespectsRegistrationOrder(t *testing.T) {
326+ // Register windows in non-lexicographic order: window-2 first, then window-1.
327+ // The result should preserve creation order, not sorted order.
328+ app := &App{
329+ browserWindows: make(map[string]*browserWindow),
330+ }
331+
332+ first := &browserWindow{id: "window-2"}
333+ second := &browserWindow{id: "window-1"}
334+ app.registerBrowserWindow(first)
335+ app.registerBrowserWindow(second)
336+
337+ // Give each window a tab so they show up in results.
338+ pane1 := entity.NewPane(entity.PaneID("p-w2"))
339+ tab1 := entity.NewTab(entity.TabID("t-w2"), entity.WorkspaceID("ws-w2"), pane1)
340+ pane2 := entity.NewPane(entity.PaneID("p-w1"))
341+ tab2 := entity.NewTab(entity.TabID("t-w1"), entity.WorkspaceID("ws-w1"), pane2)
342+
343+ app.tabs = entity.NewTabList()
344+ app.tabs.Add(tab1)
345+ app.tabs.Add(tab2)
346+ first.tabs.Add(tab1)
347+ second.tabs.Add(tab2)
348+ app.windowForTab = map[entity.TabID]*browserWindow{
349+ tab1.ID: first,
350+ tab2.ID: second,
351+ }
352+
353+ result := app.GetWindowTabLists()
354+
355+ require.Len(t, result, 2)
356+ assert.Equal(t, entity.WindowID("window-2"), result[0].WindowID, "first registered window should be first")
357+ assert.Equal(t, entity.WindowID("window-1"), result[1].WindowID, "second registered window should be second")
358+}
359+
360+func TestGetWindowTabLists_NoBrowserWindowsReturnsGlobalSnapshot(t *testing.T) {
361+ pane := entity.NewPane(entity.PaneID("p1"))
362+ tab := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane)
363+ tabs := entity.NewTabList()
364+ tabs.Add(tab)
365+
366+ app := &App{tabs: tabs}
367+
368+ result := app.GetWindowTabLists()
369+
370+ require.Len(t, result, 1)
371+ assert.Equal(t, entity.WindowID(""), result[0].WindowID)
372+ // Snapshot returns a copy so pointer identity is not expected; verify
373+ // structural equality instead.
374+ assert.Equal(t, tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
375+ assert.Len(t, result[0].Tabs.Tabs, len(tabs.Tabs))
376+}
377+
378+func TestGetWindowTabLists_AppliesActiveTabIndex(t *testing.T) {
379+ // A window with its own TabList where the second tab is active.
380+ app := &App{
381+ browserWindows: make(map[string]*browserWindow),
382+ }
383+
384+ pane1 := entity.NewPane(entity.PaneID("p1"))
385+ tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
386+ pane2 := entity.NewPane(entity.PaneID("p2"))
387+ tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
388+
389+ app.tabs = entity.NewTabList()
390+ app.tabs.Add(tab1)
391+ app.tabs.Add(tab2)
392+
393+ bwTabs := entity.NewTabList()
394+ bwTabs.Add(tab1)
395+ bwTabs.Add(tab2)
396+ bwTabs.SetActive(entity.TabID("t2"))
397+ bw := &browserWindow{id: "w1", tabs: bwTabs}
398+ app.registerBrowserWindow(bw)
399+ app.windowForTab = map[entity.TabID]*browserWindow{
400+ tab1.ID: bw,
401+ tab2.ID: bw,
402+ }
403+
404+ result := app.GetWindowTabLists()
405+
406+ require.Len(t, result, 1)
407+ assert.Equal(t, entity.TabID("t2"), result[0].Tabs.ActiveTabID, "should reflect bw.tabs.ActiveTabID")
408+}
409+
410+func TestGetWindowTabLists_PreservesPreviousActiveTabID(t *testing.T) {
411+ app := &App{
412+ browserWindows: make(map[string]*browserWindow),
413+ }
414+
415+ pane1 := entity.NewPane(entity.PaneID("p1"))
416+ tab1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), pane1)
417+ pane2 := entity.NewPane(entity.PaneID("p2"))
418+ tab2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), pane2)
419+
420+ app.tabs = entity.NewTabList()
421+ app.tabs.Add(tab1)
422+ app.tabs.Add(tab2)
423+
424+ bwTabs := entity.NewTabList()
425+ bwTabs.Add(tab1)
426+ bwTabs.Add(tab2)
427+ bwTabs.SetActive(entity.TabID("t2")) // t2 becomes active, t1 becomes previous
428+
429+ bw := &browserWindow{id: "w1", tabs: bwTabs}
430+ app.registerBrowserWindow(bw)
431+ app.windowForTab = map[entity.TabID]*browserWindow{
432+ tab1.ID: bw,
433+ tab2.ID: bw,
434+ }
435+
436+ result := app.GetWindowTabLists()
437+
438+ require.Len(t, result, 1)
439+ assert.Equal(t, entity.TabID("t1"), result[0].Tabs.PreviousActiveTabID, "should preserve bw.tabs.PreviousActiveTabID")
440+}
441+
442+func TestGetActiveWindowIndex_MatchesRegistrationOrder(t *testing.T) {
443+ // Register windows in non-lexicographic order; lastFocusedWindowID should
444+ // map to the correct index in registration order.
445+ app := &App{
446+ browserWindows: make(map[string]*browserWindow),
447+ }
448+
449+ app.registerBrowserWindow(&browserWindow{id: "z-window"})
450+ app.registerBrowserWindow(&browserWindow{id: "a-window"})
451+ app.lastFocusedWindowID = "a-window"
452+
453+ idx := app.GetActiveWindowIndex()
454+ assert.Equal(t, 1, idx, "a-window is the second registered window, index should be 1")
455+}
456+
457+func TestGetActiveWindowIndex_MatchesGetWindowTabLists(t *testing.T) {
458+ // The order of windows in GetWindowTabLists must match the indices returned
459+ // by GetActiveWindowIndex.
460+ app := &App{
461+ browserWindows: make(map[string]*browserWindow),
462+ }
463+
464+ w1 := &browserWindow{id: "w1"}
465+ w2 := &browserWindow{id: "w2"}
466+ w3 := &browserWindow{id: "w3"}
467+ app.registerBrowserWindow(w1)
468+ app.registerBrowserWindow(w2)
469+ app.registerBrowserWindow(w3)
470+
471+ // Give each window a tab.
472+ p1 := entity.NewPane(entity.PaneID("p1"))
473+ t1 := entity.NewTab(entity.TabID("t1"), entity.WorkspaceID("ws1"), p1)
474+ p2 := entity.NewPane(entity.PaneID("p2"))
475+ t2 := entity.NewTab(entity.TabID("t2"), entity.WorkspaceID("ws2"), p2)
476+ p3 := entity.NewPane(entity.PaneID("p3"))
477+ t3 := entity.NewTab(entity.TabID("t3"), entity.WorkspaceID("ws3"), p3)
478+ app.tabs = entity.NewTabList()
479+ app.tabs.Add(t1)
480+ app.tabs.Add(t2)
481+ app.tabs.Add(t3)
482+ w1.tabs.Add(t1)
483+ w2.tabs.Add(t2)
484+ w3.tabs.Add(t3)
485+ app.windowForTab = map[entity.TabID]*browserWindow{
486+ t1.ID: w1,
487+ t2.ID: w2,
488+ t3.ID: w3,
489+ }
490+
491+ app.lastFocusedWindowID = "w2"
492+
493+ lists := app.GetWindowTabLists()
494+ idx := app.GetActiveWindowIndex()
495+
496+ require.Len(t, lists, 3)
497+ assert.Equal(t, entity.WindowID("w2"), lists[idx].WindowID, "active window ID must match the lists index")
498+}
499+
500+func TestWindowOrder_FallbackWhenRegistrationOrderEmpty(t *testing.T) {
501+ // When browserWindowOrder is empty (e.g., old tests / direct map manipulation),
502+ // windowOrder must fall back to a deterministic (sorted) order.
503+ app := &App{
504+ browserWindows: map[string]*browserWindow{
505+ "z-window": {id: "z-window"},
506+ "a-window": {id: "a-window"},
507+ },
508+ }
509+
510+ order := app.windowOrder()
511+
512+ require.Len(t, order, 2)
513+ // Sorted order: "a-window" < "z-window"
514+ assert.Equal(t, "a-window", order[0])
515+ assert.Equal(t, "z-window", order[1])
516+}
517+
518+func TestRemoveBrowserWindow_RemovesFromOrder(t *testing.T) {
519+ app := &App{
520+ browserWindows: make(map[string]*browserWindow),
521+ }
522+
523+ w1 := &browserWindow{id: "w1"}
524+ w2 := &browserWindow{id: "w2"}
525+ w3 := &browserWindow{id: "w3"}
526+ app.registerBrowserWindow(w1)
527+ app.registerBrowserWindow(w2)
528+ app.registerBrowserWindow(w3)
529+
530+ app.removeBrowserWindow("w2")
531+
532+ require.Len(t, app.browserWindowOrder, 2)
533+ assert.Equal(t, "w1", app.browserWindowOrder[0])
534+ assert.Equal(t, "w3", app.browserWindowOrder[1])
535+}
536+
537+func TestRegisterBrowserWindow_DuplicateIgnores(t *testing.T) {
538+ app := &App{
539+ browserWindows: make(map[string]*browserWindow),
540+ }
541+
542+ bw := &browserWindow{id: "w1"}
543+ app.registerBrowserWindow(bw)
544+ app.registerBrowserWindow(bw) // duplicate
545+
546+ require.Len(t, app.browserWindowOrder, 1)
547+}
548+
549+func TestApp_GetWindowTabListsUsesCreationOrderAndActiveIndex(t *testing.T) {
550+ // Create two windows with per-window bw.tabs as the real source of truth.
551+ // Ensure GetWindowTabLists returns them in registration order, with correct
552+ // WindowID, and that the returned Tabs pointers are the per-window bw.tabs
553+ // (not copies or derived from global App.tabs).
554+ firstTabs := entity.NewTabList()
555+ firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
556+ firstTabs.Add(firstTab)
557+ firstTabs.SetActive(entity.TabID("tab-1"))
558+
559+ secondTabs := entity.NewTabList()
560+ secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
561+ secondTabs.Add(secondTab)
562+
563+ first := &browserWindow{id: "window-1", tabs: firstTabs}
564+ second := &browserWindow{id: "window-2", tabs: secondTabs}
565+
566+ app := &App{
567+ browserWindows: map[string]*browserWindow{first.id: first, second.id: second},
568+ browserWindowOrder: []string{"window-1", "window-2"},
569+ lastFocusedWindowID: "window-2",
570+ }
571+
572+ result := app.GetWindowTabLists()
573+
574+ require.Len(t, result, 2)
575+ assert.Equal(t, entity.WindowID("window-1"), result[0].WindowID)
576+ assert.Equal(t, entity.WindowID("window-2"), result[1].WindowID)
577+
578+ // The returned Tabs are snapshot copies of per-window bw.tabs.
579+ // Verify structural equality (not pointer identity, since
580+ // GetWindowTabLists returns a snapshot for safe concurrent read).
581+ assert.Equal(t, firstTabs.ActiveTabID, result[0].Tabs.ActiveTabID)
582+ assert.Len(t, result[0].Tabs.Tabs, len(firstTabs.Tabs))
583+ assert.Equal(t, secondTabs.ActiveTabID, result[1].Tabs.ActiveTabID)
584+ assert.Len(t, result[1].Tabs.Tabs, len(secondTabs.Tabs))
585+
586+ // Active state reflects per-window bw.tabs.
587+ assert.Equal(t, entity.TabID("tab-1"), result[0].Tabs.ActiveTabID)
588+ assert.Equal(t, entity.TabID("tab-2"), result[1].Tabs.ActiveTabID)
589+
590+ // GetActiveWindowIndex respects lastFocusedWindowID mapped to registration order.
591+ idx := app.GetActiveWindowIndex()
592+ assert.Equal(t, 1, idx, "window-2 is second registered, index should be 1")
593+}
594+
595+func TestApp_AssignRestoredWindowTabListsUsesFreshRuntimeWindowIDs(t *testing.T) {
596+ // Create two restored TabLists with distinct tabs.
597+ firstTabs := entity.NewTabList()
598+ firstTab := entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1")))
599+ firstTabs.Add(firstTab)
600+ firstTabs.SetActive(entity.TabID("tab-1"))
601+
602+ secondTabs := entity.NewTabList()
603+ secondTab := entity.NewTab(entity.TabID("tab-2"), entity.WorkspaceID("ws-2"), entity.NewPane(entity.PaneID("pane-2")))
604+ secondTabs.Add(secondTab)
605+
606+ // Create two runtime browser windows with fresh IDs distinct from saved IDs.
607+ firstBW := &browserWindow{id: "runtime-w1"}
608+ secondBW := &browserWindow{id: "runtime-w2"}
609+
610+ app := &App{
611+ browserWindows: map[string]*browserWindow{"runtime-w1": firstBW, "runtime-w2": secondBW},
612+ }
613+
614+ // Assign restored state using saved window IDs (simulating session restore).
615+ app.assignRestoredWindowTabList(firstBW, entity.WindowTabListState{
616+ WindowID: "saved-w1",
617+ Tabs: firstTabs,
618+ })
619+ app.assignRestoredWindowTabList(secondBW, entity.WindowTabListState{
620+ WindowID: "saved-w2",
621+ Tabs: secondTabs,
622+ })
623+
624+ // Tabs must be owned by runtime windows, not saved.
625+ require.Same(t, firstTabs, firstBW.tabs, "first window tabs must be assigned")
626+ require.Same(t, secondTabs, secondBW.tabs, "second window tabs must be assigned")
627+
628+ // windowForTab must map tabs to runtime windows.
629+ require.Equal(t, firstBW, app.browserWindowForTab(firstTab.ID), "tab-1 must be owned by runtime-w1")
630+ require.Equal(t, secondBW, app.browserWindowForTab(secondTab.ID), "tab-2 must be owned by runtime-w2")
631+
632+ // Saved window IDs must never leak into browserWindows.
633+ require.Nil(t, app.browserWindows["saved-w1"], "saved-w1 must not appear in browserWindows")
634+ require.Nil(t, app.browserWindows["saved-w2"], "saved-w2 must not appear in browserWindows")
635+ assertWindowOwnershipInvariant(t, app)
636+}
637+
638+func TestApp_BrowserWindowsOwnIndependentTabLists(t *testing.T) {
639+ // Construct first browser window with its own TabList and distinct tabs.
640+ firstTabs := entity.NewTabList()
641+ firstTab := entity.NewTab(entity.TabID("tab-first"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first")))
642+ firstTabs.Add(firstTab)
643+
644+ first := &browserWindow{
645+ id: "window-first",
646+ tabs: firstTabs,
647+ }
648+
649+ // Construct second browser window with its own TabList and distinct tabs.
650+ secondTabs := entity.NewTabList()
651+ secondTab := entity.NewTab(entity.TabID("tab-second"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second")))
652+ secondTabs.Add(secondTab)
653+
654+ second := &browserWindow{
655+ id: "window-second",
656+ tabs: secondTabs,
657+ }
658+
659+ app := &App{
660+ browserWindows: map[string]*browserWindow{
661+ first.id: first,
662+ second.id: second,
663+ },
664+ }
665+
666+ // tabListForBrowserWindow returns the correct TabList for each window.
667+ got := app.tabListForBrowserWindow(first)
668+ if got != firstTabs {
669+ t.Fatalf("tabListForBrowserWindow(first) = %p, want %p", got, firstTabs)
670+ }
671+ got = app.tabListForBrowserWindow(second)
672+ if got != secondTabs {
673+ t.Fatalf("tabListForBrowserWindow(second) = %p, want %p", got, secondTabs)
674+ }
675+
676+ // Each window's tabs do not contain the other window's tabs.
677+ if firstTabs.Find(secondTab.ID) != nil {
678+ t.Fatalf("first window tabs unexpectedly contain second tab")
679+ }
680+ if secondTabs.Find(firstTab.ID) != nil {
681+ t.Fatalf("second window tabs unexpectedly contain first tab")
682+ }
683+
684+ // activeTabForBrowserWindow returns the correct active tab for each window.
685+ gotTab := app.activeTabForBrowserWindow(first)
686+ if gotTab != firstTab {
687+ t.Fatalf("activeTabForBrowserWindow(first) = %p, want %p", gotTab, firstTab)
688+ }
689+ gotTab = app.activeTabForBrowserWindow(second)
690+ if gotTab != secondTab {
691+ t.Fatalf("activeTabForBrowserWindow(second) = %p, want %p", gotTab, secondTab)
692+ }
693+
694+ app.windowForTab = map[entity.TabID]*browserWindow{firstTab.ID: first, secondTab.ID: second}
695+ assertWindowOwnershipInvariant(t, app)
696+}
697+
698+func TestRestoreSession_RemovesStaleBrowserWindows(t *testing.T) {
699+ // After restore, only runtime windows should remain in browserWindows
700+ // and browserWindowOrder. Stale pre-existing windows must be removed.
701+
702+ // Runtime windows with tabs (what restoreSession produces).
703+ runtime1Tabs := entity.NewTabList()
704+ r1Tab := entity.NewTab(entity.TabID("r1-t1"), entity.WorkspaceID("ws-r1"), entity.NewPane(entity.PaneID("p-r1")))
705+ runtime1Tabs.Add(r1Tab)
706+ runtime1Tabs.SetActive(entity.TabID("r1-t1"))
707+
708+ runtime2Tabs := entity.NewTabList()
709+ r2Tab := entity.NewTab(entity.TabID("r2-t1"), entity.WorkspaceID("ws-r2"), entity.NewPane(entity.PaneID("p-r2")))
710+ runtime2Tabs.Add(r2Tab)
711+ runtime2Tabs.SetActive(entity.TabID("r2-t1"))
712+
713+ runtime1 := &browserWindow{id: "runtime-w1", tabs: runtime1Tabs}
714+ runtime2 := &browserWindow{id: "runtime-w2", tabs: runtime2Tabs}
715+
716+ // Stale pre-existing browser window (no longer in runtime set).
717+ staleTabs := entity.NewTabList()
718+ staleTab := entity.NewTab(entity.TabID("stale-t1"), entity.WorkspaceID("ws-stale"), entity.NewPane(entity.PaneID("p-stale")))
719+ staleTabs.Add(staleTab)
720+ stale := &browserWindow{id: "stale-window", tabs: staleTabs}
721+
722+ app := &App{
723+ browserWindows: map[string]*browserWindow{
724+ "stale-window": stale,
725+ "runtime-w1": runtime1,
726+ "runtime-w2": runtime2,
727+ },
728+ tabs: entity.NewTabList(),
729+ windowForTab: map[entity.TabID]*browserWindow{
730+ staleTab.ID: stale,
731+ r1Tab.ID: runtime1,
732+ r2Tab.ID: runtime2,
733+ },
734+ }
735+
736+ // Simulate post-restore cleanup: remove stale windows not in runtime set.
737+ runtimeIDs := []string{"runtime-w1", "runtime-w2"}
738+ app.pruneStaleBrowserWindows([]*browserWindow{runtime1, runtime2})
739+
740+ // After cleanup, browserWindows contains only runtime windows.
741+ require.Len(t, app.browserWindows, 2)
742+ require.NotNil(t, app.browserWindows["runtime-w1"])
743+ require.NotNil(t, app.browserWindows["runtime-w2"])
744+ require.Nil(t, app.browserWindows["stale-window"])
745+
746+ // browserWindowOrder matches runtime order.
747+ require.Equal(t, runtimeIDs, app.browserWindowOrder)
748+
749+ // GetWindowTabLists returns only runtime windows.
750+ result := app.GetWindowTabLists()
751+ require.Len(t, result, 2)
752+ assert.Equal(t, entity.WindowID("runtime-w1"), result[0].WindowID)
753+ assert.Equal(t, entity.WindowID("runtime-w2"), result[1].WindowID)
754+
755+ // No stale tabs leak into the result.
756+ // Verify structural equality (snapshot copies, not live pointers).
757+ assert.Equal(t, runtime1Tabs.ActiveTabID, result[0].Tabs.ActiveTabID)
758+ assert.Len(t, result[0].Tabs.Tabs, len(runtime1Tabs.Tabs))
759+ assert.Equal(t, runtime2Tabs.ActiveTabID, result[1].Tabs.ActiveTabID)
760+ assert.Len(t, result[1].Tabs.Tabs, len(runtime2Tabs.Tabs))
761+ assertWindowOwnershipInvariant(t, app)
762+}
763+
764+func TestRestoreSession_BrowserWindowOrderStartsWithFirstReusedWindow(t *testing.T) {
765+ // When the first restored window reuses a browser window that was not
766+ // already in browserWindowOrder, the restored order must start with
767+ // the reused window ID followed by newly-created window IDs.
768+
769+ reuseTabs := entity.NewTabList()
770+ reuseTab := entity.NewTab(entity.TabID("reuse-t1"), entity.WorkspaceID("ws-reuse"), entity.NewPane(entity.PaneID("p-reuse")))
771+ reuseTabs.Add(reuseTab)
772+ reuseBW := &browserWindow{id: "reused-window", tabs: reuseTabs}
773+
774+ createdTabs := entity.NewTabList()
775+ createdTab := entity.NewTab(entity.TabID("created-t1"), entity.WorkspaceID("ws-created"), entity.NewPane(entity.PaneID("p-created")))
776+ createdTabs.Add(createdTab)
777+ createdBW := &browserWindow{id: "created-window", tabs: createdTabs}
778+
779+ // Pre-existing browserWindowOrder does NOT contain the reused window.
780+ app := &App{
781+ browserWindows: map[string]*browserWindow{
782+ "reused-window": reuseBW,
783+ "created-window": createdBW,
784+ },
785+ browserWindowOrder: []string{"old-stale-window"}, // reused window not present
786+ tabs: entity.NewTabList(),
787+ windowForTab: map[entity.TabID]*browserWindow{},
788+ }
789+
790+ // Exercise the helper used by restoreSession to prune stale windows and
791+ // replace browserWindowOrder with the runtime restore order.
792+ runtimeWindows := []*browserWindow{reuseBW, createdBW}
793+ app.pruneStaleBrowserWindows(runtimeWindows)
794+
795+ require.Len(t, app.browserWindowOrder, 2)
796+ assert.Equal(t, "reused-window", app.browserWindowOrder[0],
797+ "first reused window must be first in order")
798+ assert.Equal(t, "created-window", app.browserWindowOrder[1],
799+ "newly-created window must follow the reused window")
800+
801+ // GetWindowTabLists respects the order.
802+ result := app.GetWindowTabLists()
803+ require.Len(t, result, 2)
804+ assert.Equal(t, entity.WindowID("reused-window"), result[0].WindowID)
805+ assert.Equal(t, entity.WindowID("created-window"), result[1].WindowID)
806+}
807+
808+func TestRestoreSession_ActiveWindowIndexSyncsState(t *testing.T) {
809+ // After restore, GetActiveWindowIndex must return the restored active index.
810+ // The focused window state must be consistent: lastFocusedWindowID matches
811+ // the window at that index, and the active window's tab target aligns.
812+
813+ runtime1Tabs := entity.NewTabList()
814+ r1Tab := entity.NewTab(entity.TabID("a1-t1"), entity.WorkspaceID("ws-a1"), entity.NewPane(entity.PaneID("p-a1")))
815+ runtime1Tabs.Add(r1Tab)
816+ runtime1Tabs.SetActive(entity.TabID("a1-t1"))
817+
818+ runtime2Tabs := entity.NewTabList()
819+ r2Tab := entity.NewTab(entity.TabID("a2-t1"), entity.WorkspaceID("ws-a2"), entity.NewPane(entity.PaneID("p-a2")))
820+ runtime2Tabs.Add(r2Tab)
821+ runtime2Tabs.SetActive(entity.TabID("a2-t1"))
822+
823+ runtime1 := &browserWindow{id: "active-w1", tabs: runtime1Tabs}
824+ runtime2 := &browserWindow{id: "active-w2", tabs: runtime2Tabs}
825+
826+ app := &App{
827+ browserWindows: map[string]*browserWindow{
828+ "active-w1": runtime1,
829+ "active-w2": runtime2,
830+ },
831+ browserWindowOrder: []string{"active-w1", "active-w2"},
832+ lastFocusedWindowID: "active-w2",
833+ tabs: entity.NewTabList(),
834+ windowForTab: map[entity.TabID]*browserWindow{},
835+ }
836+
837+ // Active index should be 1 (second window in order).
838+ idx := app.GetActiveWindowIndex()
839+ assert.Equal(t, 1, idx)
840+
841+ // The focused window is the one at that index.
842+ focused := app.lastFocusedBrowserWindow()
843+ require.NotNil(t, focused)
844+ assert.Equal(t, "active-w2", focused.id)
845+
846+ // Verify that GetActiveWindowIndex and GetWindowTabLists are consistent:
847+ // the window at the active index matches the focused window.
848+ result := app.GetWindowTabLists()
849+ require.Len(t, result, 2)
850+ assert.Equal(t, entity.WindowID("active-w2"), result[idx].WindowID,
851+ "window at active index must match focused window ID")
852+}
M internal/ui/component/tab_bar.go +38 -4
@@ -12,6 +12,9 @@ type TabBar struct {
12 box *gtk.Box12 box *gtk.Box
13 buttons map[entity.TabID]*TabButton13 buttons map[entity.TabID]*TabButton
14 14
15+ explicitlyVisible bool
16+ autoHidden bool
17+
15 // Active tab tracking18 // Active tab tracking
16 activeTabID entity.TabID19 activeTabID entity.TabID
17 20
@@ -26,7 +29,8 @@ type TabBar struct {
26 // NewTabBar creates a new tab bar widget.29 // NewTabBar creates a new tab bar widget.
27 func NewTabBar() *TabBar {30 func NewTabBar() *TabBar {
28 tb := &TabBar{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 // Create horizontal box container36 // Create horizontal box container
@@ -157,12 +161,42 @@ func (tb *TabBar) ActiveTabID() entity.TabID {
157 return tb.activeTabID161 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 func (tb *TabBar) SetVisible(visible bool) {165 func (tb *TabBar) SetVisible(visible bool) {
162- if tb.box == nil {166+ if tb == nil || tb.box == nil {
163 return167 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 // Destroy cleans up all tab bar resources.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 c.activePaneOverrideMu.Unlock()275 c.activePaneOverrideMu.Unlock()
276 }276 }
277 277
278+// ActivePaneOverrideID returns the currently forced active pane override, if any.
279+func (c *Coordinator) ActivePaneOverrideID() (entity.PaneID, bool) {
280+ return c.activePaneOverrideID()
281+}
282+
278 func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {283 func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
279 c.activePaneOverrideMu.RLock()284 c.activePaneOverrideMu.RLock()
280 defer c.activePaneOverrideMu.RUnlock()285 defer c.activePaneOverrideMu.RUnlock()
M internal/ui/coordinator/navigation.go +78 -85
@@ -16,7 +16,6 @@ import (
16 type OmniboxProvider interface {16 type OmniboxProvider interface {
17 ToggleOmnibox(ctx context.Context)17 ToggleOmnibox(ctx context.Context)
18 UpdateOmniboxZoom(factor float64)18 UpdateOmniboxZoom(factor float64)
19- SetOmniboxOnNavigate(fn func(url string))
20 }19 }
21 20
22 // NavigationCoordinator handles URL navigation, history, and browser controls.21 // NavigationCoordinator handles URL navigation, history, and browser controls.
@@ -51,137 +50,138 @@ func NewNavigationCoordinator(
51 // SetOmniboxProvider sets the omnibox provider for toggle/zoom operations.50 // SetOmniboxProvider sets the omnibox provider for toggle/zoom operations.
52 func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) {51 func (c *NavigationCoordinator) SetOmniboxProvider(provider OmniboxProvider) {
53 c.omniboxProvider = provider52 c.omniboxProvider = provider
54- if c.omniboxProvider != nil {
55- c.omniboxProvider.SetOmniboxOnNavigate(func(url string) {
56- navCtx := context.Background()
57- if c.contextProvider != nil {
58- navCtx = c.contextProvider()
59- }
60- if err := c.Navigate(navCtx, url); err != nil {
61- logging.FromContext(navCtx).Warn().Err(err).Str("url", url).Msg("omnibox-initiated navigation failed")
62- }
63- })
64- }
65 }53 }
66 54
67-// Navigate loads a URL in the active pane using NavigateUseCase.55+// requireWebView returns an error if wv is nil, preserving stable error text.
68-// This properly handles history recording and zoom application.56+func requireWebView(wv port.WebView) error {
69-func (c *NavigationCoordinator) Navigate(ctx context.Context, url string) error {57+ if wv == nil {
70- log := logging.FromContext(ctx)58+ return fmt.Errorf("no webview provided")
71- if c.contentCoord == nil {
72- log.Warn().Str("url", url).Msg("content coordinator not initialized")
73- return fmt.Errorf("content coordinator not initialized")
74 }59 }
60+ return nil
61+}
75 62
76- wv := c.contentCoord.ActiveWebView(ctx)63+func (c *NavigationCoordinator) trackNavigationOrigin(ctx context.Context, paneID entity.PaneID, url string) {
77- if wv == nil {64+ if c.contentCoord == nil || paneID == "" {
78- log.Warn().Str("url", url).Msg("no active webview for navigation")65+ return
79- return fmt.Errorf("no active webview")
80 }66 }
67+ c.contentCoord.SetNavigationOrigin(paneID, url)
68+ go func() {
69+ preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)
70+ defer cancelPreload()
71+ c.contentCoord.PreloadCachedFavicon(preloadCtx, paneID, url)
72+ }()
73+}
81 74
82- // Get active pane ID for tracking75+// NavigateWebView loads a URL in the specified pane using the provided WebView.
83- activePaneID := c.contentCoord.ActivePaneID(ctx)76+// This is the explicit-target equivalent of Navigate: it uses NavigateUseCase if
84- paneID := string(activePaneID)77+// available, falling back to direct LoadURI. Origin tracking via contentCoord is
85- if activePaneID != "" {78+// performed when contentCoord and paneID are available.
86- // Track original URL for cross-domain redirect favicon caching79+func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string, paneID entity.PaneID, wv port.WebView) error {
87- c.contentCoord.SetNavigationOrigin(activePaneID, url)80+ log := logging.FromContext(ctx)
88- // Pre-load cached favicon asynchronously (don't block navigation start)81+
89- go func() {82+ if err := requireWebView(wv); err != nil {
90- preloadCtx, cancelPreload := context.WithTimeout(ctx, faviconPreloadTimeout)83+ log.Warn().Str("url", url).Msg("NavigateWebView called with nil webview")
91- defer cancelPreload()84+ return err
92- c.contentCoord.PreloadCachedFavicon(preloadCtx, activePaneID, url)
93- }()
94 }85 }
95 86
96- // Use NavigateUseCase which handles history + zoom87+ paneIDStr := string(paneID)
88+
97 if c.navigateUC != nil {89 if c.navigateUC != nil {
98 input := usecase.NavigateInput{90 input := usecase.NavigateInput{
99 URL: url,91 URL: url,
100- PaneID: paneID,92+ PaneID: paneIDStr,
101- WebView: wv, // webkit.WebView implements port.WebView93+ WebView: wv,
102 }94 }
103 output, err := c.navigateUC.Execute(ctx, input)95 output, err := c.navigateUC.Execute(ctx, input)
104 if err != nil {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 return err98 return err
107 }99 }
100+ c.trackNavigationOrigin(ctx, paneID, url)
108 log.Debug().101 log.Debug().
109 Str("url", url).102 Str("url", url).
103+ Str("pane_id", paneIDStr).
110 Float64("zoom", output.AppliedZoom).104 Float64("zoom", output.AppliedZoom).
105+ Uint64("webview_id", uint64(wv.ID())).
111 Msg("navigation initiated via usecase")106 Msg("navigation initiated via usecase")
112 return nil107 return nil
113 }108 }
114 109
115- // Fallback: direct navigation without usecase
116 log.Warn().Msg("navigateUC not available, using direct LoadURI")110 log.Warn().Msg("navigateUC not available, using direct LoadURI")
117 if err := wv.LoadURI(ctx, url); err != nil {111 if err := wv.LoadURI(ctx, url); err != nil {
118 log.Error().Err(err).Str("url", url).Msg("failed to navigate")112 log.Error().Err(err).Str("url", url).Msg("failed to navigate")
119 return err113 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 return nil118 return nil
124 }119 }
125 120
126-// Reload reloads the current page.121+// ReloadWebView reloads the page in the provided WebView, optionally bypassing cache.
127-func (c *NavigationCoordinator) Reload(ctx context.Context) error {122+func (c *NavigationCoordinator) ReloadWebView(ctx context.Context, wv port.WebView, bypassCache bool) error {
128 log := logging.FromContext(ctx)123 log := logging.FromContext(ctx)
129 124
130- wv := c.contentCoord.ActiveWebView(ctx)125+ if err := requireWebView(wv); err != nil {
131- if wv == nil {126+ log.Debug().Msg("ReloadWebView called with nil webview")
132- log.Debug().Msg("no active webview for reload")127+ return err
133- return nil
134 }128 }
135 129
136 if c.navigateUC != nil {130 if c.navigateUC != nil {
137- return c.navigateUC.Reload(ctx, wv, false)131+ return c.navigateUC.Reload(ctx, wv, bypassCache)
138 }132 }
139 133
134+ if bypassCache {
135+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("reloading explicit webview bypassing cache")
136+ return wv.ReloadBypassCache(ctx)
137+ }
138+
139+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("reloading explicit webview")
140 return wv.Reload(ctx)140 return wv.Reload(ctx)
141 }141 }
142 142
143-// HardReload reloads the current page bypassing cache.143+// StopWebView stops loading in the provided WebView.
144-func (c *NavigationCoordinator) HardReload(ctx context.Context) error {144+func (c *NavigationCoordinator) StopWebView(ctx context.Context, wv port.WebView) error {
145 log := logging.FromContext(ctx)145 log := logging.FromContext(ctx)
146 146
147- wv := c.contentCoord.ActiveWebView(ctx)147+ if err := requireWebView(wv); err != nil {
148- if wv == nil {148+ log.Debug().Msg("StopWebView called with nil webview")
149- log.Debug().Msg("no active webview for hard reload")149+ return err
150- return nil
151 }150 }
152 151
153 if c.navigateUC != nil {152 if c.navigateUC != nil {
154- return c.navigateUC.Reload(ctx, wv, true)153+ return c.navigateUC.Stop(ctx, wv)
155 }154 }
156 155
157- return wv.ReloadBypassCache(ctx)156+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("stopping explicit webview")
157+ return wv.Stop(ctx)
158 }158 }
159 159
160-// GoBack navigates back in history.160+// GoBackWebView navigates back in history for the provided WebView.
161 // Calls webview directly - the webview layer handles SPA navigation via JS fallback.161 // Calls webview directly - the webview layer handles SPA navigation via JS fallback.
162-func (c *NavigationCoordinator) GoBack(ctx context.Context) error {162+func (c *NavigationCoordinator) GoBackWebView(ctx context.Context, wv port.WebView) error {
163 log := logging.FromContext(ctx)163 log := logging.FromContext(ctx)
164 164
165- wv := c.contentCoord.ActiveWebView(ctx)165+ if err := requireWebView(wv); err != nil {
166- if wv == nil {166+ log.Debug().Msg("GoBackWebView called with nil webview")
167- log.Debug().Msg("no active webview for go back")167+ return err
168- return nil
169 }168 }
170 169
170+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("going back in explicit webview")
171 return wv.GoBack(ctx)171 return wv.GoBack(ctx)
172 }172 }
173 173
174-// GoForward navigates forward in history.174+// GoForwardWebView navigates forward in history for the provided WebView.
175 // Calls webview directly - the webview layer handles SPA navigation via JS fallback.175 // Calls webview directly - the webview layer handles SPA navigation via JS fallback.
176-func (c *NavigationCoordinator) GoForward(ctx context.Context) error {176+func (c *NavigationCoordinator) GoForwardWebView(ctx context.Context, wv port.WebView) error {
177 log := logging.FromContext(ctx)177 log := logging.FromContext(ctx)
178 178
179- wv := c.contentCoord.ActiveWebView(ctx)179+ if err := requireWebView(wv); err != nil {
180- if wv == nil {180+ log.Debug().Msg("GoForwardWebView called with nil webview")
181- log.Debug().Msg("no active webview for go forward")181+ return err
182- return nil
183 }182 }
184 183
184+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("going forward in explicit webview")
185 return wv.GoForward(ctx)185 return wv.GoForward(ctx)
186 }186 }
187 187
@@ -199,14 +199,13 @@ func (c *NavigationCoordinator) OpenOmnibox(ctx context.Context) error {
199 return nil199 return nil
200 }200 }
201 201
202-// OpenDevTools opens the WebKit inspector for the active WebView.202+// OpenDevToolsWebView opens the WebKit inspector for the provided WebView.
203-func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error {203+func (c *NavigationCoordinator) OpenDevToolsWebView(ctx context.Context, wv port.WebView) error {
204 log := logging.FromContext(ctx)204 log := logging.FromContext(ctx)
205 205
206- wv := c.contentCoord.ActiveWebView(ctx)206+ if err := requireWebView(wv); err != nil {
207- if wv == nil {207+ log.Warn().Msg("OpenDevToolsWebView called with nil webview")
208- log.Warn().Msg("no active webview for devtools")208+ return err
209- return fmt.Errorf("no active webview")
210 }209 }
211 210
212 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening devtools")211 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening devtools")
@@ -219,14 +218,13 @@ func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error {
219 return fmt.Errorf("webview does not support devtools")218 return fmt.Errorf("webview does not support devtools")
220 }219 }
221 220
222-// PrintPage opens the print dialog for the active WebView.221+// PrintWebView opens the print dialog for the provided WebView.
223-func (c *NavigationCoordinator) PrintPage(ctx context.Context) error {222+func (c *NavigationCoordinator) PrintWebView(ctx context.Context, wv port.WebView) error {
224 log := logging.FromContext(ctx)223 log := logging.FromContext(ctx)
225 224
226- wv := c.contentCoord.ActiveWebView(ctx)225+ if err := requireWebView(wv); err != nil {
227- if wv == nil {226+ log.Warn().Msg("PrintWebView called with nil webview")
228- log.Warn().Msg("no active webview for print")227+ return err
229- return fmt.Errorf("no active webview")
230 }228 }
231 229
232 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening print dialog")230 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening print dialog")
@@ -265,11 +263,6 @@ func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) {
265 c.navigateUC.ClearPaneHistory(string(paneID))263 c.navigateUC.ClearPaneHistory(string(paneID))
266 }264 }
267 265
268-// ActiveWebView returns the WebView for the active pane (for zoom operations).
269-func (c *NavigationCoordinator) ActiveWebView(ctx context.Context) port.WebView {
270- return c.contentCoord.ActiveWebView(ctx)
271-}
272-
273 // NotifyZoomChanged updates the omnibox zoom indicator.266 // NotifyZoomChanged updates the omnibox zoom indicator.
274 func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) {267 func (c *NavigationCoordinator) NotifyZoomChanged(ctx context.Context, factor float64) {
275 if c.omniboxProvider != nil {268 if c.omniboxProvider != nil {
M internal/ui/coordinator/navigation_test.go +10 -20
@@ -4,38 +4,28 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7- "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"7 "github.com/stretchr/testify/require"
9 )8 )
10 9
11 type fakeOmniboxProvider struct {10 type fakeOmniboxProvider struct {
12- onNavigate func(string)11+ toggled bool
12+ zoomUpdates []float64
13 }13 }
14 14
15-func (f *fakeOmniboxProvider) ToggleOmnibox(context.Context) {}15+func (f *fakeOmniboxProvider) ToggleOmnibox(context.Context) { f.toggled = true }
16 16
17-func (f *fakeOmniboxProvider) UpdateOmniboxZoom(float64) {}17+func (f *fakeOmniboxProvider) UpdateOmniboxZoom(factor float64) {
18-18+ f.zoomUpdates = append(f.zoomUpdates, factor)
19-func (f *fakeOmniboxProvider) SetOmniboxOnNavigate(fn func(url string)) {
20- f.onNavigate = fn
21-}
22-
23-func TestNavigationCoordinator_NavigateWithoutContentCoordinatorReturnsError(t *testing.T) {
24- c := &NavigationCoordinator{}
25-
26- err := c.Navigate(context.Background(), "https://example.com")
27- require.Error(t, err)
28- assert.Contains(t, err.Error(), "content coordinator")
29 }19 }
30 20
31-func TestNavigationCoordinator_OmniboxCallbackDoesNotPanicOnNavigateError(t *testing.T) {21+func TestNavigationCoordinator_OmniboxProviderOpenAndZoom(t *testing.T) {
32 c := &NavigationCoordinator{}22 c := &NavigationCoordinator{}
33 provider := &fakeOmniboxProvider{}23 provider := &fakeOmniboxProvider{}
34 24
35 c.SetOmniboxProvider(provider)25 c.SetOmniboxProvider(provider)
36- require.NotNil(t, provider.onNavigate)26+ require.NoError(t, c.OpenOmnibox(context.Background()))
27+ c.NotifyZoomChanged(context.Background(), 1.25)
37 28
38- assert.NotPanics(t, func() {29+ require.True(t, provider.toggled)
39- provider.onNavigate("https://example.com")30+ require.Equal(t, []float64{1.25}, provider.zoomUpdates)
40- })
41 }31 }
A internal/ui/coordinator/navigation_webview_targeted_test.go +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 "github.com/bnema/dumber/internal/ui/window"12 "github.com/bnema/dumber/internal/ui/window"
13 )13 )
14 14
15-// TabScopeFunc filters whether a tab belongs to the current window scope.15+// TabTarget identifies which TabList and MainWindow a tab operation applies to.
16-// Returns true if the tab should be considered for window-local operations.16+// Each browser window owns a dedicated TabTarget; operations are scoped to that target.
17-type TabScopeFunc func(tab *entity.Tab, mainWindow *window.MainWindow) bool17+type TabTarget struct {
18+ Tabs *entity.TabList
19+ MainWindow *window.MainWindow
20+}
18 21
19-// TabCoordinator manages tab lifecycle operations.22+// TabCoordinator manages tab lifecycle operations within a target window.
23+// Public methods accept an explicit TabTarget so callers control which window's
24+// tabs are mutated. The currentTarget field provides a fallback for callers that
25+// operate on the "currently active" browser window (e.g., the keyboard dispatcher).
20 type TabCoordinator struct {26 type TabCoordinator struct {
21 tabsUC *usecase.ManageTabsUseCase27 tabsUC *usecase.ManageTabsUseCase
22- tabs *entity.TabList28+ currentTarget TabTarget
23- mainWindow *window.MainWindow29+ mainWindow *window.MainWindow // retained for GetTabBar and SetMainWindow
24 hideTabBarWhenSingleTab bool30 hideTabBarWhenSingleTab bool
25 31
26- // Window-scoped tab filtering (per-window tab ownership)
27- tabScope TabScopeFunc
28-
29 // Callbacks to avoid circular dependencies32 // Callbacks to avoid circular dependencies
30- onTabCreated func(ctx context.Context, tab *entity.Tab)33+ onTabCreated func(ctx context.Context, target TabTarget, tab *entity.Tab)
31- onTabSwitched func(ctx context.Context, tab *entity.Tab)34+ onTabSwitched func(ctx context.Context, target TabTarget, tab *entity.Tab)
32 onQuit func()35 onQuit func()
33- onCurrentWindowEmpty func(ctx context.Context, mainWindow *window.MainWindow)36+ onCurrentWindowEmpty func(ctx context.Context, target TabTarget)
34 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs37 onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs
35 onStateChanged func() // For session snapshots38 onStateChanged func() // For session snapshots
36-
37- // previousActiveTabID provides the per-window previous active tab ID for Alt+Tab style switching.
38- // When set and tabScope is active, SwitchToLastActive prefers this over the global PreviousActiveTabID.
39- previousActiveTabID func(mainWindow *window.MainWindow) entity.TabID
40 }39 }
41 40
42 // TabCoordinatorConfig holds configuration for TabCoordinator.41 // TabCoordinatorConfig holds configuration for TabCoordinator.
43 type TabCoordinatorConfig struct {42 type TabCoordinatorConfig struct {
44 TabsUC *usecase.ManageTabsUseCase43 TabsUC *usecase.ManageTabsUseCase
45- Tabs *entity.TabList44+ Tabs *entity.TabList // Initial tab list for the default target
46 MainWindow *window.MainWindow45 MainWindow *window.MainWindow
47 HideTabBarWhenSingleTab bool46 HideTabBarWhenSingleTab bool
48 }47 }
@@ -53,21 +52,38 @@ func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordi
53 log.Debug().Msg("creating tab coordinator")52 log.Debug().Msg("creating tab coordinator")
54 53
55 return &TabCoordinator{54 return &TabCoordinator{
56- tabsUC: cfg.TabsUC,55+ tabsUC: cfg.TabsUC,
57- tabs: cfg.Tabs,56+ currentTarget: TabTarget{
57+ Tabs: cfg.Tabs,
58+ MainWindow: cfg.MainWindow,
59+ },
58 mainWindow: cfg.MainWindow,60 mainWindow: cfg.MainWindow,
59 hideTabBarWhenSingleTab: cfg.HideTabBarWhenSingleTab,61 hideTabBarWhenSingleTab: cfg.HideTabBarWhenSingleTab,
60 }62 }
61 }63 }
62 64
65+// SetCurrentTarget updates the fallback target used by methods that don't receive
66+// an explicit TabTarget. This should be called whenever the active browser window changes.
67+func (c *TabCoordinator) SetCurrentTarget(target TabTarget) {
68+ c.currentTarget = target
69+ if target.MainWindow != nil {
70+ c.mainWindow = target.MainWindow
71+ }
72+}
73+
74+// CurrentTarget returns the currently active fallback target.
75+func (c *TabCoordinator) CurrentTarget() TabTarget {
76+ return c.currentTarget
77+}
78+
63 // SetOnTabCreated sets the callback for when a tab is created.79 // SetOnTabCreated sets the callback for when a tab is created.
64-func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, tab *entity.Tab)) {80+func (c *TabCoordinator) SetOnTabCreated(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
65 c.onTabCreated = fn81 c.onTabCreated = fn
66 }82 }
67 83
68 // SetOnTabSwitched sets the callback for when a tab switch occurs.84 // SetOnTabSwitched sets the callback for when a tab switch occurs.
69 // This is used to swap workspace views in the content area.85 // This is used to swap workspace views in the content area.
70-func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, tab *entity.Tab)) {86+func (c *TabCoordinator) SetOnTabSwitched(fn func(ctx context.Context, target TabTarget, tab *entity.Tab)) {
71 c.onTabSwitched = fn87 c.onTabSwitched = fn
72 }88 }
73 89
@@ -81,44 +97,44 @@ func (c *TabCoordinator) SetOnStateChanged(fn func()) {
81 c.onStateChanged = fn97 c.onStateChanged = fn
82 }98 }
83 99
84-// SetPreviousActiveTabIDProvider sets the callback for retrieving the per-window
85-// previous active tab ID. When set and tabScope is active, SwitchToLastActive
86-// prefers this provider over the global PreviousActiveTabID.
87-func (c *TabCoordinator) SetPreviousActiveTabIDProvider(fn func(mainWindow *window.MainWindow) entity.TabID) {
88- c.previousActiveTabID = fn
89-}
90-
91-// SetTabScope sets the function used to filter tabs to the current window.
92-// When set, tab operations (switch by index, next/prev, close) only consider
93-// tabs for which the scope function returns true.
94-func (c *TabCoordinator) SetTabScope(fn TabScopeFunc) {
95- c.tabScope = fn
96-}
97-
98 // SetOnCurrentWindowEmpty sets the callback for when the last tab in the current100 // SetOnCurrentWindowEmpty sets the callback for when the last tab in the current
99 // window is closed. This allows the app to close the window gracefully.101 // window is closed. This allows the app to close the window gracefully.
100-func (c *TabCoordinator) SetOnCurrentWindowEmpty(fn func(ctx context.Context, mainWindow *window.MainWindow)) {102+func (c *TabCoordinator) SetOnCurrentWindowEmpty(fn func(ctx context.Context, target TabTarget)) {
101 c.onCurrentWindowEmpty = fn103 c.onCurrentWindowEmpty = fn
102 }104 }
103 105
104-// scopedTabs returns tabs filtered by the tabScope function.106+// SetMainWindow updates only the main window reference for coordinator setup.
105-// When no scope function is set, returns all tabs (backward compatible).107+//
106-func (c *TabCoordinator) scopedTabs() []*entity.Tab {108+// Deprecated: Use SetCurrentTarget for runtime targeting. This method preserves
107- if c.tabs == nil {109+// currentTarget.Tabs and is safe only during app-level setup.
108- return nil110+func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {
111+ oldMainWindow := c.mainWindow
112+ c.mainWindow = mainWindow
113+ if c.currentTarget.MainWindow != mainWindow {
114+ c.currentTarget = TabTarget{
115+ Tabs: c.currentTarget.Tabs,
116+ MainWindow: mainWindow,
117+ }
109 }118 }
110-119+ if c.currentTarget.Tabs != nil && c.currentTarget.Tabs.Count() > 0 && oldMainWindow != mainWindow {
111- if c.tabScope == nil {120+ logging.FromContext(context.Background()).Debug().
112- return c.tabs.Tabs121+ Int("tab_count", c.currentTarget.Tabs.Count()).
122+ Str("old_main_window", fmt.Sprintf("%p", oldMainWindow)).
123+ Str("new_main_window", fmt.Sprintf("%p", mainWindow)).
124+ Msg("SetMainWindow: changing mainWindow while tabs exist")
113 }125 }
126+}
114 127
115- scoped := make([]*entity.Tab, 0, len(c.tabs.Tabs))128+// SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.
116- for _, tab := range c.tabs.Tabs {129+func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView)) {
117- if tab != nil && c.tabScope(tab, c.mainWindow) {130+ c.onAttachPopupToTab = fn
118- scoped = append(scoped, tab)131+}
119- }132+
133+// notifyStateChanged triggers the state changed callback if set.
134+func (c *TabCoordinator) notifyStateChanged() {
135+ if c.onStateChanged != nil {
136+ c.onStateChanged()
120 }137 }
121- return scoped
122 }138 }
123 139
124 // indexOfTab returns the 0-based index of the given tab ID in a slice of tabs,140 // indexOfTab returns the 0-based index of the given tab ID in a slice of tabs,
@@ -132,28 +148,20 @@ func indexOfTab(tabs []*entity.Tab, id entity.TabID) int {
132 return -1148 return -1
133 }149 }
134 150
135-// SetMainWindow updates the window targeted by tab UI operations.151+// Create creates a new tab in the given target with the given initial URL.
136-func (c *TabCoordinator) SetMainWindow(mainWindow *window.MainWindow) {152+func (c *TabCoordinator) Create(ctx context.Context, target TabTarget, initialURL string) (*entity.Tab, error) {
137- c.mainWindow = mainWindow153+ return c.create(ctx, target, initialURL, true)
138-}
139-
140-// notifyStateChanged triggers the state changed callback if set.
141-func (c *TabCoordinator) notifyStateChanged() {
142- if c.onStateChanged != nil {
143- c.onStateChanged()
144- }
145-}
146-
147-// Create creates a new tab with the given initial URL.
148-func (c *TabCoordinator) Create(ctx context.Context, initialURL string) (*entity.Tab, error) {
149- return c.create(ctx, initialURL, true)
150 }154 }
151 155
152-func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate bool) (*entity.Tab, error) {156+func (c *TabCoordinator) create(ctx context.Context, target TabTarget, initialURL string, activate bool) (*entity.Tab, error) {
153 log := logging.FromContext(ctx)157 log := logging.FromContext(ctx)
154 158
159+ if target.Tabs == nil {
160+ return nil, fmt.Errorf("target.Tabs is nil")
161+ }
162+
155 output, err := c.tabsUC.Create(ctx, usecase.CreateTabInput{163 output, err := c.tabsUC.Create(ctx, usecase.CreateTabInput{
156- TabList: c.tabs,164+ TabList: target.Tabs,
157 Name: "",165 Name: "",
158 InitialURL: initialURL,166 InitialURL: initialURL,
159 })167 })
@@ -164,30 +172,30 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
164 172
165 if activate {173 if activate {
166 // Set new tab as active (updates domain state and tracks previous)174 // Set new tab as active (updates domain state and tracks previous)
167- c.tabs.SetActive(output.Tab.ID)175+ target.Tabs.SetActive(output.Tab.ID)
168 }176 }
169 177
170 // Notify app before adding the tab to the visible tab bar.178 // Notify app before adding the tab to the visible tab bar.
171 // The app assigns window ownership and the window-scoped default name here,179 // The app assigns window ownership and the window-scoped default name here,
172 // and TabBar.AddTab reads tab.Title() immediately when creating the button.180 // and TabBar.AddTab reads tab.Title() immediately when creating the button.
173 if c.onTabCreated != nil {181 if c.onTabCreated != nil {
174- c.onTabCreated(ctx, output.Tab)182+ c.onTabCreated(ctx, target, output.Tab)
175 }183 }
176 184
177 // Update tab bar185 // Update tab bar
178- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {186+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
179- c.mainWindow.TabBar().AddTab(output.Tab)187+ target.MainWindow.TabBar().AddTab(output.Tab)
180 if activate {188 if activate {
181- c.mainWindow.TabBar().SetActive(output.Tab.ID)189+ target.MainWindow.TabBar().SetActive(output.Tab.ID)
182 }190 }
183 }191 }
184 192
185 // Update tab bar visibility193 // Update tab bar visibility
186- c.UpdateBarVisibility(ctx)194+ c.UpdateBarVisibility(ctx, target)
187 195
188 // Switch to the new tab's workspace view when activation is requested.196 // Switch to the new tab's workspace view when activation is requested.
189 if activate && c.onTabSwitched != nil {197 if activate && c.onTabSwitched != nil {
190- c.onTabSwitched(ctx, output.Tab)198+ c.onTabSwitched(ctx, target, output.Tab)
191 }199 }
192 200
193 // Notify state change for session snapshots201 // Notify state change for session snapshots
@@ -197,269 +205,280 @@ func (c *TabCoordinator) create(ctx context.Context, initialURL string, activate
197 return output.Tab, nil205 return output.Tab, nil
198 }206 }
199 207
200-// Close closes the active tab.208+// Close closes the active tab in the given target.
201-// When scoped, prefers switching to a sibling tab in the same window.209+// When the target's TabList becomes empty, fires onCurrentWindowEmpty.
202-// If this was the last tab in the window, fires onCurrentWindowEmpty.210+// The caller (App) is responsible for deciding whether all windows are empty
203-// If this was the last tab globally, fires onQuit.211+// and the process should quit. onQuit is NOT fired by Close.
204-func (c *TabCoordinator) Close(ctx context.Context) error {212+func (c *TabCoordinator) Close(ctx context.Context, target TabTarget) error {
205 log := logging.FromContext(ctx)213 log := logging.FromContext(ctx)
206 214
207- activeID := c.tabs.ActiveTabID215+ if target.Tabs == nil {
216+ return fmt.Errorf("target.Tabs is nil")
217+ }
218+
219+ activeID := target.Tabs.ActiveTabID
208 if activeID == "" {220 if activeID == "" {
209 log.Debug().Msg("no active tab to close")221 log.Debug().Msg("no active tab to close")
210 return nil222 return nil
211 }223 }
212 224
213- // Compute the next scoped tab to activate BEFORE closing225+ tabs := target.Tabs.Tabs
214- scoped := c.scopedTabs()226+
215- activeIdx := indexOfTab(scoped, activeID)227+ // Compute the next tab to activate BEFORE closing
216- if activeIdx < 0 && len(scoped) > 0 {228+ activeIdx := indexOfTab(tabs, activeID)
217- log.Debug().Str("active_tab_id", string(activeID)).Int("scoped_count", len(scoped)).Msg("active tab not in scoped list")
218- }
219 229
220- var nextScopedID entity.TabID230+ var nextID entity.TabID
221- if activeIdx >= 0 && len(scoped) > 1 {231+ if activeIdx >= 0 && len(tabs) > 1 {
222- if activeIdx < len(scoped)-1 {232+ if activeIdx < len(tabs)-1 {
223- nextScopedID = scoped[activeIdx+1].ID233+ nextID = tabs[activeIdx+1].ID
224 } else {234 } else {
225- nextScopedID = scoped[activeIdx-1].ID235+ nextID = tabs[activeIdx-1].ID
226 }236 }
227 }237 }
228 238
229- wasLast, err := c.tabsUC.Close(ctx, c.tabs, activeID)239+ wasLast, err := c.tabsUC.Close(ctx, target.Tabs, activeID)
230 if err != nil {240 if err != nil {
231 log.Error().Err(err).Msg("failed to close tab")241 log.Error().Err(err).Msg("failed to close tab")
232 return err242 return err
233 }243 }
234 244
235- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {245+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
236- c.mainWindow.TabBar().RemoveTab(activeID)246+ target.MainWindow.TabBar().RemoveTab(activeID)
237 }247 }
238 248
239 // Update tab bar visibility249 // Update tab bar visibility
240- c.UpdateBarVisibility(ctx)250+ c.UpdateBarVisibility(ctx, target)
241 251
242- // Quit if no tabs left globally252+ // Target TabList became empty — signal window-empty.
253+ // App decides whether to quit (all windows empty) or only close this window.
243 if wasLast {254 if wasLast {
244- if c.onQuit != nil {255+ c.notifyStateChanged()
245- c.onQuit()256+ if c.onCurrentWindowEmpty != nil {
257+ c.onCurrentWindowEmpty(ctx, target)
246 }258 }
247- log.Debug().Str("tab_id", string(activeID)).Bool("was_last", wasLast).Msg("tab closed, last globally")259+ log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, target window empty")
248 return nil260 return nil
249 }261 }
250 262
251 // If there's a sibling tab in the same window, switch to it263 // If there's a sibling tab in the same window, switch to it
252- if nextScopedID != "" {264+ if nextID != "" {
253- if err := c.tabsUC.Switch(ctx, c.tabs, nextScopedID); err != nil {265+ if err := c.tabsUC.Switch(ctx, target.Tabs, nextID); err != nil {
254- log.Error().Err(err).Str("tab_id", string(nextScopedID)).Msg("failed to switch to scoped sibling")266+ log.Error().Err(err).Str("tab_id", string(nextID)).Msg("failed to switch to sibling")
255 return err267 return err
256 }268 }
257 269
258- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {270+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
259- c.mainWindow.TabBar().SetActive(nextScopedID)271+ target.MainWindow.TabBar().SetActive(nextID)
260 }272 }
261 273
262 if c.onTabSwitched != nil {274 if c.onTabSwitched != nil {
263- if tab := c.tabs.Find(nextScopedID); tab != nil {275+ if tab := target.Tabs.Find(nextID); tab != nil {
264- c.onTabSwitched(ctx, tab)276+ c.onTabSwitched(ctx, target, tab)
265 }277 }
266 }278 }
267 279
268 // Notify state change for session snapshots280 // Notify state change for session snapshots
269 c.notifyStateChanged()281 c.notifyStateChanged()
270 282
271- log.Debug().Str("tab_id", string(activeID)).Str("next", string(nextScopedID)).Msg("tab closed, switched to scoped sibling")283+ log.Debug().Str("tab_id", string(activeID)).Str("next", string(nextID)).Msg("tab closed, switched to sibling")
272 return nil284 return nil
273 }285 }
274 286
275- // Last tab in this window but not globally last — close the window287+ return c.recoverCloseWithoutSibling(ctx, target, activeID)
288+}
289+
290+func (c *TabCoordinator) recoverCloseWithoutSibling(ctx context.Context, target TabTarget, closedID entity.TabID) error {
291+ log := logging.FromContext(ctx)
292+ // Defensive corrupted-state guard: only treat the target as empty if it is
293+ // actually empty after close; otherwise recover by activating a surviving tab.
276 c.notifyStateChanged()294 c.notifyStateChanged()
277- if c.onCurrentWindowEmpty != nil {295+ if len(target.Tabs.Tabs) == 0 {
278- c.onCurrentWindowEmpty(ctx, c.mainWindow)296+ if c.onCurrentWindowEmpty != nil {
297+ c.onCurrentWindowEmpty(ctx, target)
298+ }
299+ log.Debug().Str("tab_id", string(closedID)).Msg("tab closed, target window empty")
300+ return nil
279 }301 }
280-302+ for _, tab := range target.Tabs.Tabs {
281- log.Debug().Str("tab_id", string(activeID)).Msg("tab closed, last in window")303+ if tab != nil {
282- return nil304+ return c.Switch(ctx, target, tab.ID)
305+ }
306+ }
307+ return fmt.Errorf("tab closed but no surviving tab could be activated")
283 }308 }
284 309
285-// Switch switches to a specific tab by ID.310+// Switch switches to a specific tab by ID in the given target.
286-func (c *TabCoordinator) Switch(ctx context.Context, tabID entity.TabID) error {311+func (c *TabCoordinator) Switch(ctx context.Context, target TabTarget, tabID entity.TabID) error {
287 log := logging.FromContext(ctx)312 log := logging.FromContext(ctx)
288 313
314+ if target.Tabs == nil {
315+ return fmt.Errorf("target.Tabs is nil")
316+ }
317+
289 // Skip if already active318 // Skip if already active
290- if tabID == c.tabs.ActiveTabID {319+ if tabID == target.Tabs.ActiveTabID {
291 return nil320 return nil
292 }321 }
293 322
294- // Reject switching to a tab outside the current window scope323+ // Verify the tab exists in this target
295- if tab := c.tabs.Find(tabID); tab != nil && c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {324+ tab := target.Tabs.Find(tabID)
296- log.Debug().325+ if tab == nil {
297- Str("tab_id", string(tabID)).326+ log.Debug().Str("tab_id", string(tabID)).Msg("tab not found in target")
298- Msg("ignoring switch to tab outside current window scope")
299 return nil327 return nil
300 }328 }
301 329
302 log.Debug().330 log.Debug().
303- Str("from", string(c.tabs.ActiveTabID)).331+ Str("from", string(target.Tabs.ActiveTabID)).
304 Str("to", string(tabID)).332 Str("to", string(tabID)).
305 Msg("switching tab")333 Msg("switching tab")
306 334
307 // Update domain state335 // Update domain state
308- if err := c.tabsUC.Switch(ctx, c.tabs, tabID); err != nil {336+ if err := c.tabsUC.Switch(ctx, target.Tabs, tabID); err != nil {
309 log.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")337 log.Error().Err(err).Str("tab_id", string(tabID)).Msg("failed to switch tab")
310 return err338 return err
311 }339 }
312 340
313 // Update tab bar UI341 // Update tab bar UI
314- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {342+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
315- c.mainWindow.TabBar().SetActive(tabID)343+ target.MainWindow.TabBar().SetActive(tabID)
316 }344 }
317 345
318 // Invoke callback for workspace view switching346 // Invoke callback for workspace view switching
319 if c.onTabSwitched != nil {347 if c.onTabSwitched != nil {
320- if tab := c.tabs.Find(tabID); tab != nil {348+ c.onTabSwitched(ctx, target, tab)
321- c.onTabSwitched(ctx, tab)
322- }
323 }349 }
324 350
351+ c.notifyStateChanged()
352+
325 return nil353 return nil
326 }354 }
327 355
328-// switchRelative switches to the tab delta positions away within the current window scope.356+// switchRelative switches to the tab delta positions away within the target.
329-// delta of +1 switches next, -1 switches previous, wrapping within the scoped list.357+// delta of +1 switches next, -1 switches previous, wrapping within the list.
330-func (c *TabCoordinator) switchRelative(ctx context.Context, delta int) error {358+func (c *TabCoordinator) switchRelative(ctx context.Context, target TabTarget, delta int) error {
331- scoped := c.scopedTabs()359+ if target.Tabs == nil {
332- if len(scoped) <= 1 {360+ return fmt.Errorf("target.Tabs is nil")
361+ }
362+ tabs := target.Tabs.Tabs
363+ if len(tabs) <= 1 {
333 return nil364 return nil
334 }365 }
335 366
336- current := indexOfTab(scoped, c.tabs.ActiveTabID)367+ current := indexOfTab(tabs, target.Tabs.ActiveTabID)
337 if current < 0 {368 if current < 0 {
338 current = 0369 current = 0
339 } else {370 } else {
340- current = (current + delta + len(scoped)) % len(scoped)371+ current = (current + delta + len(tabs)) % len(tabs)
341 }372 }
342 373
343- return c.Switch(ctx, scoped[current].ID)374+ return c.Switch(ctx, target, tabs[current].ID)
344 }375 }
345 376
346-// SwitchNext switches to the next tab within the current window scope.377+// SwitchNext switches to the next tab within the given target.
347-func (c *TabCoordinator) SwitchNext(ctx context.Context) error {378+func (c *TabCoordinator) SwitchNext(ctx context.Context, target TabTarget) error {
348- return c.switchRelative(ctx, 1)379+ return c.switchRelative(ctx, target, 1)
349 }380 }
350 381
351-// SwitchPrev switches to the previous tab within the current window scope.382+// SwitchPrev switches to the previous tab within the given target.
352-func (c *TabCoordinator) SwitchPrev(ctx context.Context) error {383+func (c *TabCoordinator) SwitchPrev(ctx context.Context, target TabTarget) error {
353- return c.switchRelative(ctx, -1)384+ return c.switchRelative(ctx, target, -1)
354 }385 }
355 386
356-// SwitchByIndex switches to a tab by 0-based index within the current window scope.387+// SwitchByIndexCreating switches to a tab by 0-based index within the given target,
357-func (c *TabCoordinator) SwitchByIndex(ctx context.Context, index int) error {388+// creating new tabs as needed to reach the requested index.
389+// If the requested index is beyond the current tab count and initialURL is empty,
390+// it returns an error without mutating tabs. If index < 0, it logs debug and returns nil.
391+func (c *TabCoordinator) SwitchByIndexCreating(ctx context.Context, target TabTarget, index int, initialURL string) error {
358 log := logging.FromContext(ctx)392 log := logging.FromContext(ctx)
359 393
360- scoped := c.scopedTabs()394+ if target.Tabs == nil {
361- if index < 0 || index >= len(scoped) {395+ return fmt.Errorf("target.Tabs is nil")
362- log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("invalid scoped tab index")
363- return nil
364 }396 }
365-
366- return c.Switch(ctx, scoped[index].ID)
367-}
368-
369-// EnsureTabByIndex creates tabs until the requested index exists within the current
370-// window scope, then switches to it.
371-func (c *TabCoordinator) EnsureTabByIndex(ctx context.Context, index int, initialURL string) error {
372- log := logging.FromContext(ctx)
373-
374 if index < 0 {397 if index < 0 {
375- log.Debug().Int("index", index).Msg("invalid tab index")398+ log.Debug().Int("index", index).Msg("invalid tab index, ignoring")
376 return nil399 return nil
377 }400 }
378-401+ if target.Tabs.Count() <= index && initialURL == "" {
379- if c.tabs == nil {402+ return fmt.Errorf("initial URL is required to create missing tabs")
380- return fmt.Errorf("tab list is required")
381 }403 }
382-404+ // Intermediate tabs are created inactive (create(..., false)) and the final
383- // Create tabs within the current window scope until we have enough405+ // missing tab is created active (create(..., true)) before SwitchByIndex.
384- scoped := c.scopedTabs()406+ for target.Tabs.Count() < index {
385- if len(scoped) <= index && initialURL == "" {407+ if _, err := c.create(ctx, target, initialURL, false); err != nil {
386- log.Debug().Int("index", index).Int("scoped_count", len(scoped)).Msg("cannot auto-create missing tabs without initial URL")408+ return err
387- return fmt.Errorf("initial URL is required to auto-create missing tabs")409+ }
388 }410 }
389-411+ if target.Tabs.Count() <= index {
390- for len(c.scopedTabs()) <= index {412+ if _, err := c.create(ctx, target, initialURL, true); err != nil {
391- if _, err := c.create(ctx, initialURL, false); err != nil {
392- log.Error().Err(err).Int("index", index).Msg("failed to create tab while ensuring tab index")
393 return err413 return err
394 }414 }
395 }415 }
396-416+ return c.SwitchByIndex(ctx, target, index)
397- return c.SwitchByIndex(ctx, index)
398 }417 }
399 418
400-// SwitchToLastActive switches to the previously active tab (Alt+Tab style).419+// SwitchByIndex switches to a tab by 0-based index within the given target.
401-// When window-scoped (tabScope is set and previousActiveTabID provider is available),420+func (c *TabCoordinator) SwitchByIndex(ctx context.Context, target TabTarget, index int) error {
402-// it uses the per-window previous active tab ID. Falls back to the global
403-// PreviousActiveTabID otherwise.
404-func (c *TabCoordinator) SwitchToLastActive(ctx context.Context) error {
405 log := logging.FromContext(ctx)421 log := logging.FromContext(ctx)
406 422
407- var prevID entity.TabID423+ if target.Tabs == nil {
408- if c.tabScope != nil && c.previousActiveTabID != nil {424+ return fmt.Errorf("target.Tabs is nil")
409- // Use per-window previous active tab ID when scoped425+ }
410- prevID = c.previousActiveTabID(c.mainWindow)426+ tabs := target.Tabs.Tabs
411- } else {427+ if index < 0 || index >= len(tabs) {
412- // Fall back to global PreviousActiveTabID when not scoped or no provider428+ log.Debug().Int("index", index).Int("count", len(tabs)).Msg("invalid tab index")
413- prevID = c.tabs.PreviousActiveTabID429+ return nil
414 }430 }
415 431
416- if prevID == "" || prevID == c.tabs.ActiveTabID {432+ return c.Switch(ctx, target, tabs[index].ID)
433+}
434+
435+// SwitchToLastActive switches to the previously active tab (Alt+Tab style)
436+// within the given target.
437+func (c *TabCoordinator) SwitchToLastActive(ctx context.Context, target TabTarget) error {
438+ if target.Tabs == nil {
417 return nil439 return nil
418 }440 }
419 441
420- tab := c.tabs.Find(prevID)442+ prevID := target.Tabs.PreviousActiveTabID
421- if tab == nil {443+ if prevID == "" || prevID == target.Tabs.ActiveTabID {
422- // Only clear the global previous active tab ID if that's what we used
423- if c.tabScope == nil || c.previousActiveTabID == nil {
424- c.tabs.PreviousActiveTabID = ""
425- }
426 return nil444 return nil
427 }445 }
428 446
429- // With a scope function, only allow switching to the previous tab if it's in scope.447+ tab := target.Tabs.Find(prevID)
430- if c.tabScope != nil && !c.tabScope(tab, c.mainWindow) {448+ if tab == nil {
431- log.Debug().Str("tab_id", string(prevID)).Msg("previous active tab outside current window scope")449+ target.Tabs.PreviousActiveTabID = ""
432 return nil450 return nil
433 }451 }
434 452
435- return c.Switch(ctx, prevID)453+ return c.Switch(ctx, target, prevID)
436 }454 }
437 455
438-// UpdateBarVisibility shows or hides the tab bar based on tab count.456+// UpdateBarVisibility shows or hides the tab bar based on tab count in the target.
439-func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {457+func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context, target TabTarget) {
440 log := logging.FromContext(ctx)458 log := logging.FromContext(ctx)
441 459
442- // Check if feature is enabled460+ if target.MainWindow == nil || target.MainWindow.TabBar() == nil {
443- hideEnabled := c.hideTabBarWhenSingleTab461+ log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update")
444-
445- if !hideEnabled {
446- log.Debug().Msg("tab bar auto-hide disabled by config")
447 return462 return
448 }463 }
449 464
450- if c.mainWindow == nil || c.mainWindow.TabBar() == nil {465+ // Check if feature is enabled
451- log.Debug().Msg("mainWindow or tabBar is nil, skipping visibility update")466+ if !c.hideTabBarWhenSingleTab {
467+ log.Debug().Msg("tab bar auto-hide disabled by config")
468+ target.MainWindow.TabBar().SetAutoHidden(false)
469+ target.MainWindow.SetTabBarContentInsetVisible(true)
452 return470 return
453 }471 }
454 472
455- tabCount := c.mainWindow.TabBar().Count()473+ tabCount := target.MainWindow.TabBar().Count()
456 shouldShow := tabCount > 1474 shouldShow := tabCount > 1
457 475
458 log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")476 log.Debug().Int("tab_count", tabCount).Bool("should_show", shouldShow).Msg("setting tab bar visibility")
459- c.mainWindow.TabBar().SetVisible(shouldShow)477+ target.MainWindow.TabBar().SetAutoHidden(!shouldShow)
478+ target.MainWindow.SetTabBarContentInsetVisible(shouldShow)
460 }479 }
461 480
462-// GetTabBar returns the tab bar component.481+// GetTabBar returns the tab bar component from the current main window.
463 func (c *TabCoordinator) GetTabBar() *component.TabBar {482 func (c *TabCoordinator) GetTabBar() *component.TabBar {
464 if c.mainWindow != nil {483 if c.mainWindow != nil {
465 return c.mainWindow.TabBar()484 return c.mainWindow.TabBar()
@@ -467,23 +486,23 @@ func (c *TabCoordinator) GetTabBar() *component.TabBar {
467 return nil486 return nil
468 }487 }
469 488
470-// SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.489+// CreateWithPane creates a new tab with a pre-created pane and WebView in the given target.
471-func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView)) {
472- c.onAttachPopupToTab = fn
473-}
474-
475-// CreateWithPane creates a new tab with a pre-created pane and WebView.
476 // This is used for tabbed popup behavior where the popup pane already exists.490 // This is used for tabbed popup behavior where the popup pane already exists.
477 func (c *TabCoordinator) CreateWithPane(491 func (c *TabCoordinator) CreateWithPane(
478 ctx context.Context,492 ctx context.Context,
493+ target TabTarget,
479 pane *entity.Pane,494 pane *entity.Pane,
480 wv port.WebView,495 wv port.WebView,
481 initialURL string,496 initialURL string,
482 ) (*entity.Tab, error) {497 ) (*entity.Tab, error) {
483 log := logging.FromContext(ctx)498 log := logging.FromContext(ctx)
484 499
500+ if target.Tabs == nil {
501+ return nil, fmt.Errorf("target.Tabs is nil")
502+ }
503+
485 output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{504 output, err := c.tabsUC.CreateWithPane(ctx, usecase.CreateTabWithPaneInput{
486- TabList: c.tabs,505+ TabList: target.Tabs,
487 Name: pane.Title,506 Name: pane.Title,
488 Pane: pane,507 Pane: pane,
489 InitialURL: initialURL,508 InitialURL: initialURL,
@@ -494,23 +513,21 @@ func (c *TabCoordinator) CreateWithPane(
494 }513 }
495 514
496 // Set new tab as active515 // Set new tab as active
497- c.tabs.SetActive(output.Tab.ID)516+ target.Tabs.SetActive(output.Tab.ID)
498 517
499 // Notify app before adding tab to the visible tab bar.518 // Notify app before adding tab to the visible tab bar.
500- // The app assigns window ownership and the scoped default name here,
501- // and TabBar.AddTab reads tab.Title() immediately when creating the button.
502 if c.onTabCreated != nil {519 if c.onTabCreated != nil {
503- c.onTabCreated(ctx, output.Tab)520+ c.onTabCreated(ctx, target, output.Tab)
504 }521 }
505 522
506 // Update tab bar523 // Update tab bar
507- if c.mainWindow != nil && c.mainWindow.TabBar() != nil {524+ if target.MainWindow != nil && target.MainWindow.TabBar() != nil {
508- c.mainWindow.TabBar().AddTab(output.Tab)525+ target.MainWindow.TabBar().AddTab(output.Tab)
509- c.mainWindow.TabBar().SetActive(output.Tab.ID)526+ target.MainWindow.TabBar().SetActive(output.Tab.ID)
510 }527 }
511 528
512 // Update tab bar visibility529 // Update tab bar visibility
513- c.UpdateBarVisibility(ctx)530+ c.UpdateBarVisibility(ctx, target)
514 531
515 // Attach the popup WebView to the new tab's workspace532 // Attach the popup WebView to the new tab's workspace
516 if c.onAttachPopupToTab != nil {533 if c.onAttachPopupToTab != nil {
@@ -519,7 +536,7 @@ func (c *TabCoordinator) CreateWithPane(
519 536
520 // Switch to the new tab's workspace view537 // Switch to the new tab's workspace view
521 if c.onTabSwitched != nil {538 if c.onTabSwitched != nil {
522- c.onTabSwitched(ctx, output.Tab)539+ c.onTabSwitched(ctx, target, output.Tab)
523 }540 }
524 541
525 // Notify state change for session snapshots542 // Notify state change for session snapshots
A internal/ui/coordinator/tab_test.go +273 -0
@@ -0,0 +1,273 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "strconv"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/ui/window"
11+ "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+// TestTabCoordinator_CloseTargetEmptyDoesNotQuitGlobally verifies that
16+// closing the only tab in one window fires onCurrentWindowEmpty and does NOT
17+// call onQuit when another browser window still has tabs.
18+func TestTabCoordinator_CloseTargetEmptyDoesNotQuitGlobally(t *testing.T) {
19+ ctx := context.Background()
20+
21+ // Two independent TabLists, each with one tab.
22+ firstTabs := entity.NewTabList()
23+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
24+
25+ secondTabs := entity.NewTabList()
26+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab"), entity.WorkspaceID("ws-second"), entity.NewPane(entity.PaneID("pane-second"))))
27+
28+ gen := counterIDGen()
29+ tabsUC := usecase.NewManageTabsUseCase(gen)
30+
31+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
32+ TabsUC: tabsUC,
33+ })
34+
35+ firstMainWindow := &window.MainWindow{}
36+ secondMainWindow := &window.MainWindow{}
37+ firstTarget := TabTarget{Tabs: firstTabs, MainWindow: firstMainWindow}
38+ secondTarget := TabTarget{Tabs: secondTabs, MainWindow: secondMainWindow}
39+
40+ var (
41+ quitCalled bool
42+ windowEmptyCalled bool
43+ windowEmptyTarget *window.MainWindow
44+ )
45+
46+ coord.SetOnQuit(func() {
47+ quitCalled = true
48+ })
49+ coord.SetOnCurrentWindowEmpty(func(_ context.Context, target TabTarget) {
50+ windowEmptyCalled = true
51+ windowEmptyTarget = target.MainWindow
52+ })
53+
54+ // Close the only tab in the first window.
55+ err := coord.Close(ctx, firstTarget)
56+ require.NoError(t, err, "Close on first target returned error")
57+
58+ // onQuit must NOT be called (second window still has tabs).
59+ assert.False(t, quitCalled, "onQuit was called, but second window still has tabs")
60+
61+ // onCurrentWindowEmpty must be called for the first window.
62+ assert.True(t, windowEmptyCalled, "onCurrentWindowEmpty was not called for the emptied window")
63+ assert.Same(t, firstMainWindow, windowEmptyTarget)
64+
65+ // firstTabs must be empty.
66+ assert.Equal(t, 0, firstTabs.Count())
67+
68+ // secondTabs must be unchanged (still has its tab).
69+ assert.Equal(t, 1, secondTabs.Count())
70+ assert.Equal(t, entity.TabID("second-tab"), secondTabs.ActiveTabID)
71+
72+ // Closing the last tab globally (second window's only tab) should also fire window-empty.
73+ windowEmptyCalled = false
74+ windowEmptyTarget = nil
75+ err = coord.Close(ctx, secondTarget)
76+ require.NoError(t, err, "Close on second target returned error")
77+
78+ // onQuit must still NOT be called (App decides when to quit).
79+ assert.False(t, quitCalled, "onQuit was called on last window; onQuit is no longer used by Close")
80+
81+ // onCurrentWindowEmpty must be called for the second window.
82+ assert.True(t, windowEmptyCalled, "onCurrentWindowEmpty was not called for the last window")
83+ assert.Same(t, secondMainWindow, windowEmptyTarget)
84+}
85+
86+// counterIDGen returns a function that generates sequential IDs.
87+func counterIDGen() func() string {
88+ var counter int
89+ return func() string {
90+ counter++
91+ return strconv.Itoa(counter)
92+ }
93+}
94+
95+func TestTabCoordinator_SetMainWindowNilClearsCurrentTargetWindowPreservingTabs(t *testing.T) {
96+ tabs := entity.NewTabList()
97+ mainWindow := &window.MainWindow{}
98+ coord := NewTabCoordinator(context.Background(), TabCoordinatorConfig{
99+ Tabs: tabs,
100+ MainWindow: mainWindow,
101+ })
102+
103+ coord.SetMainWindow(nil)
104+
105+ got := coord.CurrentTarget()
106+ if got.MainWindow != nil {
107+ t.Fatalf("current target main window = %p, want nil", got.MainWindow)
108+ }
109+ if got.Tabs != tabs {
110+ t.Fatalf("current target tabs = %p, want %p", got.Tabs, tabs)
111+ }
112+}
113+
114+func TestTabCoordinator_SwitchByIndexUsesTargetTabListOnly(t *testing.T) {
115+ ctx := context.Background()
116+
117+ // Create two independent TabLists, each with two tabs.
118+ firstTabs := entity.NewTabList()
119+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab-1"), entity.WorkspaceID("ws-first-1"), entity.NewPane(entity.PaneID("pane-first-1"))))
120+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab-2"), entity.WorkspaceID("ws-first-2"), entity.NewPane(entity.PaneID("pane-first-2"))))
121+
122+ secondTabs := entity.NewTabList()
123+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
124+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
125+
126+ gen := counterIDGen()
127+ tabsUC := usecase.NewManageTabsUseCase(gen)
128+
129+ // Create TabCoordinator without global tabs (target-driven).
130+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{
131+ TabsUC: tabsUC,
132+ // No Tabs, no MainWindow — target is provided per-call.
133+ })
134+
135+ firstTarget := TabTarget{Tabs: firstTabs}
136+ secondTarget := TabTarget{Tabs: secondTabs}
137+
138+ // Confirm initial active tab IDs are as expected (first tab in each list).
139+ if got := firstTabs.ActiveTabID; got != entity.TabID("first-tab-1") {
140+ t.Fatalf("firstTabs.ActiveTabID = %q, want %q", got, "first-tab-1")
141+ }
142+ if got := secondTabs.ActiveTabID; got != entity.TabID("second-tab-1") {
143+ t.Fatalf("secondTabs.ActiveTabID = %q, want %q", got, "second-tab-1")
144+ }
145+
146+ // Switch second window to its second tab (index 1).
147+ err := coord.SwitchByIndex(ctx, secondTarget, 1)
148+ if err != nil {
149+ t.Fatalf("SwitchByIndex on second target failed: %v", err)
150+ }
151+
152+ _ = firstTarget // first target is deliberately not passed to SwitchByIndex below
153+
154+ // Assert firstTabs.ActiveTabID is unchanged.
155+ if got := firstTabs.ActiveTabID; got != entity.TabID("first-tab-1") {
156+ t.Fatalf("firstTabs.ActiveTabID = %q, want %q (should be unchanged)", got, "first-tab-1")
157+ }
158+
159+ if got := secondTabs.PreviousActiveTabID; got != entity.TabID("second-tab-1") {
160+ t.Fatalf("secondTabs.PreviousActiveTabID = %q, want %q", got, "second-tab-1")
161+ }
162+
163+ // Assert secondTabs.ActiveTabID is now the second window's tab 2.
164+ if got := secondTabs.ActiveTabID; got != entity.TabID("second-tab-2") {
165+ t.Fatalf("secondTabs.ActiveTabID = %q, want %q", got, "second-tab-2")
166+ }
167+}
168+
169+func TestTabCoordinator_CreateAndSwitchCallbacksCarryTarget(t *testing.T) {
170+ ctx := context.Background()
171+
172+ firstTabs := entity.NewTabList()
173+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
174+ secondTabs := entity.NewTabList()
175+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
176+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-2"), entity.WorkspaceID("ws-second-2"), entity.NewPane(entity.PaneID("pane-second-2"))))
177+
178+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})
179+ coord.SetCurrentTarget(TabTarget{Tabs: firstTabs})
180+
181+ secondTarget := TabTarget{Tabs: secondTabs}
182+ var createdTarget TabTarget
183+ var switchedTargets []TabTarget
184+ coord.SetOnTabCreated(func(_ context.Context, target TabTarget, tab *entity.Tab) {
185+ createdTarget = target
186+ })
187+ coord.SetOnTabSwitched(func(_ context.Context, target TabTarget, tab *entity.Tab) {
188+ switchedTargets = append(switchedTargets, target)
189+ })
190+
191+ if _, err := coord.Create(ctx, secondTarget, "https://example.com"); err != nil {
192+ t.Fatalf("Create returned error: %v", err)
193+ }
194+ if createdTarget.Tabs != secondTabs {
195+ t.Fatalf("created callback target tabs = %p, want %p", createdTarget.Tabs, secondTabs)
196+ }
197+ if len(switchedTargets) != 1 || switchedTargets[0].Tabs != secondTabs {
198+ t.Fatalf("create switch callback targets = %#v, want second target", switchedTargets)
199+ }
200+
201+ switchedTargets = nil
202+ if err := coord.Switch(ctx, secondTarget, entity.TabID("second-tab-1")); err != nil {
203+ t.Fatalf("Switch returned error: %v", err)
204+ }
205+ if len(switchedTargets) != 1 || switchedTargets[0].Tabs != secondTabs {
206+ t.Fatalf("switch callback targets = %#v, want second target", switchedTargets)
207+ }
208+ if firstTabs.ActiveTabID != entity.TabID("first-tab") {
209+ t.Fatalf("first target active tab changed to %q", firstTabs.ActiveTabID)
210+ }
211+}
212+
213+// TestTabCoordinator_SwitchByIndexCreatingCreatesMissingTabs verifies that
214+// SwitchByIndexCreating creates new tabs when the requested index exceeds
215+// the current tab count, and only modifies the specified target.
216+func TestTabCoordinator_SwitchByIndexCreatingCreatesMissingTabs(t *testing.T) {
217+ ctx := context.Background()
218+
219+ firstTabs := entity.NewTabList()
220+ firstTabs.Add(entity.NewTab(entity.TabID("first-tab"), entity.WorkspaceID("ws-first"), entity.NewPane(entity.PaneID("pane-first"))))
221+
222+ secondTabs := entity.NewTabList()
223+ secondTabs.Add(entity.NewTab(entity.TabID("second-tab-1"), entity.WorkspaceID("ws-second-1"), entity.NewPane(entity.PaneID("pane-second-1"))))
224+
225+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})
226+
227+ secondTarget := TabTarget{Tabs: secondTabs}
228+
229+ // Call SwitchByIndexCreating with index 3 (0-based), which is beyond the
230+ // current 1 tab. This should create 3 new tabs (indices 1, 2, 3) and
231+ // activate index 3.
232+ err := coord.SwitchByIndexCreating(ctx, secondTarget, 3, "about:blank")
233+ require.NoError(t, err, "SwitchByIndexCreating should not return error")
234+
235+ // secondTabs should have 4 tabs now (1 existing + 3 created).
236+ require.Equal(t, 4, secondTabs.Count(), "second target should have 4 tabs after creating up to index 3")
237+ require.Greater(t, len(secondTabs.Tabs), 3, "secondTabs.Tabs must have at least 4 elements")
238+ assert.Equal(t, secondTabs.Tabs[3].ID, secondTabs.ActiveTabID, "active tab should be at index 3")
239+
240+ // Verify created tabs/panes have URI "about:blank".
241+ for i := 1; i <= 3; i++ {
242+ require.Less(t, i, len(secondTabs.Tabs), "tab index %d out of range", i)
243+ require.NotNil(t, secondTabs.Tabs[i].Workspace, "tab %d workspace should not be nil", i)
244+ pane := secondTabs.Tabs[i].Workspace.ActivePane()
245+ require.NotNil(t, pane, "tab %d ActivePane should not be nil", i)
246+ require.NotNil(t, pane.Pane, "tab %d pane.Pane should not be nil", i)
247+ assert.Equal(t, "about:blank", pane.Pane.URI, "tab %d pane URI should be about:blank", i)
248+ }
249+
250+ // firstTabs must be completely unchanged.
251+ assert.Equal(t, 1, firstTabs.Count(), "first target should still have 1 tab")
252+ assert.Equal(t, entity.TabID("first-tab"), firstTabs.ActiveTabID, "first target active tab should be unchanged")
253+}
254+
255+// TestTabCoordinator_SwitchByIndexCreatingMissingURLError verifies that
256+// SwitchByIndexCreating returns an error when the initialURL is empty.
257+func TestTabCoordinator_SwitchByIndexCreatingMissingURLError(t *testing.T) {
258+ ctx := context.Background()
259+
260+ targetTabs := entity.NewTabList()
261+ targetTabs.Add(entity.NewTab(entity.TabID("tab-1"), entity.WorkspaceID("ws-1"), entity.NewPane(entity.PaneID("pane-1"))))
262+
263+ coord := NewTabCoordinator(ctx, TabCoordinatorConfig{TabsUC: usecase.NewManageTabsUseCase(counterIDGen())})
264+
265+ target := TabTarget{Tabs: targetTabs}
266+
267+ // Call with empty URL — should return an error.
268+ err := coord.SwitchByIndexCreating(ctx, target, 1, "")
269+ require.Error(t, err, "SwitchByIndexCreating with empty URL should return error")
270+
271+ // Tab count should remain 1.
272+ assert.Equal(t, 1, targetTabs.Count(), "tab count should remain 1 after error")
273+}
M internal/ui/deps.go +2 -2
@@ -77,9 +77,9 @@ type Dependencies struct {
77 SessionRepo repository.SessionRepository77 SessionRepo repository.SessionRepository
78 CurrentSessionID entity.SessionID78 CurrentSessionID entity.SessionID
79 SnapshotUC *usecase.SnapshotSessionUseCase79 SnapshotUC *usecase.SnapshotSessionUseCase
80- // SnapshotServiceFactory creates a snapshot service bound to the given tab-list provider.80+ // SnapshotServiceFactory creates a snapshot service bound to the given window state provider.
81 // Called after the App is initialized so the App can serve as the provider.81 // Called after the App is initialized so the App can serve as the provider.
82- SnapshotServiceFactory func(provider port.TabListProvider, intervalMs int) port.SnapshotService82+ SnapshotServiceFactory func(provider port.WindowStateProvider, intervalMs int) port.SnapshotService
83 // SessionSpawner spawns a new dumber instance for session restoration.83 // SessionSpawner spawns a new dumber instance for session restoration.
84 SessionSpawner port.SessionSpawner84 SessionSpawner port.SessionSpawner
85 // FileSystem provides file operations (e.g., for download deduplication).85 // FileSystem provides file operations (e.g., for download deduplication).
M internal/ui/dispatcher/keyboard.go +96 -27
@@ -4,9 +4,9 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 6
7+ "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/application/usecase"8 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
9- domainurl "github.com/bnema/dumber/internal/domain/url"
10 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
11 "github.com/bnema/dumber/internal/ui/component"11 "github.com/bnema/dumber/internal/ui/component"
12 "github.com/bnema/dumber/internal/ui/coordinator"12 "github.com/bnema/dumber/internal/ui/coordinator"
@@ -20,14 +20,23 @@ const (
20 configSystemViewURL = "dumb://config"20 configSystemViewURL = "dumb://config"
21 )21 )
22 22
23+type KeyboardActions struct {
24+ NewTab func(context.Context) error
25+ CloseTab func(context.Context) error
26+ NextTab func(context.Context) error
27+ PreviousTab func(context.Context) error
28+ SwitchLastTab func(context.Context) error
29+ SwitchTabIndex func(context.Context, int) error
30+ ActiveWebView func(context.Context) port.WebView
31+}
32+
23 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.33 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
24 type KeyboardDispatcher struct {34 type KeyboardDispatcher struct {
25- tabCoord *coordinator.TabCoordinator35+ actions KeyboardActions
26 wsCoord *coordinator.WorkspaceCoordinator36 wsCoord *coordinator.WorkspaceCoordinator
27 navCoord *coordinator.NavigationCoordinator37 navCoord *coordinator.NavigationCoordinator
28 zoomUC *usecase.ManageZoomUseCase38 zoomUC *usecase.ManageZoomUseCase
29 copyURLUC *usecase.CopyURLUseCase39 copyURLUC *usecase.CopyURLUseCase
30- newPaneURL string
31 actionHandlers map[input.Action]func(ctx context.Context) error40 actionHandlers map[input.Action]func(ctx context.Context) error
32 onQuit func()41 onQuit func()
33 onFindOpen func(ctx context.Context) error42 onFindOpen func(ctx context.Context) error
@@ -45,24 +54,22 @@ type KeyboardDispatcher struct {
45 // NewKeyboardDispatcher creates a new KeyboardDispatcher.54 // NewKeyboardDispatcher creates a new KeyboardDispatcher.
46 func NewKeyboardDispatcher(55 func NewKeyboardDispatcher(
47 ctx context.Context,56 ctx context.Context,
48- tabCoord *coordinator.TabCoordinator,
49 wsCoord *coordinator.WorkspaceCoordinator,57 wsCoord *coordinator.WorkspaceCoordinator,
50 navCoord *coordinator.NavigationCoordinator,58 navCoord *coordinator.NavigationCoordinator,
51 zoomUC *usecase.ManageZoomUseCase,59 zoomUC *usecase.ManageZoomUseCase,
52 copyURLUC *usecase.CopyURLUseCase,60 copyURLUC *usecase.CopyURLUseCase,
53- newPaneURL string,61+ actions KeyboardActions,
54 activePaneID func(context.Context) entity.PaneID,62 activePaneID func(context.Context) entity.PaneID,
55 ) *KeyboardDispatcher {63 ) *KeyboardDispatcher {
56 log := logging.FromContext(ctx)64 log := logging.FromContext(ctx)
57 log.Debug().Msg("creating keyboard dispatcher")65 log.Debug().Msg("creating keyboard dispatcher")
58 66
59 dispatcher := &KeyboardDispatcher{67 dispatcher := &KeyboardDispatcher{
60- tabCoord: tabCoord,68+ actions: actions,
61 wsCoord: wsCoord,69 wsCoord: wsCoord,
62 navCoord: navCoord,70 navCoord: navCoord,
63 zoomUC: zoomUC,71 zoomUC: zoomUC,
64 copyURLUC: copyURLUC,72 copyURLUC: copyURLUC,
65- newPaneURL: newPaneURL,
66 activePaneID: activePaneID,73 activePaneID: activePaneID,
67 }74 }
68 dispatcher.initActionHandlers()75 dispatcher.initActionHandlers()
@@ -140,18 +147,15 @@ func (d *KeyboardDispatcher) initActionHandlers() {
140 )147 )
141 d.actionHandlers = map[input.Action]func(ctx context.Context) error{148 d.actionHandlers = map[input.Action]func(ctx context.Context) error{
142 // Tab actions149 // Tab actions
143- input.ActionNewTab: func(ctx context.Context) error {150+ input.ActionNewTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "new tab", d.actions.NewTab) },
144- if d.newPaneURL == "" {151+ input.ActionCloseTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "close tab", d.actions.CloseTab) },
145- logging.FromContext(ctx).Warn().Msg("ActionNewTab: newPaneURL is empty, cannot open new tab")152+ input.ActionNextTab: func(ctx context.Context) error { return d.handleKeyboardAction(ctx, "next tab", d.actions.NextTab) },
146- return fmt.Errorf("newPaneURL is not configured")153+ input.ActionPreviousTab: func(ctx context.Context) error {
147- }154+ return d.handleKeyboardAction(ctx, "previous tab", d.actions.PreviousTab)
148- _, err := d.tabCoord.Create(ctx, domainurl.Normalize(d.newPaneURL))155+ },
149- return err156+ input.ActionSwitchLastTab: func(ctx context.Context) error {
157+ return d.handleKeyboardAction(ctx, "switch last tab", d.actions.SwitchLastTab)
150 },158 },
151- input.ActionCloseTab: d.tabCoord.Close,
152- input.ActionNextTab: d.tabCoord.SwitchNext,
153- input.ActionPreviousTab: d.tabCoord.SwitchPrev,
154- input.ActionSwitchLastTab: d.tabCoord.SwitchToLastActive,
155 input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, firstTabIndex) },159 input.ActionSwitchTabIndex1: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, firstTabIndex) },
156 input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, secondTabIndex) },160 input.ActionSwitchTabIndex2: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, secondTabIndex) },
157 input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, thirdTabIndex) },161 input.ActionSwitchTabIndex3: func(ctx context.Context) error { return d.handleSwitchTabIndex(ctx, thirdTabIndex) },
@@ -209,11 +213,11 @@ func (d *KeyboardDispatcher) initActionHandlers() {
209 input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },213 input.ActionStackNavUp: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "up") },
210 input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },214 input.ActionStackNavDown: func(ctx context.Context) error { return d.wsCoord.NavigateStack(ctx, "down") },
211 // Navigation215 // Navigation
212- input.ActionGoBack: d.navCoord.GoBack,216+ input.ActionGoBack: d.handleGoBack,
213- input.ActionGoForward: d.navCoord.GoForward,217+ input.ActionGoForward: d.handleGoForward,
214- input.ActionReload: d.navCoord.Reload,218+ input.ActionReload: d.handleReload,
215- input.ActionHardReload: d.navCoord.HardReload,219+ input.ActionHardReload: d.handleHardReload,
216- input.ActionPrintPage: d.navCoord.PrintPage,220+ input.ActionPrintPage: d.handlePrintPage,
217 // Zoom actions221 // Zoom actions
218 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },222 input.ActionZoomIn: func(ctx context.Context) error { return d.handleZoom(ctx, "in") },
219 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },223 input.ActionZoomOut: func(ctx context.Context) error { return d.handleZoom(ctx, "out") },
@@ -224,7 +228,7 @@ func (d *KeyboardDispatcher) initActionHandlers() {
224 input.ActionFindNext: d.handleFindNext,228 input.ActionFindNext: d.handleFindNext,
225 input.ActionFindPrev: d.handleFindPrev,229 input.ActionFindPrev: d.handleFindPrev,
226 input.ActionCloseFind: d.handleFindClose,230 input.ActionCloseFind: d.handleFindClose,
227- input.ActionOpenDevTools: d.navCoord.OpenDevTools,231+ input.ActionOpenDevTools: d.handleOpenDevTools,
228 input.ActionToggleFloatingPane: func(ctx context.Context) error {232 input.ActionToggleFloatingPane: func(ctx context.Context) error {
229 if d.onToggleFloating != nil {233 if d.onToggleFloating != nil {
230 return d.onToggleFloating(ctx)234 return d.onToggleFloating(ctx)
@@ -330,7 +334,27 @@ func (d *KeyboardDispatcher) handleMovePaneToNextTab(ctx context.Context) error
330 }334 }
331 335
332 func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {336 func (d *KeyboardDispatcher) handleSwitchTabIndex(ctx context.Context, index int) error {
333- return d.tabCoord.EnsureTabByIndex(ctx, index, domainurl.Normalize(d.newPaneURL))337+ if d.actions.SwitchTabIndex == nil {
338+ logging.FromContext(ctx).Debug().Int("index", index).Msg("switch tab index action ignored (no handler)")
339+ return nil
340+ }
341+ return d.actions.SwitchTabIndex(ctx, index)
342+}
343+
344+func (d *KeyboardDispatcher) handleKeyboardAction(ctx context.Context, name string, fn func(context.Context) error) error {
345+ if fn == nil {
346+ logging.FromContext(ctx).Debug().Str("action", name).Msg("keyboard action ignored (no handler)")
347+ return nil
348+ }
349+ return fn(ctx)
350+}
351+
352+func (d *KeyboardDispatcher) activeWebView(ctx context.Context) port.WebView {
353+ if d.actions.ActiveWebView == nil {
354+ logging.FromContext(ctx).Debug().Msg("active webview unavailable (no handler)")
355+ return nil
356+ }
357+ return d.actions.ActiveWebView(ctx)
334 }358 }
335 359
336 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {360 func (d *KeyboardDispatcher) logNoop(ctx context.Context, message string) error {
@@ -351,6 +375,51 @@ func (d *KeyboardDispatcher) handleQuit(ctx context.Context) error {
351 return nil375 return nil
352 }376 }
353 377
378+func (d *KeyboardDispatcher) withActiveWebView(ctx context.Context, action string, fn func(port.WebView) error) error {
379+ wv := d.activeWebView(ctx)
380+ if wv == nil {
381+ logging.FromContext(ctx).Debug().Str("action", action).Msg("no active webview for keyboard action")
382+ return nil
383+ }
384+ return fn(wv)
385+}
386+
387+func (d *KeyboardDispatcher) handleReload(ctx context.Context) error {
388+ return d.withActiveWebView(ctx, "reload", func(wv port.WebView) error {
389+ return d.navCoord.ReloadWebView(ctx, wv, false)
390+ })
391+}
392+
393+func (d *KeyboardDispatcher) handleHardReload(ctx context.Context) error {
394+ return d.withActiveWebView(ctx, "hard reload", func(wv port.WebView) error {
395+ return d.navCoord.ReloadWebView(ctx, wv, true)
396+ })
397+}
398+
399+func (d *KeyboardDispatcher) handleGoBack(ctx context.Context) error {
400+ return d.withActiveWebView(ctx, "go back", func(wv port.WebView) error {
401+ return d.navCoord.GoBackWebView(ctx, wv)
402+ })
403+}
404+
405+func (d *KeyboardDispatcher) handleGoForward(ctx context.Context) error {
406+ return d.withActiveWebView(ctx, "go forward", func(wv port.WebView) error {
407+ return d.navCoord.GoForwardWebView(ctx, wv)
408+ })
409+}
410+
411+func (d *KeyboardDispatcher) handlePrintPage(ctx context.Context) error {
412+ return d.withActiveWebView(ctx, "print page", func(wv port.WebView) error {
413+ return d.navCoord.PrintWebView(ctx, wv)
414+ })
415+}
416+
417+func (d *KeyboardDispatcher) handleOpenDevTools(ctx context.Context) error {
418+ return d.withActiveWebView(ctx, "open devtools", func(wv port.WebView) error {
419+ return d.navCoord.OpenDevToolsWebView(ctx, wv)
420+ })
421+}
422+
354 // handleZoom processes zoom in/out/reset actions for the active WebView.423 // handleZoom processes zoom in/out/reset actions for the active WebView.
355 func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error {424 func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) error {
356 log := logging.FromContext(ctx)425 log := logging.FromContext(ctx)
@@ -360,7 +429,7 @@ func (d *KeyboardDispatcher) handleZoom(ctx context.Context, action string) erro
360 return nil429 return nil
361 }430 }
362 431
363- wv := d.navCoord.ActiveWebView(ctx)432+ wv := d.activeWebView(ctx)
364 if wv == nil {433 if wv == nil {
365 log.Debug().Msg("no active webview for zoom")434 log.Debug().Msg("no active webview for zoom")
366 return nil435 return nil
@@ -424,7 +493,7 @@ func (d *KeyboardDispatcher) handleCopyURL(ctx context.Context) error {
424 return nil493 return nil
425 }494 }
426 495
427- wv := d.navCoord.ActiveWebView(ctx)496+ wv := d.activeWebView(ctx)
428 if wv == nil {497 if wv == nil {
429 log.Debug().Msg("no active webview for copy URL")498 log.Debug().Msg("no active webview for copy URL")
430 return nil499 return nil
M internal/ui/dispatcher/keyboard_test.go +50 -101
@@ -6,7 +6,6 @@ import (
6 6
7 "github.com/bnema/dumber/internal/application/usecase"7 "github.com/bnema/dumber/internal/application/usecase"
8 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9- domainurl "github.com/bnema/dumber/internal/domain/url"
10 "github.com/bnema/dumber/internal/ui/component"9 "github.com/bnema/dumber/internal/ui/component"
11 "github.com/bnema/dumber/internal/ui/coordinator"10 "github.com/bnema/dumber/internal/ui/coordinator"
12 "github.com/bnema/dumber/internal/ui/input"11 "github.com/bnema/dumber/internal/ui/input"
@@ -14,105 +13,55 @@ import (
14 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
15 )14 )
16 15
17-func TestKeyboardDispatcher_AltNumberCreatesTabsUntilRequestedIndexExists(t *testing.T) {16+func TestKeyboardDispatcher_TabActionsUseInjectedKeyboardActions(t *testing.T) {
18 ctx := context.Background()17 ctx := context.Background()
19- ids := []string{18+ called := make([]string, 0)
20- "tab-1", "ws-1", "pane-1",19+ var switchedIndex int
21- "tab-2", "ws-2", "pane-2",20+ d := NewKeyboardDispatcher(
22- "tab-3", "ws-3", "pane-3",21+ ctx,
23- }22+ &coordinator.WorkspaceCoordinator{},
24- idx := 023+ &coordinator.NavigationCoordinator{},
25- tabsUC := usecase.NewManageTabsUseCase(func() string {24+ nil,
26- id := ids[idx]25+ nil,
27- idx++26+ KeyboardActions{
28- return id27+ NewTab: func(context.Context) error {
29- })28+ called = append(called, "new")
30- tabs := entity.NewTabList()29+ return nil
31- tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{30+ },
32- TabsUC: tabsUC,31+ CloseTab: func(context.Context) error {
33- Tabs: tabs,32+ called = append(called, "close")
34- })33+ return nil
35-34+ },
36- _, err := tabCoord.Create(ctx, "https://initial.example")35+ NextTab: func(context.Context) error {
37- require.NoError(t, err)36+ called = append(called, "next")
38-37+ return nil
39- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "https://new.example", func(context.Context) entity.PaneID { return "" })38+ },
40-39+ PreviousTab: func(context.Context) error {
41- err = d.Dispatch(ctx, input.ActionSwitchTabIndex3)40+ called = append(called, "previous")
42- require.NoError(t, err)41+ return nil
43-42+ },
44- require.Len(t, tabs.Tabs, 3)43+ SwitchLastTab: func(context.Context) error {
45- require.NotNil(t, tabs.ActiveTab())44+ called = append(called, "last")
46- assert.Equal(t, tabs.TabAt(2).ID, tabs.ActiveTabID)45+ return nil
47- assert.Equal(t, tabs.TabAt(0).ID, tabs.PreviousActiveTabID)46+ },
48- assert.Equal(t, 2, tabs.ActiveTab().Position)47+ SwitchTabIndex: func(_ context.Context, index int) error {
49- assert.Equal(t, domainurl.Normalize("https://new.example"), tabs.TabAt(1).Workspace.Root.Pane.URI)48+ called = append(called, "index")
50- assert.Equal(t, domainurl.Normalize("https://new.example"), tabs.TabAt(2).Workspace.Root.Pane.URI)49+ switchedIndex = index
51-50+ return nil
52- err = tabCoord.SwitchToLastActive(ctx)51+ },
53- require.NoError(t, err)52+ },
54- assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)53+ func(context.Context) entity.PaneID { return "" },
55-}54+ )
56-55+
57-func TestKeyboardDispatcher_AltNumberSwitchesToExistingTabWithoutNewPaneURL(t *testing.T) {56+ require.NoError(t, d.Dispatch(ctx, input.ActionNewTab))
58- ctx := context.Background()57+ require.NoError(t, d.Dispatch(ctx, input.ActionCloseTab))
59- ids := []string{58+ require.NoError(t, d.Dispatch(ctx, input.ActionNextTab))
60- "tab-1", "ws-1", "pane-1",59+ require.NoError(t, d.Dispatch(ctx, input.ActionPreviousTab))
61- "tab-2", "ws-2", "pane-2",60+ require.NoError(t, d.Dispatch(ctx, input.ActionSwitchLastTab))
62- }61+ require.NoError(t, d.Dispatch(ctx, input.ActionSwitchTabIndex5))
63- idx := 062+
64- tabsUC := usecase.NewManageTabsUseCase(func() string {63+ assert.Equal(t, []string{"new", "close", "next", "previous", "last", "index"}, called)
65- id := ids[idx]64+ assert.Equal(t, 4, switchedIndex)
66- idx++
67- return id
68- })
69- tabs := entity.NewTabList()
70- tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
71- TabsUC: tabsUC,
72- Tabs: tabs,
73- })
74-
75- _, err := tabCoord.Create(ctx, "https://first.example")
76- require.NoError(t, err)
77- _, err = tabCoord.Create(ctx, "https://second.example")
78- require.NoError(t, err)
79-
80- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
81-
82- err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
83- require.NoError(t, err)
84-
85- require.Len(t, tabs.Tabs, 2)
86- assert.Equal(t, tabs.TabAt(1).ID, tabs.ActiveTabID)
87- assert.Equal(t, domainurl.Normalize("https://second.example"), tabs.TabAt(1).Workspace.Root.Pane.URI)
88-}
89-
90-func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithoutNewPaneURL(t *testing.T) {
91- ctx := context.Background()
92- ids := []string{
93- "tab-1", "ws-1", "pane-1",
94- }
95- idx := 0
96- tabsUC := usecase.NewManageTabsUseCase(func() string {
97- id := ids[idx]
98- idx++
99- return id
100- })
101- tabs := entity.NewTabList()
102- tabCoord := coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
103- TabsUC: tabsUC,
104- Tabs: tabs,
105- })
106-
107- _, err := tabCoord.Create(ctx, "https://first.example")
108- require.NoError(t, err)
109-
110- d := NewKeyboardDispatcher(ctx, tabCoord, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
111-
112- err = d.Dispatch(ctx, input.ActionSwitchTabIndex2)
113- require.Error(t, err)
114- require.Len(t, tabs.Tabs, 1)
115- assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)
116 }65 }
117 66
118 func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T) {67 func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T) {
@@ -135,7 +84,7 @@ func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T)
135 },84 },
136 })85 })
137 86
138- d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, wsCoord, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })87+ d := NewKeyboardDispatcher(ctx, wsCoord, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID { return "" })
139 88
140 err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)89 err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
141 require.NoError(t, err)90 require.NoError(t, err)
@@ -151,7 +100,7 @@ func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T)
151 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {100 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
152 ctx := context.Background()101 ctx := context.Background()
153 activePaneID := entity.PaneID("pane-1")102 activePaneID := entity.PaneID("pane-1")
154- d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID {103+ d := NewKeyboardDispatcher(ctx, &coordinator.WorkspaceCoordinator{}, &coordinator.NavigationCoordinator{}, nil, nil, KeyboardActions{}, func(context.Context) entity.PaneID {
155 return activePaneID104 return activePaneID
156 })105 })
157 106
M internal/ui/floating_pane_test.go +9 -3
@@ -365,13 +365,19 @@ func newFloatingPaneTestApp(t *testing.T) (*App, entity.TabID, *floatingWorkspac
365 tabID := entity.TabID("tab-1")365 tabID := entity.TabID("tab-1")
366 workspace := &entity.Workspace{ActivePaneID: entity.PaneID("pane-one")}366 workspace := &entity.Workspace{ActivePaneID: entity.PaneID("pane-one")}
367 tabs := entity.NewTabList()367 tabs := entity.NewTabList()
368- tabs.Add(&entity.Tab{ID: tabID, Workspace: workspace})368+ tab := &entity.Tab{ID: tabID, Workspace: workspace}
369+ tabs.Add(tab)
370+ tabs.SetActive(tabID)
371+ bw := &browserWindow{id: "window-1", tabs: tabs}
369 372
370 session := newFloatingPaneSession(tabID, floatingSessionIDDefault)373 session := newFloatingPaneSession(tabID, floatingSessionIDDefault)
371 374
372 app := &App{375 app := &App{
373- tabs: tabs,376+ tabs: tabs,
374- workspaceViews: make(map[entity.TabID]*component.WorkspaceView),377+ browserWindows: map[string]*browserWindow{bw.id: bw},
378+ lastFocusedWindowID: bw.id,
379+ windowForTab: map[entity.TabID]*browserWindow{tabID: bw},
380+ workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
375 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{381 floatingSessions: map[floatingSessionKey]*floatingWorkspaceSession{
376 {tabID: tabID, sessionID: floatingSessionIDDefault}: session,382 {tabID: tabID, sessionID: floatingSessionIDDefault}: session,
377 },383 },
M internal/ui/input/global_shortcuts.go +28 -1
@@ -201,6 +201,12 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
201 Msg("stale global shortcut callback ignored")201 Msg("stale global shortcut callback ignored")
202 return true202 return true
203 }203 }
204+ if !h.isActiveWindowShortcutHandler() {
205+ log.Trace().
206+ Str("action", string(actionToDispatch)).
207+ Msg("inactive window global shortcut callback ignored")
208+ return false
209+ }
204 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {210 if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) {
205 log.Trace().211 log.Trace().
206 Str("action", string(actionToDispatch)).212 Str("action", string(actionToDispatch)).
@@ -388,6 +394,10 @@ func (h *GlobalShortcutHandler) isStaleGeneration(generation uint64) bool {
388 return h == nil || generation != h.generation394 return h == nil || generation != h.generation
389 }395 }
390 396
397+func (h *GlobalShortcutHandler) isActiveWindowShortcutHandler() bool {
398+ return h != nil && h.controller != nil && h.window != nil && h.window.IsActive()
399+}
400+
391 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {401 func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool {
392 if h == nil || !isRepeatedGlobalShortcutSuppressed(action) {402 if h == nil || !isRepeatedGlobalShortcutSuppressed(action) {
393 return false403 return false
@@ -406,7 +416,24 @@ func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time
406 416
407 func isRepeatedGlobalShortcutSuppressed(action Action) bool {417 func isRepeatedGlobalShortcutSuppressed(action Action) bool {
408 switch action {418 switch action {
409- case ActionZoomReset, ActionClosePane, ActionCloseTab, ActionQuit, ActionOpenSessionManager:419+ case ActionGoBack,
420+ ActionGoForward,
421+ ActionZoomReset,
422+ ActionClosePane,
423+ ActionCloseTab,
424+ ActionQuit,
425+ ActionOpenSessionManager,
426+ ActionSwitchTabIndex1,
427+ ActionSwitchTabIndex2,
428+ ActionSwitchTabIndex3,
429+ ActionSwitchTabIndex4,
430+ ActionSwitchTabIndex5,
431+ ActionSwitchTabIndex6,
432+ ActionSwitchTabIndex7,
433+ ActionSwitchTabIndex8,
434+ ActionSwitchTabIndex9,
435+ ActionSwitchTabIndex10,
436+ ActionSwitchLastTab:
410 return true437 return true
411 default:438 default:
412 return false439 return false
M internal/ui/input/global_shortcuts_test.go +77 -1
@@ -20,7 +20,7 @@ func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) {
20 }20 }
21 }21 }
22 22
23-func TestGlobalShortcutHandlerAllowsRepeatingNavigationActions(t *testing.T) {23+func TestGlobalShortcutHandlerAllowsRepeatingFocusNavigationActions(t *testing.T) {
24 h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}24 h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
25 now := time.Unix(100, 0)25 now := time.Unix(100, 0)
26 26
@@ -32,6 +32,47 @@ func TestGlobalShortcutHandlerAllowsRepeatingNavigationActions(t *testing.T) {
32 }32 }
33 }33 }
34 34
35+func TestGlobalShortcutHandlerSuppressesRepeatedBrowserNavigationActions(t *testing.T) {
36+ h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
37+ now := time.Unix(100, 0)
38+
39+ if h.suppressRepeatedShortcut(ActionGoBack, now) {
40+ t.Fatal("first go-back dispatch was suppressed")
41+ }
42+ if !h.suppressRepeatedShortcut(ActionGoBack, now.Add(time.Millisecond)) {
43+ t.Fatal("repeated go-back dispatch was not suppressed")
44+ }
45+}
46+
47+func TestGlobalShortcutHandlerSuppressesRepeatedTabIndexSwitchActions(t *testing.T) {
48+ actions := []Action{
49+ ActionSwitchTabIndex1,
50+ ActionSwitchTabIndex2,
51+ ActionSwitchTabIndex3,
52+ ActionSwitchTabIndex4,
53+ ActionSwitchTabIndex5,
54+ ActionSwitchTabIndex6,
55+ ActionSwitchTabIndex7,
56+ ActionSwitchTabIndex8,
57+ ActionSwitchTabIndex9,
58+ ActionSwitchTabIndex10,
59+ }
60+
61+ for _, action := range actions {
62+ t.Run(string(action), func(t *testing.T) {
63+ h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
64+ now := time.Unix(100, 0)
65+
66+ if h.suppressRepeatedShortcut(action, now) {
67+ t.Fatalf("first %s dispatch was suppressed", action)
68+ }
69+ if !h.suppressRepeatedShortcut(action, now.Add(time.Millisecond)) {
70+ t.Fatalf("repeated %s dispatch was not suppressed", action)
71+ }
72+ })
73+ }
74+}
75+
35 func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) {76 func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) {
36 h := &GlobalShortcutHandler{77 h := &GlobalShortcutHandler{
37 registered: make(map[KeyBinding]Action),78 registered: make(map[KeyBinding]Action),
@@ -59,3 +100,38 @@ func TestGlobalShortcutHandlerGenerationMarksOldCallbacksStale(t *testing.T) {
59 t.Fatal("old generation was not marked stale")100 t.Fatal("old generation was not marked stale")
60 }101 }
61 }102 }
103+
104+func TestGlobalShortcutHandlerReturnsInactiveForNilOrDetached(t *testing.T) {
105+ var nilHandler *GlobalShortcutHandler
106+ if nilHandler.isActiveWindowShortcutHandler() {
107+ t.Fatal("nil handler should return inactive")
108+ }
109+ if (&GlobalShortcutHandler{}).isActiveWindowShortcutHandler() {
110+ t.Fatal("handler with nil window should return inactive")
111+ }
112+
113+ h := &GlobalShortcutHandler{
114+ registered: make(map[KeyBinding]Action),
115+ lastDispatchAt: make(map[Action]time.Time),
116+ }
117+ h.Detach()
118+ if h.isActiveWindowShortcutHandler() {
119+ t.Fatal("detached handler with nil controller should return inactive")
120+ }
121+}
122+
123+func TestGlobalShortcutHandlerSuppressesActionSwitchLastTab(t *testing.T) {
124+ if !isRepeatedGlobalShortcutSuppressed(ActionSwitchLastTab) {
125+ t.Fatal("ActionSwitchLastTab should be suppressed")
126+ }
127+
128+ h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)}
129+ now := time.Unix(100, 0)
130+
131+ if h.suppressRepeatedShortcut(ActionSwitchLastTab, now) {
132+ t.Fatal("first ActionSwitchLastTab dispatch was suppressed")
133+ }
134+ if !h.suppressRepeatedShortcut(ActionSwitchLastTab, now.Add(time.Millisecond)) {
135+ t.Fatal("repeated ActionSwitchLastTab dispatch was not suppressed")
136+ }
137+}
M internal/ui/theme/css.go +10 -0
@@ -144,6 +144,16 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
144 // Uses em units for scalable UI.144 // Uses em units for scalable UI.
145 func generateTabBarCSS(p Palette) string {145 func generateTabBarCSS(p Palette) string {
146 return `/* Tab bar styling */146 return `/* Tab bar styling */
147+/* Content area inset for 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 .tab-bar {157 .tab-bar {
148 background-color: var(--surface);158 background-color: var(--surface);
149 border-top: 0.125em solid var(--border);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 assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")185 assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")
186 }186 }
187 187
188+func TestGenerateCSS_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 func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) {217 func TestGenerateCSS_DoesNotEmitUnsupportedGTKProperties(t *testing.T) {
189 css := GenerateCSS(DefaultDarkPalette())218 css := GenerateCSS(DefaultDarkPalette())
190 219
M internal/ui/window/main_window.go +48 -5
@@ -104,13 +104,22 @@ func (mw *MainWindow) assembleLayout() {
104 tabBarPos = mw.tabBarPosition104 tabBarPos = mw.tabBarPosition
105 }105 }
106 106
107+ mw.rootBox.Append(&mw.contentOverlay.Widget)
108+
109+ tabBarWidget := mw.tabBar.Widget()
110+ tabBarWidget.SetHalign(gtk.AlignFillValue)
111+ tabBarWidget.SetValign(gtk.AlignStartValue)
107 if tabBarPos == "bottom" {112 if tabBarPos == "bottom" {
108- mw.rootBox.Append(&mw.contentOverlay.Widget)113+ tabBarWidget.SetValign(gtk.AlignEndValue)
109- mw.rootBox.Append(mw.tabBar.Widget())114+ // Inset is NOT applied unconditionally. See SetTabBarContentInsetVisible
110- } else {115+ // which is called when the bottom tab bar is actually visible.
111- mw.rootBox.Append(mw.tabBar.Widget())
112- mw.rootBox.Append(&mw.contentOverlay.Widget)
113 }116 }
117+ mw.contentOverlay.AddOverlay(tabBarWidget)
118+ mw.contentOverlay.SetClipOverlay(tabBarWidget, false)
119+ // Keep the tab bar out of measurement so showing/hiding it never changes the
120+ // WebView content allocation; the content area reserves a stable inset while
121+ // the overlay remains non-measured to avoid WebView allocation changes.
122+ mw.contentOverlay.SetMeasureOverlay(tabBarWidget, false)
114 123
115 mw.logger.Debug().124 mw.logger.Debug().
116 Str("tab_bar_position", tabBarPos).125 Str("tab_bar_position", tabBarPos).
@@ -191,6 +200,40 @@ func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
191 return mw.contentOverlay200 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 // AddOverlay adds a widget as an overlay above the content area.237 // AddOverlay adds a widget as an overlay above the content area.
195 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {238 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
196 if mw.contentOverlay != nil && widget != nil {239 if mw.contentOverlay != nil && widget != nil {

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close