detached 0747243

Harden native builds and tab state

commit
0747243ff4ff497ca7284c085a5ce89379132225
author
brice · · 2026-05-05 17:40 UTC
parent
17b7e39
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +228 -54
M .github/workflows/aur.yml +2 -2
@@ -81,9 +81,9 @@ jobs:
8181 'build() {' \
8282 ' cd dumber' \
8383 ' export CGO_ENABLED=0' \
84- ' # Do not set -buildmode=pie globally: the build also compiles js/wasm' \
85- ' # systemview assets, and Go does not support PIE for GOOS=js/GOARCH=wasm.' \
84+ ' # Keep global GOFLAGS wasm-safe; apply PIE only to native Go builds.' \
8685 ' export GOFLAGS="-trimpath -mod=readonly -modcacherw"' \
86+ ' export NATIVE_GOFLAGS="${GOFLAGS} -buildmode=pie"' \
8787 ' make build' \
8888 '}' \
8989 '' \
@@ -81,9 +81,9 @@ jobs:
81 'build() {' \81 'build() {' \
82 ' cd dumber' \82 ' cd dumber' \
83 ' export CGO_ENABLED=0' \83 ' export CGO_ENABLED=0' \
84- ' # Do not set -buildmode=pie globally: the build also compiles js/wasm' \84+ ' # Keep global GOFLAGS wasm-safe; apply PIE only to native Go builds.' \
85- ' # systemview assets, and Go does not support PIE for GOOS=js/GOARCH=wasm.' \
86 ' export GOFLAGS="-trimpath -mod=readonly -modcacherw"' \85 ' export GOFLAGS="-trimpath -mod=readonly -modcacherw"' \
86+ ' export NATIVE_GOFLAGS="${GOFLAGS} -buildmode=pie"' \
87 ' make build' \87 ' make build' \
88 '}' \88 '}' \
89 '' \89 '' \
M Makefile +9 -3
@@ -22,6 +22,12 @@ VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0
2222 COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
2323 BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
2424
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+
2531 # Linker flags
2632 LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)"
2733
@@ -42,7 +48,7 @@ help: ## Show this help message
4248 build: build-systemviews ## Build the application (pure Go, no CGO)
4349 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
4450 @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)
4652 @rm -f $(DIST_DIR)/cef-helper
4753 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
4854
@@ -56,14 +62,14 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
5662 @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; }
5763 @mkdir -p assets/systemviews
5864 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
59- GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
65+ GOFLAGS="$(WASM_GOFLAGS)" GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
6066 brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm
6167 @echo "Systemviews build complete"
6268
6369 build-quick: ## Build quickly for backend development
6470 @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."
6571 @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)
6773 @rm -f $(DIST_DIR)/cef-helper
6874 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
6975
@@ -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/PKGBUILD +2 -2
@@ -36,9 +36,9 @@ pkgver() {
3636 build() {
3737 cd dumber
3838 export CGO_ENABLED=0
39- # Do not set -buildmode=pie globally: the build also compiles js/wasm
40- # systemview assets, and Go does not support PIE for GOOS=js/GOARCH=wasm.
39+ # Keep global GOFLAGS wasm-safe; apply PIE only to native Go builds.
4140 export GOFLAGS="-trimpath -mod=readonly -modcacherw"
41+ export NATIVE_GOFLAGS="${GOFLAGS} -buildmode=pie"
4242 make build
4343 }
4444
@@ -36,9 +36,9 @@ pkgver() {
36 build() {36 build() {
37 cd dumber37 cd dumber
38 export CGO_ENABLED=038 export CGO_ENABLED=0
39- # Do not set -buildmode=pie globally: the build also compiles js/wasm39+ # Keep global GOFLAGS wasm-safe; apply PIE only to native Go builds.
40- # systemview assets, and Go does not support PIE for GOOS=js/GOARCH=wasm.
41 export GOFLAGS="-trimpath -mod=readonly -modcacherw"40 export GOFLAGS="-trimpath -mod=readonly -modcacherw"
41+ export NATIVE_GOFLAGS="${GOFLAGS} -buildmode=pie"
42 make build42 make build
43 }43 }
44 44
M internal/domain/entity/session_state.go +1 -1
@@ -154,7 +154,7 @@ type SessionInfo struct {
154154 // CountPanes returns the total number of panes in the session state.
155155 func (s *SessionState) CountPanes() int {
156156 // v2: count across windows
157- if len(s.Windows) > 0 {
157+ if s.Version >= SessionStateVersion {
158158 count := 0
159159 for _, w := range s.Windows {
160160 for _, tab := range w.Tabs {
@@ -154,7 +154,7 @@ type SessionInfo struct {
154 // CountPanes returns the total number of panes in the session state.154 // CountPanes returns the total number of panes in the session state.
155 func (s *SessionState) CountPanes() int {155 func (s *SessionState) CountPanes() int {
156 // v2: count across windows156 // v2: count across windows
157- if len(s.Windows) > 0 {157+ if s.Version >= SessionStateVersion {
158 count := 0158 count := 0
159 for _, w := range s.Windows {159 for _, w := range s.Windows {
160 for _, tab := range w.Tabs {160 for _, tab := range w.Tabs {
M internal/domain/entity/session_state_test.go +11 -0
@@ -609,6 +609,17 @@ func TestSnapshotFromWindowTabLists_EmptyWindows(t *testing.T) {
609609 assert.Equal(t, 0, state.ActiveWindowIndex)
610610 }
611611
612+func TestSessionStateCountPanesUsesVersionForWindowSnapshots(t *testing.T) {
613+ state := &entity.SessionState{
614+ Version: entity.SessionStateVersion,
615+ Tabs: []entity.TabSnapshot{{
616+ Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "legacy-pane"}}},
617+ }},
618+ }
619+
620+ assert.Equal(t, 0, state.CountPanes())
621+}
622+
612623 func TestSnapshotFromWindowTabLists_InvalidWindowIndex(t *testing.T) {
613624 pane := entity.NewPane("p1")
614625 pane.URI = "https://x.com"
@@ -609,6 +609,17 @@ func TestSnapshotFromWindowTabLists_EmptyWindows(t *testing.T) {
609 assert.Equal(t, 0, state.ActiveWindowIndex)609 assert.Equal(t, 0, state.ActiveWindowIndex)
610 }610 }
611 611
612+func TestSessionStateCountPanesUsesVersionForWindowSnapshots(t *testing.T) {
613+ state := &entity.SessionState{
614+ Version: entity.SessionStateVersion,
615+ Tabs: []entity.TabSnapshot{{
616+ Workspace: entity.WorkspaceSnapshot{Root: &entity.PaneNodeSnapshot{Pane: &entity.PaneSnapshot{ID: "legacy-pane"}}},
617+ }},
618+ }
619+
620+ assert.Equal(t, 0, state.CountPanes())
621+}
622+
612 func TestSnapshotFromWindowTabLists_InvalidWindowIndex(t *testing.T) {623 func TestSnapshotFromWindowTabLists_InvalidWindowIndex(t *testing.T) {
613 pane := entity.NewPane("p1")624 pane := entity.NewPane("p1")
614 pane.URI = "https://x.com"625 pane.URI = "https://x.com"
M internal/domain/entity/tab.go +36 -4
@@ -2,6 +2,7 @@ package entity
22
33 import (
44 "fmt"
5+ "sync"
56 "time"
67 )
78
@@ -51,6 +52,8 @@ type TabList struct {
5152 Tabs []*Tab
5253 ActiveTabID TabID
5354 PreviousActiveTabID TabID // Tracks last active tab for Alt+Tab style switching
55+
56+ mu sync.RWMutex
5457 }
5558
5659 // NewTabList creates an empty tab list.
@@ -62,6 +65,9 @@ func NewTabList() *TabList {
6265
6366 // Add appends a tab to the list.
6467 func (tl *TabList) Add(tab *Tab) {
68+ tl.mu.Lock()
69+ defer tl.mu.Unlock()
70+
6571 tab.Position = len(tl.Tabs)
6672 tl.Tabs = append(tl.Tabs, tab)
6773 if tl.ActiveTabID == "" {
@@ -71,6 +77,9 @@ func (tl *TabList) Add(tab *Tab) {
7177
7278 // Remove removes a tab by ID and reindexes positions.
7379 func (tl *TabList) Remove(id TabID) bool {
80+ tl.mu.Lock()
81+ defer tl.mu.Unlock()
82+
7483 for i, tab := range tl.Tabs {
7584 if tab.ID == id {
7685 tl.Tabs = append(tl.Tabs[:i], tl.Tabs[i+1:]...)
@@ -99,6 +108,9 @@ func (tl *TabList) Remove(id TabID) bool {
99108
100109 // Find returns a tab by ID.
101110 func (tl *TabList) Find(id TabID) *Tab {
111+ tl.mu.RLock()
112+ defer tl.mu.RUnlock()
113+
102114 for _, tab := range tl.Tabs {
103115 if tab.ID == id {
104116 return tab
@@ -114,11 +126,17 @@ func (tl *TabList) ActiveTab() *Tab {
114126
115127 // Count returns the number of tabs.
116128 func (tl *TabList) Count() int {
129+ tl.mu.RLock()
130+ defer tl.mu.RUnlock()
131+
117132 return len(tl.Tabs)
118133 }
119134
120135 // SetActive sets the active tab and updates the previous active tab.
121136 func (tl *TabList) SetActive(id TabID) {
137+ tl.mu.Lock()
138+ defer tl.mu.Unlock()
139+
122140 if id != tl.ActiveTabID && tl.ActiveTabID != "" {
123141 tl.PreviousActiveTabID = tl.ActiveTabID
124142 }
@@ -127,6 +145,9 @@ func (tl *TabList) SetActive(id TabID) {
127145
128146 // TabAt returns the tab at the given 0-based index.
129147 func (tl *TabList) TabAt(index int) *Tab {
148+ tl.mu.RLock()
149+ defer tl.mu.RUnlock()
150+
130151 if index < 0 || index >= len(tl.Tabs) {
131152 return nil
132153 }
@@ -135,6 +156,9 @@ func (tl *TabList) TabAt(index int) *Tab {
135156
136157 // Move moves a tab to a new position.
137158 func (tl *TabList) Move(id TabID, newPos int) bool {
159+ tl.mu.Lock()
160+ defer tl.mu.Unlock()
161+
138162 if newPos < 0 || newPos >= len(tl.Tabs) {
139163 return false
140164 }
@@ -169,6 +193,9 @@ func (tl *TabList) Snapshot() *TabList {
169193 if tl == nil {
170194 return nil
171195 }
196+ tl.mu.RLock()
197+ defer tl.mu.RUnlock()
198+
172199 tabs := make([]*Tab, len(tl.Tabs))
173200 copy(tabs, tl.Tabs)
174201 return &TabList{
@@ -181,13 +208,18 @@ func (tl *TabList) Snapshot() *TabList {
181208 // ReplaceFrom replaces this TabList's contents with those from another TabList.
182209 // This modifies in-place so existing references to this TabList remain valid.
183210 func (tl *TabList) ReplaceFrom(other *TabList) {
184- if other == nil {
211+ snapshot := other.Snapshot()
212+
213+ tl.mu.Lock()
214+ defer tl.mu.Unlock()
215+
216+ if snapshot == nil {
185217 tl.Tabs = make([]*Tab, 0)
186218 tl.ActiveTabID = ""
187219 tl.PreviousActiveTabID = ""
188220 return
189221 }
190- tl.Tabs = other.Tabs
191- tl.ActiveTabID = other.ActiveTabID
192- tl.PreviousActiveTabID = other.PreviousActiveTabID
222+ tl.Tabs = snapshot.Tabs
223+ tl.ActiveTabID = snapshot.ActiveTabID
224+ tl.PreviousActiveTabID = snapshot.PreviousActiveTabID
193225 }
@@ -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,9 @@ 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+
102 for _, tab := range tl.Tabs {114 for _, tab := range tl.Tabs {
103 if tab.ID == id {115 if tab.ID == id {
104 return tab116 return tab
@@ -114,11 +126,17 @@ func (tl *TabList) ActiveTab() *Tab {
114 126
115 // Count returns the number of tabs.127 // Count returns the number of tabs.
116 func (tl *TabList) Count() int {128 func (tl *TabList) Count() int {
129+ tl.mu.RLock()
130+ defer tl.mu.RUnlock()
131+
117 return len(tl.Tabs)132 return len(tl.Tabs)
118 }133 }
119 134
120 // SetActive sets the active tab and updates the previous active tab.135 // SetActive sets the active tab and updates the previous active tab.
121 func (tl *TabList) SetActive(id TabID) {136 func (tl *TabList) SetActive(id TabID) {
137+ tl.mu.Lock()
138+ defer tl.mu.Unlock()
139+
122 if id != tl.ActiveTabID && tl.ActiveTabID != "" {140 if id != tl.ActiveTabID && tl.ActiveTabID != "" {
123 tl.PreviousActiveTabID = tl.ActiveTabID141 tl.PreviousActiveTabID = tl.ActiveTabID
124 }142 }
@@ -127,6 +145,9 @@ func (tl *TabList) SetActive(id TabID) {
127 145
128 // TabAt returns the tab at the given 0-based index.146 // TabAt returns the tab at the given 0-based index.
129 func (tl *TabList) TabAt(index int) *Tab {147 func (tl *TabList) TabAt(index int) *Tab {
148+ tl.mu.RLock()
149+ defer tl.mu.RUnlock()
150+
130 if index < 0 || index >= len(tl.Tabs) {151 if index < 0 || index >= len(tl.Tabs) {
131 return nil152 return nil
132 }153 }
@@ -135,6 +156,9 @@ func (tl *TabList) TabAt(index int) *Tab {
135 156
136 // Move moves a tab to a new position.157 // Move moves a tab to a new position.
137 func (tl *TabList) Move(id TabID, newPos int) bool {158 func (tl *TabList) Move(id TabID, newPos int) bool {
159+ tl.mu.Lock()
160+ defer tl.mu.Unlock()
161+
138 if newPos < 0 || newPos >= len(tl.Tabs) {162 if newPos < 0 || newPos >= len(tl.Tabs) {
139 return false163 return false
140 }164 }
@@ -169,6 +193,9 @@ func (tl *TabList) Snapshot() *TabList {
169 if tl == nil {193 if tl == nil {
170 return nil194 return nil
171 }195 }
196+ tl.mu.RLock()
197+ defer tl.mu.RUnlock()
198+
172 tabs := make([]*Tab, len(tl.Tabs))199 tabs := make([]*Tab, len(tl.Tabs))
173 copy(tabs, tl.Tabs)200 copy(tabs, tl.Tabs)
174 return &TabList{201 return &TabList{
@@ -181,13 +208,18 @@ func (tl *TabList) Snapshot() *TabList {
181 // ReplaceFrom replaces this TabList's contents with those from another TabList.208 // ReplaceFrom replaces this TabList's contents with those from another TabList.
182 // This modifies in-place so existing references to this TabList remain valid.209 // This modifies in-place so existing references to this TabList remain valid.
183 func (tl *TabList) ReplaceFrom(other *TabList) {210 func (tl *TabList) ReplaceFrom(other *TabList) {
184- if other == nil {211+ snapshot := other.Snapshot()
212+
213+ tl.mu.Lock()
214+ defer tl.mu.Unlock()
215+
216+ if snapshot == nil {
185 tl.Tabs = make([]*Tab, 0)217 tl.Tabs = make([]*Tab, 0)
186 tl.ActiveTabID = ""218 tl.ActiveTabID = ""
187 tl.PreviousActiveTabID = ""219 tl.PreviousActiveTabID = ""
188 return220 return
189 }221 }
190- tl.Tabs = other.Tabs222+ tl.Tabs = snapshot.Tabs
191- tl.ActiveTabID = other.ActiveTabID223+ tl.ActiveTabID = snapshot.ActiveTabID
192- tl.PreviousActiveTabID = other.PreviousActiveTabID224+ tl.PreviousActiveTabID = snapshot.PreviousActiveTabID
193 }225 }
M internal/ui/app_browser_launch_test.go +48 -6
@@ -1297,6 +1297,34 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAl
12971297 assertWindowTabBarAutoHidden(t, mainWindow, true)
12981298 }
12991299
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)
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+
13001328 func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {
13011329 gtkApp := requireGTKDisplayApp(t)
13021330 defer gtkApp.Unref()
@@ -2474,7 +2502,7 @@ func TestMainWindow_BottomTabBar_ContentAreaHasInset(t *testing.T) {
24742502 }
24752503 }
24762504
2477-func TestMainWindow_TopTabBar_NoContentAreaInset(t *testing.T) {
2505+func TestMainWindow_TopTabBar_ContentAreaHasTopInset(t *testing.T) {
24782506 gtkApp := requireGTKDisplayApp(t)
24792507 defer gtkApp.Unref()
24802508
@@ -2484,19 +2512,33 @@ func TestMainWindow_TopTabBar_NoContentAreaInset(t *testing.T) {
24842512 }
24852513 defer mw.Destroy()
24862514
2487- // Top should never have the bottom inset class
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+ }
24882518 if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
24892519 t.Fatal("top-tab-bar content area: should NOT have content-area-tabbar-inset-bottom CSS class")
24902520 }
24912521
2492- // Even after SetTabBarContentInsetVisible(true), top must not get bottom inset
24932522 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+ }
24942526 if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2495- t.Fatal("top-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(true)")
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")
24962531 }
24972532
2498- // HasTabBarContentInset must return false for top
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+ }
24992537 if mw.HasTabBarContentInset() {
2500- t.Fatal("HasTabBarContentInset should return false for top tab bar position")
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")
25012543 }
25022544 }
@@ -1297,6 +1297,34 @@ func TestApp_UpdateBrowserWindowTabBarVisibilityAutoHidesSingleTabButPreservesAl
1297 assertWindowTabBarAutoHidden(t, mainWindow, true)1297 assertWindowTabBarAutoHidden(t, mainWindow, true)
1298 }1298 }
1299 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)
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+
1300 func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {1328 func TestApp_UpdateBrowserWindowTabBarVisibilityHonorsHideWhenSingleTabDisabled(t *testing.T) {
1301 gtkApp := requireGTKDisplayApp(t)1329 gtkApp := requireGTKDisplayApp(t)
1302 defer gtkApp.Unref()1330 defer gtkApp.Unref()
@@ -2474,7 +2502,7 @@ func TestMainWindow_BottomTabBar_ContentAreaHasInset(t *testing.T) {
2474 }2502 }
2475 }2503 }
2476 2504
2477-func TestMainWindow_TopTabBar_NoContentAreaInset(t *testing.T) {2505+func TestMainWindow_TopTabBar_ContentAreaHasTopInset(t *testing.T) {
2478 gtkApp := requireGTKDisplayApp(t)2506 gtkApp := requireGTKDisplayApp(t)
2479 defer gtkApp.Unref()2507 defer gtkApp.Unref()
2480 2508
@@ -2484,19 +2512,33 @@ func TestMainWindow_TopTabBar_NoContentAreaInset(t *testing.T) {
2484 }2512 }
2485 defer mw.Destroy()2513 defer mw.Destroy()
2486 2514
2487- // Top should never have the bottom inset class2515+ 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+ }
2488 if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {2518 if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2489 t.Fatal("top-tab-bar content area: should NOT have content-area-tabbar-inset-bottom CSS class")2519 t.Fatal("top-tab-bar content area: should NOT have content-area-tabbar-inset-bottom CSS class")
2490 }2520 }
2491 2521
2492- // Even after SetTabBarContentInsetVisible(true), top must not get bottom inset
2493 mw.SetTabBarContentInsetVisible(true)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+ }
2494 if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {2526 if mw.ContentArea().HasCssClass("content-area-tabbar-inset-bottom") {
2495- t.Fatal("top-tab-bar content area: should NOT have inset after SetTabBarContentInsetVisible(true)")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")
2496 }2531 }
2497 2532
2498- // HasTabBarContentInset must return false for top2533+ 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+ }
2499 if mw.HasTabBarContentInset() {2537 if mw.HasTabBarContentInset() {
2500- t.Fatal("HasTabBarContentInset should return false for top tab bar position")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")
2501 }2543 }
2502 }2544 }
M internal/ui/component/tab_bar.go +16 -17
@@ -13,6 +13,7 @@ type TabBar struct {
1313 buttons map[entity.TabID]*TabButton
1414
1515 explicitlyVisible bool
16+ autoHidden bool
1617
1718 // Active tab tracking
1819 activeTabID entity.TabID
@@ -166,14 +167,7 @@ func (tb *TabBar) SetVisible(visible bool) {
166167 return
167168 }
168169 tb.explicitlyVisible = visible
169- tb.box.SetVisible(visible)
170- if !visible {
171- tb.box.SetFocusable(false)
172- tb.box.SetCanTarget(false)
173- } else {
174- tb.box.SetFocusable(true)
175- tb.box.SetCanTarget(true)
176- }
170+ tb.applyVisibilityState()
177171 }
178172
179173 // SetAutoHidden hides the tab bar visually while keeping the overlay widget
@@ -185,19 +179,24 @@ func (tb *TabBar) SetAutoHidden(hidden bool) {
185179 return
186180 }
187181
188- // Fullscreen and other explicit hide states must win over auto-hide updates.
189- tb.box.SetVisible(tb.explicitlyVisible)
182+ tb.autoHidden = hidden
183+ tb.applyVisibilityState()
184+}
190185
191- if hidden {
192- tb.box.SetOpacity(0.0)
193- tb.box.SetCanTarget(false)
194- tb.box.SetFocusable(false)
186+func (tb *TabBar) applyVisibilityState() {
187+ if tb == nil || tb.box == nil {
195188 return
196189 }
197190
198- tb.box.SetOpacity(1.0)
199- tb.box.SetCanTarget(tb.explicitlyVisible)
200- tb.box.SetFocusable(tb.explicitlyVisible)
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)
201200 }
202201
203202 // Destroy cleans up all tab bar resources.
@@ -13,6 +13,7 @@ type TabBar struct {
13 buttons map[entity.TabID]*TabButton13 buttons map[entity.TabID]*TabButton
14 14
15 explicitlyVisible bool15 explicitlyVisible bool
16+ autoHidden bool
16 17
17 // Active tab tracking18 // Active tab tracking
18 activeTabID entity.TabID19 activeTabID entity.TabID
@@ -166,14 +167,7 @@ func (tb *TabBar) SetVisible(visible bool) {
166 return167 return
167 }168 }
168 tb.explicitlyVisible = visible169 tb.explicitlyVisible = visible
169- tb.box.SetVisible(visible)170+ tb.applyVisibilityState()
170- if !visible {
171- tb.box.SetFocusable(false)
172- tb.box.SetCanTarget(false)
173- } else {
174- tb.box.SetFocusable(true)
175- tb.box.SetCanTarget(true)
176- }
177 }171 }
178 172
179 // SetAutoHidden hides the tab bar visually while keeping the overlay widget173 // SetAutoHidden hides the tab bar visually while keeping the overlay widget
@@ -185,19 +179,24 @@ func (tb *TabBar) SetAutoHidden(hidden bool) {
185 return179 return
186 }180 }
187 181
188- // Fullscreen and other explicit hide states must win over auto-hide updates.182+ tb.autoHidden = hidden
189- tb.box.SetVisible(tb.explicitlyVisible)183+ tb.applyVisibilityState()
184+}
190 185
191- if hidden {186+func (tb *TabBar) applyVisibilityState() {
192- tb.box.SetOpacity(0.0)187+ if tb == nil || tb.box == nil {
193- tb.box.SetCanTarget(false)
194- tb.box.SetFocusable(false)
195 return188 return
196 }189 }
197 190
198- tb.box.SetOpacity(1.0)191+ interactive := tb.explicitlyVisible && !tb.autoHidden
199- tb.box.SetCanTarget(tb.explicitlyVisible)192+ tb.box.SetVisible(tb.explicitlyVisible)
200- tb.box.SetFocusable(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)
201 }200 }
202 201
203 // Destroy cleans up all tab bar resources.202 // Destroy cleans up all tab bar resources.
M internal/ui/coordinator/navigation.go +2 -2
@@ -84,8 +84,6 @@ func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string,
8484 return err
8585 }
8686
87- c.trackNavigationOrigin(ctx, paneID, url)
88-
8987 paneIDStr := string(paneID)
9088
9189 if c.navigateUC != nil {
@@ -99,6 +97,7 @@ func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string,
9997 log.Error().Err(err).Str("url", url).Str("pane_id", paneIDStr).Msg("navigation failed")
10098 return err
10199 }
100+ c.trackNavigationOrigin(ctx, paneID, url)
102101 log.Debug().
103102 Str("url", url).
104103 Str("pane_id", paneIDStr).
@@ -114,6 +113,7 @@ func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string,
114113 return err
115114 }
116115
116+ c.trackNavigationOrigin(ctx, paneID, url)
117117 log.Debug().Str("url", url).Uint64("webview_id", uint64(wv.ID())).Msg("navigated explicit webview (direct)")
118118 return nil
119119 }
@@ -84,8 +84,6 @@ func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string,
84 return err84 return err
85 }85 }
86 86
87- c.trackNavigationOrigin(ctx, paneID, url)
88-
89 paneIDStr := string(paneID)87 paneIDStr := string(paneID)
90 88
91 if c.navigateUC != nil {89 if c.navigateUC != nil {
@@ -99,6 +97,7 @@ func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string,
99 log.Error().Err(err).Str("url", url).Str("pane_id", paneIDStr).Msg("navigation failed")97 log.Error().Err(err).Str("url", url).Str("pane_id", paneIDStr).Msg("navigation failed")
100 return err98 return err
101 }99 }
100+ c.trackNavigationOrigin(ctx, paneID, url)
102 log.Debug().101 log.Debug().
103 Str("url", url).102 Str("url", url).
104 Str("pane_id", paneIDStr).103 Str("pane_id", paneIDStr).
@@ -114,6 +113,7 @@ func (c *NavigationCoordinator) NavigateWebView(ctx context.Context, url string,
114 return err113 return err
115 }114 }
116 115
116+ c.trackNavigationOrigin(ctx, paneID, url)
117 log.Debug().Str("url", url).Uint64("webview_id", uint64(wv.ID())).Msg("navigated explicit webview (direct)")117 log.Debug().Str("url", url).Uint64("webview_id", uint64(wv.ID())).Msg("navigated explicit webview (direct)")
118 return nil118 return nil
119 }119 }
M internal/ui/coordinator/navigation_webview_targeted_test.go +68 -0
@@ -9,6 +9,16 @@ import (
99 "github.com/bnema/dumber/internal/domain/entity"
1010 )
1111
12+type mockDevToolsWebView struct {
13+ *mocks.MockWebView
14+ *mocks.MockDevToolsOpener
15+}
16+
17+type mockPrinterWebView struct {
18+ *mocks.MockWebView
19+ *mocks.MockPrinter
20+}
21+
1222 // TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView verifies that
1323 // the explicit WebView-targeted navigation methods call the correct methods on the
1424 // provided WebView rather than resolving through the content coordinator.
@@ -48,6 +58,18 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi
4858 t.Run("GoForwardWebView calls GoForward once", func(t *testing.T) {
4959 testGoForwardWebViewCallsGoForward(t, ctx)
5060 })
61+ t.Run("OpenDevToolsWebView with nil webview returns error", func(t *testing.T) {
62+ testOpenDevToolsWebViewNilWebView(t, ctx)
63+ })
64+ t.Run("OpenDevToolsWebView calls OpenDevTools once", func(t *testing.T) {
65+ testOpenDevToolsWebViewTargetsProvidedWebView(t, ctx)
66+ })
67+ t.Run("PrintWebView with nil webview returns error", func(t *testing.T) {
68+ testPrintWebViewNilWebView(t, ctx)
69+ })
70+ t.Run("PrintWebView calls PrintPage once", func(t *testing.T) {
71+ testPrintWebViewTargetsProvidedWebView(t, ctx)
72+ })
5173 }
5274
5375 func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) {
@@ -166,3 +188,49 @@ func testGoForwardWebViewCallsGoForward(t *testing.T, ctx context.Context) {
166188 t.Fatalf("unexpected error: %v", err)
167189 }
168190 }
191+
192+func testOpenDevToolsWebViewNilWebView(t *testing.T, ctx context.Context) {
193+ t.Helper()
194+ c := &NavigationCoordinator{}
195+ err := c.OpenDevToolsWebView(ctx, nil)
196+ if err == nil {
197+ t.Fatal("expected error for nil webview, got nil")
198+ }
199+}
200+
201+func testOpenDevToolsWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
202+ t.Helper()
203+ base := mocks.NewMockWebView(t)
204+ opener := mocks.NewMockDevToolsOpener(t)
205+ wv := &mockDevToolsWebView{MockWebView: base, MockDevToolsOpener: opener}
206+ base.EXPECT().ID().Return(port.WebViewID(1)).Once()
207+ opener.EXPECT().OpenDevTools().Return().Once()
208+ c := &NavigationCoordinator{}
209+ err := c.OpenDevToolsWebView(ctx, wv)
210+ if err != nil {
211+ t.Fatalf("unexpected error: %v", err)
212+ }
213+}
214+
215+func testPrintWebViewNilWebView(t *testing.T, ctx context.Context) {
216+ t.Helper()
217+ c := &NavigationCoordinator{}
218+ err := c.PrintWebView(ctx, nil)
219+ if err == nil {
220+ t.Fatal("expected error for nil webview, got nil")
221+ }
222+}
223+
224+func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
225+ t.Helper()
226+ base := mocks.NewMockWebView(t)
227+ printer := mocks.NewMockPrinter(t)
228+ wv := &mockPrinterWebView{MockWebView: base, MockPrinter: printer}
229+ base.EXPECT().ID().Return(port.WebViewID(1)).Once()
230+ printer.EXPECT().PrintPage().Return().Once()
231+ c := &NavigationCoordinator{}
232+ err := c.PrintWebView(ctx, wv)
233+ if err != nil {
234+ t.Fatalf("unexpected error: %v", err)
235+ }
236+}
@@ -9,6 +9,16 @@ import (
9 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
10 )10 )
11 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+
12 // TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView verifies that22 // TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView verifies that
13 // the explicit WebView-targeted navigation methods call the correct methods on the23 // the explicit WebView-targeted navigation methods call the correct methods on the
14 // provided WebView rather than resolving through the content coordinator.24 // provided WebView rather than resolving through the content coordinator.
@@ -48,6 +58,18 @@ func TestNavigationCoordinator_WebViewTargetedActionsUseProvidedWebView(t *testi
48 t.Run("GoForwardWebView calls GoForward once", func(t *testing.T) {58 t.Run("GoForwardWebView calls GoForward once", func(t *testing.T) {
49 testGoForwardWebViewCallsGoForward(t, ctx)59 testGoForwardWebViewCallsGoForward(t, ctx)
50 })60 })
61+ t.Run("OpenDevToolsWebView with nil webview returns error", func(t *testing.T) {
62+ testOpenDevToolsWebViewNilWebView(t, ctx)
63+ })
64+ t.Run("OpenDevToolsWebView calls OpenDevTools once", func(t *testing.T) {
65+ testOpenDevToolsWebViewTargetsProvidedWebView(t, ctx)
66+ })
67+ t.Run("PrintWebView with nil webview returns error", func(t *testing.T) {
68+ testPrintWebViewNilWebView(t, ctx)
69+ })
70+ t.Run("PrintWebView calls PrintPage once", func(t *testing.T) {
71+ testPrintWebViewTargetsProvidedWebView(t, ctx)
72+ })
51 }73 }
52 74
53 func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) {75 func testNavigateWebViewNilWebView(t *testing.T, ctx context.Context) {
@@ -166,3 +188,49 @@ func testGoForwardWebViewCallsGoForward(t *testing.T, ctx context.Context) {
166 t.Fatalf("unexpected error: %v", err)188 t.Fatalf("unexpected error: %v", err)
167 }189 }
168 }190 }
191+
192+func testOpenDevToolsWebViewNilWebView(t *testing.T, ctx context.Context) {
193+ t.Helper()
194+ c := &NavigationCoordinator{}
195+ err := c.OpenDevToolsWebView(ctx, nil)
196+ if err == nil {
197+ t.Fatal("expected error for nil webview, got nil")
198+ }
199+}
200+
201+func testOpenDevToolsWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
202+ t.Helper()
203+ base := mocks.NewMockWebView(t)
204+ opener := mocks.NewMockDevToolsOpener(t)
205+ wv := &mockDevToolsWebView{MockWebView: base, MockDevToolsOpener: opener}
206+ base.EXPECT().ID().Return(port.WebViewID(1)).Once()
207+ opener.EXPECT().OpenDevTools().Return().Once()
208+ c := &NavigationCoordinator{}
209+ err := c.OpenDevToolsWebView(ctx, wv)
210+ if err != nil {
211+ t.Fatalf("unexpected error: %v", err)
212+ }
213+}
214+
215+func testPrintWebViewNilWebView(t *testing.T, ctx context.Context) {
216+ t.Helper()
217+ c := &NavigationCoordinator{}
218+ err := c.PrintWebView(ctx, nil)
219+ if err == nil {
220+ t.Fatal("expected error for nil webview, got nil")
221+ }
222+}
223+
224+func testPrintWebViewTargetsProvidedWebView(t *testing.T, ctx context.Context) {
225+ t.Helper()
226+ base := mocks.NewMockWebView(t)
227+ printer := mocks.NewMockPrinter(t)
228+ wv := &mockPrinterWebView{MockWebView: base, MockPrinter: printer}
229+ base.EXPECT().ID().Return(port.WebViewID(1)).Once()
230+ printer.EXPECT().PrintPage().Return().Once()
231+ c := &NavigationCoordinator{}
232+ err := c.PrintWebView(ctx, wv)
233+ if err != nil {
234+ t.Fatalf("unexpected error: %v", err)
235+ }
236+}
M internal/ui/theme/css.go +5 -1
@@ -144,8 +144,12 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
144144 // Uses em units for scalable UI.
145145 func generateTabBarCSS(p Palette) string {
146146 return `/* Tab bar styling */
147-/* Content area inset for bottom-positioned tab bar — uses the same 2em token as
147+/* Content area inset for overlaid tab bars — uses the same 2em token as
148148 .tab-bar min-height and remains static to avoid OSR vertical reallocations. */
149+.content-area-tabbar-inset-top {
150+ padding-top: 2em;
151+}
152+
149153 .content-area-tabbar-inset-bottom {
150154 padding-bottom: 2em;
151155 }
@@ -144,8 +144,12 @@ func GenerateCSSFull(p Palette, _ float64, fonts FontConfig, modeColors ModeColo
144 // Uses em units for scalable UI.144 // Uses em units for scalable UI.
145 func generateTabBarCSS(p Palette) string {145 func generateTabBarCSS(p Palette) string {
146 return `/* Tab bar styling */146 return `/* Tab bar styling */
147-/* Content area inset for bottom-positioned tab bar — uses the same 2em token as147+/* 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. */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+
149 .content-area-tabbar-inset-bottom {153 .content-area-tabbar-inset-bottom {
150 padding-bottom: 2em;154 padding-bottom: 2em;
151 }155 }
M internal/ui/theme/css_test.go +7 -1
@@ -185,9 +185,15 @@ func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testi
185185 assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")
186186 }
187187
188-func TestGenerateCSS_ContentAreaTabBarInsetBottom(t *testing.T) {
188+func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) {
189189 css := GenerateCSS(DefaultDarkPalette())
190190
191+ if !strings.Contains(css, ".content-area-tabbar-inset-top") {
192+ t.Fatal("expected .content-area-tabbar-inset-top selector in generated CSS")
193+ }
194+ if !strings.Contains(css, "padding-top: 2em;") {
195+ t.Fatal("expected padding-top: 2em in .content-area-tabbar-inset-top block")
196+ }
191197 if !strings.Contains(css, ".content-area-tabbar-inset-bottom") {
192198 t.Fatal("expected .content-area-tabbar-inset-bottom selector in generated CSS")
193199 }
@@ -185,9 +185,15 @@ func TestGenerateCSSWithScale_UsesMediumWeightForOmniboxSuggestionTitle(t *testi
185 assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")185 assert.NotContains(t, css, ".omnibox-suggestion-title {\n\tfont-size: 0.875em;\n\tcolor: var(--text);\n\tfont-weight: 400;")
186 }186 }
187 187
188-func TestGenerateCSS_ContentAreaTabBarInsetBottom(t *testing.T) {188+func TestGenerateCSS_ContentAreaTabBarInset(t *testing.T) {
189 css := GenerateCSS(DefaultDarkPalette())189 css := GenerateCSS(DefaultDarkPalette())
190 190
191+ if !strings.Contains(css, ".content-area-tabbar-inset-top") {
192+ t.Fatal("expected .content-area-tabbar-inset-top selector in generated CSS")
193+ }
194+ if !strings.Contains(css, "padding-top: 2em;") {
195+ t.Fatal("expected padding-top: 2em in .content-area-tabbar-inset-top block")
196+ }
191 if !strings.Contains(css, ".content-area-tabbar-inset-bottom") {197 if !strings.Contains(css, ".content-area-tabbar-inset-bottom") {
192 t.Fatal("expected .content-area-tabbar-inset-bottom selector in generated CSS")198 t.Fatal("expected .content-area-tabbar-inset-bottom selector in generated CSS")
193 }199 }
M internal/ui/window/main_window.go +21 -15
@@ -117,9 +117,8 @@ func (mw *MainWindow) assembleLayout() {
117117 mw.contentOverlay.AddOverlay(tabBarWidget)
118118 mw.contentOverlay.SetClipOverlay(tabBarWidget, false)
119119 // Keep the tab bar out of measurement so showing/hiding it never changes the
120- // WebView content allocation; the content area reserves a stable inset for
121- // bottom-position bars while the overlay remains non-measured to avoid
122- // WebView allocation changes.
120+ // WebView content allocation; the content area reserves a stable inset while
121+ // the overlay remains non-measured to avoid WebView allocation changes.
123122 mw.contentOverlay.SetMeasureOverlay(tabBarWidget, false)
124123
125124 mw.logger.Debug().
@@ -201,31 +200,38 @@ func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
201200 return mw.contentOverlay
202201 }
203202
204-// SetTabBarContentInsetVisible adds or removes the bottom tab bar inset CSS class
205-// on the content area. Only effective when tabBarPosition is "bottom".
206-// Avoids duplicate add/remove if already in the desired state.
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.
207205 func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) {
208- if mw.tabBarPosition != "bottom" || mw.contentArea == nil {
206+ if mw.contentArea == nil {
209207 return
210208 }
209+ class := mw.tabBarContentInsetClass()
211210 if visible {
212- if !mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") {
213- mw.contentArea.AddCssClass("content-area-tabbar-inset-bottom")
211+ if !mw.contentArea.HasCssClass(class) {
212+ mw.contentArea.AddCssClass(class)
214213 }
215214 } else {
216- if mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") {
217- mw.contentArea.RemoveCssClass("content-area-tabbar-inset-bottom")
215+ if mw.contentArea.HasCssClass(class) {
216+ mw.contentArea.RemoveCssClass(class)
218217 }
219218 }
220219 }
221220
222-// HasTabBarContentInset returns whether the bottom tab bar inset CSS class
223-// is currently applied to the content area.
221+// HasTabBarContentInset returns whether the tab bar inset CSS class is currently
222+// applied to the content area.
224223 func (mw *MainWindow) HasTabBarContentInset() bool {
225- if mw.tabBarPosition != "bottom" || mw.contentArea == nil {
224+ if mw.contentArea == nil {
226225 return false
227226 }
228- return mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom")
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"
229235 }
230236
231237 // AddOverlay adds a widget as an overlay above the content area.
@@ -117,9 +117,8 @@ func (mw *MainWindow) assembleLayout() {
117 mw.contentOverlay.AddOverlay(tabBarWidget)117 mw.contentOverlay.AddOverlay(tabBarWidget)
118 mw.contentOverlay.SetClipOverlay(tabBarWidget, false)118 mw.contentOverlay.SetClipOverlay(tabBarWidget, false)
119 // Keep the tab bar out of measurement so showing/hiding it never changes the119 // 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 for120+ // WebView content allocation; the content area reserves a stable inset while
121- // bottom-position bars while the overlay remains non-measured to avoid121+ // the overlay remains non-measured to avoid WebView allocation changes.
122- // WebView allocation changes.
123 mw.contentOverlay.SetMeasureOverlay(tabBarWidget, false)122 mw.contentOverlay.SetMeasureOverlay(tabBarWidget, false)
124 123
125 mw.logger.Debug().124 mw.logger.Debug().
@@ -201,31 +200,38 @@ func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
201 return mw.contentOverlay200 return mw.contentOverlay
202 }201 }
203 202
204-// SetTabBarContentInsetVisible adds or removes the bottom tab bar inset CSS class203+// SetTabBarContentInsetVisible adds or removes the tab bar inset CSS class
205-// on the content area. Only effective when tabBarPosition is "bottom".204+// on the content area. Avoids duplicate add/remove if already in the desired state.
206-// Avoids duplicate add/remove if already in the desired state.
207 func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) {205 func (mw *MainWindow) SetTabBarContentInsetVisible(visible bool) {
208- if mw.tabBarPosition != "bottom" || mw.contentArea == nil {206+ if mw.contentArea == nil {
209 return207 return
210 }208 }
209+ class := mw.tabBarContentInsetClass()
211 if visible {210 if visible {
212- if !mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") {211+ if !mw.contentArea.HasCssClass(class) {
213- mw.contentArea.AddCssClass("content-area-tabbar-inset-bottom")212+ mw.contentArea.AddCssClass(class)
214 }213 }
215 } else {214 } else {
216- if mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom") {215+ if mw.contentArea.HasCssClass(class) {
217- mw.contentArea.RemoveCssClass("content-area-tabbar-inset-bottom")216+ mw.contentArea.RemoveCssClass(class)
218 }217 }
219 }218 }
220 }219 }
221 220
222-// HasTabBarContentInset returns whether the bottom tab bar inset CSS class221+// HasTabBarContentInset returns whether the tab bar inset CSS class is currently
223-// is currently applied to the content area.222+// applied to the content area.
224 func (mw *MainWindow) HasTabBarContentInset() bool {223 func (mw *MainWindow) HasTabBarContentInset() bool {
225- if mw.tabBarPosition != "bottom" || mw.contentArea == nil {224+ if mw.contentArea == nil {
226 return false225 return false
227 }226 }
228- return mw.contentArea.HasCssClass("content-area-tabbar-inset-bottom")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"
229 }235 }
230 236
231 // AddOverlay adds a widget as an overlay above the content area.237 // AddOverlay adds a widget as an overlay above the content area.

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