detached 513db12

refactor(arch): step 1 clean architecture scaffold

- Remove old internal/, pkg/, tests/ directories
- Create clean architecture structure (domain/application/infrastructure/ui)
- Switch from gotk4 (CGO) to puregotk (pure Go) in go.mod
- Add zerolog for context-based structured logging
- Create cmd/dumber/main.go entry point
- Update Makefile for pure Go builds (no CGO)
commit
513db12ca935fc462a49b7f380a90ca5fb93a2b5
author
bnema · · 2025-12-09 20:58 UTC
parent
e2562b2
signature
unsigned
183 files · +175 -45300
M Makefile +40 -60
@@ -1,6 +1,6 @@
1-# Makefile for dumber
1+# Makefile for dumber (Clean Architecture - puregotk)
22
3-.PHONY: build build-frontend test lint clean install-tools dev generate help check init build-static build-no-gui
3+.PHONY: build build-frontend test lint clean install-tools dev generate help init
44
55 # Load local overrides from .env.local if present (Makefile syntax)
66 ifneq (,$(wildcard .env.local))
@@ -10,19 +10,12 @@ endif
1010
1111 # Variables
1212 BINARY_NAME=dumber
13-MAIN_PATH=.
13+MAIN_PATH=./cmd/dumber
1414 DIST_DIR=dist
1515
1616 # Detect number of CPU cores for parallel compilation
1717 NPROCS?=$(shell nproc 2>/dev/null || echo 1)
1818
19-# Local caches to avoid $HOME permission issues (override via .env.local)
20-# GOMODCACHE?=$(CURDIR)/tmp/go-mod
21-# GOCACHE?=$(CURDIR)/tmp/go-cache
22-# GOTMPDIR?=$(CURDIR)/tmp
23-# GOENV=GOMODCACHE=$(GOMODCACHE) GOCACHE=$(GOCACHE) GOTMPDIR=$(GOTMPDIR)
24-GOENV=
25-
2619 # Version information from git
2720 VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0-dev")
2821 COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown")
@@ -37,49 +30,32 @@ help: ## Show this help message
3730 @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
3831
3932 # Build targets
40-build: build-frontend ## Build the application with GUI (frontend assets, then Go binary with WebKitGTK)
41- @echo "Building $(BINARY_NAME) $(VERSION) with GUI using $(NPROCS) cores..."
42- @mkdir -p $(DIST_DIR) tmp tmp/go-cache tmp/go-mod
43- $(GOENV) CGO_ENABLED=1 go build -p $(NPROCS) $(LDFLAGS) -tags=webkit_cgo -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
44- @echo "✅ Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
33+build: build-frontend ## Build the application (pure Go, no CGO)
34+ @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
35+ @mkdir -p $(DIST_DIR)
36+ go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
37+ @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
4538
4639 build-frontend: ## Build Svelte GUI with Tailwind CSS and main-world script
47- @echo "Building Svelte GUI with Tailwind CSS and main-world script..."
40+ @echo "Building Svelte GUI with Tailwind CSS..."
4841 @cd gui && npm install --silent && npm run build
4942 @echo "GUI build complete"
5043
51-build-no-gui: build-frontend ## Build binary without GUI (CGO disabled, CLI-only functionality)
52- @echo "Building $(BINARY_NAME) $(VERSION) (CLI-only, no GUI) using $(NPROCS) cores..."
53- @mkdir -p $(DIST_DIR) tmp tmp/go-cache tmp/go-mod
54- $(GOENV) CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-no-gui $(MAIN_PATH)
55- @echo "✅ Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)-no-gui"
56-
57-build-static: build-no-gui ## Alias for build-no-gui (backward compatibility)
58- @echo "Note: build-static is deprecated, use build-no-gui instead"
59-
60-# GUI build with WebKitGTK 6.0 (GTK4)
61-.PHONY: build-gui run-gui
62-build-gui: build ## Alias for default build (backward compatibility)
63- @echo "Note: build-gui is now the default 'build' target"
64-
65-run-gui: ## Run the GUI with native WebKitGTK 6.0 (requires GTK4/WebKitGTK 6 dev packages)
66- @echo "Running GUI (webkit_cgo) using $(NPROCS) cores…"
67- @mkdir -p tmp tmp/go-cache tmp/go-mod
68- $(GOENV) CGO_ENABLED=1 go run -p $(NPROCS) -tags=webkit_cgo $(MAIN_PATH)
69-
70-.PHONY: check-webkit
71-check-webkit: ## Check system has GTK4/WebKitGTK 6.0/JavaScriptCore 6.0
72- @echo "Checking pkg-config versions..."
73- @which pkg-config >/dev/null 2>&1 || (echo "pkg-config not found" && exit 1)
74- @echo "gtk4: $$(pkg-config --modversion gtk4 2>/dev/null || echo not found)"
75- @echo "webkitgtk-6.0: $$(pkg-config --modversion webkitgtk-6.0 2>/dev/null || echo not found)"
76- @echo "javascriptcoregtk-6.0: $$(pkg-config --modversion javascriptcoregtk-6.0 2>/dev/null || echo not found)"
44+build-quick: ## Build without frontend (faster for backend development)
45+ @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."
46+ @mkdir -p $(DIST_DIR)
47+ go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
48+ @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
7749
7850 # Development targets
7951 dev: ## Run in development mode
8052 @echo "Running in development mode..."
8153 go run $(MAIN_PATH)
8254
55+run: build-quick ## Build and run
56+ @echo "Running $(BINARY_NAME)..."
57+ $(DIST_DIR)/$(BINARY_NAME)
58+
8359 generate: ## Generate code (SQLC)
8460 @echo "Generating code with SQLC..."
8561 sqlc generate
@@ -88,16 +64,20 @@ generate: ## Generate code (SQLC)
8864 # Testing
8965 test: ## Run tests
9066 @echo "Running tests..."
91- @mkdir -p tmp tmp/go-cache tmp/go-mod
92- $(GOENV) CGO_ENABLED=0 go test -v ./...
67+ go test -v ./...
9368
9469 test-race: ## Run tests with race detection
9570 @echo "Running tests with race detection..."
96- @mkdir -p tmp tmp/go-cache tmp/go-mod
97- $(GOENV) CGO_ENABLED=1 go test -race -v ./...
71+ go test -race -v ./...
72+
73+test-cover: ## Run tests with coverage
74+ @echo "Running tests with coverage..."
75+ go test -coverprofile=coverage.out ./...
76+ go tool cover -html=coverage.out -o coverage.html
77+ @echo "Coverage report: coverage.html"
9878
9979 # Linting
100-lint: ## Run golangci-lint (excludes pkg/webkit CGO files via config)
80+lint: ## Run golangci-lint
10181 @echo "Running golangci-lint..."
10282 golangci-lint run
10383
@@ -107,7 +87,7 @@ lint-fix: ## Run golangci-lint with --fix
10787
10888 # Format code
10989 fmt: ## Format Go code with gofmt
110- @echo "Formatting Go code with gofmt..."
90+ @echo "Formatting Go code..."
11191 go fmt ./...
11292
11393 # Tools installation
@@ -121,41 +101,41 @@ install-tools: ## Install development tools
121101 clean: ## Clean build artifacts
122102 @echo "Cleaning build artifacts..."
123103 rm -rf $(DIST_DIR)
124- rm -f $(BINARY_NAME) # Remove any old binaries in root
104+ rm -f $(BINARY_NAME)
125105 rm -rf gui/dist gui/node_modules
126106 rm -f assets/gui/gui.min.js assets/gui/homepage.min.js assets/gui/homepage.css assets/gui/main-world.min.js assets/gui/color-scheme.js
107+ rm -f coverage.out coverage.html
127108 go clean -cache
128109 go clean -testcache
129110
130111 # Project initialization
131112 init: install-tools ## Initialize project dependencies and tools
132- @echo "Project dependencies and tools installed!"
133- @echo "Ready for development. Run 'make help' for available commands."
113+ @echo "Initializing project..."
114+ go mod tidy
115+ @echo "Project initialized! Run 'make help' for available commands."
134116
135117 # Check setup
136118 check: ## Check that all tools and dependencies are working
137119 @echo "Checking project setup..."
138120 @echo "Go version:"
139- @$(GOENV) go version
121+ @go version
140122 @echo "\nSQLC version:"
141- @$(GOENV) sqlc version
123+ @sqlc version
142124 @echo "\nGolangci-lint version:"
143- @$(GOENV) golangci-lint version
125+ @golangci-lint version
144126 @echo "\nBuilding project..."
145- @$(MAKE) build
146- @echo "\nTesting built binary..."
147- @$(DIST_DIR)/$(BINARY_NAME) version
127+ @$(MAKE) build-quick
148128 @echo "\nRunning tests..."
149129 @$(MAKE) test
150- @echo "\n✅ All checks passed! Project is ready for development."
130+ @echo "\nAll checks passed!"
151131
152132 # Native release targets
153133 .PHONY: release-snapshot release
154134
155-release-snapshot: build-frontend ## Build snapshot using native goreleaser (no git tags required)
135+release-snapshot: build-frontend ## Build snapshot using goreleaser
156136 @echo "Building snapshot with goreleaser..."
157137 goreleaser release --snapshot --clean
158138
159-release: ## Create full release (amd64 only) using native goreleaser
139+release: ## Create full release using goreleaser
160140 @echo "Building release with goreleaser..."
161141 GITHUB_TOKEN=$$(gh auth token) goreleaser release --clean
M go.mod +6 -43
@@ -2,52 +2,15 @@ module github.com/bnema/dumber
22
33 go 1.25.3
44
5-require (
6- github.com/diamondburned/gotk4-webkitgtk/pkg v0.0.0-20240108031600-dee1973cf440
7- github.com/diamondburned/gotk4/pkg v0.3.1
8- github.com/fsnotify/fsnotify v1.9.0
9- github.com/golang/mock v1.6.0
10- github.com/invopop/jsonschema v0.13.0
11- github.com/ncruces/go-sqlite3 v0.29.0
12- github.com/spf13/cobra v1.10.1
13- github.com/spf13/viper v1.21.0
14- github.com/stretchr/testify v1.11.1
15- go.uber.org/mock v0.6.0
16- golang.org/x/sync v0.17.0
17- golang.org/x/sys v0.36.0
18-)
5+require github.com/rs/zerolog v1.33.0
196
207 require (
21- github.com/KarpelesLab/weak v0.1.1 // indirect
22- github.com/bahlo/generic-list-go v0.2.0 // indirect
23- github.com/buger/jsonparser v1.1.1 // indirect
24- github.com/davecgh/go-spew v1.1.1 // indirect
25- github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
26- github.com/google/go-cmp v0.7.0 // indirect
27- github.com/inconshreveable/mousetrap v1.1.0 // indirect
28- github.com/mailru/easyjson v0.7.7 // indirect
29- github.com/ncruces/julianday v1.0.0 // indirect
30- github.com/pelletier/go-toml/v2 v2.2.4 // indirect
31- github.com/pmezard/go-difflib v1.0.0 // indirect
32- github.com/rogpeppe/go-internal v1.12.0 // indirect
33- github.com/sagikazarmark/locafero v0.11.0 // indirect
34- github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
35- github.com/spf13/afero v1.15.0 // indirect
36- github.com/spf13/cast v1.10.0 // indirect
37- github.com/spf13/pflag v1.0.10 // indirect
38- github.com/subosito/gotenv v1.6.0 // indirect
39- github.com/tetratelabs/wazero v1.9.0 // indirect
40- github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
41- go.yaml.in/yaml/v3 v3.0.4 // indirect
42- go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect
43- golang.org/x/text v0.29.0 // indirect
44- gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
45- gopkg.in/yaml.v3 v3.0.1 // indirect
8+ github.com/mattn/go-colorable v0.1.13 // indirect
9+ github.com/mattn/go-isatty v0.0.19 // indirect
10+ golang.org/x/sys v0.36.0 // indirect
4611 )
4712
4813 replace (
49- github.com/diamondburned/gotk4 => github.com/bnema/gotk4 v0.3.4-0.20251017144723-f765b03fd751
50- github.com/diamondburned/gotk4-webkitgtk => github.com/bnema/gotk4-webkitgtk v0.1.2-0.20251017152300-b3012dbe285f
51- github.com/diamondburned/gotk4-webkitgtk/pkg => github.com/bnema/gotk4-webkitgtk/pkg v0.1.2-0.20251017152300-b3012dbe285f
52- github.com/diamondburned/gotk4/pkg => github.com/bnema/gotk4/pkg v0.3.4-0.20251017144723-f765b03fd751
14+ github.com/bnema/puregotk-webkit => /home/brice/projects/puregotk-webkit
15+ github.com/jwijenbergh/puregotk => /home/brice/projects/puregotk
5316 )
M go.sum +14 -109 generated file: collapsed
D internal/app/api/scheme.go +0 -238
@@ -1,238 +0,0 @@
1-package api
2-
3-import (
4- "embed"
5- "fmt"
6- "mime"
7- neturl "net/url"
8- "os"
9- "path/filepath"
10- "strings"
11-
12- "github.com/bnema/dumber/internal/app/constants"
13- "github.com/bnema/dumber/internal/config"
14- "github.com/bnema/dumber/internal/logging"
15- "github.com/bnema/dumber/internal/services"
16- webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
17- gio "github.com/diamondburned/gotk4/pkg/gio/v2"
18- glib "github.com/diamondburned/gotk4/pkg/glib/v2"
19-)
20-
21-// SchemeHandler handles custom dumb:// scheme resolution
22-type SchemeHandler struct {
23- assets embed.FS
24- parserService *services.ParserService
25- browserService *services.BrowserService
26- cfg *config.Config
27-}
28-
29-// NewSchemeHandler creates a new scheme handler
30-func NewSchemeHandler(
31- assets embed.FS,
32- parserService *services.ParserService,
33- browserService *services.BrowserService,
34-) *SchemeHandler {
35- return &SchemeHandler{
36- assets: assets,
37- parserService: parserService,
38- browserService: browserService,
39- }
40-}
41-
42-// SetConfig sets the configuration for the scheme handler
43-func (s *SchemeHandler) SetConfig(cfg *config.Config) {
44- s.cfg = cfg
45-}
46-
47-// Handle processes dumb:// scheme requests using the new URISchemeRequest API
48-func (s *SchemeHandler) Handle(req *webkit.URISchemeRequest) {
49- uri := req.URI()
50- logging.Debug(fmt.Sprintf("[scheme] request: %s", uri))
51-
52- // Known forms:
53- // - dumb://homepage or dumb:homepage → index.html
54- // - dumb://app/index.html, dumb://app/<path> → serve from gui/<path>
55- // - dumb://favicon/<hash>.png → serve cached favicon
56- // - dumb://<anything> without path → index.html
57- u, err := neturl.Parse(uri)
58- if err != nil || u.Scheme != "dumb" {
59- req.FinishError(fmt.Errorf("invalid URI: %s", uri))
60- return
61- }
62-
63- // Check if this is a favicon request
64- if u.Host == "favicon" || (u.Host == "" && strings.HasPrefix(u.Path, "/favicon/")) {
65- s.handleFavicon(req, u)
66- return
67- }
68-
69- // Static assets
70- s.handleAsset(req, u)
71-}
72-
73-// handleAsset serves static assets from embedded filesystem
74-func (s *SchemeHandler) handleAsset(req *webkit.URISchemeRequest, u *neturl.URL) {
75- // Resolve target path inside embed FS
76- var rel string
77- if u.Opaque == constants.HomepagePath || (u.Host == constants.HomepagePath && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) {
78- rel = constants.IndexHTML
79- } else if u.Host == constants.BlockedPath && (u.Path == "" || u.Path == "/") {
80- // dumb://blocked → blocked.html
81- rel = constants.BlockedHTML
82- } else {
83- host := u.Host
84- p := strings.TrimPrefix(u.Path, "/")
85- switch {
86- case host == "app" && p == "":
87- rel = constants.IndexHTML
88- case host == "app":
89- rel = p
90- case host == constants.HomepagePath && p != "":
91- // dumb://homepage/<asset>
92- rel = p
93- case host == constants.BlockedPath && p != "":
94- // dumb://blocked/<asset>
95- rel = p
96- case p != "":
97- rel = p
98- default:
99- rel = constants.IndexHTML
100- }
101- }
102-
103- // Special-case homepage favicon: map .ico request to embedded PNG file
104- if (u.Host == constants.HomepagePath || u.Opaque == constants.HomepagePath) && strings.EqualFold(rel, "favicon.ico") {
105- logging.Debug(fmt.Sprintf("[scheme] asset: rel=%s (host=%s path=%s) → mapping to favicon.png", rel, u.Host, u.Path))
106- data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("assets", "gui", "favicon.png")))
107- if rerr == nil {
108- s.finishRequest(req, "image/png", data, "favicon.png")
109- return
110- }
111- }
112-
113- logging.Debug(fmt.Sprintf("[scheme] asset: rel=%s (host=%s path=%s)", rel, u.Host, u.Path))
114-
115- // Try to read the requested asset
116- data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("assets", "gui", rel)))
117- if rerr != nil {
118- logging.Debug(fmt.Sprintf("[scheme] not found: %s", rel))
119- req.FinishError(fmt.Errorf("asset not found: %s", rel))
120- return
121- }
122-
123- // Determine mime type
124- mt := s.getMimeType(rel)
125-
126- // Finish the request with the data
127- logging.Debug(fmt.Sprintf("[scheme] serving %s with mime-type: %s", rel, mt))
128- s.finishRequest(req, mt, data, rel)
129-}
130-
131-// finishRequest completes a URI scheme request with the provided data
132-func (s *SchemeHandler) finishRequest(req *webkit.URISchemeRequest, mimeType string, data []byte, filename string) {
133- // Convert byte slice to GLib Bytes
134- gbytes := glib.NewBytes(data)
135-
136- // Create an input stream from the bytes
137- stream := gio.NewMemoryInputStreamFromBytes(gbytes)
138- if stream == nil {
139- req.FinishError(fmt.Errorf("failed to create input stream for: %s", filename))
140- logging.Error(fmt.Sprintf("[scheme] failed to create stream for: %s", filename))
141- return
142- }
143-
144- // Finish the request with the stream
145- contentLength := int64(len(data))
146- req.Finish(stream, contentLength, mimeType)
147-}
148-
149-// getMimeType determines the MIME type for a given file path
150-func (s *SchemeHandler) getMimeType(filename string) string {
151- ext := strings.ToLower(filepath.Ext(filename))
152-
153- // Try standard mime type first
154- mt := mime.TypeByExtension(ext)
155- if mt != "" {
156- return mt
157- }
158-
159- // Fallbacks for common web assets
160- switch ext {
161- case ".js":
162- return "application/javascript"
163- case ".mjs":
164- return "application/javascript"
165- case ".css":
166- return "text/css"
167- case ".svg":
168- return "image/svg+xml"
169- case ".ico":
170- return "image/x-icon"
171- case ".woff":
172- return "font/woff"
173- case ".woff2":
174- return "font/woff2"
175- case ".ttf":
176- return "font/ttf"
177- case ".otf":
178- return "font/otf"
179- case ".eot":
180- return "application/vnd.ms-fontobject"
181- case ".png":
182- return "image/png"
183- case ".jpg", ".jpeg":
184- return "image/jpeg"
185- case ".gif":
186- return "image/gif"
187- case ".webp":
188- return "image/webp"
189- case ".json":
190- return "application/json"
191- case ".xml":
192- return "application/xml"
193- case ".html", ".htm":
194- return "text/html; charset=utf-8"
195- default:
196- // Default to text/plain for unknown extensions
197- return "text/plain"
198- }
199-}
200-
201-// handleFavicon serves cached favicon files
202-func (s *SchemeHandler) handleFavicon(req *webkit.URISchemeRequest, u *neturl.URL) {
203- // Extract the filename from the URL
204- // URL format: dumb://favicon/<hash>.png
205- var filename string
206- if u.Host == "favicon" {
207- filename = strings.TrimPrefix(u.Path, "/")
208- } else {
209- filename = strings.TrimPrefix(u.Path, "/favicon/")
210- }
211-
212- if filename == "" {
213- logging.Debug(fmt.Sprintf("[scheme] favicon: empty filename"))
214- req.FinishError(fmt.Errorf("invalid favicon path"))
215- return
216- }
217-
218- // Get the favicon cache directory path
219- dataDir, err := config.GetDataDir()
220- if err != nil {
221- logging.Error(fmt.Sprintf("[scheme] favicon: failed to get data directory: %v", err))
222- req.FinishError(fmt.Errorf("failed to get data directory"))
223- return
224- }
225-
226- faviconPath := filepath.Join(dataDir, "favicons", filename)
227-
228- // Read the favicon file
229- data, err := os.ReadFile(faviconPath)
230- if err != nil {
231- logging.Debug(fmt.Sprintf("[scheme] favicon: file not found: %s", faviconPath))
232- req.FinishError(fmt.Errorf("favicon not found: %s", filename))
233- return
234- }
235-
236- logging.Debug(fmt.Sprintf("[scheme] favicon: serving %s (%d bytes)", filename, len(data)))
237- s.finishRequest(req, "image/png", data, filename)
238-}
D internal/app/browser/borders_overlay.go +0 -95
@@ -1,95 +0,0 @@
1-package browser
2-
3-import (
4- "github.com/bnema/dumber/internal/logging"
5- "github.com/bnema/dumber/pkg/webkit"
6- "github.com/diamondburned/gotk4/pkg/gtk/v4"
7-)
8-
9-// createBorderOverlay builds a transparent overlay widget that displays as a border when visible.
10-// This allows showing visual borders without affecting layout or causing content shifts.
11-func (tm *TabManager) createBorderOverlay(cssClass string) gtk.Widgetter {
12- box := gtk.NewBox(gtk.OrientationVertical, 0)
13- if box == nil {
14- logging.Error("[tabs] Failed to create border overlay for " + cssClass)
15- return nil
16- }
17-
18- box.AddCSSClass(cssClass)
19- box.SetHExpand(true)
20- box.SetVExpand(true)
21- box.SetCanTarget(false) // Don't intercept mouse events - overlay is visual only
22-
23- return box
24-}
25-
26-// wrapPaneInOverlay wraps a pane container widget in a gtk.Overlay with a border overlay.
27-// This allows showing active pane borders without affecting layout or causing content shifts.
28-// Returns the overlay (new container) and the border widget (to store in paneNode.borderOverlay).
29-func wrapPaneInOverlay(paneContainer gtk.Widgetter) (*gtk.Overlay, gtk.Widgetter) {
30- // Create the overlay container
31- overlay := gtk.NewOverlay()
32- if overlay == nil {
33- logging.Error("[workspace] Failed to create overlay for pane border")
34- return nil, nil
35- }
36- overlay.SetHExpand(true)
37- overlay.SetVExpand(true)
38-
39- // Set the pane container as the main child
40- overlay.SetChild(paneContainer)
41-
42- // Create the border overlay widget (transparent box with border CSS)
43- borderBox := gtk.NewBox(gtk.OrientationVertical, 0)
44- if borderBox == nil {
45- logging.Error("[workspace] Failed to create border overlay box")
46- return overlay, nil
47- }
48-
49- borderBox.AddCSSClass("pane-border-overlay")
50- borderBox.SetHExpand(true)
51- borderBox.SetVExpand(true)
52- borderBox.SetCanTarget(false) // Don't intercept mouse events - overlay is visual only
53-
54- // Add the border overlay to the overlay (hidden by default)
55- overlay.AddOverlay(borderBox)
56- webkit.WidgetSetVisible(borderBox, false)
57-
58- return overlay, borderBox
59-}
60-
61-// showPaneModeBorder displays the pane mode border overlay.
62-func (tm *TabManager) showPaneModeBorder() {
63- if tm.paneModeOverlay == nil {
64- return
65- }
66- webkit.WidgetSetVisible(tm.paneModeOverlay, true)
67- logging.Debug("[pane-mode] Showing pane mode border overlay")
68-}
69-
70-// hidePaneModeBorder hides the pane mode border overlay.
71-func (tm *TabManager) hidePaneModeBorder() {
72- if tm.paneModeOverlay == nil {
73- return
74- }
75- webkit.WidgetSetVisible(tm.paneModeOverlay, false)
76- logging.Debug("[pane-mode] Hiding pane mode border overlay")
77-}
78-
79-// showTabModeBorder displays the tab mode border overlay.
80-func (tm *TabManager) showTabModeBorder() {
81- if tm.tabModeOverlay == nil {
82- return
83- }
84- webkit.WidgetSetVisible(tm.tabModeOverlay, true)
85- logging.Debug("[tab-mode] Showing tab mode border overlay")
86-}
87-
88-// hideTabModeBorder hides the tab mode border overlay.
89-func (tm *TabManager) hideTabModeBorder() {
90- if tm.tabModeOverlay == nil {
91- return
92- }
93- webkit.WidgetSetVisible(tm.tabModeOverlay, false)
94- logging.Debug("[tab-mode] Hiding tab mode border overlay")
95-}
D internal/app/browser/browser.go +0 -463
@@ -1,463 +0,0 @@
1-package browser
2-
3-import (
4- "context"
5- "database/sql"
6- "embed"
7- "fmt"
8- "os"
9- "os/signal"
10- "runtime"
11- "sync"
12- "syscall"
13- "time"
14-
15- "github.com/bnema/dumber/internal/app/api"
16- "github.com/bnema/dumber/internal/app/control"
17- "github.com/bnema/dumber/internal/app/environment"
18- "github.com/bnema/dumber/internal/app/messaging"
19- "github.com/bnema/dumber/internal/config"
20- "github.com/bnema/dumber/internal/db"
21- "github.com/bnema/dumber/internal/filtering"
22- "github.com/bnema/dumber/internal/logging"
23- "github.com/bnema/dumber/internal/services"
24- "github.com/bnema/dumber/pkg/webkit"
25- "golang.org/x/sync/errgroup"
26-)
27-
28-// BrowserApp represents the main browser application
29-type BrowserApp struct {
30- version string
31- commit string
32- buildDate string
33- assets embed.FS
34-
35- // Core components
36- config *config.Config
37- database *sql.DB
38- queries *db.Queries
39-
40- // Services
41- parserService *services.ParserService
42- browserService *services.BrowserService
43- faviconService *services.FaviconService
44-
45- // WebView and controllers
46- webView *webkit.WebView
47- zoomController *control.ZoomController
48- navigationController *control.NavigationController
49- clipboardController *control.ClipboardController
50-
51- // Tab and workspace management
52- tabManager *TabManager
53-
54- // Convenience accessors (delegate to active tab's workspace)
55- // These maintain compatibility with existing code
56- panes []*BrowserPane
57- activePane *BrowserPane
58- workspace *WorkspaceManager
59-
60- // Content filtering
61- filterManager *filtering.FilterManager
62- contentBlockingService *filtering.ContentBlockingService
63- bypassRegistry *filtering.BypassRegistry
64- pendingFilteringMu sync.Mutex
65- pendingFiltering []*webkit.WebView
66-
67- // Handlers
68- schemeHandler *api.SchemeHandler
69- messageHandler *messaging.Handler
70- shortcutHandler *ShortcutHandler
71- windowShortcutHandler WindowShortcutHandlerInterface
72-
73- // Performance optimization components
74- webViewPool *WebViewPool // Pre-created WebViews for instant tab/pane creation
75- prefetchManager *PrefetchManager // DNS prefetching for faster page loads
76-}
77-
78-// Run starts the browser application
79-func Run(assets embed.FS, version, commit, buildDate string) {
80- startupStart := time.Now()
81- logging.Info(fmt.Sprintf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()))
82-
83- app := &BrowserApp{
84- version: version,
85- commit: commit,
86- buildDate: buildDate,
87- assets: assets,
88- }
89-
90- if err := app.Initialize(); err != nil {
91- logging.Error(fmt.Sprintf("Failed to initialize browser: %v", err))
92- if webkit.IsNativeAvailable() {
93- runtime.UnlockOSThread()
94- }
95- os.Exit(1)
96- }
97-
98- startupElapsed := time.Since(startupStart)
99- logging.Info(fmt.Sprintf("[startup] Application initialized in %v", startupElapsed))
100- if startupElapsed > 500*time.Millisecond {
101- logging.Warn(fmt.Sprintf("[startup] WARNING: Startup took %v (target: <500ms)", startupElapsed))
102- }
103-
104- app.Run()
105-}
106-
107-// Initialize sets up all browser components
108-func (app *BrowserApp) Initialize() error {
109- // Setup media pipeline hardening
110- environment.SetupMediaPipeline()
111-
112- // Initialize configuration
113- if err := config.Init(); err != nil {
114- return err
115- }
116- app.config = config.Get()
117-
118- // Apply environment configurations
119- environment.ApplyCodecConfiguration(app.config.CodecPreferences)
120-
121- // Setup crash handler
122- logging.SetupCrashHandler()
123-
124- // Setup output capture if configured
125- if err := app.setupOutputCapture(); err != nil {
126- logging.Warn(fmt.Sprintf("Warning: failed to setup output capture: %v", err))
127- }
128-
129- // Initialize WebKit log capture if configured
130- if app.config.Logging.CaptureCOutput {
131- if err := webkit.InitWebKitLogCapture(); err != nil {
132- logging.Warn(fmt.Sprintf("Warning: failed to initialize WebKit log capture: %v", err))
133- } else {
134- defer webkit.StopWebKitLogCapture()
135- webkit.StartWebKitOutputCapture()
136- }
137- }
138-
139- logging.Info(fmt.Sprintf("Config initialized"))
140-
141- // Detect keyboard layout in background (required for shortcuts)
142- go environment.DetectAndSetKeyboardLocale()
143-
144- // Initialize database (migrations run automatically in InitDB)
145- database, err := db.InitDB(app.config.Database.Path)
146- if err != nil {
147- return err
148- }
149- app.database = database
150- app.queries = db.New(database)
151- logging.Info(fmt.Sprintf("Database opened at %s", app.config.Database.Path))
152-
153- // DEFERRED: Cleanup expired certificate validations in background
154- go func() {
155- time.Sleep(500 * time.Millisecond) // Low priority maintenance task
156- if err := webkit.CleanupExpiredCertificateValidations(); err != nil {
157- logging.Warn(fmt.Sprintf("Warning: failed to cleanup expired certificate validations: %v", err))
158- }
159- }()
160-
161- // Initialize services
162- app.parserService = services.NewParserService(app.config, app.queries)
163- app.browserService = services.NewBrowserService(app.config, app.queries)
164-
165- // Load all caches in parallel for fast startup (target: <100ms)
166- if err := app.loadCachesParallel(context.Background()); err != nil {
167- logging.Warn(fmt.Sprintf("Warning: failed to load caches: %v", err))
168- // Non-fatal - caches will fall back to defaults or DB queries
169- }
170-
171- // Initialize handlers
172- app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService)
173- app.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
174-
175- // Initialize prefetch manager for DNS preloading
176- app.prefetchManager = NewPrefetchManager(DefaultPrefetchConfig(), app.queries)
177-
178- // Initialize WebView pool for instant tab/pane creation
179- // Pool starts warming up after main window shows (see Run method)
180- app.webViewPool = NewWebViewPool(2, 4, app.getWebViewConfig)
181-
182- return nil
183-}
184-
185-// getWebViewConfig returns the configuration for creating pooled WebViews
186-func (app *BrowserApp) getWebViewConfig() *webkit.Config {
187- dataDir, _ := config.GetDataDir()
188- cacheDir, _ := config.GetFilterCacheDir()
189-
190- return &webkit.Config{
191- EnableJavaScript: true,
192- EnableWebGL: true,
193- DefaultFontSize: app.config.Appearance.DefaultFontSize,
194- MinimumFontSize: 8, // Reasonable default
195- UserAgent: app.config.CodecPreferences.CustomUserAgent,
196- DataDir: dataDir,
197- CacheDir: cacheDir,
198- EnablePageCache: true,
199- EnableSmoothScrolling: true,
200- CreateWindow: false, // Pool WebViews don't create their own window
201- }
202-}
203-
204-// GetWebViewPool returns the WebView pool for creating new tabs/panes.
205-// Returns nil if pool is not initialized.
206-func (app *BrowserApp) GetWebViewPool() *WebViewPool {
207- return app.webViewPool
208-}
209-
210-// GetPrefetchManager returns the prefetch manager for DNS preloading.
211-// Returns nil if prefetch manager is not initialized.
212-func (app *BrowserApp) GetPrefetchManager() *PrefetchManager {
213- return app.prefetchManager
214-}
215-
216-// loadCachesParallel loads all caches in parallel for fast startup.
217-// Uses errgroup to coordinate parallel loads and capture any errors.
218-// Target: <100ms for all caches combined.
219-func (app *BrowserApp) loadCachesParallel(ctx context.Context) error {
220- startTime := time.Now()
221-
222- g, ctx := errgroup.WithContext(ctx)
223-
224- // Load zoom cache in parallel
225- g.Go(func() error {
226- if err := app.browserService.LoadZoomCacheFromDB(ctx); err != nil {
227- logging.Error(fmt.Sprintf("[cache] Failed to load zoom cache: %v", err))
228- return err
229- }
230- return nil
231- })
232-
233- // Load certificate validation cache in parallel
234- g.Go(func() error {
235- if err := app.browserService.LoadCertCacheFromDB(ctx); err != nil {
236- logging.Error(fmt.Sprintf("[cache] Failed to load cert cache: %v", err))
237- return err
238- }
239- return nil
240- })
241-
242- // Load favorites cache in parallel
243- g.Go(func() error {
244- if err := app.browserService.LoadFavoritesCacheFromDB(ctx); err != nil {
245- logging.Error(fmt.Sprintf("[cache] Failed to load favorites cache: %v", err))
246- return err
247- }
248- return nil
249- })
250-
251- // Load fuzzy search cache in parallel for instant dmenu access
252- g.Go(func() error {
253- if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil {
254- logging.Error(fmt.Sprintf("[cache] Failed to load fuzzy cache: %v", err))
255- return err
256- }
257- return nil
258- })
259-
260- // Wait for all cache loads to complete
261- if err := g.Wait(); err != nil {
262- return fmt.Errorf("cache loading failed: %w", err)
263- }
264-
265- elapsed := time.Since(startTime)
266- logging.Info(fmt.Sprintf("[cache] All caches loaded in %v", elapsed))
267-
268- // Warn if startup is slower than target
269- if elapsed > 100*time.Millisecond {
270- logging.Warn(fmt.Sprintf("[cache] Warning: Cache loading took %v (target: <100ms)", elapsed))
271- }
272-
273- return nil
274-}
275-
276-// Run executes the main browser loop
277-func (app *BrowserApp) Run() {
278- defer app.cleanup()
279- defer logging.SetupPanicRecovery()
280-
281- // Set config on scheme handler
282- app.schemeHandler.SetConfig(app.config)
283-
284- // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation)
285- webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle)
286-
287- // Create and setup WebView (this also creates TabManager which creates more WebViews)
288- if err := app.createWebView(); err != nil {
289- logging.Warn(fmt.Sprintf("Warning: failed to create WebView: %v", err))
290- return
291- }
292-
293- // Apply URI scheme handlers after WebView creation
294- if err := webkit.ApplyURISchemeHandlers(app.webView.GetWebView()); err != nil {
295- logging.Warn(fmt.Sprintf("Warning: failed to register URI scheme handlers: %v", err))
296- }
297-
298- // POST-VISIBLE: Start WebView pool warmup for instant tab/pane creation
299- if app.webViewPool != nil {
300- app.webViewPool.Start()
301- }
302-
303- // POST-VISIBLE: Start DNS prefetching from history and common domains
304- if app.prefetchManager != nil {
305- app.prefetchManager.PrefetchCommonDomains()
306- app.prefetchManager.PrefetchFromHistory(context.Background())
307- }
308-
309- // Handle browse command if present (must use active tab's navigation controller)
310- if app.tabManager != nil {
311- activeTab := app.tabManager.GetActiveTab()
312- if activeTab != nil && activeTab.workspace != nil {
313- activePane := activeTab.workspace.GetActivePane()
314- if activePane != nil && activePane.navigationController != nil {
315- activePane.navigationController.HandleBrowseCommand()
316- }
317- }
318- } else {
319- // Fallback to legacy behavior if no tab manager
320- app.navigationController.HandleBrowseCommand()
321- }
322-
323- // Setup signal handling
324- app.setupSignalHandling()
325-
326- // Run main loop
327- app.runMainLoop()
328-}
329-
330-// cleanup handles cleanup on shutdown
331-func (app *BrowserApp) cleanup() {
332- logging.Info(fmt.Sprintf("Starting browser cleanup..."))
333-
334- // Stop WebView pool first (destroys pre-created WebViews)
335- if app.webViewPool != nil {
336- logging.Info(fmt.Sprintf("Stopping WebView pool"))
337- app.webViewPool.Stop()
338- app.webViewPool = nil
339- }
340-
341- // Cleanup window shortcuts first
342- if app.windowShortcutHandler != nil {
343- logging.Info(fmt.Sprintf("Cleaning up window shortcuts"))
344- app.windowShortcutHandler.Cleanup()
345- app.windowShortcutHandler = nil
346- }
347-
348- // Cleanup tab manager (which cleans up all tabs and their workspaces)
349- if app.tabManager != nil {
350- logging.Info(fmt.Sprintf("Cleaning up tab manager"))
351- app.tabManager.Cleanup()
352- app.tabManager = nil
353- }
354-
355- // Flush all caches to ensure pending writes complete before database closes
356- if app.browserService != nil {
357- logging.Info(fmt.Sprintf("Flushing all caches..."))
358- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
359- defer cancel()
360- if err := app.browserService.FlushAllCaches(ctx); err != nil {
361- logging.Warn(fmt.Sprintf("Warning: failed to flush caches: %v", err))
362- }
363- }
364-
365- // Close database with WAL checkpoint
366- if app.database != nil {
367- logging.Info(fmt.Sprintf("Performing WAL checkpoint and closing database..."))
368-
369- // Run WAL checkpoint to commit all pending writes and truncate WAL file
370- if _, err := app.database.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil {
371- logging.Warn(fmt.Sprintf("Warning: WAL checkpoint failed: %v", err))
372- } else {
373- logging.Info(fmt.Sprintf("WAL checkpoint completed successfully"))
374- }
375-
376- // Close database connection
377- if closeErr := app.database.Close(); closeErr != nil {
378- logging.Warn(fmt.Sprintf("Warning: failed to close database: %v", closeErr))
379- } else {
380- logging.Info(fmt.Sprintf("Database closed successfully"))
381- }
382- }
383-
384- logging.Info(fmt.Sprintf("Browser cleanup completed"))
385-}
386-
387-// setupOutputCapture initializes stdout/stderr capture if configured
388-func (app *BrowserApp) setupOutputCapture() error {
389- if app.config.Logging.CaptureStdout || app.config.Logging.CaptureStderr {
390- // Capturing stdout/stderr works for non-GTK builds, but in native GTK mode
391- // it interferes with WebKit's own pipe management and crashes immediately.
392- if webkit.IsNativeAvailable() {
393- logging.Warn(fmt.Sprintf("Warning: stdout/stderr capture is not supported in native GTK mode; skipping"))
394- return nil
395- }
396-
397- logging.Warn(fmt.Sprintf("Warning: stdout/stderr capture is experimental and may interfere with normal operations"))
398- if logger := logging.GetLogger(); logger != nil {
399- outputCapture := logging.NewOutputCapture(logger)
400- if err := outputCapture.Start(); err != nil {
401- return err
402- }
403- // Note: defer outputCapture.Stop() should be handled by the caller
404- }
405- }
406- return nil
407-}
408-
409-// setupSignalHandling configures graceful shutdown on signals
410-func (app *BrowserApp) setupSignalHandling() {
411- sigChan := make(chan os.Signal, 1)
412- signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
413-
414- // Handle signals in a goroutine to quit the main loop
415- go func() {
416- sig := <-sigChan
417- logging.Info(fmt.Sprintf("Received signal %v - shutting down gracefully", sig))
418- webkit.QuitMainLoop()
419- }()
420-}
421-
422-// runMainLoop starts the appropriate main loop based on WebKit availability
423-func (app *BrowserApp) runMainLoop() {
424- if webkit.IsNativeAvailable() {
425- if os.Getenv("DUMBER_DISABLE_CONTENT_BLOCKING") != "1" {
426- // Schedule content blocking initialization to run on the first main loop iteration
427- // This ensures GTK is fully ready before we start loading filters
428- webkit.RunOnMainThread(func() {
429- if err := app.setupContentBlocking(); err != nil {
430- logging.Warn(fmt.Sprintf("Warning: failed to setup content blocking: %v", err))
431- }
432- })
433- } else {
434- logging.Warn("Content blocking skipped (DUMBER_DISABLE_CONTENT_BLOCKING=1)")
435- }
436-
437- logging.Info(fmt.Sprintf("Entering GTK main loop…"))
438- logging.Debug("[browser] Calling webkit.RunMainLoop()")
439- webkit.RunMainLoop()
440- logging.Info(fmt.Sprintf("GTK main loop exited"))
441-
442- // Flush pending history writes immediately after main loop exit
443- // This ensures database operations complete while GTK is still in a valid state
444- // MUST happen before cleanup() deferred call, which happens after this function returns
445- if app.browserService != nil {
446- logging.Info(fmt.Sprintf("Flushing pending history writes..."))
447- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
448- defer cancel()
449- if err := app.browserService.FlushHistoryQueue(ctx); err != nil {
450- logging.Warn(fmt.Sprintf("Warning: history queue flush incomplete: %v", err))
451- } else {
452- logging.Info(fmt.Sprintf("History queue flushed successfully"))
453- }
454- }
455- } else {
456- logging.Info(fmt.Sprintf("Not entering GUI loop (non-CGO build)"))
457- // In non-CGO mode, just wait for signals
458- sigChan := make(chan os.Signal, 1)
459- signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
460- sig := <-sigChan
461- logging.Info(fmt.Sprintf("Received signal %v - exiting", sig))
462- }
463-}
D internal/app/browser/constants.go +0 -19
@@ -1,19 +0,0 @@
1-package browser
2-
3-// Direction constants for pane navigation and splitting
4-const (
5- DirectionLeft = "left"
6- DirectionRight = "right"
7- DirectionUp = "up"
8- DirectionDown = "down"
9-)
10-
11-// UI string constants
12-const (
13- NewTabTitle = "New Tab"
14-)
15-
16-// Theme constants
17-const (
18- ThemeDefault = "default"
19-)
D internal/app/browser/deferred_init.go +0 -204
@@ -1,204 +0,0 @@
1-// Package browser provides the main browser application components.
2-package browser
3-
4-import (
5- "context"
6- "fmt"
7- "sync"
8- "sync/atomic"
9- "time"
10-
11- "github.com/bnema/dumber/internal/logging"
12-)
13-
14-// DeferredState represents the state of a deferred initialization
15-type DeferredState int32
16-
17-const (
18- DeferredStatePending DeferredState = iota
19- DeferredStateInitializing
20- DeferredStateReady
21- DeferredStateFailed
22-)
23-
24-// DeferredInit manages lazy initialization of optional services.
25-// Services are only initialized when first accessed, reducing boot time.
26-type DeferredInit[T any] struct {
27- name string
28- initFunc func(ctx context.Context) (T, error)
29- timeout time.Duration
30-
31- mu sync.RWMutex
32- state int32 // atomic: DeferredState
33- value T
34- err error
35- initOnce sync.Once
36-}
37-
38-// NewDeferredInit creates a new deferred initializer.
39-// name: identifier for logging
40-// initFunc: function to create the value (called at most once)
41-// timeout: maximum time to wait for initialization (0 = no timeout)
42-func NewDeferredInit[T any](name string, initFunc func(ctx context.Context) (T, error), timeout time.Duration) *DeferredInit[T] {
43- return &DeferredInit[T]{
44- name: name,
45- initFunc: initFunc,
46- timeout: timeout,
47- }
48-}
49-
50-// Get returns the initialized value, initializing it on first call.
51-// Subsequent calls return the cached value or error.
52-// Thread-safe: multiple goroutines can call Get() simultaneously.
53-func (d *DeferredInit[T]) Get(ctx context.Context) (T, error) {
54- // Fast path: already initialized
55- state := DeferredState(atomic.LoadInt32(&d.state))
56- if state == DeferredStateReady {
57- d.mu.RLock()
58- v := d.value
59- d.mu.RUnlock()
60- return v, nil
61- }
62- if state == DeferredStateFailed {
63- d.mu.RLock()
64- err := d.err
65- d.mu.RUnlock()
66- var zero T
67- return zero, err
68- }
69-
70- // Slow path: need to initialize
71- d.initOnce.Do(func() {
72- d.initialize(ctx)
73- })
74-
75- // Return result
76- d.mu.RLock()
77- defer d.mu.RUnlock()
78-
79- if d.err != nil {
80- var zero T
81- return zero, d.err
82- }
83- return d.value, nil
84-}
85-
86-// initialize performs the actual initialization
87-func (d *DeferredInit[T]) initialize(ctx context.Context) {
88- atomic.StoreInt32(&d.state, int32(DeferredStateInitializing))
89- startTime := time.Now()
90-
91- logging.Debug(fmt.Sprintf("[deferred] Initializing %s...", d.name))
92-
93- // Apply timeout if configured
94- if d.timeout > 0 {
95- var cancel context.CancelFunc
96- ctx, cancel = context.WithTimeout(ctx, d.timeout)
97- defer cancel()
98- }
99-
100- value, err := d.initFunc(ctx)
101-
102- d.mu.Lock()
103- defer d.mu.Unlock()
104-
105- elapsed := time.Since(startTime)
106-
107- if err != nil {
108- d.err = fmt.Errorf("deferred init %s failed: %w", d.name, err)
109- atomic.StoreInt32(&d.state, int32(DeferredStateFailed))
110- logging.Error(fmt.Sprintf("[deferred] %s initialization failed after %v: %v", d.name, elapsed, err))
111- return
112- }
113-
114- d.value = value
115- atomic.StoreInt32(&d.state, int32(DeferredStateReady))
116- logging.Info(fmt.Sprintf("[deferred] %s initialized in %v", d.name, elapsed))
117-}
118-
119-// TryGet returns the value if already initialized, without blocking.
120-// Returns (value, true) if ready, or (zero, false) if not yet initialized.
121-func (d *DeferredInit[T]) TryGet() (T, bool) {
122- state := DeferredState(atomic.LoadInt32(&d.state))
123- if state != DeferredStateReady {
124- var zero T
125- return zero, false
126- }
127-
128- d.mu.RLock()
129- v := d.value
130- d.mu.RUnlock()
131- return v, true
132-}
133-
134-// IsReady returns true if initialization has completed successfully
135-func (d *DeferredInit[T]) IsReady() bool {
136- return DeferredState(atomic.LoadInt32(&d.state)) == DeferredStateReady
137-}
138-
139-// IsFailed returns true if initialization failed
140-func (d *DeferredInit[T]) IsFailed() bool {
141- return DeferredState(atomic.LoadInt32(&d.state)) == DeferredStateFailed
142-}
143-
144-// State returns the current initialization state
145-func (d *DeferredInit[T]) State() DeferredState {
146- return DeferredState(atomic.LoadInt32(&d.state))
147-}
148-
149-// Name returns the identifier of this deferred initializer
150-func (d *DeferredInit[T]) Name() string {
151- return d.name
152-}
153-
154-// Error returns the initialization error, if any
155-func (d *DeferredInit[T]) Error() error {
156- d.mu.RLock()
157- defer d.mu.RUnlock()
158- return d.err
159-}
160-
161-// DeferredInitGroup manages a collection of deferred initializers
162-type DeferredInitGroup struct {
163- mu sync.RWMutex
164- items map[string]interface{ State() DeferredState }
165-}
166-
167-// NewDeferredInitGroup creates a new group for tracking deferred initializers
168-func NewDeferredInitGroup() *DeferredInitGroup {
169- return &DeferredInitGroup{
170- items: make(map[string]interface{ State() DeferredState }),
171- }
172-}
173-
174-// Register adds a deferred initializer to the group
175-func (g *DeferredInitGroup) Register(name string, item interface{ State() DeferredState }) {
176- g.mu.Lock()
177- defer g.mu.Unlock()
178- g.items[name] = item
179-}
180-
181-// Stats returns statistics about all registered initializers
182-func (g *DeferredInitGroup) Stats() map[string]DeferredState {
183- g.mu.RLock()
184- defer g.mu.RUnlock()
185-
186- stats := make(map[string]DeferredState, len(g.items))
187- for name, item := range g.items {
188- stats[name] = item.State()
189- }
190- return stats
191-}
192-
193-// AllReady returns true if all registered initializers are ready
194-func (g *DeferredInitGroup) AllReady() bool {
195- g.mu.RLock()
196- defer g.mu.RUnlock()
197-
198- for _, item := range g.items {
199- if item.State() != DeferredStateReady {
200- return false
201- }
202- }
203- return true
204-}
D internal/app/browser/focus_state_machine.go +0 -1234
@@ -1,1234 +0,0 @@
1-// focus_state_machine.go - Centralized focus state management with queue-based serialization
2-package browser
3-
4-import (
5- "container/heap"
6- "fmt"
7- "runtime/debug"
8- "sync"
9- "time"
10-
11- "github.com/bnema/dumber/internal/logging"
12- "github.com/bnema/dumber/pkg/webkit"
13- "github.com/diamondburned/gotk4/pkg/gtk/v4"
14-)
15-
16-// FocusState represents the current state of the focus management system
17-type FocusState string
18-
19-const (
20- StateInitializing FocusState = "initializing" // System startup, determining initial focus
21- StateIdle FocusState = "idle" // No focus transition in progress, system stable
22- StateTransitioning FocusState = "transitioning" // Processing a focus change request
23- StateFocused FocusState = "focused" // Focus successfully applied, awaiting next request
24- StateReconciling FocusState = "reconciling" // Fixing inconsistencies detected in CSS/focus state
25-)
26-
27-// FocusSource identifies where a focus request originated
28-type FocusSource string
29-
30-const (
31- SourceKeyboard FocusSource = "keyboard" // User keyboard navigation
32- SourceMouse FocusSource = "mouse" // Mouse hover/click
33- SourceProgrammatic FocusSource = "programmatic" // API call
34- SourceSystem FocusSource = "system" // System initialization
35- SourceStackNav FocusSource = "stack-nav" // Stack navigation
36- SourceSplit FocusSource = "split" // Pane split operation
37- SourceClose FocusSource = "close" // Pane close operation
38-)
39-
40-// Priority levels for focus requests
41-const (
42- PriorityLow = 10
43- PriorityNormal = 50
44- PriorityHigh = 90
45- PriorityUrgent = 100
46-)
47-
48-// FocusRequest represents a request to change focus to a specific pane
49-type FocusRequest struct {
50- ID string // Unique request identifier
51- TargetNode *paneNode // Target pane to focus
52- Source FocusSource // Where the request originated
53- Priority int // Request priority (higher = more urgent)
54- Timestamp time.Time // When the request was created
55- Context map[string]interface{} // Additional metadata
56-}
57-
58-// FocusTransition records a completed focus change for debugging
59-type FocusTransition struct {
60- From *paneNode // Previous focus target
61- To *paneNode // New focus target
62- State FocusState // State during transition
63- Source FocusSource // Request source
64- Timestamp time.Time // When transition occurred
65- Success bool // Whether transition succeeded
66- Error error // Error if transition failed
67- Stack []byte // Stack trace for debugging
68-}
69-
70-// RingBuffer provides a thread-safe circular buffer for focus history
71-type RingBuffer[T any] struct {
72- buffer []T
73- head int
74- tail int
75- size int
76- cap int
77- mu sync.RWMutex
78-}
79-
80-// NewRingBuffer creates a new ring buffer with the specified capacity
81-func NewRingBuffer[T any](capacity int) *RingBuffer[T] {
82- return &RingBuffer[T]{
83- buffer: make([]T, capacity),
84- cap: capacity,
85- }
86-}
87-
88-// Add inserts an item into the ring buffer
89-func (rb *RingBuffer[T]) Add(item T) {
90- rb.mu.Lock()
91- defer rb.mu.Unlock()
92-
93- rb.buffer[rb.head] = item
94- rb.head = (rb.head + 1) % rb.cap
95-
96- if rb.size < rb.cap {
97- rb.size++
98- } else {
99- rb.tail = (rb.tail + 1) % rb.cap
100- }
101-}
102-
103-// GetAll returns all items in the ring buffer in chronological order
104-func (rb *RingBuffer[T]) GetAll() []T {
105- rb.mu.RLock()
106- defer rb.mu.RUnlock()
107-
108- if rb.size == 0 {
109- return nil
110- }
111-
112- result := make([]T, rb.size)
113- for i := 0; i < rb.size; i++ {
114- idx := (rb.tail + i) % rb.cap
115- result[i] = rb.buffer[idx]
116- }
117- return result
118-}
119-
120-// FocusPriorityQueue implements a priority queue for focus requests
121-type FocusPriorityQueue []*FocusRequest
122-
123-func (pq FocusPriorityQueue) Len() int { return len(pq) }
124-
125-func (pq FocusPriorityQueue) Less(i, j int) bool {
126- // Higher priority value = higher priority (processed first)
127- return pq[i].Priority > pq[j].Priority
128-}
129-
130-func (pq FocusPriorityQueue) Swap(i, j int) {
131- pq[i], pq[j] = pq[j], pq[i]
132-}
133-
134-func (pq *FocusPriorityQueue) Push(x interface{}) {
135- *pq = append(*pq, x.(*FocusRequest))
136-}
137-
138-func (pq *FocusPriorityQueue) Pop() interface{} {
139- old := *pq
140- n := len(old)
141- item := old[n-1]
142- *pq = old[0 : n-1]
143- return item
144-}
145-
146-// RequestDeduplicator prevents duplicate requests from flooding the queue
147-type RequestDeduplicator struct {
148- mu sync.RWMutex
149- recentSigs map[string]time.Time
150- ttl time.Duration
151-}
152-
153-// NewRequestDeduplicator creates a new request deduplicator
154-func NewRequestDeduplicator(ttl time.Duration) *RequestDeduplicator {
155- return &RequestDeduplicator{
156- recentSigs: make(map[string]time.Time),
157- ttl: ttl,
158- }
159-}
160-
161-// IsDuplicate checks if a request is a duplicate within the TTL window
162-func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest, isActiveTarget bool) bool {
163- rd.mu.Lock()
164- defer rd.mu.Unlock()
165-
166- // Clean up expired signatures
167- now := time.Now()
168- for sig, timestamp := range rd.recentSigs {
169- if now.Sub(timestamp) > rd.ttl {
170- delete(rd.recentSigs, sig)
171- }
172- }
173-
174- // Generate signature for this request
175- sig := fmt.Sprintf("%p:%s", req.TargetNode, req.Source)
176-
177- // Always record latest timestamp so future checks use fresh window
178- // Prevent duplicates only when the target is already active; otherwise we
179- // want to process the request to move focus back to that pane.
180- if timestamp, exists := rd.recentSigs[sig]; exists {
181- if isActiveTarget && now.Sub(timestamp) <= rd.ttl {
182- rd.recentSigs[sig] = now
183- return true
184- }
185- }
186-
187- rd.recentSigs[sig] = now
188- return false
189-}
190-
191-// FocusStateMachine manages all focus operations through a centralized state machine
192-type FocusStateMachine struct {
193- mu sync.RWMutex
194- wm *WorkspaceManager // Reference to workspace manager
195- currentState FocusState // Current state of the focus system
196- activePane *paneNode // Currently focused pane
197- requestQueue chan FocusRequest // Queue for serializing focus requests
198- priorityQueue *FocusPriorityQueue // Priority queue for request ordering
199- pendingRequests chan struct{} // Signal channel for pending requests
200- historyRing *RingBuffer[FocusTransition] // History of focus transitions
201- validators []FocusValidator // Request validators
202- settlingTimer *time.Timer // Timer for state settling
203- queueProcessor *sync.WaitGroup // Wait group for queue processor
204- shutdownChan chan struct{} // Shutdown signal
205- requestIDCounter uint64 // Counter for generating request IDs
206-
207- // Request coalescing for rapid focus changes
208- lastRequestTime time.Time // Time of last request
209- coalescingWindow time.Duration // Window for coalescing requests
210- deduplicator *RequestDeduplicator // Request deduplication
211-
212- // Performance metrics tracking
213- metrics FocusMetrics // Performance metrics
214- processTimes []time.Duration // Recent process times for averaging
215- maxProcessTimes int // Max number of process times to keep
216-
217- // Debug configuration
218- debugEnabled bool // Enable focus debug logging
219- metricsEnabled bool // Enable metrics tracking
220- // Reconciliation loop prevention
221- lastReconcileTime time.Time // Last reconciliation timestamp
222- reconcileCount int // Recent reconciliation count
223- maxReconcileAttempts int // Max reconcile attempts per second
224-}
225-
226-// FocusValidator validates focus requests before processing
227-type FocusValidator func(request FocusRequest) error
228-
229-// Predefined errors
230-var (
231- ErrFocusQueueFull = fmt.Errorf("focus request queue is full")
232- ErrInvalidTarget = fmt.Errorf("invalid focus target")
233- ErrInvalidStackOperation = fmt.Errorf("invalid stack operation")
234- ErrStateMachineShutdown = fmt.Errorf("focus state machine is shutting down")
235-)
236-
237-// NewFocusStateMachine creates a new focus state machine
238-func NewFocusStateMachine(wm *WorkspaceManager) *FocusStateMachine {
239- pq := &FocusPriorityQueue{}
240- heap.Init(pq)
241-
242- fsm := &FocusStateMachine{
243- wm: wm,
244- currentState: StateInitializing,
245- requestQueue: make(chan FocusRequest, 100), // Buffered channel with capacity 100
246- priorityQueue: pq,
247- pendingRequests: make(chan struct{}, 1), // Signal for priority queue processing
248- historyRing: NewRingBuffer[FocusTransition](50), // Keep last 50 transitions
249- validators: make([]FocusValidator, 0),
250- queueProcessor: &sync.WaitGroup{},
251- shutdownChan: make(chan struct{}),
252- coalescingWindow: 50 * time.Millisecond, // 50ms coalescing window
253- deduplicator: NewRequestDeduplicator(200 * time.Millisecond), // 200ms dedup TTL
254- maxProcessTimes: 100, // Keep last 100 process times for averaging
255- // Debug flags will be set via ConfigureDebug()
256- debugEnabled: false,
257- metricsEnabled: false,
258- // Reconciliation loop prevention
259- maxReconcileAttempts: 3, // Max 3 reconciliation attempts per second
260- }
261-
262- // Add default validators
263- fsm.addDefaultValidators()
264-
265- return fsm
266-}
267-
268-// ConfigureDebug configures debug settings for the focus state machine
269-func (fsm *FocusStateMachine) ConfigureDebug(focusDebug, _ bool, metricsEnabled bool) {
270- fsm.mu.Lock()
271- defer fsm.mu.Unlock()
272-
273- fsm.debugEnabled = focusDebug
274- fsm.metricsEnabled = metricsEnabled
275-
276- if focusDebug {
277- logging.Info(fmt.Sprintf("[FSM] Focus debug logging enabled"))
278- }
279- if metricsEnabled {
280- logging.Info(fmt.Sprintf("[FSM] Focus metrics tracking enabled"))
281- }
282-}
283-
284-// Initialize sets up the focus state machine and determines initial focus
285-func (fsm *FocusStateMachine) Initialize() error {
286- fsm.mu.Lock()
287- defer fsm.mu.Unlock()
288-
289- logging.Info(fmt.Sprintf("[FSM] Initializing focus state machine"))
290- fsm.currentState = StateInitializing
291-
292- // Determine initial focus
293- leaves := fsm.wm.collectLeaves()
294- if len(leaves) == 0 {
295- logging.Debug(fmt.Sprintf("[FSM] No panes found during initialization"))
296- fsm.currentState = StateIdle
297- return nil
298- }
299-
300- initialPane := fsm.determineInitialFocus(leaves)
301- if initialPane == nil {
302- logging.Warn(fmt.Sprintf("[FSM] Could not determine initial focus target"))
303- fsm.currentState = StateIdle
304- return nil
305- }
306-
307- // Phase 3: Apply initial focus and CSS
308- if err := fsm.applyInitialFocus(initialPane); err != nil {
309- logging.Error(fmt.Sprintf("[FSM] Failed to apply initial focus: %v", err))
310- fsm.currentState = StateIdle
311- return err
312- }
313-
314- // Phase 4: Attach GTK focus controllers to all panes
315- fsm.attachGTKControllersToAllPanes()
316-
317- // Phase 5: Start queue processor
318- fsm.queueProcessor.Add(1)
319- go fsm.processQueue()
320-
321- // Phase 6: Start CSS verification loop
322- go fsm.startCSSVerificationLoop()
323-
324- // Phase 7: Transition to idle state
325- fsm.currentState = StateIdle
326-
327- logging.Info(fmt.Sprintf("[FSM] Focus state machine initialized successfully with pane %p", initialPane))
328- return nil
329-}
330-
331-// Shutdown gracefully shuts down the focus state machine
332-func (fsm *FocusStateMachine) Shutdown() {
333- fsm.mu.Lock()
334- defer fsm.mu.Unlock()
335-
336- logging.Info(fmt.Sprintf("[FSM] Shutting down focus state machine"))
337-
338- // Signal shutdown
339- close(fsm.shutdownChan)
340-
341- // Wait for queue processor to finish
342- fsm.queueProcessor.Wait()
343-
344- // Clean up timers
345- if fsm.settlingTimer != nil {
346- fsm.settlingTimer.Stop()
347- }
348-
349- logging.Info(fmt.Sprintf("[FSM] Focus state machine shutdown complete"))
350-}
351-
352-// RequestFocus queues a focus change request with priority and deduplication
353-func (fsm *FocusStateMachine) RequestFocus(node *paneNode, source FocusSource) error {
354- if node == nil {
355- return ErrInvalidTarget
356- }
357-
358- request := FocusRequest{
359- ID: fsm.generateRequestID(),
360- TargetNode: node,
361- Source: source,
362- Priority: fsm.calculatePriority(source),
363- Timestamp: time.Now(),
364- Context: make(map[string]interface{}),
365- }
366-
367- // Track total requests
368- if fsm.metricsEnabled {
369- fsm.mu.Lock()
370- fsm.metrics.TotalRequests++
371- fsm.mu.Unlock()
372- }
373-
374- activePane := fsm.GetActivePane()
375-
376- // Check for duplicates (only dedupe when the request targets the currently
377- // active pane to avoid suppressing necessary focus transitions).
378- if fsm.deduplicator.IsDuplicate(request, activePane == node) {
379- if fsm.metricsEnabled {
380- fsm.mu.Lock()
381- fsm.metrics.DuplicateRequests++
382- fsm.mu.Unlock()
383- }
384- if fsm.debugEnabled {
385- logging.Debug(fmt.Sprintf("[FSM] Duplicate request ignored: %s -> %p", request.Source, request.TargetNode))
386- }
387- return nil
388- }
389-
390- // Check for coalescing opportunity
391- if fsm.shouldCoalesce(request) {
392- if fsm.metricsEnabled {
393- fsm.mu.Lock()
394- fsm.metrics.CoalescedRequests++
395- fsm.mu.Unlock()
396- }
397- if fsm.debugEnabled {
398- logging.Debug(fmt.Sprintf("[FSM] Request coalesced: %s -> %p", request.Source, request.TargetNode))
399- }
400- return nil
401- }
402-
403- // Add to priority queue instead of simple channel
404- fsm.mu.Lock()
405- heap.Push(fsm.priorityQueue, &request)
406- fsm.lastRequestTime = request.Timestamp
407-
408- // Track queue depth metrics
409- currentDepth := fsm.priorityQueue.Len()
410- if currentDepth > fsm.metrics.MaxQueueDepth {
411- fsm.metrics.MaxQueueDepth = currentDepth
412- }
413- fsm.mu.Unlock()
414-
415- // Signal that a request is pending
416- select {
417- case fsm.pendingRequests <- struct{}{}:
418- default: // Channel is already signaled
419- }
420-
421- return nil
422-}
423-
424-// GetActivePane returns the currently active pane
425-func (fsm *FocusStateMachine) GetActivePane() *paneNode {
426- fsm.mu.RLock()
427- defer fsm.mu.RUnlock()
428- return fsm.activePane
429-}
430-
431-// InvalidateActivePane clears the active pane if it matches the provided node.
432-// This is used when panes are destroyed so the focus state machine doesn't keep
433-// references to invalid widgets.
434-func (fsm *FocusStateMachine) InvalidateActivePane(node *paneNode) {
435- if node == nil {
436- return
437- }
438-
439- fsm.mu.Lock()
440- defer fsm.mu.Unlock()
441-
442- if fsm.activePane != node {
443- return
444- }
445-
446- logging.Debug(fmt.Sprintf("[FSM] Active pane invalidated: %p", node))
447- fsm.activePane = nil
448-
449- if fsm.wm != nil && fsm.wm.app != nil {
450- fsm.wm.app.activePane = nil
451- }
452-
453- if fsm.currentState == StateFocused {
454- fsm.currentState = StateIdle
455- }
456-}
457-
458-// GetCurrentState returns the current state of the focus system
459-func (fsm *FocusStateMachine) GetCurrentState() FocusState {
460- fsm.mu.RLock()
461- defer fsm.mu.RUnlock()
462- return fsm.currentState
463-}
464-
465-// AddValidator adds a focus request validator
466-func (fsm *FocusStateMachine) AddValidator(validator FocusValidator) {
467- fsm.mu.Lock()
468- defer fsm.mu.Unlock()
469- fsm.validators = append(fsm.validators, validator)
470-}
471-
472-// Private methods
473-
474-// generateRequestID creates a unique request identifier
475-func (fsm *FocusStateMachine) generateRequestID() string {
476- fsm.requestIDCounter++
477- return fmt.Sprintf("req-%d-%d", time.Now().UnixNano(), fsm.requestIDCounter)
478-}
479-
480-// calculatePriority determines request priority based on source
481-func (fsm *FocusStateMachine) calculatePriority(source FocusSource) int {
482- switch source {
483- case SourceKeyboard:
484- return PriorityHigh
485- case SourceMouse:
486- return PriorityNormal
487- case SourceStackNav:
488- return PriorityHigh
489- case SourceSystem:
490- return PriorityUrgent
491- case SourceSplit, SourceClose:
492- return PriorityHigh
493- default:
494- return PriorityLow
495- }
496-}
497-
498-// addDefaultValidators adds standard focus request validators
499-func (fsm *FocusStateMachine) addDefaultValidators() {
500- // Validate target node exists and is a leaf
501- fsm.validators = append(fsm.validators, func(request FocusRequest) error {
502- if request.TargetNode == nil {
503- return ErrInvalidTarget
504- }
505- if !request.TargetNode.isLeaf {
506- return fmt.Errorf("target node is not a leaf pane")
507- }
508- if request.TargetNode.pane == nil || request.TargetNode.pane.webView == nil {
509- return fmt.Errorf("target node has no valid webview")
510- }
511- return nil
512- })
513-
514- // Validate target node still exists in workspace
515- fsm.validators = append(fsm.validators, func(request FocusRequest) error {
516- if fsm.wm.viewToNode[request.TargetNode.pane.webView] != request.TargetNode {
517- return fmt.Errorf("target node no longer exists in workspace")
518- }
519- return nil
520- })
521-}
522-
523-// validateRequest runs all validators on a focus request
524-func (fsm *FocusStateMachine) validateRequest(request FocusRequest) error {
525- for _, validator := range fsm.validators {
526- if err := validator(request); err != nil {
527- return err
528- }
529- }
530- return nil
531-}
532-
533-// determineInitialFocus selects the initial focus target based on priority rules
534-func (fsm *FocusStateMachine) determineInitialFocus(leaves []*paneNode) *paneNode {
535- if len(leaves) == 0 {
536- return nil
537- }
538-
539- // Priority 1: First pane with user content (not about:blank)
540- for _, leaf := range leaves {
541- if leaf.pane != nil && leaf.pane.webView != nil {
542- url := leaf.pane.webView.GetCurrentURL()
543- if url != "" && url != "about:blank" {
544- logging.Debug(fmt.Sprintf("[FSM] Selected pane with content as initial focus: %s", url))
545- return leaf
546- }
547- }
548- }
549-
550- // Priority 2: Top-left pane (geometrically)
551- if topLeft := fsm.findTopLeftPane(leaves); topLeft != nil {
552- logging.Debug(fmt.Sprintf("[FSM] Selected top-left pane as initial focus"))
553- return topLeft
554- }
555-
556- // Priority 3: First pane in tree order
557- logging.Debug(fmt.Sprintf("[FSM] Selected first pane as initial focus"))
558- return leaves[0]
559-}
560-
561-// findTopLeftPane finds the geometrically top-left pane
562-func (fsm *FocusStateMachine) findTopLeftPane(leaves []*paneNode) *paneNode {
563- var bestPane *paneNode
564- var bestScore float64 = 1e9
565-
566- for _, leaf := range leaves {
567- if leaf.container == nil {
568- continue
569- }
570-
571- x, y, _, _ := webkit.WidgetGetAllocation(leaf.container)
572-
573- // Score based on distance from top-left corner (0,0)
574- score := float64(x) + float64(y)
575- if score < bestScore {
576- bestScore = score
577- bestPane = leaf
578- }
579- }
580-
581- return bestPane
582-}
583-
584-// applyInitialFocus applies focus to the initial target without going through the queue
585-func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error {
586- if node == nil {
587- return ErrInvalidTarget
588- }
589-
590- logging.Debug(fmt.Sprintf("[FSM] Applying initial focus to pane %p", node))
591-
592- // Apply GTK focus
593- if err := fsm.applyGTKFocus(node); err != nil {
594- return fmt.Errorf("failed to apply GTK focus: %w", err)
595- }
596-
597- // Apply initial visual border
598- if fsm.wm != nil {
599- fsm.wm.applyActivePaneBorder(node)
600- }
601-
602- // Update state
603- fsm.activePane = node
604-
605- // Record transition
606- transition := FocusTransition{
607- From: nil,
608- To: node,
609- State: StateInitializing,
610- Source: SourceSystem,
611- Timestamp: time.Now(),
612- Success: true,
613- Stack: debug.Stack(),
614- }
615- fsm.historyRing.Add(transition)
616-
617- // Notify workspace manager
618- if fsm.wm != nil && fsm.wm.app != nil && node.pane != nil {
619- fsm.wm.app.activePane = node.pane
620- }
621-
622- // Notify JS runtime about focus state
623- if fsm.wm != nil {
624- fsm.wm.DispatchPaneFocusEvent(node, true)
625- }
626-
627- return nil
628-}
629-
630-// applyGTKFocus applies GTK focus to the specified pane
631-func (fsm *FocusStateMachine) applyGTKFocus(node *paneNode) error {
632- if node.pane == nil || node.pane.webView == nil {
633- return fmt.Errorf("pane has no valid webview")
634- }
635-
636- viewWidget := node.pane.webView.Widget()
637- if viewWidget == nil {
638- return fmt.Errorf("webview has no valid widget")
639- }
640-
641- webkit.WidgetGrabFocus(viewWidget)
642- return nil
643-}
644-
645-// processQueue processes focus requests from the priority queue
646-func (fsm *FocusStateMachine) processQueue() {
647- defer fsm.queueProcessor.Done()
648-
649- logging.Debug(fmt.Sprintf("[FSM] Focus request queue processor started"))
650-
651- for {
652- select {
653- case <-fsm.pendingRequests:
654- // Process all pending requests from priority queue
655- fsm.processPendingRequests()
656- case request := <-fsm.requestQueue:
657- // Legacy channel for backward compatibility
658- fsm.handleFocusRequest(request)
659- case <-fsm.shutdownChan:
660- logging.Debug(fmt.Sprintf("[FSM] Focus request queue processor shutting down"))
661- return
662- }
663- }
664-}
665-
666-// processPendingRequests processes all requests in the priority queue
667-func (fsm *FocusStateMachine) processPendingRequests() {
668- for {
669- fsm.mu.Lock()
670- if fsm.priorityQueue.Len() == 0 {
671- fsm.mu.Unlock()
672- break
673- }
674-
675- // Get highest priority request
676- request := heap.Pop(fsm.priorityQueue).(*FocusRequest)
677- fsm.mu.Unlock()
678-
679- // Check if a higher priority request has arrived while processing
680- if fsm.hasHigherPriorityRequest(*request) {
681- // Requeue current request and process higher priority one
682- fsm.requeue(*request)
683- continue
684- }
685-
686- fsm.handleFocusRequest(*request)
687- }
688-}
689-
690-// handleFocusRequest processes a single focus request
691-func (fsm *FocusStateMachine) handleFocusRequest(request FocusRequest) {
692- startTime := time.Now()
693-
694- // Validate request
695- if err := fsm.validateRequest(request); err != nil {
696- fsm.mu.Lock()
697- fsm.metrics.FailedRequests++
698- fsm.mu.Unlock()
699- logging.Error(fmt.Sprintf("[FSM] Invalid focus request %s: %v", request.ID, err))
700- return
701- }
702-
703- // Check if we're already focused on the target
704- fsm.mu.RLock()
705- currentActive := fsm.activePane
706- fsm.mu.RUnlock()
707-
708- if currentActive == request.TargetNode {
709- logging.Debug(fmt.Sprintf("[FSM] Focus request %s: already focused on target", request.ID))
710- fsm.recordProcessTime(time.Since(startTime))
711- return
712- }
713-
714- logging.Debug(fmt.Sprintf("[FSM] Processing focus request %s: %s -> %p",
715- request.ID, request.Source, request.TargetNode))
716-
717- // GTK operations must run on main thread - always use IdleAdd for simplicity
718- var err error
719- done := make(chan struct{})
720- _ = webkit.IdleAdd(func() bool {
721- err = fsm.executeFocusChange(request)
722- close(done)
723- return false
724- })
725- <-done
726-
727- if err != nil {
728- fsm.mu.Lock()
729- fsm.metrics.FailedRequests++
730- fsm.mu.Unlock()
731- logging.Error(fmt.Sprintf("[FSM] Focus request %s failed: %v", request.ID, err))
732- } else {
733- fsm.mu.Lock()
734- fsm.metrics.SuccessfulRequests++
735- fsm.mu.Unlock()
736- }
737-
738- fsm.recordProcessTime(time.Since(startTime))
739-}
740-
741-// executeFocusChange executes a focus change with proper state management
742-func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error {
743- fsm.mu.Lock()
744- defer fsm.mu.Unlock()
745-
746- // Transition to processing state
747- oldState := fsm.currentState
748- fsm.currentState = StateTransitioning
749-
750- oldPane := fsm.activePane
751- newPane := request.TargetNode
752-
753- // Record transition start
754- transition := FocusTransition{
755- From: oldPane,
756- To: newPane,
757- Source: request.Source,
758- Timestamp: request.Timestamp,
759- Stack: debug.Stack(),
760- }
761-
762- // Apply GTK focus
763- if err := fsm.applyGTKFocus(newPane); err != nil {
764- // Rollback state
765- fsm.currentState = oldState
766- transition.Success = false
767- transition.Error = err
768- fsm.historyRing.Add(transition)
769- return fmt.Errorf("failed to apply GTK focus: %w", err)
770- }
771-
772- // Apply visual border changes
773- if fsm.wm != nil {
774- // Remove border from old pane
775- if oldPane != nil {
776- fsm.wm.removeActivePaneBorder(oldPane)
777- }
778- // Add border to new pane
779- fsm.wm.applyActivePaneBorder(newPane)
780- }
781-
782- // Update state
783- fsm.activePane = newPane
784- fsm.currentState = StateFocused
785-
786- // Record successful transition
787- transition.Success = true
788- fsm.historyRing.Add(transition)
789-
790- // Notify workspace manager
791- if fsm.wm != nil && fsm.wm.app != nil && newPane.pane != nil {
792- fsm.wm.app.activePane = newPane.pane
793- }
794-
795- // Notify JS runtimes about focus change
796- if fsm.wm != nil {
797- if oldPane != nil {
798- fsm.wm.DispatchPaneFocusEvent(oldPane, false)
799- }
800- if newPane != nil {
801- fsm.wm.DispatchPaneFocusEvent(newPane, true)
802- }
803- }
804-
805- // Start settling timer
806- fsm.startSettlingTimer()
807-
808- logging.Debug(fmt.Sprintf("[FSM] Focus change completed: %p -> %p", oldPane, newPane))
809- return nil
810-}
811-
812-// startSettlingTimer starts a timer to transition back to idle state
813-func (fsm *FocusStateMachine) startSettlingTimer() {
814- if fsm.settlingTimer != nil {
815- fsm.settlingTimer.Stop()
816- }
817-
818- fsm.settlingTimer = time.AfterFunc(50*time.Millisecond, func() {
819- fsm.mu.Lock()
820- if fsm.currentState == StateFocused {
821- fsm.currentState = StateIdle
822- }
823- fsm.mu.Unlock()
824- })
825-}
826-
827-// startCSSVerificationLoop starts the CSS verification background loop
828-func (fsm *FocusStateMachine) startCSSVerificationLoop() {
829- ticker := time.NewTicker(100 * time.Millisecond)
830-
831- go func() {
832- defer ticker.Stop()
833-
834- for {
835- select {
836- case <-ticker.C:
837- fsm.verifyCSSState()
838- case <-fsm.shutdownChan:
839- return
840- }
841- }
842- }()
843-}
844-
845-// verifyCSSState checks and corrects CSS class inconsistencies
846-func (fsm *FocusStateMachine) verifyCSSState() {
847- fsm.mu.RLock()
848- activePane := fsm.activePane
849- fsm.mu.RUnlock()
850-
851- if activePane == nil || fsm.wm == nil {
852- return
853- }
854-
855- var issues []string
856-
857- if len(issues) > 0 {
858- // Prevent infinite reconciliation loops
859- fsm.mu.Lock()
860- now := time.Now()
861- if now.Sub(fsm.lastReconcileTime) < time.Second {
862- fsm.reconcileCount++
863- } else {
864- fsm.reconcileCount = 1
865- fsm.lastReconcileTime = now
866- }
867-
868- if fsm.reconcileCount > fsm.maxReconcileAttempts {
869- logging.Warn(fmt.Sprintf("[FSM] WARNING: Reconciliation loop detected, skipping (attempt %d)", fsm.reconcileCount))
870- fsm.mu.Unlock()
871- return
872- }
873- fsm.mu.Unlock()
874-
875- logging.Debug(fmt.Sprintf("[FSM] CSS inconsistencies found: %v", issues))
876- logging.Debug(fmt.Sprintf("[FSM] Triggering CSS reconciliation (attempt %d)", fsm.reconcileCount))
877-
878- // Track reconciliation in metrics
879- fsm.mu.Lock()
880- fsm.metrics.ReconciliationCount++
881- fsm.mu.Unlock()
882- }
883-}
884-
885-// shouldCoalesce checks if a request should be coalesced with recent requests
886-func (fsm *FocusStateMachine) shouldCoalesce(request FocusRequest) bool {
887- fsm.mu.RLock()
888- defer fsm.mu.RUnlock()
889-
890- // Only coalesce if we have a recent request and it's to the same target
891- if time.Since(fsm.lastRequestTime) > fsm.coalescingWindow {
892- return false
893- }
894-
895- // Check if there's already a pending request to the same target
896- for i := 0; i < fsm.priorityQueue.Len(); i++ {
897- pending := (*fsm.priorityQueue)[i]
898- if pending.TargetNode == request.TargetNode {
899- logging.Debug(fmt.Sprintf("[FSM] Coalescing request to same target: %p", request.TargetNode))
900- return true
901- }
902- }
903-
904- return false
905-}
906-
907-// hasHigherPriorityRequest checks if there's a higher priority request in the queue
908-func (fsm *FocusStateMachine) hasHigherPriorityRequest(current FocusRequest) bool {
909- fsm.mu.RLock()
910- defer fsm.mu.RUnlock()
911-
912- if fsm.priorityQueue.Len() == 0 {
913- return false
914- }
915-
916- // Check the highest priority request (at top of heap)
917- highest := (*fsm.priorityQueue)[0]
918- return highest.Priority > current.Priority
919-}
920-
921-// requeue adds a request back to the priority queue
922-func (fsm *FocusStateMachine) requeue(request FocusRequest) {
923- fsm.mu.Lock()
924- defer fsm.mu.Unlock()
925-
926- heap.Push(fsm.priorityQueue, &request)
927- logging.Debug(fmt.Sprintf("[FSM] Requeued request due to higher priority: %s", request.ID))
928-
929- // Signal that a request is pending
930- select {
931- case fsm.pendingRequests <- struct{}{}:
932- default: // Channel is already signaled
933- }
934-}
935-
936-// attachGTKController attaches a GTK focus controller to a pane node
937-func (fsm *FocusStateMachine) attachGTKController(node *paneNode) {
938- if node == nil || node.pane == nil || node.pane.webView == nil {
939- return
940- }
941-
942- if node.focusControllerToken != 0 {
943- logging.Debug(fmt.Sprintf("[FSM] Controller already attached to pane %p", node))
944- return
945- }
946-
947- widget := node.pane.webView.Widget()
948- if widget == nil {
949- return
950- }
951-
952- // Create focus controller for GTK4
953- controller := gtk.NewEventControllerFocus()
954-
955- // Set propagation phase to TARGET only to avoid duplicate events from parent widgets
956- // GTK_PHASE_TARGET means we only get events for this specific widget, not bubbled events
957- controller.SetPropagationPhase(gtk.PhaseTarget)
958-
959- // Connect focus enter/leave callbacks with timestamp-based deduplication
960- controller.ConnectEnter(func() {
961- // Deduplicate: WebKitGTK nested widgets can fire multiple events in same millisecond
962- now := time.Now().UnixMilli()
963- if node.lastFocusEnterTime == now {
964- return // Duplicate event in same millisecond
965- }
966- node.lastFocusEnterTime = now
967-
968- logging.Debug(fmt.Sprintf("[FSM] GTK focus enter: %p", node))
969- // Don't automatically change focus on GTK enter - let user interactions drive this
970- // This prevents infinite loops with our own focus changes
971- })
972-
973- controller.ConnectLeave(func() {
974- // Deduplicate: WebKitGTK nested widgets can fire multiple events in same millisecond
975- now := time.Now().UnixMilli()
976- if node.lastFocusLeaveTime == now {
977- return // Duplicate event in same millisecond
978- }
979- node.lastFocusLeaveTime = now
980-
981- logging.Debug(fmt.Sprintf("[FSM] GTK focus leave: %p", node))
982- // Similarly, don't react to GTK leave events automatically
983- })
984-
985- // Add controller to widget
986- webkit.WidgetAddController(widget, controller)
987-
988- // Store controller pointer as token for later removal
989- node.focusControllerToken = uintptr(controller.Native())
990- if node.focusControllerToken != 0 {
991- logging.Debug(fmt.Sprintf("[FSM] Attached GTK focus controller to pane %p with token %d", node, node.focusControllerToken))
992- }
993-}
994-
995-// detachGTKController removes a GTK focus controller from a pane node
996-func (fsm *FocusStateMachine) detachGTKController(node *paneNode, token uintptr) {
997- if node == nil || node.pane == nil || node.pane.webView == nil || token == 0 {
998- return
999- }
1000-
1001- if node.focusControllerToken != token {
1002- logging.Debug(fmt.Sprintf("[FSM] Token mismatch, skipping detach for pane %p", node))
1003- return
1004- }
1005-
1006- node.focusControllerToken = 0
1007-
1008- // Note: In GTK4, controllers are automatically removed when widget is destroyed
1009- // We just need to clear our token reference
1010- logging.Debug(fmt.Sprintf("[FSM] Detached GTK focus controller from pane %p", node))
1011-}
1012-
1013-// attachGTKControllersToAllPanes attaches focus controllers to all existing panes
1014-func (fsm *FocusStateMachine) attachGTKControllersToAllPanes() {
1015- if fsm.wm == nil {
1016- return
1017- }
1018-
1019- leaves := fsm.wm.collectLeaves()
1020- for _, leaf := range leaves {
1021- fsm.attachGTKController(leaf)
1022- }
1023-
1024- logging.Debug(fmt.Sprintf("[FSM] Attached GTK focus controllers to %d panes", len(leaves)))
1025-}
1026-
1027-// FocusDebugInfo contains comprehensive debug information about the focus system
1028-type FocusDebugInfo struct {
1029- CurrentState FocusState `json:"current_state"`
1030- ActivePane *paneNode `json:"active_pane,omitempty"`
1031- QueueLength int `json:"queue_length"`
1032- PriorityQueueSize int `json:"priority_queue_size"`
1033- History []FocusTransition `json:"history"`
1034- CSSState map[string]interface{} `json:"css_state"`
1035- ValidationErrors []string `json:"validation_errors"`
1036- Metrics FocusMetrics `json:"metrics"`
1037- RecentRequests []string `json:"recent_requests"`
1038-}
1039-
1040-// FocusMetrics tracks performance metrics for the focus system
1041-type FocusMetrics struct {
1042- TotalRequests uint64 `json:"total_requests"`
1043- SuccessfulRequests uint64 `json:"successful_requests"`
1044- FailedRequests uint64 `json:"failed_requests"`
1045- CoalescedRequests uint64 `json:"coalesced_requests"`
1046- DuplicateRequests uint64 `json:"duplicate_requests"`
1047- AverageProcessTime time.Duration `json:"average_process_time"`
1048- MaxQueueDepth int `json:"max_queue_depth"`
1049- ReconciliationCount uint64 `json:"reconciliation_count"`
1050-}
1051-
1052-// DumpState returns comprehensive debug information about the focus system
1053-func (fsm *FocusStateMachine) DumpState() FocusDebugInfo {
1054- fsm.mu.RLock()
1055- defer fsm.mu.RUnlock()
1056-
1057- info := FocusDebugInfo{
1058- CurrentState: fsm.currentState,
1059- ActivePane: fsm.activePane,
1060- QueueLength: len(fsm.requestQueue),
1061- PriorityQueueSize: fsm.priorityQueue.Len(),
1062- History: fsm.historyRing.GetAll(),
1063- ValidationErrors: fsm.getValidationErrors(),
1064- RecentRequests: fsm.getRecentRequestSignatures(),
1065- Metrics: fsm.GetMetrics(),
1066- }
1067-
1068- return info
1069-}
1070-
1071-// ForceValidation manually triggers CSS consistency check and validation
1072-func (fsm *FocusStateMachine) ForceValidation() []string {
1073- logging.Debug(fmt.Sprintf("[FSM] Force validation requested"))
1074-
1075- // Trigger CSS verification
1076- fsm.verifyCSSState()
1077-
1078- // Run all validators on current state
1079- var issues []string
1080-
1081- fsm.mu.RLock()
1082- activePane := fsm.activePane
1083- fsm.mu.RUnlock()
1084-
1085- if activePane != nil {
1086- dummyRequest := FocusRequest{
1087- ID: "validation-check",
1088- TargetNode: activePane,
1089- Source: SourceSystem,
1090- Priority: PriorityLow,
1091- Timestamp: time.Now(),
1092- }
1093-
1094- if err := fsm.validateRequest(dummyRequest); err != nil {
1095- issues = append(issues, fmt.Sprintf("Active pane validation failed: %v", err))
1096- }
1097- }
1098-
1099- logging.Debug(fmt.Sprintf("[FSM] Force validation completed: %d issues found", len(issues)))
1100- return issues
1101-}
1102-
1103-// GetQueueStatus returns detailed information about pending requests
1104-func (fsm *FocusStateMachine) GetQueueStatus() map[string]interface{} {
1105- fsm.mu.RLock()
1106- defer fsm.mu.RUnlock()
1107-
1108- status := map[string]interface{}{
1109- "channel_queue_length": len(fsm.requestQueue),
1110- "priority_queue_length": fsm.priorityQueue.Len(),
1111- "channel_queue_cap": cap(fsm.requestQueue),
1112- "processing_state": fsm.currentState,
1113- "last_request_time": fsm.lastRequestTime,
1114- "coalescing_window": fsm.coalescingWindow,
1115- }
1116-
1117- // Add priority queue details
1118- if fsm.priorityQueue.Len() > 0 {
1119- priorities := make([]int, 0, fsm.priorityQueue.Len())
1120- targets := make([]string, 0, fsm.priorityQueue.Len())
1121-
1122- for i := 0; i < fsm.priorityQueue.Len(); i++ {
1123- req := (*fsm.priorityQueue)[i]
1124- priorities = append(priorities, req.Priority)
1125- targets = append(targets, fmt.Sprintf("%p", req.TargetNode))
1126- }
1127-
1128- status["pending_priorities"] = priorities
1129- status["pending_targets"] = targets
1130- }
1131-
1132- return status
1133-}
1134-
1135-// ClearHistory clears the focus transition history (useful for debugging sessions)
1136-func (fsm *FocusStateMachine) ClearHistory() {
1137- fsm.mu.Lock()
1138- defer fsm.mu.Unlock()
1139-
1140- fsm.historyRing = NewRingBuffer[FocusTransition](50)
1141- logging.Debug(fmt.Sprintf("[FSM] Focus transition history cleared"))
1142-}
1143-
1144-// GetMetrics returns performance metrics for the focus system
1145-func (fsm *FocusStateMachine) GetMetrics() FocusMetrics {
1146- fsm.mu.RLock()
1147- defer fsm.mu.RUnlock()
1148-
1149- // Copy current metrics with calculated average
1150- metrics := fsm.metrics
1151- metrics.AverageProcessTime = fsm.calculateAverageProcessTime()
1152-
1153- return metrics
1154-}
1155-
1156-// recordProcessTime adds a process time to the metrics and maintains the sliding window
1157-func (fsm *FocusStateMachine) recordProcessTime(duration time.Duration) {
1158- fsm.mu.Lock()
1159- defer fsm.mu.Unlock()
1160-
1161- // Add to the list
1162- fsm.processTimes = append(fsm.processTimes, duration)
1163-
1164- // Maintain sliding window
1165- if len(fsm.processTimes) > fsm.maxProcessTimes {
1166- fsm.processTimes = fsm.processTimes[1:]
1167- }
1168-}
1169-
1170-// calculateAverageProcessTime calculates the average of recent process times
1171-func (fsm *FocusStateMachine) calculateAverageProcessTime() time.Duration {
1172- if len(fsm.processTimes) == 0 {
1173- return 0
1174- }
1175-
1176- var total time.Duration
1177- for _, t := range fsm.processTimes {
1178- total += t
1179- }
1180-
1181- return total / time.Duration(len(fsm.processTimes))
1182-}
1183-
1184-// getValidationErrors checks for various validation issues
1185-func (fsm *FocusStateMachine) getValidationErrors() []string {
1186- var errors []string
1187-
1188- // Check if we have an active pane but it's not valid
1189- if fsm.activePane != nil {
1190- if !fsm.activePane.isLeaf {
1191- errors = append(errors, "active pane is not a leaf node")
1192- }
1193- if fsm.activePane.pane == nil || fsm.activePane.pane.webView == nil {
1194- errors = append(errors, "active pane has no valid webview")
1195- }
1196- if fsm.wm != nil && fsm.wm.viewToNode[fsm.activePane.pane.webView] != fsm.activePane {
1197- errors = append(errors, "active pane not found in workspace view map")
1198- }
1199- }
1200-
1201- // Check for orphaned requests in queue
1202- if fsm.wm != nil {
1203- validNodes := make(map[*paneNode]bool)
1204- for _, node := range fsm.wm.viewToNode {
1205- validNodes[node] = true
1206- }
1207-
1208- for i := 0; i < fsm.priorityQueue.Len(); i++ {
1209- req := (*fsm.priorityQueue)[i]
1210- if !validNodes[req.TargetNode] {
1211- errors = append(errors, fmt.Sprintf("orphaned request for invalid node: %s", req.ID))
1212- }
1213- }
1214- }
1215-
1216- return errors
1217-}
1218-
1219-// getRecentRequestSignatures returns recent request signatures for debugging
1220-func (fsm *FocusStateMachine) getRecentRequestSignatures() []string {
1221- if fsm.deduplicator == nil {
1222- return nil
1223- }
1224-
1225- fsm.deduplicator.mu.RLock()
1226- defer fsm.deduplicator.mu.RUnlock()
1227-
1228- sigs := make([]string, 0, len(fsm.deduplicator.recentSigs))
1229- for sig, timestamp := range fsm.deduplicator.recentSigs {
1230- sigs = append(sigs, fmt.Sprintf("%s (age: %v)", sig, time.Since(timestamp)))
1231- }
1232-
1233- return sigs
1234-}
D internal/app/browser/focus_state_machine_test.go +0 -415
@@ -1,415 +0,0 @@
1-package browser
2-
3-import (
4- "container/heap"
5- "sync"
6- "testing"
7- "time"
8-)
9-
10-// TestRingBuffer_AddAndRetrieve tests basic add and retrieve operations
11-func TestRingBuffer_AddAndRetrieve(t *testing.T) {
12- rb := NewRingBuffer[int](5)
13-
14- // Add items
15- rb.Add(1)
16- rb.Add(2)
17- rb.Add(3)
18-
19- // Retrieve all
20- items := rb.GetAll()
21-
22- if len(items) != 3 {
23- t.Errorf("expected 3 items, got %d", len(items))
24- }
25-
26- expected := []int{1, 2, 3}
27- for i, item := range items {
28- if item != expected[i] {
29- t.Errorf("at index %d: expected %d, got %d", i, expected[i], item)
30- }
31- }
32-}
33-
34-// TestRingBuffer_CircularOverwrite tests circular buffer overflow behavior
35-func TestRingBuffer_CircularOverwrite(t *testing.T) {
36- rb := NewRingBuffer[int](3)
37-
38- // Fill buffer
39- rb.Add(1)
40- rb.Add(2)
41- rb.Add(3)
42-
43- // Overflow - should overwrite oldest
44- rb.Add(4)
45- rb.Add(5)
46-
47- items := rb.GetAll()
48-
49- if len(items) != 3 {
50- t.Errorf("expected 3 items, got %d", len(items))
51- }
52-
53- // Should have 3, 4, 5 (oldest items 1, 2 overwritten)
54- expected := []int{3, 4, 5}
55- for i, item := range items {
56- if item != expected[i] {
57- t.Errorf("at index %d: expected %d, got %d", i, expected[i], item)
58- }
59- }
60-}
61-
62-// TestRingBuffer_EmptyBuffer tests empty buffer behavior
63-func TestRingBuffer_EmptyBuffer(t *testing.T) {
64- rb := NewRingBuffer[string](5)
65-
66- items := rb.GetAll()
67-
68- if items != nil {
69- t.Errorf("expected nil for empty buffer, got %v", items)
70- }
71-}
72-
73-// TestRingBuffer_ConcurrentAccess tests thread-safety
74-func TestRingBuffer_ConcurrentAccess(t *testing.T) {
75- rb := NewRingBuffer[int](100)
76- var wg sync.WaitGroup
77-
78- // Concurrent writes
79- for i := 0; i < 10; i++ {
80- wg.Add(1)
81- go func(val int) {
82- defer wg.Done()
83- for j := 0; j < 10; j++ {
84- rb.Add(val*10 + j)
85- }
86- }(i)
87- }
88-
89- // Concurrent reads
90- for i := 0; i < 5; i++ {
91- wg.Add(1)
92- go func() {
93- defer wg.Done()
94- for j := 0; j < 20; j++ {
95- _ = rb.GetAll()
96- time.Sleep(time.Microsecond)
97- }
98- }()
99- }
100-
101- wg.Wait()
102-
103- // Should have 100 items without panicking
104- items := rb.GetAll()
105- if len(items) != 100 {
106- t.Errorf("expected 100 items, got %d", len(items))
107- }
108-}
109-
110-// TestPriorityQueue_OrdersByPriority tests priority ordering
111-func TestPriorityQueue_OrdersByPriority(t *testing.T) {
112- tests := []struct {
113- name string
114- requests []*FocusRequest
115- expected []string // IDs in expected pop order
116- }{
117- {
118- name: "urgent before normal",
119- requests: []*FocusRequest{
120- {ID: "normal", Priority: PriorityNormal, Timestamp: time.Now()},
121- {ID: "urgent", Priority: PriorityUrgent, Timestamp: time.Now()},
122- },
123- expected: []string{"urgent", "normal"},
124- },
125- {
126- name: "high before low",
127- requests: []*FocusRequest{
128- {ID: "low", Priority: PriorityLow, Timestamp: time.Now()},
129- {ID: "high", Priority: PriorityHigh, Timestamp: time.Now()},
130- {ID: "normal", Priority: PriorityNormal, Timestamp: time.Now()},
131- },
132- expected: []string{"high", "normal", "low"},
133- },
134- {
135- name: "multiple same priority",
136- requests: []*FocusRequest{
137- {ID: "first", Priority: PriorityNormal, Timestamp: time.Now()},
138- {ID: "second", Priority: PriorityNormal, Timestamp: time.Now()},
139- {ID: "urgent", Priority: PriorityUrgent, Timestamp: time.Now()},
140- },
141- expected: []string{"urgent", "first", "second"},
142- },
143- }
144-
145- for _, tt := range tests {
146- t.Run(tt.name, func(t *testing.T) {
147- pq := make(FocusPriorityQueue, 0)
148- heap.Init(&pq)
149-
150- // Push all requests
151- for _, req := range tt.requests {
152- heap.Push(&pq, req)
153- }
154-
155- // Pop and verify order
156- for i, expectedID := range tt.expected {
157- if pq.Len() == 0 {
158- t.Fatalf("queue empty at position %d, expected %s", i, expectedID)
159- }
160- req := heap.Pop(&pq).(*FocusRequest)
161- if req.ID != expectedID {
162- t.Errorf("at position %d: expected ID %s, got %s", i, expectedID, req.ID)
163- }
164- }
165-
166- if pq.Len() != 0 {
167- t.Errorf("queue should be empty, has %d items", pq.Len())
168- }
169- })
170- }
171-}
172-
173-// TestPriorityQueue_PushPop tests basic push/pop operations
174-func TestPriorityQueue_PushPop(t *testing.T) {
175- pq := make(FocusPriorityQueue, 0)
176- heap.Init(&pq)
177-
178- // Push items
179- heap.Push(&pq, &FocusRequest{ID: "1", Priority: 50})
180- heap.Push(&pq, &FocusRequest{ID: "2", Priority: 90})
181- heap.Push(&pq, &FocusRequest{ID: "3", Priority: 10})
182-
183- if pq.Len() != 3 {
184- t.Errorf("expected length 3, got %d", pq.Len())
185- }
186-
187- // Pop should return highest priority (90)
188- first := heap.Pop(&pq).(*FocusRequest)
189- if first.ID != "2" {
190- t.Errorf("expected ID '2', got '%s'", first.ID)
191- }
192-
193- // Next should be 50
194- second := heap.Pop(&pq).(*FocusRequest)
195- if second.ID != "1" {
196- t.Errorf("expected ID '1', got '%s'", second.ID)
197- }
198-
199- // Last should be 10
200- third := heap.Pop(&pq).(*FocusRequest)
201- if third.ID != "3" {
202- t.Errorf("expected ID '3', got '%s'", third.ID)
203- }
204-
205- if pq.Len() != 0 {
206- t.Errorf("queue should be empty")
207- }
208-}
209-
210-// TestRequestDeduplicator_DetectsDuplicates tests duplicate detection
211-func TestRequestDeduplicator_DetectsDuplicates(t *testing.T) {
212- dedup := NewRequestDeduplicator(100 * time.Millisecond)
213-
214- // Create a dummy paneNode (nil is ok for signature generation)
215- node := &paneNode{}
216-
217- req1 := FocusRequest{
218- TargetNode: node,
219- Source: SourceKeyboard,
220- Timestamp: time.Now(),
221- }
222-
223- // First request should not be duplicate
224- if dedup.IsDuplicate(req1, false) {
225- t.Error("first request should not be duplicate")
226- }
227-
228- // Immediate second request with same params should be duplicate
229- req2 := FocusRequest{
230- TargetNode: node,
231- Source: SourceKeyboard,
232- Timestamp: time.Now(),
233- }
234-
235- if !dedup.IsDuplicate(req2, true) {
236- t.Error("second identical request should be duplicate")
237- }
238-}
239-
240-// TestRequestDeduplicator_DifferentSourcesNotDuplicate tests different sources
241-func TestRequestDeduplicator_DifferentSourcesNotDuplicate(t *testing.T) {
242- dedup := NewRequestDeduplicator(100 * time.Millisecond)
243-
244- node := &paneNode{}
245-
246- req1 := FocusRequest{
247- TargetNode: node,
248- Source: SourceKeyboard,
249- }
250-
251- req2 := FocusRequest{
252- TargetNode: node,
253- Source: SourceMouse,
254- }
255-
256- dedup.IsDuplicate(req1, false)
257-
258- // Different source should not be duplicate
259- if dedup.IsDuplicate(req2, false) {
260- t.Error("requests with different sources should not be duplicates")
261- }
262-}
263-
264-// TestRequestDeduplicator_ExpiresOldSignatures tests TTL expiration
265-func TestRequestDeduplicator_ExpiresOldSignatures(t *testing.T) {
266- ttl := 50 * time.Millisecond
267- dedup := NewRequestDeduplicator(ttl)
268-
269- node := &paneNode{}
270-
271- req := FocusRequest{
272- TargetNode: node,
273- Source: SourceKeyboard,
274- }
275-
276- // First request
277- if dedup.IsDuplicate(req, false) {
278- t.Error("first request should not be duplicate")
279- }
280-
281- // Immediate duplicate
282- if !dedup.IsDuplicate(req, true) {
283- t.Error("immediate duplicate should be detected")
284- }
285-
286- // Wait for TTL to expire
287- time.Sleep(ttl + 10*time.Millisecond)
288-
289- // Should not be duplicate after expiration
290- if dedup.IsDuplicate(req, false) {
291- t.Error("request after TTL should not be duplicate")
292- }
293-}
294-
295-// TestRequestDeduplicator_ConcurrentAccess tests thread-safety
296-func TestRequestDeduplicator_ConcurrentAccess(t *testing.T) {
297- dedup := NewRequestDeduplicator(100 * time.Millisecond)
298- var wg sync.WaitGroup
299-
300- nodes := make([]*paneNode, 10)
301- for i := range nodes {
302- nodes[i] = &paneNode{}
303- }
304-
305- // Concurrent duplicate checks
306- for i := 0; i < 10; i++ {
307- wg.Add(1)
308- go func(idx int) {
309- defer wg.Done()
310- for j := 0; j < 100; j++ {
311- req := FocusRequest{
312- TargetNode: nodes[idx%len(nodes)],
313- Source: SourceKeyboard,
314- }
315- _ = dedup.IsDuplicate(req, false)
316- time.Sleep(time.Microsecond)
317- }
318- }(i)
319- }
320-
321- wg.Wait()
322- // Should complete without race conditions or panics
323-}
324-
325-// TestFocusRequest_Validation tests request validation logic
326-func TestFocusRequest_Validation(t *testing.T) {
327- tests := []struct {
328- name string
329- request *FocusRequest
330- wantValid bool
331- }{
332- {
333- name: "valid request",
334- request: &FocusRequest{
335- ID: "test-1",
336- TargetNode: &paneNode{},
337- Source: SourceKeyboard,
338- Priority: PriorityNormal,
339- Timestamp: time.Now(),
340- },
341- wantValid: true,
342- },
343- {
344- name: "nil target node",
345- request: &FocusRequest{
346- ID: "test-2",
347- Source: SourceKeyboard,
348- Priority: PriorityNormal,
349- Timestamp: time.Now(),
350- },
351- wantValid: false,
352- },
353- {
354- name: "empty ID",
355- request: &FocusRequest{
356- ID: "",
357- TargetNode: &paneNode{},
358- Source: SourceKeyboard,
359- Priority: PriorityNormal,
360- Timestamp: time.Now(),
361- },
362- wantValid: false,
363- },
364- }
365-
366- for _, tt := range tests {
367- t.Run(tt.name, func(t *testing.T) {
368- valid := tt.request != nil &&
369- tt.request.TargetNode != nil &&
370- tt.request.ID != ""
371-
372- if valid != tt.wantValid {
373- t.Errorf("expected valid=%v, got valid=%v", tt.wantValid, valid)
374- }
375- })
376- }
377-}
378-
379-// TestPriorityCalculation_BySource tests priority assignment by source
380-func TestPriorityCalculation_BySource(t *testing.T) {
381- tests := []struct {
382- source FocusSource
383- expected int
384- }{
385- {SourceSystem, PriorityUrgent},
386- {SourceKeyboard, PriorityHigh},
387- {SourceProgrammatic, PriorityNormal},
388- {SourceMouse, PriorityNormal},
389- {SourceStackNav, PriorityHigh},
390- {SourceSplit, PriorityHigh},
391- {SourceClose, PriorityUrgent},
392- }
393-
394- for _, tt := range tests {
395- t.Run(string(tt.source), func(t *testing.T) {
396- // This would be the actual priority calculation logic
397- // For now, we just verify expected mappings
398- var priority int
399- switch tt.source {
400- case SourceSystem, SourceClose:
401- priority = PriorityUrgent
402- case SourceKeyboard, SourceStackNav, SourceSplit:
403- priority = PriorityHigh
404- case SourceProgrammatic, SourceMouse:
405- priority = PriorityNormal
406- default:
407- priority = PriorityLow
408- }
409-
410- if priority != tt.expected {
411- t.Errorf("source %s: expected priority %d, got %d", tt.source, tt.expected, priority)
412- }
413- })
414- }
415-}
D internal/app/browser/interfaces.go +0 -88
@@ -1,88 +0,0 @@
1-package browser
2-
3-import (
4- "github.com/bnema/dumber/internal/app/messaging"
5- "github.com/bnema/dumber/pkg/webkit"
6-)
7-
8-//go:generate mockgen -source=interfaces.go -destination=mocks/mock_browser.go -package=mock_browser
9-
10-// WebViewInterface abstracts WebKit WebView for testing
11-type WebViewInterface interface {
12- LoadURL(url string) error
13- Show() error
14- Hide() error
15- Destroy() error
16- Window() *webkit.Window
17- GetCurrentURL() string
18- Widget() uintptr
19- RootWidget() uintptr
20- InjectScript(script string) error
21- DispatchCustomEvent(event string, detail map[string]any) error
22- RegisterKeyboardShortcut(key string, handler func()) error
23- RegisterURIChangedHandler(handler func(string))
24-}
25-
26-// PaneInterface abstracts BrowserPane for testing
27-type PaneInterface interface {
28- WebView() WebViewInterface
29- MessageHandler() MessageHandlerInterface
30- NavigationController() NavigationControllerInterface
31- ZoomController() ZoomControllerInterface
32- ClipboardController() ClipboardControllerInterface
33- ShortcutHandler() ShortcutHandlerInterface
34- ID() string
35- HasGUI() bool
36- SetHasGUI(bool)
37- SetID(string)
38- UpdateLastFocus()
39- HasGUIComponent(component string) bool
40- SetGUIComponent(component string, loaded bool)
41- Cleanup()
42-}
43-
44-// MessageHandlerInterface abstracts messaging.Handler
45-type MessageHandlerInterface interface {
46- SetWorkspaceObserver(observer messaging.WorkspaceObserver)
47-}
48-
49-// NavigationControllerInterface abstracts navigation operations
50-type NavigationControllerInterface interface {
51- NavigateToURL(url string) error
52-}
53-
54-// ZoomControllerInterface abstracts zoom operations
55-type ZoomControllerInterface interface {
56- ApplyInitialZoom()
57-}
58-
59-// ClipboardControllerInterface abstracts clipboard operations
60-type ClipboardControllerInterface interface {
61- // Add methods as needed
62-}
63-
64-// ShortcutHandlerInterface abstracts shortcut handling
65-type ShortcutHandlerInterface interface {
66- // Add methods as needed
67-}
68-
69-// BrowserAppInterface abstracts BrowserApp for testing
70-type BrowserAppInterface interface {
71- GetPanes() []*BrowserPane
72- SetActivePane(pane *BrowserPane)
73- GetActivePane() *BrowserPane
74- AppendPane(pane *BrowserPane)
75- RemovePane(pane *BrowserPane)
76- BuildWebkitConfig() (*webkit.Config, error)
77- CreatePaneForView(view *webkit.WebView) (*BrowserPane, error)
78-}
79-
80-// WindowInterface abstracts webkit.Window for testing
81-type WindowInterface interface {
82- SetChild(widget uintptr)
83-}
84-
85-// WindowShortcutHandlerInterface abstracts window shortcut handling for testing
86-type WindowShortcutHandlerInterface interface {
87- Cleanup()
88-}
D internal/app/browser/mocks/mock_browser.go +0 -752
@@ -1,752 +0,0 @@
1-// Code generated by MockGen. DO NOT EDIT.
2-// Source: interfaces.go
3-
4-// Package mock_browser is a generated GoMock package.
5-package mock_browser
6-
7-import (
8- reflect "reflect"
9-
10- browser "github.com/bnema/dumber/internal/app/browser"
11- messaging "github.com/bnema/dumber/internal/app/messaging"
12- webkit "github.com/bnema/dumber/pkg/webkit"
13- gomock "github.com/golang/mock/gomock"
14-)
15-
16-// MockWebViewInterface is a mock of WebViewInterface interface.
17-type MockWebViewInterface struct {
18- ctrl *gomock.Controller
19- recorder *MockWebViewInterfaceMockRecorder
20-}
21-
22-// MockWebViewInterfaceMockRecorder is the mock recorder for MockWebViewInterface.
23-type MockWebViewInterfaceMockRecorder struct {
24- mock *MockWebViewInterface
25-}
26-
27-// NewMockWebViewInterface creates a new mock instance.
28-func NewMockWebViewInterface(ctrl *gomock.Controller) *MockWebViewInterface {
29- mock := &MockWebViewInterface{ctrl: ctrl}
30- mock.recorder = &MockWebViewInterfaceMockRecorder{mock}
31- return mock
32-}
33-
34-// EXPECT returns an object that allows the caller to indicate expected use.
35-func (m *MockWebViewInterface) EXPECT() *MockWebViewInterfaceMockRecorder {
36- return m.recorder
37-}
38-
39-// Destroy mocks base method.
40-func (m *MockWebViewInterface) Destroy() error {
41- m.ctrl.T.Helper()
42- ret := m.ctrl.Call(m, "Destroy")
43- ret0, _ := ret[0].(error)
44- return ret0
45-}
46-
47-// Destroy indicates an expected call of Destroy.
48-func (mr *MockWebViewInterfaceMockRecorder) Destroy() *gomock.Call {
49- mr.mock.ctrl.T.Helper()
50- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Destroy", reflect.TypeOf((*MockWebViewInterface)(nil).Destroy))
51-}
52-
53-// DispatchCustomEvent mocks base method.
54-func (m *MockWebViewInterface) DispatchCustomEvent(event string, detail map[string]any) error {
55- m.ctrl.T.Helper()
56- ret := m.ctrl.Call(m, "DispatchCustomEvent", event, detail)
57- ret0, _ := ret[0].(error)
58- return ret0
59-}
60-
61-// DispatchCustomEvent indicates an expected call of DispatchCustomEvent.
62-func (mr *MockWebViewInterfaceMockRecorder) DispatchCustomEvent(event, detail interface{}) *gomock.Call {
63- mr.mock.ctrl.T.Helper()
64- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DispatchCustomEvent", reflect.TypeOf((*MockWebViewInterface)(nil).DispatchCustomEvent), event, detail)
65-}
66-
67-// GetCurrentURL mocks base method.
68-func (m *MockWebViewInterface) GetCurrentURL() string {
69- m.ctrl.T.Helper()
70- ret := m.ctrl.Call(m, "GetCurrentURL")
71- ret0, _ := ret[0].(string)
72- return ret0
73-}
74-
75-// GetCurrentURL indicates an expected call of GetCurrentURL.
76-func (mr *MockWebViewInterfaceMockRecorder) GetCurrentURL() *gomock.Call {
77- mr.mock.ctrl.T.Helper()
78- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCurrentURL", reflect.TypeOf((*MockWebViewInterface)(nil).GetCurrentURL))
79-}
80-
81-// Hide mocks base method.
82-func (m *MockWebViewInterface) Hide() error {
83- m.ctrl.T.Helper()
84- ret := m.ctrl.Call(m, "Hide")
85- ret0, _ := ret[0].(error)
86- return ret0
87-}
88-
89-// Hide indicates an expected call of Hide.
90-func (mr *MockWebViewInterfaceMockRecorder) Hide() *gomock.Call {
91- mr.mock.ctrl.T.Helper()
92- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Hide", reflect.TypeOf((*MockWebViewInterface)(nil).Hide))
93-}
94-
95-// InjectScript mocks base method.
96-func (m *MockWebViewInterface) InjectScript(script string) error {
97- m.ctrl.T.Helper()
98- ret := m.ctrl.Call(m, "InjectScript", script)
99- ret0, _ := ret[0].(error)
100- return ret0
101-}
102-
103-// InjectScript indicates an expected call of InjectScript.
104-func (mr *MockWebViewInterfaceMockRecorder) InjectScript(script interface{}) *gomock.Call {
105- mr.mock.ctrl.T.Helper()
106- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "InjectScript", reflect.TypeOf((*MockWebViewInterface)(nil).InjectScript), script)
107-}
108-
109-// LoadURL mocks base method.
110-func (m *MockWebViewInterface) LoadURL(url string) error {
111- m.ctrl.T.Helper()
112- ret := m.ctrl.Call(m, "LoadURL", url)
113- ret0, _ := ret[0].(error)
114- return ret0
115-}
116-
117-// LoadURL indicates an expected call of LoadURL.
118-func (mr *MockWebViewInterfaceMockRecorder) LoadURL(url interface{}) *gomock.Call {
119- mr.mock.ctrl.T.Helper()
120- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "LoadURL", reflect.TypeOf((*MockWebViewInterface)(nil).LoadURL), url)
121-}
122-
123-// RegisterKeyboardShortcut mocks base method.
124-func (m *MockWebViewInterface) RegisterKeyboardShortcut(key string, handler func()) error {
125- m.ctrl.T.Helper()
126- ret := m.ctrl.Call(m, "RegisterKeyboardShortcut", key, handler)
127- ret0, _ := ret[0].(error)
128- return ret0
129-}
130-
131-// RegisterKeyboardShortcut indicates an expected call of RegisterKeyboardShortcut.
132-func (mr *MockWebViewInterfaceMockRecorder) RegisterKeyboardShortcut(key, handler interface{}) *gomock.Call {
133- mr.mock.ctrl.T.Helper()
134- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterKeyboardShortcut", reflect.TypeOf((*MockWebViewInterface)(nil).RegisterKeyboardShortcut), key, handler)
135-}
136-
137-// RegisterURIChangedHandler mocks base method.
138-func (m *MockWebViewInterface) RegisterURIChangedHandler(handler func(string)) {
139- m.ctrl.T.Helper()
140- m.ctrl.Call(m, "RegisterURIChangedHandler", handler)
141-}
142-
143-// RegisterURIChangedHandler indicates an expected call of RegisterURIChangedHandler.
144-func (mr *MockWebViewInterfaceMockRecorder) RegisterURIChangedHandler(handler interface{}) *gomock.Call {
145- mr.mock.ctrl.T.Helper()
146- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RegisterURIChangedHandler", reflect.TypeOf((*MockWebViewInterface)(nil).RegisterURIChangedHandler), handler)
147-}
148-
149-// RootWidget mocks base method.
150-func (m *MockWebViewInterface) RootWidget() uintptr {
151- m.ctrl.T.Helper()
152- ret := m.ctrl.Call(m, "RootWidget")
153- ret0, _ := ret[0].(uintptr)
154- return ret0
155-}
156-
157-// RootWidget indicates an expected call of RootWidget.
158-func (mr *MockWebViewInterfaceMockRecorder) RootWidget() *gomock.Call {
159- mr.mock.ctrl.T.Helper()
160- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RootWidget", reflect.TypeOf((*MockWebViewInterface)(nil).RootWidget))
161-}
162-
163-// Show mocks base method.
164-func (m *MockWebViewInterface) Show() error {
165- m.ctrl.T.Helper()
166- ret := m.ctrl.Call(m, "Show")
167- ret0, _ := ret[0].(error)
168- return ret0
169-}
170-
171-// Show indicates an expected call of Show.
172-func (mr *MockWebViewInterfaceMockRecorder) Show() *gomock.Call {
173- mr.mock.ctrl.T.Helper()
174- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Show", reflect.TypeOf((*MockWebViewInterface)(nil).Show))
175-}
176-
177-// Widget mocks base method.
178-func (m *MockWebViewInterface) Widget() uintptr {
179- m.ctrl.T.Helper()
180- ret := m.ctrl.Call(m, "Widget")
181- ret0, _ := ret[0].(uintptr)
182- return ret0
183-}
184-
185-// Widget indicates an expected call of Widget.
186-func (mr *MockWebViewInterfaceMockRecorder) Widget() *gomock.Call {
187- mr.mock.ctrl.T.Helper()
188- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Widget", reflect.TypeOf((*MockWebViewInterface)(nil).Widget))
189-}
190-
191-// Window mocks base method.
192-func (m *MockWebViewInterface) Window() *webkit.Window {
193- m.ctrl.T.Helper()
194- ret := m.ctrl.Call(m, "Window")
195- ret0, _ := ret[0].(*webkit.Window)
196- return ret0
197-}
198-
199-// Window indicates an expected call of Window.
200-func (mr *MockWebViewInterfaceMockRecorder) Window() *gomock.Call {
201- mr.mock.ctrl.T.Helper()
202- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Window", reflect.TypeOf((*MockWebViewInterface)(nil).Window))
203-}
204-
205-// MockPaneInterface is a mock of PaneInterface interface.
206-type MockPaneInterface struct {
207- ctrl *gomock.Controller
208- recorder *MockPaneInterfaceMockRecorder
209-}
210-
211-// MockPaneInterfaceMockRecorder is the mock recorder for MockPaneInterface.
212-type MockPaneInterfaceMockRecorder struct {
213- mock *MockPaneInterface
214-}
215-
216-// NewMockPaneInterface creates a new mock instance.
217-func NewMockPaneInterface(ctrl *gomock.Controller) *MockPaneInterface {
218- mock := &MockPaneInterface{ctrl: ctrl}
219- mock.recorder = &MockPaneInterfaceMockRecorder{mock}
220- return mock
221-}
222-
223-// EXPECT returns an object that allows the caller to indicate expected use.
224-func (m *MockPaneInterface) EXPECT() *MockPaneInterfaceMockRecorder {
225- return m.recorder
226-}
227-
228-// Cleanup mocks base method.
229-func (m *MockPaneInterface) Cleanup() {
230- m.ctrl.T.Helper()
231- m.ctrl.Call(m, "Cleanup")
232-}
233-
234-// Cleanup indicates an expected call of Cleanup.
235-func (mr *MockPaneInterfaceMockRecorder) Cleanup() *gomock.Call {
236- mr.mock.ctrl.T.Helper()
237- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Cleanup", reflect.TypeOf((*MockPaneInterface)(nil).Cleanup))
238-}
239-
240-// ClipboardController mocks base method.
241-func (m *MockPaneInterface) ClipboardController() browser.ClipboardControllerInterface {
242- m.ctrl.T.Helper()
243- ret := m.ctrl.Call(m, "ClipboardController")
244- ret0, _ := ret[0].(browser.ClipboardControllerInterface)
245- return ret0
246-}
247-
248-// ClipboardController indicates an expected call of ClipboardController.
249-func (mr *MockPaneInterfaceMockRecorder) ClipboardController() *gomock.Call {
250- mr.mock.ctrl.T.Helper()
251- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ClipboardController", reflect.TypeOf((*MockPaneInterface)(nil).ClipboardController))
252-}
253-
254-// HasGUI mocks base method.
255-func (m *MockPaneInterface) HasGUI() bool {
256- m.ctrl.T.Helper()
257- ret := m.ctrl.Call(m, "HasGUI")
258- ret0, _ := ret[0].(bool)
259- return ret0
260-}
261-
262-// HasGUI indicates an expected call of HasGUI.
263-func (mr *MockPaneInterfaceMockRecorder) HasGUI() *gomock.Call {
264- mr.mock.ctrl.T.Helper()
265- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasGUI", reflect.TypeOf((*MockPaneInterface)(nil).HasGUI))
266-}
267-
268-// HasGUIComponent mocks base method.
269-func (m *MockPaneInterface) HasGUIComponent(component string) bool {
270- m.ctrl.T.Helper()
271- ret := m.ctrl.Call(m, "HasGUIComponent", component)
272- ret0, _ := ret[0].(bool)
273- return ret0
274-}
275-
276-// HasGUIComponent indicates an expected call of HasGUIComponent.
277-func (mr *MockPaneInterfaceMockRecorder) HasGUIComponent(component interface{}) *gomock.Call {
278- mr.mock.ctrl.T.Helper()
279- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasGUIComponent", reflect.TypeOf((*MockPaneInterface)(nil).HasGUIComponent), component)
280-}
281-
282-// ID mocks base method.
283-func (m *MockPaneInterface) ID() string {
284- m.ctrl.T.Helper()
285- ret := m.ctrl.Call(m, "ID")
286- ret0, _ := ret[0].(string)
287- return ret0
288-}
289-
290-// ID indicates an expected call of ID.
291-func (mr *MockPaneInterfaceMockRecorder) ID() *gomock.Call {
292- mr.mock.ctrl.T.Helper()
293- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ID", reflect.TypeOf((*MockPaneInterface)(nil).ID))
294-}
295-
296-// MessageHandler mocks base method.
297-func (m *MockPaneInterface) MessageHandler() browser.MessageHandlerInterface {
298- m.ctrl.T.Helper()
299- ret := m.ctrl.Call(m, "MessageHandler")
300- ret0, _ := ret[0].(browser.MessageHandlerInterface)
301- return ret0
302-}
303-
304-// MessageHandler indicates an expected call of MessageHandler.
305-func (mr *MockPaneInterfaceMockRecorder) MessageHandler() *gomock.Call {
306- mr.mock.ctrl.T.Helper()
307- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "MessageHandler", reflect.TypeOf((*MockPaneInterface)(nil).MessageHandler))
308-}
309-
310-// NavigationController mocks base method.
311-func (m *MockPaneInterface) NavigationController() browser.NavigationControllerInterface {
312- m.ctrl.T.Helper()
313- ret := m.ctrl.Call(m, "NavigationController")
314- ret0, _ := ret[0].(browser.NavigationControllerInterface)
315- return ret0
316-}
317-
318-// NavigationController indicates an expected call of NavigationController.
319-func (mr *MockPaneInterfaceMockRecorder) NavigationController() *gomock.Call {
320- mr.mock.ctrl.T.Helper()
321- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NavigationController", reflect.TypeOf((*MockPaneInterface)(nil).NavigationController))
322-}
323-
324-// SetGUIComponent mocks base method.
325-func (m *MockPaneInterface) SetGUIComponent(component string, loaded bool) {
326- m.ctrl.T.Helper()
327- m.ctrl.Call(m, "SetGUIComponent", component, loaded)
328-}
329-
330-// SetGUIComponent indicates an expected call of SetGUIComponent.
331-func (mr *MockPaneInterfaceMockRecorder) SetGUIComponent(component, loaded interface{}) *gomock.Call {
332- mr.mock.ctrl.T.Helper()
333- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetGUIComponent", reflect.TypeOf((*MockPaneInterface)(nil).SetGUIComponent), component, loaded)
334-}
335-
336-// SetHasGUI mocks base method.
337-func (m *MockPaneInterface) SetHasGUI(arg0 bool) {
338- m.ctrl.T.Helper()
339- m.ctrl.Call(m, "SetHasGUI", arg0)
340-}
341-
342-// SetHasGUI indicates an expected call of SetHasGUI.
343-func (mr *MockPaneInterfaceMockRecorder) SetHasGUI(arg0 interface{}) *gomock.Call {
344- mr.mock.ctrl.T.Helper()
345- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetHasGUI", reflect.TypeOf((*MockPaneInterface)(nil).SetHasGUI), arg0)
346-}
347-
348-// SetID mocks base method.
349-func (m *MockPaneInterface) SetID(arg0 string) {
350- m.ctrl.T.Helper()
351- m.ctrl.Call(m, "SetID", arg0)
352-}
353-
354-// SetID indicates an expected call of SetID.
355-func (mr *MockPaneInterfaceMockRecorder) SetID(arg0 interface{}) *gomock.Call {
356- mr.mock.ctrl.T.Helper()
357- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetID", reflect.TypeOf((*MockPaneInterface)(nil).SetID), arg0)
358-}
359-
360-// ShortcutHandler mocks base method.
361-func (m *MockPaneInterface) ShortcutHandler() browser.ShortcutHandlerInterface {
362- m.ctrl.T.Helper()
363- ret := m.ctrl.Call(m, "ShortcutHandler")
364- ret0, _ := ret[0].(browser.ShortcutHandlerInterface)
365- return ret0
366-}
367-
368-// ShortcutHandler indicates an expected call of ShortcutHandler.
369-func (mr *MockPaneInterfaceMockRecorder) ShortcutHandler() *gomock.Call {
370- mr.mock.ctrl.T.Helper()
371- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ShortcutHandler", reflect.TypeOf((*MockPaneInterface)(nil).ShortcutHandler))
372-}
373-
374-// UpdateLastFocus mocks base method.
375-func (m *MockPaneInterface) UpdateLastFocus() {
376- m.ctrl.T.Helper()
377- m.ctrl.Call(m, "UpdateLastFocus")
378-}
379-
380-// UpdateLastFocus indicates an expected call of UpdateLastFocus.
381-func (mr *MockPaneInterfaceMockRecorder) UpdateLastFocus() *gomock.Call {
382- mr.mock.ctrl.T.Helper()
383- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateLastFocus", reflect.TypeOf((*MockPaneInterface)(nil).UpdateLastFocus))
384-}
385-
386-// WebView mocks base method.
387-func (m *MockPaneInterface) WebView() browser.WebViewInterface {
388- m.ctrl.T.Helper()
389- ret := m.ctrl.Call(m, "WebView")
390- ret0, _ := ret[0].(browser.WebViewInterface)
391- return ret0
392-}
393-
394-// WebView indicates an expected call of WebView.
395-func (mr *MockPaneInterfaceMockRecorder) WebView() *gomock.Call {
396- mr.mock.ctrl.T.Helper()
397- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "WebView", reflect.TypeOf((*MockPaneInterface)(nil).WebView))
398-}
399-
400-// ZoomController mocks base method.
401-func (m *MockPaneInterface) ZoomController() browser.ZoomControllerInterface {
402- m.ctrl.T.Helper()
403- ret := m.ctrl.Call(m, "ZoomController")
404- ret0, _ := ret[0].(browser.ZoomControllerInterface)
405- return ret0
406-}
407-
408-// ZoomController indicates an expected call of ZoomController.
409-func (mr *MockPaneInterfaceMockRecorder) ZoomController() *gomock.Call {
410- mr.mock.ctrl.T.Helper()
411- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ZoomController", reflect.TypeOf((*MockPaneInterface)(nil).ZoomController))
412-}
413-
414-// MockMessageHandlerInterface is a mock of MessageHandlerInterface interface.
415-type MockMessageHandlerInterface struct {
416- ctrl *gomock.Controller
417- recorder *MockMessageHandlerInterfaceMockRecorder
418-}
419-
420-// MockMessageHandlerInterfaceMockRecorder is the mock recorder for MockMessageHandlerInterface.
421-type MockMessageHandlerInterfaceMockRecorder struct {
422- mock *MockMessageHandlerInterface
423-}
424-
425-// NewMockMessageHandlerInterface creates a new mock instance.
426-func NewMockMessageHandlerInterface(ctrl *gomock.Controller) *MockMessageHandlerInterface {
427- mock := &MockMessageHandlerInterface{ctrl: ctrl}
428- mock.recorder = &MockMessageHandlerInterfaceMockRecorder{mock}
429- return mock
430-}
431-
432-// EXPECT returns an object that allows the caller to indicate expected use.
433-func (m *MockMessageHandlerInterface) EXPECT() *MockMessageHandlerInterfaceMockRecorder {
434- return m.recorder
435-}
436-
437-// SetWorkspaceObserver mocks base method.
438-func (m *MockMessageHandlerInterface) SetWorkspaceObserver(observer messaging.WorkspaceObserver) {
439- m.ctrl.T.Helper()
440- m.ctrl.Call(m, "SetWorkspaceObserver", observer)
441-}
442-
443-// SetWorkspaceObserver indicates an expected call of SetWorkspaceObserver.
444-func (mr *MockMessageHandlerInterfaceMockRecorder) SetWorkspaceObserver(observer interface{}) *gomock.Call {
445- mr.mock.ctrl.T.Helper()
446- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetWorkspaceObserver", reflect.TypeOf((*MockMessageHandlerInterface)(nil).SetWorkspaceObserver), observer)
447-}
448-
449-// MockNavigationControllerInterface is a mock of NavigationControllerInterface interface.
450-type MockNavigationControllerInterface struct {
451- ctrl *gomock.Controller
452- recorder *MockNavigationControllerInterfaceMockRecorder
453-}
454-
455-// MockNavigationControllerInterfaceMockRecorder is the mock recorder for MockNavigationControllerInterface.
456-type MockNavigationControllerInterfaceMockRecorder struct {
457- mock *MockNavigationControllerInterface
458-}
459-
460-// NewMockNavigationControllerInterface creates a new mock instance.
461-func NewMockNavigationControllerInterface(ctrl *gomock.Controller) *MockNavigationControllerInterface {
462- mock := &MockNavigationControllerInterface{ctrl: ctrl}
463- mock.recorder = &MockNavigationControllerInterfaceMockRecorder{mock}
464- return mock
465-}
466-
467-// EXPECT returns an object that allows the caller to indicate expected use.
468-func (m *MockNavigationControllerInterface) EXPECT() *MockNavigationControllerInterfaceMockRecorder {
469- return m.recorder
470-}
471-
472-// NavigateToURL mocks base method.
473-func (m *MockNavigationControllerInterface) NavigateToURL(url string) error {
474- m.ctrl.T.Helper()
475- ret := m.ctrl.Call(m, "NavigateToURL", url)
476- ret0, _ := ret[0].(error)
477- return ret0
478-}
479-
480-// NavigateToURL indicates an expected call of NavigateToURL.
481-func (mr *MockNavigationControllerInterfaceMockRecorder) NavigateToURL(url interface{}) *gomock.Call {
482- mr.mock.ctrl.T.Helper()
483- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "NavigateToURL", reflect.TypeOf((*MockNavigationControllerInterface)(nil).NavigateToURL), url)
484-}
485-
486-// MockZoomControllerInterface is a mock of ZoomControllerInterface interface.
487-type MockZoomControllerInterface struct {
488- ctrl *gomock.Controller
489- recorder *MockZoomControllerInterfaceMockRecorder
490-}
491-
492-// MockZoomControllerInterfaceMockRecorder is the mock recorder for MockZoomControllerInterface.
493-type MockZoomControllerInterfaceMockRecorder struct {
494- mock *MockZoomControllerInterface
495-}
496-
497-// NewMockZoomControllerInterface creates a new mock instance.
498-func NewMockZoomControllerInterface(ctrl *gomock.Controller) *MockZoomControllerInterface {
499- mock := &MockZoomControllerInterface{ctrl: ctrl}
500- mock.recorder = &MockZoomControllerInterfaceMockRecorder{mock}
501- return mock
502-}
503-
504-// EXPECT returns an object that allows the caller to indicate expected use.
505-func (m *MockZoomControllerInterface) EXPECT() *MockZoomControllerInterfaceMockRecorder {
506- return m.recorder
507-}
508-
509-// ApplyInitialZoom mocks base method.
510-func (m *MockZoomControllerInterface) ApplyInitialZoom() {
511- m.ctrl.T.Helper()
512- m.ctrl.Call(m, "ApplyInitialZoom")
513-}
514-
515-// ApplyInitialZoom indicates an expected call of ApplyInitialZoom.
516-func (mr *MockZoomControllerInterfaceMockRecorder) ApplyInitialZoom() *gomock.Call {
517- mr.mock.ctrl.T.Helper()
518- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ApplyInitialZoom", reflect.TypeOf((*MockZoomControllerInterface)(nil).ApplyInitialZoom))
519-}
520-
521-// MockClipboardControllerInterface is a mock of ClipboardControllerInterface interface.
522-type MockClipboardControllerInterface struct {
523- ctrl *gomock.Controller
524- recorder *MockClipboardControllerInterfaceMockRecorder
525-}
526-
527-// MockClipboardControllerInterfaceMockRecorder is the mock recorder for MockClipboardControllerInterface.
528-type MockClipboardControllerInterfaceMockRecorder struct {
529- mock *MockClipboardControllerInterface
530-}
531-
532-// NewMockClipboardControllerInterface creates a new mock instance.
533-func NewMockClipboardControllerInterface(ctrl *gomock.Controller) *MockClipboardControllerInterface {
534- mock := &MockClipboardControllerInterface{ctrl: ctrl}
535- mock.recorder = &MockClipboardControllerInterfaceMockRecorder{mock}
536- return mock
537-}
538-
539-// EXPECT returns an object that allows the caller to indicate expected use.
540-func (m *MockClipboardControllerInterface) EXPECT() *MockClipboardControllerInterfaceMockRecorder {
541- return m.recorder
542-}
543-
544-// MockShortcutHandlerInterface is a mock of ShortcutHandlerInterface interface.
545-type MockShortcutHandlerInterface struct {
546- ctrl *gomock.Controller
547- recorder *MockShortcutHandlerInterfaceMockRecorder
548-}
549-
550-// MockShortcutHandlerInterfaceMockRecorder is the mock recorder for MockShortcutHandlerInterface.
551-type MockShortcutHandlerInterfaceMockRecorder struct {
552- mock *MockShortcutHandlerInterface
553-}
554-
555-// NewMockShortcutHandlerInterface creates a new mock instance.
556-func NewMockShortcutHandlerInterface(ctrl *gomock.Controller) *MockShortcutHandlerInterface {
557- mock := &MockShortcutHandlerInterface{ctrl: ctrl}
558- mock.recorder = &MockShortcutHandlerInterfaceMockRecorder{mock}
559- return mock
560-}
561-
562-// EXPECT returns an object that allows the caller to indicate expected use.
563-func (m *MockShortcutHandlerInterface) EXPECT() *MockShortcutHandlerInterfaceMockRecorder {
564- return m.recorder
565-}
566-
567-// MockBrowserAppInterface is a mock of BrowserAppInterface interface.
568-type MockBrowserAppInterface struct {
569- ctrl *gomock.Controller
570- recorder *MockBrowserAppInterfaceMockRecorder
571-}
572-
573-// MockBrowserAppInterfaceMockRecorder is the mock recorder for MockBrowserAppInterface.
574-type MockBrowserAppInterfaceMockRecorder struct {
575- mock *MockBrowserAppInterface
576-}
577-
578-// NewMockBrowserAppInterface creates a new mock instance.
579-func NewMockBrowserAppInterface(ctrl *gomock.Controller) *MockBrowserAppInterface {
580- mock := &MockBrowserAppInterface{ctrl: ctrl}
581- mock.recorder = &MockBrowserAppInterfaceMockRecorder{mock}
582- return mock
583-}
584-
585-// EXPECT returns an object that allows the caller to indicate expected use.
586-func (m *MockBrowserAppInterface) EXPECT() *MockBrowserAppInterfaceMockRecorder {
587- return m.recorder
588-}
589-
590-// AppendPane mocks base method.
591-func (m *MockBrowserAppInterface) AppendPane(pane *browser.BrowserPane) {
592- m.ctrl.T.Helper()
593- m.ctrl.Call(m, "AppendPane", pane)
594-}
595-
596-// AppendPane indicates an expected call of AppendPane.
597-func (mr *MockBrowserAppInterfaceMockRecorder) AppendPane(pane interface{}) *gomock.Call {
598- mr.mock.ctrl.T.Helper()
599- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AppendPane", reflect.TypeOf((*MockBrowserAppInterface)(nil).AppendPane), pane)
600-}
601-
602-// BuildWebkitConfig mocks base method.
603-func (m *MockBrowserAppInterface) BuildWebkitConfig() (*webkit.Config, error) {
604- m.ctrl.T.Helper()
605- ret := m.ctrl.Call(m, "BuildWebkitConfig")
606- ret0, _ := ret[0].(*webkit.Config)
607- ret1, _ := ret[1].(error)
608- return ret0, ret1
609-}
610-
611-// BuildWebkitConfig indicates an expected call of BuildWebkitConfig.
612-func (mr *MockBrowserAppInterfaceMockRecorder) BuildWebkitConfig() *gomock.Call {
613- mr.mock.ctrl.T.Helper()
614- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildWebkitConfig", reflect.TypeOf((*MockBrowserAppInterface)(nil).BuildWebkitConfig))
615-}
616-
617-// CreatePaneForView mocks base method.
618-func (m *MockBrowserAppInterface) CreatePaneForView(view *webkit.WebView) (*browser.BrowserPane, error) {
619- m.ctrl.T.Helper()
620- ret := m.ctrl.Call(m, "CreatePaneForView", view)
621- ret0, _ := ret[0].(*browser.BrowserPane)
622- ret1, _ := ret[1].(error)
623- return ret0, ret1
624-}
625-
626-// CreatePaneForView indicates an expected call of CreatePaneForView.
627-func (mr *MockBrowserAppInterfaceMockRecorder) CreatePaneForView(view interface{}) *gomock.Call {
628- mr.mock.ctrl.T.Helper()
629- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CreatePaneForView", reflect.TypeOf((*MockBrowserAppInterface)(nil).CreatePaneForView), view)
630-}
631-
632-// GetActivePane mocks base method.
633-func (m *MockBrowserAppInterface) GetActivePane() *browser.BrowserPane {
634- m.ctrl.T.Helper()
635- ret := m.ctrl.Call(m, "GetActivePane")
636- ret0, _ := ret[0].(*browser.BrowserPane)
637- return ret0
638-}
639-
640-// GetActivePane indicates an expected call of GetActivePane.
641-func (mr *MockBrowserAppInterfaceMockRecorder) GetActivePane() *gomock.Call {
642- mr.mock.ctrl.T.Helper()
643- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetActivePane", reflect.TypeOf((*MockBrowserAppInterface)(nil).GetActivePane))
644-}
645-
646-// GetPanes mocks base method.
647-func (m *MockBrowserAppInterface) GetPanes() []*browser.BrowserPane {
648- m.ctrl.T.Helper()
649- ret := m.ctrl.Call(m, "GetPanes")
650- ret0, _ := ret[0].([]*browser.BrowserPane)
651- return ret0
652-}
653-
654-// GetPanes indicates an expected call of GetPanes.
655-func (mr *MockBrowserAppInterfaceMockRecorder) GetPanes() *gomock.Call {
656- mr.mock.ctrl.T.Helper()
657- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPanes", reflect.TypeOf((*MockBrowserAppInterface)(nil).GetPanes))
658-}
659-
660-// RemovePane mocks base method.
661-func (m *MockBrowserAppInterface) RemovePane(pane *browser.BrowserPane) {
662- m.ctrl.T.Helper()
663- m.ctrl.Call(m, "RemovePane", pane)
664-}
665-
666-// RemovePane indicates an expected call of RemovePane.
667-func (mr *MockBrowserAppInterfaceMockRecorder) RemovePane(pane interface{}) *gomock.Call {
668- mr.mock.ctrl.T.Helper()
669- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemovePane", reflect.TypeOf((*MockBrowserAppInterface)(nil).RemovePane), pane)
670-}
671-
672-// SetActivePane mocks base method.
673-func (m *MockBrowserAppInterface) SetActivePane(pane *browser.BrowserPane) {
674- m.ctrl.T.Helper()
675- m.ctrl.Call(m, "SetActivePane", pane)
676-}
677-
678-// SetActivePane indicates an expected call of SetActivePane.
679-func (mr *MockBrowserAppInterfaceMockRecorder) SetActivePane(pane interface{}) *gomock.Call {
680- mr.mock.ctrl.T.Helper()
681- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetActivePane", reflect.TypeOf((*MockBrowserAppInterface)(nil).SetActivePane), pane)
682-}
683-
684-// MockWindowInterface is a mock of WindowInterface interface.
685-type MockWindowInterface struct {
686- ctrl *gomock.Controller
687- recorder *MockWindowInterfaceMockRecorder
688-}
689-
690-// MockWindowInterfaceMockRecorder is the mock recorder for MockWindowInterface.
691-type MockWindowInterfaceMockRecorder struct {
692- mock *MockWindowInterface
693-}
694-
695-// NewMockWindowInterface creates a new mock instance.
696-func NewMockWindowInterface(ctrl *gomock.Controller) *MockWindowInterface {
697- mock := &MockWindowInterface{ctrl: ctrl}
698- mock.recorder = &MockWindowInterfaceMockRecorder{mock}
699- return mock
700-}
701-
702-// EXPECT returns an object that allows the caller to indicate expected use.
703-func (m *MockWindowInterface) EXPECT() *MockWindowInterfaceMockRecorder {
704- return m.recorder
705-}
706-
707-// SetChild mocks base method.
708-func (m *MockWindowInterface) SetChild(widget uintptr) {
709- m.ctrl.T.Helper()
710- m.ctrl.Call(m, "SetChild", widget)
711-}
712-
713-// SetChild indicates an expected call of SetChild.
714-func (mr *MockWindowInterfaceMockRecorder) SetChild(widget interface{}) *gomock.Call {
715- mr.mock.ctrl.T.Helper()
716- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetChild", reflect.TypeOf((*MockWindowInterface)(nil).SetChild), widget)
717-}
718-
719-// MockWindowShortcutHandlerInterface is a mock of WindowShortcutHandlerInterface interface.
720-type MockWindowShortcutHandlerInterface struct {
721- ctrl *gomock.Controller
722- recorder *MockWindowShortcutHandlerInterfaceMockRecorder
723-}
724-
725-// MockWindowShortcutHandlerInterfaceMockRecorder is the mock recorder for MockWindowShortcutHandlerInterface.
726-type MockWindowShortcutHandlerInterfaceMockRecorder struct {
727- mock *MockWindowShortcutHandlerInterface
728-}
729-
730-// NewMockWindowShortcutHandlerInterface creates a new mock instance.
731-func NewMockWindowShortcutHandlerInterface(ctrl *gomock.Controller) *MockWindowShortcutHandlerInterface {
732- mock := &MockWindowShortcutHandlerInterface{ctrl: ctrl}
733- mock.recorder = &MockWindowShortcutHandlerInterfaceMockRecorder{mock}
734- return mock
735-}
736-
737-// EXPECT returns an object that allows the caller to indicate expected use.
738-func (m *MockWindowShortcutHandlerInterface) EXPECT() *MockWindowShortcutHandlerInterfaceMockRecorder {
739- return m.recorder
740-}
741-
742-// Cleanup mocks base method.
743-func (m *MockWindowShortcutHandlerInterface) Cleanup() {
744- m.ctrl.T.Helper()
745- m.ctrl.Call(m, "Cleanup")
746-}
747-
748-// Cleanup indicates an expected call of Cleanup.
749-func (mr *MockWindowShortcutHandlerInterfaceMockRecorder) Cleanup() *gomock.Call {
750- mr.mock.ctrl.T.Helper()
751- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Cleanup", reflect.TypeOf((*MockWindowShortcutHandlerInterface)(nil).Cleanup))
752-}
D internal/app/browser/pane.go +0 -138
@@ -1,138 +0,0 @@
1-package browser
2-
3-import (
4- "fmt"
5- "time"
6-
7- "github.com/bnema/dumber/internal/app/control"
8- "github.com/bnema/dumber/internal/app/messaging"
9- "github.com/bnema/dumber/internal/logging"
10- "github.com/bnema/dumber/pkg/webkit"
11-)
12-
13-// BrowserPane bundles the per-pane WebView and its controllers.
14-type BrowserPane struct {
15- webView *webkit.WebView
16- navigationController *control.NavigationController
17- zoomController *control.ZoomController
18- clipboardController *control.ClipboardController
19- messageHandler *messaging.Handler
20- shortcutHandler *ShortcutHandler
21-
22- // Multi-pane support
23- id string
24- hasGUI bool
25- guiComponents map[string]bool
26- lastFocusTime time.Time
27-}
28-
29-func (p *BrowserPane) WebView() *webkit.WebView { return p.webView }
30-
31-func (p *BrowserPane) MessageHandler() *messaging.Handler { return p.messageHandler }
32-
33-func (p *BrowserPane) NavigationController() *control.NavigationController {
34- return p.navigationController
35-}
36-
37-func (p *BrowserPane) ZoomController() *control.ZoomController { return p.zoomController }
38-
39-func (p *BrowserPane) ClipboardController() *control.ClipboardController {
40- return p.clipboardController
41-}
42-
43-func (p *BrowserPane) ShortcutHandler() *ShortcutHandler { return p.shortcutHandler }
44-
45-// ID returns the unique pane identifier
46-func (p *BrowserPane) ID() string { return p.id }
47-
48-// HasGUI returns whether GUI components are injected
49-func (p *BrowserPane) HasGUI() bool { return p.hasGUI }
50-
51-// SetHasGUI marks GUI components as injected
52-func (p *BrowserPane) SetHasGUI(has bool) { p.hasGUI = has }
53-
54-// SetID sets the pane identifier
55-func (p *BrowserPane) SetID(id string) { p.id = id }
56-
57-// UpdateLastFocus updates the last focus time
58-func (p *BrowserPane) UpdateLastFocus() { p.lastFocusTime = time.Now() }
59-
60-// Initialize GUI component tracking
61-func (p *BrowserPane) initializeGUITracking() {
62- if p.guiComponents == nil {
63- p.guiComponents = make(map[string]bool)
64- }
65-}
66-
67-// HasGUIComponent checks if a specific GUI component is loaded
68-func (p *BrowserPane) HasGUIComponent(component string) bool {
69- if p.guiComponents == nil {
70- return false
71- }
72- return p.guiComponents[component]
73-}
74-
75-// SetGUIComponent marks a GUI component as loaded
76-func (p *BrowserPane) SetGUIComponent(component string, loaded bool) {
77- p.initializeGUITracking()
78- p.guiComponents[component] = loaded
79-}
80-
81-// Cleanup releases pane resources
82-func (p *BrowserPane) Cleanup() {
83- if p == nil {
84- return
85- }
86-
87- if p.shortcutHandler != nil {
88- p.shortcutHandler.Detach()
89- logging.Debug(fmt.Sprintf("[pane-%s] Cleaned up shortcuts", p.id))
90- }
91-
92- if p.navigationController != nil {
93- p.navigationController.Detach()
94- }
95-
96- if p.zoomController != nil {
97- p.zoomController.DetachWebView()
98- }
99-
100- if p.clipboardController != nil {
101- p.clipboardController.Detach()
102- }
103-
104- if p.messageHandler != nil {
105- p.messageHandler.SetWebView(nil)
106- }
107-
108- // Destroy WebView to unregister from global registry and release resources
109- if p.webView != nil && !p.webView.IsDestroyed() {
110- if err := p.webView.Destroy(); err != nil {
111- logging.Error(fmt.Sprintf("[pane-%s] failed to destroy webview: %v", p.id, err))
112- } else {
113- logging.Debug(fmt.Sprintf("[pane-%s] successfully destroyed webview", p.id))
114- }
115- }
116-
117- // Clear tracking
118- p.guiComponents = nil
119- p.hasGUI = false
120-}
121-
122-// CleanupFromWorkspace removes this pane from workspace tracking maps and app.panes slice
123-// This should be called before Cleanup() to ensure proper workspace state management
124-func (p *BrowserPane) CleanupFromWorkspace(wm interface{}) {
125- if p == nil || p.webView == nil {
126- return
127- }
128-
129- // Type assert to avoid circular import - workspace manager will pass itself
130- if workspaceManager, ok := wm.(interface {
131- removeFromMaps(*webkit.WebView)
132- removeFromAppPanes(*BrowserPane)
133- }); ok {
134- workspaceManager.removeFromMaps(p.webView)
135- workspaceManager.removeFromAppPanes(p)
136- logging.Debug(fmt.Sprintf("[pane-%s] Cleaned up from workspace tracking", p.id))
137- }
138-}
D internal/app/browser/prefetch.go +0 -306
@@ -1,306 +0,0 @@
1-// Package browser provides the main browser application components.
2-package browser
3-
4-import (
5- "context"
6- "fmt"
7- "net/url"
8- "sync"
9- "sync/atomic"
10- "time"
11-
12- "github.com/bnema/dumber/internal/db"
13- "github.com/bnema/dumber/internal/logging"
14- "github.com/bnema/dumber/pkg/webkit"
15-)
16-
17-// PrefetchManager handles DNS prefetching and resource preloading.
18-// Improves page load times by resolving DNS before navigation.
19-type PrefetchManager struct {
20- mu sync.RWMutex
21-
22- // Configuration
23- enabled int32 // atomic: 1 = enabled, 0 = disabled
24- maxPrefetches int // Maximum concurrent prefetches
25- throttleDelay time.Duration // Minimum delay between prefetches for same domain
26- maxCacheSize int // Maximum entries in recentPrefetches before cleanup
27-
28- // State tracking
29- recentPrefetches map[string]time.Time // domain -> last prefetch time
30- prefetchCount int64
31-
32- // Database access for history-based prefetching
33- queries db.DatabaseQuerier
34-}
35-
36-// PrefetchConfig contains configuration for the prefetch manager
37-type PrefetchConfig struct {
38- Enabled bool
39- MaxPrefetches int
40- ThrottleDelay time.Duration
41-}
42-
43-// DefaultPrefetchConfig returns sensible default configuration
44-func DefaultPrefetchConfig() PrefetchConfig {
45- return PrefetchConfig{
46- Enabled: true,
47- MaxPrefetches: 10,
48- ThrottleDelay: 100 * time.Millisecond,
49- }
50-}
51-
52-// NewPrefetchManager creates a new prefetch manager
53-func NewPrefetchManager(cfg PrefetchConfig, queries db.DatabaseQuerier) *PrefetchManager {
54- var enabled int32
55- if cfg.Enabled {
56- enabled = 1
57- }
58- return &PrefetchManager{
59- enabled: enabled,
60- maxPrefetches: cfg.MaxPrefetches,
61- throttleDelay: cfg.ThrottleDelay,
62- maxCacheSize: 500, // Limit cache to prevent unbounded growth
63- recentPrefetches: make(map[string]time.Time),
64- queries: queries,
65- }
66-}
67-
68-// isEnabled returns true if prefetching is enabled (atomic read)
69-func (pm *PrefetchManager) isEnabled() bool {
70- return atomic.LoadInt32(&pm.enabled) == 1
71-}
72-
73-// cleanupOldEntries removes old entries from the cache to prevent unbounded growth
74-func (pm *PrefetchManager) cleanupOldEntries() {
75- // Only cleanup if we exceed max size
76- if len(pm.recentPrefetches) <= pm.maxCacheSize {
77- return
78- }
79-
80- cutoff := time.Now().Add(-10 * pm.throttleDelay)
81- for hostname, lastTime := range pm.recentPrefetches {
82- if lastTime.Before(cutoff) {
83- delete(pm.recentPrefetches, hostname)
84- }
85- }
86-}
87-
88-// PrefetchDNS prefetches DNS for the given URL.
89-// Throttles requests to avoid excessive DNS queries.
90-func (pm *PrefetchManager) PrefetchDNS(rawURL string) {
91- if !pm.isEnabled() || rawURL == "" {
92- return
93- }
94-
95- // Extract hostname from URL
96- hostname := pm.extractHostname(rawURL)
97- if hostname == "" {
98- return
99- }
100-
101- // Check throttle
102- pm.mu.Lock()
103- if lastPrefetch, exists := pm.recentPrefetches[hostname]; exists {
104- if time.Since(lastPrefetch) < pm.throttleDelay {
105- pm.mu.Unlock()
106- return
107- }
108- }
109- pm.recentPrefetches[hostname] = time.Now()
110- pm.prefetchCount++
111- pm.cleanupOldEntries() // Prevent unbounded growth
112- pm.mu.Unlock()
113-
114- // Perform DNS prefetch
115- webkit.PrefetchDNS(hostname)
116- logging.Debug(fmt.Sprintf("[prefetch] DNS prefetch for %s", hostname))
117-}
118-
119-// PrefetchDNSBatch prefetches DNS for multiple URLs
120-func (pm *PrefetchManager) PrefetchDNSBatch(urls []string) {
121- if !pm.isEnabled() {
122- return
123- }
124-
125- seen := make(map[string]bool)
126- count := 0
127-
128- for _, rawURL := range urls {
129- if count >= pm.maxPrefetches {
130- break
131- }
132-
133- hostname := pm.extractHostname(rawURL)
134- if hostname == "" || seen[hostname] {
135- continue
136- }
137- seen[hostname] = true
138-
139- pm.PrefetchDNS(rawURL)
140- count++
141- }
142-
143- if count > 0 {
144- logging.Debug(fmt.Sprintf("[prefetch] Batch prefetched DNS for %d domains", count))
145- }
146-}
147-
148-// PrefetchFromHistory prefetches DNS for frequently visited domains.
149-// Should be called after startup to warm DNS cache.
150-func (pm *PrefetchManager) PrefetchFromHistory(ctx context.Context) {
151- if !pm.isEnabled() || pm.queries == nil {
152- return
153- }
154-
155- // Get most visited URLs from history
156- entries, err := pm.queries.GetMostVisited(ctx, int64(pm.maxPrefetches*2))
157- if err != nil {
158- logging.Warn(fmt.Sprintf("[prefetch] Failed to get history for prefetch: %v", err))
159- return
160- }
161-
162- // Extract unique hostnames
163- seen := make(map[string]bool)
164- hostnames := make([]string, 0, pm.maxPrefetches)
165-
166- for _, entry := range entries {
167- hostname := pm.extractHostname(entry.Url)
168- if hostname == "" || seen[hostname] {
169- continue
170- }
171- seen[hostname] = true
172- hostnames = append(hostnames, hostname)
173-
174- if len(hostnames) >= pm.maxPrefetches {
175- break
176- }
177- }
178-
179- // Prefetch in background
180- if len(hostnames) > 0 {
181- go func() {
182- for _, hostname := range hostnames {
183- webkit.PrefetchDNS(hostname)
184- pm.mu.Lock()
185- pm.recentPrefetches[hostname] = time.Now()
186- pm.prefetchCount++
187- pm.mu.Unlock()
188- }
189- logging.Info(fmt.Sprintf("[prefetch] Prefetched DNS for %d domains from history", len(hostnames)))
190- }()
191- }
192-}
193-
194-// PrefetchCommonDomains prefetches DNS for commonly used CDNs and services.
195-// Call this at startup for faster loading of common resources.
196-func (pm *PrefetchManager) PrefetchCommonDomains() {
197- if !pm.isEnabled() {
198- return
199- }
200-
201- commonDomains := []string{
202- // CDNs
203- "cdnjs.cloudflare.com",
204- "cdn.jsdelivr.net",
205- "unpkg.com",
206- "code.jquery.com",
207- // Fonts
208- "fonts.googleapis.com",
209- "fonts.gstatic.com",
210- // Common services
211- "www.google.com",
212- "www.googleapis.com",
213- "api.github.com",
214- "raw.githubusercontent.com",
215- }
216-
217- go func() {
218- for _, domain := range commonDomains {
219- webkit.PrefetchDNS(domain)
220- pm.mu.Lock()
221- pm.recentPrefetches[domain] = time.Now()
222- pm.prefetchCount++
223- pm.mu.Unlock()
224- }
225- logging.Info(fmt.Sprintf("[prefetch] Prefetched DNS for %d common domains", len(commonDomains)))
226- }()
227-}
228-
229-// OnLinkHover should be called when user hovers over a link.
230-// Prefetches DNS for the link target.
231-func (pm *PrefetchManager) OnLinkHover(linkURL string) {
232- pm.PrefetchDNS(linkURL)
233-}
234-
235-// extractHostname extracts the hostname from a URL string
236-func (pm *PrefetchManager) extractHostname(rawURL string) string {
237- if rawURL == "" {
238- return ""
239- }
240-
241- // Handle URLs without scheme
242- if rawURL[0] != 'h' && rawURL[0] != 'H' {
243- // Check if it looks like a hostname
244- if !containsScheme(rawURL) {
245- rawURL = "https://" + rawURL
246- }
247- }
248-
249- parsed, err := url.Parse(rawURL)
250- if err != nil {
251- return ""
252- }
253-
254- hostname := parsed.Hostname()
255- if hostname == "" || hostname == "localhost" {
256- return ""
257- }
258-
259- // Skip internal URLs
260- if hostname == "127.0.0.1" || hostname == "::1" {
261- return ""
262- }
263-
264- return hostname
265-}
266-
267-// containsScheme checks if URL contains a scheme
268-func containsScheme(rawURL string) bool {
269- for i := 0; i < len(rawURL) && i < 10; i++ {
270- if rawURL[i] == ':' {
271- return true
272- }
273- if rawURL[i] == '/' || rawURL[i] == '?' || rawURL[i] == '#' {
274- return false
275- }
276- }
277- return false
278-}
279-
280-// Stats returns prefetch statistics
281-func (pm *PrefetchManager) Stats() map[string]interface{} {
282- pm.mu.RLock()
283- defer pm.mu.RUnlock()
284-
285- return map[string]interface{}{
286- "enabled": pm.isEnabled(),
287- "prefetch_count": pm.prefetchCount,
288- "cached_domains": len(pm.recentPrefetches),
289- }
290-}
291-
292-// SetEnabled enables or disables prefetching
293-func (pm *PrefetchManager) SetEnabled(enabled bool) {
294- var val int32
295- if enabled {
296- val = 1
297- }
298- atomic.StoreInt32(&pm.enabled, val)
299-}
300-
301-// ClearCache clears the prefetch throttle cache
302-func (pm *PrefetchManager) ClearCache() {
303- pm.mu.Lock()
304- defer pm.mu.Unlock()
305- pm.recentPrefetches = make(map[string]time.Time)
306-}
D internal/app/browser/progress_bar.go +0 -24
@@ -1,24 +0,0 @@
1-package browser
2-
3-import (
4- "github.com/bnema/dumber/internal/logging"
5- "github.com/bnema/dumber/pkg/webkit"
6- "github.com/diamondburned/gotk4/pkg/gtk/v4"
7-)
8-
9-// createProgressBar builds the shared loading indicator shown at the bottom of the window.
10-func (tm *TabManager) createProgressBar() gtk.Widgetter {
11- bar := gtk.NewProgressBar()
12- if bar == nil {
13- logging.Error("[tabs] Failed to create tab progress bar")
14- return nil
15- }
16-
17- bar.SetHExpand(true)
18- bar.SetVExpand(false)
19- bar.SetShowText(false)
20- bar.SetFraction(0.0)
21- bar.AddCSSClass("tab-progress-bar")
22- webkit.WidgetSetVisible(bar, false)
23- return bar
24-}
D internal/app/browser/shortcuts.go +0 -141
@@ -1,141 +0,0 @@
1-package browser
2-
3-import (
4- "encoding/json"
5- "fmt"
6-
7- "github.com/bnema/dumber/internal/app/control"
8- "github.com/bnema/dumber/internal/config"
9- "github.com/bnema/dumber/internal/logging"
10- "github.com/bnema/dumber/pkg/webkit"
11-)
12-
13-// ShortcutHandler manages keyboard shortcuts for the browser
14-type ShortcutHandler struct {
15- webView *webkit.WebView
16- clipboardController *control.ClipboardController
17- config *config.Config
18- app *BrowserApp
19-}
20-
21-// NewShortcutHandler creates a new shortcut handler
22-func NewShortcutHandler(webView *webkit.WebView, clipboardController *control.ClipboardController, cfg *config.Config, app *BrowserApp) *ShortcutHandler {
23- return &ShortcutHandler{
24- webView: webView,
25- clipboardController: clipboardController,
26- config: cfg,
27- app: app,
28- }
29-}
30-
31-func (s *ShortcutHandler) isActivePane() bool {
32- if s == nil || s.webView == nil {
33- return false
34- }
35- if s.app == nil || s.app.workspace == nil {
36- return true // No workspace context; allow
37- }
38-
39- // Find node for this WebView
40- node := s.app.workspace.viewToNode[s.webView]
41- if node == nil {
42- return false
43- }
44-
45- // For related popups, they're active if parent chain contains the active node
46- if node.isRelated && node.parentPane != nil {
47- activeNode := s.app.workspace.GetActiveNode()
48- cur := node
49- for cur != nil {
50- if cur == activeNode {
51- return true
52- }
53- cur = cur.parentPane
54- }
55- return false
56- }
57-
58- // Independent panes: equal to active node
59- return node == s.app.workspace.GetActiveNode()
60-}
61-
62-// RegisterShortcuts registers pane-specific keyboard shortcuts with focus guards
63-func (s *ShortcutHandler) RegisterShortcuts() {
64- s.exposeWorkspaceConfig()
65-
66- // NOTE: Global shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C, F12) are now handled
67- // by WindowShortcutHandler at the window level to prevent duplicates
68-
69- // Page refresh shortcuts
70- _ = s.webView.RegisterKeyboardShortcut("ctrl+r", 0, func() {
71- if !s.isActivePane() {
72- return
73- }
74- logging.Debug("Shortcut: Reload page")
75- _ = s.webView.Reload()
76- })
77-
78- _ = s.webView.RegisterKeyboardShortcut("ctrl+shift+r", 0, func() {
79- if !s.isActivePane() {
80- return
81- }
82- logging.Debug("Shortcut: Hard reload page")
83- _ = s.webView.ReloadBypassCache()
84- })
85-
86- _ = s.webView.RegisterKeyboardShortcut("F5", 0, func() {
87- if !s.isActivePane() {
88- return
89- }
90- logging.Debug("Shortcut: F5 reload")
91- _ = s.webView.Reload()
92- })
93-
94- // Note: Workspace pane navigation shortcuts (Alt + Arrow keys) are handled by WorkspaceManager
95- // and zoom shortcuts (Ctrl +/-/0) are handled by WindowShortcutHandler to ensure they work properly across all panes
96-}
97-
98-func (s *ShortcutHandler) dispatchUIShortcut(action string, extra map[string]any) error {
99- if s == nil || s.webView == nil {
100- return fmt.Errorf("webview unavailable for action %s", action)
101- }
102-
103- detail := map[string]any{
104- "action": action,
105- }
106- for k, v := range extra {
107- detail[k] = v
108- }
109-
110- return s.webView.DispatchCustomEvent("dumber:ui:shortcut", detail)
111-}
112-
113-func (s *ShortcutHandler) exposeWorkspaceConfig() {
114- if s.webView == nil || s.config == nil {
115- return
116- }
117-
118- payload, err := json.Marshal(s.config.Workspace)
119- if err != nil {
120- logging.Error(fmt.Sprintf("Shortcut: failed to marshal workspace config: %v", err))
121- return
122- }
123-
124- script := fmt.Sprintf(`(function(){
125- const cfg = %s;
126- window.__dumber_workspace_config = cfg;
127- document.dispatchEvent(new CustomEvent('dumber:workspace-config',{detail:cfg}));
128-})();`, string(payload))
129-
130- if err := s.webView.InjectScript(script); err != nil {
131- logging.Error(fmt.Sprintf("Shortcut: failed to expose workspace config: %v", err))
132- }
133-}
134-
135-// Detach releases the WebView reference to prevent post-destruction usage.
136-func (s *ShortcutHandler) Detach() {
137- if s == nil {
138- return
139- }
140- s.webView = nil
141-}
D internal/app/browser/stacked_panes.go +0 -963
@@ -1,963 +0,0 @@
1-package browser
2-
3-import (
4- "errors"
5- "fmt"
6- "sync/atomic"
7- "time"
8-
9- "github.com/bnema/dumber/internal/logging"
10- "github.com/bnema/dumber/pkg/webkit"
11- "github.com/diamondburned/gotk4/pkg/gdk/v4"
12- "github.com/diamondburned/gotk4/pkg/gtk/v4"
13-)
14-
15-const (
16- // faviconPlaceholder is the emoji displayed when no favicon is available
17- faviconPlaceholder = "🌐"
18-)
19-
20-// StackedPaneManager handles all stacked pane operations
21-type StackedPaneManager struct {
22- wm *WorkspaceManager
23- titleBarToPane map[uint64]*paneNode
24- nextTitleBarID uint64
25-}
26-
27-// NewStackedPaneManager creates a new stacked pane manager
28-func NewStackedPaneManager(wm *WorkspaceManager) *StackedPaneManager {
29- return &StackedPaneManager{
30- wm: wm,
31- titleBarToPane: make(map[uint64]*paneNode),
32- }
33-}
34-
35-// StackPane creates a new pane stacked on top of the target pane.
36-// This is the main entry point for creating stacked panes.
37-func (spm *StackedPaneManager) StackPane(target *paneNode) (*paneNode, error) {
38- if target == nil || !target.isLeaf || target.pane == nil {
39- return nil, errors.New("stack target must be a leaf pane")
40- }
41-
42- // Create the new pane first
43- newLeaf, err := spm.prepareNewStackedPane()
44- if err != nil {
45- return nil, fmt.Errorf("failed to prepare new stacked pane: %w", err)
46- }
47-
48- var stackNode *paneNode
49- var insertIndex int
50-
51- // Check if target is already in a stack
52- if target.parent != nil && target.parent.isStacked {
53- // Target is already in a stack - add to existing stack
54- stackNode, insertIndex, err = spm.addPaneToExistingStack(target, newLeaf)
55- if err != nil {
56- return nil, fmt.Errorf("failed to add pane to existing stack: %w", err)
57- }
58- } else {
59- // Target is not stacked - create initial stack
60- stackNode, insertIndex, err = spm.convertToStackedContainer(target, newLeaf)
61- if err != nil {
62- return nil, fmt.Errorf("failed to convert to stacked container: %w", err)
63- }
64- }
65-
66- // Finalize the stack creation
67- return spm.finalizeStackCreation(stackNode, newLeaf, insertIndex)
68-}
69-
70-// prepareNewStackedPane creates a new pane and its container
71-func (spm *StackedPaneManager) prepareNewStackedPane() (*paneNode, error) {
72- newView, err := spm.wm.createWebView()
73- if err != nil {
74- return nil, err
75- }
76-
77- newPane, err := spm.wm.createPane(newView)
78- if err != nil {
79- return nil, err
80- }
81-
82- if handler := newPane.MessageHandler(); handler != nil {
83- handler.SetWorkspaceObserver(spm.wm)
84- }
85-
86- newContainer := newPane.webView.RootWidget()
87- if newContainer == nil {
88- return nil, errors.New("new pane missing container")
89- }
90- webkit.WidgetSetHExpand(newContainer, true)
91- webkit.WidgetSetVExpand(newContainer, true)
92-
93- // Create the new leaf node
94- newLeaf := &paneNode{
95- pane: newPane,
96- isLeaf: true,
97- }
98- // Initialize widgets properly using workspace manager helper
99- spm.wm.initializePaneWidgets(newLeaf, newContainer)
100-
101- // After wrapping the WebView in an overlay, operate on that container.
102- newContainer = newLeaf.container
103- if newContainer == nil {
104- return nil, errors.New("new stacked pane missing container overlay")
105- }
106- webkit.WidgetSetHExpand(newContainer, true)
107- webkit.WidgetSetVExpand(newContainer, true)
108-
109- // Create title bar for the new pane
110- newTitleBar := spm.createTitleBar(newLeaf)
111- spm.wm.setTitleBar(newLeaf, newTitleBar)
112-
113- // Keep new container hidden initially - will be shown after transition
114- webkit.WidgetSetVisible(newContainer, false)
115- if newTitleBar != nil {
116- webkit.WidgetSetVisible(newTitleBar, false)
117- }
118-
119- return newLeaf, nil
120-}
121-
122-// addPaneToExistingStack adds a new pane to an existing stack
123-func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode) (*paneNode, int, error) {
124- stackNode := target.parent
125-
126- // Find the current position of the target pane in the stack
127- currentIndex := -1
128- for i, pane := range stackNode.stackedPanes {
129- if pane == target {
130- currentIndex = i
131- break
132- }
133- }
134-
135- if currentIndex == -1 {
136- return nil, 0, errors.New("target pane not found in stack")
137- }
138-
139- // Insert the new pane right after the current pane
140- insertIndex := currentIndex + 1
141- logging.Debug(fmt.Sprintf("[workspace] adding to existing stack: currentIndex=%d insertIndex=%d stackSize=%d",
142- currentIndex, insertIndex, len(stackNode.stackedPanes)))
143-
144- // Set parent relationship
145- newLeaf.parent = stackNode
146-
147- // Insert the new pane at the correct position in the slice
148- stackNode.stackedPanes = append(stackNode.stackedPanes, nil) // Expand slice
149- copy(stackNode.stackedPanes[insertIndex+1:], stackNode.stackedPanes[insertIndex:]) // Shift elements
150- stackNode.stackedPanes[insertIndex] = newLeaf // Insert new pane
151-
152- // Unparent the new container before adding it to the stack (only if it has a parent)
153- if newLeaf.container != nil {
154- if webkit.WidgetGetParent(newLeaf.container) != nil {
155- webkit.WidgetUnparent(newLeaf.container)
156- }
157- }
158-
159- // Insert widgets at correct position for Zellij-style layout
160- // Each pane has 2 widgets (titleBar, container), so position = insertIndex * 2
161-
162- // Find the widget to insert after (based on insertIndex)
163- var insertAfterWidget gtk.Widgetter
164- if insertIndex > 0 {
165- // Insert after the previous pane's container
166- prevPane := stackNode.stackedPanes[insertIndex-1]
167- if prevPane.container != nil {
168- insertAfterWidget = prevPane.container
169- }
170- }
171-
172- if insertAfterWidget != nil {
173- // Insert titleBar after the previous pane's container
174- if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
175- box.InsertChildAfter(newLeaf.titleBar, insertAfterWidget)
176- }
177- // Insert container after the newly inserted titleBar
178- if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil && newLeaf.titleBar != nil {
179- box.InsertChildAfter(newLeaf.container, newLeaf.titleBar)
180- }
181- logging.Debug(fmt.Sprintf("[workspace] inserted widgets at position %d (after widget %p)", insertIndex, insertAfterWidget))
182- } else {
183- // Insert at the beginning (insertIndex = 0)
184- if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
185- box.Prepend(newLeaf.container)
186- }
187- if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
188- box.Prepend(newLeaf.titleBar)
189- }
190- logging.Debug("[workspace] prepended widgets at position 0")
191- }
192-
193- return stackNode, insertIndex, nil
194-}
195-
196-// convertToStackedContainer converts a simple pane to a stacked container
197-func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNode) (*paneNode, int, error) {
198- logging.Debug(fmt.Sprintf("[workspace] converting pane to stacked: %p", target))
199-
200- // Create the wrapper container - this is what will be used by splitNode
201- stackWrapperContainer := gtk.NewBox(gtk.OrientationVertical, 0)
202- if stackWrapperContainer == nil {
203- return nil, 0, errors.New("failed to create stack wrapper container")
204- }
205- stackWrapperContainer.SetHExpand(true)
206- stackWrapperContainer.SetVExpand(true)
207- // Note: Don't call Realize() here - GTK4 will realize automatically when added to toplevel
208-
209- // Add CSS class to the stack container for proper styling
210- stackWrapperContainer.AddCSSClass(stackContainerClass)
211-
212- // Create the internal box for the actual stacked widgets (titles + webviews)
213- stackInternalBox := gtk.NewBox(gtk.OrientationVertical, 0)
214- if stackInternalBox == nil {
215- return nil, 0, errors.New("failed to create stack internal box")
216- }
217- stackInternalBox.SetHExpand(true)
218- stackInternalBox.SetVExpand(true)
219-
220- // The internal box goes inside the wrapper
221- stackWrapperContainer.Append(stackInternalBox)
222-
223- // Get the existing container and parent info
224- existingContainer := target.container
225- parent := target.parent
226-
227- // Create title bar for the existing pane
228- titleBar := spm.createTitleBar(target)
229-
230- // Keep existing container visible during transition to prevent rendering glitch
231- if titleBar != nil {
232- webkit.WidgetSetVisible(titleBar, false)
233- }
234-
235- // Detach existing container from its current parent first
236- spm.detachFromParent(target, parent)
237-
238- // Build the complete stack structure with hidden widgets
239- if titleBar != nil {
240- stackInternalBox.Append(titleBar)
241- }
242- if existingContainer != nil {
243- stackInternalBox.Append(existingContainer)
244- }
245-
246- // Immediately reattach the stack wrapper to minimize visibility gap
247- spm.reattachToParent(stackWrapperContainer, target, parent)
248-
249- // Keep target as a regular leaf node (it's just inside a stack now)
250- // Individual panes within a stack should NOT be marked as isStacked
251- // Only the stack container itself has isStacked=true
252- target.isStacked = false
253- target.isLeaf = true
254- // container stays the same (existingContainer)
255- spm.wm.setTitleBar(target, titleBar)
256-
257- // Create the stack container node - container points to wrapper, stackWrapper points to internal box
258- stackNode := &paneNode{
259- isStacked: true,
260- isLeaf: false,
261- stackedPanes: []*paneNode{target},
262- activeStackIndex: 0, // KEEP CURRENT PANE ACTIVE during transition (index 0)
263- parent: parent,
264- }
265- spm.wm.setContainer(stackNode, stackWrapperContainer, "stack-wrapper") // Wrapper for GTK operations (splits, etc.)
266- spm.wm.setStackWrapper(stackNode, stackInternalBox) // Internal box for stack operations
267-
268- // Update target's parent to be the stack node
269- target.parent = stackNode
270-
271- // Update parent references (GTK operations already done above)
272- if parent == nil {
273- spm.wm.root = stackNode
274- } else {
275- if parent.left == target {
276- parent.left = stackNode
277- } else if parent.right == target {
278- parent.right = stackNode
279- }
280- }
281-
282- // Set up the new pane
283- newLeaf.parent = stackNode
284-
285- // Insert the new pane at index 1 (after the original pane)
286- insertIndex := 1
287- stackNode.stackedPanes = append(stackNode.stackedPanes, newLeaf)
288-
289- // Unparent the new container before adding it to the stack
290- // This is critical: even freshly created WebViews might have internal parent refs
291- if newLeaf.container != nil {
292- parentWidget := webkit.WidgetGetParent(newLeaf.container)
293- if parentWidget != nil {
294- logging.Debug(fmt.Sprintf("[workspace] unparenting new pane container %p from parent %p before stack append", newLeaf.container, parentWidget))
295- webkit.WidgetUnparent(newLeaf.container)
296- // Verify unparent succeeded
297- if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != nil {
298- logging.Warn(fmt.Sprintf("[workspace] container %p still has parent %p after unparent", newLeaf.container, finalParent))
299- }
300- }
301- }
302-
303- // Add the new widgets to the internal stack box
304- if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
305- box.Append(newLeaf.titleBar)
306- }
307- if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
308- box.Append(newLeaf.container)
309- }
310-
311- return stackNode, insertIndex, nil
312-}
313-
314-// detachFromParent removes a pane from its current parent
315-func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNode) {
316- if parent == nil {
317- // Target is the root - clear it (handles both tab and non-tab environments)
318- spm.wm.clearRootContainer()
319- // Unparent if it has a GTK parent
320- if target.container != nil {
321- if webkit.WidgetGetParent(target.container) != nil {
322- webkit.WidgetUnparent(target.container)
323- }
324- }
325- } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
326- // Target has a parent paned - remove it (automatically unparents in GTK4)
327- if parent.left == target {
328- paned.SetStartChild(nil)
329- } else if parent.right == target {
330- paned.SetEndChild(nil)
331- }
332- }
333-}
334-
335-// reattachToParent attaches a container to the parent
336-func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) {
337- if parent == nil {
338- // Attach as root (handles both tab and non-tab environments)
339- spm.wm.setRootContainer(container)
340- } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
341- if parent.left == target {
342- paned.SetStartChild(container)
343- } else if parent.right == target {
344- paned.SetEndChild(container)
345- }
346- }
347-}
348-
349-// finalizeStackCreation completes the stack creation process
350-func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNode, insertIndex int) (*paneNode, error) {
351- // Update workspace state first
352- spm.wm.viewToNode[newLeaf.pane.webView] = newLeaf
353- spm.wm.setupPopupHandling(newLeaf.pane.webView, newLeaf)
354- spm.wm.ensureHover(newLeaf)
355- spm.wm.app.panes = append(spm.wm.app.panes, newLeaf.pane)
356- if newLeaf.pane.zoomController != nil {
357- newLeaf.pane.zoomController.ApplyInitialZoom()
358- }
359-
360- // Mark stack operation timestamp to prevent focus conflicts
361- spm.wm.lastStackOperation = time.Now()
362-
363- // This ensures Zellij-style layout where the previously active pane shows its current title
364- currentActiveIndex := stackNode.activeStackIndex
365- if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex {
366- currentlyActivePane := stackNode.stackedPanes[currentActiveIndex]
367- if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != nil {
368- currentTitle := currentlyActivePane.pane.webView.GetTitle()
369- if currentTitle != "" {
370- spm.updateTitleBarLabel(currentlyActivePane, currentTitle)
371- logging.Debug(fmt.Sprintf("[workspace] updated title bar for pane becoming inactive during stack creation: %s", currentTitle))
372- }
373- }
374- }
375-
376- // Immediately switch to the new pane (don't use deprecated IdleAdd)
377- stackNode.activeStackIndex = insertIndex
378- spm.UpdateStackVisibility(stackNode)
379-
380- // Set focus on the new pane synchronously
381- spm.wm.SetActivePane(newLeaf, SourceSplit)
382-
383- logging.Debug(fmt.Sprintf("[workspace] stacked new pane: stackNode=%p newLeaf=%p stackSize=%d activeIndex=%d insertIndex=%d",
384- stackNode, newLeaf, len(stackNode.stackedPanes), stackNode.activeStackIndex, insertIndex))
385- return newLeaf, nil
386-}
387-
388-// UpdateStackVisibility updates the visibility of panes in a stack
389-func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
390- logging.Debug(fmt.Sprintf("[workspace] updateStackVisibility called: stackNode=%p", stackNode))
391-
392- if stackNode == nil {
393- logging.Debug("[workspace] updateStackVisibility aborted: stackNode is nil")
394- return
395- }
396-
397- if !stackNode.isStacked {
398- logging.Debug(fmt.Sprintf("[workspace] updateStackVisibility aborted: stackNode.isStacked=%v", stackNode.isStacked))
399- return
400- }
401-
402- if len(stackNode.stackedPanes) == 0 {
403- logging.Debug(fmt.Sprintf("[workspace] updateStackVisibility aborted: stackedPanes empty, len=%d", len(stackNode.stackedPanes)))
404- return
405- }
406-
407- activeIndex := stackNode.activeStackIndex
408- if activeIndex < 0 || activeIndex >= len(stackNode.stackedPanes) {
409- logging.Debug(fmt.Sprintf("[workspace] updateStackVisibility: correcting activeIndex from %d to 0", activeIndex))
410- activeIndex = 0
411- stackNode.activeStackIndex = activeIndex
412- }
413-
414- logging.Debug(fmt.Sprintf("[workspace] updating stack visibility: activeIndex=%d stackSize=%d", activeIndex, len(stackNode.stackedPanes)))
415-
416- // CRITICAL: Process ALL panes in a single pass to prevent flickering
417- for i, pane := range stackNode.stackedPanes {
418- if i == activeIndex {
419- // Active pane: show container, NEVER show title bar
420- if pane.container != nil {
421- webkit.WidgetSetVisible(pane.container, true)
422- }
423- if pane.titleBar != nil {
424- webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane
425- }
426- logging.Debug(fmt.Sprintf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i))
427- } else {
428- // Inactive panes: hide container, show title bar
429- if pane.container != nil {
430- webkit.WidgetSetVisible(pane.container, false)
431- }
432- if pane.titleBar != nil {
433- webkit.WidgetSetVisible(pane.titleBar, true)
434-
435- // Refresh title bar to pick up any newly downloaded favicons
436- if pane.pane != nil && pane.pane.webView != nil {
437- title := pane.pane.webView.GetTitle()
438- if title == "" {
439- title = NewTabTitle
440- }
441- spm.updateTitleBarLabel(pane, title)
442- }
443- }
444- }
445- }
446-}
447-
448-// NavigateStack handles navigation within a stacked pane container
449-func (spm *StackedPaneManager) NavigateStack(direction string) bool {
450- if spm == nil || spm.wm == nil || spm.wm.GetActiveNode() == nil {
451- return false
452- }
453-
454- // Find the stack container this pane belongs to
455- var stackNode *paneNode
456- current := spm.wm.GetActiveNode()
457-
458- // Check if current pane is directly in a stack
459- if current.parent != nil && current.parent.isStacked {
460- stackNode = current.parent
461- } else {
462- // Current pane might be the stack container itself if it was the first pane converted to stack
463- if current.isStacked {
464- stackNode = current
465- }
466- }
467-
468- if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
469- return false // Not in a stack or stack has only one pane
470- }
471-
472- // Use the stackNode's activeStackIndex instead of searching for current pane
473- // This is more reliable than trying to match panes
474- currentIndex := stackNode.activeStackIndex
475- if currentIndex < 0 || currentIndex >= len(stackNode.stackedPanes) {
476- logging.Debug(fmt.Sprintf("[workspace] navigateStack: invalid activeStackIndex=%d, resetting to 0", currentIndex))
477- currentIndex = 0
478- stackNode.activeStackIndex = 0
479- }
480-
481- // Calculate new index based on direction
482- var newIndex int
483- switch direction {
484- case DirectionUp:
485- newIndex = currentIndex - 1
486- if newIndex < 0 {
487- // At the top of the stack - allow navigation outside
488- return false
489- }
490- case DirectionDown:
491- newIndex = currentIndex + 1
492- if newIndex >= len(stackNode.stackedPanes) {
493- // At the bottom of the stack - allow navigation outside
494- return false
495- }
496- default:
497- return false
498- }
499-
500- if newIndex == currentIndex {
501- return false // No change
502- }
503-
504- // Update title bar for the pane transitioning from ACTIVE to INACTIVE
505- // This ensures the collapsed pane shows its current page title (Zellij-style layout)
506- currentActivePane := stackNode.stackedPanes[currentIndex]
507- if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
508- currentTitle := currentActivePane.pane.webView.GetTitle()
509- if currentTitle != "" {
510- spm.updateTitleBarLabel(currentActivePane, currentTitle)
511- logging.Debug(fmt.Sprintf("[workspace] updated title bar for pane transitioning to INACTIVE: %s", currentTitle))
512- }
513- }
514-
515- // Update active stack index and visibility
516- stackNode.activeStackIndex = newIndex
517- spm.UpdateStackVisibility(stackNode)
518-
519- // Focus the new active pane
520- newActivePane := stackNode.stackedPanes[newIndex]
521- spm.wm.SetActivePane(newActivePane, SourceStackNav)
522-
523- logging.Debug(fmt.Sprintf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
524- direction, currentIndex, newIndex, len(stackNode.stackedPanes)))
525- return true
526-}
527-
528-// createTitleBar creates a title bar widget for a pane in a stack
529-func (spm *StackedPaneManager) createTitleBar(pane *paneNode) gtk.Widgetter {
530- // Get the actual title and URL from the WebView
531- var titleText, pageURL string
532- if pane.pane != nil && pane.pane.webView != nil {
533- titleText = pane.pane.webView.GetTitle()
534- if titleText == "" {
535- titleText = NewTabTitle // Fallback only when title is actually empty
536- }
537- pageURL = pane.pane.webView.GetCurrentURL()
538- } else {
539- titleText = NewTabTitle
540- }
541-
542- titleBar := spm.createTitleBarWithTitle(titleText, pageURL)
543- if titleBar != nil {
544- // Store the mapping from titleBar ID to pane
545- titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
546- spm.titleBarToPane[titleBarID] = pane
547-
548- // Attach click handler
549- webkit.WidgetAttachClickHandler(titleBar, func() {
550- spm.handleTitleBarClick(titleBarID)
551- })
552- }
553-
554- return titleBar
555-}
556-
557-// UpdateTitleBar updates the title bar label for a WebView in stacked panes
558-func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title string) {
559- if spm.wm == nil || webView == nil || title == "" {
560- return
561- }
562-
563- // Find the pane node for this WebView
564- node, exists := spm.wm.viewToNode[webView]
565- if !exists || node == nil || !node.isLeaf {
566- return
567- }
568-
569- // Check if this pane is in a stack and has a title bar
570- if node.parent != nil && node.parent.isStacked && node.titleBar != nil {
571- // CRITICAL: Only update title bar for INACTIVE panes
572- // Active panes should never show their title bar
573- stackNode := node.parent
574- activeIndex := stackNode.activeStackIndex
575-
576- // Find this pane's index in the stack
577- paneIndex := -1
578- for i, pane := range stackNode.stackedPanes {
579- if pane == node {
580- paneIndex = i
581- break
582- }
583- }
584-
585- if paneIndex != -1 && paneIndex != activeIndex {
586- // This is an INACTIVE pane - safe to update title bar
587- spm.updateTitleBarLabel(node, title)
588- logging.Debug(fmt.Sprintf("[workspace] updated title bar for INACTIVE WebView %d: %s", webView.ID(), title))
589- } else {
590- // This is the ACTIVE pane - title bar should remain hidden
591- logging.Debug(fmt.Sprintf("[workspace] skipped title bar update for ACTIVE WebView %d: %s", webView.ID(), title))
592- }
593- }
594-}
595-
596-// updateTitleBarLabel updates the label widget within a title bar by recreating it
597-func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) {
598- if node == nil || node.titleBar == nil || node.parent == nil || !node.parent.isStacked {
599- return
600- }
601-
602- // Get the current URL from the WebView for favicon
603- var pageURL string
604- if node.pane != nil && node.pane.webView != nil {
605- pageURL = node.pane.webView.GetCurrentURL()
606- }
607-
608- // Create new title bar with updated title and favicon
609- newTitleBar := spm.createTitleBarWithTitle(title, pageURL)
610- if newTitleBar == nil {
611- logging.Error("[workspace] failed to create new title bar")
612- return
613- }
614-
615- // Store the mapping from titleBar ID to pane and attach click handler
616- titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
617- spm.titleBarToPane[titleBarID] = node
618-
619- // Attach click handler
620- webkit.WidgetAttachClickHandler(newTitleBar, func() {
621- spm.handleTitleBarClick(titleBarID)
622- })
623-
624- // Replace the old title bar in the stack
625- if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {
626- // Find the correct insertion position based on the pane's index in the stack
627- paneIndex := -1
628- for i, stackedPane := range node.parent.stackedPanes {
629- if stackedPane == node {
630- paneIndex = i
631- break
632- }
633- }
634-
635- // Remove old title bar
636- if node.titleBar != nil {
637- webkit.WidgetUnparent(node.titleBar)
638- }
639-
640- // Update the node's title bar reference
641- spm.wm.setTitleBar(node, newTitleBar)
642-
643- // Insert the new title bar at the correct position
644- if box, ok := node.parent.stackWrapper.(*gtk.Box); ok {
645- if paneIndex == 0 {
646- // First pane - insert at the beginning
647- box.Prepend(newTitleBar)
648- } else {
649- // Insert after the previous pane's container
650- prevPane := node.parent.stackedPanes[paneIndex-1]
651- if prevPane.container != nil {
652- box.InsertChildAfter(newTitleBar, prevPane.container)
653- }
654- }
655- }
656-
657- logging.Debug(fmt.Sprintf("[workspace] replaced title bar for pane %d: %p", paneIndex, node))
658- }
659-}
660-
661-// createTitleBarWithTitle creates a title bar with a specific title and optional favicon
662-func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) gtk.Widgetter {
663- titleBox := gtk.NewBox(gtk.OrientationHorizontal, 8)
664- if titleBox == nil {
665- return nil
666- }
667-
668- titleBox.AddCSSClass("stacked-pane-title")
669- titleBox.SetHExpand(true)
670- titleBox.SetVExpand(false)
671-
672- placeholderLabel := gtk.NewLabel(faviconPlaceholder)
673- if placeholderLabel != nil {
674- placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")
675- titleBox.Append(placeholderLabel)
676- webkit.WidgetSetVisible(placeholderLabel, true)
677- }
678-
679- if pageURL != "" && spm.wm != nil && spm.wm.app != nil && spm.wm.app.faviconService != nil {
680- spm.wm.app.faviconService.GetFaviconTexture(pageURL, func(texture *gdk.Texture, err error) {
681- if err != nil {
682- logging.Warn(fmt.Sprintf("[favicon] Failed to load favicon for title bar: %s - %v", pageURL, err))
683- return
684- }
685-
686- if texture != nil && titleBox != nil {
687-
688- // Create favicon image with UI scaling
689- faviconImg := gtk.NewImageFromPaintable(texture)
690- // Scale favicon size based on UI scale (base: 16px)
691- faviconSize := 16
692- if spm.wm != nil && spm.wm.app != nil && spm.wm.app.config != nil {
693- uiScale := spm.wm.app.config.Workspace.Styling.UIScale
694- if uiScale > 0 {
695- faviconSize = int(16 * uiScale)
696- }
697- }
698- faviconImg.SetPixelSize(faviconSize)
699- webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
700-
701- // IDEMPOTENT: Remove placeholder only if it still has titleBox as parent
702- // This handles the case where multiple async callbacks fire for the same title bar
703- if placeholderLabel != nil && webkit.WidgetGetParent(placeholderLabel) != nil {
704- titleBox.Remove(placeholderLabel)
705- }
706-
707- // Prepend favicon image (multiple calls are safe - GTK handles it)
708- titleBox.Prepend(faviconImg)
709- webkit.WidgetSetVisible(faviconImg, true)
710- }
711- })
712- }
713-
714- // Create title label with the specified title
715- titleLabel := gtk.NewLabel(title)
716- if titleLabel == nil {
717- return titleBox
718- }
719-
720- titleLabel.AddCSSClass("stacked-pane-title-text")
721- titleLabel.SetEllipsize(2)
722-
723- titleBox.Append(titleLabel)
724- webkit.WidgetSetVisible(titleBox, true)
725- webkit.WidgetSetVisible(titleLabel, true)
726-
727- return titleBox
728-}
729-
730-// TODO(#8): Refactor this function to reduce cyclomatic complexity (49 -> <30)
731-//
732-// CloseStackedPane handles closing a pane that is part of a stack.
733-// The logic mirrors the regular close path: we rebuild the tree, update focus,
734-// and let WorkspaceManager perform the actual cleanup/destroy work so that all
735-// bookkeeping stays consistent.
736-func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
737- if node == nil || node.parent == nil || !node.parent.isStacked {
738- return errors.New("node is not part of a stacked pane")
739- }
740-
741- stackNode := node.parent
742-
743- // Locate the pane inside the stack slice.
744- nodeIndex := -1
745- for i, stackedPane := range stackNode.stackedPanes {
746- if stackedPane == node {
747- nodeIndex = i
748- break
749- }
750- }
751- if nodeIndex == -1 {
752- return errors.New("node not found in stack")
753- }
754-
755- logging.Debug(fmt.Sprintf("[workspace] closing stacked pane: index=%d stackSize=%d", nodeIndex, len(stackNode.stackedPanes)))
756-
757- // Detach hover/focus controllers before tearing down widgets to avoid GTK
758- // callbacks referencing freed memory during destruction.
759- if spm.wm != nil {
760- spm.wm.detachHover(node)
761- spm.wm.detachFocus(node)
762- }
763-
764- // Remove the pane widgets from the stack container before mutating the tree.
765- stackBox := stackNode.stackWrapper
766- if stackBox == nil {
767- stackBox = stackNode.container
768- }
769- if box, ok := stackBox.(*gtk.Box); ok && box != nil {
770- if node.titleBar != nil {
771- box.Remove(node.titleBar)
772- }
773- if node.container != nil {
774- box.Remove(node.container)
775- }
776- }
777-
778- // Remove the pane from the stack slice.
779- stackNode.stackedPanes = append(stackNode.stackedPanes[:nodeIndex], stackNode.stackedPanes[nodeIndex+1:]...)
780-
781- switch len(stackNode.stackedPanes) {
782- case 0:
783- // Stack container is now empty: remove it from the layout entirely.
784- parent := stackNode.parent
785- if parent == nil {
786- // Stack was the root of the workspace — closing this pane exits the app.
787- if stackNode.container != nil {
788- if webkit.WidgetGetParent(stackNode.container) != nil {
789- webkit.WidgetUnparent(stackNode.container)
790- }
791- }
792- // Clear root container (handles both tab and non-tab environments)
793- spm.wm.clearRootContainer()
794- _, err := spm.wm.cleanupAndExit(node)
795- return err
796- }
797-
798- // Detach the empty stack container from its GtkPaned parent.
799- if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
800- if parent.left == stackNode {
801- paned.SetStartChild(nil)
802- } else if parent.right == stackNode {
803- paned.SetEndChild(nil)
804- }
805- webkit.WidgetQueueAllocate(paned)
806- }
807- if stackNode.container != nil {
808- if webkit.WidgetGetParent(stackNode.container) != nil {
809- webkit.WidgetUnparent(stackNode.container)
810- }
811- }
812-
813- sibling := spm.wm.getSibling(stackNode)
814- grandparent := parent.parent
815-
816- spm.wm.promoteSibling(grandparent, parent, sibling)
817- spm.wm.swapContainers(grandparent, parent, sibling)
818-
819- generation := spm.wm.nextCleanupGeneration()
820- spm.wm.cleanupPane(node, generation)
821- spm.wm.decommissionParent(parent, generation)
822-
823- if sibling != nil {
824- spm.wm.setFocusToLeaf(sibling)
825- } else {
826- spm.wm.updateMainPane()
827- }
828-
829- stackNode.parent = nil
830- stackNode.container = nil
831- stackNode.stackWrapper = nil
832- stackNode.stackedPanes = nil
833-
834- return nil
835-
836- case 1:
837- // Promote the remaining pane back to a regular leaf node.
838- lastPane := stackNode.stackedPanes[0]
839- parent := stackNode.parent
840- lastPaneContainer := lastPane.container
841-
842- if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPane.titleBar != nil {
843- box.Remove(lastPane.titleBar)
844- }
845- if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPaneContainer != nil {
846- box.Remove(lastPaneContainer)
847- }
848- if stackNode.container != nil {
849- if webkit.WidgetGetParent(stackNode.container) != nil {
850- webkit.WidgetUnparent(stackNode.container)
851- }
852- }
853-
854- if parent == nil {
855- // Stack was root - make last pane the new root
856- spm.wm.root = lastPane
857- lastPane.parent = nil
858- if lastPaneContainer != nil {
859- spm.wm.setRootContainer(lastPaneContainer)
860- webkit.WidgetQueueAllocate(lastPaneContainer)
861- webkit.WidgetShow(lastPaneContainer)
862- }
863- } else {
864- if parent.left == stackNode {
865- parent.left = lastPane
866- } else if parent.right == stackNode {
867- parent.right = lastPane
868- }
869- lastPane.parent = parent
870- if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil && lastPaneContainer != nil {
871- if parent.left == lastPane {
872- paned.SetStartChild(lastPaneContainer)
873- } else {
874- paned.SetEndChild(lastPaneContainer)
875- }
876- webkit.WidgetQueueAllocate(paned)
877- }
878- if lastPaneContainer != nil {
879- webkit.WidgetSetVisible(lastPaneContainer, true)
880- }
881- }
882-
883- lastPane.isStacked = false
884- lastPane.stackedPanes = nil
885- lastPane.titleBar = nil
886- spm.wm.viewToNode[lastPane.pane.webView] = lastPane
887- spm.wm.setupPopupHandling(lastPane.pane.webView, lastPane)
888-
889- generation := spm.wm.nextCleanupGeneration()
890- spm.wm.cleanupPane(node, generation)
891-
892- spm.wm.SetActivePane(lastPane, SourceClose)
893- if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != nil {
894- webkit.WidgetShow(lastPaneWidget)
895- webkit.WidgetGrabFocus(lastPaneWidget)
896- }
897-
898- stackNode.parent = nil
899- stackNode.container = nil
900- stackNode.stackWrapper = nil
901- stackNode.stackedPanes = nil
902-
903- logging.Debug("[workspace] converted stack back to regular pane")
904- return nil
905-
906- default:
907- // Still multiple panes in stack — adjust active index and visibility.
908- if stackNode.activeStackIndex >= nodeIndex && stackNode.activeStackIndex > 0 {
909- stackNode.activeStackIndex--
910- }
911- if stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
912- stackNode.activeStackIndex = len(stackNode.stackedPanes) - 1
913- }
914-
915- spm.UpdateStackVisibility(stackNode)
916- if spm.wm.GetActiveNode() == node {
917- newActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
918- spm.wm.SetActivePane(newActivePane, SourceClose)
919- }
920-
921- generation := spm.wm.nextCleanupGeneration()
922- spm.wm.cleanupPane(node, generation)
923-
924- logging.Debug(fmt.Sprintf("[workspace] closed pane from stack: remaining=%d activeIndex=%d",
925- len(stackNode.stackedPanes), stackNode.activeStackIndex))
926- return nil
927- }
928-}
929-
930-// handleTitleBarClick handles clicks on title bars to switch the active pane
931-func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uint64) {
932- pane, exists := spm.titleBarToPane[titleBarID]
933- if !exists || pane == nil || pane.parent == nil || !pane.parent.isStacked {
934- return
935- }
936-
937- stackNode := pane.parent
938-
939- // Find the index of the clicked pane
940- for i, p := range stackNode.stackedPanes {
941- if p == pane {
942- // Only switch if it's not already active
943- if i != stackNode.activeStackIndex {
944- logging.Debug(fmt.Sprintf("[workspace] title bar clicked: switching from pane %d to pane %d",
945- stackNode.activeStackIndex, i))
946-
947- // Update title bar for the pane transitioning from ACTIVE to INACTIVE
948- currentActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
949- if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
950- currentTitle := currentActivePane.pane.webView.GetTitle()
951- if currentTitle != "" {
952- spm.updateTitleBarLabel(currentActivePane, currentTitle)
953- }
954- }
955-
956- stackNode.activeStackIndex = i
957- spm.UpdateStackVisibility(stackNode)
958- spm.wm.SetActivePane(pane, SourceStackNav)
959- }
960- return
961- }
962- }
963-}
D internal/app/browser/tab_bar.go +0 -445
@@ -1,445 +0,0 @@
1-package browser
2-
3-import (
4- "fmt"
5- "sync/atomic"
6-
7- "github.com/bnema/dumber/internal/logging"
8- "github.com/bnema/dumber/pkg/webkit"
9- "github.com/diamondburned/gotk4/pkg/gdk/v4"
10- "github.com/diamondburned/gotk4/pkg/gtk/v4"
11-)
12-
13-// createTabBar creates the horizontal tab bar container.
14-// Returns the tab bar widget.
15-func (tm *TabManager) createTabBar() gtk.Widgetter {
16- logging.Debug("[tabs] Creating tab bar container")
17-
18- tabBar := gtk.NewBox(gtk.OrientationHorizontal, 0)
19- if tabBar == nil {
20- logging.Error("[tabs] Failed to create tab bar box")
21- return nil
22- }
23-
24- tabBar.AddCSSClass("tab-bar")
25- tabBar.SetHExpand(true)
26- tabBar.SetVExpand(false)
27-
28- tm.tabBar = tabBar
29- logging.Debug("[tabs] Tab bar container created")
30- return tabBar
31-}
32-
33-// createTabButton creates a simple GTK button widget for a tab with just a text label.
34-func (tm *TabManager) createTabButton(tab *Tab) gtk.Widgetter {
35- logging.Debug(fmt.Sprintf("[tabs] Creating button for tab %s", tab.id))
36-
37- // Create button
38- button := gtk.NewButton()
39- if button == nil {
40- logging.Error("[tabs] Failed to create tab button")
41- return nil
42- }
43-
44- // CRITICAL: Disable focus-on-click to prevent GTK focus system from interfering
45- // Tab buttons should be clickable without grabbing focus from the WebView
46- button.SetFocusOnClick(false)
47- button.SetCanFocus(false)
48-
49- // Apply CSS classes
50- button.AddCSSClass("tab-button")
51-
52- // Get display title (custom name or default name)
53- displayTitle := tab.title
54- if tab.customTitle != "" {
55- displayTitle = tab.customTitle
56- }
57-
58- // Create title label
59- titleLabel := gtk.NewLabel(displayTitle)
60- if titleLabel != nil {
61- titleLabel.AddCSSClass("tab-title")
62- titleLabel.SetEllipsize(2) // Pango ellipsize mode: middle
63- titleLabel.SetMaxWidthChars(20)
64- webkit.WidgetSetVisible(titleLabel, true)
65- }
66-
67- // Set button child
68- button.SetChild(titleLabel)
69-
70- // Store button reference for later updates
71- tab.titleButton = button
72-
73- // Note: Click handler will be attached in createTabInternal after tab is added to slice
74-
75- // Set active class if this is the active tab
76- if tab.isActive {
77- webkit.WidgetAddCSSClass(button, "tab-button-active")
78- }
79-
80- webkit.WidgetSetVisible(button, true)
81- logging.Debug(fmt.Sprintf("[tabs] Button created for tab %s", tab.id))
82- return button
83-}
84-
85-// attachTabClickHandler attaches a click handler to switch to a tab when clicked.
86-// Uses the same pattern as StackedPaneManager: maps unique button ID -> tab,
87-// then looks up current index at click time to handle tab reordering correctly.
88-func (tm *TabManager) attachTabClickHandler(button gtk.Widgetter, tab *Tab) {
89- if button == nil || tab == nil {
90- logging.Error("[tabs] Cannot attach click handler: nil button or tab")
91- return
92- }
93-
94- gtkButton, ok := button.(*gtk.Button)
95- if !ok {
96- logging.Error(fmt.Sprintf("[tabs] Cannot attach click handler: widget is not *gtk.Button for tab %s", tab.id))
97- return
98- }
99-
100- // Generate unique button ID and store mapping (pattern from stacked panes)
101- buttonID := atomic.AddUint64(&tm.nextButtonID, 1)
102- tm.buttonToTab[buttonID] = tab
103-
104- gtkButton.ConnectClicked(func() {
105- tm.handleTabButtonClick(buttonID)
106- })
107-
108- logging.Debug(fmt.Sprintf("[tabs] Click handler attached to tab %s (buttonID=%d)", tab.id, buttonID))
109-}
110-
111-// handleTabButtonClick handles clicks on tab buttons to switch tabs.
112-// Looks up the current index dynamically to handle tab reordering (pattern from stacked panes).
113-func (tm *TabManager) handleTabButtonClick(buttonID uint64) {
114- tm.mu.Lock()
115- defer tm.mu.Unlock()
116-
117- // Look up tab from button ID
118- tab, exists := tm.buttonToTab[buttonID]
119- if !exists || tab == nil {
120- logging.Warn(fmt.Sprintf("[tabs] Tab button click for unknown ID: %d", buttonID))
121- return
122- }
123-
124- // Find current index of this tab
125- targetIndex := -1
126- for i, t := range tm.tabs {
127- if t == tab {
128- targetIndex = i
129- break
130- }
131- }
132-
133- if targetIndex < 0 {
134- logging.Error(fmt.Sprintf("[tabs] Tab %s not found in tabs slice", tab.id))
135- return
136- }
137-
138- // Only switch if not already active
139- if targetIndex == tm.activeIndex {
140- logging.Debug(fmt.Sprintf("[tabs] Tab %s already active (index %d)", tab.id, targetIndex))
141- return
142- }
143-
144- logging.Info(fmt.Sprintf("[tabs] Tab button clicked: switching from tab %d to %d (%s)", tm.activeIndex, targetIndex, tab.id))
145-
146- // Switch to this tab
147- if err := tm.switchToTabInternal(targetIndex); err != nil {
148- logging.Error(fmt.Sprintf("[tabs] Failed to switch to tab %d: %v", targetIndex, err))
149- }
150-}
151-
152-// updateTabButton updates the tab button's title label.
153-func (tm *TabManager) updateTabButton(tab *Tab) {
154- if tab.titleButton == nil {
155- logging.Warn(fmt.Sprintf("[tabs] Cannot update button for tab %s: button is nil", tab.id))
156- return
157- }
158-
159- webkit.RunOnMainThread(func() {
160- button, ok := tab.titleButton.(*gtk.Button)
161- if !ok {
162- logging.Error(fmt.Sprintf("[tabs] Tab button is not a *gtk.Button: %s", tab.id))
163- return
164- }
165-
166- // Get button child (the label)
167- child := button.Child()
168- if child == nil {
169- logging.Warn(fmt.Sprintf("[tabs] Tab button has no child: %s", tab.id))
170- return
171- }
172-
173- label, ok := child.(*gtk.Label)
174- if !ok {
175- logging.Error(fmt.Sprintf("[tabs] Tab button child is not a *gtk.Label: %s", tab.id))
176- return
177- }
178-
179- // Update label text
180- displayTitle := tab.title
181- if tab.customTitle != "" {
182- displayTitle = tab.customTitle
183- }
184- label.SetLabel(displayTitle)
185- logging.Debug(fmt.Sprintf("[tabs] Updated tab button label: %s -> %s", tab.id, displayTitle))
186- })
187-}
188-
189-// addTabToBar adds a tab button to the tab bar.
190-func (tm *TabManager) addTabToBar(tab *Tab) {
191- if tm.tabBar == nil {
192- logging.Error("[tabs] Cannot add tab to bar: tab bar is nil")
193- return
194- }
195-
196- if tab.titleButton == nil {
197- logging.Error(fmt.Sprintf("[tabs] Cannot add tab %s to bar: button is nil", tab.id))
198- return
199- }
200-
201- webkit.RunOnMainThread(func() {
202- if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok {
203- tabBarBox.Append(tab.titleButton)
204- logging.Debug(fmt.Sprintf("[tabs] Added tab %s to tab bar", tab.id))
205- } else {
206- logging.Error("[tabs] Tab bar is not a *gtk.Box")
207- }
208- })
209-}
210-
211-// removeTabFromBar removes a tab button from the tab bar.
212-func (tm *TabManager) removeTabFromBar(tab *Tab) {
213- if tm.tabBar == nil || tab.titleButton == nil {
214- return
215- }
216-
217- webkit.RunOnMainThread(func() {
218- if tabBarBox, ok := tm.tabBar.(*gtk.Box); ok {
219- tabBarBox.Remove(tab.titleButton)
220- logging.Debug(fmt.Sprintf("[tabs] Removed tab %s from tab bar", tab.id))
221- }
222- })
223-}
224-
225-// setTabActiveStyle applies or removes the active CSS class on a tab button.
226-func (tm *TabManager) setTabActiveStyle(tab *Tab, active bool) {
227- if tab.titleButton == nil {
228- logging.Warn(fmt.Sprintf("[tabs] Cannot set active style: button is nil for tab %s", tab.id))
229- return
230- }
231-
232- webkit.RunOnMainThread(func() {
233- if active {
234- webkit.WidgetAddCSSClass(tab.titleButton, "tab-button-active")
235- logging.Debug(fmt.Sprintf("[tabs] Added active class to tab %s", tab.id))
236- } else {
237- webkit.WidgetRemoveCSSClass(tab.titleButton, "tab-button-active")
238- logging.Debug(fmt.Sprintf("[tabs] Removed active class from tab %s", tab.id))
239- }
240- })
241-}
242-
243-// startInlineRename replaces the tab button's label with an entry widget for inline editing.
244-func (tm *TabManager) startInlineRename(tab *Tab) {
245- if tab.titleButton == nil {
246- logging.Error(fmt.Sprintf("[tabs] Cannot start rename: button is nil for tab %s", tab.id))
247- return
248- }
249-
250- // Check if rename already in progress
251- tm.mu.Lock()
252- if tm.renameInProgress {
253- logging.Debug("[tabs] Rename already in progress, ignoring")
254- tm.mu.Unlock()
255- return
256- }
257- tm.renameInProgress = true
258- tm.renamingTab = tab
259-
260- // Stop tab mode timer during rename
261- if tm.tabModeTimer != nil {
262- tm.tabModeTimer.Stop()
263- tm.tabModeTimer = nil
264- }
265- tm.mu.Unlock()
266-
267- webkit.RunOnMainThread(func() {
268- button, ok := tab.titleButton.(*gtk.Button)
269- if !ok {
270- logging.Error(fmt.Sprintf("[tabs] Tab button is not a *gtk.Button: %s", tab.id))
271- tm.cancelRename()
272- return
273- }
274-
275- // Get current text
276- currentText := tab.title
277- if tab.customTitle != "" {
278- currentText = tab.customTitle
279- }
280-
281- // Store original label for restoration
282- originalLabel := button.Child()
283-
284- // Create entry widget
285- entry := gtk.NewEntry()
286- if entry == nil {
287- logging.Error("[tabs] Failed to create entry widget for rename")
288- tm.cancelRename()
289- return
290- }
291-
292- // Store current focus behavior so we can restore it later
293- prevFocusOnClick := button.FocusOnClick()
294- prevCanFocus := button.CanFocus()
295- tm.mu.Lock()
296- tm.renamePrevFocusOnClick = prevFocusOnClick
297- tm.renamePrevCanFocus = prevCanFocus
298- tm.mu.Unlock()
299-
300- // Allow the entry to receive focus while rename is active
301- button.SetFocusOnClick(true)
302- button.SetCanFocus(true)
303-
304- // Configure entry BEFORE adding to widget tree
305- entry.SetText(currentText)
306- entry.AddCSSClass("tab-rename-entry")
307- entry.SetCanFocus(true) // Explicitly allow focus
308- entry.SetFocusable(true) // GTK4 focusability
309-
310- // Handle Enter key - save
311- entry.ConnectActivate(func() {
312- newName := entry.Text()
313- logging.Debug(fmt.Sprintf("[tabs] Rename activate (Enter): '%s' -> '%s'", currentText, newName))
314- tm.finishInlineRename(tab, newName, originalLabel)
315- })
316-
317- // Handle Escape key - cancel
318- keyController := gtk.NewEventControllerKey()
319- keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
320- const gdkKeyEscape = 0xff1b
321- if keyval == gdkKeyEscape {
322- logging.Debug("[tabs] Rename cancelled (Escape)")
323- tm.finishInlineRename(tab, "", originalLabel) // Empty = cancel
324- return true
325- }
326- return false
327- })
328- entry.AddController(keyController)
329-
330- // Handle focus-out - cancel (safety)
331- focusController := gtk.NewEventControllerFocus()
332- focusController.ConnectLeave(func() {
333- logging.Debug("[tabs] Rename cancelled (focus-out)")
334- tm.finishInlineRename(tab, "", originalLabel)
335- })
336- entry.AddController(focusController)
337-
338- // Replace label with entry
339- button.SetChild(entry)
340-
341- // Make entry visible first
342- webkit.WidgetSetVisible(entry, true)
343-
344- // Grab focus after widget is visible and realized
345- // Use idle callback to ensure widget is fully realized
346- entry.GrabFocus()
347-
348- // Select all text after focus
349- entry.SelectRegion(0, -1)
350-
351- logging.Debug(fmt.Sprintf("[tabs] Started inline rename for tab %s", tab.id))
352- })
353-}
354-
355-// cancelRename clears the rename state without changing anything
356-func (tm *TabManager) cancelRename() {
357- tm.mu.Lock()
358- defer tm.mu.Unlock()
359- tm.renameInProgress = false
360- tm.renamingTab = nil
361- tm.renamePrevFocusOnClick = false
362- tm.renamePrevCanFocus = false
363- logging.Debug("[tabs] Rename state cleared")
364-}
365-
366-// finishInlineRename restores the label and saves the new name if provided.
367-func (tm *TabManager) finishInlineRename(tab *Tab, newName string, originalLabel gtk.Widgetter) {
368- // Guard against multiple calls
369- tm.mu.Lock()
370- if !tm.renameInProgress {
371- logging.Debug("[tabs] finishInlineRename called but no rename in progress, ignoring")
372- tm.mu.Unlock()
373- return
374- }
375- if tm.renamingTab != tab {
376- logging.Debug(fmt.Sprintf("[tabs] finishInlineRename called for wrong tab (expected %s, got %s)",
377- tm.renamingTab.id, tab.id))
378- tm.mu.Unlock()
379- return
380- }
381-
382- // Clear rename state immediately to prevent re-entry
383- tm.renameInProgress = false
384- tm.renamingTab = nil
385- tm.mu.Unlock()
386-
387- webkit.RunOnMainThread(func() {
388- button, ok := tab.titleButton.(*gtk.Button)
389- if !ok {
390- logging.Error(fmt.Sprintf("[tabs] Tab button is not a *gtk.Button during finish: %s", tab.id))
391- return
392- }
393-
394- // Restore original label
395- button.SetChild(originalLabel)
396-
397- // Restore original focus behavior now that rename is finished
398- tm.mu.Lock()
399- prevFocusOnClick := tm.renamePrevFocusOnClick
400- prevCanFocus := tm.renamePrevCanFocus
401- tm.renamePrevFocusOnClick = false
402- tm.renamePrevCanFocus = false
403- tm.mu.Unlock()
404- button.SetFocusOnClick(prevFocusOnClick)
405- button.SetCanFocus(prevCanFocus)
406-
407- // Save new name if provided and not just whitespace
408- newName = trimWhitespace(newName)
409- if newName != "" {
410- tab.customTitle = newName
411- logging.Info(fmt.Sprintf("[tabs] Tab %s renamed to '%s'", tab.id, newName))
412- } else {
413- logging.Debug(fmt.Sprintf("[tabs] Tab %s rename cancelled", tab.id))
414- }
415-
416- // Update label text
417- tm.updateTabButton(tab)
418-
419- // Exit tab mode
420- tm.ExitTabMode("rename-complete")
421- })
422-}
423-
424-// trimWhitespace removes leading and trailing whitespace from a string.
425-func trimWhitespace(s string) string {
426- start := 0
427- end := len(s)
428-
429- // Trim leading whitespace
430- for start < end && isWhitespace(s[start]) {
431- start++
432- }
433-
434- // Trim trailing whitespace
435- for end > start && isWhitespace(s[end-1]) {
436- end--
437- }
438-
439- return s[start:end]
440-}
441-
442-// isWhitespace checks if a byte is a whitespace character.
443-func isWhitespace(b byte) bool {
444- return b == ' ' || b == '\t' || b == '\n' || b == '\r'
445-}
D internal/app/browser/tab_manager.go +0 -717
@@ -1,717 +0,0 @@
1-package browser
2-
3-import (
4- "fmt"
5- "sync"
6- "time"
7-
8- "github.com/bnema/dumber/internal/config"
9- "github.com/bnema/dumber/internal/logging"
10- "github.com/bnema/dumber/pkg/webkit"
11- "github.com/diamondburned/gotk4/pkg/gtk/v4"
12-)
13-
14-// TabManager manages the global tab system for the browser.
15-// Each tab contains an independent workspace with its own pane tree.
16-type TabManager struct {
17- tabs []*Tab
18- activeIndex int
19- window *webkit.Window
20- app *BrowserApp
21-
22- // GTK widgets
23- rootContainer gtk.Widgetter // Main vertical box containing tab bar + content
24- tabBar gtk.Widgetter // Horizontal tab bar container
25- ContentArea gtk.Widgetter // Container for active workspace (exported for border styling)
26- progressBar gtk.Widgetter // Thin load progress indicator
27-
28- // Border overlays (float over content without layout shift)
29- paneModeOverlay gtk.Widgetter // Border overlay for pane mode
30- tabModeOverlay gtk.Widgetter // Border overlay for tab mode
31-
32- // Modal state
33- tabModeActive bool
34- tabModeTimer *time.Timer
35- lastTabCreate time.Time // Debounce guard for rapid tab creation shortcuts
36-
37- // Rename state
38- renameInProgress bool
39- renamingTab *Tab
40- // Stores original focus behaviour while inline rename is active
41- renamePrevFocusOnClick bool
42- renamePrevCanFocus bool
43-
44- // Tab button click handling (pattern from StackedPaneManager)
45- buttonToTab map[uint64]*Tab // Maps button ID to tab for click handling
46- nextButtonID uint64 // Atomic counter for generating unique button IDs
47-
48- // Synchronization
49- mu sync.RWMutex
50-}
51-
52-// Tab represents a single browser tab containing a complete workspace.
53-type Tab struct {
54- id string
55- title string
56- customTitle string // User-provided custom name (persists across page loads)
57- workspace *WorkspaceManager
58- titleButton gtk.Widgetter
59- isActive bool
60- progress float64
61- isLoading bool
62- webViews map[*webkit.WebView]struct{}
63-}
64-
65-// NewTabManager creates a new tab manager instance.
66-func NewTabManager(app *BrowserApp, window *webkit.Window) *TabManager {
67- tm := &TabManager{
68- tabs: make([]*Tab, 0),
69- activeIndex: -1,
70- window: window,
71- app: app,
72- buttonToTab: make(map[uint64]*Tab),
73- }
74-
75- logging.Info("[tabs] Tab manager initialized")
76- return tm
77-}
78-
79-// Initialize sets up the tab bar and creates the initial tab.
80-func (tm *TabManager) Initialize(initialURL string) error {
81- tm.mu.Lock()
82- defer tm.mu.Unlock()
83-
84- logging.Info("[tabs] Initializing tab system")
85-
86- // Create root container layout
87- if err := tm.createRootContainer(); err != nil {
88- return fmt.Errorf("failed to create root container: %w", err)
89- }
90-
91- // Create initial tab
92- if err := tm.createTabInternal(initialURL); err != nil {
93- return fmt.Errorf("failed to create initial tab: %w", err)
94- }
95-
96- logging.Info("[tabs] Tab system initialized with 1 tab")
97- return nil
98-}
99-
100-// createRootContainer creates the main vertical container with tab bar and content area.
101-func (tm *TabManager) createRootContainer() error {
102- logging.Info("[tabs] Creating root container with tab bar")
103-
104- // Get tab bar position from config
105- cfg := tm.getConfig()
106- position := cfg.Workspace.TabBarPosition
107- if position != "top" && position != "bottom" {
108- position = "bottom" // Fallback to default
109- }
110-
111- logging.Debug(fmt.Sprintf("[tabs] Tab bar position: %s", position))
112-
113- // Create main vertical box (tab bar + content area)
114- rootBox := gtk.NewBox(gtk.OrientationVertical, 0)
115- if rootBox == nil {
116- return fmt.Errorf("failed to create root container box")
117- }
118-
119- rootBox.SetHExpand(true)
120- rootBox.SetVExpand(true)
121-
122- // Create tab bar
123- tabBar := tm.createTabBar()
124- if tabBar == nil {
125- return fmt.Errorf("failed to create tab bar")
126- }
127-
128- // Create content area for active workspace
129- contentArea := gtk.NewBox(gtk.OrientationVertical, 0)
130- if contentArea == nil {
131- return fmt.Errorf("failed to create content area")
132- }
133- contentArea.SetHExpand(true)
134- contentArea.SetVExpand(true)
135- contentArea.AddCSSClass("tab-content-area")
136-
137- // Create progress bar (displayed at the bottom regardless of tab bar position)
138- progressBar := tm.createProgressBar()
139-
140- // Add widgets in order based on position
141- if position == "top" {
142- rootBox.Append(tabBar)
143- rootBox.Append(contentArea)
144- } else {
145- rootBox.Append(contentArea)
146- rootBox.Append(tabBar)
147- }
148-
149- // Wrap everything in an overlay so the progress bar can float without resizing layout
150- rootOverlay := gtk.NewOverlay()
151- if rootOverlay == nil {
152- return fmt.Errorf("failed to create root overlay")
153- }
154- rootOverlay.SetHExpand(true)
155- rootOverlay.SetVExpand(true)
156- rootOverlay.SetChild(rootBox)
157- if progressBar != nil {
158- if bar, ok := progressBar.(*gtk.ProgressBar); ok && bar != nil {
159- bar.SetHAlign(gtk.AlignFill)
160- bar.SetVAlign(gtk.AlignEnd)
161- }
162- rootOverlay.AddOverlay(progressBar)
163- }
164-
165- // Create border overlays (hidden by default, shown when pane/tab mode is active)
166- paneModeOverlay := tm.createBorderOverlay("pane-mode-border")
167- tabModeOverlay := tm.createBorderOverlay("tab-mode-border")
168-
169- if paneModeOverlay != nil {
170- rootOverlay.AddOverlay(paneModeOverlay)
171- webkit.WidgetSetVisible(paneModeOverlay, false)
172- }
173-
174- if tabModeOverlay != nil {
175- rootOverlay.AddOverlay(tabModeOverlay)
176- webkit.WidgetSetVisible(tabModeOverlay, false)
177- }
178-
179- // Store references
180- tm.rootContainer = rootOverlay
181- tm.tabBar = tabBar
182- tm.ContentArea = contentArea
183- tm.progressBar = progressBar
184- tm.paneModeOverlay = paneModeOverlay
185- tm.tabModeOverlay = tabModeOverlay
186-
187- logging.Info(fmt.Sprintf("[tabs] Root container created with tab bar at %s", position))
188- return nil
189-}
190-
191-// CreateTab creates a new tab and switches to it.
192-// URL can be empty string to use default homepage.
193-func (tm *TabManager) CreateTab(url string) error {
194- tm.mu.Lock()
195- defer tm.mu.Unlock()
196-
197- return tm.createTabInternal(url)
198-}
199-
200-// createTabInternal creates a new tab (must be called with lock held).
201-func (tm *TabManager) createTabInternal(url string) error {
202- logging.Info(fmt.Sprintf("[tabs] Creating new tab with URL: %s", url))
203-
204- const tabCreateDebounce = 200 * time.Millisecond
205- if !tm.lastTabCreate.IsZero() && time.Since(tm.lastTabCreate) < tabCreateDebounce {
206- logging.Warn("[tabs] Ignoring rapid tab creation request (debounced)")
207- return nil
208- }
209-
210- // Use default URL if empty
211- if url == "" {
212- url = "about:blank"
213- }
214-
215- // Generate unique tab ID and default name
216- tabNumber := len(tm.tabs) + 1
217- tabID := fmt.Sprintf("tab-%d-%d", tabNumber, time.Now().Unix())
218- defaultTitle := fmt.Sprintf("Tab %d", tabNumber)
219-
220- // Create tab structure
221- tab := &Tab{
222- id: tabID,
223- title: defaultTitle,
224- isActive: false,
225- webViews: make(map[*webkit.WebView]struct{}),
226- }
227-
228- // Create WebView for this tab
229- cfg, err := tm.app.buildWebkitConfig()
230- if err != nil {
231- return fmt.Errorf("failed to build webkit config: %w", err)
232- }
233- cfg.CreateWindow = false // Tab webviews are embedded, don't create their own window
234-
235- view, err := webkit.NewWebView(cfg)
236- if err != nil {
237- return fmt.Errorf("failed to create webview: %w", err)
238- }
239-
240- // Register with content blocking service for network/cosmetic filtering
241- tm.app.RegisterWebViewForFiltering(view)
242-
243- // Create pane for this WebView
244- pane, err := tm.app.createPaneForView(view)
245- if err != nil {
246- return fmt.Errorf("failed to create pane: %w", err)
247- }
248-
249- // Create workspace for this tab
250- workspace := NewWorkspaceManager(tm.app, pane)
251- tab.workspace = workspace
252- tm.registerWebViewLocked(tab, view)
253-
254- // Verify workspace root exists
255- rootWidget := workspace.GetRootWidget()
256- if rootWidget == nil {
257- return fmt.Errorf("workspace root widget is nil")
258- }
259- logging.Info(fmt.Sprintf("[tabs] New tab created with root container: %p (workspace %p)", rootWidget, workspace))
260-
261- // Load the URL in the workspace's pane
262- if url != "" {
263- if err := view.LoadURL(url); err != nil {
264- logging.Warn(fmt.Sprintf("[tabs] Failed to load URL %s: %v", url, err))
265- }
266- }
267-
268- // Create tab button in tab bar
269- button := tm.createTabButton(tab)
270- if button == nil {
271- return fmt.Errorf("failed to create tab button")
272- }
273- tab.titleButton = button
274-
275- // Add button to tab bar
276- tm.addTabToBar(tab)
277-
278- // Add to tabs slice
279- tm.tabs = append(tm.tabs, tab)
280- tm.lastTabCreate = time.Now()
281-
282- // Attach click handler with unique ID (pattern from stacked panes)
283- tm.attachTabClickHandler(tab.titleButton, tab)
284-
285- // Switch to new tab
286- newIndex := len(tm.tabs) - 1
287- if err := tm.switchToTabInternal(newIndex); err != nil {
288- return fmt.Errorf("failed to switch to new tab: %w", err)
289- }
290-
291- // Update tab bar visibility (hide if only 1 tab)
292- tm.updateTabBarVisibility()
293-
294- logging.Info(fmt.Sprintf("[tabs] Created tab %s (total: %d)", tabID, len(tm.tabs)))
295- return nil
296-}
297-
298-// SwitchToTab switches to the tab at the given index.
299-func (tm *TabManager) SwitchToTab(index int) error {
300- tm.mu.Lock()
301- defer tm.mu.Unlock()
302-
303- return tm.switchToTabInternal(index)
304-}
305-
306-// switchToTabInternal switches to a tab (must be called with lock held).
307-func (tm *TabManager) switchToTabInternal(index int) error {
308- // Validate index
309- if index < 0 || index >= len(tm.tabs) {
310- logging.Warn(fmt.Sprintf("[tabs] Invalid tab index: %d (valid: 0-%d)", index, len(tm.tabs)-1))
311- return fmt.Errorf("tab index %d out of range", index)
312- }
313-
314- // Check if already active
315- if index == tm.activeIndex {
316- logging.Debug(fmt.Sprintf("[tabs] Tab %d already active", index))
317- return nil
318- }
319-
320- logging.Info(fmt.Sprintf("[tabs] Switching from tab %d to %d", tm.activeIndex, index))
321-
322- // Hide current tab if any
323- if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
324- oldTab := tm.tabs[tm.activeIndex]
325- oldTab.isActive = false
326-
327- // Clear GTK focus from old tab's workspace to prevent it from catching keyboard events
328- if oldTab.workspace != nil {
329- oldTab.workspace.ClearFocus()
330- }
331-
332- // Hide old workspace container and remove from content area
333- // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container,
334- // because splits/stacks change the workspace root
335- if oldTab.workspace != nil {
336- currentRoot := oldTab.workspace.GetRootWidget()
337- if currentRoot != nil {
338- webkit.RunOnMainThread(func() {
339- if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
340- contentBox.Remove(currentRoot)
341- logging.Info(fmt.Sprintf("[tabs] Removed tab %d container %p from ContentArea (current root)", tm.activeIndex, currentRoot))
342- }
343- })
344- }
345- }
346-
347- // Remove active CSS class from old button
348- tm.setTabActiveStyle(oldTab, false)
349- }
350-
351- // Show new tab
352- newTab := tm.tabs[index]
353- newTab.isActive = true
354- tm.activeIndex = index
355-
356- // Add new workspace container to content area
357- // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
358- if newTab.workspace != nil {
359- currentRoot := newTab.workspace.GetRootWidget()
360- if currentRoot != nil {
361- webkit.RunOnMainThread(func() {
362- if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
363- contentBox.Append(currentRoot)
364- webkit.WidgetSetVisible(currentRoot, true)
365- logging.Info(fmt.Sprintf("[tabs] Added tab %d container %p to ContentArea (current root)", index, currentRoot))
366- }
367- })
368- }
369- }
370-
371- // Add active CSS class to new button
372- tm.setTabActiveStyle(newTab, true)
373-
374- // Update app-level workspace/panes references for compatibility
375- if newTab.workspace != nil {
376- tm.app.workspace = newTab.workspace
377- tm.app.panes = newTab.workspace.GetAllPanes()
378- tm.app.activePane = newTab.workspace.GetActivePane()
379-
380- // Focus the new workspace's active pane
381- newTab.workspace.RestoreFocus()
382- }
383-
384- // Sync progress bar with the newly active tab
385- tm.updateProgressBarLocked()
386-
387- logging.Info(fmt.Sprintf("[tabs] Switched to tab %d (%s)", index, newTab.id))
388- return nil
389-}
390-
391-// CloseTab closes the tab at the given index.
392-func (tm *TabManager) CloseTab(index int) error {
393- tm.mu.Lock()
394- defer tm.mu.Unlock()
395-
396- return tm.closeTabInternal(index)
397-}
398-
399-// closeTabInternal closes a tab (must be called with lock held).
400-func (tm *TabManager) closeTabInternal(index int) error {
401- // Validate index
402- if index < 0 || index >= len(tm.tabs) {
403- logging.Warn(fmt.Sprintf("[tabs] Cannot close: invalid tab index %d", index))
404- return fmt.Errorf("tab index %d out of range", index)
405- }
406-
407- // Prevent closing last tab
408- if len(tm.tabs) == 1 {
409- logging.Warn("[tabs] Cannot close last tab")
410- return fmt.Errorf("cannot close the last tab")
411- }
412-
413- tab := tm.tabs[index]
414- logging.Info(fmt.Sprintf("[tabs] Closing tab %d (%s)", index, tab.id))
415-
416- // Cleanup workspace (WorkspaceManager cleanup is handled automatically when tabs are removed)
417-
418- // Clean up button mapping (find and remove this tab's button ID)
419- for buttonID, mappedTab := range tm.buttonToTab {
420- if mappedTab == tab {
421- delete(tm.buttonToTab, buttonID)
422- logging.Debug(fmt.Sprintf("[tabs] Cleaned up button mapping for closed tab %s (buttonID=%d)", tab.id, buttonID))
423- break
424- }
425- }
426-
427- // Remove tab button from tab bar
428- tm.removeTabFromBar(tab)
429-
430- // Remove workspace container from content area if it's currently visible
431- // CRITICAL: Get the CURRENT root widget from workspace, not the cached tab.container
432- if tab.isActive && tab.workspace != nil {
433- currentRoot := tab.workspace.GetRootWidget()
434- if currentRoot != nil {
435- webkit.RunOnMainThread(func() {
436- if contentBox, ok := tm.ContentArea.(*gtk.Box); ok {
437- contentBox.Remove(currentRoot)
438- logging.Info(fmt.Sprintf("[tabs] Removed closing tab %d container %p from ContentArea (current root)", index, currentRoot))
439- }
440- })
441- }
442- }
443-
444- // Remove from slice
445- tm.tabs = append(tm.tabs[:index], tm.tabs[index+1:]...)
446-
447- // Determine which tab to switch to
448- newIndex := index
449- if index >= len(tm.tabs) {
450- newIndex = len(tm.tabs) - 1
451- }
452-
453- // Update active index and switch
454- if index == tm.activeIndex {
455- tm.activeIndex = -1 // Temporarily invalid
456- if err := tm.switchToTabInternal(newIndex); err != nil {
457- return fmt.Errorf("failed to switch after close: %w", err)
458- }
459- } else if index < tm.activeIndex {
460- // Adjust active index if a tab before it was closed
461- tm.activeIndex--
462- }
463-
464- // Update tab bar visibility (hide if only 1 tab)
465- tm.updateTabBarVisibility()
466-
467- // Update progress bar (active tab may have changed after close)
468- tm.updateProgressBarLocked()
469-
470- logging.Info(fmt.Sprintf("[tabs] Tab closed (remaining: %d)", len(tm.tabs)))
471- return nil
472-}
473-
474-// RenameTab sets a custom title for the tab at the given index.
475-func (tm *TabManager) RenameTab(index int, customTitle string) error {
476- tm.mu.Lock()
477- defer tm.mu.Unlock()
478-
479- if index < 0 || index >= len(tm.tabs) {
480- return fmt.Errorf("tab index %d out of range", index)
481- }
482-
483- tab := tm.tabs[index]
484- tab.customTitle = customTitle
485-
486- logging.Info(fmt.Sprintf("[tabs] Renamed tab %d to '%s'", index, customTitle))
487-
488- // Update tab button label
489- tm.updateTabButton(tab)
490-
491- return nil
492-}
493-
494-// Note: Tab titles are static (Tab 1, Tab 2, etc.) and don't change based on page titles.
495-// This is different from stacked panes which show page titles.
496-// Users can only change tab names via explicit rename action (Alt+T → r)
497-
498-// GetActiveTab returns the currently active tab.
499-func (tm *TabManager) GetActiveTab() *Tab {
500- tm.mu.RLock()
501- defer tm.mu.RUnlock()
502-
503- if tm.activeIndex >= 0 && tm.activeIndex < len(tm.tabs) {
504- return tm.tabs[tm.activeIndex]
505- }
506- return nil
507-}
508-
509-// GetTabCount returns the number of open tabs.
510-func (tm *TabManager) GetTabCount() int {
511- tm.mu.RLock()
512- defer tm.mu.RUnlock()
513-
514- return len(tm.tabs)
515-}
516-
517-// IsTabModeActive returns whether tab mode is currently active.
518-func (tm *TabManager) IsTabModeActive() bool {
519- tm.mu.RLock()
520- defer tm.mu.RUnlock()
521-
522- return tm.tabModeActive
523-}
524-
525-// IsRenameInProgress returns whether a tab rename is currently in progress.
526-func (tm *TabManager) IsRenameInProgress() bool {
527- tm.mu.RLock()
528- defer tm.mu.RUnlock()
529-
530- return tm.renameInProgress
531-}
532-
533-// NextTab switches to the next tab (wraps around).
534-func (tm *TabManager) NextTab() error {
535- tm.mu.Lock()
536- defer tm.mu.Unlock()
537-
538- if len(tm.tabs) <= 1 {
539- return nil // No other tab to switch to
540- }
541-
542- nextIndex := (tm.activeIndex + 1) % len(tm.tabs)
543- return tm.switchToTabInternal(nextIndex)
544-}
545-
546-// PreviousTab switches to the previous tab (wraps around).
547-func (tm *TabManager) PreviousTab() error {
548- tm.mu.Lock()
549- defer tm.mu.Unlock()
550-
551- if len(tm.tabs) <= 1 {
552- return nil // No other tab to switch to
553- }
554-
555- prevIndex := tm.activeIndex - 1
556- if prevIndex < 0 {
557- prevIndex = len(tm.tabs) - 1
558- }
559- return tm.switchToTabInternal(prevIndex)
560-}
561-
562-// GetRootContainer returns the main container widget that should be set as window child.
563-func (tm *TabManager) GetRootContainer() gtk.Widgetter {
564- tm.mu.RLock()
565- defer tm.mu.RUnlock()
566-
567- return tm.rootContainer
568-}
569-
570-// GetWindow returns the GTK window backing the tab manager.
571-func (tm *TabManager) GetWindow() *webkit.Window {
572- tm.mu.RLock()
573- defer tm.mu.RUnlock()
574-
575- return tm.window
576-}
577-
578-// Cleanup performs cleanup of all tabs and resources.
579-func (tm *TabManager) Cleanup() {
580- tm.mu.Lock()
581- defer tm.mu.Unlock()
582-
583- logging.Info("[tabs] Cleaning up tab manager")
584-
585- // Stop tab mode timer if active
586- if tm.tabModeTimer != nil {
587- tm.tabModeTimer.Stop()
588- }
589-
590- // Cleanup all tabs (workspaces cleanup automatically when dereferenced)
591-
592- logging.Info("[tabs] Tab manager cleanup complete")
593-}
594-
595-// updateTabBarVisibility shows or hides the tab bar based on tab count.
596-// Tab bar is hidden when there's only 1 tab (no need to show it).
597-func (tm *TabManager) updateTabBarVisibility() {
598- if tm.tabBar == nil {
599- return
600- }
601-
602- tabCount := len(tm.tabs)
603- shouldShow := tabCount > 1
604-
605- webkit.WidgetSetVisible(tm.tabBar, shouldShow)
606-
607- if shouldShow {
608- logging.Debug(fmt.Sprintf("[tabs] Tab bar visible (%d tabs)", tabCount))
609- } else {
610- logging.Debug("[tabs] Tab bar hidden (only 1 tab)")
611- }
612-}
613-
614-// GetConfig is a helper to get the current configuration.
615-func (tm *TabManager) getConfig() *config.Config {
616- return config.Get()
617-}
618-
619-// updateProgressForWebView updates progress state for the tab that owns the given WebView.
620-func (tm *TabManager) updateProgressForWebView(view *webkit.WebView, progress float64, loading bool) {
621- if view == nil || tm == nil {
622- return
623- }
624-
625- tm.mu.Lock()
626- defer tm.mu.Unlock()
627-
628- tab := tm.findTabForViewLocked(view)
629- if tab == nil {
630- return
631- }
632-
633- // Clamp progress
634- if progress < 0 {
635- progress = 0
636- }
637- if progress > 1 {
638- progress = 1
639- }
640-
641- // Only mark loading when progress is incomplete and the event indicates an active load.
642- tab.isLoading = loading && progress < 1.0
643- tab.progress = progress
644-
645- tm.updateProgressBarLocked()
646-}
647-
648-// findTabForViewLocked finds the tab owning the given WebView. Caller must hold tm.mu.
649-func (tm *TabManager) findTabForViewLocked(view *webkit.WebView) *Tab {
650- for _, tab := range tm.tabs {
651- if _, ok := tab.webViews[view]; ok {
652- return tab
653- }
654- }
655- return nil
656-}
657-
658-// registerWebViewLocked associates a WebView with a tab. Caller must hold tm.mu.
659-func (tm *TabManager) registerWebViewLocked(tab *Tab, view *webkit.WebView) {
660- if tab == nil || view == nil {
661- return
662- }
663- if tab.webViews == nil {
664- tab.webViews = make(map[*webkit.WebView]struct{})
665- }
666- tab.webViews[view] = struct{}{}
667-}
668-
669-// registerWebViewForWorkspace registers a webview to the tab that owns the workspace.
670-func (tm *TabManager) registerWebViewForWorkspace(view *webkit.WebView, workspace *WorkspaceManager) {
671- if tm == nil || view == nil || workspace == nil {
672- return
673- }
674-
675- tm.mu.Lock()
676- defer tm.mu.Unlock()
677-
678- for _, tab := range tm.tabs {
679- if tab.workspace == workspace {
680- tm.registerWebViewLocked(tab, view)
681- return
682- }
683- }
684-}
685-
686-// updateProgressBarLocked syncs the shared progress bar to the active tab. Caller must hold tm.mu.
687-func (tm *TabManager) updateProgressBarLocked() {
688- if tm.progressBar == nil {
689- return
690- }
691-
692- bar, ok := tm.progressBar.(*gtk.ProgressBar)
693- if !ok || bar == nil {
694- return
695- }
696-
697- // No active tab -> hide bar
698- if tm.activeIndex < 0 || tm.activeIndex >= len(tm.tabs) {
699- webkit.RunOnMainThread(func() {
700- bar.SetFraction(0.0)
701- webkit.WidgetSetVisible(bar, false)
702- })
703- return
704- }
705-
706- activeTab := tm.tabs[tm.activeIndex]
707- visible := activeTab.isLoading
708- fraction := activeTab.progress
709- if !visible {
710- fraction = 0.0
711- }
712-
713- webkit.RunOnMainThread(func() {
714- bar.SetFraction(fraction)
715- webkit.WidgetSetVisible(bar, visible)
716- })
717-}
D internal/app/browser/tab_mode.go +0 -242
@@ -1,242 +0,0 @@
1-package browser
2-
3-import (
4- "fmt"
5- "time"
6-
7- "github.com/bnema/dumber/internal/logging"
8-)
9-
10-// EnterTabMode activates the modal tab management mode.
11-// This is triggered by Alt+T and provides visual feedback and temporary key bindings.
12-func (tm *TabManager) EnterTabMode() {
13- logging.Debug("[tab-mode] EnterTabMode called - attempting to acquire lock")
14- tm.mu.Lock()
15- defer tm.mu.Unlock()
16- logging.Debug("[tab-mode] Lock acquired")
17-
18- // Already in tab mode?
19- if tm.tabModeActive {
20- logging.Debug("[tab-mode] Tab mode already active")
21- return
22- }
23-
24- logging.Info("[tab-mode] Entering tab mode")
25-
26- logging.Debug("[tab-mode] Setting tabModeActive flag")
27- tm.tabModeActive = true
28-
29- // Apply visual border indicator
30- logging.Debug("[tab-mode] About to call applyTabModeBorder()")
31- tm.applyTabModeBorder()
32- logging.Debug("[tab-mode] Returned from applyTabModeBorder()")
33-
34- // Notify JavaScript for UI feedback (if needed)
35- logging.Debug("[tab-mode] About to call dispatchTabModeEventInternal()")
36- tm.dispatchTabModeEventInternal("entered", "")
37- logging.Debug("[tab-mode] Returned from dispatchTabModeEventInternal()")
38-
39- // Start timeout timer to auto-exit tab mode
40- logging.Debug("[tab-mode] Getting config")
41- cfg := tm.getConfig()
42- timeoutDuration := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
43- logging.Debug(fmt.Sprintf("[tab-mode] Config retrieved, timeout: %v", timeoutDuration))
44-
45- // Cancel existing timer if any
46- if tm.tabModeTimer != nil {
47- logging.Debug("[tab-mode] Stopping existing timer")
48- tm.tabModeTimer.Stop()
49- }
50-
51- logging.Debug("[tab-mode] Creating new timeout timer")
52- tm.tabModeTimer = time.AfterFunc(timeoutDuration, func() {
53- logging.Debug("[tab-mode] Timeout reached, exiting tab mode")
54- tm.ExitTabMode("timeout")
55- })
56-
57- logging.Debug(fmt.Sprintf("[tab-mode] Tab mode entered successfully, will auto-exit in %v", timeoutDuration))
58-}
59-
60-// ExitTabMode deactivates tab mode and removes visual indicators.
61-func (tm *TabManager) ExitTabMode(reason string) {
62- tm.mu.Lock()
63- defer tm.mu.Unlock()
64-
65- if !tm.tabModeActive {
66- return
67- }
68-
69- logging.Info(fmt.Sprintf("[tab-mode] Exiting tab mode: %s", reason))
70-
71- tm.tabModeActive = false
72-
73- // Stop timeout timer
74- if tm.tabModeTimer != nil {
75- tm.tabModeTimer.Stop()
76- tm.tabModeTimer = nil
77- }
78-
79- // Remove visual border indicator
80- tm.removeTabModeBorder()
81-
82- // Notify JavaScript (use internal version to avoid lock reentry)
83- tm.dispatchTabModeEventInternal("exited", reason)
84-}
85-
86-// HandleTabAction processes a tab mode action (new-tab, close-tab, etc.).
87-func (tm *TabManager) HandleTabAction(action string) {
88- tm.mu.Lock()
89-
90- if !tm.tabModeActive {
91- tm.mu.Unlock()
92- logging.Debug(fmt.Sprintf("[tab-mode] Ignoring action '%s': tab mode not active", action))
93- return
94- }
95-
96- tm.mu.Unlock()
97-
98- logging.Info(fmt.Sprintf("[tab-mode] Handling action '%s'", action))
99-
100- switch action {
101- case "new-tab":
102- tm.handleNewTab()
103- case "close-tab":
104- tm.handleCloseTab()
105- case "next-tab":
106- tm.handleNextTab()
107- case "previous-tab":
108- tm.handlePreviousTab()
109- case "rename-tab":
110- tm.handleRenameTab()
111- case "confirm":
112- // Just exit tab mode
113- tm.ExitTabMode("confirm")
114- case "cancel":
115- // Exit tab mode without action
116- tm.ExitTabMode("cancel")
117- default:
118- logging.Warn(fmt.Sprintf("[tab-mode] Unknown action: %s", action))
119- }
120-
121- // Exit tab mode after most actions (except rename which shows a dialog)
122- if action != "cancel" && action != "confirm" && action != "rename-tab" {
123- tm.ExitTabMode(action)
124- }
125-}
126-
127-// handleNewTab creates a new tab.
128-func (tm *TabManager) handleNewTab() {
129- logging.Debug("[tab-mode] Creating new tab")
130-
131- if err := tm.CreateTab(""); err != nil {
132- logging.Error(fmt.Sprintf("[tab-mode] Failed to create new tab: %v", err))
133- }
134-}
135-
136-// handleCloseTab closes the currently active tab.
137-func (tm *TabManager) handleCloseTab() {
138- logging.Debug("[tab-mode] Closing active tab")
139-
140- tm.mu.RLock()
141- activeIndex := tm.activeIndex
142- tabCount := len(tm.tabs)
143- tm.mu.RUnlock()
144-
145- if tabCount <= 1 {
146- logging.Warn("[tab-mode] Cannot close last tab")
147- return
148- }
149-
150- if err := tm.CloseTab(activeIndex); err != nil {
151- logging.Error(fmt.Sprintf("[tab-mode] Failed to close tab: %v", err))
152- }
153-}
154-
155-// handleNextTab switches to the next tab.
156-func (tm *TabManager) handleNextTab() {
157- logging.Debug("[tab-mode] Switching to next tab")
158-
159- if err := tm.NextTab(); err != nil {
160- logging.Error(fmt.Sprintf("[tab-mode] Failed to switch to next tab: %v", err))
161- }
162-}
163-
164-// handlePreviousTab switches to the previous tab.
165-func (tm *TabManager) handlePreviousTab() {
166- logging.Debug("[tab-mode] Switching to previous tab")
167-
168- if err := tm.PreviousTab(); err != nil {
169- logging.Error(fmt.Sprintf("[tab-mode] Failed to switch to previous tab: %v", err))
170- }
171-}
172-
173-// handleRenameTab starts inline editing of the active tab's name.
174-func (tm *TabManager) handleRenameTab() {
175- logging.Debug("[tab-mode] Starting inline tab rename")
176-
177- tm.mu.RLock()
178- activeIndex := tm.activeIndex
179- tabCount := len(tm.tabs)
180- var activeTab *Tab
181- if activeIndex >= 0 && activeIndex < len(tm.tabs) {
182- activeTab = tm.tabs[activeIndex]
183- }
184- tm.mu.RUnlock()
185-
186- // Guard: Don't allow rename if only 1 tab (tab bar is hidden)
187- if tabCount <= 1 {
188- logging.Warn("[tab-mode] Cannot rename: only one tab (tab bar hidden)")
189- return
190- }
191-
192- if activeTab == nil {
193- logging.Error("[tab-mode] No active tab to rename")
194- return
195- }
196-
197- // Start inline rename (tab mode will be exited by finishInlineRename)
198- logging.Info(fmt.Sprintf("[tab-mode] Starting inline rename for tab %s", activeTab.id))
199- tm.startInlineRename(activeTab)
200-}
201-
202-// applyTabModeBorder applies visual indicator when tab mode is active.
203-// Uses an overlay widget to show the border without affecting layout.
204-func (tm *TabManager) applyTabModeBorder() {
205- tm.showTabModeBorder()
206-}
207-
208-// removeTabModeBorder removes the visual indicator when exiting tab mode.
209-// Uses an overlay widget to hide the border without affecting layout.
210-func (tm *TabManager) removeTabModeBorder() {
211- tm.hideTabModeBorder()
212-}
213-
214-// dispatchTabModeEventInternal sends an event without acquiring locks.
215-// Must be called with lock already held.
216-func (tm *TabManager) dispatchTabModeEventInternal(event string, detail string) {
217- logging.Debug(fmt.Sprintf("[tab-mode] dispatchTabModeEventInternal: entry (event=%s, detail=%s)", event, detail))
218-
219- // Get active tab
220- if tm.activeIndex < 0 || tm.activeIndex >= len(tm.tabs) {
221- logging.Debug(fmt.Sprintf("[tab-mode] dispatchTabModeEventInternal: invalid activeIndex=%d, returning", tm.activeIndex))
222- return
223- }
224-
225- activeTab := tm.tabs[tm.activeIndex]
226- if activeTab == nil {
227- logging.Debug("[tab-mode] dispatchTabModeEventInternal: activeTab is nil, returning")
228- return
229- }
230-
231- // Log tab mode events for debugging (no JS dispatch needed for simple text tab labels)
232- logging.Debug(fmt.Sprintf("[tab-mode] Tab mode event '%s' with detail '%s' for tab %s", event, detail, activeTab.id))
233- logging.Debug("[tab-mode] dispatchTabModeEventInternal: completed successfully")
234-}
235-
236-// dispatchTabModeEvent sends a custom event to the active tab's webview (with locking).
237-// This can be used to provide UI feedback in the web content.
238-func (tm *TabManager) dispatchTabModeEvent(event string, detail string) {
239- tm.mu.RLock()
240- defer tm.mu.RUnlock()
241- tm.dispatchTabModeEventInternal(event, detail)
242-}
D internal/app/browser/webview.go +0 -513
@@ -1,513 +0,0 @@
1-package browser
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "os"
8- "path/filepath"
9- "time"
10-
11- "github.com/bnema/dumber/internal/app/constants"
12- "github.com/bnema/dumber/internal/app/control"
13- "github.com/bnema/dumber/internal/app/messaging"
14- "github.com/bnema/dumber/internal/config"
15- "github.com/bnema/dumber/internal/filtering"
16- "github.com/bnema/dumber/internal/logging"
17- "github.com/bnema/dumber/internal/services"
18- "github.com/bnema/dumber/pkg/webkit"
19-)
20-
21-// Note: Memory management and color palettes are now handled at the application level
22-// The gotk4 webkit.Config only contains basic WebKit settings
23-
24-func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
25- // Ensure data directories exist for WebKit
26- dataDir, err := config.GetDataDir()
27- if err != nil {
28- return nil, err
29- }
30- stateDir, err := config.GetStateDir()
31- if err != nil {
32- return nil, err
33- }
34-
35- webkitData := filepath.Join(dataDir, "webkit")
36- webkitCache := filepath.Join(stateDir, "webkit-cache")
37- if err := os.MkdirAll(webkitData, constants.DirPerm); err != nil {
38- return nil, err
39- }
40- if err := os.MkdirAll(webkitCache, constants.DirPerm); err != nil {
41- return nil, err
42- }
43-
44- // Build WebKit configuration with data directories for persistence
45- cfg := &webkit.Config{
46- UserAgent: buildUserAgent(app.config),
47- EnableJavaScript: true,
48- EnableWebGL: true,
49- EnableMediaStream: true,
50- HardwareAcceleration: true,
51- DefaultFontSize: app.config.Appearance.DefaultFontSize,
52- MinimumFontSize: 8,
53- EnablePageCache: true, // Instant back/forward navigation (bfcache)
54- EnableSmoothScrolling: true, // Smooth scrolling animations
55- DataDir: webkitData,
56- CacheDir: webkitCache,
57- AppearanceConfigJSON: app.buildAppearanceConfigJSON(),
58- CreateWindow: true, // Default to creating a window for standalone WebViews
59- }
60-
61- return cfg, nil
62-}
63-
64-// buildUserAgent constructs the user agent string from config
65-func buildUserAgent(cfg *config.Config) string {
66- if cfg.CodecPreferences.CustomUserAgent != "" {
67- return cfg.CodecPreferences.CustomUserAgent
68- }
69- // Use default WebKit user agent
70- return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
71-}
72-
73-// buildAppearanceConfigJSON builds the palette configuration as JSON
74-// The GUI expects window.__dumber_palette with just the palette data
75-func (app *BrowserApp) buildAppearanceConfigJSON() string {
76- if app.config == nil {
77- return ""
78- }
79-
80- // Build palette config in the format main-world.ts expects
81- // It looks for window.__dumber_palette = { "light": {...}, "dark": {...} }
82- paletteConfig := map[string]interface{}{
83- "light": map[string]string{
84- "background": app.config.Appearance.LightPalette.Background,
85- "surface": app.config.Appearance.LightPalette.Surface,
86- "surface_variant": app.config.Appearance.LightPalette.SurfaceVariant,
87- "text": app.config.Appearance.LightPalette.Text,
88- "muted": app.config.Appearance.LightPalette.Muted,
89- "accent": app.config.Appearance.LightPalette.Accent,
90- "border": app.config.Appearance.LightPalette.Border,
91- },
92- "dark": map[string]string{
93- "background": app.config.Appearance.DarkPalette.Background,
94- "surface": app.config.Appearance.DarkPalette.Surface,
95- "surface_variant": app.config.Appearance.DarkPalette.SurfaceVariant,
96- "text": app.config.Appearance.DarkPalette.Text,
97- "muted": app.config.Appearance.DarkPalette.Muted,
98- "accent": app.config.Appearance.DarkPalette.Accent,
99- "border": app.config.Appearance.DarkPalette.Border,
100- },
101- }
102-
103- payload, err := json.Marshal(paletteConfig)
104- if err != nil {
105- logging.Error(fmt.Sprintf("[webview] Failed to marshal palette config: %v", err))
106- return ""
107- }
108-
109- return string(payload)
110-}
111-
112-func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
113- pane := &BrowserPane{
114- webView: view,
115- }
116-
117- pane.clipboardController = control.NewClipboardController(view)
118- pane.zoomController = control.NewZoomController(app.browserService, view)
119- pane.navigationController = control.NewNavigationController(
120- app.parserService,
121- app.browserService,
122- view,
123- pane.zoomController,
124- )
125- pane.messageHandler = messaging.NewHandler(app.parserService, app.browserService)
126- pane.messageHandler.SetWebView(view)
127- pane.messageHandler.SetNavigationController(pane.navigationController)
128- pane.shortcutHandler = NewShortcutHandler(view, pane.clipboardController, app.config, app)
129-
130- pane.zoomController.RegisterHandlers()
131- pane.shortcutHandler.RegisterShortcuts()
132-
133- // Register navigation handler for workspace focus events if workspace exists
134- if app.workspace != nil {
135- app.workspace.RegisterNavigationHandler(view)
136- }
137-
138- return pane, nil
139-}
140-
141-func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, error) {
142- pane, err := app.buildPane(view)
143- if err != nil {
144- return nil, err
145- }
146-
147- // Generate unique pane ID
148- pane.SetID(fmt.Sprintf("pane-%d-%p", time.Now().Unix(), view))
149- pane.initializeGUITracking()
150-
151- // GUI manager will be loaded on-demand when needed
152- logging.Debug(fmt.Sprintf("[webview] Created pane for webview: %d", view.ID()))
153-
154- app.attachPaneHandlers(pane)
155- return pane, nil
156-}
157-
158-func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
159- if pane == nil || pane.webView == nil {
160- return
161- }
162-
163- pane.webView.RegisterTitleChangedHandler(func(title string) {
164- if title == "" {
165- return
166- }
167- url := pane.webView.GetCurrentURL()
168- if url == "" {
169- return
170- }
171-
172- // Update database with new title
173- go func(url, title string) {
174- ctx := context.Background()
175- if err := app.browserService.UpdatePageTitle(ctx, url, title); err != nil {
176- logging.Warn(fmt.Sprintf("Warning: failed to update page title: %v", err))
177- }
178- }(url, title)
179-
180- // Update stacked pane title bar if this pane is in a stack
181- if app.workspace != nil {
182- app.workspace.UpdateTitleBar(pane.webView, title)
183- }
184- })
185-
186- pane.webView.RegisterScriptMessageHandler(func(payload string) {
187- if app.workspace != nil && shouldFocusForScriptMessage(payload) {
188- app.workspace.focusByView(pane.webView)
189- }
190- pane.messageHandler.Handle(payload)
191- })
192-
193- // Page load progress - drive tab-level progress bar
194- pane.webView.RegisterLoadStartedHandler(func() {
195- app.handleLoadProgress(pane.webView, 0.0, true)
196- })
197- pane.webView.RegisterLoadFinishedHandler(func() {
198- app.handleLoadProgress(pane.webView, 1.0, false)
199- })
200- pane.webView.RegisterLoadProgressHandler(func(progress float64) {
201- app.handleLoadProgress(pane.webView, progress, true)
202- })
203-
204- if pane.messageHandler != nil && pane.navigationController != nil {
205- pane.messageHandler.SetNavigationController(pane.navigationController)
206- }
207-
208- // Setup WebKit-native popup lifecycle using create/ready-to-show/close signals
209- if app.workspace != nil {
210- node := app.workspace.GetNodeForWebView(pane.webView)
211- if node != nil {
212- app.workspace.setupPopupHandling(pane.webView, node)
213- logging.Debug(fmt.Sprintf("[webview] Setup native popup handling for WebView ID: %d", pane.webView.ID()))
214- }
215- }
216-}
217-
218-// handleLoadProgress routes WebView load progress events to the tab manager for UI display.
219-func (app *BrowserApp) handleLoadProgress(view *webkit.WebView, progress float64, loading bool) {
220- if app == nil || view == nil || app.tabManager == nil {
221- return
222- }
223-
224- // Run asynchronously to avoid blocking the GTK/main thread during load-changed signals.
225- go app.tabManager.updateProgressForWebView(view, progress, loading)
226-}
227-
228-// shouldFocusForScriptMessage filters script messages and only allows focus handoff
229-// for explicit user-driven actions (navigation, history commands, popup lifecycle).
230-// Background bootstrap messages (palette sync, workspace bridge chatter, etc.) are
231-// ignored so they don't steal focus from the user's active pane.
232-func shouldFocusForScriptMessage(payload string) bool {
233- var msg messaging.Message
234- if err := json.Unmarshal([]byte(payload), &msg); err != nil {
235- return true // keep legacy behaviour if parsing fails
236- }
237-
238- switch msg.Type {
239- case "navigate", "query", "wails", "history_recent", "history_stats", "history_search", "history_delete", "close-popup":
240- return true
241- default:
242- return false
243- }
244-}
245-
246-// createWebView creates and configures the WebView
247-func (app *BrowserApp) createWebView() error {
248- logging.Info(fmt.Sprintf("Creating WebView (native backend expected: %v)", webkit.IsNativeAvailable()))
249-
250- cfg, err := app.buildWebkitConfig()
251- if err != nil {
252- return err
253- }
254-
255- // Note: Window creation is now handled automatically by webkit.NewWebView
256- view, err := webkit.NewWebView(cfg)
257-
258- if err != nil {
259- return err
260- }
261-
262- // Ensure root WebView participates in content blocking even if the service initializes later
263- app.RegisterWebViewForFiltering(view)
264-
265- pane, err := app.createPaneForView(view)
266- if err != nil {
267- return err
268- }
269-
270- app.webView = view
271- app.zoomController = pane.zoomController
272- app.navigationController = pane.navigationController
273- app.clipboardController = pane.clipboardController
274- app.messageHandler = pane.messageHandler
275- app.shortcutHandler = pane.shortcutHandler
276-
277- // Initialize tab manager (which manages workspaces)
278- window := view.Window()
279- if window != nil && app.tabManager == nil {
280- app.tabManager = NewTabManager(app, window)
281-
282- // Determine initial URL
283- initialURL := "about:blank"
284-
285- // Initialize tab system (creates root container with tab bar and first tab)
286- if err := app.tabManager.Initialize(initialURL); err != nil {
287- logging.Error(fmt.Sprintf("ERROR: failed to initialize tab manager: %v", err))
288- return err
289- }
290-
291- // Set tab manager's root container as window content
292- rootContainer := app.tabManager.GetRootContainer()
293- if rootContainer != nil {
294- window.SetChild(rootContainer)
295- logging.Info(fmt.Sprintf("[tabs] Tab system initialized and set as window content"))
296- } else {
297- logging.Error(fmt.Sprintf("ERROR: tab manager root container is nil"))
298- return fmt.Errorf("failed to get tab manager root container")
299- }
300- }
301-
302- // Initialize window-level global shortcuts AFTER tab manager is set up
303- if window != nil {
304- app.windowShortcutHandler = NewWindowShortcutHandler(window, app)
305- if app.windowShortcutHandler != nil {
306- logging.Info(fmt.Sprintf("Window-level global shortcuts initialized"))
307- } else {
308- logging.Warn(fmt.Sprintf("Warning: failed to initialize window-level shortcuts"))
309- }
310- }
311-
312- app.setupWebViewIntegration()
313-
314- // Initialize FaviconService after WebView is created
315- if err := app.initializeFaviconService(); err != nil {
316- logging.Warn(fmt.Sprintf("Warning: failed to initialize favicon service: %v", err))
317- // Continue without favicon service - not critical
318- }
319-
320- // Apply initial zoom and show window
321- app.zoomController.ApplyInitialZoom()
322-
323- logging.Info(fmt.Sprintf("Showing WebView window…"))
324- if err := view.Show(); err != nil {
325- logging.Warn(fmt.Sprintf("Warning: failed to show WebView: %v", err))
326- } else if !webkit.IsNativeAvailable() {
327- logging.Info(fmt.Sprintf("Notice: running without webkit_cgo tag — no native window will be displayed."))
328- }
329-
330- return nil
331-}
332-
333-// setupWebViewIntegration connects the WebView to browser services
334-func (app *BrowserApp) setupWebViewIntegration() {
335- app.browserService.AttachWebView(app.webView)
336-
337- // GUI bundle is loaded via WebKit user scripts in SetupUserContentManager
338- // Appearance config is also injected at document-start via UserContentManager
339-
340- // Use native window as title updater
341- if win := app.webView.Window(); win != nil {
342- app.browserService.SetWindowTitleUpdater(win)
343- }
344-}
345-
346-// initializeFaviconService creates and initializes the FaviconService
347-func (app *BrowserApp) initializeFaviconService() error {
348- // Get the FaviconDatabase from the WebView
349- faviconDB := app.webView.GetFaviconDatabase()
350- if faviconDB == nil {
351- return fmt.Errorf("favicon database not available")
352- }
353-
354- // Get the WebKit data directory
355- dataDir, err := config.GetDataDir()
356- if err != nil {
357- return fmt.Errorf("failed to get data directory: %w", err)
358- }
359- webkitData := filepath.Join(dataDir, "webkit")
360-
361- // Create FaviconService
362- faviconService, err := services.NewFaviconService(faviconDB, app.queries, webkitData)
363- if err != nil {
364- return fmt.Errorf("failed to create favicon service: %w", err)
365- }
366-
367- app.faviconService = faviconService
368- logging.Info(fmt.Sprintf("[favicon] FaviconService initialized successfully"))
369- return nil
370-}
371-
372-// setupContentBlocking initializes the content blocking system with proper timing
373-func (app *BrowserApp) setupContentBlocking() error {
374- logging.Info(fmt.Sprintf("Initializing content blocking system..."))
375-
376- // Temporary kill-switch for crash triage: set env DUMBER_DISABLE_CONTENT_BLOCKING=1
377- // to skip all blocking setup and run bare WebViews.
378- if os.Getenv("DUMBER_DISABLE_CONTENT_BLOCKING") == "1" {
379- logging.Warn("Content blocking disabled via DUMBER_DISABLE_CONTENT_BLOCKING=1 (test mode)")
380- return nil
381- }
382-
383- // Enable WebKit debug logging if requested
384- webkit.SetupWebKitDebugLogging()
385-
386- defer func() {
387- if r := recover(); r != nil {
388- logging.Error(fmt.Sprintf("Content blocking setup panic recovered: %v", r))
389- }
390- }()
391-
392- // Setup filter system with database whitelist support
393- filterManager, err := filtering.SetupFilterSystem(app.queries)
394- if err != nil {
395- logging.Warn(fmt.Sprintf("Warning: Failed to setup filter system: %v", err))
396- return nil // Don't fail browser startup, continue without filters
397- }
398-
399- // Store reference to filter manager
400- app.filterManager = filterManager
401-
402- // Get data directory for content blocking service
403- dataDir, err := config.GetDataDir()
404- if err != nil {
405- logging.Warn(fmt.Sprintf("Warning: Failed to get data dir for content blocking: %v", err))
406- return nil
407- }
408-
409- // Create content blocking service
410- cbService, err := filtering.NewContentBlockingService(dataDir+"/webkit", filterManager)
411- if err != nil {
412- logging.Warn(fmt.Sprintf("Warning: Failed to create content blocking service: %v", err))
413- return nil
414- }
415- app.contentBlockingService = cbService
416-
417- // Register any WebViews created before content blocking was ready
418- app.pendingFilteringMu.Lock()
419- for _, pending := range app.pendingFiltering {
420- if pending != nil && !pending.IsDestroyed() {
421- cbService.RegisterWebView(pending)
422- }
423- }
424- app.pendingFiltering = nil
425- app.pendingFilteringMu.Unlock()
426-
427- // Set up callback to apply network filters when they become ready
428- // This callback will be called from the async filter loading process
429- filterManager.SetFiltersReadyCallback(func() {
430- logging.Info(fmt.Sprintf("[filtering] Filters ready, distributing to all WebViews..."))
431-
432- // Get the WebKit JSON rules from filter manager
433- filterJSON, err := filterManager.GetNetworkFilters()
434- if err != nil {
435- logging.Error(fmt.Sprintf("[filtering] Failed to get network filters: %v", err))
436- return
437- }
438-
439- if len(filterJSON) == 0 {
440- logging.Info(fmt.Sprintf("[filtering] No network filters to apply"))
441- return
442- }
443-
444- logging.Debug(fmt.Sprintf("[filtering] Got %d bytes of WebKit JSON rules", len(filterJSON)))
445-
446- // Signal the service that filters are ready - it will apply to all registered WebViews
447- cbService.SetFiltersReady(filterJSON)
448- logging.Info(fmt.Sprintf("[filtering] Content blocking enabled for all WebViews"))
449- })
450-
451- // Create bypass registry for one-time URL bypasses
452- app.bypassRegistry = filtering.NewBypassRegistry()
453-
454- // Wire up message handler with filter manager and bypass registry
455- if app.messageHandler != nil {
456- app.messageHandler.SetFilterManager(filterManager)
457- app.messageHandler.SetBypassRegistry(app.bypassRegistry)
458- logging.Debug("[filtering] Message handler wired with filter manager and bypass registry")
459- }
460-
461- // Start async filter loading
462- // This allows filters to compile in the background while the browser starts
463- go func() {
464- if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
465- logging.Warn(fmt.Sprintf("Warning: failed to initialize filters asynchronously: %v", err))
466- }
467- }()
468-
469- logging.Info(fmt.Sprintf("Content blocking system initialization started"))
470- return nil
471-}
472-
473-// RegisterWebViewForFiltering registers a WebView with the content blocking service.
474-// This should be called for every new WebView created in the application.
475-func (app *BrowserApp) RegisterWebViewForFiltering(wv *webkit.WebView) {
476- if wv == nil || wv.IsDestroyed() {
477- return
478- }
479-
480- if app.contentBlockingService == nil {
481- app.pendingFilteringMu.Lock()
482- app.pendingFiltering = append(app.pendingFiltering, wv)
483- app.pendingFilteringMu.Unlock()
484- return
485- }
486-
487- app.contentBlockingService.RegisterWebView(wv)
488-}
489-
490-// UnregisterWebViewFromFiltering removes a WebView from the content blocking service.
491-func (app *BrowserApp) UnregisterWebViewFromFiltering(wv *webkit.WebView) {
492- if wv == nil {
493- return
494- }
495-
496- if app.contentBlockingService == nil {
497- // Drop from pending list to avoid registering destroyed WebViews later
498- app.pendingFilteringMu.Lock()
499- filtered := app.pendingFiltering[:0]
500- for _, pending := range app.pendingFiltering {
501- if pending != nil && pending != wv {
502- filtered = append(filtered, pending)
503- }
504- }
505- app.pendingFiltering = filtered
506- app.pendingFilteringMu.Unlock()
507- return
508- }
509-
510- if !wv.IsDestroyed() {
511- app.contentBlockingService.UnregisterWebView(wv)
512- }
513-}
D internal/app/browser/webview_pool.go +0 -346
@@ -1,346 +0,0 @@
1-// Package browser provides the main browser application components.
2-package browser
3-
4-import (
5- "fmt"
6- "sync"
7- "sync/atomic"
8- "time"
9-
10- "github.com/bnema/dumber/internal/logging"
11- "github.com/bnema/dumber/pkg/webkit"
12-)
13-
14-// PoolState represents the current state of the WebView pool
15-type PoolState int32
16-
17-const (
18- PoolStateIdle PoolState = iota
19- PoolStateWarming
20- PoolStateReady
21- PoolStateStopped
22-)
23-
24-// WebViewPool manages pre-created WebViews for instant tab/pane creation.
25-// Uses a "warm pool" strategy: pre-creates fresh WebViews rather than recycling,
26-// since WebKitGTK doesn't support resetting WebView state.
27-type WebViewPool struct {
28- mu sync.RWMutex
29-
30- // Pool of ready-to-use WebViews
31- available []*webkit.WebView
32-
33- // Configuration
34- targetSize int // Target number of WebViews to maintain
35- maxSize int // Maximum pool size
36- warmupDelay time.Duration // Delay before initial warmup
37-
38- // State tracking
39- state int32 // atomic: PoolState
40- created int64 // atomic: total WebViews created
41- acquired int64 // atomic: total WebViews acquired from pool
42- misses int64 // atomic: times pool was empty when requested
43- configGetter func() *webkit.Config
44-
45- // Shutdown
46- stopCh chan struct{}
47- wg sync.WaitGroup
48- stopOnce sync.Once // Ensures Stop() is idempotent
49-}
50-
51-// PoolStats contains statistics about pool usage
52-type PoolStats struct {
53- Available int
54- TargetSize int
55- MaxSize int
56- Created int64
57- Acquired int64
58- Misses int64
59- HitRate float64
60- State PoolState
61-}
62-
63-// NewWebViewPool creates a new WebView pool with the given configuration.
64-// configGetter is called each time a new WebView needs to be created.
65-func NewWebViewPool(targetSize, maxSize int, configGetter func() *webkit.Config) *WebViewPool {
66- if targetSize < 1 {
67- targetSize = 1
68- }
69- if maxSize < targetSize {
70- maxSize = targetSize
71- }
72-
73- return &WebViewPool{
74- available: make([]*webkit.WebView, 0, maxSize),
75- targetSize: targetSize,
76- maxSize: maxSize,
77- warmupDelay: 100 * time.Millisecond, // Wait for UI to render first
78- configGetter: configGetter,
79- stopCh: make(chan struct{}),
80- }
81-}
82-
83-// SetWarmupDelay sets the delay before initial pool warmup.
84-// Must be called before Start().
85-func (p *WebViewPool) SetWarmupDelay(delay time.Duration) {
86- p.warmupDelay = delay
87-}
88-
89-// Start begins the pool warmup process in the background.
90-// Should be called after the main window is visible.
91-func (p *WebViewPool) Start() {
92- if !atomic.CompareAndSwapInt32(&p.state, int32(PoolStateIdle), int32(PoolStateWarming)) {
93- logging.Warn("[pool] Pool already started or stopped")
94- return
95- }
96-
97- p.wg.Add(1)
98- go p.warmupLoop()
99-
100- logging.Info(fmt.Sprintf("[pool] WebView pool started (target=%d, max=%d, delay=%v)",
101- p.targetSize, p.maxSize, p.warmupDelay))
102-}
103-
104-// warmupLoop runs in background to maintain pool size
105-func (p *WebViewPool) warmupLoop() {
106- defer p.wg.Done()
107-
108- // Initial delay to let UI render first
109- select {
110- case <-time.After(p.warmupDelay):
111- case <-p.stopCh:
112- return
113- }
114-
115- // Initial warmup
116- p.warmup()
117- atomic.StoreInt32(&p.state, int32(PoolStateReady))
118-
119- // Periodic check to maintain pool size (every 500ms)
120- ticker := time.NewTicker(500 * time.Millisecond)
121- defer ticker.Stop()
122-
123- for {
124- select {
125- case <-ticker.C:
126- p.warmup()
127- case <-p.stopCh:
128- return
129- }
130- }
131-}
132-
133-// warmup creates WebViews to reach target size
134-func (p *WebViewPool) warmup() {
135- p.mu.RLock()
136- currentSize := len(p.available)
137- p.mu.RUnlock()
138-
139- needed := p.targetSize - currentSize
140- if needed <= 0 {
141- return
142- }
143-
144- logging.Debug(fmt.Sprintf("[pool] Warming up %d WebViews (current=%d, target=%d)",
145- needed, currentSize, p.targetSize))
146-
147- for i := 0; i < needed; i++ {
148- select {
149- case <-p.stopCh:
150- return
151- default:
152- }
153-
154- // Create WebView on GTK main thread
155- var wv *webkit.WebView
156- var err error
157-
158- done := make(chan struct{})
159- webkit.RunOnMainThread(func() {
160- defer close(done)
161- cfg := p.configGetter()
162- if cfg == nil {
163- logging.Error("[pool] Config getter returned nil")
164- return
165- }
166- // Pool WebViews don't create their own window
167- cfg.CreateWindow = false
168- wv, err = webkit.NewWebView(cfg)
169- })
170- <-done
171-
172- if err != nil {
173- logging.Error(fmt.Sprintf("[pool] Failed to create WebView: %v", err))
174- continue
175- }
176- if wv == nil {
177- logging.Error("[pool] Created WebView is nil")
178- continue
179- }
180-
181- p.mu.Lock()
182- if len(p.available) < p.maxSize {
183- p.available = append(p.available, wv)
184- atomic.AddInt64(&p.created, 1)
185- logging.Debug(fmt.Sprintf("[pool] Added WebView ID %d to pool (size=%d)",
186- wv.ID(), len(p.available)))
187- } else {
188- // Pool full, destroy the extra WebView
189- p.mu.Unlock()
190- webkit.RunOnMainThread(func() {
191- wv.Destroy()
192- })
193- logging.Debug("[pool] Pool at max capacity, discarding extra WebView")
194- continue
195- }
196- p.mu.Unlock()
197- }
198-}
199-
200-// Acquire gets a WebView from the pool, or creates one if pool is empty.
201-// The returned WebView is ready to use but needs to be added to a container.
202-// Returns nil and error if creation fails.
203-func (p *WebViewPool) Acquire() (*webkit.WebView, error) {
204- state := PoolState(atomic.LoadInt32(&p.state))
205- if state == PoolStateStopped {
206- return nil, fmt.Errorf("pool is stopped")
207- }
208-
209- p.mu.Lock()
210- if len(p.available) > 0 {
211- // Take from pool (LIFO for better cache locality)
212- wv := p.available[len(p.available)-1]
213- p.available = p.available[:len(p.available)-1]
214- remaining := len(p.available) // Capture before unlock to avoid race
215- p.mu.Unlock()
216-
217- atomic.AddInt64(&p.acquired, 1)
218- logging.Debug(fmt.Sprintf("[pool] Acquired WebView ID %d from pool (remaining=%d)",
219- wv.ID(), remaining))
220-
221- return wv, nil
222- }
223- p.mu.Unlock()
224-
225- // Pool empty, create on-demand (fallback)
226- atomic.AddInt64(&p.misses, 1)
227- logging.Debug("[pool] Pool empty, creating WebView on-demand")
228-
229- var wv *webkit.WebView
230- var err error
231-
232- done := make(chan struct{})
233- webkit.RunOnMainThread(func() {
234- defer close(done)
235- cfg := p.configGetter()
236- if cfg == nil {
237- err = fmt.Errorf("config getter returned nil")
238- return
239- }
240- cfg.CreateWindow = false
241- wv, err = webkit.NewWebView(cfg)
242- })
243- <-done
244-
245- if err != nil {
246- return nil, fmt.Errorf("failed to create WebView: %w", err)
247- }
248-
249- atomic.AddInt64(&p.created, 1)
250- return wv, nil
251-}
252-
253-// TryAcquire attempts to get a WebView from the pool without blocking.
254-// Returns nil if pool is empty (does not create on-demand).
255-func (p *WebViewPool) TryAcquire() *webkit.WebView {
256- state := PoolState(atomic.LoadInt32(&p.state))
257- if state == PoolStateStopped {
258- return nil
259- }
260-
261- p.mu.Lock()
262- defer p.mu.Unlock()
263-
264- if len(p.available) > 0 {
265- wv := p.available[len(p.available)-1]
266- p.available = p.available[:len(p.available)-1]
267- atomic.AddInt64(&p.acquired, 1)
268- logging.Debug(fmt.Sprintf("[pool] TryAcquire: got WebView ID %d (remaining=%d)",
269- wv.ID(), len(p.available)))
270- return wv
271- }
272-
273- atomic.AddInt64(&p.misses, 1)
274- return nil
275-}
276-
277-// Stats returns current pool statistics
278-func (p *WebViewPool) Stats() PoolStats {
279- p.mu.RLock()
280- available := len(p.available)
281- p.mu.RUnlock()
282-
283- acquired := atomic.LoadInt64(&p.acquired)
284- misses := atomic.LoadInt64(&p.misses)
285-
286- var hitRate float64
287- total := acquired + misses
288- if total > 0 {
289- hitRate = float64(acquired) / float64(total)
290- }
291-
292- return PoolStats{
293- Available: available,
294- TargetSize: p.targetSize,
295- MaxSize: p.maxSize,
296- Created: atomic.LoadInt64(&p.created),
297- Acquired: acquired,
298- Misses: misses,
299- HitRate: hitRate,
300- State: PoolState(atomic.LoadInt32(&p.state)),
301- }
302-}
303-
304-// Stop shuts down the pool and destroys all pooled WebViews.
305-// Safe to call multiple times.
306-func (p *WebViewPool) Stop() {
307- p.stopOnce.Do(func() {
308- // Transition to stopped state from any running state
309- for {
310- oldState := atomic.LoadInt32(&p.state)
311- if oldState == int32(PoolStateStopped) || oldState == int32(PoolStateIdle) {
312- return
313- }
314- if atomic.CompareAndSwapInt32(&p.state, oldState, int32(PoolStateStopped)) {
315- break
316- }
317- }
318-
319- close(p.stopCh)
320- p.wg.Wait()
321-
322- // Destroy all pooled WebViews
323- p.mu.Lock()
324- views := p.available
325- p.available = nil
326- p.mu.Unlock()
327-
328- if len(views) > 0 {
329- logging.Info(fmt.Sprintf("[pool] Destroying %d pooled WebViews", len(views)))
330- for _, wv := range views {
331- if wv != nil && !wv.IsDestroyed() {
332- wv.Destroy()
333- }
334- }
335- }
336-
337- stats := p.Stats()
338- logging.Info(fmt.Sprintf("[pool] Pool stopped (created=%d, acquired=%d, misses=%d, hitRate=%.2f%%)",
339- stats.Created, stats.Acquired, stats.Misses, stats.HitRate*100))
340- })
341-}
342-
343-// IsReady returns true if the pool has completed initial warmup
344-func (p *WebViewPool) IsReady() bool {
345- return PoolState(atomic.LoadInt32(&p.state)) == PoolStateReady
346-}
D internal/app/browser/window_shortcuts.go +0 -669
@@ -1,669 +0,0 @@
1-package browser
2-
3-import (
4- "fmt"
5- "sync"
6- "time"
7-
8- "github.com/bnema/dumber/internal/app/messaging"
9- "github.com/bnema/dumber/internal/logging"
10- "github.com/bnema/dumber/pkg/webkit"
11- "github.com/diamondburned/gotk4/pkg/gdk/v4"
12- "github.com/diamondburned/gotk4/pkg/gtk/v4"
13-)
14-
15-// WindowShortcutHandler manages global shortcuts at the window level
16-type WindowShortcutHandler struct {
17- window *webkit.Window
18- app *BrowserApp
19- shortcuts *webkit.WindowShortcuts
20- mu sync.Mutex
21-
22- // Debounce tracking
23- lastOmniboxToggle time.Time
24- lastFindToggle time.Time
25- lastCopyURL time.Time
26- lastDevTools time.Time
27- lastPrint time.Time
28-}
29-
30-// NewWindowShortcutHandler creates a new window-level shortcut handler
31-func NewWindowShortcutHandler(window *webkit.Window, app *BrowserApp) *WindowShortcutHandler {
32- h := &WindowShortcutHandler{
33- window: window,
34- app: app,
35- }
36-
37- if err := h.initialize(); err != nil {
38- logging.Error(fmt.Sprintf("[window-shortcuts] Failed to initialize: %v", err))
39- return nil
40- }
41-
42- return h
43-}
44-
45-func (h *WindowShortcutHandler) initialize() error {
46- // Create WindowShortcuts manager
47- h.shortcuts = webkit.NewWindowShortcuts(h.window)
48- if h.shortcuts == nil {
49- return ErrFailedToInitialize
50- }
51-
52- // Register hardware keycode-based shortcuts (for AZERTY/international keyboards)
53- if err := h.registerKeycodeShortcuts(); err != nil {
54- logging.Error(fmt.Sprintf("[window-shortcuts] Failed to register keycode shortcuts: %v", err))
55- }
56-
57- return h.registerGlobalShortcuts()
58-}
59-
60-func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
61- shortcuts := []struct {
62- key string
63- handler func()
64- desc string
65- }{
66- {"ctrl+l", h.handleOmniboxToggle, "Omnibox toggle"},
67- {"ctrl+f", h.handleFindToggle, "Find in page"},
68- {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
69- {"ctrl+shift+p", h.handlePrint, "Print page"},
70- {"F12", h.handleDevTools, "Developer tools"},
71- // Pane management: Ctrl+P handled by WebView keyboard bridge (not window shortcuts)
72- // Tab management
73- {"ctrl+t", h.handleTabMode, "Enter tab mode"},
74- // Global tab navigation (Ctrl+Tab) is handled via EventControllerKey in registerKeycodeShortcuts
75- // This prevents GTK's default focus cycling behavior
76- // NOTE: Tab mode action keys (n, c, x, l, h, r, Tab, Escape, Enter) are NOT registered
77- // as global shortcuts. They are handled by the WebView keyboard bridge which checks
78- // if tab mode is active before processing them. This prevents them from consuming
79- // keys when tab mode is not active (allowing normal typing in web pages).
80- // Direct tab switching is now handled via hardware keycodes (see registerKeycodeShortcuts)
81- // This supports all keyboard layouts (QWERTY, AZERTY, etc.)
82- // Page reload shortcuts
83- {"ctrl+r", h.handleReload, "Reload page"},
84- {"ctrl+shift+r", h.handleHardReload, "Hard reload (bypass cache)"},
85- {"F5", h.handleReload, "Reload page"},
86- {"ctrl+F5", h.handleHardReload, "Hard reload (bypass cache)"},
87- // Zoom shortcuts - global level for proper active pane targeting
88- {"ctrl+plus", h.handleZoomIn, "Zoom in"},
89- {"ctrl+equal", h.handleZoomIn, "Zoom in (=)"},
90- {"ctrl+minus", h.handleZoomOut, "Zoom out"},
91- {"ctrl+0", h.handleZoomReset, "Zoom reset"},
92- // Workspace navigation shortcuts - global level for proper active pane targeting
93- {"alt+ArrowLeft", func() { h.handleWorkspaceNavigation(DirectionLeft) }, "Navigate left pane"},
94- {"alt+ArrowRight", func() { h.handleWorkspaceNavigation(DirectionRight) }, "Navigate right pane"},
95- {"alt+ArrowUp", func() { h.handleWorkspaceNavigation(DirectionUp) }, "Navigate up pane"},
96- {"alt+ArrowDown", func() { h.handleWorkspaceNavigation(DirectionDown) }, "Navigate down pane"},
97- // Vim-style workspace navigation
98- {"alt+h", func() { h.handleWorkspaceNavigation(DirectionLeft) }, "Navigate left pane (vim)"},
99- {"alt+l", func() { h.handleWorkspaceNavigation(DirectionRight) }, "Navigate right pane (vim)"},
100- {"alt+k", func() { h.handleWorkspaceNavigation(DirectionUp) }, "Navigate up pane (vim)"},
101- {"alt+j", func() { h.handleWorkspaceNavigation(DirectionDown) }, "Navigate down pane (vim)"},
102- }
103-
104- for _, shortcut := range shortcuts {
105- h.shortcuts.RegisterShortcut(shortcut.key, shortcut.handler)
106- logging.Debug(fmt.Sprintf("[window-shortcuts] Registered global shortcut: %s (%s)",
107- shortcut.key, shortcut.desc))
108- }
109-
110- // NOTE: Ctrl+P is NOT registered here - it's handled by WebView's keyboard bridge
111- // (RegisterPaneModeHandler in workspace_manager.go) which calls app.workspace.EnterPaneMode()
112-
113- return nil
114-}
115-
116-// registerKeycodeShortcuts registers keyboard shortcuts based on hardware keycodes
117-// This allows Alt+number shortcuts to work on AZERTY and other keyboard layouts
118-func (h *WindowShortcutHandler) registerKeycodeShortcuts() error {
119- if h.window == nil {
120- return fmt.Errorf("window is nil")
121- }
122-
123- gtkWindow := h.window.AsWindow()
124- if gtkWindow == nil {
125- return fmt.Errorf("gtk window is nil")
126- }
127-
128- // Create event controller for keyboard events
129- keyController := gtk.NewEventControllerKey()
130- keyController.SetPropagationPhase(gtk.PhaseCapture)
131-
132- // GDK key constants
133- const (
134- gdkKeyTab = 0xff09
135- gdkKeyEscape = 0xff1b
136- gdkKeyReturn = 0xff0d
137- gdkKeyN = 0x006e
138- gdkKeyC = 0x0063
139- gdkKeyX = 0x0078
140- gdkKeyL = 0x006c
141- gdkKeyH = 0x0068
142- gdkKeyR = 0x0072
143- )
144-
145- // Map hardware keycodes to tab indices
146- // These keycodes are the same across all keyboard layouts
147- keycodeToTab := map[uint]int{
148- 10: 0, // 1/& key
149- 11: 1, // 2/é key
150- 12: 2, // 3/" key
151- 13: 3, // 4/' key
152- 14: 4, // 5/( key
153- 15: 5, // 6/- key
154- 16: 6, // 7/è key
155- 17: 7, // 8/_ key
156- 18: 8, // 9/ç key
157- 19: 9, // 0/à key
158- }
159-
160- keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
161- // IMPORTANT: Let WebView's keyboard bridge handle pane mode action keys first
162- // During pane mode, single-letter keys (h,j,k,l,x,s,v, etc.) must reach the WebView
163- // So we only intercept specific shortcuts here and let everything else through
164-
165- // Handle Ctrl+Tab for next tab
166- if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) {
167- logging.Debug(fmt.Sprintf("[window-shortcuts] Ctrl+Tab -> next tab"))
168- h.handleNextTab()
169- return true // Consume event to prevent focus cycling
170- }
171-
172- // Handle Ctrl+Shift+Tab for previous tab
173- if keyval == gdkKeyTab && state.Has(gdk.ControlMask) && state.Has(gdk.ShiftMask) {
174- logging.Debug(fmt.Sprintf("[window-shortcuts] Ctrl+Shift+Tab -> previous tab"))
175- h.handlePrevTab()
176- return true // Consume event to prevent focus cycling
177- }
178-
179- // Check for Alt+number (but not with Ctrl or Shift)
180- if state.Has(gdk.AltMask) && !state.Has(gdk.ControlMask) && !state.Has(gdk.ShiftMask) {
181- // Check if this is a number key
182- if tabIndex, ok := keycodeToTab[keycode]; ok {
183- logging.Debug(fmt.Sprintf("[window-shortcuts] Hardware keycode shortcut: Alt+keycode(%d) -> tab %d", keycode, tabIndex))
184- h.handleDirectTabSwitch(tabIndex)
185- return true // Handled
186- }
187- }
188-
189- // Handle tab mode action keys (only when tab mode is active)
190- // IMPORTANT: If rename is in progress, let ALL keys through to the Entry widget
191- // These keys need to be checked here so they can propagate when tab mode is not active
192- if h.isTabModeActive() {
193- // Check if rename is in progress - if so, don't intercept ANY keys
194- // This allows the Entry widget to receive all keyboard input
195- if h.isRenameInProgress() {
196- return false // Let all keys propagate to Entry widget
197- }
198-
199- handled := false
200- action := ""
201-
202- // Check for no modifiers (plain key press)
203- if !state.Has(gdk.ControlMask) && !state.Has(gdk.AltMask) {
204- switch keyval {
205- case gdkKeyN, gdkKeyC:
206- action = "new-tab"
207- handled = true
208- case gdkKeyX:
209- action = "close-tab"
210- handled = true
211- case gdkKeyL:
212- action = "next-tab"
213- handled = true
214- case gdkKeyH:
215- action = "previous-tab"
216- handled = true
217- case gdkKeyR:
218- action = "rename-tab"
219- handled = true
220- case gdkKeyTab:
221- action = "next-tab"
222- handled = true
223- case gdkKeyReturn:
224- action = "confirm"
225- handled = true
226- case gdkKeyEscape:
227- action = "cancel"
228- handled = true
229- }
230- }
231-
232- // Handle Shift+Tab for previous tab in tab mode
233- if keyval == gdkKeyTab && state.Has(gdk.ShiftMask) && !state.Has(gdk.ControlMask) {
234- action = "previous-tab"
235- handled = true
236- }
237-
238- if handled {
239- logging.Debug(fmt.Sprintf("[window-shortcuts] Tab mode action: %s", action))
240- h.handleTabModeAction(action)
241- return true // Consume event
242- }
243- }
244-
245- // Let all other keys propagate to WebView's keyboard bridge
246- // This includes Ctrl+P, Ctrl+T, and all pane mode action keys
247- return false
248- })
249-
250- gtkWindow.AddController(keyController)
251- logging.Debug(fmt.Sprintf("[window-shortcuts] Registered capture phase shortcuts: Ctrl+Tab, Alt+number"))
252-
253- return nil
254-}
255-
256-func (h *WindowShortcutHandler) handleOmniboxToggle() {
257- h.handleUIToggle(&h.lastOmniboxToggle, "omnibox", "omnibox-nav-toggle")
258-}
259-
260-func (h *WindowShortcutHandler) handleFindToggle() {
261- h.handleUIToggle(&h.lastFindToggle, "find", "omnibox-find-toggle")
262-}
263-
264-func (h *WindowShortcutHandler) handleCopyURL() {
265- h.mu.Lock()
266- defer h.mu.Unlock()
267-
268- if time.Since(h.lastCopyURL) < 100*time.Millisecond {
269- return
270- }
271- h.lastCopyURL = time.Now()
272-
273- if h.app.activePane == nil || h.app.activePane.webView == nil {
274- logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for copy URL"))
275- return
276- }
277-
278- logging.Debug(fmt.Sprintf("[window-shortcuts] Copy URL -> pane %p", h.app.activePane.webView))
279-
280- // Execute copy URL script directly on active pane
281- script := `
282- (async function() {
283- const toast = (message, type) => {
284- document.dispatchEvent(new CustomEvent('dumber:toast', {
285- detail: { message, duration: 2000, type }
286- }));
287- };
288-
289- try {
290- const url = window.location.href;
291- await navigator.clipboard.writeText(url);
292- console.log('🔗 Window shortcut copied URL:', url);
293- toast('URL copied to clipboard!', 'success');
294- } catch (error) {
295- console.error('❌ Failed to copy URL:', error);
296- toast('Failed to copy URL', 'error');
297- }
298- })();
299- `
300-
301- if err := h.app.activePane.webView.InjectScript(script); err != nil {
302- logging.Error(fmt.Sprintf("[window-shortcuts] Failed to inject copy URL script: %v", err))
303- }
304-}
305-
306-func (h *WindowShortcutHandler) handleDevTools() {
307- h.mu.Lock()
308- defer h.mu.Unlock()
309-
310- if time.Since(h.lastDevTools) < 200*time.Millisecond {
311- return
312- }
313- h.lastDevTools = time.Now()
314-
315- if h.app.activePane == nil || h.app.activePane.webView == nil {
316- logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for devtools"))
317- return
318- }
319-
320- logging.Debug(fmt.Sprintf("[window-shortcuts] DevTools -> pane %p", h.app.activePane.webView))
321-
322- if err := h.app.activePane.webView.ShowDevTools(); err != nil {
323- logging.Error(fmt.Sprintf("[window-shortcuts] Failed to show devtools: %v", err))
324- }
325-}
326-
327-func (h *WindowShortcutHandler) handlePrint() {
328- h.mu.Lock()
329- defer h.mu.Unlock()
330-
331- if time.Since(h.lastPrint) < 200*time.Millisecond {
332- return
333- }
334- h.lastPrint = time.Now()
335-
336- if h.app.activePane == nil || h.app.activePane.webView == nil {
337- logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for print"))
338- return
339- }
340-
341- logging.Debug(fmt.Sprintf("[window-shortcuts] Print -> pane %p", h.app.activePane.webView))
342-
343- if err := h.app.activePane.webView.ShowPrintDialog(); err != nil {
344- logging.Error(fmt.Sprintf("[window-shortcuts] Failed to show print dialog: %v", err))
345- }
346-}
347-
348-func (h *WindowShortcutHandler) handleReload() {
349- h.mu.Lock()
350- defer h.mu.Unlock()
351-
352- if h.app.activePane == nil || h.app.activePane.webView == nil {
353- logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for reload"))
354- return
355- }
356-
357- logging.Debug(fmt.Sprintf("[window-shortcuts] Reload page -> pane %p", h.app.activePane.webView))
358-
359- if err := h.app.activePane.webView.Reload(); err != nil {
360- logging.Error(fmt.Sprintf("[window-shortcuts] Failed to reload page: %v", err))
361- }
362-}
363-
364-func (h *WindowShortcutHandler) handleHardReload() {
365- h.mu.Lock()
366- defer h.mu.Unlock()
367-
368- if h.app.activePane == nil || h.app.activePane.webView == nil {
369- logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for hard reload"))
370- return
371- }
372-
373- logging.Debug(fmt.Sprintf("[window-shortcuts] Hard reload (bypass cache) -> pane %p", h.app.activePane.webView))
374-
375- if err := h.app.activePane.webView.ReloadBypassCache(); err != nil {
376- logging.Error(fmt.Sprintf("[window-shortcuts] Failed to hard reload page: %v", err))
377- }
378-}
379-
380-// handleZoomIn increases zoom level by 10%
381-func (h *WindowShortcutHandler) handleZoomIn() {
382- h.handleZoom("in", 1.1)
383-}
384-
385-// handleZoomOut decreases zoom level by 10%
386-func (h *WindowShortcutHandler) handleZoomOut() {
387- h.handleZoom("out", 1.0/1.1)
388-}
389-
390-// handleZoomReset resets zoom to 100%
391-func (h *WindowShortcutHandler) handleZoomReset() {
392- h.handleZoom("reset", 1.0)
393-}
394-
395-// handleZoom applies zoom changes to the active pane
396-func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
397- h.mu.Lock()
398- defer h.mu.Unlock()
399-
400- if h.app.activePane == nil || h.app.activePane.webView == nil {
401- logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for zoom %s", action))
402- return
403- }
404-
405- activeWebView := h.app.activePane.webView
406- logging.Debug(fmt.Sprintf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView))
407-
408- // Get current zoom level
409- currentZoom := activeWebView.GetZoom()
410-
411- // Calculate new zoom level
412- var newZoom float64
413- if action == "reset" {
414- newZoom = 1.0
415- } else {
416- newZoom = currentZoom * multiplier
417- // Apply zoom limits
418- if newZoom < 0.25 {
419- newZoom = 0.25
420- }
421- if newZoom > 5.0 {
422- newZoom = 5.0
423- }
424- }
425-
426- logging.Debug(fmt.Sprintf("[window-shortcuts] Zoom %s: %.2f -> %.2f", action, currentZoom, newZoom))
427-
428- // Apply zoom to active pane
429- if err := activeWebView.SetZoom(newZoom); err != nil {
430- logging.Error(fmt.Sprintf("[window-shortcuts] Failed to set zoom: %v", err))
431- return
432- }
433-
434- // Ensure GUI/toast component is loaded before showing toast
435- h.ensureGUIInActivePane("toast")
436-
437- // Show zoom toast notification
438- toastScript := fmt.Sprintf(`
439- (function() {
440- try {
441- if (typeof window.__dumber_showZoomToast === 'function') {
442- window.__dumber_showZoomToast(%f);
443- }
444- } catch (e) {
445- console.error('[window-shortcuts] Failed to show zoom toast:', e);
446- }
447- })();
448- `, newZoom)
449-
450- if err := activeWebView.InjectScript(toastScript); err != nil {
451- logging.Error(fmt.Sprintf("[window-shortcuts] Failed to show zoom toast: %v", err))
452- }
453-}
454-
455-func (h *WindowShortcutHandler) ensureGUIInActivePane(component string) {
456- if h.app.activePane == nil {
457- return
458- }
459-
460- pane := h.app.activePane
461- if !pane.HasGUI() {
462- logging.Debug(fmt.Sprintf("[window-shortcuts] Injecting GUI into pane %s for %s", pane.ID(), component))
463- if h.app.workspace != nil {
464- h.app.workspace.ensureGUIInPane(pane)
465- }
466- }
467-
468- // Ensure specific component is available
469- if !pane.HasGUIComponent(component) {
470- logging.Debug(fmt.Sprintf("[window-shortcuts] Ensuring %s component in pane %s", component, pane.ID()))
471- pane.SetGUIComponent(component, true)
472- }
473-}
474-
475-func (h *WindowShortcutHandler) getWebViewId(pane *BrowserPane) string {
476- if pane == nil || pane.webView == nil {
477- return "unknown"
478- }
479- return pane.webView.IDString()
480-}
481-
482-func (h *WindowShortcutHandler) handleUIToggle(lastToggle *time.Time, featureName, action string) {
483- h.mu.Lock()
484- defer h.mu.Unlock()
485-
486- if time.Since(*lastToggle) < 50*time.Millisecond {
487- logging.Debug(fmt.Sprintf("[window-shortcuts] %s toggle debounced", featureName))
488- return
489- }
490- *lastToggle = time.Now()
491-
492- if h.app.activePane == nil || h.app.activePane.webView == nil {
493- logging.Debug(fmt.Sprintf("[window-shortcuts] No active pane for %s", featureName))
494- return
495- }
496-
497- logging.Debug(fmt.Sprintf("[window-shortcuts] %s toggle -> pane %p", featureName, h.app.activePane.webView))
498-
499- h.ensureGUIInActivePane("omnibox")
500-
501- if err := h.app.activePane.webView.DispatchCustomEvent("dumber:ui:shortcut", map[string]any{
502- "action": action,
503- "webviewId": h.getWebViewId(h.app.activePane),
504- "timestamp": time.Now().UnixMilli(),
505- "source": "window-global",
506- }); err != nil {
507- logging.Error(fmt.Sprintf("[window-shortcuts] Failed to dispatch %s toggle: %v", featureName, err))
508- }
509-}
510-
511-func (h *WindowShortcutHandler) handleWorkspaceNavigation(direction string) {
512- if h.app == nil || h.app.workspace == nil {
513- logging.Debug(fmt.Sprintf("[window-shortcuts] No workspace for navigation"))
514- return
515- }
516-
517- logging.Debug(fmt.Sprintf("[window-shortcuts] Workspace navigation: %s", direction))
518-
519- if h.app.workspace.FocusNeighbor(direction) {
520- logging.Debug(fmt.Sprintf("[window-shortcuts] Workspace navigation %s successful", direction))
521- } else {
522- logging.Debug(fmt.Sprintf("[window-shortcuts] Workspace navigation %s failed", direction))
523- }
524-}
525-
526-func (h *WindowShortcutHandler) handleClosePane() {
527- if h.app == nil || h.app.workspace == nil {
528- logging.Debug(fmt.Sprintf("[window-shortcuts] No workspace for close pane"))
529- return
530- }
531-
532- // Find the currently active WebView
533- var activeWebView *webkit.WebView
534- logging.Debug(fmt.Sprintf("[window-shortcuts] Searching for active WebView among %d WebViews", len(h.app.workspace.viewToNode)))
535- for webView := range h.app.workspace.viewToNode {
536- if webView != nil {
537- isActive := webView.IsActive()
538- logging.Debug(fmt.Sprintf("[window-shortcuts] WebView %d: IsActive=%t", webView.ID(), isActive))
539- if isActive {
540- activeWebView = webView
541- logging.Debug(fmt.Sprintf("[window-shortcuts] Found active WebView: %d", webView.ID()))
542- break
543- }
544- }
545- }
546-
547- if activeWebView == nil {
548- logging.Debug(fmt.Sprintf("[window-shortcuts] No active WebView found, using workspace closeCurrentPane"))
549- h.app.workspace.closeCurrentPane()
550- return
551- }
552-
553- // Check if the active WebView is a popup
554- node := h.app.workspace.viewToNode[activeWebView]
555- if node != nil && node.isPopup {
556- logging.Debug(fmt.Sprintf("[window-shortcuts] Closing popup via OnWorkspaceMessage"))
557- // Use the proper close-popup message for popups
558- msg := messaging.Message{
559- Event: "close-popup",
560- WebViewID: activeWebView.IDString(),
561- Reason: "user-ctrl-w",
562- }
563- h.app.workspace.OnWorkspaceMessage(activeWebView, msg)
564- } else {
565- logging.Debug(fmt.Sprintf("[window-shortcuts] Closing regular pane via workspace closeCurrentPane"))
566- // Use regular close for non-popup panes
567- h.app.workspace.closeCurrentPane()
568- }
569-}
570-
571-// handlePaneMode enters pane mode for modal pane management.
572-func (h *WindowShortcutHandler) handlePaneMode() {
573- if h.app == nil || h.app.workspace == nil {
574- logging.Debug(fmt.Sprintf("[window-shortcuts] Cannot enter pane mode: workspace not available"))
575- return
576- }
577-
578- logging.Debug(fmt.Sprintf("[window-shortcuts] Entering pane mode"))
579- h.app.workspace.EnterPaneMode()
580-}
581-
582-// handleTabMode enters tab mode for modal tab management.
583-func (h *WindowShortcutHandler) handleTabMode() {
584- if h.app == nil || h.app.tabManager == nil {
585- logging.Debug(fmt.Sprintf("[window-shortcuts] Cannot enter tab mode: tab manager not available"))
586- return
587- }
588-
589- logging.Debug(fmt.Sprintf("[window-shortcuts] Entering tab mode"))
590- h.app.tabManager.EnterTabMode()
591-}
592-
593-// handleNextTab switches to the next tab.
594-func (h *WindowShortcutHandler) handleNextTab() {
595- if h.app == nil || h.app.tabManager == nil {
596- logging.Debug(fmt.Sprintf("[window-shortcuts] Cannot switch tab: tab manager not available"))
597- return
598- }
599-
600- logging.Debug(fmt.Sprintf("[window-shortcuts] Switching to next tab"))
601- if err := h.app.tabManager.NextTab(); err != nil {
602- logging.Error(fmt.Sprintf("[window-shortcuts] Failed to switch to next tab: %v", err))
603- }
604-}
605-
606-// handlePrevTab switches to the previous tab.
607-func (h *WindowShortcutHandler) handlePrevTab() {
608- if h.app == nil || h.app.tabManager == nil {
609- logging.Debug(fmt.Sprintf("[window-shortcuts] Cannot switch tab: tab manager not available"))
610- return
611- }
612-
613- logging.Debug(fmt.Sprintf("[window-shortcuts] Switching to previous tab"))
614- if err := h.app.tabManager.PreviousTab(); err != nil {
615- logging.Error(fmt.Sprintf("[window-shortcuts] Failed to switch to previous tab: %v", err))
616- }
617-}
618-
619-// handleDirectTabSwitch switches to a specific tab by index (0-based).
620-func (h *WindowShortcutHandler) handleDirectTabSwitch(index int) {
621- if h.app == nil || h.app.tabManager == nil {
622- logging.Debug(fmt.Sprintf("[window-shortcuts] Cannot switch tab: tab manager not available"))
623- return
624- }
625-
626- logging.Debug(fmt.Sprintf("[window-shortcuts] Direct tab switch to index %d", index))
627- if err := h.app.tabManager.SwitchToTab(index); err != nil {
628- logging.Error(fmt.Sprintf("[window-shortcuts] Failed to switch to tab %d: %v", index, err))
629- }
630-}
631-
632-// isTabModeActive checks if tab mode is currently active
633-func (h *WindowShortcutHandler) isTabModeActive() bool {
634- if h.app == nil || h.app.tabManager == nil {
635- return false
636- }
637- return h.app.tabManager.IsTabModeActive()
638-}
639-
640-// isRenameInProgress checks if a tab rename is currently in progress
641-func (h *WindowShortcutHandler) isRenameInProgress() bool {
642- if h.app == nil || h.app.tabManager == nil {
643- return false
644- }
645- return h.app.tabManager.IsRenameInProgress()
646-}
647-
648-// handleTabModeAction handles tab mode action keys (n, x, l, h, etc.)
649-// Only processes actions when tab mode is active
650-func (h *WindowShortcutHandler) handleTabModeAction(action string) {
651- if h.app == nil || h.app.tabManager == nil {
652- return
653- }
654-
655- // Tab mode is active, handle the action
656- logging.Debug(fmt.Sprintf("[window-shortcuts] Tab mode action: %s", action))
657- h.app.tabManager.HandleTabAction(action)
658-}
659-
660-// Cleanup releases resources
661-func (h *WindowShortcutHandler) Cleanup() {
662- // WindowShortcuts doesn't require explicit cleanup
663- h.shortcuts = nil
664-}
665-
666-// Error definitions
667-var (
668- ErrFailedToInitialize = fmt.Errorf("failed to initialize window shortcuts")
669-)
D internal/app/browser/workspace_css.go +0 -407
@@ -1,407 +0,0 @@
1-// workspace_css.go - CSS generation and styling for workspace panes
2-package browser
3-
4-import (
5- "fmt"
6- "strings"
7-
8- "github.com/bnema/dumber/internal/config"
9- "github.com/bnema/dumber/internal/logging"
10- "github.com/bnema/dumber/pkg/webkit"
11-)
12-
13-// Helper functions for stacked pane CSS colors
14-
15-// getStackTitleBg returns the background color for stack titles based on theme
16-func getStackTitleBg(isDark bool) string {
17- if isDark {
18- return "#404040"
19- }
20- return "#f0f0f0"
21-}
22-
23-// getStackTitleHoverBg returns the hover background color for stack titles based on theme
24-func getStackTitleHoverBg(isDark bool) string {
25- if isDark {
26- return "#505050"
27- }
28- return "#e0e0e0"
29-}
30-
31-// getStackTitleTextColor returns the text color for stack titles based on theme
32-func getStackTitleTextColor(isDark bool) string {
33- if isDark {
34- return "#ffffff"
35- }
36- return "#333333"
37-}
38-
39-// getStackTitleBorderColor returns a subtle border color slightly darker than the title background
40-func getStackTitleBorderColor(isDark bool) string {
41- if isDark {
42- return "#353535" // Slightly darker than #404040
43- }
44- return "#e5e5e5" // Slightly darker than #f0f0f0
45-}
46-
47-// getActiveTabBg returns a distinct background color for the active tab
48-func getActiveTabBg(isDark bool) string {
49- if isDark {
50- return "#707070" // Significantly lighter gray than inactive (#404040) - similar to stacked panes
51- }
52- return "#c0c0c0" // Significantly darker gray than inactive (#f0f0f0) - similar to stacked panes
53-}
54-
55-// getEntryBg returns the background color for text entry widgets
56-func getEntryBg(isDark bool) string {
57- if isDark {
58- return "#2a2a2a" // Darker background for dark theme
59- }
60- return "#e0e0e0" // Darker gray for light theme
61-}
62-
63-// getEntryFocusBg returns the background color for focused entry widgets
64-func getEntryFocusBg(isDark bool) string {
65- if isDark {
66- return "#323232" // Slightly lighter when focused
67- }
68- return "#f5f5f5" // Very light gray when focused
69-}
70-
71-// getEntryBorderColor returns the border color for entry widgets
72-func getEntryBorderColor(isDark bool) string {
73- if isDark {
74- return "#555555" // Subtle border for dark theme
75- }
76- return "#cccccc" // Subtle border for light theme
77-}
78-
79-// getActivePaneBorderColor returns the border color for active panes based on config and theme
80-func getActivePaneBorderColor(styling config.WorkspaceStylingConfig, isDark bool) string {
81- // Use configured border color if set
82- if styling.BorderColor != "" {
83- // Check if it's a GTK theme variable (starts with @)
84- if strings.HasPrefix(styling.BorderColor, "@") {
85- return styling.BorderColor
86- }
87- // Return the configured color as-is (could be hex, rgb, etc.)
88- return styling.BorderColor
89- }
90-
91- // Fallback to hardcoded colors based on theme
92- if isDark {
93- return "#4A90E2" // Deeper blue pastel for dark theme
94- }
95- return "#87CEEB" // Sky blue pastel for light theme
96-}
97-
98-// generateWorkspaceCSS generates the complete CSS for workspace panes and stacked panes
99-func (wm *WorkspaceManager) generateWorkspaceCSS() string {
100- cfg := config.Get()
101- styling := cfg.Workspace.Styling
102-
103- // Get appropriate colors based on GTK theme preference
104- var windowBackgroundColor string
105- isDark := webkit.PrefersDarkTheme()
106- if isDark {
107- windowBackgroundColor = "#2b2b2b" // Dark window background
108- } else {
109- windowBackgroundColor = "#ffffff" // Light window background
110- }
111-
112- activeBorderColor := getActivePaneBorderColor(styling, isDark)
113-
114- // Pane mode border color - use configured value or fallback to blue
115- paneModeColor := styling.PaneModeBorderColor
116- if paneModeColor == "" {
117- paneModeColor = "#4A90E2" // Fallback to blue if not configured
118- }
119-
120- // Tab mode border color - use configured value or fallback to orange
121- tabModeColor := styling.TabModeBorderColor
122- if tabModeColor == "" {
123- tabModeColor = "#FFA500" // Fallback to orange if not configured
124- }
125-
126- // Stacked title border - subtle separator between titles
127- stackedTitleBorder := fmt.Sprintf("1px solid %s", getStackTitleBorderColor(isDark))
128-
129- // UI Scale calculations for title bar elements (base values at 1.0 scale)
130- uiScale := styling.UIScale
131- if uiScale <= 0 {
132- uiScale = 1.0 // Fallback to default if invalid
133- }
134- titleFontSize := int(12 * uiScale) // Base: 12px
135- titlePaddingVertical := int(4 * uiScale) // Base: 4px
136- titlePaddingHorizontal := int(8 * uiScale) // Base: 8px
137- titleMinHeight := int(24 * uiScale) // Base: 24px
138- faviconMargin := int(4 * uiScale) // Base: 4px
139-
140- css := fmt.Sprintf(`window {
141- background-color: %s;
142- padding: 0;
143- margin: 0;
144- }
145-
146- /* Pane mode border overlay - floats over content without layout shift */
147- .pane-mode-border {
148- border: %dpx solid %s;
149- border-radius: 0;
150- background-color: transparent;
151- }
152-
153- /* Tab mode border overlay - floats over content without layout shift */
154- .tab-mode-border {
155- border: %dpx solid %s;
156- border-radius: 0;
157- background-color: transparent;
158- }
159-
160- /* Pane border overlay - active pane indicator without layout shift */
161- .pane-border-overlay {
162- border: %dpx solid %s;
163- border-radius: 0;
164- background-color: transparent;
165- transition-property: opacity;
166- transition-duration: %dms;
167- transition-timing-function: ease-in-out;
168- }
169-
170- /* Workspace root container */
171- paned, box {
172- background-color: %s;
173- }
174-
175- /* Base pane styling - no borders (using overlays instead) */
176- .workspace-pane, .stacked-pane-container {
177- border: none;
178- margin: 0;
179- }
180-
181- /* Stacked panes styling */
182- .stacked-pane-container {
183- background-color: %s;
184- }
185-
186- .stacked-pane-title {
187- background-color: %s;
188- border-bottom: %s;
189- padding: %dpx %dpx;
190- min-height: %dpx;
191- transition: background-color %dms ease-in-out;
192- }
193-
194- .stacked-pane-title:hover {
195- background-color: %s;
196- }
197-
198- .stacked-pane-title-text {
199- font-size: %dpx;
200- color: %s;
201- font-weight: 500;
202- }
203-
204- .stacked-pane-favicon {
205- margin-right: %dpx;
206- }
207-
208- .stacked-pane-active {
209- /* Active pane is fully visible */
210- }
211-
212- .stacked-pane-collapsed {
213- /* Collapsed panes are hidden - handled in code via widget visibility */
214- }
215-
216- /* Tab bar styling */
217- .tab-bar {
218- background-color: %s;
219- border-top: 2px solid %s;
220- padding: 0;
221- min-height: %dpx;
222- }
223-
224- /* Tab progress bar */
225- progressbar.tab-progress-bar {
226- min-height: 4px;
227- padding: 0;
228- margin: 0;
229- border: none;
230- transition: opacity 180ms ease-in-out;
231- }
232-
233- progressbar.tab-progress-bar trough {
234- min-height: 4px;
235- background: transparent;
236- border: none;
237- padding: 0;
238- }
239-
240- progressbar.tab-progress-bar progress {
241- min-height: 4px;
242- border-radius: 0;
243- background-image: linear-gradient(90deg, %s 0%%, %s 50%%, %s 100%%);
244- background-size: 240%% 100%%;
245- background-position: 0%% 0%%;
246- transition: width 180ms ease-in-out, background-position 420ms linear;
247- animation: tab-progress-stripe 1.3s linear infinite;
248- }
249-
250- /* Tab button styling - matches stacked pane titles */
251- button.tab-button {
252- background-color: %s;
253- background-image: none;
254- border: none;
255- border-right: 1px solid %s;
256- border-radius: 0;
257- padding: %dpx %dpx;
258- transition: background-color %dms ease-in-out;
259- }
260-
261- button.tab-button:hover {
262- background-color: %s;
263- background-image: none;
264- }
265-
266- button.tab-button.tab-button-active {
267- background-color: %s;
268- background-image: none;
269- border: none;
270- border-right: 1px solid %s;
271- border-radius: 0;
272- font-weight: 600;
273- }
274-
275- /* Tab title text */
276- .tab-title {
277- font-size: %dpx;
278- color: %s;
279- font-weight: 500;
280- }
281-
282- /* Tab rename entry */
283- entry.tab-rename-entry {
284- background-color: %s;
285- color: %s;
286- font-size: %dpx;
287- padding: 2px %dpx;
288- border: 1px solid %s;
289- border-radius: 3px;
290- box-shadow: inset 1px 1px 3px rgba(0, 0, 0, 0.4);
291- min-width: 80px;
292- min-height: 0;
293- }
294-
295- entry.tab-rename-entry:focus {
296- background-color: %s;
297- border-color: %s;
298- box-shadow: inset 1px 1px 4px rgba(0, 0, 0, 0.5);
299- outline: none;
300- }
301-
302- /* Tab content area */
303- .tab-content-area {
304- background-color: %s;
305- }
306-
307- /* Tab workspace containers */
308- .tab-workspace-container {
309- background-color: %s;
310- }
311-
312- /* Pulse animation for tab mode */
313- @keyframes pulse-border {
314- 0%%, 100%% { opacity: 1.0; }
315- 50%% { opacity: 0.6; }
316- }
317-
318- @keyframes tab-progress-stripe {
319- 0%% { background-position: 0%% 0%%; }
320- 100%% { background-position: -100%% 0%%; }
321- }`,
322- windowBackgroundColor, // window background
323- styling.PaneModeBorderWidth, // .pane-mode-border border-width
324- paneModeColor, // .pane-mode-border border-color
325- styling.TabModeBorderWidth, // .tab-mode-border border-width
326- tabModeColor, // .tab-mode-border border-color
327- styling.BorderWidth, // .pane-border-overlay border-width
328- activeBorderColor, // .pane-border-overlay border-color
329- styling.TransitionDuration, // .pane-border-overlay transition-duration
330- windowBackgroundColor, // paned, box background
331- windowBackgroundColor, // stacked-pane-container background
332- getStackTitleBg(isDark), // stacked-pane-title background
333- stackedTitleBorder, // stacked-pane-title border-bottom
334- titlePaddingVertical, // stacked-pane-title padding vertical
335- titlePaddingHorizontal, // stacked-pane-title padding horizontal
336- titleMinHeight, // stacked-pane-title min-height
337- styling.TransitionDuration, // stacked-pane-title transition
338- getStackTitleHoverBg(isDark), // stacked-pane-title:hover background
339- titleFontSize, // stacked-pane-title-text font-size
340- getStackTitleTextColor(isDark), // stacked-pane-title-text color
341- faviconMargin, // stacked-pane-favicon margin-right
342- // Tab bar styles
343- windowBackgroundColor, // tab-bar background
344- getStackTitleBorderColor(isDark), // tab-bar border-top
345- int(32*uiScale), // tab-bar min-height
346- "#606060", // tab progress gradient start (neutral gray)
347- "#747474", // tab progress gradient middle (neutral gray)
348- "#8a8a8a", // tab progress gradient end (neutral gray)
349- getStackTitleBg(isDark), // tab-button background (same as stacked title)
350- getStackTitleBorderColor(isDark), // tab-button border-right
351- titlePaddingVertical, // tab-button padding vertical
352- titlePaddingHorizontal, // tab-button padding horizontal
353- styling.TransitionDuration, // tab-button transition
354- getStackTitleHoverBg(isDark), // tab-button:hover background
355- getActiveTabBg(isDark), // tab-button-active background (much more visible)
356- getStackTitleBorderColor(isDark), // tab-button-active border-right
357- titleFontSize, // tab-title font-size
358- getStackTitleTextColor(isDark), // tab-title color
359- // Tab rename entry styles
360- getEntryBg(isDark), // entry.tab-rename-entry background
361- getStackTitleTextColor(isDark), // entry.tab-rename-entry color (text)
362- titleFontSize, // entry.tab-rename-entry font-size
363- titlePaddingHorizontal, // entry.tab-rename-entry padding horizontal (vertical is hardcoded 2px)
364- getEntryBorderColor(isDark), // entry.tab-rename-entry border-color
365- getEntryFocusBg(isDark), // entry.tab-rename-entry:focus background
366- activeBorderColor, // entry.tab-rename-entry:focus border-color (use active pane color)
367- windowBackgroundColor, // tab-content-area background
368- windowBackgroundColor, // tab-workspace-container background
369- )
370-
371- return css
372-}
373-
374-// Global CSS provider tracking to prevent duplication
375-var (
376- globalCSSInitialized bool
377- globalCSSContent string
378-)
379-
380-// ensureWorkspaceStyles ensures that CSS styles are applied for workspace panes
381-// Improved to prevent CSS provider duplication by using global CSS tracking
382-func (wm *WorkspaceManager) ensureWorkspaceStyles() {
383- if wm == nil {
384- return
385- }
386-
387- // Generate CSS content
388- workspaceCSS := wm.generateWorkspaceCSS()
389-
390- // Only apply CSS if it hasn't been applied globally or if content changed
391- if !globalCSSInitialized || globalCSSContent != workspaceCSS {
392- if err := webkit.AddCSSProvider(workspaceCSS); err != nil {
393- logging.Error(fmt.Sprintf("[workspace] WARNING: Failed to add CSS provider: %v", err))
394- }
395- globalCSSInitialized = true
396- globalCSSContent = workspaceCSS
397- logging.Info(fmt.Sprintf("[workspace] Applied workspace CSS styles (%d bytes)", len(workspaceCSS)))
398-
399- previewLen := 200
400- if len(workspaceCSS) < previewLen {
401- previewLen = len(workspaceCSS)
402- }
403- logging.Info(fmt.Sprintf("[workspace] CSS preview: %s...", workspaceCSS[:previewLen]))
404- }
405-
406- wm.cssInitialized = true
407-}
D internal/app/browser/workspace_debug.go +0 -40
@@ -1,40 +0,0 @@
1-package browser
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/dumber/internal/logging"
7-)
8-
9-func (wm *WorkspaceManager) paneCloseLogf(format string, args ...interface{}) {
10- if wm == nil || !wm.debugPaneClose {
11- return
12- }
13- logging.Info(fmt.Sprintf("[pane-close] "+format, args...))
14-}
15-
16-func (wm *WorkspaceManager) dumpTreeState(label string) {
17- if wm == nil || wm.diagnostics == nil {
18- return
19- }
20- wm.diagnostics.Capture(label, wm.root)
21-}
22-
23-func (wm *WorkspaceManager) DiagnosticsSnapshots() []TreeSnapshot {
24- if wm == nil || wm.diagnostics == nil {
25- return nil
26- }
27- return wm.diagnostics.Snapshots()
28-}
29-
30-func (wm *WorkspaceManager) SetPaneCloseDebug(enabled bool) {
31- if wm == nil {
32- return
33- }
34- wm.debugPaneClose = enabled
35- if wm.diagnostics == nil {
36- wm.diagnostics = NewWorkspaceDiagnostics(enabled)
37- return
38- }
39- wm.diagnostics.SetEnabled(enabled)
40-}
D internal/app/browser/workspace_diagnostics.go +0 -156
@@ -1,156 +0,0 @@
1-package browser
2-
3-import (
4- "fmt"
5- "strings"
6- "sync"
7-
8- "github.com/bnema/dumber/internal/logging"
9-)
10-
11-type TreeSnapshot struct {
12- Label string
13- Lines []string
14-}
15-
16-type WorkspaceDiagnostics struct {
17- mu sync.Mutex
18- enabled bool
19- snapshots []TreeSnapshot
20-}
21-
22-func NewWorkspaceDiagnostics(enabled bool) *WorkspaceDiagnostics {
23- return &WorkspaceDiagnostics{enabled: enabled}
24-}
25-
26-func (d *WorkspaceDiagnostics) Enabled() bool {
27- if d == nil {
28- return false
29- }
30- d.mu.Lock()
31- defer d.mu.Unlock()
32- return d.enabled
33-}
34-
35-func (d *WorkspaceDiagnostics) SetEnabled(enabled bool) {
36- if d == nil {
37- return
38- }
39- d.mu.Lock()
40- d.enabled = enabled
41- d.mu.Unlock()
42-}
43-
44-func (d *WorkspaceDiagnostics) Capture(label string, root *paneNode) TreeSnapshot {
45- if d == nil {
46- return TreeSnapshot{}
47- }
48-
49- d.mu.Lock()
50- enabled := d.enabled
51- d.mu.Unlock()
52- if !enabled {
53- return TreeSnapshot{}
54- }
55-
56- snapshot := TreeSnapshot{Label: label}
57- visited := make(map[*paneNode]bool)
58- buildTreeSnapshot(root, 0, "", visited, &snapshot.Lines)
59- if len(snapshot.Lines) == 0 {
60- snapshot.Lines = append(snapshot.Lines, "<empty workspace>")
61- }
62-
63- d.mu.Lock()
64- d.snapshots = append(d.snapshots, snapshot)
65- d.mu.Unlock()
66-
67- logging.Info(fmt.Sprintf("[pane-close] snapshot %s\n%s", snapshot.Label, strings.Join(snapshot.Lines, "\n")))
68- return snapshot
69-}
70-
71-func (d *WorkspaceDiagnostics) Snapshots() []TreeSnapshot {
72- if d == nil {
73- return nil
74- }
75- d.mu.Lock()
76- defer d.mu.Unlock()
77-
78- out := make([]TreeSnapshot, len(d.snapshots))
79- copy(out, d.snapshots)
80- return out
81-}
82-
83-func buildTreeSnapshot(node *paneNode, depth int, prefix string, visited map[*paneNode]bool, lines *[]string) {
84- indent := strings.Repeat(" ", depth)
85- if node == nil {
86- *lines = append(*lines, fmt.Sprintf("%s%s<nil>", indent, prefix))
87- return
88- }
89-
90- if depth > 64 {
91- *lines = append(*lines, fmt.Sprintf("%s%s<max depth reached>", indent, prefix))
92- return
93- }
94-
95- if visited[node] {
96- *lines = append(*lines, fmt.Sprintf("%s%s<cycle node=%p>", indent, prefix, node))
97- return
98- }
99-
100- visited[node] = true
101- *lines = append(*lines, fmt.Sprintf("%s%s%s", indent, prefix, describePaneNode(node)))
102-
103- if node.isStacked {
104- for idx, child := range node.stackedPanes {
105- childPrefix := fmt.Sprintf("[%d] ", idx)
106- buildTreeSnapshot(child, depth+1, childPrefix, visited, lines)
107- }
108- return
109- }
110-
111- if node.left != nil || node.right != nil {
112- buildTreeSnapshot(node.left, depth+1, "L: ", visited, lines)
113- buildTreeSnapshot(node.right, depth+1, "R: ", visited, lines)
114- }
115-}
116-
117-func describePaneNode(node *paneNode) string {
118- if node == nil {
119- return "<nil>"
120- }
121-
122- parts := []string{fmt.Sprintf("node=%p", node)}
123-
124- if node.isLeaf {
125- parts = append(parts, "leaf")
126- if node.pane != nil && node.pane.ID() != "" {
127- parts = append(parts, fmt.Sprintf("pane=%s", node.pane.ID()))
128- }
129- if node.isPopup {
130- parts = append(parts, "popup")
131- }
132- } else if node.isStacked {
133- parts = append(parts, fmt.Sprintf("stack size=%d active=%d", len(node.stackedPanes), node.activeStackIndex))
134- } else {
135- parts = append(parts, fmt.Sprintf("split orientation=%d", node.orientation))
136- }
137-
138- if node.container != nil {
139- parts = append(parts, fmt.Sprintf("widget=%p", node.container))
140- }
141-
142- if node.parent != nil {
143- parts = append(parts, fmt.Sprintf("parent=%p", node.parent))
144- }
145-
146- if !node.isLeaf && !node.isStacked {
147- if node.left != nil {
148- parts = append(parts, fmt.Sprintf("left=%p", node.left))
149- }
150- if node.right != nil {
151- parts = append(parts, fmt.Sprintf("right=%p", node.right))
152- }
153- }
154-
155- return strings.Join(parts, " ")
156-}
D internal/app/browser/workspace_geometry_validator.go +0 -208
@@ -1,208 +0,0 @@
1-// workspace_geometry_validator.go - Simplified geometry validation for split operations
2-package browser
3-
4-import (
5- "fmt"
6- "github.com/bnema/dumber/internal/logging"
7- "github.com/bnema/dumber/pkg/webkit"
8-)
9-
10-// GeometryValidator validates pane geometry constraints for splits and operations
11-type GeometryValidator struct {
12- minPaneWidth int
13- minPaneHeight int
14- debugMode bool
15-}
16-
17-// PaneGeometry represents the geometry of a pane
18-type PaneGeometry struct {
19- X int
20- Y int
21- Width int
22- Height int
23- IsValid bool
24-}
25-
26-// ValidationResult represents the result of a geometry validation
27-type ValidationResult struct {
28- IsValid bool
29- Reason string
30- Geometry PaneGeometry
31- RequiresRevalidation bool
32-}
33-
34-// NewGeometryValidator creates a new geometry validator
35-func NewGeometryValidator() *GeometryValidator {
36- return &GeometryValidator{
37- minPaneWidth: 300, // Minimum width for usable pane
38- minPaneHeight: 200, // Minimum height for usable pane
39- debugMode: false,
40- }
41-}
42-
43-// SetDebugMode enables or disables debug logging
44-func (gv *GeometryValidator) SetDebugMode(debug bool) {
45- gv.debugMode = debug
46-}
47-
48-// GetPaneGeometry extracts geometry information from a pane node
49-func (gv *GeometryValidator) GetPaneGeometry(node *paneNode) PaneGeometry {
50- geometry := PaneGeometry{IsValid: false}
51-
52- if node == nil || node.container == nil {
53- if gv.debugMode {
54- logging.Error(fmt.Sprintf("[geometry] Invalid node or missing container"))
55- }
56- return geometry
57- }
58-
59- // Get widget allocation directly - GTK operations are always on main thread
60- x, y, width, height := webkit.WidgetGetAllocation(node.container)
61-
62- geometry.X = x
63- geometry.Y = y
64- geometry.Width = width
65- geometry.Height = height
66- geometry.IsValid = true
67-
68- if gv.debugMode {
69- logging.Info(fmt.Sprintf("[geometry] Pane geometry: %dx%d at (%d,%d)",
70- geometry.Width, geometry.Height, geometry.X, geometry.Y))
71- }
72-
73- return geometry
74-}
75-
76-// prepareGeometryResult centralizes the common geometry validation logic used by
77-// split/stack checks. It returns the computed geometry, a populated
78-// ValidationResult (for early exits), and a boolean that indicates whether the
79-// caller should continue with additional constraints.
80-func (gv *GeometryValidator) prepareGeometryResult(node *paneNode) (PaneGeometry, ValidationResult, bool) {
81- geometry := gv.GetPaneGeometry(node)
82- result := ValidationResult{Geometry: geometry}
83-
84- if !geometry.IsValid {
85- if geometry.Width == 0 && geometry.Height == 0 {
86- result.IsValid = true
87- result.Reason = "geometry pending allocation"
88- result.RequiresRevalidation = true
89- } else {
90- result.IsValid = false
91- result.Reason = "could not determine pane geometry"
92- }
93- return geometry, result, false
94- }
95-
96- return geometry, result, true
97-}
98-
99-// ValidateHorizontalSplit validates if a pane can be split horizontally
100-func (gv *GeometryValidator) ValidateHorizontalSplit(node *paneNode) ValidationResult {
101- geometry, result, ok := gv.prepareGeometryResult(node)
102- if !ok {
103- return result
104- }
105-
106- requiredWidth := gv.minPaneWidth * 2
107- if geometry.Width < requiredWidth {
108- result.IsValid = false
109- result.Reason = fmt.Sprintf("pane width %d < required %d for horizontal split",
110- geometry.Width, requiredWidth)
111- return result
112- }
113-
114- if geometry.Height < gv.minPaneHeight {
115- result.IsValid = false
116- result.Reason = fmt.Sprintf("pane height %d < minimum %d",
117- geometry.Height, gv.minPaneHeight)
118- return result
119- }
120-
121- result.IsValid = true
122- result.Reason = "horizontal split is valid"
123- return result
124-}
125-
126-// ValidateVerticalSplit validates if a pane can be split vertically
127-func (gv *GeometryValidator) ValidateVerticalSplit(node *paneNode) ValidationResult {
128- geometry, result, ok := gv.prepareGeometryResult(node)
129- if !ok {
130- return result
131- }
132-
133- requiredHeight := gv.minPaneHeight * 2
134- if geometry.Height < requiredHeight {
135- result.IsValid = false
136- result.Reason = fmt.Sprintf("pane height %d < required %d for vertical split",
137- geometry.Height, requiredHeight)
138- return result
139- }
140-
141- if geometry.Width < gv.minPaneWidth {
142- result.IsValid = false
143- result.Reason = fmt.Sprintf("pane width %d < minimum %d",
144- geometry.Width, gv.minPaneWidth)
145- return result
146- }
147-
148- result.IsValid = true
149- result.Reason = "vertical split is valid"
150- return result
151-}
152-
153-// ValidateSplit validates a split operation based on direction
154-func (gv *GeometryValidator) ValidateSplit(node *paneNode, direction string) ValidationResult {
155- if node == nil {
156- return ValidationResult{IsValid: false, Reason: "node is nil"}
157- }
158-
159- if node.isStacked {
160- return ValidationResult{IsValid: false, Reason: "cannot split stacked pane container"}
161- }
162-
163- if !node.isLeaf {
164- return ValidationResult{IsValid: false, Reason: "can only split leaf panes"}
165- }
166-
167- switch direction {
168- case DirectionLeft, DirectionRight:
169- return gv.ValidateHorizontalSplit(node)
170- case DirectionUp, DirectionDown:
171- return gv.ValidateVerticalSplit(node)
172- default:
173- return ValidationResult{IsValid: false, Reason: fmt.Sprintf("unknown split direction: %s", direction)}
174- }
175-}
176-
177-// ValidateStackOperation validates if a pane can be converted to a stack
178-func (gv *GeometryValidator) ValidateStackOperation(node *paneNode) ValidationResult {
179- geometry, result, ok := gv.prepareGeometryResult(node)
180- if !ok {
181- return result
182- }
183-
184- if node.isStacked {
185- result.IsValid = false
186- result.Reason = "pane is already stacked"
187- return result
188- }
189-
190- minStackHeight := gv.minPaneHeight + 30 // Extra space for title bar
191- if geometry.Height < minStackHeight {
192- result.IsValid = false
193- result.Reason = fmt.Sprintf("pane height %d < required %d for stacking",
194- geometry.Height, minStackHeight)
195- return result
196- }
197-
198- if geometry.Width < gv.minPaneWidth {
199- result.IsValid = false
200- result.Reason = fmt.Sprintf("pane width %d < minimum %d",
201- geometry.Width, gv.minPaneWidth)
202- return result
203- }
204-
205- result.IsValid = true
206- result.Reason = "stack operation is valid"
207- return result
208-}
D internal/app/browser/workspace_hover.go +0 -81
@@ -1,81 +0,0 @@
1-// workspace_hover.go - Hover handler management for workspace panes
2-package browser
3-
4-import (
5- "fmt"
6-
7- "github.com/bnema/dumber/internal/logging"
8- "github.com/bnema/dumber/pkg/webkit"
9- "github.com/diamondburned/gotk4/pkg/glib/v2"
10-)
11-
12-// ensureHover attaches hover handlers to a pane node for mouse-based focus changes
13-func (wm *WorkspaceManager) ensureHover(node *paneNode) {
14- if wm == nil || node == nil || !node.isLeaf {
15- return
16- }
17-
18- // Skip if already attached or no container
19- if node.container == nil || node.hoverToken != nil {
20- return
21- }
22-
23- // Attach hover handler to the pane container
24- controller := webkit.WidgetAddHoverHandler(node.container, func() {
25- if wm == nil || node == nil {
26- return
27- }
28-
29- // Skip if this pane is already active to avoid redundant focus churn.
30- if wm.GetActiveNode() == node {
31- return
32- }
33-
34- // Debounce hover events to prevent rapid focus changes (minimum 150ms between hover-triggered focus changes)
35- now := glib.GetMonotonicTime()
36- if node.lastHoverTime > 0 && (now-node.lastHoverTime) < 150000 { // 150ms in microseconds
37- return
38- }
39- node.lastHoverTime = now
40-
41- // Request focus change when mouse enters the pane
42- wm.SetActivePane(node, SourceMouse)
43- })
44-
45- if controller != nil {
46- node.hoverToken = controller
47- logging.Debug(fmt.Sprintf("[workspace] Attached hover handler to pane %p (controller=%p)", node, controller))
48- } else {
49- logging.Error(fmt.Sprintf("[workspace] Failed to attach hover handler to pane %p", node))
50- }
51-}
52-
53-// detachHover removes hover handlers from a pane node
54-func (wm *WorkspaceManager) detachHover(node *paneNode) {
55- if wm == nil || node == nil || node.hoverToken == nil {
56- return
57- }
58-
59- if node.container == nil {
60- node.hoverToken = nil
61- return
62- }
63-
64- webkit.WidgetRemoveHoverHandler(node.container, node.hoverToken)
65- logging.Debug(fmt.Sprintf("[workspace] Detached hover handler from pane %p (controller=%p)", node, node.hoverToken))
66- node.hoverToken = nil
67-}
68-
69-// attachHoverHandlersToAllPanes attaches hover handlers to all leaf panes
70-func (wm *WorkspaceManager) attachHoverHandlersToAllPanes() {
71- if wm == nil {
72- return
73- }
74-
75- leaves := wm.collectLeaves()
76- for _, leaf := range leaves {
77- wm.ensureHover(leaf)
78- }
79-
80- logging.Debug(fmt.Sprintf("[workspace] Attached hover handlers to %d panes", len(leaves)))
81-}
D internal/app/browser/workspace_manager.go +0 -713
@@ -1,713 +0,0 @@
1-// workspace_manager.go - Core workspace management and coordination
2-package browser
3-
4-import (
5- "errors"
6- "fmt"
7- "strings"
8- "sync"
9- "sync/atomic"
10- "time"
11-
12- "github.com/bnema/dumber/internal/app/messaging"
13- "github.com/bnema/dumber/internal/logging"
14- "github.com/bnema/dumber/pkg/webkit"
15- glib "github.com/diamondburned/gotk4/pkg/glib/v2"
16- gtk "github.com/diamondburned/gotk4/pkg/gtk/v4"
17-)
18-
19-// WorkspaceManager coordinates Zellij-style pane operations.
20-type WorkspaceManager struct {
21- app *BrowserApp
22- window *webkit.Window
23- root *paneNode
24- mainPane *paneNode
25- viewToNode map[*webkit.WebView]*paneNode
26- lastSplitMsg map[*webkit.WebView]time.Time
27- lastExitMsg map[*webkit.WebView]time.Time
28- paneModeActive bool
29- splitting int32 // atomic: 0=false, 1=true
30- cssInitialized bool
31- createWebViewFn func() (*webkit.WebView, error)
32- createPaneFn func(*webkit.WebView) (*BrowserPane, error)
33-
34- // Coordination fields for preventing duplicate events
35- paneModeSource *webkit.WebView // Which webview initiated pane mode
36- paneModeActivePane *paneNode // Which pane node has pane mode active
37- paneModeContainer gtk.Widgetter // The container widget that has margin applied
38- paneMutex sync.Mutex // Protects pane mode state
39-
40- // Focus throttling fields to prevent infinite loops
41- lastFocusChange time.Time // When focus was last changed
42- focusThrottleMutex sync.Mutex // Protects focus throttling state
43-
44- // Stack operation timing to prevent focus conflicts
45- lastStackOperation time.Time // When a stack operation was last performed
46-
47- // NEW: Pane creation deduplicator
48- // Popups waiting for ready-to-show lifecycle callback
49- pendingPopups map[uint64]*pendingPopup
50-
51- // Specialized managers for different pane operations
52- stackedPaneManager *StackedPaneManager
53- focusStateMachine *FocusStateMachine
54-
55- // Focus debouncing to prevent rapid oscillation from any source
56- lastFocusTime time.Time
57- lastFocusTarget *paneNode
58- focusDebounce time.Duration
59-
60- // Validation and safety systems (debug-only, controlled by DUMBER_DEBUG_WORKSPACE env)
61- treeValidator *TreeValidator // Debug-only tree validation
62- treeRebalancer *TreeRebalancer // Rebalance after close operations
63- geometryValidator *GeometryValidator // Validate split constraints
64- stackLifecycleManager *StackLifecycleManager // Stack pane lifecycle
65-
66- // Debug instrumentation helpers
67- debugLevel DebugLevel
68- debugPaneClose bool
69- diagnostics *WorkspaceDiagnostics
70-
71- // Cleanup tracking
72- cleanupCounter uint
73- pendingIdle map[uintptr][]*paneNode
74-}
75-
76-// resolveWindow ensures the workspace has a reference to the GTK window.
77-func (wm *WorkspaceManager) resolveWindow() *webkit.Window {
78- if wm == nil {
79- return nil
80- }
81-
82- if wm.window != nil {
83- return wm.window
84- }
85-
86- if wm.app != nil {
87- if wm.app.tabManager != nil {
88- if win := wm.app.tabManager.GetWindow(); win != nil {
89- wm.window = win
90- return win
91- }
92- }
93- if wm.app.webView != nil {
94- if win := wm.app.webView.Window(); win != nil {
95- wm.window = win
96- return win
97- }
98- }
99- }
100-
101- return nil
102-}
103-
104-// Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
105-
106-// NewWorkspaceManager builds a workspace manager rooted at the provided pane.
107-func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManager {
108- debugLevel := getDebugLevel()
109-
110- manager := &WorkspaceManager{
111- app: app,
112- window: rootPane.webView.Window(),
113- viewToNode: make(map[*webkit.WebView]*paneNode),
114- lastSplitMsg: make(map[*webkit.WebView]time.Time),
115- lastExitMsg: make(map[*webkit.WebView]time.Time),
116- pendingPopups: make(map[uint64]*pendingPopup),
117- focusDebounce: 150 * time.Millisecond,
118- debugLevel: debugLevel,
119- }
120-
121- // Initialize validation components (opt-in based on debug level)
122- if debugLevel >= DebugBasic {
123- manager.treeValidator = NewTreeValidator(true, debugLevel == DebugFull)
124- manager.geometryValidator = NewGeometryValidator()
125- manager.geometryValidator.SetDebugMode(debugLevel == DebugFull)
126- } else {
127- // Production mode: disable validators
128- manager.treeValidator = NewTreeValidator(false, false)
129- manager.geometryValidator = NewGeometryValidator()
130- }
131-
132- // Initialize tree rebalancer (always needed for proper close operations)
133- manager.treeRebalancer = NewTreeRebalancer(manager, manager.treeValidator)
134-
135- // Initialize stack lifecycle manager
136- manager.stackLifecycleManager = NewStackLifecycleManager(manager, manager.treeValidator)
137-
138- // Initialize existing specialized managers (now enhanced with bulletproof components)
139- manager.stackedPaneManager = NewStackedPaneManager(manager)
140- manager.focusStateMachine = NewFocusStateMachine(manager)
141- manager.createWebViewFn = func() (*webkit.WebView, error) {
142- if manager.app == nil {
143- return nil, errors.New("workspace manager missing app reference")
144- }
145- cfg, err := manager.app.buildWebkitConfig()
146- if err != nil {
147- return nil, err
148- }
149- // CRITICAL: Pane WebViews are embedded inside the workspace paned containers,
150- // so they do not need their own toplevel GtkWindow. Setting CreateWindow=false
151- // prevents GTK "widget already has parent" errors when adding to paned containers.
152- cfg.CreateWindow = false
153- view, err := webkit.NewWebView(cfg)
154- if err != nil {
155- return nil, err
156- }
157- // Register with content blocking service for network/cosmetic filtering
158- manager.app.RegisterWebViewForFiltering(view)
159- return view, nil
160- }
161- manager.createPaneFn = func(view *webkit.WebView) (*BrowserPane, error) {
162- if manager.app == nil {
163- return nil, errors.New("workspace manager missing app reference")
164- }
165- return manager.app.createPaneForView(view)
166- }
167- manager.ensureWorkspaceStyles()
168-
169- rootContainer := rootPane.webView.RootWidget()
170- root := &paneNode{
171- pane: rootPane,
172- isLeaf: true,
173- }
174- // Initialize widgets properly
175- manager.initializePaneWidgets(root, rootContainer)
176-
177- manager.root = root
178- manager.mainPane = root
179- manager.viewToNode[rootPane.webView] = root
180- manager.setupPopupHandling(rootPane.webView, root)
181- manager.ensureHover(root)
182-
183- if handler := rootPane.MessageHandler(); handler != nil {
184- handler.SetWorkspaceObserver(manager)
185- }
186-
187- // CRITICAL: Register navigation handler (including pane mode) for the root pane
188- // This must happen here because when creating the first tab, app.workspace is nil
189- // in createPaneForView, so it doesn't register the handler
190- manager.RegisterNavigationHandler(rootPane.webView)
191-
192- // NOTE: app.workspace is NOT set here - it's managed exclusively by TabManager.
193- // TabManager sets app.workspace during tab switching (including initial tab creation).
194- // This prevents workspaces from interfering with each other when multiple tabs exist.
195-
196- // Configure debug settings from app config
197- if app.config != nil {
198- manager.focusStateMachine.ConfigureDebug(
199- app.config.Debug.EnableFocusDebug,
200- false, // CSS debug removed
201- app.config.Debug.EnableFocusMetrics,
202- )
203-
204- manager.debugPaneClose = app.config.Debug.EnablePaneCloseDebug
205- }
206-
207- manager.diagnostics = NewWorkspaceDiagnostics(manager.debugPaneClose)
208-
209- // Initialize focus state machine after all setup is complete
210- if err := manager.focusStateMachine.Initialize(); err != nil {
211- logging.Error(fmt.Sprintf("[workspace] Failed to initialize focus state machine: %v", err))
212- }
213-
214- // Ensure hover controllers are attached to all panes for mouse-driven focus changes
215- manager.attachHoverHandlersToAllPanes()
216-
217- if manager.debugPaneClose {
218- manager.dumpTreeState("workspace_init")
219- }
220-
221- return manager
222-}
223-
224-// shouldDebounce checks if an operation should be debounced based on timing
225-func (wm *WorkspaceManager) shouldDebounce(source *webkit.WebView, threshold time.Duration) bool {
226- if last, ok := wm.lastSplitMsg[source]; ok {
227- if elapsed := time.Since(last); elapsed < threshold {
228- logging.Debug(fmt.Sprintf("[workspace] operation debounced: %.0fms", elapsed.Seconds()*1000))
229- return true
230- }
231- }
232- wm.lastSplitMsg[source] = time.Now()
233- return false
234-}
235-
236-// withSplitLock executes an operation with atomic splitting lock protection
237-func (wm *WorkspaceManager) withSplitLock(operation string, fn func() error) error {
238- if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
239- logging.Debug(fmt.Sprintf("[workspace] %s ignored: operation in progress", operation))
240- return fmt.Errorf("operation in progress")
241- }
242-
243- defer func() {
244- atomic.StoreInt32(&wm.splitting, 0)
245- if r := recover(); r != nil {
246- logging.Error(fmt.Sprintf("[workspace] %s panicked: %v", operation, r))
247- panic(r)
248- }
249- }()
250-
251- return fn()
252-}
253-
254-// cleanupPopupStorage removes localStorage entries for a closed popup
255-func (wm *WorkspaceManager) cleanupPopupStorage(parentNode, targetNode *paneNode, webviewID string) {
256- if parentNode.pane == nil || parentNode.pane.WebView() == nil || targetNode.requestID == "" {
257- return
258- }
259-
260- requestID := targetNode.requestID
261- parentWebViewID := parentNode.pane.WebView().ID()
262-
263- script := fmt.Sprintf(`
264- try {
265- localStorage.removeItem('popup_mapping_%d');
266- const keys = ['popup_%s_parent_info', 'popup_%s_parent_action',
267- 'popup_%s_message_to_popup', 'popup_%s_message_to_parent',
268- 'oauth_callback_%s'];
269- keys.forEach(k => { try { localStorage.removeItem(k); } catch(e) {} });
270- console.log('[workspace] Cleaned popup localStorage');
271- } catch(e) { console.warn('[workspace] localStorage cleanup failed:', e); }
272- `, parentWebViewID, requestID, requestID, requestID, requestID, webviewID)
273-
274- if err := parentNode.pane.WebView().InjectScript(script); err != nil {
275- logging.Error(fmt.Sprintf("[workspace] localStorage cleanup failed: %v", err))
276- } else {
277- logging.Debug(fmt.Sprintf("[workspace] Cleaned localStorage for popup requestId=%s webviewId=%s", requestID, webviewID))
278- }
279-}
280-
281-// OnWorkspaceMessage implements messaging.WorkspaceObserver.
282-func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
283- node := wm.viewToNode[source]
284-
285- if msg.WebViewID != "" {
286- if explicitNode := wm.findNodeByWebViewID(msg.WebViewID); explicitNode != nil {
287- if explicitNode.pane != nil && explicitNode.pane.webView != nil {
288- node = explicitNode
289- source = explicitNode.pane.webView
290- }
291- }
292- }
293-
294- if node == nil {
295- logging.Warn(fmt.Sprintf("[workspace] message from unknown webview: event=%s webviewId=%s", msg.Event, msg.WebViewID))
296- return
297- }
298-
299- switch event := strings.ToLower(msg.Event); event {
300- case "pane-split":
301- wm.SetActivePane(node, SourceProgrammatic)
302- direction := strings.ToLower(msg.Direction)
303- if direction == "" {
304- direction = DirectionRight
305- }
306-
307- if wm.shouldDebounce(source, 200*time.Millisecond) {
308- return
309- }
310-
311- err := wm.withSplitLock("split", func() error {
312- newNode, err := wm.SplitPane(node, direction)
313- if err != nil {
314- if glib.MainContextDefault().IsOwner() {
315- glib.MainContextDefault().Iteration(false)
316- }
317- return err
318- }
319- wm.clonePaneState(node, newNode)
320- return nil
321- })
322-
323- if err != nil {
324- logging.Error(fmt.Sprintf("[workspace] split failed: %v", err))
325- }
326-
327- case "pane-stack":
328- wm.SetActivePane(node, SourceProgrammatic)
329-
330- if wm.shouldDebounce(source, 200*time.Millisecond) {
331- return
332- }
333-
334- err := wm.withSplitLock("stack", func() error {
335- newNode, err := wm.stackedPaneManager.StackPane(node)
336- if err != nil {
337- if glib.MainContextDefault().IsOwner() {
338- glib.MainContextDefault().Iteration(false)
339- }
340- return err
341- }
342- wm.clonePaneState(node, newNode)
343- return nil
344- })
345-
346- if err != nil {
347- logging.Error(fmt.Sprintf("[workspace] stack failed: %v", err))
348- }
349-
350- case "close-popup":
351- if msg.WebViewID == "" {
352- logging.Warn(fmt.Sprintf("[workspace] close-popup: empty webviewId, ignoring"))
353- return
354- }
355-
356- // Find popup pane by webview ID
357- var targetNode *paneNode
358- for webView, node := range wm.viewToNode {
359- if webView != nil && fmt.Sprintf("%d", webView.ID()) == msg.WebViewID {
360- targetNode = node
361- break
362- }
363- }
364-
365- if targetNode == nil {
366- logging.Warn(fmt.Sprintf("[workspace] close-popup: webview not found: %s", msg.WebViewID))
367- return
368- }
369-
370- if !targetNode.isPopup {
371- logging.Warn(fmt.Sprintf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID))
372- return
373- }
374-
375- logging.Info(fmt.Sprintf("[workspace] Closing popup due to %s", msg.Reason))
376-
377- // Remove from parent's active popup list
378- if targetNode.parentPane != nil {
379- parentNode := targetNode.parentPane
380- for i, childID := range parentNode.activePopupChildren {
381- if childID == msg.WebViewID {
382- parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...)
383- logging.Debug(fmt.Sprintf("[workspace] Removed popup from parent (remaining: %d)", len(parentNode.activePopupChildren)))
384- break
385- }
386- }
387-
388- wm.cleanupPopupStorage(parentNode, targetNode, msg.WebViewID)
389- }
390-
391- if err := wm.ClosePane(targetNode); err != nil {
392- logging.Error(fmt.Sprintf("[workspace] Failed to close popup: %v", err))
393- }
394-
395- default:
396- logging.Warn(fmt.Sprintf("[workspace] unhandled workspace event: %s", msg.Event))
397- }
398-}
399-
400-func (wm *WorkspaceManager) findNodeByWebViewID(id string) *paneNode {
401- if wm == nil || id == "" {
402- return nil
403- }
404-
405- for webView, node := range wm.viewToNode {
406- if webView != nil && webView.IDString() == id {
407- return node
408- }
409- }
410- return nil
411-}
412-
413-// GetActiveNode returns the currently active pane node
414-func (wm *WorkspaceManager) GetActiveNode() *paneNode {
415- if wm == nil || wm.focusStateMachine == nil {
416- return nil
417- }
418- return wm.focusStateMachine.GetActivePane()
419-}
420-
421-// SetActivePane requests focus change through the focus state machine with debouncing
422-func (wm *WorkspaceManager) SetActivePane(node *paneNode, source FocusSource) {
423- now := time.Now()
424-
425- // Check if we should debounce this focus request (except for urgent system events)
426- if source != SourceSystem && now.Sub(wm.lastFocusTime) < wm.focusDebounce && wm.lastFocusTarget == node {
427- // Too soon and same target - ignore this request to prevent rapid oscillation
428- return
429- }
430-
431- // Update tracking and proceed with focus change
432- wm.lastFocusTime = now
433- wm.lastFocusTarget = node
434-
435- if err := wm.focusStateMachine.RequestFocus(node, source); err != nil {
436- logging.Error(fmt.Sprintf("[workspace] Focus request failed: %v", err))
437- }
438-}
439-
440-// GetNodeForWebView returns the pane node associated with a WebView
441-func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode {
442- return wm.viewToNode[webView]
443-}
444-
445-// RegisterNavigationHandler sets up navigation handling for a webview
446-func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
447- if webView == nil {
448- return
449- }
450-
451- // Register workspace navigation callback for alt+arrow pane navigation
452- webView.RegisterWorkspaceNavigationHandler(func(direction string) bool {
453- if wm == nil {
454- return false
455- }
456- return wm.FocusNeighbor(direction)
457- })
458-
459- // Register pane mode callbacks for Ctrl+P and pane mode action keys
460- webView.RegisterPaneModeHandler(func(action string) bool {
461- if wm == nil {
462- return false
463- }
464-
465- if action == "enter" {
466- // CRITICAL FIX: Use app.workspace instead of closure's wm
467- // This ensures Ctrl+P always targets the ACTIVE tab's workspace
468- if wm.app != nil && wm.app.workspace != nil {
469- wm.app.workspace.EnterPaneMode()
470- return true
471- }
472- return false
473- }
474-
475- if action == "exit" {
476- // Check if pane mode is active
477- wm.paneMutex.Lock()
478- wasActive := wm.paneModeActive
479- wm.paneMutex.Unlock()
480-
481- if wasActive {
482- wm.ExitPaneMode("escape")
483- return true // Mode was active, we handled it
484- }
485- return false // No mode active, pass through to DOM
486- }
487-
488- // For other actions, use active workspace (same pattern as "enter")
489- // This ensures action keys work on the correct tab
490- if wm.app != nil && wm.app.workspace != nil {
491- // Check if pane mode is active on the active workspace
492- activeWs := wm.app.workspace
493- activeWs.paneMutex.Lock()
494- isActive := activeWs.paneModeActive
495- activeWs.paneMutex.Unlock()
496-
497- if !isActive {
498- return false // Not in pane mode, allow normal behavior
499- }
500-
501- // Pane mode is active, handle the action on active workspace
502- activeWs.HandlePaneAction(action)
503- return true
504- }
505- return false
506- }, func() bool {
507- // Check if pane mode is currently active
508- wm.paneMutex.Lock()
509- defer wm.paneMutex.Unlock()
510- return wm.paneModeActive
511- })
512-
513- // Register middle-click link handler to open links in new panes
514- webView.RegisterMiddleClickLinkHandler(func(linkURL string) bool {
515- if wm == nil {
516- return false
517- }
518-
519- logging.Info(fmt.Sprintf("[workspace] Middle-click/Ctrl+click on link, opening in new pane: %s", linkURL))
520-
521- // Get the node for this webview
522- node := wm.GetNodeForWebView(webView)
523- if node == nil {
524- logging.Error(fmt.Sprintf("[workspace] Cannot find node for middle-clicked link"))
525- return false
526- }
527-
528- // Get behavior from BlankTargetBehavior config
529- behavior := "stacked" // Default
530- if wm.app != nil && wm.app.config != nil && wm.app.config.Workspace.Popups.BlankTargetBehavior != "" {
531- behavior = strings.ToLower(wm.app.config.Workspace.Popups.BlankTargetBehavior)
532- }
533-
534- var newNode *paneNode
535- var err error
536-
537- switch behavior {
538- case "stacked":
539- // Stack the current pane - this also creates a new pane in the stack
540- logging.Debug(fmt.Sprintf("[workspace] Using stacked behavior for gesture link"))
541- newNode, err = wm.stackedPaneManager.StackPane(node)
542- if err != nil {
543- logging.Error(fmt.Sprintf("[workspace] Failed to stack pane for gesture link: %v", err))
544- return false
545- }
546- // newNode is the new pane that was added to the stack
547- logging.Debug(fmt.Sprintf("[workspace] StackPane created new pane in stack: %p", newNode))
548-
549- case "split":
550- // Regular split behavior
551- logging.Debug(fmt.Sprintf("[workspace] Using split behavior for gesture link"))
552- direction := "right"
553- if wm.app.config.Workspace.Popups.Placement != "" {
554- direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
555- }
556- newNode, err = wm.SplitPane(node, direction)
557-
558- case "tabbed":
559- // Tabbed not yet implemented, fall back to split
560- logging.Warn(fmt.Sprintf("[workspace] WARNING: Tabbed behavior not yet implemented for gesture links, falling back to split"))
561- direction := "right"
562- if wm.app.config.Workspace.Popups.Placement != "" {
563- direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
564- }
565- newNode, err = wm.SplitPane(node, direction)
566-
567- default:
568- // Unknown behavior, fall back to split
569- logging.Warn(fmt.Sprintf("[workspace] WARNING: Unknown behavior '%s' for gesture links, falling back to split", behavior))
570- direction := "right"
571- if wm.app.config.Workspace.Popups.Placement != "" {
572- direction = strings.ToLower(wm.app.config.Workspace.Popups.Placement)
573- }
574- newNode, err = wm.SplitPane(node, direction)
575- }
576-
577- if err != nil {
578- logging.Error(fmt.Sprintf("[workspace] Failed to handle gesture link: %v", err))
579- return false
580- }
581-
582- // Navigate the new pane to the URL
583- if newNode != nil && newNode.pane != nil && newNode.pane.WebView() != nil {
584- if err := newNode.pane.WebView().LoadURL(linkURL); err != nil {
585- logging.Error(fmt.Sprintf("[workspace] Failed to load URL in new pane: %v", err))
586- return false
587- }
588-
589- // Set the new pane as active
590- wm.SetActivePane(newNode, SourceProgrammatic)
591- }
592-
593- return true // Indicate we handled the click
594- })
595-
596- logging.Debug(fmt.Sprintf("[workspace] Registered navigation handler for webview: %d", webView.ID()))
597-}
598-
599-// GetAllPanes returns all BrowserPanes in this workspace.
600-// This is used by TabManager to update app-level panes reference.
601-func (wm *WorkspaceManager) GetAllPanes() []*BrowserPane {
602- panes := make([]*BrowserPane, 0, len(wm.viewToNode))
603- for _, node := range wm.viewToNode {
604- if node.pane != nil {
605- panes = append(panes, node.pane)
606- }
607- }
608- return panes
609-}
610-
611-// GetActivePane returns the currently active BrowserPane in this workspace.
612-// This is used by TabManager to update app-level activePane reference.
613-func (wm *WorkspaceManager) GetActivePane() *BrowserPane {
614- node := wm.GetActiveNode()
615- if node != nil && node.pane != nil {
616- return node.pane
617- }
618- return nil
619-}
620-
621-// RestoreFocus restores focus to the active pane in this workspace.
622-// Called when switching to a tab to ensure the correct pane has focus.
623-func (wm *WorkspaceManager) RestoreFocus() {
624- logging.Debug(fmt.Sprintf("[workspace] RestoreFocus: workspace %p", wm))
625-
626- // Get the active pane and explicitly grab GTK focus
627- activeNode := wm.GetActiveNode()
628- if activeNode != nil && activeNode.pane != nil && activeNode.pane.webView != nil {
629- // Explicitly grab GTK focus to this WebView
630- // This ensures keyboard events go to the active tab's WebView
631- // Note: RestoreFocus is called from switchToTabInternal which is already on the main thread
632- webkit.WidgetGrabFocus(activeNode.pane.webView.AsWidget())
633- logging.Debug(fmt.Sprintf("[workspace] Grabbed GTK focus for WebView in workspace %p", wm))
634-
635- // Also request focus via FSM
636- if wm.focusStateMachine != nil {
637- wm.focusStateMachine.RequestFocus(activeNode, SourceProgrammatic)
638- }
639- }
640-}
641-
642-// IsInActiveTab returns whether this workspace is the app's current active workspace.
643-// This is checked dynamically by comparing with app.workspace (updated during tab switching).
644-func (wm *WorkspaceManager) IsInActiveTab() bool {
645- if wm.app == nil {
646- return false
647- }
648- // The active workspace is the one currently assigned to app.workspace
649- isActive := wm.app.workspace == wm
650- logging.Debug(fmt.Sprintf("[workspace] IsInActiveTab check: workspace %p, app.workspace %p, isActive=%v", wm, wm.app.workspace, isActive))
651- return isActive
652-}
653-
654-// ClearFocus removes GTK focus from all panes in this workspace.
655-// This prevents inactive tab's WebViews from catching keyboard events.
656-func (wm *WorkspaceManager) ClearFocus() {
657- logging.Debug(fmt.Sprintf("[workspace] ClearFocus: workspace %p", wm))
658- // No state to clear - IsInActiveTab() checks dynamically
659-}
660-
661-// GetRootWidget returns the root GTK widget for this workspace.
662-// This is the container that should be added to the tab's content area.
663-func (wm *WorkspaceManager) GetRootWidget() gtk.Widgetter {
664- if wm.root != nil && wm.root.container != nil {
665- return wm.root.container
666- }
667- return nil
668-}
669-
670-// setRootContainer attaches a widget as the workspace root to TabManager.ContentArea.
671-// The tab system is always present (even with a single tab), so this always uses ContentArea.
672-func (wm *WorkspaceManager) setRootContainer(container gtk.Widgetter) {
673- if container == nil {
674- return
675- }
676-
677- if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
678- logging.Error(fmt.Sprintf("[workspace] ERROR: Cannot set root - tab manager not initialized"))
679- return
680- }
681-
682- contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
683- if !ok || contentBox == nil {
684- logging.Error(fmt.Sprintf("[workspace] ERROR: ContentArea is not a Box"))
685- return
686- }
687-
688- contentBox.Append(container)
689- webkit.WidgetSetVisible(container, true)
690- logging.Debug(fmt.Sprintf("[workspace] Attached root container %p to ContentArea", container))
691-}
692-
693-// clearRootContainer removes the current workspace root from TabManager.ContentArea.
694-// The tab system is always present (even with a single tab), so this always uses ContentArea.
695-func (wm *WorkspaceManager) clearRootContainer() {
696- if wm.root == nil || wm.root.container == nil {
697- return
698- }
699-
700- if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
701- logging.Error(fmt.Sprintf("[workspace] ERROR: Cannot clear root - tab manager not initialized"))
702- return
703- }
704-
705- contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
706- if !ok || contentBox == nil {
707- logging.Error(fmt.Sprintf("[workspace] ERROR: ContentArea is not a Box"))
708- return
709- }
710-
711- contentBox.Remove(wm.root.container)
712- logging.Debug(fmt.Sprintf("[workspace] Removed root container %p from ContentArea", wm.root.container))
713-}
D internal/app/browser/workspace_navigation.go +0 -474
@@ -1,474 +0,0 @@
1-// workspace_navigation.go - Navigation and focus management for workspace panes
2-package browser
3-
4-import (
5- "fmt"
6- "math"
7- "strings"
8- "time"
9-
10- "github.com/bnema/dumber/internal/logging"
11- "github.com/bnema/dumber/pkg/webkit"
12- "github.com/diamondburned/gotk4/pkg/gtk/v4"
13-)
14-
15-const (
16- // focusThrottleInterval prevents rapid focus changes that could cause infinite loops
17- focusThrottleInterval = 100 * time.Millisecond
18-)
19-
20-// DispatchPaneFocusEvent sends a workspace focus event to a pane's webview
21-func (wm *WorkspaceManager) DispatchPaneFocusEvent(node *paneNode, active bool) {
22- if node == nil || node.pane == nil || node.pane.webView == nil {
23- return
24- }
25-
26- detail := map[string]any{
27- "active": active,
28- "webview": fmt.Sprintf("%p", node.pane.webView),
29- "webviewId": node.pane.webView.ID(),
30- "hasGUI": node.pane.HasGUI(),
31- "timestamp": time.Now().UnixMilli(),
32- }
33-
34- if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
35- logging.Error(fmt.Sprintf("[workspace] failed to dispatch focus event: %v", err))
36- } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
37- logging.Debug(fmt.Sprintf("[workspace] dispatched focus event for webview %d (active=%v)", node.pane.webView.ID(), active))
38- }
39-}
40-
41-// focusAfterPaneMode restores focus when a pane mode operation completes without
42-// stealing focus from the active pane inside a stack that just spawned a sibling.
43-func (wm *WorkspaceManager) focusAfterPaneMode(node *paneNode) {
44- wm.focusRespectingStack(node, "focus-after-pane-mode")
45-}
46-
47-// focusRespectingStack handles focus while respecting stacked pane hierarchies
48-func (wm *WorkspaceManager) focusRespectingStack(node *paneNode, reason string) {
49- if node == nil {
50- return
51- }
52-
53- if stack := node.parent; stack != nil && stack.isStacked {
54- activeIndex := stack.activeStackIndex
55- if activeIndex >= 0 && activeIndex < len(stack.stackedPanes) {
56- activePane := stack.stackedPanes[activeIndex]
57- if activePane != nil && activePane != node {
58- if reason != "" {
59- logging.Info(fmt.Sprintf("[workspace] %s: preserving active stacked pane index=%d", reason, activeIndex))
60- }
61- wm.SetActivePane(activePane, SourceKeyboard)
62- return
63- }
64- }
65- }
66-
67- wm.SetActivePane(node, SourceKeyboard)
68-}
69-
70-// focusByView changes focus to the pane containing the specified WebView
71-func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
72- if wm == nil || view == nil {
73- return
74- }
75-
76- // Throttle focus changes to prevent infinite loops
77- wm.focusThrottleMutex.Lock()
78- if time.Since(wm.lastFocusChange) < focusThrottleInterval {
79- wm.focusThrottleMutex.Unlock()
80- return
81- }
82- wm.lastFocusChange = time.Now()
83- wm.focusThrottleMutex.Unlock()
84-
85- if node, ok := wm.viewToNode[view]; ok {
86- if wm.GetActiveNode() != node {
87- wm.focusRespectingStack(node, "focus-by-view")
88- }
89- }
90-}
91-
92-// Note: ensureHover and detachHover are now implemented in workspace_hover.go
93-
94-// detachFocus removes the GTK focus controller attached to a pane, if any.
95-func (wm *WorkspaceManager) detachFocus(node *paneNode) {
96- if wm == nil || node == nil || node.focusControllerToken == 0 {
97- return
98- }
99-
100- if wm.focusStateMachine != nil {
101- wm.focusStateMachine.detachGTKController(node, node.focusControllerToken)
102- }
103-
104- node.focusControllerToken = 0
105-}
106-
107-// FocusNeighbor moves focus to the nearest pane in the requested direction using the
108-// actual widget geometry to determine adjacency. For stacked panes, DirectionUp and DirectionDown
109-// navigate within the stack.
110-func (wm *WorkspaceManager) FocusNeighbor(direction string) bool {
111- if wm == nil {
112- return false
113- }
114- switch strings.ToLower(direction) {
115- case DirectionUp, DirectionDown:
116- // Check if current pane is part of a stack and handle stack navigation
117- if wm.stackedPaneManager.NavigateStack(strings.ToLower(direction)) {
118- return true
119- }
120- // Fall back to regular adjacency navigation
121- return wm.focusAdjacent(strings.ToLower(direction))
122- case DirectionLeft, DirectionRight:
123- return wm.focusAdjacent(strings.ToLower(direction))
124- default:
125- logging.Error(fmt.Sprintf("[workspace] invalid focus direction: %s (expected: up, down, left, right)", direction))
126- return false
127- }
128-}
129-
130-// navigateStack handles navigation within a stacked pane container.
131-func (wm *WorkspaceManager) navigateStack(direction string) bool {
132- currentFocused := wm.GetActiveNode()
133- if currentFocused == nil {
134- return false
135- }
136-
137- // Find the stack container this pane belongs to
138- var stackNode *paneNode
139- current := currentFocused
140-
141- // Check if current pane is directly in a stack
142- if current.parent != nil && current.parent.isStacked {
143- stackNode = current.parent
144- } else {
145- // Current pane might be the stack container itself if it was the first pane converted to stack
146- if current.isStacked {
147- stackNode = current
148- }
149- }
150-
151- if stackNode == nil || !stackNode.isStacked || len(stackNode.stackedPanes) <= 1 {
152- return false // Not in a stack or stack has only one pane
153- }
154-
155- // Find current pane's index in the stack
156- currentIndex := -1
157- for i, pane := range stackNode.stackedPanes {
158- if pane == current || (pane.pane != nil && current.pane != nil && pane.pane.webView == current.pane.webView) {
159- currentIndex = i
160- break
161- }
162- }
163-
164- if currentIndex == -1 {
165- logging.Error(fmt.Sprintf("[workspace] navigateStack: current pane not found in stack"))
166- return false
167- }
168-
169- // Calculate new index based on direction
170- var newIndex int
171- switch direction {
172- case DirectionUp:
173- newIndex = currentIndex - 1
174- if newIndex < 0 {
175- newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
176- }
177- case DirectionDown:
178- newIndex = currentIndex + 1
179- if newIndex >= len(stackNode.stackedPanes) {
180- newIndex = 0 // Wrap to first
181- }
182- default:
183- return false
184- }
185-
186- if newIndex == currentIndex {
187- return false // No change
188- }
189-
190- // Update active stack index and visibility
191- stackNode.activeStackIndex = newIndex
192- wm.stackedPaneManager.UpdateStackVisibility(stackNode)
193-
194- // Focus the new active pane
195- newActivePane := stackNode.stackedPanes[newIndex]
196- wm.SetActivePane(newActivePane, SourceStackNav)
197-
198- logging.Debug(fmt.Sprintf("[workspace] navigated stack: direction=%s from=%d to=%d stackSize=%d",
199- direction, currentIndex, newIndex, len(stackNode.stackedPanes)))
200- return true
201-}
202-
203-// focusAdjacent uses geometry-based calculations to find and focus adjacent panes
204-func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
205- currentFocused := wm.GetActiveNode()
206- if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == nil {
207- return false
208- }
209-
210- if neighbor := wm.structuralNeighbor(currentFocused, direction); neighbor != nil {
211- logging.Debug(fmt.Sprintf("[workspace] focusAdjacent: using structural neighbor for direction=%s", direction))
212- wm.SetActivePane(neighbor, SourceKeyboard)
213- return true
214- }
215-
216- curX, curY, curWidth, curHeight := wm.getNavigationAllocation(currentFocused)
217- cx := float64(curX) + float64(curWidth)/2.0
218- cy := float64(curY) + float64(curHeight)/2.0
219- logging.Debug(fmt.Sprintf("[workspace] focusAdjacent: current pane center=(%.0f, %.0f) direction=%s", cx, cy, direction))
220-
221- leaves := wm.collectLeaves()
222- bestScore := math.MaxFloat64
223- var best *paneNode
224- var debugCandidates []string
225-
226- for _, candidate := range leaves {
227- if candidate == nil || candidate == currentFocused || candidate.container == nil {
228- continue
229- }
230-
231- x, y, width, height := wm.getNavigationAllocation(candidate)
232- tx := float64(x) + float64(width)/2.0
233- ty := float64(y) + float64(height)/2.0
234-
235- dx := tx - cx
236- dy := ty - cy
237-
238- var score float64
239- switch direction {
240- case DirectionLeft:
241- if dx >= -focusEpsilon {
242- continue
243- }
244- score = math.Abs(dx)*1000 + math.Abs(dy)
245- case DirectionRight:
246- if dx <= focusEpsilon {
247- continue
248- }
249- score = math.Abs(dx)*1000 + math.Abs(dy)
250- case DirectionUp:
251- if dy >= -focusEpsilon {
252- debugCandidates = append(debugCandidates, fmt.Sprintf("SKIPPED cand=%p center=(%.0f,%.0f) dy=%.0f (not above)", candidate.container, tx, ty, dy))
253- continue
254- }
255- score = math.Abs(dy)*1000 + math.Abs(dx)
256- debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p center=(%.0f,%.0f) dy=%.0f score=%.0f", candidate.container, tx, ty, dy, score))
257- case DirectionDown:
258- if dy <= focusEpsilon {
259- debugCandidates = append(debugCandidates, fmt.Sprintf("SKIPPED cand=%p center=(%.0f,%.0f) dy=%.0f (not below)", candidate.container, tx, ty, dy))
260- continue
261- }
262- score = math.Abs(dy)*1000 + math.Abs(dx)
263- debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p center=(%.0f,%.0f) dy=%.0f score=%.0f", candidate.container, tx, ty, dy, score))
264- }
265-
266- if score < bestScore {
267- bestScore = score
268- best = candidate
269- }
270- }
271-
272- if best != nil {
273- bx, by, _, _ := webkit.WidgetGetAllocation(best.container)
274- logging.Debug(fmt.Sprintf("[workspace] focusAdjacent: found best candidate at pos=(%d,%d) for direction=%s", bx, by, direction))
275- wm.SetActivePane(best, SourceKeyboard)
276- return true
277- }
278-
279- if len(debugCandidates) > 0 {
280- logging.Debug(fmt.Sprintf("[workspace] focusAdjacent: NO candidate found for direction=%s current=%p center=(%.0f,%.0f) candidates=[%s]", direction, currentFocused.container, cx, cy, strings.Join(debugCandidates, "; ")))
281- }
282- return false
283-}
284-
285-// getNavigationAllocation returns window-absolute coordinates for navigation geometric checks.
286-// Uses ComputeBounds relative to the window root to get actual screen positions.
287-// For panes inside a stack, returns the stack wrapper's bounds.
288-// For regular panes, returns the pane's own bounds.
289-func (wm *WorkspaceManager) getNavigationAllocation(node *paneNode) (x, y, width, height int) {
290- if node == nil || node.container == nil {
291- return 0, 0, 0, 0
292- }
293-
294- // If this pane is inside a stack, use the stack wrapper's window bounds
295- if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {
296- return webkit.WidgetGetWindowBounds(node.parent.stackWrapper)
297- }
298-
299- // For regular panes or stack containers themselves, use their own window bounds
300- return webkit.WidgetGetWindowBounds(node.container)
301-}
302-
303-// structuralNeighbor finds neighbors based on the tree structure rather than geometry
304-func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
305- if node == nil || node.container == nil {
306- return nil
307- }
308-
309- refX, refY, refWidth, refHeight := wm.getNavigationAllocation(node)
310- cx := float64(refX) + float64(refWidth)/2.0
311- cy := float64(refY) + float64(refHeight)/2.0
312- axisVertical := direction == DirectionUp || direction == DirectionDown
313-
314- logging.Debug(fmt.Sprintf("[workspace] structuralNeighbor: node=%p pos=(%d,%d) direction=%s", node.container, refX, refY, direction))
315-
316- // Start from the node's parent, but skip stack containers for vertical navigation
317- // Stack containers are transparent to vertical navigation - we want to navigate
318- // from stack to external panes, not within the stack (that's handled by NavigateStack)
319- startParent := node.parent
320- if startParent != nil && startParent.isStacked && axisVertical {
321- logging.Debug(fmt.Sprintf("[workspace] structuralNeighbor: skipping stack container parent, using stack's parent instead"))
322- node = startParent // Treat the stack container as the navigation node
323- startParent = startParent.parent
324- }
325-
326- for parent := startParent; parent != nil; parent = parent.parent {
327- isLeft := parent.left == node
328- isRight := parent.right == node
329- logging.Debug(fmt.Sprintf("[workspace] structuralNeighbor: checking parent orientation=%v isLeft=%v isRight=%v", parent.orientation, isLeft, isRight))
330-
331- switch direction {
332- case DirectionUp:
333- if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node {
334- logging.Info(fmt.Sprintf("[workspace] structuralNeighbor: DirectionUp - we are RIGHT child, looking in LEFT subtree"))
335- if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
336- lx, ly, _, _ := webkit.WidgetGetAllocation(leaf.container)
337- logging.Debug(fmt.Sprintf("[workspace] structuralNeighbor: found leaf at pos=(%d,%d)", lx, ly))
338- return leaf
339- }
340- }
341- case DirectionDown:
342- if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node {
343- logging.Info(fmt.Sprintf("[workspace] structuralNeighbor: DirectionDown - we are LEFT child, looking in RIGHT subtree"))
344- if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
345- lx, ly, _, _ := wm.getNavigationAllocation(leaf)
346- logging.Debug(fmt.Sprintf("[workspace] structuralNeighbor: found leaf at pos=(%d,%d)", lx, ly))
347- return leaf
348- } else {
349- logging.Error(fmt.Sprintf("[workspace] structuralNeighbor: closestLeafFromSubtree returned nil for DirectionDown"))
350- }
351- }
352- case DirectionLeft:
353- if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.right == node {
354- if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
355- return leaf
356- }
357- }
358- case DirectionRight:
359- if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.left == node {
360- if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
361- return leaf
362- }
363- }
364- }
365- node = parent
366- }
367- return nil
368-}
369-
370-// closestLeafFromSubtree finds the closest leaf node in a subtree based on direction
371-func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float64, direction string) *paneNode {
372- leaves := wm.collectLeavesFromWithDirection(node, direction)
373- logging.Debug(fmt.Sprintf("[workspace] closestLeafFromSubtree: found %d leaves for direction=%s from cx=%.0f cy=%.0f", len(leaves), direction, cx, cy))
374- bestScore := math.MaxFloat64
375- var best *paneNode
376- for _, leaf := range leaves {
377- if leaf == nil || leaf.container == nil {
378- continue
379- }
380-
381- x, y, width, height := wm.getNavigationAllocation(leaf)
382- tx := float64(x) + float64(width)/2.0
383- ty := float64(y) + float64(height)/2.0
384- dx := tx - cx
385- dy := ty - cy
386- logging.Info(fmt.Sprintf("[workspace] closestLeafFromSubtree: leaf=%p pos=(%d,%d) center=(%.0f,%.0f) dx=%.0f dy=%.0f", leaf.container, x, y, tx, ty, dx, dy))
387- var score float64
388- switch direction {
389- case DirectionLeft:
390- if dx >= -focusEpsilon {
391- logging.Warn(fmt.Sprintf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not left)", dx))
392- continue
393- }
394- score = math.Abs(dx)*1000 + math.Abs(dy)
395- case DirectionRight:
396- if dx <= focusEpsilon {
397- logging.Warn(fmt.Sprintf("[workspace] closestLeafFromSubtree: SKIPPED (dx=%.0f not right)", dx))
398- continue
399- }
400- score = math.Abs(dx)*1000 + math.Abs(dy)
401- case DirectionUp:
402- if dy >= -focusEpsilon {
403- logging.Warn(fmt.Sprintf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not above)", dy))
404- continue
405- }
406- score = math.Abs(dy)*1000 + math.Abs(dx)
407- case DirectionDown:
408- if dy <= focusEpsilon {
409- logging.Warn(fmt.Sprintf("[workspace] closestLeafFromSubtree: SKIPPED (dy=%.0f not below)", dy))
410- continue
411- }
412- score = math.Abs(dy)*1000 + math.Abs(dx)
413- default:
414- continue
415- }
416- logging.Debug(fmt.Sprintf("[workspace] closestLeafFromSubtree: ACCEPTED with score=%.0f", score))
417- if score < bestScore {
418- bestScore = score
419- best = leaf
420- }
421- }
422- if best == nil {
423- logging.Warn(fmt.Sprintf("[workspace] closestLeafFromSubtree: no match found, trying boundaryFallback"))
424- return wm.boundaryFallback(node, direction)
425- }
426- logging.Info(fmt.Sprintf("[workspace] closestLeafFromSubtree: returning best=%p", best.container))
427- return best
428-}
429-
430-// boundaryFallback provides fallback navigation when geometric search fails
431-func (wm *WorkspaceManager) boundaryFallback(node *paneNode, direction string) *paneNode {
432- return wm.boundaryFallbackWithDepth(node, direction, 0)
433-}
434-
435-// boundaryFallbackWithDepth provides recursive fallback with depth protection
436-func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction string, depth int) *paneNode {
437- // Prevent infinite recursion - max tree depth should be reasonable
438- const maxDepth = 50
439- if depth > maxDepth {
440- logging.Error(fmt.Sprintf("[workspace] boundaryFallback: max depth exceeded, possible tree corruption"))
441- return nil
442- }
443-
444- if node == nil {
445- return nil
446- }
447- if node.isLeaf {
448- return node
449- }
450- switch direction {
451- case DirectionUp:
452- if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
453- return leaf
454- }
455- return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
456- case DirectionDown:
457- if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
458- return leaf
459- }
460- return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
461- case DirectionLeft:
462- if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil {
463- return leaf
464- }
465- return wm.boundaryFallbackWithDepth(node.left, direction, depth+1)
466- case DirectionRight:
467- if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil {
468- return leaf
469- }
470- return wm.boundaryFallbackWithDepth(node.right, direction, depth+1)
471- default:
472- return nil
473- }
474-}
D internal/app/browser/workspace_navigation_test.go +0 -223
@@ -1,223 +0,0 @@
1-package browser
2-
3-import (
4- "testing"
5-
6- "github.com/diamondburned/gotk4/pkg/gtk/v4"
7-)
8-
9-// TestFocusNeighborVerticalSplit tests basic tree structure validation
10-// Note: Full geometry-based navigation testing requires integration tests with real GTK widgets
11-func TestFocusNeighborVerticalSplit(t *testing.T) {
12- t.Skip("Structural neighbor navigation requires real GTK widgets for geometry calculations")
13-}
14-
15-// TestFocusNeighborHorizontalSplit tests basic tree structure validation
16-// Note: Full geometry-based navigation testing requires integration tests with real GTK widgets
17-func TestFocusNeighborHorizontalSplit(t *testing.T) {
18- t.Skip("Structural neighbor navigation requires real GTK widgets for geometry calculations")
19-}
20-
21-// TestPaneNodeStructure verifies basic pane node relationships
22-func TestPaneNodeStructure(t *testing.T) {
23- root := &paneNode{
24- orientation: gtk.OrientationVertical,
25- isLeaf: false,
26- }
27-
28- child1 := &paneNode{
29- parent: root,
30- isLeaf: true,
31- }
32-
33- child2 := &paneNode{
34- parent: root,
35- isLeaf: true,
36- }
37-
38- root.left = child1
39- root.right = child2
40-
41- // Verify parent-child relationships
42- if child1.parent != root {
43- t.Errorf("child1.parent should be root")
44- }
45-
46- if child2.parent != root {
47- t.Errorf("child2.parent should be root")
48- }
49-
50- // Verify root children
51- if root.left != child1 {
52- t.Errorf("root.left should be child1")
53- }
54-
55- if root.right != child2 {
56- t.Errorf("root.right should be child2")
57- }
58-
59- // Verify leaf status
60- if !child1.isLeaf {
61- t.Errorf("child1 should be a leaf")
62- }
63-
64- if !child2.isLeaf {
65- t.Errorf("child2 should be a leaf")
66- }
67-
68- if root.isLeaf {
69- t.Errorf("root should not be a leaf")
70- }
71-}
72-
73-// TestMapDirection verifies direction mapping to GTK orientation
74-func TestMapDirection(t *testing.T) {
75- tests := []struct {
76- direction string
77- orientation gtk.Orientation
78- existingFirst bool
79- }{
80- {DirectionDown, gtk.OrientationVertical, true},
81- {DirectionUp, gtk.OrientationVertical, false},
82- {DirectionRight, gtk.OrientationHorizontal, true},
83- {DirectionLeft, gtk.OrientationHorizontal, false},
84- }
85-
86- for _, tt := range tests {
87- t.Run(tt.direction, func(t *testing.T) {
88- orientation, existingFirst := mapDirection(tt.direction)
89-
90- if orientation != tt.orientation {
91- t.Errorf("mapDirection(%q) orientation = %v, want %v",
92- tt.direction, orientation, tt.orientation)
93- }
94-
95- if existingFirst != tt.existingFirst {
96- t.Errorf("mapDirection(%q) existingFirst = %v, want %v",
97- tt.direction, existingFirst, tt.existingFirst)
98- }
99- })
100- }
101-}
102-
103-// TestInvalidDirections verifies handling of invalid directions
104-func TestInvalidDirections(t *testing.T) {
105- wm := &WorkspaceManager{}
106-
107- invalidDirections := []string{
108- "",
109- "unknown",
110- "diagonal",
111- }
112-
113- for _, direction := range invalidDirections {
114- t.Run(direction, func(t *testing.T) {
115- success := wm.FocusNeighbor(direction)
116- if success {
117- t.Errorf("Expected FocusNeighbor(%q) to return false, got true", direction)
118- }
119- })
120- }
121-}
122-
123-// TestCaseSensitiveDirections verifies that direction strings are case-insensitive
124-func TestCaseSensitiveDirections(t *testing.T) {
125- wm := &WorkspaceManager{}
126-
127- // These should be accepted (case insensitive), but will return false
128- // because there's no active pane to navigate from
129- validDirections := []string{
130- "UP",
131- "Down",
132- "LEFT",
133- "Right",
134- DirectionUp,
135- DirectionDown,
136- DirectionLeft,
137- DirectionRight,
138- }
139-
140- for _, direction := range validDirections {
141- t.Run(direction, func(t *testing.T) {
142- // Should not panic or error, just return false since no panes exist
143- _ = wm.FocusNeighbor(direction)
144- })
145- }
146-}
147-
148-// TestNavigationCoordinateBug documents the coordinate space bug in navigation
149-//
150-// Bug Description:
151-// When comparing positions for geometric navigation, the code mixes two coordinate spaces:
152-// - Normal panes: return window-absolute coordinates from WidgetGetAllocation()
153-// - Stacked panes: return stack-box-relative coordinates from WidgetGetAllocation()
154-//
155-// This causes navigation to fail because we're comparing coordinates in different spaces.
156-//
157-// Real-world example from logs:
158-//
159-// Normal pane (top half): center=(957, 532) - window coords
160-// Stack pane (bottom): center=(955, 511) - relative to stack box!
161-// Result: dy=-21 → SKIPPED as "not below"
162-//
163-// The fix: Always use stack wrapper allocation for panes inside stacks.
164-func TestNavigationCoordinateBug(t *testing.T) {
165- // Simulate the coordinate math from real user scenario
166- // Window: 1914x1064, vertical split: normal pane (top) + stack (bottom)
167-
168- t.Run("BugValidation_CoordinateMixing", func(t *testing.T) {
169- // Normal pane occupies top half - window-absolute coords
170- normalCX, normalCY := 957.0, 266.0
171-
172- // Stack pane child returns RELATIVE coordinates (bug!)
173- // Its allocation is relative to stack box at y=532
174- stackChildCX, stackChildCY := 957.0, 133.0 // Relative to parent!
175-
176- dy := stackChildCY - normalCY
177- t.Logf("Normal pane: center=(%.0f, %.0f)", normalCX, normalCY)
178- t.Logf("Stack child (buggy relative): center=(%.0f, %.0f)", stackChildCX, stackChildCY)
179- t.Logf("dy = %.0f (negative = appears above)", dy)
180-
181- // Navigation DOWN requires dy > 0, but we get negative!
182- const focusEpsilon = 0.5
183- if dy <= focusEpsilon {
184- t.Logf("✓ BUG: dy=%.0f, navigation DOWN would FAIL", dy)
185- }
186- })
187-
188- t.Run("CorrectBehavior_UseStackWrapper", func(t *testing.T) {
189- // Normal pane (same as before)
190- normalCX, normalCY := 957.0, 266.0
191-
192- // Stack wrapper has correct window-absolute coordinates
193- stackWrapperCX, stackWrapperCY := 957.0, 798.0
194-
195- dy := stackWrapperCY - normalCY
196- t.Logf("Normal pane: center=(%.0f, %.0f)", normalCX, normalCY)
197- t.Logf("Stack wrapper (correct): center=(%.0f, %.0f)", stackWrapperCX, stackWrapperCY)
198- t.Logf("dy = %.0f (positive = correctly below)", dy)
199-
200- const focusEpsilon = 0.5
201- if dy > focusEpsilon {
202- t.Logf("✓ CORRECT: Navigation DOWN would SUCCEED")
203- }
204-
205- // Also verify UP navigation
206- dyUp := normalCY - stackWrapperCY
207- if dyUp < -focusEpsilon {
208- t.Logf("✓ CORRECT: Navigation UP would SUCCEED")
209- }
210- })
211-}
212-
213-// TestGetNavigationAllocation tests the proposed fix helper function
214-// This will be uncommented and updated after implementing the helper
215-func TestGetNavigationAllocation(t *testing.T) {
216- t.Skip("Enable this test after implementing getNavigationAllocation() helper")
217-
218- // TODO: After implementing getNavigationAllocation(), test:
219- // 1. Regular leaf pane → returns pane.container allocation
220- // 2. Pane inside stack → returns stack.stackWrapper allocation
221- // 3. Stack container itself → returns stack.stackWrapper allocation
222- // 4. Nil safety: node=nil, container=nil, parent=nil, stackWrapper=nil
223-}
D internal/app/browser/workspace_operations.go +0 -353
@@ -1,353 +0,0 @@
1-// workspace_operations.go - Workspace operation methods with validation and safety
2-package browser
3-
4-import (
5- "fmt"
6- "github.com/bnema/dumber/internal/logging"
7- "github.com/bnema/dumber/pkg/webkit"
8-)
9-
10-// SplitPane performs a split operation with validation and safety checks
11-func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*paneNode, error) {
12- if wm == nil {
13- return nil, fmt.Errorf("workspace manager is nil")
14- }
15-
16- // Validate direction parameter
17- validDirections := map[string]bool{
18- DirectionLeft: true,
19- DirectionRight: true,
20- DirectionUp: true,
21- DirectionDown: true,
22- }
23- if !validDirections[direction] {
24- return nil, fmt.Errorf("invalid split direction '%s', expected one of: left, right, up, down", direction)
25- }
26-
27- logging.Debug(fmt.Sprintf("[workspace] Starting split operation: target=%p direction=%s", target, direction))
28-
29- // Step 1: Validate geometry constraints (only in DebugBasic+)
30- if wm.debugLevel >= DebugBasic {
31- validation := wm.geometryValidator.ValidateSplit(target, direction)
32- if !validation.IsValid {
33- if wm.debugLevel == DebugFull {
34- return nil, fmt.Errorf("split validation failed: %s", validation.Reason)
35- }
36- logging.Error(fmt.Sprintf("[workspace] WARNING: Split validation failed but allowing operation: %s", validation.Reason))
37- }
38-
39- // Log if re-validation will be needed due to pending widget allocation
40- if validation.RequiresRevalidation {
41- logging.Info(fmt.Sprintf("[workspace] Split validation passed with pending allocation - operation will proceed"))
42- }
43-
44- // Step 3: Validate tree invariants before operation
45- if err := wm.treeValidator.ValidateTree(wm.root, "before_split"); err != nil {
46- if wm.debugLevel == DebugFull {
47- return nil, fmt.Errorf("tree validation failed before split: %w", err)
48- }
49- logging.Error(fmt.Sprintf("[workspace] WARNING: Tree validation failed before split but allowing operation: %v", err))
50- }
51- }
52-
53- // Step 2: Execute directly if we're already on the GTK main thread
54- if webkit.IsMainThread() {
55- logging.Info(fmt.Sprintf("[workspace] Already on main thread, executing split directly"))
56- newNode, err := wm.splitNode(target, direction, nil)
57- if err != nil {
58- return nil, err
59- }
60-
61- if wm.debugLevel >= DebugBasic {
62- if err := wm.treeValidator.ValidateTree(wm.root, "after_split"); err != nil {
63- logging.Error(fmt.Sprintf("[workspace] Tree validation failed after split: %v", err))
64- }
65- }
66-
67- // Tree rebalancing is only needed after close promotions. Splits have correct allocation from GTK.
68-
69- logging.Info(fmt.Sprintf("[workspace] Split operation completed successfully (direct execution): newNode=%p", newNode))
70- return newNode, nil
71- }
72-
73- // Step 3: Not on main thread, marshal via IdleAdd
74- logging.Info(fmt.Sprintf("[workspace] Not on main thread, marshalling split via IdleAdd"))
75- var newNode *paneNode
76- var splitErr error
77- done := make(chan struct{})
78-
79- _ = webkit.IdleAdd(func() bool {
80- newNode, splitErr = wm.splitNode(target, direction, nil)
81- if splitErr == nil {
82- if wm.debugLevel >= DebugBasic {
83- if verr := wm.treeValidator.ValidateTree(wm.root, "after_split"); verr != nil {
84- logging.Error(fmt.Sprintf("[workspace] Tree validation failed after split: %v", verr))
85- }
86- }
87- }
88- close(done)
89- return false
90- })
91-
92- <-done
93-
94- if splitErr != nil {
95- return nil, splitErr
96- }
97-
98- logging.Info(fmt.Sprintf("[workspace] Split operation completed successfully: newNode=%p", newNode))
99- return newNode, nil
100-}
101-
102-// ClosePane performs a close operation with validation and safety checks
103-func (wm *WorkspaceManager) ClosePane(node *paneNode) error {
104- if wm == nil {
105- return fmt.Errorf("workspace manager is nil")
106- }
107-
108- logging.Info(fmt.Sprintf("[workspace] Starting close operation: node=%p", node))
109- wm.paneCloseLogf("start bulletproof close node=%p", node)
110- wm.dumpTreeState("before_close")
111-
112- // Step 1: Validate tree invariants before operation (only in DebugBasic+)
113- if wm.debugLevel >= DebugBasic {
114- if err := wm.treeValidator.ValidateTree(wm.root, "before_close"); err != nil {
115- if wm.debugLevel == DebugFull {
116- return fmt.Errorf("tree validation failed before close: %w", err)
117- }
118- logging.Error(fmt.Sprintf("[workspace] WARNING: Tree validation failed before close but allowing operation: %v", err))
119- }
120- }
121-
122- // Step 3: Check if this is a stacked pane and use enhanced lifecycle management
123- if node.parent != nil && node.parent.isStacked {
124- logging.Debug(fmt.Sprintf("[workspace] Using enhanced stack lifecycle management for close"))
125- return wm.stackLifecycleManager.CloseStackedPaneWithLifecycle(node)
126- }
127-
128- // Step 4: Execute directly when already on the GTK main thread
129- if webkit.IsMainThread() {
130- logging.Info(fmt.Sprintf("[workspace] Already on main thread, executing close directly"))
131-
132- wm.paneCloseLogf("invoking closePane node=%p", node)
133- promoted, err := wm.closePane(node)
134- if err != nil {
135- wm.paneCloseLogf("closePane failed node=%p err=%v", node, err)
136- wm.dumpTreeState("after_close_error")
137- return err
138- }
139-
140- if wm.debugLevel >= DebugBasic {
141- if err := wm.treeValidator.ValidateTree(wm.root, "after_close"); err != nil {
142- logging.Error(fmt.Sprintf("[workspace] Tree validation failed after close: %v", err))
143- }
144- }
145- wm.paneCloseLogf("closePane succeeded node=%p promoted=%p root=%p", node, promoted, wm.root)
146- wm.dumpTreeState("after_close_success")
147-
148- if wm.treeRebalancer != nil {
149- if err := wm.treeRebalancer.RebalanceAfterClose(node, promoted); err != nil {
150- logging.Error(fmt.Sprintf("[workspace] Tree rebalancing failed after close: %v", err))
151- }
152- }
153-
154- logging.Info(fmt.Sprintf("[workspace] Close operation completed successfully (direct execution)"))
155- return nil
156- }
157-
158- // Step 5: Not on main thread, marshal via IdleAdd
159- logging.Info(fmt.Sprintf("[workspace] Not on main thread, marshalling close via IdleAdd"))
160- var promoted *paneNode
161- var closeErr error
162- done := make(chan struct{})
163-
164- _ = webkit.IdleAdd(func() bool {
165- wm.paneCloseLogf("invoking closePane node=%p", node)
166- promoted, closeErr = wm.closePane(node)
167- if closeErr != nil {
168- wm.paneCloseLogf("closePane failed node=%p err=%v", node, closeErr)
169- wm.dumpTreeState("after_close_error")
170- } else {
171- if wm.debugLevel >= DebugBasic {
172- if verr := wm.treeValidator.ValidateTree(wm.root, "after_close"); verr != nil {
173- logging.Error(fmt.Sprintf("[workspace] Tree validation failed after close: %v", verr))
174- }
175- }
176- wm.paneCloseLogf("closePane succeeded node=%p promoted=%p root=%p", node, promoted, wm.root)
177- wm.dumpTreeState("after_close_success")
178-
179- if wm.treeRebalancer != nil {
180- if rerr := wm.treeRebalancer.RebalanceAfterClose(node, promoted); rerr != nil {
181- logging.Error(fmt.Sprintf("[workspace] Tree rebalancing failed after close: %v", rerr))
182- }
183- }
184- }
185- close(done)
186- return false
187- })
188-
189- <-done
190-
191- if closeErr != nil {
192- return closeErr
193- }
194-
195- logging.Info(fmt.Sprintf("[workspace] Close operation completed successfully"))
196- return nil
197-}
198-
199-// StackPane performs a stack operation with validation and safety checks
200-func (wm *WorkspaceManager) StackPane(target *paneNode) (*paneNode, error) {
201- if wm == nil {
202- return nil, fmt.Errorf("workspace manager is nil")
203- }
204-
205- logging.Info(fmt.Sprintf("[workspace] Starting stack operation: target=%p", target))
206-
207- // Step 1: Validate stack operation constraints (only in DebugBasic+)
208- if wm.debugLevel >= DebugBasic {
209- validation := wm.geometryValidator.ValidateStackOperation(target)
210- if !validation.IsValid {
211- if wm.debugLevel == DebugFull {
212- return nil, fmt.Errorf("stack validation failed: %s", validation.Reason)
213- }
214- logging.Error(fmt.Sprintf("[workspace] WARNING: Stack validation failed but allowing operation: %s", validation.Reason))
215- }
216-
217- // Log if re-validation will be needed due to pending widget allocation
218- if validation.RequiresRevalidation {
219- logging.Info(fmt.Sprintf("[workspace] Stack validation passed with pending allocation - operation will proceed"))
220- }
221- }
222-
223- // Step 2: Validate tree invariants before operation (only in DebugBasic+)
224- if wm.debugLevel >= DebugBasic {
225- if err := wm.treeValidator.ValidateTree(wm.root, "before_stack"); err != nil {
226- if wm.debugLevel == DebugFull {
227- return nil, fmt.Errorf("tree validation failed before stack: %w", err)
228- }
229- logging.Error(fmt.Sprintf("[workspace] WARNING: Tree validation failed before stack but allowing operation: %v", err))
230- }
231- }
232-
233- // Step 3: Execute directly if already on the GTK main thread
234- if webkit.IsMainThread() {
235- logging.Info(fmt.Sprintf("[workspace] Already on main thread, executing stack directly"))
236- newNode, err := wm.stackedPaneManager.StackPane(target)
237- if err != nil {
238- return nil, err
239- }
240-
241- if wm.debugLevel >= DebugBasic {
242- if err := wm.treeValidator.ValidateTree(wm.root, "after_stack"); err != nil {
243- logging.Error(fmt.Sprintf("[workspace] Tree validation failed after stack: %v", err))
244- }
245- }
246-
247- logging.Info(fmt.Sprintf("[workspace] Stack operation completed successfully (direct execution): newNode=%p", newNode))
248- return newNode, nil
249- }
250-
251- // Step 4: Not on main thread, marshal via IdleAdd
252- logging.Info(fmt.Sprintf("[workspace] Not on main thread, marshalling stack via IdleAdd"))
253- var newNode *paneNode
254- var stackErr error
255- done := make(chan struct{})
256-
257- _ = webkit.IdleAdd(func() bool {
258- newNode, stackErr = wm.stackedPaneManager.StackPane(target)
259- if stackErr == nil {
260- if wm.debugLevel >= DebugBasic {
261- if verr := wm.treeValidator.ValidateTree(wm.root, "after_stack"); verr != nil {
262- logging.Error(fmt.Sprintf("[workspace] Tree validation failed after stack: %v", verr))
263- }
264- }
265- }
266- close(done)
267- return false
268- })
269-
270- <-done
271-
272- if stackErr != nil {
273- return nil, stackErr
274- }
275-
276- logging.Info(fmt.Sprintf("[workspace] Stack operation completed successfully: newNode=%p", newNode))
277- return newNode, nil
278-}
279-
280-// EnableEnhancedMode enables all enhanced validation features
281-func (wm *WorkspaceManager) EnableEnhancedMode() {
282- if wm.treeValidator != nil {
283- wm.treeValidator.Enable()
284- }
285- if wm.treeRebalancer != nil {
286- wm.treeRebalancer.Enable()
287- }
288- logging.Info(fmt.Sprintf("[workspace] Enhanced validation mode enabled"))
289-}
290-
291-// DisableEnhancedMode disables enhanced features for performance
292-func (wm *WorkspaceManager) DisableEnhancedMode() {
293- if wm.treeValidator != nil {
294- wm.treeValidator.Disable()
295- }
296- if wm.treeRebalancer != nil {
297- wm.treeRebalancer.Disable()
298- }
299- logging.Info(fmt.Sprintf("[workspace] Enhanced validation mode disabled"))
300-}
301-
302-// SetEnhancedDebugMode enables/disables debug mode for all enhanced validation components
303-func (wm *WorkspaceManager) SetEnhancedDebugMode(debug bool) {
304- if wm.treeValidator != nil {
305- wm.treeValidator.SetDebugMode(debug)
306- }
307- if wm.geometryValidator != nil {
308- wm.geometryValidator.SetDebugMode(debug)
309- }
310- logging.Debug(fmt.Sprintf("[workspace] Enhanced debug mode set to: %v", debug))
311-}
312-
313-// GetEnhancedStats returns comprehensive statistics about all enhanced validation components
314-func (wm *WorkspaceManager) GetEnhancedStats() map[string]interface{} {
315- stats := make(map[string]interface{})
316-
317- if wm.treeValidator != nil {
318- stats["tree_validation"] = wm.treeValidator.GetValidationStats()
319- }
320-
321- if wm.treeRebalancer != nil {
322- stats["tree_rebalancing"] = wm.treeRebalancer.GetRebalancingStats()
323- }
324-
325- // Geometry validator no longer tracks stats - simplified
326-
327- return stats
328-}
329-
330-// ValidateWorkspaceIntegrity performs a comprehensive validation of the workspace
331-func (wm *WorkspaceManager) ValidateWorkspaceIntegrity() error {
332- logging.Info(fmt.Sprintf("[workspace] Performing comprehensive workspace integrity check"))
333-
334- // Tree structure validation
335- if wm.treeValidator != nil {
336- if err := wm.treeValidator.ValidateTree(wm.root, "integrity_check"); err != nil {
337- return fmt.Errorf("tree validation failed: %w", err)
338- }
339- }
340-
341- // Geometry validation simplified - ValidateWorkspaceLayout removed
342-
343- logging.Info(fmt.Sprintf("[workspace] Workspace integrity check completed successfully"))
344- return nil
345-}
346-
347-// ShutdownEnhancedComponents gracefully shuts down all enhanced validation components
348-func (wm *WorkspaceManager) ShutdownEnhancedComponents() {
349- logging.Info(fmt.Sprintf("[workspace] Shutting down enhanced validation components"))
350-
351- // Other components don't require explicit shutdown currently
352- logging.Info(fmt.Sprintf("[workspace] Enhanced validation components shutdown complete"))
353-}
D internal/app/browser/workspace_pane_mode.go +0 -219
@@ -1,219 +0,0 @@
1-// workspace_pane_mode.go - Backend-driven pane mode implementation
2-package browser
3-
4-import (
5- "fmt"
6-
7- "github.com/bnema/dumber/internal/logging"
8-)
9-
10-// EnterPaneMode activates pane mode on the currently focused pane
11-// This is called directly from the keyboard bridge, not from JavaScript
12-func (wm *WorkspaceManager) EnterPaneMode() {
13- // Note: Caller (RegisterPaneModeHandler) ensures this is called on app.workspace (active tab)
14-
15- wm.paneMutex.Lock()
16- defer wm.paneMutex.Unlock()
17-
18- // Get the currently focused pane
19- activeNode := wm.GetActiveNode()
20- if activeNode == nil {
21- logging.Error(fmt.Sprintf("[pane-mode] Cannot enter pane mode: no active pane"))
22- return
23- }
24-
25- // Already in pane mode on this pane?
26- if wm.paneModeActive && wm.paneModeActivePane == activeNode {
27- logging.Info(fmt.Sprintf("[pane-mode] Pane mode already active on this pane"))
28- return
29- }
30-
31- logging.Debug(fmt.Sprintf("[pane-mode] Entering pane mode on pane %p (workspace=%p)", activeNode, wm))
32-
33- wm.paneModeActive = true
34- wm.paneModeActivePane = activeNode
35-
36- if activeNode.pane != nil && activeNode.pane.webView != nil {
37- wm.paneModeSource = activeNode.pane.webView
38- }
39-
40- // Apply visual border to workspace root
41- wm.applyPaneModeBorder()
42-
43- // Notify JavaScript for UI feedback (visual indicators)
44- wm.dispatchPaneModeEvent("entered", "")
45-}
46-
47-// ExitPaneMode deactivates pane mode
48-func (wm *WorkspaceManager) ExitPaneMode(reason string) {
49- wm.paneMutex.Lock()
50- defer wm.paneMutex.Unlock()
51-
52- if !wm.paneModeActive {
53- return
54- }
55-
56- logging.Info(fmt.Sprintf("[pane-mode] Exiting pane mode: %s", reason))
57-
58- wm.paneModeActive = false
59- wm.paneModeActivePane = nil
60- wm.paneModeSource = nil
61-
62- // Remove visual border from workspace root
63- wm.removePaneModeBorder()
64-
65- // Notify JavaScript
66- wm.dispatchPaneModeEvent("exited", reason)
67-}
68-
69-// HandlePaneAction processes pane mode actions (close, split, etc.)
70-// Called from keyboard bridge when user presses action keys in pane mode
71-func (wm *WorkspaceManager) HandlePaneAction(action string) {
72- // Note: No IsInActiveTab check here - caller (RegisterPaneModeHandler) ensures
73- // this is called on app.workspace (active tab's workspace)
74-
75- wm.paneMutex.Lock()
76-
77- if !wm.paneModeActive {
78- wm.paneMutex.Unlock()
79- logging.Info(fmt.Sprintf("[pane-mode] Ignoring action '%s': pane mode not active", action))
80- return
81- }
82-
83- activePane := wm.paneModeActivePane
84- wm.paneMutex.Unlock()
85-
86- if activePane == nil {
87- logging.Info(fmt.Sprintf("[pane-mode] Ignoring action '%s': no active pane", action))
88- wm.ExitPaneMode("no-active-pane")
89- return
90- }
91-
92- logging.Debug(fmt.Sprintf("[pane-mode] Handling action '%s' on pane %p", action, activePane))
93-
94- switch action {
95- case "close":
96- wm.handlePaneClose(activePane)
97- case "split-right", "split-left", "split-up", "split-down":
98- wm.handlePaneSplit(activePane, action)
99- case "stack":
100- wm.handlePaneStack(activePane)
101- default:
102- logging.Warn(fmt.Sprintf("[pane-mode] Unknown action: %s", action))
103- }
104-
105- // Exit pane mode after action
106- wm.ExitPaneMode(action)
107-}
108-
109-// handlePaneClose closes the pane that has pane mode active
110-func (wm *WorkspaceManager) handlePaneClose(node *paneNode) {
111- if node == nil {
112- return
113- }
114-
115- logging.Debug(fmt.Sprintf("[pane-mode] Closing pane %p", node))
116-
117- // Notify JavaScript first so it can show toast
118- wm.dispatchPaneModeEvent("action", "close")
119-
120- // Close the pane
121- if err := wm.ClosePane(node); err != nil {
122- logging.Error(fmt.Sprintf("[pane-mode] Failed to close pane: %v", err))
123- }
124-}
125-
126-// handlePaneSplit splits the pane in the specified direction
127-func (wm *WorkspaceManager) handlePaneSplit(node *paneNode, action string) {
128- if node == nil {
129- return
130- }
131-
132- // Extract direction from action (e.g., "split-right" -> DirectionRight)
133- var direction string
134- switch action {
135- case "split-right":
136- direction = DirectionRight
137- case "split-left":
138- direction = DirectionLeft
139- case "split-up":
140- direction = DirectionUp
141- case "split-down":
142- direction = DirectionDown
143- default:
144- logging.Error(fmt.Sprintf("[pane-mode] Invalid split action: %s", action))
145- return
146- }
147-
148- logging.Debug(fmt.Sprintf("[pane-mode] Splitting pane %p in direction %s", node, direction))
149-
150- // Notify JavaScript first so it can show toast
151- wm.dispatchPaneModeEvent("action", action)
152-
153- // Perform the split (pass nil so it creates a fresh pane, not popup mode)
154- newNode, err := wm.splitNode(node, direction, nil)
155- if err != nil {
156- logging.Error(fmt.Sprintf("[pane-mode] Failed to split pane: %v", err))
157- return
158- }
159-
160- // Load initial URL in the new pane so scripts execute
161- wm.clonePaneState(node, newNode)
162-
163- logging.Debug(fmt.Sprintf("[pane-mode] Split successful: direction=%s", direction))
164-}
165-
166-// handlePaneStack creates a stacked pane
167-func (wm *WorkspaceManager) handlePaneStack(node *paneNode) {
168- if node == nil {
169- return
170- }
171-
172- logging.Debug(fmt.Sprintf("[pane-mode] Stacking pane %p", node))
173-
174- // Notify JavaScript first so it can show toast
175- wm.dispatchPaneModeEvent("action", "stack")
176-
177- // Perform the stack operation
178- newNode, err := wm.stackedPaneManager.StackPane(node)
179- if err != nil {
180- logging.Error(fmt.Sprintf("[pane-mode] Failed to stack pane: %v", err))
181- return
182- }
183-
184- // Load initial URL in the new pane so scripts execute
185- wm.clonePaneState(node, newNode)
186-
187- logging.Info(fmt.Sprintf("[pane-mode] Stack successful"))
188-}
189-
190-// dispatchPaneModeEvent sends a pane mode event to JavaScript for UI updates
191-func (wm *WorkspaceManager) dispatchPaneModeEvent(event string, detail string) {
192- if wm.paneModeActivePane == nil || wm.paneModeActivePane.pane == nil || wm.paneModeActivePane.pane.webView == nil {
193- return
194- }
195-
196- view := wm.paneModeActivePane.pane.webView
197- data := map[string]interface{}{
198- "event": event,
199- "detail": detail,
200- }
201-
202- if err := view.DispatchCustomEvent("dumber:pane-mode", data); err != nil {
203- logging.Error(fmt.Sprintf("[pane-mode] Failed to dispatch event: %v", err))
204- }
205-}
206-
207-// applyPaneModeBorder applies pane mode visual indicator using overlay
208-func (wm *WorkspaceManager) applyPaneModeBorder() {
209- if wm.app != nil && wm.app.tabManager != nil {
210- wm.app.tabManager.showPaneModeBorder()
211- }
212-}
213-
214-// removePaneModeBorder removes the pane mode visual indicator
215-func (wm *WorkspaceManager) removePaneModeBorder() {
216- if wm.app != nil && wm.app.tabManager != nil {
217- wm.app.tabManager.hidePaneModeBorder()
218- }
219-}
D internal/app/browser/workspace_pane_ops.go +0 -1323
@@ -1,1323 +0,0 @@
1-// workspace_pane_ops.go - Pane creation, splitting, closing and tree operations
2-package browser
3-
4-import (
5- "errors"
6- "fmt"
7-
8- "github.com/diamondburned/gotk4/pkg/gdk/v4"
9- "github.com/diamondburned/gotk4/pkg/gtk/v4"
10-
11- "github.com/bnema/dumber/internal/logging"
12- "github.com/bnema/dumber/pkg/webkit"
13-)
14-
15-// closeContext manages the context for a pane close operation
16-type closeContext struct {
17- wm *WorkspaceManager
18- target *paneNode
19- remaining int
20- err error
21- generation uint
22-}
23-
24-// beginClose initializes a close context with validation
25-func (wm *WorkspaceManager) beginClose(node *paneNode) closeContext {
26- ctx := closeContext{wm: wm, target: node, generation: wm.nextCleanupGeneration()}
27- switch {
28- case wm == nil:
29- ctx.err = errors.New("workspace manager nil")
30- case node == nil || !node.isLeaf:
31- ctx.err = errors.New("invalid close target")
32- case node.pane == nil || node.pane.webView == nil:
33- ctx.err = errors.New("close target missing webview")
34- default:
35- ctx.remaining = len(wm.app.panes)
36- }
37- return ctx
38-}
39-
40-// Generation returns the cleanup generation for this context
41-func (ctx closeContext) Generation() uint {
42- return ctx.generation
43-}
44-
45-// finish handles cleanup operations after close completion
46-func (ctx closeContext) finish() {
47- if ctx.err == nil {
48- ctx.wm.updateMainPane()
49- }
50-}
51-
52-// collectLeaves returns all leaf nodes in the workspace tree
53-func (wm *WorkspaceManager) collectLeaves() []*paneNode {
54- return wm.collectLeavesFrom(wm.root)
55-}
56-
57-// collectLeavesFromWithDirection collects leaves from a subtree with direction-aware stack entry
58-func (wm *WorkspaceManager) collectLeavesFromWithDirection(node *paneNode, direction string) []*paneNode {
59- var leaves []*paneNode
60- visited := make(map[*paneNode]bool)
61-
62- var walk func(*paneNode, int)
63- walk = func(n *paneNode, depth int) {
64- // Prevent infinite recursion and cycles
65- const maxDepth = 50
66- if n == nil || depth > maxDepth {
67- return
68- }
69- if visited[n] {
70- logging.Error(fmt.Sprintf("[workspace] collectLeavesFromWithDirection: cycle detected in tree"))
71- return
72- }
73- visited[n] = true
74-
75- if n.isLeaf {
76- leaves = append(leaves, n)
77- return
78- }
79-
80- // Handle stacked panes - select entry pane based on direction
81- if n.isStacked && len(n.stackedPanes) > 0 {
82- var entryIndex int
83- switch direction {
84- case DirectionDown:
85- // Coming from above - enter at first pane
86- entryIndex = 0
87- case DirectionUp:
88- // Coming from below - enter at last pane
89- entryIndex = len(n.stackedPanes) - 1
90- default:
91- // For horizontal directions, use active pane
92- entryIndex = n.activeStackIndex
93- if entryIndex < 0 || entryIndex >= len(n.stackedPanes) {
94- entryIndex = 0
95- }
96- }
97- if entryIndex >= 0 && entryIndex < len(n.stackedPanes) {
98- walk(n.stackedPanes[entryIndex], depth+1)
99- }
100- return
101- }
102-
103- // Handle regular split nodes
104- walk(n.left, depth+1)
105- walk(n.right, depth+1)
106- }
107- walk(node, 0)
108- return leaves
109-}
110-
111-// collectLeavesFrom collects all leaf nodes from a given subtree
112-func (wm *WorkspaceManager) collectLeavesFrom(node *paneNode) []*paneNode {
113- var leaves []*paneNode
114- visited := make(map[*paneNode]bool)
115-
116- var walk func(*paneNode, int)
117- walk = func(n *paneNode, depth int) {
118- // Prevent infinite recursion and cycles
119- const maxDepth = 50
120- if n == nil || depth > maxDepth {
121- return
122- }
123- if visited[n] {
124- logging.Error(fmt.Sprintf("[workspace] collectLeavesFrom: cycle detected in tree"))
125- return
126- }
127- visited[n] = true
128-
129- if n.isLeaf {
130- leaves = append(leaves, n)
131- return
132- }
133-
134- // Handle stacked panes - only include the currently ACTIVE pane as a leaf
135- // This prevents multiple CSS classes and ensures correct focus management
136- if n.isStacked && len(n.stackedPanes) > 0 {
137- activeIndex := n.activeStackIndex
138- if activeIndex >= 0 && activeIndex < len(n.stackedPanes) {
139- // Only the active pane in the stack counts as a leaf for focus/navigation
140- activePaneInStack := n.stackedPanes[activeIndex]
141- walk(activePaneInStack, depth+1)
142- }
143- return
144- }
145-
146- // Handle regular split nodes
147- walk(n.left, depth+1)
148- walk(n.right, depth+1)
149- }
150- walk(node, 0)
151- return leaves
152-}
153-
154-// createWebView creates a new WebView with the workspace's configuration
155-func (wm *WorkspaceManager) createWebView() (*webkit.WebView, error) {
156- if wm == nil || wm.createWebViewFn == nil {
157- return nil, errors.New("workspace manager missing webview factory")
158- }
159- return wm.createWebViewFn()
160-}
161-
162-// createPane creates a new BrowserPane for the given WebView
163-func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, error) {
164- if wm == nil || wm.createPaneFn == nil {
165- return nil, errors.New("workspace manager missing pane factory")
166- }
167- pane, err := wm.createPaneFn(view)
168- if err != nil {
169- return nil, err
170- }
171-
172- // Register the new WebView with the tab manager for progress tracking.
173- if wm.app != nil && wm.app.tabManager != nil {
174- wm.app.tabManager.registerWebViewForWorkspace(view, wm)
175- }
176-
177- return pane, nil
178-}
179-
180-// clonePaneState sets up a new pane with cloned state from another pane
181-func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
182- if wm == nil || target == nil {
183- logging.Debug(fmt.Sprintf("[workspace] clonePaneState: wm or target is nil"))
184- return
185- }
186- if target.pane == nil || target.pane.webView == nil {
187- logging.Debug(fmt.Sprintf("[workspace] clonePaneState: target pane or webView is nil"))
188- return
189- }
190-
191- // Load about:blank and auto-open omnibox
192- const blankURL = "about:blank"
193-
194- logging.Debug(fmt.Sprintf("[workspace] clonePaneState: loading %s in pane %p", blankURL, target.pane))
195- if err := target.pane.webView.LoadURL(blankURL); err != nil {
196- logging.Error(fmt.Sprintf("[workspace] failed to load blank page in new pane: %v", err))
197- } else {
198- logging.Debug(fmt.Sprintf("[workspace] successfully initiated load of %s", blankURL))
199- }
200-}
201-
202-// safelyDetachControllersBeforeReparent marks nodes for controller reattachment after reparenting.
203-// GTK4 automatically removes controllers when widgets are unparented, so we just clear our
204-// references and mark for reattachment. Manually removing controllers causes GTK bloom filter
205-// corruption (double-removal assertion failure).
206-func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode) {
207- if wm == nil || node == nil {
208- return
209- }
210-
211- markForDetachment := func(target *paneNode) {
212- if target == nil {
213- return
214- }
215-
216- // Just mark for reattachment and clear our references
217- // GTK4 will auto-remove controllers during unparent
218- if target.hoverToken != nil {
219- target.pendingHoverReattach = true
220- target.hoverToken = nil // Clear reference, don't manually remove
221- logging.Debug(fmt.Sprintf("[workspace] Marked hover controller for reattach on pane %p (GTK will auto-remove)", target))
222- }
223-
224- if target.focusControllerToken != 0 {
225- target.pendingFocusReattach = true
226- target.focusControllerToken = 0 // Clear reference, don't manually remove
227- logging.Debug(fmt.Sprintf("[workspace] Marked focus controller for reattach on pane %p (GTK will auto-remove)", target))
228- }
229- }
230-
231- if node.isStacked {
232- for _, child := range node.stackedPanes {
233- markForDetachment(child)
234- }
235- return
236- }
237-
238- if node.isLeaf {
239- markForDetachment(node)
240- }
241-}
242-
243-// closeCurrentPane closes the currently focused pane
244-func (wm *WorkspaceManager) closeCurrentPane() {
245- if wm == nil || wm.GetActiveNode() == nil {
246- return
247- }
248- if err := wm.ClosePane(wm.GetActiveNode()); err != nil {
249- logging.Error(fmt.Sprintf("[workspace] close current pane failed: %v", err))
250- }
251-}
252-
253-// leftmostLeaf finds the leftmost leaf node in a subtree
254-func (wm *WorkspaceManager) leftmostLeaf(node *paneNode) *paneNode {
255- for node != nil && !node.isLeaf {
256- if node.isStacked {
257- if node.activeStackIndex >= 0 && node.activeStackIndex < len(node.stackedPanes) {
258- candidate := node.stackedPanes[node.activeStackIndex]
259- if candidate != nil {
260- return candidate
261- }
262- }
263- if len(node.stackedPanes) > 0 {
264- return node.stackedPanes[0]
265- }
266- return nil
267- }
268- if node.left != nil {
269- node = node.left
270- continue
271- }
272- node = node.right
273- }
274- return node
275-}
276-
277-// findReplacementRoot finds a suitable replacement when closing the current root pane
278-func (wm *WorkspaceManager) findReplacementRoot(excludeNode *paneNode) *paneNode {
279- if wm == nil || wm.root == nil {
280- return nil
281- }
282-
283- // If root is being closed and there are other panes, find a replacement
284- leaves := wm.collectLeaves()
285- for _, leaf := range leaves {
286- if leaf != excludeNode && leaf != nil && leaf.isLeaf {
287- // Find the topmost ancestor that's not the current root
288- current := leaf
289- for current.parent != nil && current.parent != wm.root {
290- current = current.parent
291- }
292-
293- // If this leaf is a direct child of root, or root only has one subtree,
294- // we can promote this subtree to be the new root
295- if current.parent == wm.root {
296- // If the sibling is being excluded, promote this subtree
297- var sibling *paneNode
298- if wm.root.left == current {
299- sibling = wm.root.right
300- } else {
301- sibling = wm.root.left
302- }
303-
304- if sibling == excludeNode {
305- // The sibling is being closed, so promote this subtree
306- return current
307- }
308- }
309-
310- // Otherwise, return the first suitable leaf
311- return leaf
312- }
313- }
314-
315- return nil
316-}
317-
318-// updateMainPane updates the main pane reference based on current state
319-func (wm *WorkspaceManager) updateMainPane() {
320- if len(wm.app.panes) == 1 {
321- if leaf := wm.viewToNode[wm.app.panes[0].webView]; leaf != nil {
322- wm.mainPane = leaf
323- }
324- return
325- }
326-
327- if wm.mainPane == nil || !wm.mainPane.isLeaf {
328- if wm.GetActiveNode() != nil && wm.GetActiveNode().isLeaf {
329- wm.mainPane = wm.GetActiveNode()
330- }
331- }
332-}
333-
334-// Helper methods to support clean pane removal from workspace tracking
335-// These methods implement the interface expected by BrowserPane.CleanupFromWorkspace()
336-
337-// removeFromMaps removes a WebView from all workspace tracking maps
338-func (wm *WorkspaceManager) removeFromMaps(webView *webkit.WebView) {
339- if wm == nil || webView == nil {
340- return
341- }
342-
343- delete(wm.viewToNode, webView)
344- delete(wm.lastSplitMsg, webView)
345- delete(wm.lastExitMsg, webView)
346- logging.Debug(fmt.Sprintf("[workspace] removed webview %p from tracking maps", webView))
347-}
348-
349-// removeFromAppPanes removes a BrowserPane from the app.panes slice
350-func (wm *WorkspaceManager) removeFromAppPanes(pane *BrowserPane) {
351- if wm == nil || wm.app == nil || pane == nil {
352- return
353- }
354-
355- for i, p := range wm.app.panes {
356- if p == pane {
357- wm.app.panes = append(wm.app.panes[:i], wm.app.panes[i+1:]...)
358- logging.Debug(fmt.Sprintf("[workspace] removed pane from app.panes slice, remaining: %d", len(wm.app.panes)))
359- return
360- }
361- }
362-}
363-
364-// insertPopupPane has been REMOVED - all popup insertion now uses splitNode() with existing pane parameter.
365-// This eliminates widget reparenting of existing WebViews, which was causing rendering corruption.
366-// See splitNode() for the unified implementation.
367-
368-// TODO(#8): Refactor this function to reduce cyclomatic complexity (65 -> <30)
369-//
370-// splitNode splits a target pane into two panes in the specified direction
371-// splitNode splits a target pane into two panes in the specified direction.
372-// If existingPane is provided, it will be used instead of creating a new WebView.
373-// This allows reusing the function for both normal splits (nil) and popup insertion (popup pane).
374-func (wm *WorkspaceManager) splitNode(target *paneNode, direction string, existingPane *BrowserPane) (*paneNode, error) {
375- if target == nil || !target.isLeaf || target.pane == nil {
376- return nil, errors.New("split target must be a leaf pane")
377- }
378-
379- // IMPORTANT: If the target is inside a stacked pane, we need to split AROUND the stack,
380- // not the individual pane or the stack itself. We create a new split at the stack's parent level.
381- originalTarget := target
382- var stackContainer *paneNode
383- if target.parent != nil && target.parent.isStacked {
384- // Target is in a stack - we'll create the split around the entire stack
385- stackContainer = target.parent
386- logging.Debug(fmt.Sprintf("[workspace] target is in stack, will split around the stack: originalTarget=%p stackContainer=%p", originalTarget, stackContainer))
387- }
388-
389- // Determine what we're actually splitting
390- var splitTarget *paneNode
391- var splitTargetContainer gtk.Widgetter
392-
393- if stackContainer != nil {
394- // Case: splitting from inside a stack - we split around the entire stack
395- splitTarget = stackContainer
396- splitTargetContainer = stackContainer.container
397- logging.Debug(fmt.Sprintf("[workspace] splitting around stack: stackContainer=%p", splitTarget))
398- } else {
399- // Case: normal split from a simple pane
400- splitTarget = target
401- splitTargetContainer = target.container
402- logging.Info(fmt.Sprintf("[workspace] normal split from pane: target=%p", splitTarget))
403- }
404-
405- // Use provided pane or create new one
406- var newPane *BrowserPane
407-
408- if existingPane != nil {
409- // Reuse existing pane (for popup insertion)
410- newPane = existingPane
411- logging.Debug(fmt.Sprintf("[workspace] Using existing pane for split: %p", newPane))
412- } else {
413- // Create new WebView and pane (for normal splits)
414- newView, err := wm.createWebView()
415- if err != nil {
416- return nil, err
417- }
418-
419- newPane, err = wm.createPane(newView)
420- if err != nil {
421- return nil, err
422- }
423- logging.Info(fmt.Sprintf("[workspace] Created new pane for split: %p", newPane))
424- }
425-
426- if handler := newPane.MessageHandler(); handler != nil {
427- handler.SetWorkspaceObserver(wm)
428- }
429-
430- newContainer := newPane.webView.RootWidget()
431- if newContainer == nil {
432- return nil, errors.New("new pane missing container")
433- }
434- webkit.WidgetSetHExpand(newContainer, true)
435- webkit.WidgetSetVExpand(newContainer, true)
436-
437- // Use the determined split target container
438- if splitTargetContainer == nil {
439- return nil, errors.New("split target missing container")
440- }
441-
442- // Only apply GTK operations if this is a simple widget (leaf pane)
443- // For stacked containers, we need to be more careful
444- if splitTarget.isLeaf {
445- if splitTargetContainer != nil {
446- webkit.WidgetSetHExpand(splitTargetContainer, true)
447- webkit.WidgetSetVExpand(splitTargetContainer, true)
448- }
449- } else if splitTarget.isStacked {
450- // Stack containers need the same setup as regular panes for proper splitting
451- if splitTargetContainer != nil {
452- webkit.WidgetSetHExpand(splitTargetContainer, true)
453- webkit.WidgetSetVExpand(splitTargetContainer, true)
454- logging.Debug(fmt.Sprintf("[workspace] configured stack wrapper for split: %p", splitTargetContainer))
455- }
456- }
457-
458- orientation, existingFirst := mapDirection(direction)
459- logging.Debug(fmt.Sprintf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v isPopup=%v",
460- direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked, existingPane != nil))
461-
462- paned := gtk.NewPaned(orientation)
463- if paned == nil {
464- return nil, errors.New("failed to create GtkPaned")
465- }
466- paned.SetHExpand(true)
467- paned.SetVExpand(true)
468-
469- // Allow both children to resize; avoids shrinking existing pane to zero during root splits
470- paned.SetResizeStartChild(true)
471- paned.SetResizeEndChild(true)
472-
473- newLeaf := &paneNode{
474- pane: newPane,
475- isLeaf: true,
476- }
477- wm.initializePaneWidgets(newLeaf, newContainer)
478-
479- // After wrapping the WebView in an overlay, use the overlay container for layout operations.
480- newContainer = newLeaf.container
481- if newContainer == nil {
482- return nil, errors.New("new pane missing container overlay")
483- }
484- webkit.WidgetSetHExpand(newContainer, true)
485- webkit.WidgetSetVExpand(newContainer, true)
486-
487- split := &paneNode{
488- parent: splitTarget.parent,
489- orientation: orientation,
490- isLeaf: false,
491- }
492- wm.initializePaneWidgets(split, paned)
493-
494- parent := split.parent
495-
496- // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
497- // NOTE: The active class is correctly removed here - focus will transfer to the NEW pane
498- // The focus state machine will apply the active class to the new pane via SetActivePane()
499- if splitTargetContainer != nil {
500- // Remove active border class if present (prevents GTK bloom filter corruption during unparent)
501- if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) {
502- webkit.WidgetRemoveCSSClass(splitTargetContainer, activePaneClass)
503- logging.Debug(fmt.Sprintf("[workspace] removed active class from split target (will transfer to new pane)"))
504- }
505- }
506-
507- // Prevent GTK from auto-dropping controllers while the widget is temporarily unparented.
508- wm.safelyDetachControllersBeforeReparent(splitTarget)
509-
510- // For non-root splits, unparent from parent paned
511- if parent != nil && parent.container != nil {
512- // Split target has a parent paned - remove it (automatically unparents in GTK4)
513- logging.Debug(fmt.Sprintf("[workspace] unparenting split target container=%p from parent paned=%p", splitTargetContainer, parent.container))
514- if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
515- if parent.left == splitTarget {
516- parentPaned.SetStartChild(nil)
517- } else if parent.right == splitTarget {
518- parentPaned.SetEndChild(nil)
519- }
520- // GTK4 automatically unparents when we set paned child to nil - no manual unparent needed
521- webkit.WidgetQueueAllocate(parent.container)
522- }
523- }
524- // NOTE: For root splits (parent == nil), we DON'T unparent here.
525- // We'll unparent immediately before adding to paned below to minimize orphan time.
526-
527- // Set up the tree structure first
528- if existingFirst {
529- split.left = splitTarget
530- split.right = newLeaf
531- } else {
532- split.left = newLeaf
533- split.right = splitTarget
534- }
535-
536- splitTarget.parent = split
537- newLeaf.parent = split
538-
539- // Update tree root/parent references
540- if parent == nil {
541- wm.root = split
542- } else {
543- if parent.left == splitTarget {
544- parent.left = split
545- } else if parent.right == splitTarget {
546- parent.right = split
547- }
548- }
549-
550- // GTK4 handles widget operations automatically - no need to force redraw
551-
552- // Handle root split: attach paned to ContentArea (tab system is always present)
553- if parent == nil {
554- // ROOT SPLIT: Manipulate TabManager.ContentArea
555- if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
556- logging.Error(fmt.Sprintf("[workspace] ERROR: Cannot perform root split - tab manager not initialized"))
557- return nil, fmt.Errorf("tab manager not initialized")
558- }
559-
560- contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
561- if !ok || contentBox == nil {
562- logging.Error(fmt.Sprintf("[workspace] ERROR: ContentArea is not a Box"))
563- return nil, fmt.Errorf("ContentArea is not a Box")
564- }
565-
566- // Remove the old workspace root from ContentArea
567- contentBox.Remove(splitTargetContainer)
568- logging.Debug(fmt.Sprintf("[workspace] ROOT SPLIT: removed old root %p from ContentArea", splitTargetContainer))
569-
570- // Attach the new paned to ContentArea FIRST
571- contentBox.Append(paned)
572- logging.Debug(fmt.Sprintf("[workspace] ROOT SPLIT: attached paned %p to ContentArea", paned))
573-
574- // Now add children to the paned - GTK will handle reparenting automatically
575- if existingFirst {
576- paned.SetStartChild(splitTargetContainer)
577- paned.SetEndChild(newContainer)
578- logging.Debug(fmt.Sprintf("[workspace] ROOT SPLIT: added splitTarget=%p (start), new=%p (end)", splitTargetContainer, newContainer))
579- } else {
580- paned.SetStartChild(newContainer)
581- paned.SetEndChild(splitTargetContainer)
582- logging.Debug(fmt.Sprintf("[workspace] ROOT SPLIT: added new=%p (start), splitTarget=%p (end)", newContainer, splitTargetContainer))
583- }
584- } else {
585- // NON-ROOT SPLIT: Standard approach
586-
587- // Add both containers to the new paned
588- if existingFirst {
589- if splitTargetContainer != nil {
590- paned.SetStartChild(splitTargetContainer)
591- paned.SetEndChild(newContainer)
592- logging.Debug(fmt.Sprintf("[workspace] added splitTarget=%p as start child, new=%p as end child", splitTargetContainer, newContainer))
593- }
594- } else {
595- if splitTargetContainer != nil {
596- paned.SetStartChild(newContainer)
597- paned.SetEndChild(splitTargetContainer)
598- logging.Debug(fmt.Sprintf("[workspace] added new=%p as start child, splitTarget=%p as end child", newContainer, splitTargetContainer))
599- }
600- }
601-
602- // Attach the new paned to its parent
603- if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
604- if parent.left == split {
605- parentPaned.SetStartChild(paned)
606- } else if parent.right == split {
607- parentPaned.SetEndChild(paned)
608- }
609- webkit.WidgetQueueAllocate(parent.container)
610- paned.QueueAllocate()
611- logging.Info(fmt.Sprintf("[workspace] paned inserted into parent=%p", parent.container))
612- }
613- }
614-
615- paned.Show()
616-
617- // Synchronize the divider once the paned has a meaningful allocation.
618- wm.syncPanedDivider(paned, orientation, split)
619-
620- // GTK4 automatically handles widget rendering after reparenting - no manual updates needed
621-
622- wm.viewToNode[newPane.webView] = newLeaf
623- wm.setupPopupHandling(newPane.webView, newLeaf)
624- wm.ensureHover(newLeaf)
625-
626- // Don't ensure hover on originalTarget when splitting from stack - it competes with new pane focus
627- // The originalTarget is still in the stack and should not interfere with the new split pane
628- if originalTarget == target {
629- // Only ensure hover on target if it's a regular (non-stack) split
630- if !target.pendingHoverReattach {
631- wm.ensureHover(target)
632- }
633- }
634-
635- // Only add to app.panes if this is a new pane (not an existing popup pane)
636- if existingPane == nil {
637- wm.app.panes = append(wm.app.panes, newPane)
638- } else {
639- // For existing popup panes, they're already in app.panes from createPane() in handlePopupReadyToShow
640- logging.Info(fmt.Sprintf("[workspace] Skipping app.panes append for existing popup pane (already added)"))
641- }
642-
643- if newPane.zoomController != nil {
644- newPane.zoomController.ApplyInitialZoom()
645- }
646-
647- // Update CSS classes for all panes now that we have multiple panes
648-
649- reattachTargets := []*paneNode{splitTarget}
650- if splitTarget != nil && splitTarget.isStacked {
651- reattachTargets = append(reattachTargets, splitTarget.stackedPanes...)
652- }
653- guardNodes := append([]*paneNode{newLeaf}, reattachTargets...)
654- wm.scheduleIdleGuarded(func() bool {
655- if newLeaf == nil || !newLeaf.widgetValid {
656- return false
657- }
658- if newContainer != nil {
659- webkit.WidgetShow(newContainer)
660- }
661-
662- // CRITICAL: For root splits, also show the split target container after reparenting
663- // When the split target is reparented from window → paned, GTK doesn't preserve visibility
664- if parent == nil && splitTargetContainer != nil {
665- webkit.WidgetShow(splitTargetContainer)
666- webkit.WidgetQueueAllocate(splitTargetContainer)
667- webkit.WidgetQueueDraw(splitTargetContainer)
668- }
669-
670- // Ensure the underlying WebView widget is visible after reparenting
671- if parent == nil && splitTarget != nil && splitTarget.pane != nil && splitTarget.pane.webView != nil {
672- if webViewWidget := splitTarget.pane.webView.AsWidget(); webViewWidget != nil {
673- webkit.WidgetShow(webViewWidget)
674- webkit.WidgetQueueAllocate(webViewWidget)
675- webkit.WidgetQueueDraw(webViewWidget)
676- }
677- }
678-
679- // Attach GTK focus controller to new pane
680- if wm.focusStateMachine != nil {
681- wm.focusStateMachine.attachGTKController(newLeaf)
682- }
683-
684- for _, candidate := range reattachTargets {
685- if candidate == nil || !candidate.widgetValid {
686- continue
687- }
688- if candidate.pendingHoverReattach {
689- wm.ensureHover(candidate)
690- if candidate.hoverToken != nil {
691- candidate.pendingHoverReattach = false
692- }
693- }
694- if candidate.pendingFocusReattach && wm.focusStateMachine != nil {
695- wm.focusStateMachine.attachGTKController(candidate)
696- if candidate.focusControllerToken != 0 {
697- candidate.pendingFocusReattach = false
698- }
699- }
700- }
701- wm.SetActivePane(newLeaf, SourceSplit)
702- return false
703- }, guardNodes...)
704-
705- return newLeaf, nil
706-}
707-
708-// Helper functions for the simplified closePane implementation
709-
710-// getSibling returns the sibling node for a given node
711-func (wm *WorkspaceManager) getSibling(node *paneNode) *paneNode {
712- if node.parent == nil {
713- return nil
714- }
715- if node.parent.left == node {
716- return node.parent.right
717- }
718- return node.parent.left
719-}
720-
721-// nextCleanupGeneration returns the next cleanup generation counter
722-func (wm *WorkspaceManager) nextCleanupGeneration() uint {
723- wm.cleanupCounter++
724- return wm.cleanupCounter
725-}
726-
727-// promoteSibling promotes a sibling node to replace its parent in the tree
728-func (wm *WorkspaceManager) promoteSibling(grand *paneNode, parent *paneNode, sibling *paneNode) {
729- if grand == nil {
730- wm.root = sibling
731- sibling.parent = nil
732- return
733- }
734- sibling.parent = grand
735- if grand.left == parent {
736- grand.left = sibling
737- } else {
738- grand.right = sibling
739- }
740-}
741-
742-// swapContainers updates GTK widget hierarchy for promoted siblings
743-func (wm *WorkspaceManager) swapContainers(grand *paneNode, parent *paneNode, sibling *paneNode) {
744- if grand == nil {
745- // Sibling is being promoted to root
746- // When TreeRebalancer is enabled, skip GTK attachment here as it will be
747- // handled by the rebalancer's promotion transaction to avoid double-attachment
748- if wm.treeRebalancer == nil || !wm.treeRebalancer.enabled {
749- // NOTE: Can't use attachRoot() here because wm.root has already been updated
750- // to point to the sibling by promoteSibling(). We need to manually swap in ContentArea:
751- // the old paned (parent) is still in ContentArea and needs to be removed,
752- // then the sibling needs to be added.
753-
754- // The sibling has already been unparented from the old paned earlier in the close logic,
755- // so it has no GTK parent now.
756-
757- if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
758- logging.Error(fmt.Sprintf("[workspace] ERROR: Cannot promote sibling to root - tab manager not initialized"))
759- return
760- }
761-
762- contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
763- if !ok || contentBox == nil {
764- logging.Error(fmt.Sprintf("[workspace] ERROR: ContentArea is not a Box"))
765- return
766- }
767-
768- // CRITICAL: Remove the old paned from ContentArea first
769- if parent != nil && parent.container != nil {
770- contentBox.Remove(parent.container)
771- logging.Debug(fmt.Sprintf("[workspace] Removed old paned %p from ContentArea", parent.container))
772- }
773-
774- // Now add sibling to ContentArea as the new root
775- contentBox.Append(sibling.container)
776- webkit.WidgetSetVisible(sibling.container, true)
777- logging.Debug(fmt.Sprintf("[workspace] Promoted sibling %p to root in ContentArea", sibling.container))
778- }
779- return
780- }
781- // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
782- if sibling.container != nil {
783- if webkit.WidgetHasCSSClass(sibling.container, activePaneClass) {
784- webkit.WidgetRemoveCSSClass(sibling.container, activePaneClass)
785- }
786- }
787-
788- if grandPaned, ok := grand.container.(*gtk.Paned); ok && grandPaned != nil && sibling.container != nil {
789- // GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
790- // Focus was already cleared before unparenting closed pane
791- if parent := webkit.WidgetGetParent(sibling.container); parent != nil && parent != grand.container {
792- logging.Debug(fmt.Sprintf("[workspace] widget %p reparenting from %p to %p (GTK4 will auto-unparent)", sibling.container, parent, grand.container))
793- }
794-
795- if grand.left == sibling {
796- grandPaned.SetStartChild(sibling.container)
797- logging.Debug(fmt.Sprintf("[workspace] swapContainers: set widget %p as start child of paned %p", sibling.container, grand.container))
798- } else {
799- grandPaned.SetEndChild(sibling.container)
800- logging.Debug(fmt.Sprintf("[workspace] swapContainers: set widget %p as end child of paned %p", sibling.container, grand.container))
801- }
802- webkit.WidgetQueueAllocate(grand.container)
803- }
804-}
805-
806-// cascadePromotion handles the case where a paned node has only one child
807-// This happens when closing a pane leaves the grandparent with only one child
808-func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
809- if singleChildPaned == nil {
810- return
811- }
812-
813- // Find the single child
814- var onlyChild *paneNode
815- if singleChildPaned.left != nil && singleChildPaned.right == nil {
816- onlyChild = singleChildPaned.left
817- } else if singleChildPaned.right != nil && singleChildPaned.left == nil {
818- onlyChild = singleChildPaned.right
819- } else {
820- // Either no children or two children - nothing to cascade
821- return
822- }
823-
824- logging.Info(fmt.Sprintf("[workspace] Cascading promotion: replacing paned %p with its only child %p", singleChildPaned, onlyChild))
825-
826- greatGrandparent := singleChildPaned.parent
827-
828- // Update tree structure
829- onlyChild.parent = greatGrandparent
830- if greatGrandparent == nil {
831- // Single child becomes new root
832- wm.root = onlyChild
833- logging.Debug(fmt.Sprintf("[workspace] Only child %p promoted to root", onlyChild))
834-
835- // Attach to window
836- if onlyChild.container != nil {
837- wm.attachRoot(onlyChild)
838- }
839-
840- // Cleanup the now-orphaned paned
841- wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
842- } else {
843- // Replace paned in great-grandparent
844- if greatGrandparent.left == singleChildPaned {
845- greatGrandparent.left = onlyChild
846- } else {
847- greatGrandparent.right = onlyChild
848- }
849-
850- logging.Debug(fmt.Sprintf("[workspace] Only child %p attached to great-grandparent %p", onlyChild, greatGrandparent))
851-
852- // Reparent widget in GTK
853- if greatGrandPaned, ok := greatGrandparent.container.(*gtk.Paned); ok && greatGrandPaned != nil && onlyChild.container != nil {
854- // GTK4 PanedSet*Child automatically handles unparenting
855- if greatGrandparent.left == onlyChild {
856- greatGrandPaned.SetStartChild(onlyChild.container)
857- } else {
858- greatGrandPaned.SetEndChild(onlyChild.container)
859- }
860-
861- webkit.WidgetSetHExpand(onlyChild.container, true)
862- webkit.WidgetSetVExpand(onlyChild.container, true)
863- webkit.WidgetQueueAllocate(onlyChild.container)
864- webkit.WidgetQueueAllocate(greatGrandparent.container)
865- wm.scheduleIdleGuarded(func() bool {
866- if onlyChild == nil || !onlyChild.widgetValid {
867- return false
868- }
869- if onlyChild.container != nil {
870- webkit.WidgetShow(onlyChild.container)
871- webkit.WidgetQueueResize(onlyChild.container)
872- webkit.WidgetQueueDraw(onlyChild.container)
873- }
874- return false
875- }, onlyChild)
876- }
877-
878- // Cleanup the now-orphaned paned
879- wm.decommissionParent(singleChildPaned, wm.cleanupCounter)
880-
881- // Check if great-grandparent now also has only one child (recursive case)
882- if greatGrandparent != nil && ((greatGrandparent.left == nil) != (greatGrandparent.right == nil)) {
883- logging.Info(fmt.Sprintf("[workspace] Great-grandparent %p also has only one child, continuing cascade", greatGrandparent))
884- wm.cascadePromotion(greatGrandparent)
885- }
886- }
887-}
888-
889-// attachRoot attaches a node as the new root in TabManager.ContentArea.
890-// NOTE: This is typically called AFTER wm.root has already been updated to point to this node.
891-// The old root container (if any) will be orphaned in ContentArea and garbage collected.
892-func (wm *WorkspaceManager) attachRoot(root *paneNode) {
893- if root == nil || root.container == nil {
894- return
895- }
896-
897- // Note: In gotk4, reference counting is handled automatically by Go's GC
898- // No need for manual Ref()/Unref() calls
899-
900- // Detach from previous container (paned, stack, etc.) before attaching to ContentArea
901- // While GTK4 auto-unparents for paned operations, Box.Append requires manual unparent
902- if parent := webkit.WidgetGetParent(root.container); parent != nil {
903- logging.Debug(fmt.Sprintf("[workspace] unparenting widget %p from parent %p before root attach", root.container, parent))
904- webkit.WidgetUnparent(root.container)
905- // Note: In gotk4, the Go object reference remains valid after unparent
906- // Only the GTK parent relationship is cleared
907- }
908-
909- if root.container != nil {
910- webkit.WidgetSetHExpand(root.container, true)
911- webkit.WidgetSetVExpand(root.container, true)
912- }
913-
914- // Add to ContentArea (the old root will be orphaned and garbage collected)
915- if wm.app == nil || wm.app.tabManager == nil || wm.app.tabManager.ContentArea == nil {
916- logging.Error(fmt.Sprintf("[workspace] ERROR: Cannot attach root - tab manager not initialized"))
917- return
918- }
919-
920- contentBox, ok := wm.app.tabManager.ContentArea.(*gtk.Box)
921- if !ok || contentBox == nil {
922- logging.Error(fmt.Sprintf("[workspace] ERROR: ContentArea is not a Box"))
923- return
924- }
925-
926- contentBox.Append(root.container)
927- webkit.WidgetSetVisible(root.container, true)
928- if root.container != nil {
929- webkit.WidgetQueueAllocate(root.container)
930- webkit.WidgetShow(root.container)
931- webkit.WidgetQueueResize(root.container)
932- webkit.WidgetQueueDraw(root.container)
933- }
934-
935- wm.scheduleIdleGuarded(func() bool {
936- if root == nil || !root.widgetValid {
937- return false
938- }
939- if root.container != nil {
940- webkit.WidgetQueueResize(root.container)
941- webkit.WidgetQueueDraw(root.container)
942- }
943- return false
944- }, root)
945-
946- // Verify attachment succeeded
947- if finalParent := webkit.WidgetGetParent(root.container); finalParent == nil {
948- logging.Warn(fmt.Sprintf("[workspace] WARNING: widget %p has no parent after SetChild", root.container))
949- } else {
950- logging.Debug(fmt.Sprintf("[workspace] attachRoot successful: widget %p now child of %p", root.container, finalParent))
951- }
952-}
953-
954-// cleanupPane safely cleans up a pane node with generation tracking
955-func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) {
956- if node == nil {
957- return
958- }
959- if !node.widgetValid {
960- return
961- }
962-
963- if wm.focusStateMachine != nil {
964- wm.focusStateMachine.InvalidateActivePane(node)
965- }
966-
967- wm.cancelIdleHandles(node)
968-
969- node.widgetValid = false
970- node.cleanupGeneration = generation
971-
972- if node.pane != nil {
973- pane := node.pane
974- // Unregister from filtering BEFORE cleanup to prevent stale pointer callbacks
975- if pane.webView != nil {
976- wm.app.UnregisterWebViewFromFiltering(pane.webView)
977- }
978- pane.Cleanup()
979- pane.CleanupFromWorkspace(wm)
980- if pane.webView != nil {
981- logging.Debug(fmt.Sprintf("[workspace] releasing WebView reference: %p", pane.webView))
982- pane.webView = nil
983- }
984- node.pane = nil
985- }
986-
987- if node.container != nil {
988- // Container widget will be destroyed by GTK when unparented
989- node.container = nil
990- }
991-
992- node.pendingIdleHandles = nil
993- node.parent = nil
994- node.left = nil
995- node.right = nil
996-}
997-
998-// decommissionParent cleans up a parent node after promotion
999-func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint) {
1000- if parent == nil {
1001- return
1002- }
1003-
1004- // For branch nodes (paneds), destroy the widget
1005- // Children have already been unparented during promotion/close, no need to clear them
1006- if !parent.isLeaf && parent.container != nil {
1007- // In gotk4, we don't need to manually destroy - GTK handles this when unparented
1008- // Just mark it for cleanup
1009- logging.Debug(fmt.Sprintf("[workspace] marked paned widget for cleanup: %p", parent.container))
1010- }
1011-
1012- wm.cleanupPane(parent, generation)
1013-}
1014-
1015-// promoteNewRoot handles root replacement when closing the root pane
1016-func (wm *WorkspaceManager) promoteNewRoot(ctx closeContext, oldRoot *paneNode) (*paneNode, error) {
1017- candidate := wm.findReplacementRoot(oldRoot)
1018- if candidate == nil {
1019- return wm.cleanupAndExit(oldRoot)
1020- }
1021-
1022- sibling := wm.getSibling(candidate)
1023- if sibling != nil {
1024- wm.promoteSibling(candidate.parent.parent, candidate.parent, sibling)
1025- }
1026-
1027- candidate.parent = nil
1028- wm.root = candidate
1029-
1030- wm.attachRoot(candidate)
1031-
1032- wm.cleanupPane(oldRoot, ctx.Generation())
1033- return candidate, nil
1034-}
1035-
1036-// cleanupAndExit handles the final pane cleanup and application exit
1037-func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) {
1038- logging.Info(fmt.Sprintf("[workspace] closing final pane; exiting browser"))
1039- wm.cleanupPane(node, wm.nextCleanupGeneration())
1040- wm.detachHover(node)
1041-
1042- // Gracefully quit the GTK main loop
1043- webkit.QuitMainLoop()
1044- return nil, nil
1045-}
1046-
1047-// setFocusToLeaf sets focus to the leftmost leaf of a node
1048-func (wm *WorkspaceManager) setFocusToLeaf(node *paneNode) {
1049- focusTarget := wm.leftmostLeaf(node)
1050- if focusTarget != nil {
1051- wm.SetActivePane(focusTarget, SourceClose)
1052- }
1053-}
1054-
1055-// TODO(#8): Refactor this function to reduce cyclomatic complexity (31 -> <30)
1056-//
1057-// closePane closes a specific pane and handles tree restructuring (simplified implementation)
1058-func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
1059- ctx := wm.beginClose(node)
1060- defer ctx.finish()
1061-
1062- // STEP 1: Basic validation (quick fail)
1063- if ctx.err != nil {
1064- return nil, ctx.err
1065- }
1066-
1067- // STEP 2: Handle stacked panes via compatibility shim
1068- if node.parent != nil && node.parent.isStacked {
1069- return wm.closeStackedPaneCompat(node)
1070- }
1071-
1072- // Ensure we detach hover/focus controllers before the widget hierarchy changes.
1073- wm.detachHover(node)
1074- wm.detachFocus(node)
1075-
1076- // STEP 3: Handle trivial exit cases
1077- // Don't exit if closing a popup - popups aren't counted in app.panes
1078- if ctx.remaining == 1 && !node.isPopup {
1079- return wm.cleanupAndExit(node)
1080- }
1081- if node == wm.root {
1082- return wm.promoteNewRoot(ctx, node)
1083- }
1084-
1085- // STEP 4: Unparent closed pane FIRST to avoid leaving paned with single child
1086- parent := node.parent
1087- sibling := wm.getSibling(node)
1088- grandparent := parent.parent
1089-
1090- // Clear grandparent's focus child BEFORE unparenting closed pane
1091- // GTK propagates focus events upward, so we must clear grandparent focus first
1092- if grandparent != nil && grandparent.container != nil {
1093- webkit.WidgetSetFocusChild(grandparent.container, nil)
1094- }
1095-
1096- // Unparent the closed pane's container from the paned BEFORE promoting sibling
1097- if node.container != nil {
1098- if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container {
1099- if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
1100- if parent.left == node {
1101- parentPaned.SetStartChild(nil)
1102- } else if parent.right == node {
1103- parentPaned.SetEndChild(nil)
1104- }
1105- logging.Debug(fmt.Sprintf("[workspace] unparented closed pane container: %p", node.container))
1106- }
1107- }
1108- }
1109-
1110- // STEP 5: Unparent sibling from parent paned BEFORE promotion
1111- // The sibling needs to be detached from the parent paned before we can attach it elsewhere
1112- if sibling.container != nil && parent.container != nil {
1113- if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
1114- if parent.left == sibling {
1115- parentPaned.SetStartChild(nil)
1116- } else if parent.right == sibling {
1117- parentPaned.SetEndChild(nil)
1118- }
1119- logging.Debug(fmt.Sprintf("[workspace] unparented sibling container %p from parent paned %p", sibling.container, parent.container))
1120- }
1121- }
1122-
1123- // STEP 6: Promote sibling in-place (tree structure)
1124- wm.promoteSibling(grandparent, parent, sibling)
1125-
1126- // STEP 7: GTK widget reparenting
1127- // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
1128- // For root promotion: we need to remove the old paned from ContentArea
1129- wm.swapContainers(grandparent, parent, sibling)
1130-
1131- // Reconnect the promoted sibling's rendering pipeline after GTK reparenting.
1132- if sibling != nil && sibling.container != nil {
1133- wm.scheduleIdleGuarded(func() bool {
1134- if sibling == nil || !sibling.widgetValid {
1135- return false
1136- }
1137- if sibling.container != nil {
1138- webkit.WidgetShow(sibling.container)
1139- webkit.WidgetQueueResize(sibling.container)
1140- webkit.WidgetQueueDraw(sibling.container)
1141- }
1142- return false
1143- }, sibling)
1144- }
1145-
1146- // STEP 8: Check if grandparent now has only one child (cascade promotion needed)
1147- if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) {
1148- // Grandparent has exactly one child - it should be promoted too
1149- logging.Info(fmt.Sprintf("[workspace] Grandparent %p now has only one child, cascading promotion", grandparent))
1150- wm.cascadePromotion(grandparent)
1151- }
1152-
1153- // STEP 9: Cleanup & focus
1154- wm.cleanupPane(node, ctx.Generation())
1155- wm.decommissionParent(parent, ctx.Generation())
1156- if sibling != nil {
1157- wm.scheduleIdleGuarded(func() bool {
1158- if sibling == nil || !sibling.widgetValid {
1159- return false
1160- }
1161- wm.setFocusToLeaf(sibling)
1162- return false
1163- }, sibling)
1164- }
1165-
1166- return sibling, nil
1167-}
1168-
1169-// closeStackedPaneCompat provides compatibility layer for closing stacked panes
1170-func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, error) {
1171- stack := node.parent
1172-
1173- // Find index
1174- index := -1
1175- for i, pane := range stack.stackedPanes {
1176- if pane == node {
1177- index = i
1178- break
1179- }
1180- }
1181-
1182- if index == -1 {
1183- return nil, errors.New("pane not in stack")
1184- }
1185-
1186- // Remove from array
1187- stack.stackedPanes = append(
1188- stack.stackedPanes[:index],
1189- stack.stackedPanes[index+1:]...,
1190- )
1191-
1192- // If only one pane left, unstack it
1193- if len(stack.stackedPanes) == 1 {
1194- remaining := stack.stackedPanes[0]
1195-
1196- // Replace stack with remaining pane in tree
1197- remaining.parent = stack.parent
1198- if stack.parent != nil {
1199- if stack.parent.left == stack {
1200- stack.parent.left = remaining
1201- } else {
1202- stack.parent.right = remaining
1203- }
1204-
1205- // Update GTK widgets
1206- if parentPaned, ok := stack.parent.container.(*gtk.Paned); ok && parentPaned != nil && remaining.container != nil {
1207- if stack.parent.left == remaining {
1208- parentPaned.SetStartChild(remaining.container)
1209- } else {
1210- parentPaned.SetEndChild(remaining.container)
1211- }
1212- }
1213- } else {
1214- // Stack was root - attach remaining pane as new root
1215- wm.root = remaining
1216- if remaining.container != nil {
1217- wm.setRootContainer(remaining.container)
1218- }
1219- }
1220-
1221- // Cleanup stack container
1222- wm.cleanupPane(stack, wm.nextCleanupGeneration())
1223-
1224- return remaining, nil
1225- }
1226-
1227- // Update active index if needed
1228- if stack.activeStackIndex >= len(stack.stackedPanes) {
1229- stack.activeStackIndex = len(stack.stackedPanes) - 1
1230- }
1231-
1232- // Remove widget from GTK box
1233- if stackBox, ok := stack.container.(*gtk.Box); ok && stackBox != nil && node.container != nil {
1234- stackBox.Remove(node.container)
1235- }
1236-
1237- // Show new active pane
1238- if stack.activeStackIndex >= 0 {
1239- active := stack.stackedPanes[stack.activeStackIndex]
1240- if active.container != nil {
1241- webkit.WidgetShow(active.container)
1242- }
1243- }
1244-
1245- // Cleanup closed pane
1246- wm.cleanupPane(node, wm.nextCleanupGeneration())
1247-
1248- return stack, nil
1249-}
1250-
1251-// syncPanedDivider waits until a GtkPaned has a meaningful allocation before snapping
1252-// the divider to an even split. GTK often reports near-zero allocations for a few frames
1253-// after insertion, so we retry on map/tick/idle until the size stabilizes.
1254-func (wm *WorkspaceManager) syncPanedDivider(paned *gtk.Paned, orientation gtk.Orientation, guard *paneNode) {
1255- if wm == nil || paned == nil {
1256- return
1257- }
1258-
1259- const (
1260- minDimension = 32
1261- maxFrames = 120
1262- )
1263-
1264- setPosition := func() bool {
1265- if guard != nil && !guard.widgetValid {
1266- return true
1267- }
1268-
1269- alloc := paned.Allocation()
1270- dimension := alloc.Width()
1271- if orientation == gtk.OrientationVertical {
1272- dimension = alloc.Height()
1273- }
1274-
1275- if dimension < minDimension {
1276- return false
1277- }
1278-
1279- pos := dimension / 2
1280- if pos <= 0 {
1281- return false
1282- }
1283-
1284- paned.SetPosition(pos)
1285- logging.Debug(fmt.Sprintf("[workspace] Set paned position to 50%%: %d (orientation=%d, size=%dx%d)", pos, orientation, alloc.Width(), alloc.Height()))
1286- return true
1287- }
1288-
1289- // Attempt immediately with the current allocation.
1290- if setPosition() {
1291- return
1292- }
1293-
1294- var frames uint
1295-
1296- paned.ConnectMap(func() {
1297- setPosition()
1298- })
1299-
1300- paned.AddTickCallback(func(widget gtk.Widgetter, _ gdk.FrameClocker) bool {
1301- if guard != nil && !guard.widgetValid {
1302- return false
1303- }
1304-
1305- frames++
1306- if setPosition() {
1307- return false
1308- }
1309-
1310- if frames >= maxFrames {
1311- alloc := paned.Allocation()
1312- logging.Error(fmt.Sprintf("[workspace] Unable to stabilize paned position after %d frames (size=%dx%d)", frames, alloc.Width(), alloc.Height()))
1313- return false
1314- }
1315-
1316- return true
1317- })
1318-
1319- wm.scheduleIdleGuarded(func() bool {
1320- setPosition()
1321- return false
1322- }, guard)
1323-}
D internal/app/browser/workspace_popup.go +0 -626
@@ -1,626 +0,0 @@
1-// workspace_popup.go - Popup window handling and related pane management
2-package browser
3-
4-import (
5- "fmt"
6- "strings"
7- "time"
8-
9- "github.com/bnema/dumber/internal/config"
10- "github.com/bnema/dumber/internal/logging"
11- "github.com/bnema/dumber/pkg/webkit"
12- webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
13-)
14-
15-const (
16- // Default popup window dimensions - sized for OAuth flows
17- defaultPopupMinWidth = 500 // Minimum width for OAuth popups
18- defaultPopupMinHeight = 600 // Minimum height for OAuth popups
19-)
20-
21-// HandlePopup is DEPRECATED and removed.
22-// Popup lifecycle now uses WebKit's native create/ready-to-show/close signals.
23-// See setupPopupHandling(), handlePopupReadyToShow(), and handlePopupClose() instead.
24-
25-// registerOAuthAutoClose sets up OAuth auto-close functionality for popups
26-// Note: OAuth detection is now handled by the main-world.js injection script
27-func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) {
28- logging.Info(fmt.Sprintf("[workspace] OAuth auto-close enabled for popup with URL: %s", url))
29- logging.Info(fmt.Sprintf("[workspace] OAuth detection will be handled by main-world.js injection script"))
30-}
31-
32-// insertIndependentPane inserts a new independent pane next to the source
33-func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
34- newPane, err := wm.createPane(webView)
35- if err != nil {
36- return err
37- }
38- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
39- if direction == "" {
40- direction = DirectionRight
41- }
42- // Use splitNode with existing pane instead of insertPopupPane
43- if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil {
44- return err
45- }
46- node := wm.viewToNode[webView]
47- if node != nil {
48- node.windowType = webkit.WindowTypeTab
49- node.isRelated = false
50- }
51- if url != "" {
52- _ = webView.LoadURL(url)
53- }
54- return nil
55-}
56-
57-// configureRelatedPopup creates a related view and inserts it
58-func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) {
59- // Use the WebView that was already created and returned to WebKit
60- related := webView
61- newPane, err := wm.createPane(related)
62- if err != nil {
63- logging.Error(fmt.Sprintf("[workspace] failed to create related popup pane: %v", err))
64- return
65- }
66- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
67- if direction == "" {
68- direction = DirectionRight
69- }
70- // Use splitNode with existing pane instead of insertPopupPane
71- if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil {
72- logging.Error(fmt.Sprintf("[workspace] failed to insert related popup pane: %v", err))
73- return
74- }
75- node := wm.viewToNode[related]
76- popupWebViewID := related.IDString()
77-
78- if node != nil {
79- node.windowType = webkit.WindowTypePopup
80- node.windowFeatures = feat
81- node.isRelated = true
82- node.parentPane = sourceNode
83- node.isPopup = true
84- // Heuristic + config for auto-close intent
85- node.autoClose = wm.shouldAutoClose(url)
86-
87- // Apply minimum size constraints to prevent compression
88- var width, height int
89- if feat != nil {
90- width = feat.Width
91- height = feat.Height
92- }
93- wm.applyPopupSizeConstraints(related, width, height)
94- }
95-
96- // Track this popup in the parent's activePopupChildren
97- // This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
98- if sourceNode != nil && popupWebViewID != "" {
99- if sourceNode.activePopupChildren == nil {
100- sourceNode.activePopupChildren = make([]string, 0)
101- }
102- sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
103- logging.Debug(fmt.Sprintf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren)))
104-
105- // Register navigation policy handler for parent to block script-initiated navigations
106- // when it has active popup children (prevents OAuth from hijacking parent pane)
107- if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
108- sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
109- // Always allow user-initiated navigations
110- if isUserGesture {
111- return true
112- }
113-
114- // Block script-initiated navigations if this pane has active popup children
115- if len(sourceNode.activePopupChildren) > 0 {
116- logging.Info(fmt.Sprintf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url))
117- return false
118- }
119-
120- // Allow if no active popups
121- return true
122- })
123- logging.Debug(fmt.Sprintf("[workspace] Registered navigation policy handler for parent pane"))
124- }
125- }
126-
127- // Pipe into existing auto-close flow only for popups (confirmed by detection)
128- related.RegisterCloseHandler(func() {
129- logging.Info(fmt.Sprintf("[workspace] Popup requesting close via window.close()"))
130-
131- // Remove this popup from parent's activePopupChildren
132- if sourceNode != nil && popupWebViewID != "" {
133- for i, childID := range sourceNode.activePopupChildren {
134- if childID == popupWebViewID {
135- sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
136- logging.Debug(fmt.Sprintf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren)))
137- break
138- }
139- }
140- }
141-
142- if n := wm.viewToNode[related]; n != nil && n.isPopup {
143- time.AfterFunc(200*time.Millisecond, func() {
144- wm.scheduleIdleGuarded(func() bool {
145- if n == nil || !n.widgetValid {
146- return false
147- }
148- if err := wm.ClosePane(n); err != nil {
149- logging.Error(fmt.Sprintf("[workspace] Failed to close popup pane: %v", err))
150- }
151- return false
152- }, n)
153- })
154- }
155- })
156-
157- // URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs
158- if node != nil && node.isPopup && node.autoClose {
159- wm.registerOAuthAutoClose(related, url)
160- }
161- if url != "" {
162- _ = related.LoadURL(url)
163- }
164-}
165-
166-// applyPopupSizeConstraints applies minimum size constraints to prevent OAuth popup compression
167-func (wm *WorkspaceManager) applyPopupSizeConstraints(view *webkit.WebView, width, height int) {
168- minWidth := defaultPopupMinWidth
169- minHeight := defaultPopupMinHeight
170-
171- // Use provided dimensions if valid
172- if width > 0 {
173- minWidth = width
174- }
175- if height > 0 {
176- minHeight = height
177- }
178-
179- // Apply to container
180- if container := view.RootWidget(); container != nil {
181- webkit.WidgetSetSizeRequest(container, minWidth, minHeight)
182- webkit.WidgetQueueResize(container)
183- logging.Debug(fmt.Sprintf("[workspace] Applied minimum size %dx%d to popup container=%p", minWidth, minHeight, container))
184- }
185-
186- // Apply to WebView widget
187- if webViewWidget := view.Widget(); webViewWidget != nil {
188- webkit.WidgetSetSizeRequest(webViewWidget, minWidth, minHeight)
189- webkit.WidgetQueueResize(webViewWidget)
190- logging.Debug(fmt.Sprintf("[workspace] Applied minimum size %dx%d to popup webview=%p", minWidth, minHeight, webViewWidget))
191- }
192-}
193-
194-// shouldAutoClose checks simple OAuth-like URL patterns and config flag
195-func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
196- logging.Info(fmt.Sprintf("[workspace] shouldAutoClose called for URL: %s", url))
197-
198- if wm == nil || wm.app == nil || wm.app.config == nil {
199- logging.Error(fmt.Sprintf("[workspace] shouldAutoClose: missing config, returning true"))
200- return true
201- }
202- if !wm.app.config.Workspace.Popups.OAuthAutoClose {
203- logging.Info(fmt.Sprintf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false"))
204- return false
205- }
206-
207- u := strings.ToLower(url)
208- logging.Debug(fmt.Sprintf("[workspace] shouldAutoClose: checking lowercase URL: %s", u))
209-
210- // RFC 6749 compliant OAuth 2.0 URL patterns
211- oauthPatterns := []string{
212- // Standard OAuth endpoints
213- "oauth", "authorize", "authorization",
214- // Standard callback/redirect patterns
215- "callback", "redirect", "auth/callback",
216- // OpenID Connect patterns
217- "oidc", "openid",
218- // Common OAuth parameter indicators
219- "response_type=", "client_id=", "redirect_uri=", "scope=", "state=",
220- // Standard OAuth response parameters
221- "code=", "access_token=", "id_token=", "token_type=",
222- // Error response parameters
223- "error=", "error_description=",
224- }
225-
226- // Check for OAuth patterns in URL
227- for _, pattern := range oauthPatterns {
228- if strings.Contains(u, pattern) {
229- logging.Info(fmt.Sprintf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern))
230- return true
231- }
232- }
233-
234- logging.Info(fmt.Sprintf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false"))
235- return false
236-}
237-
238-// RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded
239-func (wm *WorkspaceManager) RunOnUI(fn func()) {
240- if fn != nil {
241- fn()
242- }
243-}
244-
245-// handleDetectedWindowType handles window type detection from smart detection path
246-func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) {
247- if wm.viewToNode[webView] != nil {
248- return // Already placed
249- }
250-
251- logging.Info(fmt.Sprintf("[workspace] Smart detection result: type=%d url=%s", windowType, url))
252-
253- switch windowType {
254- case webkit.WindowTypeTab:
255- // For tabs, create a NEW independent WebView (can't use the related one)
256- webkitCfg, err := wm.app.buildWebkitConfig()
257- if err != nil {
258- logging.Error(fmt.Sprintf("[workspace] failed to build webkit config for tab: %v", err))
259- return
260- }
261- // Tabs are embedded in workspace paned containers - no separate window needed
262- webkitCfg.CreateWindow = false
263-
264- // Create independent WebView matching our standard tab insertion path
265- independentView, err := webkit.NewWebView(webkitCfg)
266- if err != nil {
267- logging.Error(fmt.Sprintf("[workspace] failed to create independent tab WebView: %v", err))
268- return
269- }
270-
271- // Register with content blocking service for network/cosmetic filtering
272- wm.app.RegisterWebViewForFiltering(independentView)
273-
274- // The related webView was just for detection - we don't use it for tabs
275- // Insert the new independent view as a tab
276- if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
277- logging.Error(fmt.Sprintf("[workspace] Failed to insert independent pane: %v", err))
278- }
279-
280- case webkit.WindowTypePopup:
281- // For popups, use the related WebView we already created
282- wm.configureRelatedPopup(sourceNode, webView, url, features)
283- default:
284- // Fallback to popup behavior
285- wm.configureRelatedPopup(sourceNode, webView, url, features)
286- }
287-}
288-
289-// setupPopupHandling connects WebView's create signal for popup lifecycle management
290-// This implements the WebKit-native popup lifecycle as described in POPUP_REFACTORING_PLAN.md
291-func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNode *paneNode) {
292- if webView == nil || parentNode == nil {
293- return
294- }
295-
296- webView.RegisterPopupCreateHandler(func(navAction *webkitgtk.NavigationAction) *webkit.WebView {
297- // Get URL from navigation action
298- req := navAction.Request()
299- if req == nil {
300- logging.Info(fmt.Sprintf("[workspace] create signal: no request in navigation action"))
301- return nil
302- }
303- url := req.URI()
304-
305- logging.Info(fmt.Sprintf("[workspace] create signal received for URL: %s", url))
306-
307- // Check if popups are enabled in config
308- if wm.app.config != nil && !wm.app.config.Workspace.Popups.OpenInNewPane {
309- logging.Info(fmt.Sprintf("[workspace] Popup creation disabled in config - blocking popup"))
310- return nil
311- }
312-
313- // CRITICAL: Create a BARE gotk4 WebView as RELATED to parent (no wrapping, no initialization)
314- // WebKit requires the related-view property to be set during construction for proper lifecycle
315- // This ensures session/cookie sharing and proper WindowFeatures initialization
316- // All initialization (wrapping, container, scripts) happens in ready-to-show
317- logging.Debug(fmt.Sprintf("[workspace] Step 1: Creating bare gotk4 WebView with related-view property"))
318- barePopupView := webkit.NewBareRelatedWebView(webView.GtkWebView())
319- if barePopupView == nil {
320- logging.Error(fmt.Sprintf("[workspace] Failed to create bare related popup WebView - blocking popup"))
321- return nil
322- }
323- logging.Debug(fmt.Sprintf("[workspace] Step 1 complete: bare related gotk4 WebView created=%p (parent=%p)", barePopupView, webView.GtkWebView()))
324-
325- // Wrap the bare view immediately to get a proper ID
326- // BUT don't initialize it yet - that happens in ready-to-show
327- logging.Info(fmt.Sprintf("[workspace] Step 2: Wrapping bare view (no initialization)"))
328- wrappedView := webkit.WrapBareWebView(barePopupView)
329- if wrappedView == nil {
330- logging.Error(fmt.Sprintf("[workspace] Failed to wrap bare popup WebView - blocking popup"))
331- return nil
332- }
333-
334- // Use the wrapper's ID for tracking
335- popupID := wrappedView.ID()
336- logging.Debug(fmt.Sprintf("[workspace] Step 2 complete: wrapped view created with ID=%d", popupID))
337-
338- // Store wrapped view (not initialized) for initialization in ready-to-show
339- logging.Info(fmt.Sprintf("[workspace] Step 3: Storing in pendingPopups map"))
340- wm.pendingPopups[popupID] = &pendingPopup{
341- wrappedView: wrappedView,
342- parentView: webView,
343- parentNode: parentNode,
344- url: url,
345- }
346- logging.Info(fmt.Sprintf("[workspace] Step 3 complete: stored in pendingPopups"))
347-
348- // Connect signals immediately BEFORE returning to WebKit
349- // This is safe because we're only connecting signal handlers, not accessing properties
350- // WebKit will configure WindowFeatures AFTER this callback returns, which is fine
351- logging.Info(fmt.Sprintf("[workspace] Step 4: Connecting signals immediately before returning to WebKit"))
352-
353- // Connect ready-to-show signal
354- barePopupView.ConnectReadyToShow(func() {
355- logging.Info(fmt.Sprintf("[workspace] ready-to-show signal received for popup ID=%d", popupID))
356- wm.handlePopupReadyToShow(popupID)
357- })
358- logging.Debug(fmt.Sprintf("[workspace] Connected ready-to-show signal for popup ID=%d", popupID))
359-
360- // Connect close handler
361- barePopupView.ConnectClose(func() {
362- logging.Info(fmt.Sprintf("[workspace] close signal received for popup ID=%d", popupID))
363- wm.handlePopupClose(popupID)
364- })
365- logging.Debug(fmt.Sprintf("[workspace] Connected close signal for popup ID=%d", popupID))
366-
367- logging.Debug(fmt.Sprintf("[workspace] Step 4 complete: signals connected"))
368-
369- logging.Debug(fmt.Sprintf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)"))
370-
371- // Return the wrapped view - WebKit will extract the underlying gotk4 view and configure it
372- return wrappedView
373- })
374-}
375-
376-// handlePopupReadyToShow inserts popup pane when WebKit says it's ready
377-func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
378- info, ok := wm.pendingPopups[popupID]
379- if !ok {
380- logging.Warn(fmt.Sprintf("[workspace] ready-to-show for unknown popup ID: %d", popupID))
381- return
382- }
383- delete(wm.pendingPopups, popupID)
384-
385- logging.Info(fmt.Sprintf("[workspace] ready-to-show received for popup ID=%d, starting full initialization", popupID))
386-
387- // NOW it's safe to initialize (WebKit has configured WindowProperties)
388- // We already have the wrapped view from setupPopupHandling
389- wrappedView := info.wrappedView
390-
391- // Build webkit config for initialization
392- webkitCfg, err := wm.app.buildWebkitConfig()
393- if err != nil {
394- logging.Error(fmt.Sprintf("[workspace] Failed to build webkit config for popup initialization: %v", err))
395- return
396- }
397- webkitCfg.CreateWindow = false // Embedded in workspace
398-
399- // Initialize the wrapped view with full configuration
400- if err := wrappedView.InitializeFromBare(webkitCfg); err != nil {
401- logging.Error(fmt.Sprintf("[workspace] Failed to initialize popup WebView: %v", err))
402- return
403- }
404-
405- // Register with content blocking service for network/cosmetic filtering
406- wm.app.RegisterWebViewForFiltering(wrappedView)
407-
408- logging.Debug(fmt.Sprintf("[workspace] Successfully initialized popup WebView ID %d", wrappedView.ID()))
409-
410- // Create pane for the fully initialized WebView
411- popupPane, err := wm.createPane(wrappedView)
412- if err != nil {
413- logging.Error(fmt.Sprintf("[workspace] Failed to create popup pane: %v", err))
414- return
415- }
416-
417- logging.Info(fmt.Sprintf("[workspace] Created popup pane, now inserting into workspace"))
418-
419- // Determine window type from node (set during setupPopupHandling)
420- node := wm.viewToNode[wrappedView]
421- windowType := webkit.WindowTypePopup
422- if node != nil {
423- windowType = node.windowType
424- }
425-
426- // Select behavior based on window type:
427- // - WindowTypeTab (_blank links): Use BlankTargetBehavior config
428- // - WindowTypePopup (JavaScript popups): Use global Behavior config
429- var behaviorStr string
430- if windowType == webkit.WindowTypeTab {
431- behaviorStr = wm.app.config.Workspace.Popups.BlankTargetBehavior
432- logging.Debug(fmt.Sprintf("[workspace] WindowTypeTab (_blank link) detected, using BlankTargetBehavior: %s", behaviorStr))
433- } else {
434- behaviorStr = string(wm.app.config.Workspace.Popups.Behavior)
435- logging.Debug(fmt.Sprintf("[workspace] WindowTypePopup detected, using Behavior: %s", behaviorStr))
436- }
437-
438- // Map string to PopupBehavior enum
439- var behavior config.PopupBehavior
440- switch behaviorStr {
441- case "split":
442- behavior = config.PopupBehaviorSplit
443- case "stacked":
444- behavior = config.PopupBehaviorStacked
445- case "tabbed":
446- behavior = config.PopupBehaviorTabbed
447- default:
448- behavior = config.PopupBehaviorSplit // Fallback
449- }
450-
451- logging.Info(fmt.Sprintf("[workspace] Final behavior: %s", behavior))
452-
453- switch behavior {
454- case config.PopupBehaviorSplit:
455- // Split pane behavior (default) - use splitNode with existing popup pane
456- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
457- if direction == "" {
458- direction = DirectionRight
459- }
460- if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
461- logging.Error(fmt.Sprintf("[workspace] Failed to insert split popup pane: %v", err))
462- return
463- }
464-
465- case config.PopupBehaviorStacked:
466- // Stacked pane behavior
467- _, err := wm.stackedPaneManager.StackPane(info.parentNode)
468- if err != nil {
469- logging.Error(fmt.Sprintf("[workspace] Failed to stack existing pane: %v", err))
470- return
471- }
472- // Insert the popup pane in the same container - use splitNode with existing popup pane
473- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
474- if direction == "" {
475- direction = DirectionRight
476- }
477- if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
478- logging.Error(fmt.Sprintf("[workspace] Failed to insert stacked popup pane: %v", err))
479- return
480- }
481-
482- case config.PopupBehaviorTabbed:
483- // Tabbed behavior - TODO: implement proper tabbed interface
484- logging.Warn(fmt.Sprintf("[workspace] WARNING: Tabbed popup behavior not yet implemented, falling back to split"))
485- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
486- if direction == "" {
487- direction = DirectionRight
488- }
489- if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
490- logging.Error(fmt.Sprintf("[workspace] Failed to insert tabbed popup pane: %v", err))
491- return
492- }
493-
494- case config.PopupBehaviorWindowed:
495- // Windowed behavior - opens in new workspace/window
496- // TODO: implement new window creation with GTK
497- logging.Warn(fmt.Sprintf("[workspace] WARNING: Windowed popup behavior not yet implemented, falling back to split"))
498- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
499- if direction == "" {
500- direction = DirectionRight
501- }
502- if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
503- logging.Error(fmt.Sprintf("[workspace] Failed to insert windowed popup pane: %v", err))
504- return
505- }
506-
507- default:
508- // Fallback to split if unknown behavior
509- logging.Warn(fmt.Sprintf("[workspace] Unknown popup behavior '%s', falling back to split", behavior))
510- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
511- if direction == "" {
512- direction = DirectionRight
513- }
514- if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
515- logging.Error(fmt.Sprintf("[workspace] Failed to insert popup pane: %v", err))
516- return
517- }
518- }
519-
520- // Configure the popup node
521- // Note: windowType should already be set correctly from setupPopupHandling
522- // Don't overwrite it here - preserve WindowTypeTab for _blank links
523- node = wm.viewToNode[wrappedView]
524- if node != nil {
525- node.isRelated = true
526- node.parentPane = info.parentNode
527- node.isPopup = true // Mark as popup to prevent browser exit when parent closes
528- node.autoClose = wm.shouldAutoClose(info.url)
529- node.popupID = popupID // Store popupID for close signal lookup
530-
531- // Track this popup in the parent's activePopupChildren
532- if info.parentNode != nil {
533- popupWebViewID := wrappedView.IDString()
534- if info.parentNode.activePopupChildren == nil {
535- info.parentNode.activePopupChildren = make([]string, 0)
536- }
537- info.parentNode.activePopupChildren = append(info.parentNode.activePopupChildren, popupWebViewID)
538- logging.Debug(fmt.Sprintf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)",
539- popupWebViewID, len(info.parentNode.activePopupChildren)))
540-
541- // Register navigation policy handler for parent
542- if info.parentNode.pane != nil && info.parentNode.pane.WebView() != nil {
543- info.parentNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
544- if isUserGesture {
545- return true
546- }
547- if len(info.parentNode.activePopupChildren) > 0 {
548- logging.Info(fmt.Sprintf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s",
549- len(info.parentNode.activePopupChildren), url))
550- return false
551- }
552- return true
553- })
554- }
555- }
556-
557- // URL-based auto-close for OAuth popups
558- if node.autoClose {
559- wm.registerOAuthAutoClose(wrappedView, info.url)
560- }
561- }
562-
563- wm.ensureGUIInPane(popupPane)
564-
565- // Widgets are already shown by splitNode - no need to show again
566- // GTK4 handles visibility automatically during reparenting
567-
568- logging.Info(fmt.Sprintf("[workspace] Popup pane inserted and visible for URL: %s", info.url))
569-}
570-
571-// handlePopupClose responds to WebKit's close signal
572-func (wm *WorkspaceManager) handlePopupClose(popupID uint64) {
573- logging.Info(fmt.Sprintf("[workspace] close signal received for popup native=%d", popupID))
574-
575- // Check if it's in pending (not yet shown)
576- if info, ok := wm.pendingPopups[popupID]; ok {
577- delete(wm.pendingPopups, popupID)
578- logging.Info(fmt.Sprintf("[workspace] Popup closed before ready-to-show, cleaning up"))
579- // Just remove from pending, WebKit will handle cleanup
580- _ = info // avoid unused variable
581- return
582- }
583-
584- // Find pane by popupID (stored in node during ready-to-show)
585- var targetNode *paneNode
586- for _, node := range wm.viewToNode {
587- if node != nil && node.popupID == popupID {
588- targetNode = node
589- break
590- }
591- }
592-
593- if targetNode == nil {
594- logging.Warn(fmt.Sprintf("[workspace] close signal for unknown popup ID=%d, already closed?", popupID))
595- return
596- }
597-
598- // Remove from parent's activePopupChildren
599- // Find the wrapped WebView for this node to get its IDString
600- var wrappedViewID string
601- for webView, node := range wm.viewToNode {
602- if node == targetNode {
603- wrappedViewID = webView.IDString()
604- break
605- }
606- }
607-
608- if targetNode.parentPane != nil && wrappedViewID != "" {
609- for i, childID := range targetNode.parentPane.activePopupChildren {
610- if childID == wrappedViewID {
611- targetNode.parentPane.activePopupChildren = append(
612- targetNode.parentPane.activePopupChildren[:i],
613- targetNode.parentPane.activePopupChildren[i+1:]...)
614- logging.Debug(fmt.Sprintf("[workspace] Removed popup from parent's activePopupChildren (remaining: %d)",
615- len(targetNode.parentPane.activePopupChildren)))
616- break
617- }
618- }
619- }
620-
621- // Clean close via existing ClosePane (WebKit already stopped)
622- logging.Info(fmt.Sprintf("[workspace] Closing popup pane via ClosePane"))
623- if err := wm.ClosePane(targetNode); err != nil {
624- logging.Error(fmt.Sprintf("[workspace] Error closing popup pane: %v", err))
625- }
626-}
D internal/app/browser/workspace_popup_test.go +0 -419
@@ -1,419 +0,0 @@
1-package browser
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/dumber/internal/config"
7- "github.com/bnema/dumber/pkg/webkit"
8-)
9-
10-// TestPendingPopupStorage tests the pendingPopups map operations
11-func TestPendingPopupStorage(t *testing.T) {
12- wm := &WorkspaceManager{
13- pendingPopups: make(map[uint64]*pendingPopup),
14- }
15-
16- // Test adding a pending popup
17- popupID := uint64(1)
18- pending := &pendingPopup{
19- url: "https://example.com",
20- }
21-
22- wm.pendingPopups[popupID] = pending
23-
24- // Verify storage
25- retrieved, ok := wm.pendingPopups[popupID]
26- if !ok {
27- t.Errorf("Expected popup ID %d to be stored", popupID)
28- }
29-
30- if retrieved.url != "https://example.com" {
31- t.Errorf("Expected URL 'https://example.com', got %s", retrieved.url)
32- }
33-
34- // Test deletion
35- delete(wm.pendingPopups, popupID)
36- _, ok = wm.pendingPopups[popupID]
37- if ok {
38- t.Errorf("Expected popup ID %d to be deleted", popupID)
39- }
40-}
41-
42-// TestPendingPopupCleanup tests cleanup of pending popups that never showed
43-func TestPendingPopupCleanup(t *testing.T) {
44- wm := &WorkspaceManager{
45- pendingPopups: make(map[uint64]*pendingPopup),
46- }
47-
48- // Add multiple pending popups
49- for i := uint64(1); i <= 5; i++ {
50- wm.pendingPopups[i] = &pendingPopup{
51- url: "https://example.com",
52- }
53- }
54-
55- // Verify all added
56- if len(wm.pendingPopups) != 5 {
57- t.Errorf("Expected 5 pending popups, got %d", len(wm.pendingPopups))
58- }
59-
60- // Simulate close before ready-to-show for popup 3
61- popupID := uint64(3)
62- if _, ok := wm.pendingPopups[popupID]; ok {
63- delete(wm.pendingPopups, popupID)
64- }
65-
66- // Verify cleanup
67- if len(wm.pendingPopups) != 4 {
68- t.Errorf("Expected 4 pending popups after cleanup, got %d", len(wm.pendingPopups))
69- }
70-
71- _, ok := wm.pendingPopups[popupID]
72- if ok {
73- t.Errorf("Expected popup ID %d to be removed", popupID)
74- }
75-}
76-
77-// TestPopupIDTracking tests that popup IDs are tracked correctly in paneNode
78-func TestPopupIDTracking(t *testing.T) {
79- node := &paneNode{
80- isLeaf: true,
81- windowType: webkit.WindowTypePopup,
82- isPopup: true,
83- popupID: 42,
84- }
85-
86- // Verify popup ID is stored
87- if node.popupID != 42 {
88- t.Errorf("Expected popupID 42, got %d", node.popupID)
89- }
90-
91- // Verify window type
92- if node.windowType != webkit.WindowTypePopup {
93- t.Errorf("Expected WindowTypePopup, got %d", node.windowType)
94- }
95-
96- // Verify popup flag
97- if !node.isPopup {
98- t.Errorf("Expected isPopup to be true")
99- }
100-}
101-
102-// TestActivePopupChildren tests tracking of active popup children in parent pane
103-func TestActivePopupChildren(t *testing.T) {
104- parentNode := &paneNode{
105- isLeaf: true,
106- activePopupChildren: make([]string, 0),
107- }
108-
109- // Add popup children
110- popupID1 := "1"
111- popupID2 := "2"
112- popupID3 := "3"
113-
114- parentNode.activePopupChildren = append(parentNode.activePopupChildren, popupID1)
115- parentNode.activePopupChildren = append(parentNode.activePopupChildren, popupID2)
116- parentNode.activePopupChildren = append(parentNode.activePopupChildren, popupID3)
117-
118- // Verify count
119- if len(parentNode.activePopupChildren) != 3 {
120- t.Errorf("Expected 3 active popup children, got %d", len(parentNode.activePopupChildren))
121- }
122-
123- // Test removal of middle child
124- for i, childID := range parentNode.activePopupChildren {
125- if childID == popupID2 {
126- parentNode.activePopupChildren = append(
127- parentNode.activePopupChildren[:i],
128- parentNode.activePopupChildren[i+1:]...,
129- )
130- break
131- }
132- }
133-
134- // Verify removal
135- if len(parentNode.activePopupChildren) != 2 {
136- t.Errorf("Expected 2 active popup children after removal, got %d", len(parentNode.activePopupChildren))
137- }
138-
139- // Verify correct children remain
140- if parentNode.activePopupChildren[0] != popupID1 || parentNode.activePopupChildren[1] != popupID3 {
141- t.Errorf("Expected children [%s, %s], got %v", popupID1, popupID3, parentNode.activePopupChildren)
142- }
143-}
144-
145-// TestShouldAutoClose tests OAuth URL detection logic
146-func TestShouldAutoClose(t *testing.T) {
147- tests := []struct {
148- name string
149- url string
150- enabled bool
151- expected bool
152- }{
153- {
154- name: "OAuth authorize URL",
155- url: "https://accounts.google.com/o/oauth2/v2/auth?client_id=...",
156- enabled: true,
157- expected: true,
158- },
159- {
160- name: "OAuth callback URL",
161- url: "https://example.com/auth/callback?code=xyz",
162- enabled: true,
163- expected: true,
164- },
165- {
166- name: "OAuth token response",
167- url: "https://example.com/redirect?access_token=xyz",
168- enabled: true,
169- expected: true,
170- },
171- {
172- name: "Regular URL - auto-close enabled",
173- url: "https://example.com/page",
174- enabled: true,
175- expected: false,
176- },
177- {
178- name: "OAuth URL - auto-close disabled",
179- url: "https://accounts.google.com/o/oauth2/v2/auth?client_id=...",
180- enabled: false,
181- expected: false,
182- },
183- {
184- name: "OpenID Connect URL",
185- url: "https://example.com/oidc/authorize",
186- enabled: true,
187- expected: true,
188- },
189- {
190- name: "Error response",
191- url: "https://example.com/callback?error=access_denied",
192- enabled: true,
193- expected: true,
194- },
195- }
196-
197- for _, tt := range tests {
198- t.Run(tt.name, func(t *testing.T) {
199- wm := &WorkspaceManager{
200- app: &BrowserApp{
201- config: &config.Config{
202- Workspace: config.WorkspaceConfig{
203- Popups: config.PopupBehaviorConfig{
204- OAuthAutoClose: tt.enabled,
205- },
206- },
207- },
208- },
209- }
210-
211- result := wm.shouldAutoClose(tt.url)
212- if result != tt.expected {
213- t.Errorf("shouldAutoClose(%q) with enabled=%v = %v, want %v",
214- tt.url, tt.enabled, result, tt.expected)
215- }
216- })
217- }
218-}
219-
220-// TestPopupBehaviorConfig tests popup behavior configuration
221-func TestPopupBehaviorConfig(t *testing.T) {
222- tests := []struct {
223- name string
224- behavior config.PopupBehavior
225- }{
226- {"Split behavior", config.PopupBehaviorSplit},
227- {"Stacked behavior", config.PopupBehaviorStacked},
228- {"Tabbed behavior", config.PopupBehaviorTabbed},
229- {"Windowed behavior", config.PopupBehaviorWindowed},
230- }
231-
232- for _, tt := range tests {
233- t.Run(tt.name, func(t *testing.T) {
234- cfg := &config.Config{
235- Workspace: config.WorkspaceConfig{
236- Popups: config.PopupBehaviorConfig{
237- Behavior: tt.behavior,
238- },
239- },
240- }
241-
242- // Verify behavior is stored correctly
243- if cfg.Workspace.Popups.Behavior != tt.behavior {
244- t.Errorf("Expected behavior %s, got %s",
245- tt.behavior, cfg.Workspace.Popups.Behavior)
246- }
247- })
248- }
249-}
250-
251-// TestPopupWindowTypeTracking tests window type tracking in paneNode
252-func TestPopupWindowTypeTracking(t *testing.T) {
253- tests := []struct {
254- name string
255- windowType webkit.WindowType
256- isPopup bool
257- isRelated bool
258- }{
259- {
260- name: "Regular tab",
261- windowType: webkit.WindowTypeTab,
262- isPopup: false,
263- isRelated: false,
264- },
265- {
266- name: "Popup window",
267- windowType: webkit.WindowTypePopup,
268- isPopup: true,
269- isRelated: true,
270- },
271- }
272-
273- for _, tt := range tests {
274- t.Run(tt.name, func(t *testing.T) {
275- node := &paneNode{
276- windowType: tt.windowType,
277- isPopup: tt.isPopup,
278- isRelated: tt.isRelated,
279- }
280-
281- if node.windowType != tt.windowType {
282- t.Errorf("Expected windowType %d, got %d", tt.windowType, node.windowType)
283- }
284-
285- if node.isPopup != tt.isPopup {
286- t.Errorf("Expected isPopup %v, got %v", tt.isPopup, node.isPopup)
287- }
288-
289- if node.isRelated != tt.isRelated {
290- t.Errorf("Expected isRelated %v, got %v", tt.isRelated, node.isRelated)
291- }
292- })
293- }
294-}
295-
296-// TestPopupParentRelationship tests parent-child relationship tracking
297-func TestPopupParentRelationship(t *testing.T) {
298- parentNode := &paneNode{
299- isLeaf: true,
300- activePopupChildren: make([]string, 0),
301- }
302-
303- popupNode := &paneNode{
304- isLeaf: true,
305- windowType: webkit.WindowTypePopup,
306- isPopup: true,
307- parentPane: parentNode,
308- isRelated: true,
309- }
310-
311- // Verify parent relationship
312- if popupNode.parentPane != parentNode {
313- t.Errorf("Expected popup's parent to be parentNode")
314- }
315-
316- // Verify popup is marked as related
317- if !popupNode.isRelated {
318- t.Errorf("Expected popup to be marked as related")
319- }
320-
321- // Add popup to parent's children
322- popupWebViewID := "test-popup-id"
323- parentNode.activePopupChildren = append(parentNode.activePopupChildren, popupWebViewID)
324-
325- // Verify parent tracks popup
326- if len(parentNode.activePopupChildren) != 1 {
327- t.Errorf("Expected parent to track 1 popup child, got %d", len(parentNode.activePopupChildren))
328- }
329-
330- if parentNode.activePopupChildren[0] != popupWebViewID {
331- t.Errorf("Expected parent to track popup ID %s, got %s",
332- popupWebViewID, parentNode.activePopupChildren[0])
333- }
334-}
335-
336-// TestPopupCleanupGeneration tests cleanup generation tracking
337-func TestPopupCleanupGeneration(t *testing.T) {
338- node := &paneNode{
339- isLeaf: true,
340- windowType: webkit.WindowTypePopup,
341- isPopup: true,
342- widgetValid: true,
343- cleanupGeneration: 0,
344- }
345-
346- // Simulate lifecycle progression
347- node.cleanupGeneration = 1
348- if node.cleanupGeneration != 1 {
349- t.Errorf("Expected cleanupGeneration 1, got %d", node.cleanupGeneration)
350- }
351-
352- // Simulate widget becoming invalid
353- node.widgetValid = false
354- if node.widgetValid {
355- t.Errorf("Expected widgetValid to be false")
356- }
357-
358- // Increment generation for new lifecycle
359- node.cleanupGeneration = 2
360- if node.cleanupGeneration != 2 {
361- t.Errorf("Expected cleanupGeneration 2, got %d", node.cleanupGeneration)
362- }
363-}
364-
365-// TestMultiplePendingPopups tests handling multiple pending popups simultaneously
366-func TestMultiplePendingPopups(t *testing.T) {
367- wm := &WorkspaceManager{
368- pendingPopups: make(map[uint64]*pendingPopup),
369- }
370-
371- // Add multiple pending popups
372- popups := map[uint64]string{
373- 1: "https://popup1.com",
374- 2: "https://popup2.com",
375- 3: "https://popup3.com",
376- }
377-
378- for id, url := range popups {
379- wm.pendingPopups[id] = &pendingPopup{
380- url: url,
381- }
382- }
383-
384- // Verify all stored
385- if len(wm.pendingPopups) != 3 {
386- t.Errorf("Expected 3 pending popups, got %d", len(wm.pendingPopups))
387- }
388-
389- // Verify each URL
390- for id, expectedURL := range popups {
391- pending, ok := wm.pendingPopups[id]
392- if !ok {
393- t.Errorf("Expected popup ID %d to be stored", id)
394- continue
395- }
396- if pending.url != expectedURL {
397- t.Errorf("Expected URL %s for popup %d, got %s", expectedURL, id, pending.url)
398- }
399- }
400-
401- // Simulate ready-to-show for popup 2 (remove from pending)
402- delete(wm.pendingPopups, 2)
403-
404- // Verify remaining
405- if len(wm.pendingPopups) != 2 {
406- t.Errorf("Expected 2 pending popups after ready-to-show, got %d", len(wm.pendingPopups))
407- }
408-
409- // Verify popup 2 is gone but 1 and 3 remain
410- if _, ok := wm.pendingPopups[2]; ok {
411- t.Errorf("Expected popup 2 to be removed after ready-to-show")
412- }
413- if _, ok := wm.pendingPopups[1]; !ok {
414- t.Errorf("Expected popup 1 to still be pending")
415- }
416- if _, ok := wm.pendingPopups[3]; !ok {
417- t.Errorf("Expected popup 3 to still be pending")
418- }
419-}
D internal/app/browser/workspace_stack_lifecycle.go +0 -92
@@ -1,92 +0,0 @@
1-// workspace_stack_lifecycle.go - Stack pane lifecycle coordination without SafeWidget wrappers
2-package browser
3-
4-import (
5- "errors"
6- "fmt"
7- "github.com/bnema/dumber/internal/logging"
8-)
9-
10-// StackLifecycleManager manages the lifecycle hooks around stacked pane operations.
11-type StackLifecycleManager struct {
12- wm *WorkspaceManager
13- treeValidator *TreeValidator
14-}
15-
16-// NewStackLifecycleManager creates a new stack lifecycle manager.
17-func NewStackLifecycleManager(wm *WorkspaceManager, treeValidator *TreeValidator) *StackLifecycleManager {
18- return &StackLifecycleManager{
19- wm: wm,
20- treeValidator: treeValidator,
21- }
22-}
23-
24-// CloseStackedPaneWithLifecycle validates the stack state and delegates the actual
25-// close operation to the stacked pane manager. This keeps widget handling simple
26-// (raw GTK pointers) while still giving us a single place for additional safety checks.
27-func (slm *StackLifecycleManager) CloseStackedPaneWithLifecycle(node *paneNode) error {
28- if node == nil {
29- return errors.New("node is nil")
30- }
31-
32- stackNode := node.parent
33- if stackNode == nil || !stackNode.isStacked {
34- return errors.New("node is not part of a stacked pane")
35- }
36-
37- logging.Info(fmt.Sprintf("[stack-lifecycle] Closing stacked pane: node=%p stack=%p stackSize=%d",
38- node, stackNode, len(stackNode.stackedPanes)))
39-
40- if err := slm.validateStackState(stackNode, "before close"); err != nil {
41- return fmt.Errorf("invalid stack state before close: %w", err)
42- }
43-
44- if slm.wm == nil || slm.wm.stackedPaneManager == nil {
45- return errors.New("stacked pane manager not available")
46- }
47-
48- if err := slm.wm.stackedPaneManager.CloseStackedPane(node); err != nil {
49- return err
50- }
51-
52- if slm.treeValidator != nil {
53- if err := slm.treeValidator.ValidateTree(slm.wm.root, "after stack close"); err != nil {
54- logging.Error(fmt.Sprintf("[stack-lifecycle] Tree validation failed after stack close: %v", err))
55- }
56- }
57-
58- return nil
59-}
60-
61-// validateStackState validates the current state of a stack before we perform operations.
62-func (slm *StackLifecycleManager) validateStackState(stackNode *paneNode, operation string) error {
63- if stackNode == nil {
64- return errors.New("stack node is nil")
65- }
66-
67- if !stackNode.isStacked {
68- return errors.New("node is not marked as stacked")
69- }
70-
71- if len(stackNode.stackedPanes) == 0 {
72- return errors.New("stack has no panes")
73- }
74-
75- if stackNode.activeStackIndex < 0 || stackNode.activeStackIndex >= len(stackNode.stackedPanes) {
76- return fmt.Errorf("invalid active stack index: %d (size: %d)",
77- stackNode.activeStackIndex, len(stackNode.stackedPanes))
78- }
79-
80- for i, pane := range stackNode.stackedPanes {
81- if pane == nil {
82- return fmt.Errorf("stacked pane at index %d is nil", i)
83- }
84- if pane.parent != stackNode {
85- return fmt.Errorf("stacked pane at index %d has wrong parent", i)
86- }
87- }
88-
89- logging.Info(fmt.Sprintf("[stack-lifecycle] Stack state valid for operation '%s': size=%d activeIndex=%d",
90- operation, len(stackNode.stackedPanes), stackNode.activeStackIndex))
91- return nil
92-}
D internal/app/browser/workspace_tree_rebalancer.go +0 -65
@@ -1,65 +0,0 @@
1-// workspace_tree_rebalancer.go - Simplified no-op tree rebalancer placeholder.
2-//
3-// The original TreeRebalancer relied on SafeWidget abstractions. Until a
4-// pointer-based implementation is restored, we keep a lightweight stub so the
5-// rest of the workspace code can continue to depend on the API without
6-// carrying dead logic.
7-package browser
8-
9-// TreeRebalancer is currently a no-op shim kept for API compatibility.
10-type TreeRebalancer struct {
11- enabled bool
12-}
13-
14-// RebalanceOperation describes an attempted rebalance step (unused in shim).
15-type RebalanceOperation struct {
16- Type RebalanceType
17- Node *paneNode
18- Description string
19-}
20-
21-// RebalanceType enumerates rebalancer operations (kept for completeness).
22-type RebalanceType int
23-
24-const (
25- RebalanceRotateLeft RebalanceType = iota
26- RebalanceRotateRight
27- RebalancePromote
28- RebalanceRestructure
29-)
30-
31-// TreeMetrics represents the structural metrics of the workspace tree.
32-type TreeMetrics struct{}
33-
34-// NewTreeRebalancer returns a disabled stub implementation.
35-// NOTE: Tree rebalancing logic has been temporarily disabled in favor of manual geometry management.
36-// Future work may restore automatic rebalancing or replace it with an alternative approach.
37-func NewTreeRebalancer(_ *WorkspaceManager, _ *TreeValidator) *TreeRebalancer {
38- return &TreeRebalancer{}
39-}
40-
41-// SetMaxImbalance is preserved for API compatibility.
42-func (tr *TreeRebalancer) SetMaxImbalance(_ int) {}
43-
44-// Enable toggles the stub on (no functional impact).
45-func (tr *TreeRebalancer) Enable() { tr.enabled = true }
46-
47-// Disable toggles the stub off.
48-func (tr *TreeRebalancer) Disable() { tr.enabled = false }
49-
50-// RebalanceAfterClose is a no-op in the stub implementation.
51-func (tr *TreeRebalancer) RebalanceAfterClose(_ *paneNode, _ *paneNode) error {
52- return nil
53-}
54-
55-// CalculateTreeMetrics returns empty metrics in the stub.
56-func (tr *TreeRebalancer) CalculateTreeMetrics(_ *paneNode) TreeMetrics {
57- return TreeMetrics{}
58-}
59-
60-// GetRebalancingStats exposes minimal instrumentation for diagnostics.
61-func (tr *TreeRebalancer) GetRebalancingStats() map[string]interface{} {
62- return map[string]interface{}{
63- "enabled": tr != nil && tr.enabled,
64- }
65-}
D internal/app/browser/workspace_tree_validator.go +0 -53
@@ -1,53 +0,0 @@
1-// workspace_tree_validator.go - Simplified validator stub to keep API surface.
2-package browser
3-
4-import "fmt"
5-
6-// TreeValidator previously performed extensive tree checks. The SafeWidget
7-// removal rendered the implementation obsolete, so we keep a lightweight shim
8-// until a new validator is written.
9-type TreeValidator struct {
10- enabled bool
11-}
12-
13-// NewTreeValidator returns a stub validator instance.
14-func NewTreeValidator(enabled bool, _ bool) *TreeValidator {
15- return &TreeValidator{enabled: enabled}
16-}
17-
18-// ValidateTree is a no-op that reports success unless validation is explicitly enabled.
19-func (tv *TreeValidator) ValidateTree(_ *paneNode, _ string) error {
20- if tv == nil || !tv.enabled {
21- return nil
22- }
23- return fmt.Errorf("tree validation not implemented")
24-}
25-
26-// SetDebugMode preserved for compatibility.
27-func (tv *TreeValidator) SetDebugMode(_ bool) {}
28-
29-// Enable turns validation on (no functional impact in the stub).
30-func (tv *TreeValidator) Enable() {
31- if tv != nil {
32- tv.enabled = true
33- }
34-}
35-
36-// Disable turns validation off.
37-func (tv *TreeValidator) Disable() {
38- if tv != nil {
39- tv.enabled = false
40- }
41-}
42-
43-// GetValidationStats mimics the old diagnostic output.
44-func (tv *TreeValidator) GetValidationStats() map[string]interface{} {
45- return map[string]interface{}{
46- "enabled": tv != nil && tv.enabled,
47- }
48-}
49-
50-// Enabled reports current state (used by tests/instrumentation).
51-func (tv *TreeValidator) Enabled() bool {
52- return tv != nil && tv.enabled
53-}
D internal/app/browser/workspace_types.go +0 -110
@@ -1,110 +0,0 @@
1-// workspace_types.go - Type definitions and constants for workspace management
2-package browser
3-
4-import (
5- "os"
6-
7- "github.com/bnema/dumber/pkg/webkit"
8- "github.com/diamondburned/gotk4/pkg/gtk/v4"
9-)
10-
11-// DebugLevel controls validation and safety checks
12-type DebugLevel int
13-
14-const (
15- // DebugOff disables all validation (production mode)
16- DebugOff DebugLevel = iota
17- // DebugBasic enables basic validation only (development default)
18- DebugBasic
19- // DebugFull enables all validation and detailed logging (testing)
20- DebugFull
21-)
22-
23-// getDebugLevel reads debug level from environment variable
24-func getDebugLevel() DebugLevel {
25- switch os.Getenv("DUMBER_DEBUG_WORKSPACE") {
26- case "off", "0":
27- return DebugOff
28- case "basic", "1":
29- return DebugBasic
30- case "full", "2":
31- return DebugFull
32- default:
33- // Default to basic for development
34- return DebugBasic
35- }
36-}
37-
38-// paneNode represents a node in the workspace pane tree structure.
39-// It can be either a leaf node (containing a browser pane) or a branch node
40-// (containing child nodes for split panes or stacked panes).
41-//
42-// Node Types:
43-// 1. Regular Leaf (isLeaf=true, windowType=Tab): Normal browsing pane
44-// 2. Popup Leaf (isLeaf=true, windowType=Popup): OAuth/feature-restricted popup
45-// 3. Stacked Container (isStacked=true, no left/right): Terminal branch with stackedPanes[]
46-// 4. Split Branch (isLeaf=false, has left+right): Pure layout node with GtkPaned
47-type paneNode struct {
48- pane *BrowserPane
49- parent *paneNode
50- left *paneNode
51- right *paneNode
52-
53- // Widget management - gotk4 widgets (all ops on main thread)
54- container gtk.Widgetter // Main container: *gtk.Paned (branch), *gtk.Box (stack), or WebView widget (leaf)
55- borderOverlay gtk.Widgetter // Border overlay widget (for active pane visual indicator)
56- orientation gtk.Orientation
57- isLeaf bool
58- isPopup bool // Deprecated: use windowType instead
59-
60- // Window type tracking
61- windowType webkit.WindowType // Tab or Popup
62- windowFeatures *webkit.WindowFeatures // Features if popup
63- isRelated bool // Shares context
64- parentPane *paneNode // Parent for related views
65- activePopupChildren []string // WebView IDs of active popup children (for tracking related popups)
66- autoClose bool // Auto-close on OAuth success
67- requestID string // Request ID for popup (used for localStorage cleanup)
68- popupID uint64 // Unique popup ID (for bare WebView lifecycle tracking)
69- hoverToken *gtk.EventControllerMotion // Hover event controller for mouse-based focus
70- focusControllerToken uintptr // Focus controller token (stored as native pointer)
71- pendingHoverReattach bool // Needs hover reattachment after reparent
72- pendingFocusReattach bool // Needs focus reattachment after reparent
73- lastHoverTime int64 // Timestamp of last hover event (for debouncing)
74- lastFocusEnterTime int64 // Timestamp of last focus enter event (for deduplication)
75- lastFocusLeaveTime int64 // Timestamp of last focus leave event (for deduplication)
76-
77- // Stacked panes support - terminal branch nodes
78- isStacked bool // Whether this node contains stacked panes
79- stackedPanes []*paneNode // List of stacked panes (if isStacked)
80- activeStackIndex int // Index of currently visible pane in stack
81- titleBar gtk.Widgetter // *gtk.Box for title bar (when collapsed)
82- stackWrapper gtk.Widgetter // Internal *gtk.Box containing the actual stacked widgets (titles + webviews)
83-
84- // Cleanup tracking
85- widgetValid bool // Guard flagged before GTK destruction
86- cleanupGeneration uint // Helps assert that asynchronous callbacks skip stale nodes
87- pendingIdleHandles map[uintptr]struct{} // Idle callbacks touching this node
88-}
89-
90-// Workspace CSS class constants
91-const (
92- basePaneClass = "workspace-pane"
93- multiPaneClass = "workspace-multi-pane"
94- activePaneClass = "workspace-pane-active"
95- outlinePaneClass = "workspace-pane-active-outline"
96- stackContainerClass = "stacked-pane-container"
97-)
98-
99-// pendingPopup tracks a popup WebView that has been created by WebKit but not
100-// yet fully initialized/inserted into the workspace. It allows the ready-to-show
101-// handler to finish setup once WebKit has configured window properties.
102-type pendingPopup struct {
103- wrappedView *webkit.WebView
104- parentView *webkit.WebView
105- parentNode *paneNode
106- url string
107-}
108-
109-// Focus calculation epsilon for geometric comparisons
110-const focusEpsilon = 1e-3
D internal/app/browser/workspace_utils.go +0 -347
@@ -1,347 +0,0 @@
1-// workspace_utils.go - Utility functions and helpers for workspace management
2-package browser
3-
4-import (
5- "fmt"
6-
7- "github.com/bnema/dumber/internal/logging"
8- "github.com/bnema/dumber/pkg/webkit"
9- glib "github.com/diamondburned/gotk4/pkg/glib/v2"
10- gtk "github.com/diamondburned/gotk4/pkg/gtk/v4"
11-)
12-
13-// UpdateTitleBar updates the title bar label for a WebView in stacked panes
14-func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string) {
15- // Delegate to StackedPaneManager which has the correct logic
16- if wm.stackedPaneManager != nil {
17- wm.stackedPaneManager.UpdateTitleBar(webView, title)
18- }
19-}
20-
21-// ensureGUIInPane lazily loads GUI components into a pane when it gains focus
22-func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
23- if pane == nil {
24- return
25- }
26-
27- // GUI is already injected globally via WebKit's enableUserContentManager
28- if !pane.HasGUI() {
29- pane.SetHasGUI(true)
30- pane.SetGUIComponent("manager", true)
31- pane.SetGUIComponent("omnibox", true)
32- }
33-}
34-
35-// mapDirection maps a direction string to GTK orientation and positioning
36-// The bool return value indicates whether the existing pane should be the first child (start)
37-// In GTK Paned with Vertical orientation: start=top, end=bottom
38-// In GTK Paned with Horizontal orientation: start=left, end=right
39-func mapDirection(direction string) (gtk.Orientation, bool) {
40- switch direction {
41- case DirectionLeft:
42- // New pane goes to left, existing to right
43- return gtk.OrientationHorizontal, false
44- case DirectionUp:
45- // New pane goes to top (start), existing to bottom (end)
46- // So existingFirst = false
47- return gtk.OrientationVertical, false
48- case DirectionDown:
49- // New pane goes to bottom (end), existing to top (start)
50- // So existingFirst = true
51- return gtk.OrientationVertical, true
52- default:
53- // Right: new pane to right, existing to left
54- return gtk.OrientationHorizontal, true
55- }
56-}
57-
58-// Widget management utilities
59-
60-// initializePaneWidgets sets up widgets for a paneNode
61-func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, widget gtk.Widgetter) {
62- // Only wrap leaf nodes in overlays (not branch nodes like GtkPaned)
63- if node.isLeaf {
64- // Wrap the widget in an overlay with a border (for active pane indication)
65- overlay, borderWidget := wrapPaneInOverlay(widget)
66-
67- // Store the overlay as the container and the border widget
68- node.container = overlay
69- node.borderOverlay = borderWidget
70- } else {
71- // Branch nodes (splits, stacks) don't need border overlays
72- node.container = widget
73- node.borderOverlay = nil
74- }
75-
76- // Add base CSS class to the container
77- if node.container != nil {
78- webkit.WidgetAddCSSClass(node.container, basePaneClass)
79- }
80-
81- // Initialize other widget fields as nil (will be set when needed)
82- node.titleBar = nil
83- node.stackWrapper = nil
84-
85- // Initialize cleanup tracking fields
86- node.widgetValid = true
87- node.cleanupGeneration = 0
88-}
89-
90-// setContainer sets the container widget
91-func (wm *WorkspaceManager) setContainer(node *paneNode, widget gtk.Widgetter, typeInfo string) {
92- _ = typeInfo // Type info kept for API compatibility but not used
93-
94- // Only wrap leaf nodes in overlays (not branch nodes like stacked containers)
95- if node.isLeaf {
96- // Wrap the widget in an overlay with a border (for active pane indication)
97- overlay, borderWidget := wrapPaneInOverlay(widget)
98-
99- // Store the overlay as the container and the border widget
100- node.container = overlay
101- node.borderOverlay = borderWidget
102- } else {
103- // Branch nodes (splits, stacks) don't need border overlays
104- node.container = widget
105- node.borderOverlay = nil
106- }
107-
108- // Mark widget validity based on container presence so idle guards run for stacks too
109- if widget != nil {
110- node.widgetValid = true
111- } else {
112- node.widgetValid = false
113- }
114-}
115-
116-// setTitleBar sets the titleBar widget
117-func (wm *WorkspaceManager) setTitleBar(node *paneNode, widget gtk.Widgetter) {
118- node.titleBar = widget
119-}
120-
121-// setStackWrapper sets the stackWrapper widget
122-func (wm *WorkspaceManager) setStackWrapper(node *paneNode, widget gtk.Widgetter) {
123- node.stackWrapper = widget
124-}
125-
126-// Centralized Active Pane Border Management System
127-// Handles all pane types: regular, popup, stacked, split panes
128-
129-// borderTargets returns the widgets that should reflect the pane border state.
130-func (wm *WorkspaceManager) borderTargets(node *paneNode) []gtk.Widgetter {
131- if node == nil {
132- return nil
133- }
134-
135- targets := make([]gtk.Widgetter, 0, 4)
136- seen := make(map[*gtk.Widget]struct{})
137- add := func(widget gtk.Widgetter) {
138- if widget == nil {
139- return
140- }
141- if base := gtk.BaseWidget(widget); base != nil {
142- if _, exists := seen[base]; exists {
143- return
144- }
145- seen[base] = struct{}{}
146- }
147- targets = append(targets, widget)
148- }
149-
150- // Primary container for this node.
151- add(node.container)
152- add(node.stackWrapper)
153-
154- // Highlight stacks via their outer container as well.
155- if node.parent != nil && node.parent.isStacked {
156- add(node.parent.container)
157- add(node.parent.stackWrapper)
158- }
159-
160- // Fallback to the WebView root widget to cover any edge cases where the
161- // container reference is temporarily nil.
162- if node.pane != nil && node.pane.webView != nil {
163- add(node.pane.webView.RootWidget())
164- }
165-
166- if len(targets) == 0 {
167- return nil
168- }
169- return targets
170-}
171-
172-// applyActivePaneBorder shows the border overlay for the active pane.
173-func (wm *WorkspaceManager) applyActivePaneBorder(node *paneNode) {
174- if wm == nil || node == nil || node.borderOverlay == nil {
175- return
176- }
177-
178- // Only show active border if there are 2+ panes
179- leaves := wm.collectLeaves()
180- if len(leaves) < 2 {
181- return
182- }
183-
184- webkit.WidgetSetVisible(node.borderOverlay, true)
185- logging.Debug(fmt.Sprintf("[workspace] Showing border overlay for pane %p", node))
186-}
187-
188-// removeActivePaneBorder hides the border overlay for a pane.
189-func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) {
190- if wm == nil || node == nil || node.borderOverlay == nil {
191- return
192- }
193-
194- webkit.WidgetSetVisible(node.borderOverlay, false)
195- logging.Debug(fmt.Sprintf("[workspace] Hiding border overlay for pane %p", node))
196-}
197-
198-// Widget cleanup utilities
199-
200-// cleanupWidget safely invalidates and cleans up a widget
201-func (wm *WorkspaceManager) cleanupWidget(widget gtk.Widgetter) {
202- if widget == nil {
203- return
204- }
205-
206- // Widget will be destroyed by GTK when unparented/removed
207- // No manual cleanup needed - GTK4 handles reference counting
208-}
209-
210-// scheduleIdleGuarded queues a callback to run on the GTK main loop and tracks it against
211-// the provided pane nodes so it can be cancelled safely if the nodes are destroyed.
212-func (wm *WorkspaceManager) scheduleIdleGuarded(fn func() bool, nodes ...*paneNode) uintptr {
213- if wm == nil || fn == nil {
214- return 0
215- }
216- guards := uniquePaneNodes(nodes...)
217- var handle uintptr
218- handle = uintptr(glib.IdleAdd(func() bool {
219- defer wm.releaseIdleHandle(handle)
220- for _, node := range guards {
221- if node == nil {
222- continue
223- }
224- if !node.widgetValid {
225- return false
226- }
227- }
228- return fn()
229- }))
230- if handle != 0 {
231- wm.registerIdleHandle(handle, guards...)
232- }
233- return handle
234-}
235-
236-func uniquePaneNodes(nodes ...*paneNode) []*paneNode {
237- if len(nodes) == 0 {
238- return nil
239- }
240- seen := make(map[*paneNode]struct{}, len(nodes))
241- unique := make([]*paneNode, 0, len(nodes))
242- for _, node := range nodes {
243- if node == nil {
244- continue
245- }
246- if _, ok := seen[node]; ok {
247- continue
248- }
249- seen[node] = struct{}{}
250- unique = append(unique, node)
251- }
252- return unique
253-}
254-
255-func (wm *WorkspaceManager) registerIdleHandle(handle uintptr, nodes ...*paneNode) {
256- if wm == nil || handle == 0 {
257- return
258- }
259- unique := uniquePaneNodes(nodes...)
260- if len(unique) == 0 {
261- return
262- }
263- if wm.pendingIdle == nil {
264- wm.pendingIdle = make(map[uintptr][]*paneNode)
265- }
266- wm.pendingIdle[handle] = unique
267- for _, node := range unique {
268- if node.pendingIdleHandles == nil {
269- node.pendingIdleHandles = make(map[uintptr]struct{})
270- }
271- node.pendingIdleHandles[handle] = struct{}{}
272- }
273-}
274-
275-func (wm *WorkspaceManager) releaseIdleHandle(handle uintptr) {
276- if wm == nil || handle == 0 {
277- return
278- }
279- if wm.pendingIdle == nil {
280- return
281- }
282- nodes, ok := wm.pendingIdle[handle]
283- if !ok {
284- return
285- }
286- delete(wm.pendingIdle, handle)
287- for _, node := range nodes {
288- if node == nil || node.pendingIdleHandles == nil {
289- continue
290- }
291- delete(node.pendingIdleHandles, handle)
292- if len(node.pendingIdleHandles) == 0 {
293- node.pendingIdleHandles = nil
294- }
295- }
296-}
297-
298-func (wm *WorkspaceManager) cancelIdleHandle(handle uintptr) {
299- if handle == 0 {
300- return
301- }
302- wm.releaseIdleHandle(handle)
303- glib.SourceRemove(glib.SourceHandle(handle))
304-}
305-
306-func (wm *WorkspaceManager) cancelIdleHandles(node *paneNode) {
307- if wm == nil || node == nil || len(node.pendingIdleHandles) == 0 {
308- return
309- }
310- handles := make([]uintptr, 0, len(node.pendingIdleHandles))
311- for handle := range node.pendingIdleHandles {
312- handles = append(handles, handle)
313- }
314- for _, handle := range handles {
315- wm.cancelIdleHandle(handle)
316- }
317-}
318-
319-// Note: nextCleanupGeneration, updateMainPane, dumpTreeState, paneCloseLogf
320-// are defined in workspace_pane_ops.go and workspace_debug.go
321-
322-// formatPaneInfo returns a debug string for a pane node
323-func formatPaneInfo(node *paneNode) string {
324- if node == nil {
325- return "nil"
326- }
327-
328- var info string
329- if node.isLeaf {
330- info = fmt.Sprintf("leaf(container=%p", node.container)
331- if node.pane != nil && node.pane.webView != nil {
332- info += fmt.Sprintf(" webview=%d", node.pane.webView.ID())
333- }
334- if node.windowType == webkit.WindowTypePopup {
335- info += " popup"
336- }
337- info += ")"
338- } else if node.isStacked {
339- info = fmt.Sprintf("stack(panes=%d active=%d container=%p)",
340- len(node.stackedPanes), node.activeStackIndex, node.container)
341- } else {
342- info = fmt.Sprintf("branch(container=%p orientation=%v)",
343- node.container, node.orientation)
344- }
345-
346- return info
347-}
D internal/app/cli/runner.go +0 -79
@@ -1,79 +0,0 @@
1-package cli
2-
3-import (
4- "fmt"
5- "os"
6-
7- "github.com/bnema/dumber/internal/cli"
8- "github.com/bnema/dumber/internal/config"
9- "github.com/bnema/dumber/internal/logging"
10-)
11-
12-// Execute runs the CLI functionality using the existing CLI package
13-func Execute(version, commit, buildDate string) {
14- // Initialize configuration system
15- if err := config.Init(); err != nil {
16- fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err)
17- os.Exit(1)
18- }
19-
20- // Initialize logging system for CLI mode (file logging only, no output capture)
21- cfg := config.Get()
22- if err := logging.Init(
23- cfg.Logging.LogDir,
24- cfg.Logging.Level,
25- cfg.Logging.Format,
26- cfg.Logging.EnableFileLog,
27- cfg.Logging.MaxSize,
28- cfg.Logging.MaxBackups,
29- cfg.Logging.MaxAge,
30- cfg.Logging.Compress,
31- ); err != nil {
32- fmt.Fprintf(os.Stderr, "Warning: failed to initialize logging: %v\n", err)
33- }
34-
35- // Start configuration watching for live reload
36- if err := config.Watch(); err != nil {
37- // Don't exit on watch error, just warn
38- fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err)
39- }
40-
41- rootCmd := cli.NewRootCmd(version, commit, buildDate)
42-
43- // Handle dmenu flag at the root level for direct integration
44- if len(os.Args) > 1 && os.Args[1] == "--dmenu" {
45- handleDmenuMode()
46- return
47- }
48-
49- // Execute the root command
50- if err := rootCmd.Execute(); err != nil {
51- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
52- os.Exit(1)
53- }
54-}
55-
56-// handleDmenuMode processes dmenu-specific functionality
57-func handleDmenuMode() {
58- // Create a temporary CLI instance to handle dmenu mode
59- cliInstance, err := cli.NewCLI()
60- if err != nil {
61- fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err)
62- os.Exit(1)
63- }
64- defer func() {
65- if closeErr := cliInstance.Close(); closeErr != nil {
66- fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
67- }
68- }()
69-
70- // Generate dmenu options
71- dmenuCmd := cli.NewDmenuCmd()
72- if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil {
73- fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err)
74- if closeErr := cliInstance.Close(); closeErr != nil {
75- fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
76- }
77- os.Exit(1)
78- }
79-}
D internal/app/constants/constants.go +0 -23
@@ -1,23 +0,0 @@
1-// Package constants contains shared constants for the application.
2-package constants
3-
4-// MIME type and path constants
5-const (
6- ContentTypeJSON = "application/json; charset=utf-8"
7- HomepagePath = "homepage"
8- BlockedPath = "blocked"
9- IndexHTML = "index.html"
10- BlockedHTML = "blocked.html"
11-)
12-
13-// Parsing and utility constants
14-const (
15- ColonSplitParts = 2 // Number of parts when splitting on colon
16- DirPerm = 0o755 // Directory permissions
17-)
18-
19-// JavaScript hash constants (auto-generated, should not be changed)
20-const (
21- HashGetRecentHistory = 3708519028 // BrowserService.GetRecentHistory(limit)
22- HashGetSearchShortcuts = 4078533762 // BrowserService.GetSearchShortcuts()
23-)
D internal/app/control/clipboard.go +0 -66
@@ -1,66 +0,0 @@
1-package control
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/dumber/internal/logging"
7- "github.com/bnema/dumber/pkg/clipboard"
8- "github.com/bnema/dumber/pkg/webkit"
9-)
10-
11-// ClipboardController handles clipboard operations
12-type ClipboardController struct {
13- webView *webkit.WebView
14-}
15-
16-// NewClipboardController creates a new clipboard controller
17-func NewClipboardController(webView *webkit.WebView) *ClipboardController {
18- return &ClipboardController{
19- webView: webView,
20- }
21-}
22-
23-// CopyCurrentURL copies the current WebView URL to the clipboard
24-func (c *ClipboardController) CopyCurrentURL() {
25- logging.Debug(fmt.Sprintf("Shortcut: Copy URL"))
26- currentURL := c.webView.GetCurrentURL()
27- if currentURL == "" {
28- logging.Warn(fmt.Sprintf("No URL to copy"))
29- c.showToast("No URL to copy", "error")
30- return
31- }
32-
33- if err := clipboard.CopyToClipboard(currentURL); err != nil {
34- logging.Error(fmt.Sprintf("Failed to copy URL to clipboard: %v", err))
35- c.showToast("Failed to copy URL", "error")
36- } else {
37- logging.Debug(fmt.Sprintf("URL copied to clipboard: %s", currentURL))
38- c.showToast("URL copied to clipboard", "success")
39- }
40-}
41-
42-func (c *ClipboardController) showToast(message, toastType string) {
43- if c == nil || c.webView == nil {
44- return
45- }
46-
47- detail := map[string]any{
48- "message": message,
49- "duration": 2000,
50- }
51- if toastType != "" {
52- detail["type"] = toastType
53- }
54-
55- if err := c.webView.DispatchCustomEvent("dumber:toast", detail); err != nil {
56- logging.Error(fmt.Sprintf("Failed to dispatch toast event: %v", err))
57- }
58-}
59-
60-// Detach releases the WebView reference when the pane is closed.
61-func (c *ClipboardController) Detach() {
62- if c == nil {
63- return
64- }
65- c.webView = nil
66-}
D internal/app/control/navigation.go +0 -149
@@ -1,149 +0,0 @@
1-package control
2-
3-import (
4- "context"
5- "fmt"
6- "os"
7-
8- "github.com/bnema/dumber/internal/logging"
9- "github.com/bnema/dumber/internal/services"
10- "github.com/bnema/dumber/pkg/webkit"
11-)
12-
13-// NavigationController manages navigation functionality
14-type NavigationController struct {
15- parserService *services.ParserService
16- browserService *services.BrowserService
17- webView *webkit.WebView
18- zoomController *ZoomController
19-}
20-
21-// NewNavigationController creates a new navigation controller
22-func NewNavigationController(
23- parserService *services.ParserService,
24- browserService *services.BrowserService,
25- webView *webkit.WebView,
26- zoomController *ZoomController,
27-) *NavigationController {
28- return &NavigationController{
29- parserService: parserService,
30- browserService: browserService,
31- webView: webView,
32- zoomController: zoomController,
33- }
34-}
35-
36-// NavigateToURL parses input and navigates to the resulting URL
37-func (n *NavigationController) NavigateToURL(input string) error {
38- ctx := context.Background()
39- result, err := n.parserService.ParseInput(ctx, input)
40- if err != nil {
41- return err
42- }
43-
44- // Record navigation in browser service
45- if _, navErr := n.browserService.Navigate(ctx, result.URL); navErr != nil {
46- logging.Warn(fmt.Sprintf("Warning: failed to navigate to %s: %v", result.URL, navErr))
47- }
48-
49- var (
50- zoomLevel float64
51- haveZoom bool
52- )
53- if n.browserService != nil {
54- if level, err := n.browserService.GetZoomLevel(ctx, result.URL); err == nil {
55- zoomLevel = level
56- haveZoom = true
57- if n.webView != nil {
58- if n.webView.UsesDomZoom() {
59- if err := n.webView.SeedDomZoom(zoomLevel); err != nil {
60- logging.Warn(fmt.Sprintf("Warning: failed to seed DOM zoom for %s: %v", result.URL, err))
61- }
62- } else {
63- n.webView.RunOnMainThread(func() {
64- if err := n.webView.SetZoom(zoomLevel); err != nil {
65- logging.Warn(fmt.Sprintf("Warning: failed to prime native zoom for %s: %v", result.URL, err))
66- }
67- })
68- }
69- }
70- } else {
71- logging.Warn(fmt.Sprintf("Warning: failed to lookup zoom for %s: %v", result.URL, err))
72- }
73- }
74-
75- // Load URL in WebView
76- if err := n.webView.LoadURL(result.URL); err != nil {
77- return err
78- }
79-
80- // Apply zoom for the new URL using the cached level when available.
81- if n.zoomController != nil {
82- if haveZoom {
83- n.zoomController.ApplyZoomForURLWithLevel(result.URL, zoomLevel, false)
84- } else {
85- n.zoomController.ApplyZoomForURL(result.URL)
86- }
87- }
88-
89- return nil
90-}
91-
92-// HandleBrowseCommand processes the browse command line argument
93-func (n *NavigationController) HandleBrowseCommand() {
94- if len(os.Args) >= 3 && os.Args[1] == "browse" {
95- logging.Debug(fmt.Sprintf("Browse command detected: %s", os.Args[2]))
96- ctx := context.Background()
97- result, err := n.parserService.ParseInput(ctx, os.Args[2])
98- if err == nil {
99- logging.Debug(fmt.Sprintf("Parsed input → URL: %s", result.URL))
100- if _, navErr := n.browserService.Navigate(ctx, result.URL); navErr != nil {
101- logging.Warn(fmt.Sprintf("Warning: failed to navigate to %s: %v", result.URL, navErr))
102- }
103- if n.webView != nil {
104- logging.Debug(fmt.Sprintf("Loading URL in WebView: %s", result.URL))
105- var (
106- zoomLevel float64
107- haveZoom bool
108- )
109- if n.browserService != nil {
110- if level, err := n.browserService.GetZoomLevel(ctx, result.URL); err == nil {
111- zoomLevel = level
112- haveZoom = true
113- if n.webView.UsesDomZoom() {
114- if err := n.webView.SeedDomZoom(zoomLevel); err != nil {
115- logging.Warn(fmt.Sprintf("Warning: failed to seed DOM zoom for %s: %v", result.URL, err))
116- }
117- } else {
118- n.webView.RunOnMainThread(func() {
119- if err := n.webView.SetZoom(zoomLevel); err != nil {
120- logging.Warn(fmt.Sprintf("Warning: failed to prime native zoom for %s: %v", result.URL, err))
121- }
122- })
123- }
124- } else {
125- logging.Warn(fmt.Sprintf("Warning: failed to lookup zoom for %s: %v", result.URL, err))
126- }
127- }
128- if err := n.webView.LoadURL(result.URL); err != nil {
129- logging.Warn(fmt.Sprintf("Warning: failed to load URL: %v", err))
130- }
131- if n.zoomController != nil {
132- if haveZoom {
133- n.zoomController.ApplyZoomForURLWithLevel(result.URL, zoomLevel, false)
134- } else {
135- n.zoomController.ApplyZoomForURL(result.URL)
136- }
137- }
138- }
139- }
140- }
141-}
142-
143-// Detach releases the associated WebView to avoid calls after destruction.
144-func (n *NavigationController) Detach() {
145- if n == nil {
146- return
147- }
148- n.webView = nil
149-}
D internal/app/control/zoom.go +0 -198
@@ -1,198 +0,0 @@
1-package control
2-
3-import (
4- "context"
5- "fmt"
6- "time"
7-
8- "github.com/bnema/dumber/internal/logging"
9- "github.com/bnema/dumber/internal/services"
10- "github.com/bnema/dumber/pkg/webkit"
11-)
12-
13-// ZoomController manages zoom functionality for the WebView
14-type ZoomController struct {
15- currentURL string
16- lastZoomDomain string
17- programmaticChange bool
18- programmaticTimer *time.Timer
19- browserService *services.BrowserService
20- webView *webkit.WebView
21- initializing bool // true until first load completes, suppresses toasts
22-}
23-
24-// WebViewInterface defines the interface for WebView zoom operations
25-type WebViewInterface interface {
26- GetCurrentURL() string
27- SetZoom(level float64) error
28- InjectScript(script string) error
29- RegisterZoomChangedHandler(handler func(float64))
30- RegisterURIChangedHandler(handler func(string))
31- RegisterLoadCommittedHandler(handler func(string))
32-}
33-
34-// NewZoomController creates a new zoom controller
35-func NewZoomController(browserService *services.BrowserService, webView *webkit.WebView) *ZoomController {
36- return &ZoomController{
37- currentURL: "dumb://homepage",
38- browserService: browserService,
39- webView: webView,
40- initializing: true,
41- }
42-}
43-
44-// RegisterHandlers sets up the zoom-related event handlers
45-func (z *ZoomController) RegisterHandlers() {
46- z.webView.RegisterZoomChangedHandler(z.handleZoomChange)
47- z.webView.RegisterURIChangedHandler(z.handleURIChange)
48- z.webView.RegisterLoadCommittedHandler(z.handleLoadCommitted)
49-}
50-
51-// handleURIChange tracks URL changes but doesn't apply zoom (wait for load-committed)
52-func (z *ZoomController) handleURIChange(url string) {
53- z.currentURL = url
54-}
55-
56-// handleLoadCommitted responds to load committed events and applies saved zoom levels
57-// This fires when the page actually starts loading content, preventing visual glitches
58-func (z *ZoomController) handleLoadCommitted(url string) {
59- if url == "" {
60- return
61- }
62- // First load complete, allow toasts from now on
63- z.initializing = false
64- currentDomain := services.ZoomKeyForLog(url)
65- z.loadZoomLevelAsync(url, currentDomain, false)
66-}
67-
68-// handleZoomChange responds to zoom level changes and persists them
69-func (z *ZoomController) handleZoomChange(level float64) {
70- url := z.webView.GetCurrentURL()
71- if url == "" {
72- return
73- }
74-
75- go func(url string, level float64) {
76- ctx := context.Background()
77- if err := z.browserService.SetZoomLevel(ctx, url, level); err != nil {
78- logging.Error(fmt.Sprintf("[zoom] failed to save level %.2f for %s: %v", level, url, err))
79- return
80- }
81- key := services.ZoomKeyForLog(url)
82- logging.Debug(fmt.Sprintf("[zoom] saved %.2f for %s", level, key))
83- }(url, level)
84-
85- // Only show toast for user-initiated zoom changes, not during init or programmatic changes
86- if !z.programmaticChange && !z.initializing {
87- z.showZoomToast(level)
88- }
89-}
90-
91-// showZoomToast displays a zoom level notification using TypeScript toast system
92-func (z *ZoomController) showZoomToast(level float64) {
93- logging.Debug(fmt.Sprintf("[zoom] Attempting to show zoom toast for level %.2f", level))
94-
95- if z.webView == nil {
96- logging.Debug(fmt.Sprintf("[zoom] webview unavailable for zoom toast"))
97- return
98- }
99-
100- if err := z.webView.DispatchCustomEvent("dumber:toast:zoom", map[string]any{"level": level}); err != nil {
101- logging.Error(fmt.Sprintf("[zoom] failed to dispatch zoom toast: %v", err))
102- }
103-}
104-
105-// ApplyInitialZoom sets the initial zoom level for the current URL
106-func (z *ZoomController) ApplyInitialZoom() {
107- ctx := context.Background()
108- url := z.webView.GetCurrentURL()
109- if url == "" {
110- url = "dumb://homepage"
111- }
112- if zoomLevel, err := z.browserService.GetZoomLevel(ctx, url); err == nil {
113- z.applyZoomLevel(url, services.ZoomKeyForLog(url), zoomLevel, false)
114- }
115-}
116-
117-// ApplyZoomForURL applies zoom for a specific URL (used for navigation)
118-func (z *ZoomController) ApplyZoomForURL(url string) {
119- if url == "" {
120- return
121- }
122- currentDomain := services.ZoomKeyForLog(url)
123- z.loadZoomLevelAsync(url, currentDomain, false)
124-}
125-
126-// ApplyZoomForURLWithLevel applies a known zoom level without hitting the database again.
127-func (z *ZoomController) ApplyZoomForURLWithLevel(url string, zoomLevel float64, allowToast bool) {
128- if z == nil || url == "" {
129- return
130- }
131- z.applyZoomLevel(url, services.ZoomKeyForLog(url), zoomLevel, allowToast)
132-}
133-
134-func (z *ZoomController) loadZoomLevelAsync(url, domain string, allowToast bool) {
135- if z.browserService == nil || z.webView == nil || url == "" {
136- return
137- }
138- go func(url, domain string, allowToast bool) {
139- ctx := context.Background()
140- zoomLevel, err := z.browserService.GetZoomLevel(ctx, url)
141- if err != nil {
142- return
143- }
144-
145- z.applyZoomLevel(url, domain, zoomLevel, allowToast)
146- }(url, domain, allowToast)
147-}
148-
149-func (z *ZoomController) applyZoomLevel(url, domain string, zoomLevel float64, allowToast bool) {
150- if z == nil || z.webView == nil || url == "" {
151- return
152- }
153-
154- z.webView.RunOnMainThread(func() {
155- if z.webView == nil {
156- return
157- }
158- if z.currentURL != url {
159- z.currentURL = url
160- }
161-
162- // Clear any existing timer
163- if z.programmaticTimer != nil {
164- z.programmaticTimer.Stop()
165- }
166-
167- z.programmaticChange = true
168-
169- // Reset programmatic change flag after a delay to handle async zoom events
170- z.programmaticTimer = time.AfterFunc(200*time.Millisecond, func() {
171- z.programmaticChange = false
172- z.programmaticTimer = nil
173- })
174-
175- if err := z.webView.SetZoom(zoomLevel); err != nil {
176- logging.Warn(fmt.Sprintf("Warning: failed to set zoom: %v", err))
177- return
178- }
179-
180- logging.Debug(fmt.Sprintf("[zoom] loaded %.2f for %s", zoomLevel, domain))
181- if allowToast && domain != z.lastZoomDomain && z.lastZoomDomain != "" {
182- z.showZoomToast(zoomLevel)
183- }
184- z.lastZoomDomain = domain
185- })
186-}
187-
188-// DetachWebView releases the WebView reference and stops timers.
189-func (z *ZoomController) DetachWebView() {
190- if z == nil {
191- return
192- }
193- if z.programmaticTimer != nil {
194- z.programmaticTimer.Stop()
195- z.programmaticTimer = nil
196- }
197- z.webView = nil
198-}
D internal/app/environment/codec.go +0 -150
@@ -1,150 +0,0 @@
1-package environment
2-
3-import (
4- "fmt"
5- "os"
6- "strconv"
7- "strings"
8-
9- "github.com/bnema/dumber/internal/config"
10- "github.com/bnema/dumber/internal/logging"
11-)
12-
13-// ApplyCodecConfiguration applies codec preferences to environment variables and GStreamer settings
14-func ApplyCodecConfiguration(codecPrefs config.CodecConfig) {
15- logging.Info(fmt.Sprintf("[codec] Applying codec preferences: preferred=%s, force_av1=%t, block_vp9=%t",
16- codecPrefs.PreferredCodecs, codecPrefs.ForceAV1, codecPrefs.BlockVP9))
17-
18- // Build comprehensive GStreamer plugin rankings
19- var rankSettings []string
20-
21- // Get existing GST_PLUGIN_FEATURE_RANK or start fresh
22- existingRank := os.Getenv("GST_PLUGIN_FEATURE_RANK")
23- if existingRank != "" {
24- rankSettings = strings.Split(existingRank, ",")
25- }
26-
27- // AV1 decoder promotions (high priority)
28- if codecPrefs.ForceAV1 || strings.Contains(codecPrefs.PreferredCodecs, "av1") {
29- av1Ranks := []string{
30- "vaav1dec:768", // VA-API AV1 decoder (highest priority)
31- "nvav1dec:512", // NVDEC AV1 decoder
32- "av1dec:384", // Software AV1 decoder (fallback)
33- "avdec_av1:256", // libav AV1 decoder
34- }
35- rankSettings = append(rankSettings, av1Ranks...)
36- logging.Info(fmt.Sprintf("[codec] Promoted AV1 decoders for maximum priority"))
37- }
38-
39- // H.264 decoder management
40- if strings.Contains(codecPrefs.PreferredCodecs, "h264") {
41- h264Ranks := []string{
42- "vah264dec:512", // VA-API H.264 decoder
43- "nvh264dec:384", // NVDEC H.264 decoder
44- "avdec_h264:256", // Software H.264 decoder
45- }
46- rankSettings = append(rankSettings, h264Ranks...)
47- }
48-
49- // VP9 decoder demotion/blocking
50- if codecPrefs.DisableVP9Hardware || codecPrefs.BlockVP9 {
51- vp9Demotions := []string{
52- "vaapivp9dec:0", // Block VA-API VP9
53- "nvvp9dec:0", // Block NVDEC VP9
54- }
55- if codecPrefs.BlockVP9 {
56- vp9Demotions = append(vp9Demotions, "vp9dec:0", "avdec_vp9:0") // Block all VP9
57- } else {
58- // Allow software VP9 with low priority
59- vp9Demotions = append(vp9Demotions, "vp9dec:64", "avdec_vp9:64")
60- }
61- rankSettings = append(rankSettings, vp9Demotions...)
62- logging.Info(fmt.Sprintf("[codec] Demoted/blocked VP9 decoders"))
63- }
64-
65- // VP8 decoder blocking
66- if codecPrefs.BlockVP8 {
67- vp8Demotions := []string{
68- "vaapivp8dec:0",
69- "vp8dec:0",
70- "avdec_vp8:0",
71- }
72- rankSettings = append(rankSettings, vp8Demotions...)
73- logging.Info(fmt.Sprintf("[codec] Blocked VP8 decoders"))
74- }
75-
76- // Promote hardware JPEG decoders for faster image loading
77- jpegRanks := []string{
78- "vaapijpegdec:512", // VA-API JPEG decoder (highest priority)
79- "nvjpegdec:384", // NVDEC JPEG decoder
80- "jpegdec:256", // Software fallback
81- }
82- rankSettings = append(rankSettings, jpegRanks...)
83- logging.Info("[codec] Promoted hardware JPEG decoders")
84-
85- // Apply the complete ranking system
86- if len(rankSettings) > 0 {
87- finalRank := strings.Join(rankSettings, ",")
88- if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", finalRank); err != nil {
89- logging.Warn(fmt.Sprintf("[codec] Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err))
90- } else {
91- logging.Info(fmt.Sprintf("[codec] Set comprehensive GST_PLUGIN_FEATURE_RANK: %s", finalRank))
92- }
93- }
94-
95- // Set video buffer sizes if specified
96- if codecPrefs.VideoBufferSizeMB > 0 {
97- bufferSize := strconv.Itoa(codecPrefs.VideoBufferSizeMB * 1024 * 1024)
98-
99- if err := os.Setenv("GST_BUFFER_SIZE", bufferSize); err != nil {
100- logging.Warn(fmt.Sprintf("[codec] Warning: failed to set GST_BUFFER_SIZE: %v", err))
101- }
102-
103- if err := os.Setenv("GST_QUEUE2_MAX_SIZE_BYTES", bufferSize); err != nil {
104- logging.Warn(fmt.Sprintf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_BYTES: %v", err))
105- }
106-
107- logging.Info(fmt.Sprintf("[codec] Set video buffer size to %dMB", codecPrefs.VideoBufferSizeMB))
108- }
109-
110- // Set queue buffer time if specified
111- if codecPrefs.QueueBufferTimeSec > 0 {
112- // Convert seconds to nanoseconds for GStreamer
113- bufferTime := strconv.Itoa(codecPrefs.QueueBufferTimeSec * 1000000000)
114-
115- if err := os.Setenv("GST_QUEUE2_MAX_SIZE_TIME", bufferTime); err != nil {
116- logging.Warn(fmt.Sprintf("[codec] Warning: failed to set GST_QUEUE2_MAX_SIZE_TIME: %v", err))
117- } else {
118- logging.Info(fmt.Sprintf("[codec] Set queue buffer time to %ds", codecPrefs.QueueBufferTimeSec))
119- }
120- }
121-
122- // Set additional codec-specific environment variables
123- if codecPrefs.ForceAV1 {
124- // Enable AV1 hardware decoding if available
125- if err := os.Setenv("GST_AV1_DECODER_ENABLE_HW", "1"); err != nil {
126- logging.Warn(fmt.Sprintf("[codec] Warning: failed to set GST_AV1_DECODER_ENABLE_HW: %v", err))
127- }
128- }
129-
130- // Disable video post-processing to avoid VA-API issues
131- if err := os.Setenv("GST_VAAPI_DISABLE_VPP", "1"); err != nil {
132- logging.Warn(fmt.Sprintf("[codec] Warning: failed to set GST_VAAPI_DISABLE_VPP: %v", err))
133- }
134-
135- logging.Info(fmt.Sprintf("[codec] Codec configuration applied successfully"))
136-}
137-
138-// BuildBlockedCodecsList converts config codec blocking preferences to a string slice
139-func BuildBlockedCodecsList(codecPrefs config.CodecConfig) []string {
140- var blocked []string
141-
142- if codecPrefs.BlockVP9 {
143- blocked = append(blocked, "vp9")
144- }
145- if codecPrefs.BlockVP8 {
146- blocked = append(blocked, "vp8")
147- }
148-
149- return blocked
150-}
D internal/app/environment/locale.go +0 -101
@@ -1,101 +0,0 @@
1-package environment
2-
3-import (
4- "fmt"
5- "os"
6- "os/exec"
7- "strings"
8-
9- "github.com/bnema/dumber/internal/logging"
10-)
11-
12-// DetectAndSetKeyboardLocale attempts to determine the current keyboard layout
13-// and hints it to the webkit input layer for accelerator compatibility.
14-func DetectAndSetKeyboardLocale() {
15- const colonSplitParts = 2 // Number of parts when splitting on colon
16-
17- var locale string
18-
19- // 0) Explicit override
20- locale = os.Getenv("DUMBER_KEYBOARD_LOCALE")
21- if locale == "" {
22- locale = os.Getenv("DUMB_BROWSER_KEYBOARD_LOCALE") // legacy prefix
23- }
24-
25- // 1) XKB env override (actual keyboard layout)
26- if locale == "" {
27- locale = os.Getenv("XKB_DEFAULT_LAYOUT")
28- }
29-
30- // 2) Best-effort probe of localectl/setxkbmap (actual keyboard layout)
31- // Check these BEFORE LANG since LANG is system language, not keyboard layout
32- if locale == "" {
33- if out, err := exec.Command("localectl", "status").Output(); err == nil {
34- s := string(out)
35- for _, line := range strings.Split(s, "\n") {
36- line = strings.TrimSpace(line)
37- if strings.HasPrefix(strings.ToLower(line), "x11 layout:") || strings.HasPrefix(strings.ToLower(line), "keyboard layout:") {
38- parts := strings.SplitN(line, ":", colonSplitParts)
39- if len(parts) == colonSplitParts {
40- cand := strings.TrimSpace(parts[1])
41- if cand != "" {
42- locale = cand
43- logging.Debug(fmt.Sprintf("[locale] keyboard layout from localectl: %s", locale))
44- }
45- }
46- break
47- }
48- }
49- }
50- }
51- if locale == "" {
52- if out, err := exec.Command("setxkbmap", "-query").Output(); err == nil {
53- s := string(out)
54- for _, line := range strings.Split(s, "\n") {
55- line = strings.TrimSpace(line)
56- if strings.HasPrefix(strings.ToLower(line), "layout:") {
57- parts := strings.SplitN(line, ":", colonSplitParts)
58- if len(parts) == colonSplitParts {
59- cand := strings.TrimSpace(parts[1])
60- if cand != "" {
61- locale = cand
62- logging.Debug(fmt.Sprintf("[locale] keyboard layout from setxkbmap: %s", locale))
63- }
64- }
65- break
66- }
67- }
68- }
69- }
70-
71- // 3) Fallback to system locale (language, not keyboard layout)
72- if locale == "" {
73- locale = os.Getenv("LC_ALL")
74- }
75- if locale == "" {
76- locale = os.Getenv("LANG")
77- }
78- if locale == "" {
79- locale = os.Getenv("LC_CTYPE")
80- }
81- // Trim variants from locale
82- if locale != "" {
83- if i := strings.IndexByte(locale, '.'); i > 0 {
84- locale = locale[:i]
85- }
86- if i := strings.IndexByte(locale, '@'); i > 0 {
87- locale = locale[:i]
88- }
89- if i := strings.IndexByte(locale, '_'); i > 0 {
90- locale = locale[:i]
91- }
92- logging.Debug(fmt.Sprintf("[locale] keyboard layout from system locale: %s", locale))
93- }
94-
95- if locale == "" {
96- locale = "en"
97- logging.Debug(fmt.Sprintf("[locale] keyboard layout defaulted to: %s", locale))
98- }
99-
100- logging.Info(fmt.Sprintf("[locale] keyboard locale detected: %s", locale))
101-}
D internal/app/environment/media.go +0 -28
@@ -1,28 +0,0 @@
1-package environment
2-
3-import (
4- "fmt"
5- "os"
6-
7- "github.com/bnema/dumber/internal/logging"
8-)
9-
10-// SetupMediaPipeline proactively hardens media pipeline to avoid WebProcess crashes from buggy VAAPI/plugins.
11-// Can be disabled with DUMBER_MEDIA_SAFE=0
12-func SetupMediaPipeline() {
13- if os.Getenv("DUMBER_MEDIA_SAFE") != "0" {
14- // Demote VAAPI-related elements so they won't be auto-picked
15- if os.Getenv("GST_PLUGIN_FEATURE_RANK") == "" {
16- if err := os.Setenv("GST_PLUGIN_FEATURE_RANK", "vaapisink:0,vaapidecodebin:0,vaapih264dec:0,vaapivideoconvert:0"); err != nil {
17- logging.Warn(fmt.Sprintf("Warning: failed to set GST_PLUGIN_FEATURE_RANK: %v", err))
18- }
19- }
20- // Optionally disable media stream features that can engage complex pipelines (webcam/mic)
21- if os.Getenv("WEBKIT_DISABLE_MEDIA_STREAM") == "" {
22- if err := os.Setenv("WEBKIT_DISABLE_MEDIA_STREAM", "1"); err != nil {
23- logging.Warn(fmt.Sprintf("Warning: failed to set WEBKIT_DISABLE_MEDIA_STREAM: %v", err))
24- }
25- }
26- logging.Info(fmt.Sprintf("[media] Safe mode enabled: VAAPI demoted, webcam/mic disabled (override with DUMBER_MEDIA_SAFE=0)"))
27- }
28-}
D internal/app/messaging/handler.go +0 -482
@@ -1,482 +0,0 @@
1-package messaging
2-
3-import (
4- "bytes"
5- "context"
6- "encoding/json"
7- "fmt"
8- "strconv"
9-
10- "github.com/bnema/dumber/internal/app/constants"
11- "github.com/bnema/dumber/internal/app/control"
12- "github.com/bnema/dumber/internal/app/messaging/handlers"
13- "github.com/bnema/dumber/internal/config"
14- "github.com/bnema/dumber/internal/filtering"
15- "github.com/bnema/dumber/internal/logging"
16- "github.com/bnema/dumber/internal/services"
17- "github.com/bnema/dumber/pkg/webkit"
18-)
19-
20-// WorkspaceObserver interface for observing workspace events.
21-type WorkspaceObserver interface {
22- OnWorkspaceMessage(source *webkit.WebView, msg Message)
23-}
24-
25-// Handler processes script messages from the WebView
26-type Handler struct {
27- parserService *services.ParserService
28- browserService *services.BrowserService
29- webView *webkit.WebView
30- navigationController *control.NavigationController
31- lastTheme string
32- workspaceObserver WorkspaceObserver
33- // Content filtering
34- filterManager *filtering.FilterManager
35- bypassRegistry *filtering.BypassRegistry
36-}
37-
38-// Message represents a script message from the WebView
39-type Message struct {
40- Type string `json:"type"`
41- URL string `json:"url"`
42- Q string `json:"q"`
43- Limit int `json:"limit"`
44- Offset int `json:"offset"`
45- Value string `json:"value"`
46- Event string `json:"event"`
47- Action string `json:"action"`
48- Direction string `json:"direction"`
49- // History operations
50- HistoryID string `json:"historyId"`
51- Range string `json:"range"`
52- Domain string `json:"domain"`
53- // Request tracking
54- RequestID string `json:"requestId"`
55- // Popup close tracking
56- WebViewID string `json:"webviewId"`
57- Reason string `json:"reason"`
58- // Wails fetch bridge
59- ID string `json:"id"`
60- Payload json.RawMessage `json:"payload"`
61- // Favorites operations
62- Title string `json:"title"`
63- FaviconURL string `json:"faviconURL"`
64- // Folder/Tag operations
65- FolderID int64 `json:"folderId"`
66- TagID int64 `json:"tagId"`
67- FavoriteID int64 `json:"favoriteId"`
68- Shortcut int `json:"shortcut"`
69- Name string `json:"name"`
70- Icon string `json:"icon"`
71- Color string `json:"color"`
72- Position int64 `json:"position"`
73-}
74-
75-// NewHandler creates a new message handler
76-func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {
77- return &Handler{
78- parserService: parserService,
79- browserService: browserService,
80- }
81-}
82-
83-// SetNavigationController injects the navigation controller for unified navigation flow.
84-func (h *Handler) SetNavigationController(controller *control.NavigationController) {
85- h.navigationController = controller
86-}
87-
88-// SetWorkspaceObserver registers a workspace event observer.
89-func (h *Handler) SetWorkspaceObserver(observer WorkspaceObserver) {
90- h.workspaceObserver = observer
91-}
92-
93-// SetWebView sets the WebView reference (needed for script injection)
94-func (h *Handler) SetWebView(webView *webkit.WebView) {
95- h.webView = webView
96-}
97-
98-// SetFilterManager sets the filter manager for whitelist operations.
99-func (h *Handler) SetFilterManager(fm *filtering.FilterManager) {
100- h.filterManager = fm
101-}
102-
103-// SetBypassRegistry sets the bypass registry for one-time bypass operations.
104-func (h *Handler) SetBypassRegistry(br *filtering.BypassRegistry) {
105- h.bypassRegistry = br
106-}
107-
108-// Handle processes incoming script messages
109-func (h *Handler) Handle(payload string) {
110- msg, err := parseIncomingMessage(payload)
111- if err != nil {
112- logging.Error(fmt.Sprintf("[ERROR] Failed to unmarshal message: %v", err))
113- return
114- }
115-
116- ctx := h.handlerCtx()
117-
118- switch msg.Type {
119- // ═══════════════════════════════════════════════════════════════
120- // NAVIGATION
121- // ═══════════════════════════════════════════════════════════════
122- case "navigate":
123- handlers.HandleNavigation(ctx, handlers.NavigationMessage{URL: msg.URL})
124-
125- // ═══════════════════════════════════════════════════════════════
126- // OMNIBOX
127- // ═══════════════════════════════════════════════════════════════
128- case "query":
129- if h.webView == nil {
130- return
131- }
132- handlers.HandleQuery(ctx, h.toOmniboxMsg(msg))
133- case "omnibox_initial_history":
134- if h.webView == nil {
135- return
136- }
137- handlers.HandleOmniboxInitialHistory(ctx, h.toOmniboxMsg(msg))
138- case "prefix_query":
139- handlers.HandlePrefixQuery(ctx, h.toOmniboxMsg(msg))
140-
141- // ═══════════════════════════════════════════════════════════════
142- // HISTORY
143- // ═══════════════════════════════════════════════════════════════
144- case "history_recent":
145- handlers.HandleHistoryRecent(ctx, h.toHistoryMsg(msg))
146- case "history_stats":
147- handlers.HandleHistoryStats(ctx)
148- case "history_search":
149- handlers.HandleHistorySearch(ctx, h.toHistoryMsg(msg))
150- case "history_delete", "history_delete_entry":
151- handlers.HandleHistoryDelete(ctx, h.toHistoryMsg(msg))
152- case "history_timeline":
153- handlers.HandleHistoryTimeline(ctx, h.toHistoryMsg(msg))
154- case "history_search_fts":
155- handlers.HandleHistorySearchFTS(ctx, h.toHistoryMsg(msg))
156- case "history_delete_range":
157- handlers.HandleHistoryDeleteRange(ctx, h.toHistoryMsg(msg))
158- case "history_clear_all":
159- handlers.HandleHistoryClearAll(ctx)
160- case "history_delete_domain":
161- handlers.HandleHistoryDeleteDomain(ctx, h.toHistoryMsg(msg))
162- case "history_analytics":
163- handlers.HandleHistoryAnalytics(ctx, h.toHistoryMsg(msg))
164- case "history_domain_stats", "domain_stats":
165- handlers.HandleDomainStats(ctx, h.toHistoryMsg(msg))
166-
167- // ═══════════════════════════════════════════════════════════════
168- // FAVORITES
169- // ═══════════════════════════════════════════════════════════════
170- case "favorite_list", "get_favorites":
171- handlers.HandleGetFavorites(ctx, h.toFavoritesMsg(msg))
172- case "toggle_favorite":
173- handlers.HandleToggleFavorite(ctx, h.toFavoritesMsg(msg))
174- case "is_favorite":
175- handlers.HandleIsFavorite(ctx, h.toFavoritesMsg(msg))
176- case "favorite_set_shortcut":
177- handlers.HandleFavoriteSetShortcut(ctx, h.toFavoritesMsg(msg))
178- case "favorite_get_by_shortcut":
179- handlers.HandleFavoriteGetByShortcut(ctx, h.toFavoritesMsg(msg))
180- case "favorite_set_folder":
181- handlers.HandleFavoriteSetFolder(ctx, h.toFavoritesMsg(msg))
182-
183- // ═══════════════════════════════════════════════════════════════
184- // FOLDERS
185- // ═══════════════════════════════════════════════════════════════
186- case "folder_list":
187- handlers.HandleFolderList(ctx, h.toFavoritesMsg(msg))
188- case "folder_create":
189- handlers.HandleFolderCreate(ctx, h.toFavoritesMsg(msg))
190- case "folder_update":
191- handlers.HandleFolderUpdate(ctx, h.toFavoritesMsg(msg))
192- case "folder_delete":
193- handlers.HandleFolderDelete(ctx, h.toFavoritesMsg(msg))
194-
195- // ═══════════════════════════════════════════════════════════════
196- // TAGS
197- // ═══════════════════════════════════════════════════════════════
198- case "tag_list":
199- handlers.HandleTagList(ctx, h.toFavoritesMsg(msg))
200- case "tag_create":
201- handlers.HandleTagCreate(ctx, h.toFavoritesMsg(msg))
202- case "tag_update":
203- handlers.HandleTagUpdate(ctx, h.toFavoritesMsg(msg))
204- case "tag_delete":
205- handlers.HandleTagDelete(ctx, h.toFavoritesMsg(msg))
206- case "tag_assign":
207- handlers.HandleTagAssign(ctx, h.toFavoritesMsg(msg))
208- case "tag_remove":
209- handlers.HandleTagRemove(ctx, h.toFavoritesMsg(msg))
210-
211- // ═══════════════════════════════════════════════════════════════
212- // WORKSPACE
213- // ═══════════════════════════════════════════════════════════════
214- case "workspace":
215- h.handleWorkspace(msg)
216- case "close-popup":
217- h.handleClosePopup(msg)
218-
219- // ═══════════════════════════════════════════════════════════════
220- // CONTENT FILTERING
221- // ═══════════════════════════════════════════════════════════════
222- case "addToWhitelist":
223- h.handleAddToWhitelist(msg)
224- case "bypassOnce":
225- h.handleBypassOnce(msg)
226-
227- // ═══════════════════════════════════════════════════════════════
228- // MISC
229- // ═══════════════════════════════════════════════════════════════
230- case "wails":
231- handlers.HandleWailsBridge(ctx, handlers.WailsBridgeMessage{
232- ID: msg.ID,
233- Payload: msg.Payload,
234- }, constants.HashGetRecentHistory, constants.HashGetSearchShortcuts)
235- case "theme":
236- handlers.HandleTheme(&h.lastTheme, handlers.ThemeMessage{Value: msg.Value})
237- case "console-message":
238- cfg := config.Get()
239- handlers.HandleConsoleMessage(cfg.Logging.CaptureConsole, handlers.ConsoleMessage{Payload: msg.Payload})
240- case "request-webview-id":
241- handlers.HandleWebViewIDRequest(ctx)
242- case "get_search_shortcuts":
243- handlers.HandleGetSearchShortcuts(ctx)
244- case "get_color_palettes":
245- handlers.HandleGetColorPalettes(ctx)
246- case "keyboard_blocking":
247- handlers.HandleKeyboardBlocking(ctx, handlers.KeyboardBlockingMessage{Action: msg.Action})
248- }
249-}
250-
251-// ═══════════════════════════════════════════════════════════════
252-// MESSAGE PARSING
253-// ═══════════════════════════════════════════════════════════════
254-
255-func parseIncomingMessage(payload string) (Message, error) {
256- data := []byte(payload)
257- var msg Message
258- if err := json.Unmarshal(data, &msg); err == nil {
259- return msg, nil
260- } else {
261- normalized, normErr := normalizeWebViewIDPayload(data)
262- if normErr != nil {
263- return Message{}, err
264- }
265-
266- if err := json.Unmarshal(normalized, &msg); err != nil {
267- return Message{}, err
268- }
269-
270- return msg, nil
271- }
272-}
273-
274-func normalizeWebViewIDPayload(data []byte) ([]byte, error) {
275- var raw map[string]json.RawMessage
276- if err := json.Unmarshal(data, &raw); err != nil {
277- return nil, err
278- }
279-
280- rawID, ok := raw["webviewId"]
281- if !ok {
282- return nil, fmt.Errorf("webviewId missing in payload")
283- }
284-
285- normalizedID, err := parseWebViewIDRaw(rawID)
286- if err != nil {
287- return nil, err
288- }
289-
290- raw["webviewId"] = json.RawMessage(strconv.Quote(normalizedID))
291-
292- return json.Marshal(raw)
293-}
294-
295-func parseWebViewIDRaw(raw json.RawMessage) (string, error) {
296- trimmed := bytes.TrimSpace(raw)
297- if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("null")) {
298- return "", nil
299- }
300-
301- if trimmed[0] == '"' {
302- var id string
303- if err := json.Unmarshal(trimmed, &id); err != nil {
304- return "", err
305- }
306- return id, nil
307- }
308-
309- var number json.Number
310- if err := json.Unmarshal(trimmed, &number); err == nil {
311- return number.String(), nil
312- }
313-
314- return "", fmt.Errorf("unsupported webviewId format: %s", string(trimmed))
315-}
316-
317-// ═══════════════════════════════════════════════════════════════
318-// WORKSPACE HANDLERS (require WorkspaceObserver interface)
319-// ═══════════════════════════════════════════════════════════════
320-
321-func (h *Handler) handleWorkspace(msg Message) {
322- if h.workspaceObserver == nil {
323- logging.Warn(fmt.Sprintf("[workspace] Received workspace event %q but no observer registered", msg.Event))
324- return
325- }
326- if h.webView == nil {
327- logging.Debug(fmt.Sprintf("[workspace] Ignoring workspace event %q: webview not attached", msg.Event))
328- return
329- }
330- logging.Debug(fmt.Sprintf("[workspace] Forwarding workspace event: event=%s direction=%s action=%s webviewId=%s", msg.Event, msg.Direction, msg.Action, msg.WebViewID))
331- h.workspaceObserver.OnWorkspaceMessage(h.webView, msg)
332-}
333-
334-func (h *Handler) handleClosePopup(msg Message) {
335- logging.Debug(fmt.Sprintf("[messaging] Received close-popup request: webviewId=%s reason=%s", msg.WebViewID, msg.Reason))
336-
337- if h.workspaceObserver == nil {
338- logging.Warn("[messaging] No workspace observer registered for close-popup request")
339- return
340- }
341- if h.webView == nil {
342- logging.Debug("[messaging] No webview attached for close-popup request")
343- return
344- }
345-
346- closeMsg := Message{
347- Type: "workspace",
348- Event: "close-popup",
349- WebViewID: msg.WebViewID,
350- Reason: msg.Reason,
351- }
352-
353- logging.Debug(fmt.Sprintf("[messaging] Forwarding close-popup to workspace: webviewId=%s reason=%s", msg.WebViewID, msg.Reason))
354- h.workspaceObserver.OnWorkspaceMessage(h.webView, closeMsg)
355-}
356-
357-// ═══════════════════════════════════════════════════════════════
358-// CONTENT FILTERING HANDLERS
359-// ═══════════════════════════════════════════════════════════════
360-
361-func (h *Handler) handleAddToWhitelist(msg Message) {
362- if h.filterManager == nil {
363- logging.Error("[filtering] Cannot add to whitelist: filter manager not set")
364- h.sendBlockedPageResponse(msg.RequestID, false, nil, "Filter manager not available")
365- return
366- }
367-
368- domain := msg.Domain
369- if domain == "" {
370- logging.Error("[filtering] Cannot add to whitelist: domain is empty")
371- h.sendBlockedPageResponse(msg.RequestID, false, nil, "Domain is required")
372- return
373- }
374-
375- ctx := context.Background()
376- if err := h.filterManager.AddToWhitelist(ctx, domain); err != nil {
377- logging.Error(fmt.Sprintf("[filtering] Failed to add %s to whitelist: %v", domain, err))
378- h.sendBlockedPageResponse(msg.RequestID, false, nil, err.Error())
379- return
380- }
381-
382- logging.Info(fmt.Sprintf("[filtering] Added %s to whitelist", domain))
383- h.sendBlockedPageResponse(msg.RequestID, true, map[string]interface{}{"domain": domain}, "")
384-}
385-
386-func (h *Handler) handleBypassOnce(msg Message) {
387- if h.bypassRegistry == nil {
388- logging.Error("[filtering] Cannot bypass: bypass registry not set")
389- h.sendBlockedPageResponse(msg.RequestID, false, nil, "Bypass registry not available")
390- return
391- }
392-
393- url := msg.URL
394- if url == "" {
395- logging.Error("[filtering] Cannot bypass: URL is empty")
396- h.sendBlockedPageResponse(msg.RequestID, false, nil, "URL is required")
397- return
398- }
399-
400- h.bypassRegistry.AllowOnce(url)
401- logging.Info(fmt.Sprintf("[filtering] Allowed one-time bypass for %s", url))
402- h.sendBlockedPageResponse(msg.RequestID, true, map[string]interface{}{"url": url}, "")
403-}
404-
405-func (h *Handler) sendBlockedPageResponse(requestID string, success bool, data interface{}, errMsg string) {
406- if h.webView == nil {
407- return
408- }
409-
410- response := map[string]interface{}{
411- "requestId": requestID,
412- "success": success,
413- }
414- if data != nil {
415- response["data"] = data
416- }
417- if errMsg != "" {
418- response["error"] = errMsg
419- }
420-
421- jsonBytes, err := json.Marshal(response)
422- if err != nil {
423- logging.Error(fmt.Sprintf("[filtering] Failed to marshal response: %v", err))
424- return
425- }
426-
427- script := fmt.Sprintf("window.postMessage(%s, '*')", string(jsonBytes))
428- if err := h.webView.InjectScript(script); err != nil {
429- logging.Error(fmt.Sprintf("[filtering] Failed to inject response: %v", err))
430- }
431-}
432-
433-// ═══════════════════════════════════════════════════════════════
434-// CONTEXT AND MESSAGE CONVERSION
435-// ═══════════════════════════════════════════════════════════════
436-
437-func (h *Handler) handlerCtx() *handlers.Context {
438- return &handlers.Context{
439- ParserService: h.parserService,
440- BrowserService: h.browserService,
441- WebView: h.webView,
442- NavController: h.navigationController,
443- }
444-}
445-
446-func (h *Handler) toHistoryMsg(msg Message) handlers.HistoryMessage {
447- return handlers.HistoryMessage{
448- Type: msg.Type,
449- Q: msg.Q,
450- Limit: msg.Limit,
451- Offset: msg.Offset,
452- HistoryID: msg.HistoryID,
453- RequestID: msg.RequestID,
454- Range: msg.Range,
455- Domain: msg.Domain,
456- }
457-}
458-
459-func (h *Handler) toFavoritesMsg(msg Message) handlers.FavoritesMessage {
460- return handlers.FavoritesMessage{
461- Type: msg.Type,
462- URL: msg.URL,
463- Title: msg.Title,
464- FaviconURL: msg.FaviconURL,
465- ID: msg.FavoriteID,
466- FolderID: msg.FolderID,
467- TagID: msg.TagID,
468- Shortcut: msg.Shortcut,
469- Name: msg.Name,
470- Icon: msg.Icon,
471- Color: msg.Color,
472- Position: msg.Position,
473- RequestID: msg.RequestID,
474- }
475-}
476-
477-func (h *Handler) toOmniboxMsg(msg Message) handlers.OmniboxMessage {
478- return handlers.OmniboxMessage{
479- Q: msg.Q,
480- Limit: msg.Limit,
481- }
482-}
D internal/app/messaging/handler_test.go +0 -27
@@ -1,27 +0,0 @@
1-package messaging
2-
3-import "testing"
4-
5-func TestParseIncomingMessageAcceptsNumericWebViewID(t *testing.T) {
6- payload := `{"type":"workspace","event":"focus","webviewId":42}`
7- msg, err := parseIncomingMessage(payload)
8- if err != nil {
9- t.Fatalf("unexpected error parsing payload: %v", err)
10- }
11-
12- if msg.WebViewID != "42" {
13- t.Fatalf("expected webviewId to be \"42\", got %q", msg.WebViewID)
14- }
15-}
16-
17-func TestParseIncomingMessagePreservesStringWebViewID(t *testing.T) {
18- payload := `{"type":"workspace","event":"focus","webviewId":"popup-123"}`
19- msg, err := parseIncomingMessage(payload)
20- if err != nil {
21- t.Fatalf("unexpected error parsing payload: %v", err)
22- }
23-
24- if msg.WebViewID != "popup-123" {
25- t.Fatalf("expected webviewId to remain popup-123, got %q", msg.WebViewID)
26- }
27-}
D internal/app/messaging/handlers/context.go +0 -175
@@ -1,175 +0,0 @@
1-// Package handlers contains domain-specific message handlers for WebView communication.
2-package handlers
3-
4-import (
5- "context"
6- "encoding/json"
7-
8- "github.com/bnema/dumber/internal/config"
9- "github.com/bnema/dumber/internal/parser"
10- "github.com/bnema/dumber/internal/services"
11-)
12-
13-// NavigationController interface for URL navigation.
14-type NavigationController interface {
15- NavigateToURL(input string) error
16-}
17-
18-// WebViewInjector interface for WebView script injection operations.
19-type WebViewInjector interface {
20- InjectScript(script string) error
21- DispatchCustomEvent(eventName string, data interface{}) error
22- LoadURL(url string) error
23- SetZoom(level float64) error
24- ID() uint64
25- IsDestroyed() bool
26-}
27-
28-// Parser interface for input parsing operations.
29-type Parser interface {
30- ParseInput(ctx context.Context, input string) (*parser.ParseResult, error)
31-}
32-
33-// Browser interface for browser service operations used by handlers.
34-type Browser interface {
35- // Navigation
36- Navigate(ctx context.Context, url string) (*services.NavigationResult, error)
37- GetZoomLevel(ctx context.Context, url string) (float64, error)
38-
39- // History
40- GetRecentHistory(ctx context.Context, limit int) ([]services.HistoryEntry, error)
41- GetRecentHistoryWithOffset(ctx context.Context, limit, offset int) ([]services.HistoryEntry, error)
42- GetMostVisited(ctx context.Context, limit int) ([]services.HistoryEntry, error)
43- SearchHistory(ctx context.Context, query string, limit int) ([]services.HistoryEntry, error)
44- DeleteHistoryEntry(ctx context.Context, id int64) error
45- GetHistoryStats(ctx context.Context) (map[string]interface{}, error)
46- GetHistoryTimeline(ctx context.Context, limit, offset int) ([]services.TimelineEntry, error)
47- SearchHistoryFTS(ctx context.Context, query string, limit int) ([]services.HistoryEntry, error)
48- DeleteHistoryLastHour(ctx context.Context) error
49- DeleteHistoryLastDay(ctx context.Context) error
50- DeleteHistoryLastWeek(ctx context.Context) error
51- DeleteHistoryLastMonth(ctx context.Context) error
52- ClearAllHistory(ctx context.Context) error
53- DeleteHistoryByDomain(ctx context.Context, domain string) error
54- GetHistoryAnalytics(ctx context.Context) (*services.HistoryAnalytics, error)
55- GetDomainStats(ctx context.Context, limit int) ([]services.DomainStat, error)
56-
57- // Favorites
58- GetFavorites(ctx context.Context) ([]services.FavoriteEntry, error)
59- ToggleFavorite(ctx context.Context, url, title, faviconURL string) (bool, error)
60- IsFavorite(ctx context.Context, url string) (bool, error)
61- SetFavoriteShortcut(ctx context.Context, favoriteID int64, shortcut int) error
62- GetFavoriteByShortcut(ctx context.Context, shortcut int) (*services.FavoriteEntry, error)
63- SetFavoriteFolder(ctx context.Context, favoriteID int64, folderID *int64) error
64-
65- // Folders
66- GetFolders(ctx context.Context) ([]services.FolderEntry, error)
67- CreateFolder(ctx context.Context, name, icon string) (*services.FolderEntry, error)
68- UpdateFolder(ctx context.Context, id int64, name, icon string) error
69- DeleteFolder(ctx context.Context, id int64) error
70-
71- // Tags
72- GetTags(ctx context.Context) ([]services.TagEntry, error)
73- CreateTag(ctx context.Context, name, color string) (*services.TagEntry, error)
74- UpdateTag(ctx context.Context, id int64, name, color string) error
75- DeleteTag(ctx context.Context, id int64) error
76- AssignTag(ctx context.Context, favoriteID, tagID int64) error
77- RemoveTag(ctx context.Context, favoriteID, tagID int64) error
78-
79- // Config
80- GetConfig(ctx context.Context) (*config.Config, error)
81- GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error)
82- GetColorPalettesForMessaging() services.ColorPalettesResponse
83- GetBestPrefixMatch(ctx context.Context, query string) string
84-}
85-
86-// Context provides shared dependencies for all handlers.
87-type Context struct {
88- ParserService Parser
89- BrowserService Browser
90- WebView WebViewInjector
91- NavController NavigationController
92-}
93-
94-// Convenience type aliases for handler return types
95-type (
96- HistoryEntry = services.HistoryEntry
97- TimelineEntry = services.TimelineEntry
98- HistoryAnalytics = services.HistoryAnalytics
99- DomainStat = services.DomainStat
100- FavoriteEntry = services.FavoriteEntry
101- FolderEntry = services.FolderEntry
102- TagEntry = services.TagEntry
103- ColorPalettesResponse = services.ColorPalettesResponse
104-)
105-
106-// InjectJSON marshals data and injects it via a JavaScript callback function.
107-func (c *Context) InjectJSON(callbackName string, data any) error {
108- if c.WebView == nil {
109- return nil
110- }
111- b, err := json.Marshal(data)
112- if err != nil {
113- return err
114- }
115- script := "window." + callbackName + " && window." + callbackName + "(" + string(b) + ")"
116- return c.WebView.InjectScript(script)
117-}
118-
119-// InjectJSONWithRequestID marshals data and injects it via a JavaScript callback with requestId.
120-func (c *Context) InjectJSONWithRequestID(callbackName string, data any, requestID string) error {
121- if c.WebView == nil {
122- return nil
123- }
124- b, err := json.Marshal(data)
125- if err != nil {
126- return err
127- }
128- var script string
129- if requestID != "" {
130- script = "window." + callbackName + " && window." + callbackName + "(" + string(b) + ", '" + requestID + "')"
131- } else {
132- script = "window." + callbackName + " && window." + callbackName + "(" + string(b) + ")"
133- }
134- return c.WebView.InjectScript(script)
135-}
136-
137-// InjectError sends an error message to a JavaScript error callback.
138-func (c *Context) InjectError(callbackName, message string) error {
139- if c.WebView == nil {
140- return nil
141- }
142- escaped, _ := json.Marshal(message)
143- script := "window." + callbackName + " && window." + callbackName + "(" + string(escaped) + ")"
144- return c.WebView.InjectScript(script)
145-}
146-
147-// InjectErrorWithRequestID sends an error with requestId for promise rejection.
148-func (c *Context) InjectErrorWithRequestID(callbackName, message, requestID string) error {
149- if c.WebView == nil {
150- return nil
151- }
152- escaped, _ := json.Marshal(message)
153- var script string
154- if requestID != "" {
155- script = "window." + callbackName + " && window." + callbackName + "(" + string(escaped) + ", '" + requestID + "')"
156- } else {
157- script = "window." + callbackName + " && window." + callbackName + "(" + string(escaped) + ")"
158- }
159- return c.WebView.InjectScript(script)
160-}
161-
162-// Ctx returns a background context for database operations.
163-func (c *Context) Ctx() context.Context {
164- return context.Background()
165-}
166-
167-// IsReady checks if WebView and BrowserService are available.
168-func (c *Context) IsReady() bool {
169- return c.WebView != nil && c.BrowserService != nil
170-}
171-
172-// IsWebViewReady checks if WebView is available and not destroyed.
173-func (c *Context) IsWebViewReady() bool {
174- return c.WebView != nil && !c.WebView.IsDestroyed()
175-}
D internal/app/messaging/handlers/favorites.go +0 -389
@@ -1,389 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5- "strconv"
6-
7- "github.com/bnema/dumber/internal/logging"
8-)
9-
10-// FavoritesMessage contains fields for favorites operations.
11-type FavoritesMessage struct {
12- Type string `json:"type"`
13- URL string `json:"url"`
14- Title string `json:"title"`
15- FaviconURL string `json:"faviconURL"`
16- ID int64 `json:"id"`
17- FolderID int64 `json:"folderId"`
18- TagID int64 `json:"tagId"`
19- Shortcut int `json:"shortcut"` // 1-9
20- Name string `json:"name"`
21- Icon string `json:"icon"`
22- Color string `json:"color"`
23- Position int64 `json:"position"`
24- RequestID string `json:"requestId"`
25-}
26-
27-// HandleGetFavorites sends all favorites to JavaScript.
28-func HandleGetFavorites(c *Context, msg FavoritesMessage) {
29- if !c.IsWebViewReady() || c.BrowserService == nil {
30- return
31- }
32-
33- favorites, err := c.BrowserService.GetFavorites(c.Ctx())
34- if err != nil {
35- logging.Error(fmt.Sprintf("[handlers] Failed to get favorites: %v", err))
36- _ = c.InjectError("__dumber_favorites_error", "Failed to get favorites")
37- return
38- }
39-
40- logging.Debug(fmt.Sprintf("[handlers] Sending %d favorites to JavaScript", len(favorites)))
41- _ = c.InjectJSONWithRequestID("__dumber_favorites", favorites, msg.RequestID)
42-}
43-
44-// HandleToggleFavorite adds or removes a URL from favorites.
45-func HandleToggleFavorite(c *Context, msg FavoritesMessage) {
46- if !c.IsWebViewReady() || c.BrowserService == nil {
47- return
48- }
49-
50- if msg.URL == "" {
51- logging.Warn("[handlers] Cannot toggle favorite - URL is empty")
52- return
53- }
54-
55- added, err := c.BrowserService.ToggleFavorite(c.Ctx(), msg.URL, msg.Title, msg.FaviconURL)
56- if err != nil {
57- logging.Error(fmt.Sprintf("[handlers] Failed to toggle favorite for %s: %v", msg.URL, err))
58- _ = c.InjectError("__dumber_favorite_toggled_error", "Failed to toggle favorite")
59- return
60- }
61-
62- logging.Debug(fmt.Sprintf("[handlers] Favorite toggled for %s (added: %v)", msg.URL, added))
63- _ = c.InjectJSON("__dumber_favorite_toggled", map[string]interface{}{
64- "url": msg.URL,
65- "added": added,
66- })
67-}
68-
69-// HandleIsFavorite checks if a URL is favorited.
70-func HandleIsFavorite(c *Context, msg FavoritesMessage) {
71- if !c.IsWebViewReady() || c.BrowserService == nil {
72- return
73- }
74-
75- if msg.URL == "" {
76- return
77- }
78-
79- isFavorite, err := c.BrowserService.IsFavorite(c.Ctx(), msg.URL)
80- if err != nil {
81- logging.Error(fmt.Sprintf("[handlers] Failed to check if favorite for %s: %v", msg.URL, err))
82- return
83- }
84-
85- _ = c.InjectJSON("__dumber_is_favorite", map[string]interface{}{
86- "url": msg.URL,
87- "isFavorite": isFavorite,
88- })
89-}
90-
91-// ═══════════════════════════════════════════════════════════════
92-// FOLDER HANDLERS
93-// ═══════════════════════════════════════════════════════════════
94-
95-// HandleFolderList returns all favorite folders.
96-func HandleFolderList(c *Context, msg FavoritesMessage) {
97- if !c.IsWebViewReady() || c.BrowserService == nil {
98- return
99- }
100-
101- folders, err := c.BrowserService.GetFolders(c.Ctx())
102- if err != nil {
103- logging.Error(fmt.Sprintf("[handlers] Failed to get folders: %v", err))
104- _ = c.InjectError("__dumber_folder_error", "Failed to load folders")
105- return
106- }
107-
108- _ = c.InjectJSONWithRequestID("__dumber_folders", folders, msg.RequestID)
109-}
110-
111-// HandleFolderCreate creates a new folder.
112-func HandleFolderCreate(c *Context, msg FavoritesMessage) {
113- if !c.IsWebViewReady() || c.BrowserService == nil {
114- return
115- }
116-
117- if msg.Name == "" {
118- _ = c.InjectErrorWithRequestID("__dumber_error", "Folder name is required", msg.RequestID)
119- return
120- }
121-
122- folder, err := c.BrowserService.CreateFolder(c.Ctx(), msg.Name, msg.Icon)
123- if err != nil {
124- logging.Error(fmt.Sprintf("[handlers] Failed to create folder: %v", err))
125- _ = c.InjectErrorWithRequestID("__dumber_error", "Failed to create folder", msg.RequestID)
126- return
127- }
128-
129- _ = c.InjectJSONWithRequestID("__dumber_folders", folder, msg.RequestID)
130-}
131-
132-// HandleFolderUpdate updates an existing folder.
133-func HandleFolderUpdate(c *Context, msg FavoritesMessage) {
134- if !c.IsWebViewReady() || c.BrowserService == nil {
135- return
136- }
137-
138- if msg.ID == 0 {
139- _ = c.InjectError("__dumber_folder_error", "Folder ID is required")
140- return
141- }
142-
143- if err := c.BrowserService.UpdateFolder(c.Ctx(), msg.ID, msg.Name, msg.Icon); err != nil {
144- logging.Error(fmt.Sprintf("[handlers] Failed to update folder %d: %v", msg.ID, err))
145- _ = c.InjectError("__dumber_folder_error", "Failed to update folder")
146- return
147- }
148-
149- _ = c.InjectJSON("__dumber_folder_updated", map[string]int64{"id": msg.ID})
150-}
151-
152-// HandleFolderDelete deletes a folder.
153-func HandleFolderDelete(c *Context, msg FavoritesMessage) {
154- if !c.IsWebViewReady() || c.BrowserService == nil {
155- return
156- }
157-
158- if msg.ID == 0 {
159- _ = c.InjectError("__dumber_folder_error", "Folder ID is required")
160- return
161- }
162-
163- if err := c.BrowserService.DeleteFolder(c.Ctx(), msg.ID); err != nil {
164- logging.Error(fmt.Sprintf("[handlers] Failed to delete folder %d: %v", msg.ID, err))
165- _ = c.InjectError("__dumber_folder_error", "Failed to delete folder")
166- return
167- }
168-
169- _ = c.InjectJSON("__dumber_folder_deleted", map[string]int64{"id": msg.ID})
170-}
171-
172-// ═══════════════════════════════════════════════════════════════
173-// TAG HANDLERS
174-// ═══════════════════════════════════════════════════════════════
175-
176-// HandleTagList returns all tags.
177-func HandleTagList(c *Context, msg FavoritesMessage) {
178- if !c.IsWebViewReady() || c.BrowserService == nil {
179- return
180- }
181-
182- tags, err := c.BrowserService.GetTags(c.Ctx())
183- if err != nil {
184- logging.Error(fmt.Sprintf("[handlers] Failed to get tags: %v", err))
185- _ = c.InjectError("__dumber_tag_error", "Failed to load tags")
186- return
187- }
188-
189- _ = c.InjectJSONWithRequestID("__dumber_tags", tags, msg.RequestID)
190-}
191-
192-// HandleTagCreate creates a new tag.
193-func HandleTagCreate(c *Context, msg FavoritesMessage) {
194- if !c.IsWebViewReady() || c.BrowserService == nil {
195- return
196- }
197-
198- if msg.Name == "" {
199- _ = c.InjectErrorWithRequestID("__dumber_error", "Tag name is required", msg.RequestID)
200- return
201- }
202-
203- color := msg.Color
204- if color == "" {
205- color = "#6b7280" // default gray
206- }
207-
208- tag, err := c.BrowserService.CreateTag(c.Ctx(), msg.Name, color)
209- if err != nil {
210- logging.Error(fmt.Sprintf("[handlers] Failed to create tag: %v", err))
211- _ = c.InjectErrorWithRequestID("__dumber_error", "Failed to create tag", msg.RequestID)
212- return
213- }
214-
215- _ = c.InjectJSONWithRequestID("__dumber_tags", tag, msg.RequestID)
216-}
217-
218-// HandleTagUpdate updates an existing tag.
219-func HandleTagUpdate(c *Context, msg FavoritesMessage) {
220- if !c.IsWebViewReady() || c.BrowserService == nil {
221- return
222- }
223-
224- if msg.ID == 0 {
225- _ = c.InjectError("__dumber_tag_error", "Tag ID is required")
226- return
227- }
228-
229- if err := c.BrowserService.UpdateTag(c.Ctx(), msg.ID, msg.Name, msg.Color); err != nil {
230- logging.Error(fmt.Sprintf("[handlers] Failed to update tag %d: %v", msg.ID, err))
231- _ = c.InjectError("__dumber_tag_error", "Failed to update tag")
232- return
233- }
234-
235- _ = c.InjectJSON("__dumber_tag_updated", map[string]int64{"id": msg.ID})
236-}
237-
238-// HandleTagDelete deletes a tag.
239-func HandleTagDelete(c *Context, msg FavoritesMessage) {
240- if !c.IsWebViewReady() || c.BrowserService == nil {
241- return
242- }
243-
244- if msg.ID == 0 {
245- _ = c.InjectError("__dumber_tag_error", "Tag ID is required")
246- return
247- }
248-
249- if err := c.BrowserService.DeleteTag(c.Ctx(), msg.ID); err != nil {
250- logging.Error(fmt.Sprintf("[handlers] Failed to delete tag %d: %v", msg.ID, err))
251- _ = c.InjectError("__dumber_tag_error", "Failed to delete tag")
252- return
253- }
254-
255- _ = c.InjectJSON("__dumber_tag_deleted", map[string]int64{"id": msg.ID})
256-}
257-
258-// HandleTagAssign assigns a tag to a favorite.
259-func HandleTagAssign(c *Context, msg FavoritesMessage) {
260- if !c.IsWebViewReady() || c.BrowserService == nil {
261- return
262- }
263-
264- if msg.ID == 0 || msg.TagID == 0 {
265- _ = c.InjectError("__dumber_tag_error", "Favorite ID and Tag ID are required")
266- return
267- }
268-
269- if err := c.BrowserService.AssignTag(c.Ctx(), msg.ID, msg.TagID); err != nil {
270- logging.Error(fmt.Sprintf("[handlers] Failed to assign tag %d to favorite %d: %v", msg.TagID, msg.ID, err))
271- _ = c.InjectError("__dumber_tag_error", "Failed to assign tag")
272- return
273- }
274-
275- _ = c.InjectJSON("__dumber_tag_assigned", map[string]int64{
276- "favoriteId": msg.ID,
277- "tagId": msg.TagID,
278- })
279-}
280-
281-// HandleTagRemove removes a tag from a favorite.
282-func HandleTagRemove(c *Context, msg FavoritesMessage) {
283- if !c.IsWebViewReady() || c.BrowserService == nil {
284- return
285- }
286-
287- if msg.ID == 0 || msg.TagID == 0 {
288- _ = c.InjectError("__dumber_tag_error", "Favorite ID and Tag ID are required")
289- return
290- }
291-
292- if err := c.BrowserService.RemoveTag(c.Ctx(), msg.ID, msg.TagID); err != nil {
293- logging.Error(fmt.Sprintf("[handlers] Failed to remove tag %d from favorite %d: %v", msg.TagID, msg.ID, err))
294- _ = c.InjectError("__dumber_tag_error", "Failed to remove tag")
295- return
296- }
297-
298- _ = c.InjectJSON("__dumber_tag_removed", map[string]int64{
299- "favoriteId": msg.ID,
300- "tagId": msg.TagID,
301- })
302-}
303-
304-// ═══════════════════════════════════════════════════════════════
305-// FAVORITE SHORTCUT & FOLDER HANDLERS
306-// ═══════════════════════════════════════════════════════════════
307-
308-// HandleFavoriteSetShortcut sets a keyboard shortcut (1-9) for a favorite.
309-func HandleFavoriteSetShortcut(c *Context, msg FavoritesMessage) {
310- if !c.IsWebViewReady() || c.BrowserService == nil {
311- return
312- }
313-
314- if msg.ID == 0 {
315- _ = c.InjectError("__dumber_favorite_error", "Favorite ID is required")
316- return
317- }
318-
319- if msg.Shortcut < 0 || msg.Shortcut > 9 {
320- _ = c.InjectError("__dumber_favorite_error", "Shortcut must be 1-9 or 0 to clear")
321- return
322- }
323-
324- if err := c.BrowserService.SetFavoriteShortcut(c.Ctx(), msg.ID, msg.Shortcut); err != nil {
325- logging.Error(fmt.Sprintf("[handlers] Failed to set shortcut for favorite %d: %v", msg.ID, err))
326- _ = c.InjectError("__dumber_favorite_error", "Failed to set shortcut")
327- return
328- }
329-
330- _ = c.InjectJSON("__dumber_favorite_shortcut_set", map[string]interface{}{
331- "id": msg.ID,
332- "shortcut": msg.Shortcut,
333- })
334-}
335-
336-// HandleFavoriteGetByShortcut gets a favorite by its shortcut key.
337-func HandleFavoriteGetByShortcut(c *Context, msg FavoritesMessage) {
338- if !c.IsWebViewReady() || c.BrowserService == nil {
339- return
340- }
341-
342- if msg.Shortcut < 1 || msg.Shortcut > 9 {
343- _ = c.InjectJSON("__dumber_favorite_by_shortcut", nil)
344- return
345- }
346-
347- favorite, err := c.BrowserService.GetFavoriteByShortcut(c.Ctx(), msg.Shortcut)
348- if err != nil {
349- // Not found is not an error - just return null
350- _ = c.InjectJSON("__dumber_favorite_by_shortcut", nil)
351- return
352- }
353-
354- _ = c.InjectJSON("__dumber_favorite_by_shortcut", favorite)
355-}
356-
357-// HandleFavoriteSetFolder moves a favorite to a folder.
358-func HandleFavoriteSetFolder(c *Context, msg FavoritesMessage) {
359- if !c.IsWebViewReady() || c.BrowserService == nil {
360- return
361- }
362-
363- if msg.ID == 0 {
364- _ = c.InjectError("__dumber_favorite_error", "Favorite ID is required")
365- return
366- }
367-
368- // FolderID of 0 means remove from folder
369- var folderID *int64
370- if msg.FolderID > 0 {
371- folderID = &msg.FolderID
372- }
373-
374- if err := c.BrowserService.SetFavoriteFolder(c.Ctx(), msg.ID, folderID); err != nil {
375- logging.Error(fmt.Sprintf("[handlers] Failed to set folder for favorite %d: %v", msg.ID, err))
376- _ = c.InjectError("__dumber_favorite_error", "Failed to move favorite")
377- return
378- }
379-
380- _ = c.InjectJSON("__dumber_favorite_folder_set", map[string]interface{}{
381- "id": msg.ID,
382- "folderId": msg.FolderID,
383- })
384-}
385-
386-// ParseFavoriteID parses a string ID to int64.
387-func ParseFavoriteID(idStr string) (int64, error) {
388- return strconv.ParseInt(idStr, 10, 64)
389-}
D internal/app/messaging/handlers/history.go +0 -297
@@ -1,297 +0,0 @@
1-package handlers
2-
3-import (
4- "database/sql"
5- "encoding/json"
6- "fmt"
7- "strconv"
8-
9- "github.com/bnema/dumber/internal/logging"
10-)
11-
12-// HistoryMessage contains fields for history operations.
13-type HistoryMessage struct {
14- Type string `json:"type"`
15- Q string `json:"q"`
16- Limit int `json:"limit"`
17- Offset int `json:"offset"`
18- HistoryID string `json:"historyId"`
19- RequestID string `json:"requestId"`
20- Range string `json:"range"` // hour, day, week, month
21- Domain string `json:"domain"` // for domain-based deletion
22-}
23-
24-// HandleHistoryRecent processes recent history requests.
25-func HandleHistoryRecent(c *Context, msg HistoryMessage) {
26- if !c.IsReady() {
27- return
28- }
29-
30- limit := msg.Limit
31- if limit <= 0 {
32- limit = 20
33- }
34- offset := msg.Offset
35-
36- var entries []interface{}
37- var err error
38-
39- if offset > 0 {
40- histEntries, e := c.BrowserService.GetRecentHistoryWithOffset(c.Ctx(), limit, offset)
41- if e != nil {
42- err = e
43- } else {
44- entries = make([]interface{}, len(histEntries))
45- for i, entry := range histEntries {
46- entries[i] = entry
47- }
48- }
49- } else {
50- histEntries, e := c.BrowserService.GetRecentHistory(c.Ctx(), limit)
51- if e != nil {
52- err = e
53- } else {
54- entries = make([]interface{}, len(histEntries))
55- for i, entry := range histEntries {
56- entries[i] = entry
57- }
58- }
59- }
60-
61- if err != nil {
62- logging.Error(fmt.Sprintf("[handlers] Failed to get recent history: %v", err))
63- requestIdParam := ""
64- if msg.RequestID != "" {
65- requestIdParam = ", '" + msg.RequestID + "'"
66- }
67- _ = c.WebView.InjectScript("window.__dumber_history_error && window.__dumber_history_error('Failed to load recent history'" + requestIdParam + ")")
68- return
69- }
70-
71- b, _ := json.Marshal(entries)
72- requestIdParam := ""
73- if msg.RequestID != "" {
74- requestIdParam = ", '" + msg.RequestID + "'"
75- }
76- _ = c.WebView.InjectScript("window.__dumber_history_recent && window.__dumber_history_recent(" + string(b) + requestIdParam + ")")
77-}
78-
79-// HandleHistoryStats processes history stats requests.
80-func HandleHistoryStats(c *Context) {
81- if !c.IsReady() {
82- return
83- }
84-
85- stats, err := c.BrowserService.GetHistoryStats(c.Ctx())
86- if err != nil {
87- logging.Error(fmt.Sprintf("[handlers] Failed to get history stats: %v", err))
88- _ = c.InjectError("__dumber_history_error", "Failed to load history stats")
89- return
90- }
91-
92- _ = c.InjectJSON("__dumber_history_stats", stats)
93-}
94-
95-// HandleHistorySearch processes history search requests.
96-func HandleHistorySearch(c *Context, msg HistoryMessage) {
97- if !c.IsReady() {
98- return
99- }
100-
101- limit := msg.Limit
102- if limit <= 0 {
103- limit = 5
104- }
105-
106- entries, err := c.BrowserService.SearchHistory(c.Ctx(), msg.Q, limit)
107- if err != nil {
108- logging.Error(fmt.Sprintf("[handlers] Failed to search history: %v", err))
109- _ = c.InjectError("__dumber_history_error", "Failed to search history")
110- return
111- }
112-
113- _ = c.InjectJSON("__dumber_history_search", entries)
114-}
115-
116-// HandleHistoryDelete processes single history entry deletion.
117-func HandleHistoryDelete(c *Context, msg HistoryMessage) {
118- if !c.IsReady() {
119- return
120- }
121-
122- if msg.HistoryID == "" {
123- logging.Warn("[handlers] History delete: missing historyId")
124- _ = c.InjectError("__dumber_history_error", "Missing history ID")
125- return
126- }
127-
128- id, err := strconv.ParseInt(msg.HistoryID, 10, 64)
129- if err != nil {
130- logging.Error(fmt.Sprintf("[handlers] History delete: invalid ID format: %v", err))
131- _ = c.InjectError("__dumber_history_error", "Invalid history ID format")
132- return
133- }
134-
135- if err := c.BrowserService.DeleteHistoryEntry(c.Ctx(), id); err != nil {
136- logging.Error(fmt.Sprintf("[handlers] Failed to delete history entry: %v", err))
137- _ = c.InjectError("__dumber_history_error", "Failed to delete history entry")
138- return
139- }
140-
141- _ = c.InjectJSON("__dumber_history_deleted", map[string]string{"deletedId": msg.HistoryID})
142-}
143-
144-// HandleHistoryTimeline returns history grouped by date for timeline display.
145-func HandleHistoryTimeline(c *Context, msg HistoryMessage) {
146- if !c.IsReady() {
147- return
148- }
149-
150- limit := msg.Limit
151- if limit <= 0 {
152- limit = 50
153- }
154- offset := msg.Offset
155-
156- entries, err := c.BrowserService.GetHistoryTimeline(c.Ctx(), limit, offset)
157- if err != nil {
158- logging.Error(fmt.Sprintf("[handlers] Failed to get history timeline: %v", err))
159- _ = c.InjectError("__dumber_history_error", "Failed to load history timeline")
160- return
161- }
162-
163- _ = c.InjectJSONWithRequestID("__dumber_history_timeline", entries, msg.RequestID)
164-}
165-
166-// HandleHistorySearchFTS performs full-text search on history.
167-func HandleHistorySearchFTS(c *Context, msg HistoryMessage) {
168- if !c.IsReady() {
169- return
170- }
171-
172- if msg.Q == "" {
173- _ = c.InjectJSONWithRequestID("__dumber_history_search_results", []interface{}{}, msg.RequestID)
174- return
175- }
176-
177- limit := msg.Limit
178- if limit <= 0 {
179- limit = 100
180- }
181-
182- entries, err := c.BrowserService.SearchHistoryFTS(c.Ctx(), msg.Q, limit)
183- if err != nil {
184- logging.Error(fmt.Sprintf("[handlers] Failed to FTS search history: %v", err))
185- _ = c.InjectError("__dumber_history_error", "Failed to search history")
186- return
187- }
188-
189- _ = c.InjectJSONWithRequestID("__dumber_history_search_results", entries, msg.RequestID)
190-}
191-
192-// HandleHistoryDeleteRange deletes history by time range.
193-func HandleHistoryDeleteRange(c *Context, msg HistoryMessage) {
194- if !c.IsReady() {
195- return
196- }
197-
198- var err error
199- switch msg.Range {
200- case "hour":
201- err = c.BrowserService.DeleteHistoryLastHour(c.Ctx())
202- case "day":
203- err = c.BrowserService.DeleteHistoryLastDay(c.Ctx())
204- case "week":
205- err = c.BrowserService.DeleteHistoryLastWeek(c.Ctx())
206- case "month":
207- err = c.BrowserService.DeleteHistoryLastMonth(c.Ctx())
208- default:
209- _ = c.InjectError("__dumber_history_error", "Invalid time range")
210- return
211- }
212-
213- if err != nil {
214- logging.Error(fmt.Sprintf("[handlers] Failed to delete history range %s: %v", msg.Range, err))
215- _ = c.InjectError("__dumber_history_error", "Failed to clear history")
216- return
217- }
218-
219- _ = c.InjectJSON("__dumber_history_range_deleted", map[string]string{"range": msg.Range})
220-}
221-
222-// HandleHistoryClearAll deletes all history.
223-func HandleHistoryClearAll(c *Context) {
224- if !c.IsReady() {
225- return
226- }
227-
228- if err := c.BrowserService.ClearAllHistory(c.Ctx()); err != nil {
229- logging.Error(fmt.Sprintf("[handlers] Failed to clear all history: %v", err))
230- _ = c.InjectError("__dumber_history_error", "Failed to clear history")
231- return
232- }
233-
234- _ = c.InjectJSON("__dumber_history_cleared", map[string]bool{"success": true})
235-}
236-
237-// HandleHistoryDeleteDomain deletes all history for a domain.
238-func HandleHistoryDeleteDomain(c *Context, msg HistoryMessage) {
239- if !c.IsReady() {
240- return
241- }
242-
243- if msg.Domain == "" {
244- _ = c.InjectError("__dumber_history_error", "Missing domain")
245- return
246- }
247-
248- if err := c.BrowserService.DeleteHistoryByDomain(c.Ctx(), msg.Domain); err != nil {
249- logging.Error(fmt.Sprintf("[handlers] Failed to delete history for domain %s: %v", msg.Domain, err))
250- _ = c.InjectError("__dumber_history_error", "Failed to delete domain history")
251- return
252- }
253-
254- _ = c.InjectJSON("__dumber_history_domain_deleted", map[string]string{"domain": msg.Domain})
255-}
256-
257-// HandleHistoryAnalytics returns history analytics data.
258-func HandleHistoryAnalytics(c *Context, msg HistoryMessage) {
259- if !c.IsReady() {
260- return
261- }
262-
263- analytics, err := c.BrowserService.GetHistoryAnalytics(c.Ctx())
264- if err != nil {
265- logging.Error(fmt.Sprintf("[handlers] Failed to get history analytics: %v", err))
266- _ = c.InjectError("__dumber_history_error", "Failed to load analytics")
267- return
268- }
269-
270- _ = c.InjectJSONWithRequestID("__dumber_analytics", analytics, msg.RequestID)
271-}
272-
273-// HandleDomainStats returns domain statistics.
274-func HandleDomainStats(c *Context, msg HistoryMessage) {
275- if !c.IsReady() {
276- return
277- }
278-
279- limit := msg.Limit
280- if limit <= 0 {
281- limit = 20
282- }
283-
284- stats, err := c.BrowserService.GetDomainStats(c.Ctx(), limit)
285- if err != nil {
286- logging.Error(fmt.Sprintf("[handlers] Failed to get domain stats: %v", err))
287- _ = c.InjectError("__dumber_history_error", "Failed to load domain stats")
288- return
289- }
290-
291- _ = c.InjectJSONWithRequestID("__dumber_domain_stats", stats, msg.RequestID)
292-}
293-
294-// parseNullString converts a string to sql.NullString for domain deletion.
295-func parseNullString(s string) sql.NullString {
296- return sql.NullString{String: s, Valid: s != ""}
297-}
D internal/app/messaging/handlers/misc.go +0 -172
@@ -1,172 +0,0 @@
1-package handlers
2-
3-import (
4- "encoding/json"
5- "fmt"
6- "time"
7-
8- "github.com/bnema/dumber/internal/logging"
9-)
10-
11-// ThemeMessage contains fields for theme operations.
12-type ThemeMessage struct {
13- Value string `json:"value"`
14-}
15-
16-// HandleTheme processes theme-related messages.
17-func HandleTheme(lastTheme *string, msg ThemeMessage) {
18- if msg.Value != "" && msg.Value != *lastTheme {
19- logging.Debug(fmt.Sprintf("[handlers] color-scheme changed: %s", msg.Value))
20- *lastTheme = msg.Value
21- }
22-}
23-
24-// ConsoleMessage contains fields for console message operations.
25-type ConsoleMessage struct {
26- Payload json.RawMessage `json:"payload"`
27-}
28-
29-// HandleConsoleMessage processes console-message from JavaScript.
30-func HandleConsoleMessage(captureEnabled bool, msg ConsoleMessage) {
31- if !captureEnabled {
32- return
33- }
34-
35- var consolePayload struct {
36- Level string `json:"level"`
37- Message string `json:"message"`
38- URL string `json:"url"`
39- }
40-
41- if err := json.Unmarshal(msg.Payload, &consolePayload); err != nil {
42- logging.Error(fmt.Sprintf("[handlers] Failed to unmarshal console-message: %v", err))
43- return
44- }
45-
46- logging.CaptureWebKitLog(consolePayload.Message)
47-}
48-
49-// HandleWebViewIDRequest responds to JavaScript requests for the webview ID.
50-func HandleWebViewIDRequest(c *Context) {
51- if !c.IsWebViewReady() {
52- logging.Warn("[handlers] Cannot provide webview ID - webview not available or destroyed")
53- return
54- }
55-
56- webViewID := c.WebView.ID()
57- logging.Debug(fmt.Sprintf("[handlers] Sending webview ID %d to JavaScript", webViewID))
58-
59- if err := c.WebView.DispatchCustomEvent("dumber:webview-id", map[string]any{
60- "webviewId": webViewID,
61- "timestamp": time.Now().UnixMilli(),
62- }); err != nil {
63- logging.Error(fmt.Sprintf("[handlers] Failed to send webview ID: %v", err))
64- }
65-}
66-
67-// HandleGetSearchShortcuts sends search shortcuts configuration to JavaScript.
68-func HandleGetSearchShortcuts(c *Context) {
69- if !c.IsWebViewReady() || c.BrowserService == nil {
70- return
71- }
72-
73- shortcuts, err := c.BrowserService.GetSearchShortcuts(c.Ctx())
74- if err != nil {
75- logging.Error(fmt.Sprintf("[handlers] Failed to get search shortcuts: %v", err))
76- _ = c.InjectError("__dumber_search_shortcuts_error", "Failed to get search shortcuts")
77- return
78- }
79-
80- logging.Debug("[handlers] Sending search shortcuts to JavaScript")
81- _ = c.InjectJSON("__dumber_search_shortcuts", shortcuts)
82-}
83-
84-// HandleGetColorPalettes sends color palettes configuration to JavaScript.
85-func HandleGetColorPalettes(c *Context) {
86- if !c.IsWebViewReady() || c.BrowserService == nil {
87- return
88- }
89-
90- palettes := c.BrowserService.GetColorPalettesForMessaging()
91-
92- logging.Debug("[handlers] Sending color palettes to JavaScript")
93- _ = c.InjectJSON("__dumber_color_palettes", palettes)
94-}
95-
96-// KeyboardBlockingMessage contains fields for keyboard blocking operations.
97-type KeyboardBlockingMessage struct {
98- Action string `json:"action"`
99-}
100-
101-// HandleKeyboardBlocking enables or disables keyboard event blocking for omnibox.
102-func HandleKeyboardBlocking(c *Context, msg KeyboardBlockingMessage) {
103- if c.WebView == nil {
104- return
105- }
106-
107- var script string
108- switch msg.Action {
109- case "enable":
110- script = "window.__dumber_enableKeyboardBlocking?.()"
111- default:
112- script = "window.__dumber_disableKeyboardBlocking?.()"
113- }
114-
115- if err := c.WebView.InjectScript(script); err != nil {
116- logging.Error(fmt.Sprintf("[handlers] Failed to inject keyboard blocking script: %v", err))
117- }
118-}
119-
120-// WailsBridgeMessage contains fields for Wails bridge operations.
121-type WailsBridgeMessage struct {
122- ID string `json:"id"`
123- Payload json.RawMessage `json:"payload"`
124-}
125-
126-// WailsBridgePayload represents the inner payload structure.
127-type WailsBridgePayload struct {
128- MethodID uint32 `json:"methodID"`
129- MethodName string `json:"methodName"`
130- Args json.RawMessage `json:"args"`
131-}
132-
133-// HandleWailsBridge processes Wails runtime bridge calls for homepage.
134-func HandleWailsBridge(c *Context, msg WailsBridgeMessage, hashGetRecentHistory, hashGetSearchShortcuts uint32) {
135- if c.WebView == nil || c.BrowserService == nil {
136- return
137- }
138-
139- var p WailsBridgePayload
140- if err := json.Unmarshal(msg.Payload, &p); err != nil {
141- return
142- }
143-
144- switch p.MethodID {
145- case hashGetRecentHistory:
146- var args []interface{}
147- _ = json.Unmarshal(p.Args, &args)
148- limit := 50
149- if len(args) > 0 {
150- if f, ok := args[0].(float64); ok {
151- limit = int(f)
152- }
153- }
154- entries, err := c.BrowserService.GetRecentHistory(c.Ctx(), limit)
155- if err != nil {
156- return
157- }
158- resp, _ := json.Marshal(entries)
159- _ = c.WebView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
160-
161- case hashGetSearchShortcuts:
162- shortcuts, err := c.BrowserService.GetSearchShortcuts(c.Ctx())
163- if err != nil {
164- return
165- }
166- resp, _ := json.Marshal(shortcuts)
167- _ = c.WebView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', " + string(resp) + ")")
168-
169- default:
170- _ = c.WebView.InjectScript("window.__dumber_wails_resolve('" + msg.ID + "', '{}')")
171- }
172-}
D internal/app/messaging/handlers/navigation.go +0 -62
@@ -1,62 +0,0 @@
1-package handlers
2-
3-import (
4- "fmt"
5-
6- "github.com/bnema/dumber/internal/logging"
7-)
8-
9-// NavigationMessage contains fields for navigation operations.
10-type NavigationMessage struct {
11- URL string `json:"url"`
12-}
13-
14-// HandleNavigation processes navigation requests from the frontend.
15-func HandleNavigation(c *Context, msg NavigationMessage) {
16- if c.NavController != nil {
17- if err := c.NavController.NavigateToURL(msg.URL); err != nil {
18- logging.Error(fmt.Sprintf("[handlers] Navigation controller failed for input %q: %v", msg.URL, err))
19- HandleLegacyNavigation(c, msg)
20- }
21- return
22- }
23-
24- HandleLegacyNavigation(c, msg)
25-}
26-
27-// HandleLegacyNavigation uses Parser for navigation when NavController is unavailable.
28-func HandleLegacyNavigation(c *Context, msg NavigationMessage) {
29- if c.ParserService == nil {
30- return
31- }
32-
33- res, err := c.ParserService.ParseInput(c.Ctx(), msg.URL)
34- if err != nil {
35- logging.Error(fmt.Sprintf("[handlers] Legacy navigation parse failed for %q: %v", msg.URL, err))
36- return
37- }
38-
39- targetURL := res.URL
40-
41- if c.BrowserService != nil {
42- if _, navErr := c.BrowserService.Navigate(c.Ctx(), targetURL); navErr != nil {
43- logging.Warn(fmt.Sprintf("[handlers] Warning: failed to navigate to %s: %v", targetURL, navErr))
44- }
45- }
46-
47- if c.WebView == nil {
48- return
49- }
50-
51- if err := c.WebView.LoadURL(targetURL); err != nil {
52- logging.Error(fmt.Sprintf("[handlers] Legacy LoadURL failed for %s: %v", targetURL, err))
53- }
54-
55- if c.BrowserService != nil {
56- if z, zerr := c.BrowserService.GetZoomLevel(c.Ctx(), targetURL); zerr == nil {
57- if err := c.WebView.SetZoom(z); err != nil {
58- logging.Error(fmt.Sprintf("[handlers] Legacy SetZoom failed for %s: %v", targetURL, err))
59- }
60- }
61- }
62-}
D internal/app/messaging/handlers/omnibox.go +0 -186
@@ -1,186 +0,0 @@
1-package handlers
2-
3-import (
4- "encoding/json"
5- "fmt"
6-
7- "github.com/bnema/dumber/internal/logging"
8-)
9-
10-// OmniboxMessage contains fields for omnibox operations.
11-type OmniboxMessage struct {
12- Q string `json:"q"`
13- Limit int `json:"limit"`
14-}
15-
16-type suggestion struct {
17- URL string `json:"url"`
18- Favicon string `json:"favicon,omitempty"`
19-}
20-
21-// HandleQuery computes omnibox suggestions and returns them to the GUI.
22-func HandleQuery(c *Context, msg OmniboxMessage) {
23- if !c.IsReady() {
24- return
25- }
26-
27- q := msg.Q
28- limit := msg.Limit
29- if limit <= 0 {
30- limit = 10
31- }
32-
33- results := make([]suggestion, 0, limit)
34- seen := make(map[string]struct{}, limit*2)
35-
36- // History search
37- if len(results) < limit {
38- remaining := limit - len(results)
39- if entries, err := c.BrowserService.SearchHistory(c.Ctx(), q, remaining); err == nil {
40- for _, e := range entries {
41- bb, _ := json.Marshal(e)
42- var m map[string]any
43- _ = json.Unmarshal(bb, &m)
44- var url string
45- if s, ok := m["url"].(string); ok {
46- url = s
47- } else if s, ok := m["URL"].(string); ok {
48- url = s
49- }
50- if url == "" {
51- continue
52- }
53- if _, ok := seen[url]; ok {
54- continue
55- }
56- favicon := ""
57- if s, ok := m["favicon_url"].(string); ok && s != "" {
58- favicon = s
59- }
60- results = append(results, suggestion{URL: url, Favicon: favicon})
61- seen[url] = struct{}{}
62- if len(results) >= limit {
63- break
64- }
65- }
66- }
67- }
68-
69- // Inject back to GUI
70- if b, err := json.Marshal(results); err == nil {
71- script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))"
72- if err := c.WebView.InjectScript(script); err != nil {
73- logging.Error(fmt.Sprintf("[handlers] Failed to inject omnibox suggestions: %v", err))
74- }
75- }
76-}
77-
78-// HandleOmniboxInitialHistory processes initial history display for empty omnibox.
79-func HandleOmniboxInitialHistory(c *Context, msg OmniboxMessage) {
80- if !c.IsReady() {
81- return
82- }
83-
84- limit := msg.Limit
85- if limit <= 0 {
86- limit = 10
87- }
88-
89- cfg, err := c.BrowserService.GetConfig(c.Ctx())
90- if err != nil {
91- logging.Error(fmt.Sprintf("[handlers] Failed to get config: %v", err))
92- return
93- }
94- behavior := cfg.Omnibox.InitialBehavior
95-
96- results := make([]suggestion, 0, limit)
97- seen := make(map[string]struct{}, limit*2)
98-
99- var entries []interface{}
100-
101- switch behavior {
102- case "recent":
103- historyEntries, histErr := c.BrowserService.GetRecentHistory(c.Ctx(), limit)
104- err = histErr
105- for _, e := range historyEntries {
106- entries = append(entries, e)
107- }
108- case "most_visited":
109- historyEntries, histErr := c.BrowserService.GetMostVisited(c.Ctx(), limit)
110- err = histErr
111- for _, e := range historyEntries {
112- entries = append(entries, e)
113- }
114- case "none":
115- entries = []interface{}{}
116- }
117-
118- if err != nil {
119- logging.Error(fmt.Sprintf("[handlers] Failed to fetch initial history: %v", err))
120- return
121- }
122-
123- for _, entry := range entries {
124- bb, _ := json.Marshal(entry)
125- var m map[string]any
126- _ = json.Unmarshal(bb, &m)
127-
128- var url string
129- if s, ok := m["url"].(string); ok {
130- url = s
131- } else if s, ok := m["URL"].(string); ok {
132- url = s
133- }
134- if url == "" {
135- continue
136- }
137-
138- if _, ok := seen[url]; ok {
139- continue
140- }
141-
142- favicon := ""
143- if s, ok := m["favicon_url"].(string); ok && s != "" {
144- favicon = s
145- }
146-
147- results = append(results, suggestion{URL: url, Favicon: favicon})
148- seen[url] = struct{}{}
149- if len(results) >= limit {
150- break
151- }
152- }
153-
154- if b, err := json.Marshal(results); err == nil {
155- script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))"
156- if err := c.WebView.InjectScript(script); err != nil {
157- logging.Error(fmt.Sprintf("[handlers] Failed to inject initial history: %v", err))
158- }
159- }
160-}
161-
162-// HandlePrefixQuery returns the best prefix-matching URL for inline suggestions.
163-func HandlePrefixQuery(c *Context, msg OmniboxMessage) {
164- if !c.IsReady() {
165- return
166- }
167-
168- query := msg.Q
169- if query == "" {
170- _ = c.WebView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)")
171- return
172- }
173-
174- prefixURL := c.BrowserService.GetBestPrefixMatch(c.Ctx(), query)
175-
176- if prefixURL != "" {
177- escaped, err := json.Marshal(prefixURL)
178- if err != nil {
179- _ = c.WebView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)")
180- return
181- }
182- _ = c.WebView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(" + string(escaped) + ")")
183- } else {
184- _ = c.WebView.InjectScript("window.__dumber_omnibox_inline_suggestion && window.__dumber_omnibox_inline_suggestion(null)")
185- }
186-}
D internal/cache/binary_format.go +0 -459
@@ -1,459 +0,0 @@
1-// Package cache provides binary format serialization for fuzzy cache data structures.
2-package cache
3-
4-import (
5- "encoding/binary"
6- "fmt"
7- "os"
8- "syscall"
9- "unsafe"
10-
11- "github.com/bnema/dumber/internal/logging"
12-)
13-
14-// Binary cache format layout:
15-// Header: [magic:4][version:4][entryCount:4][indexOffset:8][lastModified:8]
16-// Entries: [url_len:2][url:N][title_len:2][title:N][visit:2][lastVisit:4][score:2]
17-// Index: [trigramCount:4][trigram:3][count:4][ids:4*count]...
18-
19-// Binary format constants for cache file structure
20-const (
21- CacheMagic = 0x44554d42 // "DUMB" in little-endian
22- CacheVersion = 1
23- HeaderSize = 28 // Size of binary header
24-)
25-
26-// BinaryHeader represents the cache file header.
27-type BinaryHeader struct {
28- Magic uint32 // Magic number for format identification
29- Version uint32 // Cache format version
30- EntryCount uint32 // Number of entries in cache
31- IndexOffset uint64 // Offset to trigram index section
32- LastModified int64 // Unix timestamp of last modification
33-}
34-
35-// SaveToBinary writes the cache to a binary file using memory mapping for performance.
36-func (c *DmenuFuzzyCache) SaveToBinary(filename string) error {
37- c.mu.RLock()
38- defer c.mu.RUnlock()
39-
40- // Calculate total file size
41- totalSize := c.calculateFileSize()
42-
43- // Create the file with the exact size needed
44- file, err := os.Create(filename) //nolint:gosec // G304: filename is controlled by cache configuration, not user input
45- if err != nil {
46- return fmt.Errorf("failed to create cache file: %w", err)
47- }
48- defer func() {
49- if closeErr := file.Close(); closeErr != nil {
50- logging.Warn(fmt.Sprintf("failed to close cache file: %v", closeErr))
51- }
52- }()
53-
54- // Pre-allocate file space
55- if err := file.Truncate(int64(totalSize)); err != nil {
56- return fmt.Errorf("failed to allocate file space: %w", err)
57- }
58-
59- // Memory map the file for fast writing
60- data, err := syscall.Mmap(int(file.Fd()), 0, totalSize, syscall.PROT_WRITE, syscall.MAP_SHARED)
61- if err != nil {
62- return fmt.Errorf("failed to mmap file: %w", err)
63- }
64- defer func() {
65- if unmapErr := syscall.Munmap(data); unmapErr != nil {
66- logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
67- }
68- }()
69-
70- // Write data using unsafe pointer operations for maximum speed
71- offset := 0
72-
73- // Write header
74- offset = c.writeHeader(data, offset, totalSize)
75-
76- // Write entries
77- entriesEndOffset := c.writeEntries(data, offset)
78-
79- // Write trigram index
80- trigramEndOffset := c.writeTrigramIndex(data, entriesEndOffset)
81-
82- // Write sorted index
83- c.writeSortedIndex(data, trigramEndOffset)
84-
85- // Force sync to disk (Linux-specific, skip on error)
86- // syscall.Msync may not be available on all platforms
87- // _ = syscall.Msync(data, syscall.MS_SYNC)
88-
89- return nil
90-}
91-
92-// LoadFromBinary loads cache from a binary file using memory mapping.
93-func (c *DmenuFuzzyCache) LoadFromBinary(filename string) error {
94- file, err := os.Open(filename) //nolint:gosec // G304: filename is controlled by cache configuration, not user input
95- if err != nil {
96- return fmt.Errorf("failed to open cache file: %w", err)
97- }
98- defer func() {
99- if closeErr := file.Close(); closeErr != nil {
100- logging.Warn(fmt.Sprintf("failed to close cache file: %v", closeErr))
101- }
102- }()
103-
104- // Get file size
105- stat, err := file.Stat()
106- if err != nil {
107- return fmt.Errorf("failed to stat cache file: %w", err)
108- }
109- fileSize := int(stat.Size())
110-
111- if fileSize < HeaderSize {
112- return fmt.Errorf("cache file too small: %d bytes", fileSize)
113- }
114-
115- // Memory map the file for fast reading
116- data, err := syscall.Mmap(int(file.Fd()), 0, fileSize, syscall.PROT_READ, syscall.MAP_SHARED)
117- if err != nil {
118- return fmt.Errorf("failed to mmap cache file: %w", err)
119- }
120- // Note: We don't defer Munmap here as we want to keep the mapping alive
121- // The mapping will be cleaned up when the process exits
122-
123- // Parse header
124- header, err := c.parseHeader(data)
125- if err != nil {
126- if unmapErr := syscall.Munmap(data); unmapErr != nil {
127- logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
128- }
129- return fmt.Errorf("failed to parse header: %w", err)
130- }
131-
132- c.mu.Lock()
133- defer c.mu.Unlock()
134-
135- // Parse entries
136- entries, err := c.parseEntries(data, HeaderSize, header.EntryCount)
137- if err != nil {
138- if unmapErr := syscall.Munmap(data); unmapErr != nil {
139- logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
140- }
141- return fmt.Errorf("failed to parse entries: %w", err)
142- }
143-
144- // Parse trigram index
145- if header.IndexOffset > uint64(^uint(0)>>1) { // Check if it exceeds int max
146- return fmt.Errorf("index offset too large: %d", header.IndexOffset)
147- }
148- trigramIndex, trigramEndOffset, err := c.parseTrigramIndex(data, int(header.IndexOffset)) //nolint:gosec // G115: bounds checked above
149- if err != nil {
150- if unmapErr := syscall.Munmap(data); unmapErr != nil {
151- logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
152- }
153- return fmt.Errorf("failed to parse trigram index: %w", err)
154- }
155-
156- // Parse sorted index
157- sortedIndex, err := c.parseSortedIndex(data, trigramEndOffset)
158- if err != nil {
159- if unmapErr := syscall.Munmap(data); unmapErr != nil {
160- logging.Warn(fmt.Sprintf("failed to unmap cache file: %v", unmapErr))
161- }
162- return fmt.Errorf("failed to parse sorted index: %w", err)
163- }
164-
165- // Update cache state
166- c.entries = entries
167- c.trigramIndex = trigramIndex
168- c.sortedIndex = sortedIndex
169- c.entryCount = header.EntryCount
170- c.lastModified = header.LastModified
171- c.version = header.Version
172-
173- return nil
174-}
175-
176-// calculateFileSize estimates the total file size needed.
177-func (c *DmenuFuzzyCache) calculateFileSize() int {
178- size := HeaderSize
179-
180- // Calculate entries section size
181- for _, entry := range c.entries {
182- size += 2 + len(entry.URL) // url_len + url
183- size += 2 + len(entry.Title) // title_len + title
184- size += 2 + 4 + 2 // visit + lastVisit + score
185- }
186-
187- // Calculate trigram index size
188- size += 4 // trigram count
189- for trigram, ids := range c.trigramIndex {
190- size += len(trigram) + 4 + len(ids)*4 // trigram + count + ids
191- }
192-
193- // Calculate sorted index size
194- size += 4 + len(c.sortedIndex)*4 // count + indices
195-
196- return size
197-}
198-
199-// writeHeader writes the binary header.
200-func (c *DmenuFuzzyCache) writeHeader(data []byte, offset int, totalSize int) int {
201- // Log header info to file only (not stdout) to avoid interfering with dmenu
202- if logger := logging.GetLogger(); logger != nil {
203- logger.WriteFileOnly(logging.LogLevelInfo(), fmt.Sprintf("writing header: totalSize=%d bytes", totalSize), "CACHE")
204- }
205- // Calculate index offset (header + all entries)
206- indexOffset := HeaderSize
207- for _, entry := range c.entries {
208- indexOffset += 2 + len(entry.URL) + 2 + len(entry.Title) + 8
209- }
210-
211- // Check bounds for integer conversions
212- if len(c.entries) > 0xFFFFFFFF { // uint32 max
213- logging.Warn(fmt.Sprintf("too many entries for binary format: %d, truncating", len(c.entries)))
214- }
215- if indexOffset < 0 {
216- logging.Warn(fmt.Sprintf("Warning: negative index offset: %d, using 0", indexOffset))
217- indexOffset = 0
218- }
219-
220- header := BinaryHeader{
221- Magic: CacheMagic,
222- Version: CacheVersion,
223- EntryCount: uint32(len(c.entries)), //nolint:gosec // G115: bounds checked above
224- IndexOffset: uint64(indexOffset), //nolint:gosec // G115: bounds checked above
225- LastModified: c.lastModified,
226- }
227-
228- // Write header using unsafe for maximum performance
229- *(*BinaryHeader)(unsafe.Pointer(&data[offset])) = header
230- return offset + HeaderSize
231-}
232-
233-// writeEntries writes all cache entries in binary format.
234-func (c *DmenuFuzzyCache) writeEntries(data []byte, offset int) int {
235- for _, entry := range c.entries {
236- // Write URL
237- urlLen := uint16(len(entry.URL))
238- *(*uint16)(unsafe.Pointer(&data[offset])) = urlLen
239- offset += 2
240- copy(data[offset:], entry.URL)
241- offset += len(entry.URL)
242-
243- // Write Title
244- titleLen := uint16(len(entry.Title))
245- *(*uint16)(unsafe.Pointer(&data[offset])) = titleLen
246- offset += 2
247- copy(data[offset:], entry.Title)
248- offset += len(entry.Title)
249-
250- // Write metadata
251- *(*uint16)(unsafe.Pointer(&data[offset])) = entry.VisitCount
252- offset += 2
253- *(*uint32)(unsafe.Pointer(&data[offset])) = entry.LastVisit
254- offset += 4
255- *(*uint16)(unsafe.Pointer(&data[offset])) = entry.Score
256- offset += 2
257- }
258- return offset
259-}
260-
261-// writeTrigramIndex writes the trigram index in binary format.
262-func (c *DmenuFuzzyCache) writeTrigramIndex(data []byte, offset int) int {
263- // Write trigram count
264- trigramCount := uint32(len(c.trigramIndex))
265- *(*uint32)(unsafe.Pointer(&data[offset])) = trigramCount
266- offset += 4
267-
268- // Write trigrams and their entry lists
269- for trigram, ids := range c.trigramIndex {
270- // Write trigram (exactly 3 bytes)
271- copy(data[offset:offset+3], trigram)
272- offset += 3
273-
274- // Write entry count
275- idCount := uint32(len(ids))
276- *(*uint32)(unsafe.Pointer(&data[offset])) = idCount
277- offset += 4
278-
279- // Write entry IDs
280- for _, id := range ids {
281- *(*uint32)(unsafe.Pointer(&data[offset])) = id
282- offset += 4
283- }
284- }
285- return offset
286-}
287-
288-// writeSortedIndex writes the sorted index in binary format.
289-func (c *DmenuFuzzyCache) writeSortedIndex(data []byte, offset int) int {
290- // Write sorted index count
291- indexCount := uint32(len(c.sortedIndex))
292- *(*uint32)(unsafe.Pointer(&data[offset])) = indexCount
293- offset += 4
294-
295- // Write sorted indices
296- for _, id := range c.sortedIndex {
297- *(*uint32)(unsafe.Pointer(&data[offset])) = id
298- offset += 4
299- }
300- return offset
301-}
302-
303-// parseHeader parses the binary header from mapped data.
304-func (c *DmenuFuzzyCache) parseHeader(data []byte) (*BinaryHeader, error) {
305- if len(data) < HeaderSize {
306- return nil, fmt.Errorf("insufficient data for header")
307- }
308-
309- header := (*BinaryHeader)(unsafe.Pointer(&data[0]))
310-
311- if header.Magic != CacheMagic {
312- return nil, fmt.Errorf("invalid magic number: 0x%x", header.Magic)
313- }
314-
315- if header.Version != CacheVersion {
316- return nil, fmt.Errorf("unsupported cache version: %d", header.Version)
317- }
318-
319- return header, nil
320-}
321-
322-// parseEntries parses cache entries from binary data.
323-func (c *DmenuFuzzyCache) parseEntries(data []byte, offset int, count uint32) ([]CompactEntry, error) {
324- entries := make([]CompactEntry, 0, count)
325-
326- for i := uint32(0); i < count; i++ {
327- if offset+2 > len(data) {
328- return nil, fmt.Errorf("insufficient data for URL length at entry %d", i)
329- }
330-
331- // Read URL
332- urlLen := *(*uint16)(unsafe.Pointer(&data[offset]))
333- offset += 2
334- if offset+int(urlLen) > len(data) {
335- return nil, fmt.Errorf("insufficient data for URL at entry %d", i)
336- }
337- url := string(data[offset : offset+int(urlLen)])
338- offset += int(urlLen)
339-
340- // Read Title
341- if offset+2 > len(data) {
342- return nil, fmt.Errorf("insufficient data for title length at entry %d", i)
343- }
344- titleLen := *(*uint16)(unsafe.Pointer(&data[offset]))
345- offset += 2
346- if offset+int(titleLen) > len(data) {
347- return nil, fmt.Errorf("insufficient data for title at entry %d", i)
348- }
349- title := string(data[offset : offset+int(titleLen)])
350- offset += int(titleLen)
351-
352- // Read metadata
353- if offset+8 > len(data) {
354- return nil, fmt.Errorf("insufficient data for metadata at entry %d", i)
355- }
356- visitCount := *(*uint16)(unsafe.Pointer(&data[offset]))
357- offset += 2
358- lastVisit := *(*uint32)(unsafe.Pointer(&data[offset]))
359- offset += 4
360- score := *(*uint16)(unsafe.Pointer(&data[offset]))
361- offset += 2
362-
363- entries = append(entries, CompactEntry{
364- URL: url,
365- Title: title,
366- VisitCount: visitCount,
367- LastVisit: lastVisit,
368- Score: score,
369- })
370- }
371-
372- return entries, nil
373-}
374-
375-// parseTrigramIndex parses the trigram index from binary data.
376-func (c *DmenuFuzzyCache) parseTrigramIndex(data []byte, offset int) (map[string][]uint32, int, error) {
377- if offset+4 > len(data) {
378- return nil, 0, fmt.Errorf("insufficient data for trigram count")
379- }
380-
381- trigramCount := *(*uint32)(unsafe.Pointer(&data[offset]))
382- offset += 4
383-
384- trigramIndex := make(map[string][]uint32, trigramCount)
385-
386- for i := uint32(0); i < trigramCount; i++ {
387- // Read trigram
388- if offset+3 > len(data) {
389- return nil, 0, fmt.Errorf("insufficient data for trigram %d", i)
390- }
391- trigram := string(data[offset : offset+3])
392- offset += 3
393-
394- // Read entry count
395- if offset+4 > len(data) {
396- return nil, 0, fmt.Errorf("insufficient data for entry count in trigram %d", i)
397- }
398- idCount := *(*uint32)(unsafe.Pointer(&data[offset]))
399- offset += 4
400-
401- // Read entry IDs
402- if offset+int(idCount)*4 > len(data) {
403- return nil, 0, fmt.Errorf("insufficient data for entry IDs in trigram %d", i)
404- }
405-
406- ids := make([]uint32, idCount)
407- for j := uint32(0); j < idCount; j++ {
408- ids[j] = *(*uint32)(unsafe.Pointer(&data[offset]))
409- offset += 4
410- }
411-
412- trigramIndex[trigram] = ids
413- }
414-
415- return trigramIndex, offset, nil
416-}
417-
418-// parseSortedIndex parses the sorted index from binary data.
419-func (c *DmenuFuzzyCache) parseSortedIndex(data []byte, offset int) ([]uint32, error) {
420- if offset+4 > len(data) {
421- return nil, fmt.Errorf("insufficient data for sorted index count")
422- }
423-
424- indexCount := *(*uint32)(unsafe.Pointer(&data[offset]))
425- offset += 4
426-
427- if offset+int(indexCount)*4 > len(data) {
428- return nil, fmt.Errorf("insufficient data for sorted index entries")
429- }
430-
431- sortedIndex := make([]uint32, indexCount)
432- for i := uint32(0); i < indexCount; i++ {
433- sortedIndex[i] = *(*uint32)(unsafe.Pointer(&data[offset]))
434- offset += 4
435- }
436-
437- return sortedIndex, nil
438-}
439-
440-// IsValidCacheFile checks if a file is a valid cache file without fully loading it.
441-func IsValidCacheFile(filename string) bool {
442- file, err := os.Open(filename)
443- if err != nil {
444- return false
445- }
446- defer func() {
447- if closeErr := file.Close(); closeErr != nil {
448- logging.Warn(fmt.Sprintf("Warning: failed to close cache validation file: %v", closeErr))
449- }
450- }()
451-
452- var header BinaryHeader
453- err = binary.Read(file, binary.LittleEndian, &header)
454- if err != nil {
455- return false
456- }
457-
458- return header.Magic == CacheMagic && header.Version == CacheVersion
459-}
D internal/cache/cert_cache.go +0 -78
@@ -1,78 +0,0 @@
1-package cache
2-
3-import (
4- "context"
5-
6- "github.com/bnema/dumber/internal/cache/generic"
7- "github.com/bnema/dumber/internal/db"
8-)
9-
10-// CertDBOperations implements DatabaseOperations for certificate validations cache.
11-// Handles loading, persisting, and deleting certificate validations from the database.
12-type CertDBOperations struct {
13- queries db.DatabaseQuerier
14-}
15-
16-// NewCertDBOperations creates a new CertDBOperations instance.
17-func NewCertDBOperations(queries db.DatabaseQuerier) *CertDBOperations {
18- return &CertDBOperations{
19- queries: queries,
20- }
21-}
22-
23-// LoadAll loads all certificate validations from the database.
24-// Returns a map of hostname -> CertificateValidation.
25-// Only loads non-expired validations.
26-func (c *CertDBOperations) LoadAll(ctx context.Context) (map[string]db.CertificateValidation, error) {
27- validations, err := c.queries.ListCertificateValidations(ctx)
28- if err != nil {
29- return nil, err
30- }
31-
32- result := make(map[string]db.CertificateValidation, len(validations))
33- for _, validation := range validations {
34- // Use hostname as key - most recent validation per hostname
35- // (query already orders by created_at DESC in GetCertificateValidationByHostname)
36- result[validation.Hostname] = validation
37- }
38-
39- return result, nil
40-}
41-
42-// Persist saves a certificate validation to the database.
43-// Uses INSERT OR REPLACE to handle both new and existing entries.
44-func (c *CertDBOperations) Persist(ctx context.Context, hostname string, validation db.CertificateValidation) error {
45- return c.queries.StoreCertificateValidation(
46- ctx,
47- hostname,
48- validation.CertificateHash,
49- validation.UserDecision,
50- validation.ExpiresAt,
51- )
52-}
53-
54-// Delete removes a certificate validation from the database.
55-func (c *CertDBOperations) Delete(ctx context.Context, hostname string) error {
56- // Get the validation to find the certificate hash
57- validation, err := c.queries.GetCertificateValidationByHostname(ctx, hostname)
58- if err != nil {
59- return err
60- }
61-
62- return c.queries.DeleteCertificateValidation(ctx, hostname, validation.CertificateHash)
63-}
64-
65-// CertValidationsCache is a specialized cache for certificate validations.
66-// It wraps GenericCache with hostname-specific helper methods.
67-// Key insight: Caches hostname -> validation decision to avoid DB queries on HTTPS cert errors.
68-type CertValidationsCache struct {
69- *generic.GenericCache[string, db.CertificateValidation]
70-}
71-
72-// NewCertValidationsCache creates a new certificate validations cache.
73-func NewCertValidationsCache(queries db.DatabaseQuerier) *CertValidationsCache {
74- dbOps := NewCertDBOperations(queries)
75- return &CertValidationsCache{
76- GenericCache: generic.NewGenericCache(dbOps),
77- }
78-}
D internal/cache/favorites_cache.go +0 -93
@@ -1,93 +0,0 @@
1-package cache
2-
3-import (
4- "context"
5- "database/sql"
6-
7- "github.com/bnema/dumber/internal/cache/generic"
8- "github.com/bnema/dumber/internal/db"
9-)
10-
11-// FavoritesDBOperations implements DatabaseOperations for favorites cache.
12-// Handles loading, persisting, and deleting favorites from the database.
13-type FavoritesDBOperations struct {
14- queries db.DatabaseQuerier
15-}
16-
17-// NewFavoritesDBOperations creates a new FavoritesDBOperations instance.
18-func NewFavoritesDBOperations(queries db.DatabaseQuerier) *FavoritesDBOperations {
19- return &FavoritesDBOperations{
20- queries: queries,
21- }
22-}
23-
24-// LoadAll loads all favorites from the database.
25-// Returns a map of URL -> Favorite.
26-func (f *FavoritesDBOperations) LoadAll(ctx context.Context) (map[string]db.Favorite, error) {
27- favorites, err := f.queries.GetAllFavorites(ctx)
28- if err != nil {
29- return nil, err
30- }
31-
32- result := make(map[string]db.Favorite, len(favorites))
33- for _, fav := range favorites {
34- result[fav.Url] = fav
35- }
36-
37- return result, nil
38-}
39-
40-// Persist saves a favorite to the database.
41-// Uses UPSERT logic: if URL exists, updates metadata; otherwise creates new.
42-func (f *FavoritesDBOperations) Persist(ctx context.Context, url string, favorite db.Favorite) error {
43- // Check if favorite already exists
44- _, err := f.queries.GetFavoriteByURL(ctx, url)
45- if err == sql.ErrNoRows {
46- // Create new favorite
47- return f.queries.CreateFavorite(ctx, url, favorite.Title, favorite.FaviconUrl)
48- } else if err != nil {
49- return err
50- }
51-
52- // Update existing favorite
53- return f.queries.UpdateFavorite(ctx, favorite.Title, favorite.FaviconUrl, url)
54-}
55-
56-// Delete removes a favorite from the database.
57-func (f *FavoritesDBOperations) Delete(ctx context.Context, url string) error {
58- return f.queries.DeleteFavorite(ctx, url)
59-}
60-
61-// FavoritesCache is a specialized cache for favorites.
62-// It wraps GenericCache with favorites-specific helper methods.
63-type FavoritesCache struct {
64- *generic.GenericCache[string, db.Favorite]
65-}
66-
67-// NewFavoritesCache creates a new favorites cache.
68-func NewFavoritesCache(queries db.DatabaseQuerier) *FavoritesCache {
69- dbOps := NewFavoritesDBOperations(queries)
70- return &FavoritesCache{
71- GenericCache: generic.NewGenericCache(dbOps),
72- }
73-}
74-
75-// GetAll returns all favorites as a slice, ordered by position.
76-// This is a convenience method for the frontend.
77-func (f *FavoritesCache) GetAll() []db.Favorite {
78- // Get all favorites from the cache using the List method
79- favorites := f.GenericCache.List()
80-
81- // Sort by position
82- // Note: favorites should already be ordered by position from DB load,
83- // but this ensures consistency even if items were added at runtime.
84- for i := 0; i < len(favorites)-1; i++ {
85- for j := i + 1; j < len(favorites); j++ {
86- if favorites[i].Position > favorites[j].Position {
87- favorites[i], favorites[j] = favorites[j], favorites[i]
88- }
89- }
90- }
91-
92- return favorites
93-}
D internal/cache/filter_cache.go +0 -430
@@ -1,430 +0,0 @@
1-package cache
2-
3-import (
4- "encoding/binary"
5- "fmt"
6- "io"
7- "os"
8- "syscall"
9- "time"
10- "unsafe"
11-
12- "github.com/bnema/dumber/internal/logging"
13-)
14-
15-// FilterData represents the compiled filter data for caching
16-type FilterData struct {
17- Version uint32
18- Created time.Time
19- NetworkRules []NetworkRule
20- CosmeticRules []CosmeticRule
21-}
22-
23-// NetworkRule represents a compiled network filtering rule
24-type NetworkRule struct {
25- Pattern string
26- Domain string
27- Action uint8 // 0=block, 1=allow
28- ResourceType uint32 // Bitmask of resource types
29- Priority uint8
30-}
31-
32-// CosmeticRule represents a compiled cosmetic filtering rule
33-type CosmeticRule struct {
34- Domain string
35- Selector string
36-}
37-
38-const (
39- // Cache format version for compatibility
40- cacheFormatVersion = uint32(1)
41-
42- // Magic bytes to identify cache files
43- cacheMagic = uint64(0x44554D4245524301) // "DUMBERC" + version
44-
45- // File permissions
46- cacheFilePerms = 0644
47-)
48-
49-// FilterCache provides high-performance binary filter caching with memory mapping
50-type FilterCache struct {
51- path string
52- mmapData []byte
53- size int64
54-}
55-
56-// CacheHeader represents the binary cache file header
57-type CacheHeader struct {
58- Magic uint64 // Magic bytes for file identification
59- Version uint32 // Cache format version
60- DataSize uint64 // Size of data section
61- RuleCount uint32 // Number of filter rules
62- Created int64 // Unix timestamp when cache was created
63- Checksum uint64 // CRC64 checksum of data section
64-}
65-
66-// FilterRule represents a compiled filter rule in binary format
67-type FilterRule struct {
68- Type uint8 // Rule type (0=network, 1=cosmetic)
69- Priority uint8 // Rule priority
70- Action uint8 // Action to take (0=block, 1=allow, etc.)
71- Flags uint8 // Additional flags
72- PatternLen uint16 // Length of pattern string
73- DomainLen uint16 // Length of domain string
74- ResourceType uint32 // Bitmask of resource types
75- // Followed by: Pattern (variable length), Domain (variable length)
76-}
77-
78-// NewFilterCache creates a new filter cache instance
79-func NewFilterCache(path string) *FilterCache {
80- return &FilterCache{
81- path: path,
82- }
83-}
84-
85-// LoadMapped loads the cache file using memory mapping for zero-copy access
86-func (fc *FilterCache) LoadMapped() (*FilterData, error) {
87- file, err := os.Open(fc.path)
88- if err != nil {
89- return nil, fmt.Errorf("failed to open cache file: %w", err)
90- }
91- defer func() {
92- if err := file.Close(); err != nil {
93- logging.Warn(fmt.Sprintf("failed to close cache file: %v", err))
94- }
95- }()
96-
97- // Get file size
98- stat, err := file.Stat()
99- if err != nil {
100- return nil, fmt.Errorf("failed to stat cache file: %w", err)
101- }
102-
103- fc.size = stat.Size()
104- if fc.size < int64(unsafe.Sizeof(CacheHeader{})) {
105- return nil, fmt.Errorf("cache file too small")
106- }
107-
108- // Memory map the file for read-only access
109- fc.mmapData, err = syscall.Mmap(int(file.Fd()), 0, int(fc.size), syscall.PROT_READ, syscall.MAP_SHARED)
110- if err != nil {
111- return nil, fmt.Errorf("failed to mmap cache file: %w", err)
112- }
113-
114- // Parse the header
115- header, err := fc.parseHeader()
116- if err != nil {
117- if unmapErr := fc.Unmap(); unmapErr != nil {
118- logging.Warn(fmt.Sprintf("[filter-cache] failed to unmap after header parse error: %v", unmapErr))
119- }
120- return nil, fmt.Errorf("invalid cache header: %w", err)
121- }
122-
123- // Validate magic and version
124- if header.Magic != cacheMagic {
125- if unmapErr := fc.Unmap(); unmapErr != nil {
126- logging.Warn(fmt.Sprintf("[filter-cache] failed to unmap after magic validation: %v", unmapErr))
127- }
128- return nil, fmt.Errorf("invalid cache magic bytes")
129- }
130-
131- if header.Version != cacheFormatVersion {
132- if unmapErr := fc.Unmap(); unmapErr != nil {
133- logging.Warn(fmt.Sprintf("[filter-cache] failed to unmap after version validation: %v", unmapErr))
134- }
135- return nil, fmt.Errorf("unsupported cache version: %d", header.Version)
136- }
137-
138- // Parse filter data without copying
139- data, err := fc.parseInPlace(header)
140- if err != nil {
141- if unmapErr := fc.Unmap(); unmapErr != nil {
142- logging.Warn(fmt.Sprintf("failed to unmap during error handling: %v", unmapErr))
143- }
144- return nil, fmt.Errorf("failed to parse cache data: %w", err)
145- }
146-
147- logging.Info(fmt.Sprintf("Loaded binary cache: %d rules, %d bytes (memory-mapped)",
148- header.RuleCount, fc.size))
149-
150- return data, nil
151-}
152-
153-// parseHeader extracts the cache header from memory-mapped data
154-func (fc *FilterCache) parseHeader() (*CacheHeader, error) {
155- if len(fc.mmapData) < int(unsafe.Sizeof(CacheHeader{})) {
156- return nil, fmt.Errorf("insufficient data for header")
157- }
158-
159- header := (*CacheHeader)(unsafe.Pointer(&fc.mmapData[0]))
160- return header, nil
161-}
162-
163-// parseInPlace parses filter data directly from memory-mapped data without copying
164-func (fc *FilterCache) parseInPlace(header *CacheHeader) (*FilterData, error) {
165- data := &FilterData{
166- Version: header.Version,
167- NetworkRules: make([]NetworkRule, 0, header.RuleCount),
168- CosmeticRules: make([]CosmeticRule, 0),
169- }
170-
171- // Start parsing after the header
172- offset := int(unsafe.Sizeof(CacheHeader{}))
173- remaining := header.RuleCount
174-
175- for remaining > 0 && offset < len(fc.mmapData) {
176- // Parse rule header
177- if offset+int(unsafe.Sizeof(FilterRule{})) > len(fc.mmapData) {
178- break
179- }
180-
181- ruleHeader := (*FilterRule)(unsafe.Pointer(&fc.mmapData[offset]))
182- offset += int(unsafe.Sizeof(FilterRule{}))
183-
184- // Extract pattern string (zero-copy)
185- if offset+int(ruleHeader.PatternLen) > len(fc.mmapData) {
186- break
187- }
188- pattern := string(fc.mmapData[offset : offset+int(ruleHeader.PatternLen)])
189- offset += int(ruleHeader.PatternLen)
190-
191- // Extract domain string (zero-copy)
192- if offset+int(ruleHeader.DomainLen) > len(fc.mmapData) {
193- break
194- }
195- domain := string(fc.mmapData[offset : offset+int(ruleHeader.DomainLen)])
196- offset += int(ruleHeader.DomainLen)
197-
198- // Create appropriate rule type
199- switch ruleHeader.Type {
200- case 0: // Network rule
201- data.NetworkRules = append(data.NetworkRules, NetworkRule{
202- Pattern: pattern,
203- Domain: domain,
204- Action: ruleHeader.Action,
205- ResourceType: ruleHeader.ResourceType,
206- Priority: ruleHeader.Priority,
207- })
208- case 1: // Cosmetic rule
209- data.CosmeticRules = append(data.CosmeticRules, CosmeticRule{
210- Domain: domain,
211- Selector: pattern,
212- })
213- }
214-
215- remaining--
216- }
217-
218- return data, nil
219-}
220-
221-// Write saves filter data to cache in binary format
222-func (fc *FilterCache) Write(data *FilterData) error {
223- // Create temporary file for atomic write
224- tmpPath := fc.path + ".tmp"
225- file, err := os.OpenFile(tmpPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, cacheFilePerms)
226- if err != nil {
227- return fmt.Errorf("failed to create cache file: %w", err)
228- }
229- defer func() {
230- if err := file.Close(); err != nil {
231- logging.Warn(fmt.Sprintf("failed to close cache file: %v", err))
232- }
233- }()
234-
235- // Calculate total rule count
236- totalRules := uint32(len(data.NetworkRules) + len(data.CosmeticRules))
237-
238- // Write header
239- header := CacheHeader{
240- Magic: cacheMagic,
241- Version: data.Version,
242- RuleCount: totalRules,
243- Created: data.Created.Unix(),
244- DataSize: 0, // Will be calculated
245- Checksum: 0, // Will be calculated
246- }
247-
248- if err := binary.Write(file, binary.LittleEndian, header); err != nil {
249- if removeErr := os.Remove(tmpPath); removeErr != nil {
250- logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
251- }
252- return fmt.Errorf("failed to write header: %w", err)
253- }
254-
255- dataStart, _ := file.Seek(0, io.SeekCurrent)
256-
257- // Write network rules
258- for _, rule := range data.NetworkRules {
259- if err := fc.writeNetworkRule(file, rule); err != nil {
260- if removeErr := os.Remove(tmpPath); removeErr != nil {
261- logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
262- }
263- return fmt.Errorf("failed to write network rule: %w", err)
264- }
265- }
266-
267- // Write cosmetic rules
268- for _, rule := range data.CosmeticRules {
269- if err := fc.writeCosmeticRule(file, rule); err != nil {
270- if removeErr := os.Remove(tmpPath); removeErr != nil {
271- logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
272- }
273- return fmt.Errorf("failed to write cosmetic rule: %w", err)
274- }
275- }
276-
277- // Calculate data size
278- dataEnd, _ := file.Seek(0, io.SeekCurrent)
279- dataSize := uint64(dataEnd - dataStart)
280-
281- // Update header with data size
282- if _, err := file.Seek(0, io.SeekStart); err != nil {
283- if removeErr := os.Remove(tmpPath); removeErr != nil {
284- logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
285- }
286- return err
287- }
288-
289- header.DataSize = dataSize
290- // TODO: Calculate and set checksum
291-
292- if err := binary.Write(file, binary.LittleEndian, header); err != nil {
293- if removeErr := os.Remove(tmpPath); removeErr != nil {
294- logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
295- }
296- return fmt.Errorf("failed to update header: %w", err)
297- }
298-
299- if err := file.Sync(); err != nil {
300- if removeErr := os.Remove(tmpPath); removeErr != nil {
301- logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
302- }
303- return fmt.Errorf("failed to sync cache file: %w", err)
304- }
305-
306- if err := file.Close(); err != nil {
307- logging.Warn(fmt.Sprintf("failed to close cache file: %v", err))
308- }
309-
310- // Atomic rename
311- if err := os.Rename(tmpPath, fc.path); err != nil {
312- if removeErr := os.Remove(tmpPath); removeErr != nil {
313- logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr))
314- }
315- return fmt.Errorf("failed to rename cache file: %w", err)
316- }
317-
318- logging.Info(fmt.Sprintf("Wrote binary cache: %d rules, %d bytes", totalRules, dataSize))
319- return nil
320-}
321-
322-// writeNetworkRule writes a network rule in binary format
323-func (fc *FilterCache) writeNetworkRule(file *os.File, rule NetworkRule) error {
324- patternBytes := []byte(rule.Pattern)
325- domainBytes := []byte(rule.Domain)
326-
327- ruleHeader := FilterRule{
328- Type: 0, // Network rule
329- Priority: rule.Priority,
330- Action: rule.Action,
331- Flags: 0,
332- PatternLen: uint16(len(patternBytes)),
333- DomainLen: uint16(len(domainBytes)),
334- ResourceType: rule.ResourceType,
335- }
336-
337- // Write rule header
338- if err := binary.Write(file, binary.LittleEndian, ruleHeader); err != nil {
339- return err
340- }
341-
342- // Write pattern
343- if _, err := file.Write(patternBytes); err != nil {
344- return err
345- }
346-
347- // Write domain
348- if _, err := file.Write(domainBytes); err != nil {
349- return err
350- }
351-
352- return nil
353-}
354-
355-// writeCosmeticRule writes a cosmetic rule in binary format
356-func (fc *FilterCache) writeCosmeticRule(file *os.File, rule CosmeticRule) error {
357- selectorBytes := []byte(rule.Selector)
358- domainBytes := []byte(rule.Domain)
359-
360- ruleHeader := FilterRule{
361- Type: 1, // Cosmetic rule
362- Priority: 0,
363- Action: 0, // Hide
364- Flags: 0,
365- PatternLen: uint16(len(selectorBytes)),
366- DomainLen: uint16(len(domainBytes)),
367- ResourceType: 0,
368- }
369-
370- // Write rule header
371- if err := binary.Write(file, binary.LittleEndian, ruleHeader); err != nil {
372- return err
373- }
374-
375- // Write selector
376- if _, err := file.Write(selectorBytes); err != nil {
377- return err
378- }
379-
380- // Write domain
381- if _, err := file.Write(domainBytes); err != nil {
382- return err
383- }
384-
385- return nil
386-}
387-
388-// Unmap releases the memory-mapped data
389-func (fc *FilterCache) Unmap() error {
390- if fc.mmapData != nil {
391- if err := syscall.Munmap(fc.mmapData); err != nil {
392- return fmt.Errorf("failed to unmap cache data: %w", err)
393- }
394- fc.mmapData = nil
395- }
396- return nil
397-}
398-
399-// Size returns the size of the cache file in bytes
400-func (fc *FilterCache) Size() int64 {
401- return fc.size
402-}
403-
404-// IsValid checks if the cache file exists and has valid format
405-func (fc *FilterCache) IsValid() bool {
406- file, err := os.Open(fc.path)
407- if err != nil {
408- return false
409- }
410- defer func() {
411- if err := file.Close(); err != nil {
412- logging.Warn(fmt.Sprintf("failed to close file during validation: %v", err))
413- }
414- }()
415-
416- var magic uint64
417- if err := binary.Read(file, binary.LittleEndian, &magic); err != nil {
418- return false
419- }
420-
421- return magic == cacheMagic
422-}
423-
424-// Remove deletes the cache file
425-func (fc *FilterCache) Remove() error {
426- if err := fc.Unmap(); err != nil {
427- logging.Warn(fmt.Sprintf("failed to unmap file during removal: %v", err))
428- }
429- return os.Remove(fc.path)
430-}
D internal/cache/fuzzy_cache.go +0 -373
@@ -1,373 +0,0 @@
1-package cache
2-
3-import (
4- "context"
5- "crypto/sha256"
6- "fmt"
7- "os"
8- "path/filepath"
9- "sync"
10- "sync/atomic"
11- "time"
12-
13- "github.com/bnema/dumber/internal/logging"
14-)
15-
16-// Cache update constants
17-const (
18- updateWaitIntervalMs = 10 // Milliseconds to wait during concurrent cache build
19- dbHashTimeoutSec = 5 // Seconds timeout for database hash calculation
20- recentHistoryLimit = 20 // Number of recent entries to check for changes
21- exactMatchBonus = 0.95 // Score bonus for exact matches
22- dirPerm = 0755 // Directory permissions
23-)
24-
25-// CacheManager handles the lifecycle of the fuzzy cache with smart invalidation.
26-type CacheManager struct {
27- cache *DmenuFuzzyCache
28- config *CacheConfig
29- queries HistoryQuerier
30- mu sync.RWMutex
31- lastDBHash string // Hash of database content to detect changes
32- updating int32 // Atomic flag to prevent concurrent updates
33-}
34-
35-// NewCacheManager creates a new cache manager.
36-func NewCacheManager(queries HistoryQuerier, config *CacheConfig) *CacheManager {
37- if config == nil {
38- config = DefaultCacheConfig()
39- }
40-
41- // Set default cache file path if not specified
42- if config.CacheFile == "" {
43- stateDir, err := config.GetStateDir()
44- if err != nil {
45- // Fallback to temp directory if state dir fails
46- config.CacheFile = filepath.Join(os.TempDir(), "dumber_fuzzy_cache.bin")
47- } else {
48- config.CacheFile = filepath.Join(stateDir, "dmenu_fuzzy_cache.bin")
49- }
50- }
51-
52- cm := &CacheManager{
53- cache: &DmenuFuzzyCache{},
54- config: config,
55- queries: queries,
56- }
57-
58- return cm
59-}
60-
61-// GetCache returns the current cache, loading from filesystem or DB as needed.
62-func (cm *CacheManager) GetCache(ctx context.Context) (*DmenuFuzzyCache, error) {
63- cm.mu.RLock()
64-
65- // Check if we have a valid cache
66- if cm.cache.entryCount > 0 {
67- defer cm.mu.RUnlock()
68- return cm.cache, nil
69- }
70- cm.mu.RUnlock()
71-
72- // Need to load cache
73- return cm.loadOrBuildCache(ctx)
74-}
75-
76-// loadOrBuildCache loads cache from filesystem or builds from database.
77-func (cm *CacheManager) loadOrBuildCache(ctx context.Context) (*DmenuFuzzyCache, error) {
78- cm.mu.Lock()
79- defer cm.mu.Unlock()
80-
81- // Double-check after acquiring lock
82- if cm.cache.entryCount > 0 {
83- return cm.cache, nil
84- }
85-
86- // Try loading from filesystem first
87- if cm.shouldLoadFromFile() {
88- if err := cm.cache.LoadFromBinary(cm.config.CacheFile); err == nil {
89- // Verify cache is still valid
90- if cm.isCacheValid(ctx) {
91- return cm.cache, nil
92- }
93- }
94- }
95-
96- // Build fresh cache from database
97- return cm.buildCacheFromDB(ctx)
98-}
99-
100-// shouldLoadFromFile determines if we should try loading from filesystem.
101-func (cm *CacheManager) shouldLoadFromFile() bool {
102- if _, err := os.Stat(cm.config.CacheFile); os.IsNotExist(err) {
103- return false
104- }
105-
106- // Check if file is valid and not too old
107- if !IsValidCacheFile(cm.config.CacheFile) {
108- return false
109- }
110-
111- // Check file age
112- info, err := os.Stat(cm.config.CacheFile)
113- if err != nil {
114- return false
115- }
116-
117- return time.Since(info.ModTime()) < cm.config.TTL
118-}
119-
120-// isCacheValid checks if the cache is still valid by comparing DB hash.
121-func (cm *CacheManager) isCacheValid(ctx context.Context) bool {
122- currentHash, err := cm.calculateDBHash(ctx)
123- if err != nil {
124- return false
125- }
126-
127- return currentHash == cm.lastDBHash
128-}
129-
130-// buildCacheFromDB builds a fresh cache from the database.
131-func (cm *CacheManager) buildCacheFromDB(ctx context.Context) (*DmenuFuzzyCache, error) {
132- // Prevent concurrent builds
133- if !atomic.CompareAndSwapInt32(&cm.updating, 0, 1) {
134- // Another goroutine is building, wait for it
135- for atomic.LoadInt32(&cm.updating) == 1 {
136- time.Sleep(updateWaitIntervalMs * time.Millisecond)
137- }
138- return cm.cache, nil
139- }
140- defer atomic.StoreInt32(&cm.updating, 0)
141-
142- startTime := time.Now()
143-
144- // Get history from database
145- history, err := cm.queries.GetHistory(ctx, int64(cm.config.MaxEntries))
146- if err != nil {
147- return nil, fmt.Errorf("failed to get history: %w", err)
148- }
149-
150- // Build new cache
151- newCache := &DmenuFuzzyCache{
152- version: CacheVersion,
153- lastModified: time.Now().Unix(),
154- entryCount: uint32(len(history)),
155- }
156-
157- // Convert database entries to compact format
158- newCache.entries = make([]CompactEntry, len(history))
159- for i, entry := range history {
160- title := ""
161- if entry.Title.Valid {
162- title = entry.Title.String
163- }
164-
165- visitCount := int64(0)
166- if entry.VisitCount.Valid {
167- visitCount = entry.VisitCount.Int64
168- }
169-
170- lastVisited := time.Now()
171- if entry.LastVisited.Valid {
172- lastVisited = entry.LastVisited.Time
173- }
174-
175- faviconURL := ""
176- if entry.FaviconUrl.Valid {
177- faviconURL = entry.FaviconUrl.String
178- }
179-
180- newCache.entries[i] = NewCompactEntry(
181- entry.Url,
182- title,
183- faviconURL,
184- visitCount,
185- lastVisited,
186- )
187- }
188-
189- // Build search indices
190- newCache.buildTrigramIndex()
191- newCache.buildPrefixTrie()
192- newCache.buildSortedIndex() // Pre-sort for O(1) getTopEntries
193-
194- // Update current cache
195- cm.cache = newCache
196-
197- // Update DB hash
198- cm.lastDBHash, _ = cm.calculateDBHash(ctx)
199-
200- // Save to filesystem in background
201- go cm.saveToFilesystemAsync()
202-
203- // Log cache timing to file only (not stdout) to avoid interfering with dmenu
204- if logger := logging.GetLogger(); logger != nil {
205- logger.WriteFileOnly(logging.LogLevelInfo(), fmt.Sprintf("built in %v with %d entries", time.Since(startTime), len(history)), "CACHE")
206- }
207- return cm.cache, nil
208-}
209-
210-// InvalidateAndRefresh forces cache invalidation and refresh.
211-// This should be called when we know the DB has changed (e.g., after adding history).
212-// Returns a channel that is closed when the refresh is complete (can be ignored by callers).
213-func (cm *CacheManager) InvalidateAndRefresh(ctx context.Context) <-chan struct{} {
214- done := make(chan struct{})
215- go func() {
216- defer close(done)
217-
218- cm.mu.Lock()
219- defer cm.mu.Unlock()
220-
221- // Clear current cache to force rebuild
222- cm.cache = &DmenuFuzzyCache{}
223- cm.lastDBHash = ""
224-
225- // Remove cache file
226- if err := os.Remove(cm.config.CacheFile); err != nil {
227- logging.Warn(fmt.Sprintf("failed to remove cache file %s: %v", cm.config.CacheFile, err))
228- }
229-
230- // Build new cache
231- _, err := cm.buildCacheFromDB(ctx)
232- if err != nil {
233- logging.Warn(fmt.Sprintf("failed to refresh cache: %v", err))
234- }
235- }()
236- return done
237-}
238-
239-// OnApplicationExit should be called when the application is about to exit.
240-// This triggers a background cache refresh for the next startup.
241-func (cm *CacheManager) OnApplicationExit(ctx context.Context) {
242- // Check if cache needs updating
243- if !cm.isCacheValid(ctx) {
244- // Refresh cache in background for next startup
245- go func() {
246- ctx, cancel := context.WithTimeout(context.Background(), dbHashTimeoutSec*time.Second)
247- defer cancel()
248-
249- _, err := cm.buildCacheFromDB(ctx)
250- if err != nil {
251- logging.Warn(fmt.Sprintf("failed to refresh cache on exit: %v", err))
252- }
253- }()
254- }
255-}
256-
257-// saveToFilesystemAsync saves the cache to filesystem in a background goroutine.
258-func (cm *CacheManager) saveToFilesystemAsync() {
259- // Create cache directory if it doesn't exist
260- cacheDir := filepath.Dir(cm.config.CacheFile)
261- if err := os.MkdirAll(cacheDir, dirPerm); err != nil {
262- logging.Warn(fmt.Sprintf("failed to create cache directory: %v", err))
263- return
264- }
265-
266- // Save to temporary file first, then atomic rename
267- tempFile := cm.config.CacheFile + ".tmp"
268- if err := cm.cache.SaveToBinary(tempFile); err != nil {
269- logging.Warn(fmt.Sprintf("failed to save cache: %v", err))
270- if err := os.Remove(tempFile); err != nil {
271- logging.Warn(fmt.Sprintf("failed to remove temp file %s: %v", tempFile, err))
272- }
273- return
274- }
275-
276- // Atomic rename
277- if err := os.Rename(tempFile, cm.config.CacheFile); err != nil {
278- logging.Warn(fmt.Sprintf("failed to rename cache file: %v", err))
279- if err := os.Remove(tempFile); err != nil {
280- logging.Warn(fmt.Sprintf("failed to remove temp file %s: %v", tempFile, err))
281- }
282- }
283-}
284-
285-// calculateDBHash creates a hash of the current database state to detect changes.
286-func (cm *CacheManager) calculateDBHash(ctx context.Context) (string, error) {
287- // Get a small sample of recent entries to create a fingerprint
288- recentHistory, err := cm.queries.GetHistory(ctx, recentHistoryLimit) // Just check recent entries
289- if err != nil {
290- return "", err
291- }
292-
293- // Create hash from URLs, visit counts, and last visited times
294- hasher := sha256.New()
295- for _, entry := range recentHistory {
296- hasher.Write([]byte(entry.Url))
297- if entry.VisitCount.Valid {
298- if _, err := fmt.Fprintf(hasher, "%d", entry.VisitCount.Int64); err != nil {
299- logging.Warn(fmt.Sprintf("failed to write visit count to hasher: %v", err))
300- }
301- }
302- if entry.LastVisited.Valid {
303- hasher.Write([]byte(entry.LastVisited.Time.Format(time.RFC3339)))
304- }
305- }
306-
307- return fmt.Sprintf("%x", hasher.Sum(nil)), nil
308-}
309-
310-// Search performs a fuzzy search using the cached data.
311-func (cm *CacheManager) Search(ctx context.Context, query string) (*FuzzyResult, error) {
312- cache, err := cm.GetCache(ctx)
313- if err != nil {
314- return nil, err
315- }
316-
317- return cache.Search(query, cm.config), nil
318-}
319-
320-// GetTopEntries returns the top entries without a search query.
321-func (cm *CacheManager) GetTopEntries(ctx context.Context) (*FuzzyResult, error) {
322- cache, err := cm.GetCache(ctx)
323- if err != nil {
324- return nil, err
325- }
326-
327- return cache.getTopEntries(cm.config), nil
328-}
329-
330-// GetBestPrefixMatch returns the highest-scoring URL that starts with the given prefix.
331-// Used for fish-style inline autosuggestions.
332-func (cm *CacheManager) GetBestPrefixMatch(ctx context.Context, prefix string) string {
333- cache, err := cm.GetCache(ctx)
334- if err != nil || prefix == "" {
335- return ""
336- }
337-
338- return cache.getBestPrefixMatch(prefix)
339-}
340-
341-// Stats returns cache statistics.
342-func (cm *CacheManager) Stats() CacheStats {
343- cm.mu.RLock()
344- defer cm.mu.RUnlock()
345-
346- stats := CacheStats{
347- EntryCount: int(cm.cache.entryCount),
348- TrigramCount: len(cm.cache.trigramIndex),
349- LastModified: time.Unix(cm.cache.lastModified, 0),
350- }
351-
352- if info, err := os.Stat(cm.config.CacheFile); err == nil {
353- stats.FileSize = info.Size()
354- stats.FileModTime = info.ModTime()
355- }
356-
357- return stats
358-}
359-
360-// CacheStats provides information about the cache state.
361-type CacheStats struct {
362- EntryCount int // Number of cached entries
363- TrigramCount int // Number of trigrams in index
364- FileSize int64 // Size of cache file on disk
365- LastModified time.Time // When cache was last built
366- FileModTime time.Time // When cache file was last modified
367-}
368-
369-// String returns a string representation of cache stats.
370-func (cs CacheStats) String() string {
371- return fmt.Sprintf("Entries: %d, Trigrams: %d, File: %d bytes, Modified: %v",
372- cs.EntryCount, cs.TrigramCount, cs.FileSize, cs.FileModTime.Format("15:04:05"))
373-}
D internal/cache/fuzzy_cache_test.go +0 -828
@@ -1,828 +0,0 @@
1-package cache
2-
3-import (
4- "context"
5- "database/sql"
6- "os"
7- "path/filepath"
8- "testing"
9- "time"
10-
11- mock_cache "github.com/bnema/dumber/internal/cache/mocks"
12- "github.com/bnema/dumber/internal/db"
13- "go.uber.org/mock/gomock"
14-)
15-
16-func createTestHistory() []db.History {
17- now := time.Now()
18- return []db.History{
19- {
20- ID: 1,
21- Url: "https://github.com",
22- Title: sql.NullString{String: "GitHub", Valid: true},
23- VisitCount: sql.NullInt64{Int64: 25, Valid: true},
24- LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true},
25- },
26- {
27- ID: 2,
28- Url: "https://stackoverflow.com",
29- Title: sql.NullString{String: "Stack Overflow", Valid: true},
30- VisitCount: sql.NullInt64{Int64: 15, Valid: true},
31- LastVisited: sql.NullTime{Time: now.Add(-2 * time.Hour), Valid: true},
32- },
33- {
34- ID: 3,
35- Url: "https://golang.org",
36- Title: sql.NullString{String: "The Go Programming Language", Valid: true},
37- VisitCount: sql.NullInt64{Int64: 8, Valid: true},
38- LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
39- },
40- {
41- ID: 4,
42- Url: "https://news.ycombinator.com",
43- Title: sql.NullString{String: "Hacker News", Valid: true},
44- VisitCount: sql.NullInt64{Int64: 30, Valid: true},
45- LastVisited: sql.NullTime{Time: now.Add(-30 * time.Minute), Valid: true},
46- },
47- {
48- ID: 5,
49- Url: "https://youtube.com",
50- Title: sql.NullString{String: "YouTube", Valid: true},
51- VisitCount: sql.NullInt64{Int64: 50, Valid: true},
52- LastVisited: sql.NullTime{Time: now.Add(-10 * time.Minute), Valid: true},
53- },
54- }
55-}
56-
57-func TestCacheManager_BuildCache(t *testing.T) {
58- ctrl := gomock.NewController(t)
59- defer ctrl.Finish()
60-
61- mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
62- history := createTestHistory()
63-
64- // Mock both calls - one for building cache, one for calculating hash
65- mockQuerier.EXPECT().GetHistory(gomock.Any(), int64(10000)).Return(history, nil)
66- mockQuerier.EXPECT().GetHistory(gomock.Any(), int64(20)).Return(history, nil)
67-
68- tempDir, err := os.MkdirTemp("", "dumber_cache_test")
69- if err != nil {
70- t.Fatalf("Failed to create temp dir: %v", err)
71- }
72- defer func() {
73- if err := os.RemoveAll(tempDir); err != nil {
74- t.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
75- }
76- }()
77-
78- config := DefaultCacheConfig()
79- config.CacheFile = filepath.Join(tempDir, "test_cache.bin")
80- config.MaxResults = 10
81-
82- manager := NewCacheManager(mockQuerier, config)
83- ctx := context.Background()
84-
85- // Test cache building
86- cache, err := manager.GetCache(ctx)
87- if err != nil {
88- t.Fatalf("Failed to get cache: %v", err)
89- }
90-
91- if cache.entryCount != 5 {
92- t.Errorf("Expected 5 entries, got %d", cache.entryCount)
93- }
94-
95- // Test that indices are built
96- if len(cache.trigramIndex) == 0 {
97- t.Error("Trigram index should not be empty")
98- }
99-
100- if cache.prefixTrie == nil || cache.prefixTrie.Root == nil {
101- t.Error("Prefix trie should be initialized")
102- }
103-}
104-
105-// validateSimilarityScore is a helper function to validate similarity scores
106-func validateSimilarityScore(t *testing.T, result, expected, delta float64, testName string) {
107- if result < 0.0 || result > 1.0 {
108- t.Errorf("Score %f should be between 0.0 and 1.0", result)
109- }
110-
111- diff := result - expected
112- if diff < 0 {
113- diff = -diff
114- }
115- if diff > delta {
116- t.Errorf("%s: Expected %f ±%f, got %f", testName, expected, delta, result)
117- }
118-}
119-
120-func TestFuzzySearcher_JaroWinklerSimilarity(t *testing.T) {
121- config := DefaultCacheConfig()
122- searcher := NewFuzzySearcher(config)
123-
124- tests := []struct {
125- name string
126- s1 string
127- s2 string
128- expected float64
129- delta float64
130- }{
131- {"identical strings", "test", "test", 1.0, 0.0},
132- {"empty strings", "", "", 1.0, 0.0},
133- {"one empty", "", "test", 0.0, 0.0},
134- {"similar strings", "github", "gihub", 0.88, 0.1},
135- {"different strings", "github", "stackoverflow", 0.41, 0.05},
136- {"prefix match", "programming", "program", 0.9, 0.1},
137- }
138-
139- for _, tt := range tests {
140- t.Run(tt.name, func(t *testing.T) {
141- result := searcher.jaroWinklerSimilarity(tt.s1, tt.s2)
142- validateSimilarityScore(t, result, tt.expected, tt.delta, tt.name)
143- })
144- }
145-}
146-
147-func TestFuzzySearcher_SubstringSimilarity(t *testing.T) {
148- config := DefaultCacheConfig()
149- searcher := NewFuzzySearcher(config)
150-
151- tests := []struct {
152- name string
153- query string
154- text string
155- expected float64
156- delta float64
157- }{
158- {"exact match", "github", "github", 1.0, 0.0},
159- {"substring at start", "git", "github.com", 0.45, 0.2},
160- {"substring at end", "com", "github.com", 0.3, 0.1},
161- {"no match", "xyz", "github.com", 0.0, 0.0},
162- {"empty query", "", "github.com", 0.0, 0.0},
163- {"empty text", "git", "", 0.0, 0.0},
164- }
165-
166- for _, tt := range tests {
167- t.Run(tt.name, func(t *testing.T) {
168- result := searcher.substringSimilarity(tt.query, tt.text)
169- validateSimilarityScore(t, result, tt.expected, tt.delta, tt.name)
170- })
171- }
172-}
173-
174-func TestFuzzySearcher_TokenizedSimilarity(t *testing.T) {
175- config := DefaultCacheConfig()
176- searcher := NewFuzzySearcher(config)
177-
178- tests := []struct {
179- name string
180- query string
181- text string
182- expected float64
183- delta float64
184- }{
185- {"exact token match", "go programming", "The Go Programming Language", 0.8, 0.2},
186- {"partial token match", "stack overflow", "Stack Overflow Questions", 0.8, 0.2},
187- {"no token match", "python django", "JavaScript React Framework", 0.0, 0.1},
188- {"single token", "github", "GitHub Repository", 0.78, 0.05},
189- }
190-
191- for _, tt := range tests {
192- t.Run(tt.name, func(t *testing.T) {
193- result := searcher.tokenizedSimilarity(tt.query, tt.text)
194-
195- if result < 0.0 || result > 1.0 {
196- t.Errorf("Score %f should be between 0.0 and 1.0", result)
197- }
198-
199- diff := result - tt.expected
200- if diff < 0 {
201- diff = -diff
202- }
203- if diff > tt.delta {
204- t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
205- }
206- })
207- }
208-}
209-
210-func TestFuzzySearcher_RecencyScore(t *testing.T) {
211- config := DefaultCacheConfig()
212- searcher := NewFuzzySearcher(config)
213-
214- now := time.Now()
215- tests := []struct {
216- name string
217- lastVisitDays uint32
218- expected float64
219- delta float64
220- }{
221- {"recent visit", DaysFromTime(now.Add(-1 * time.Hour)), 0.95, 0.1},
222- {"one day ago", DaysFromTime(now.Add(-24 * time.Hour)), 0.88, 0.1},
223- {"one week ago", DaysFromTime(now.Add(-7 * 24 * time.Hour)), 0.7, 0.1},
224- {"one month ago", DaysFromTime(now.Add(-30 * 24 * time.Hour)), 0.37, 0.1},
225- {"very old", DaysFromTime(now.Add(-365 * 24 * time.Hour)), 0.01, 0.01},
226- }
227-
228- for _, tt := range tests {
229- t.Run(tt.name, func(t *testing.T) {
230- result := searcher.calculateRecencyScore(tt.lastVisitDays)
231-
232- if result < 0.0 || result > 1.0 {
233- t.Errorf("Score %f should be between 0.0 and 1.0", result)
234- }
235-
236- diff := result - tt.expected
237- if diff < 0 {
238- diff = -diff
239- }
240- if diff > tt.delta {
241- t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
242- }
243- })
244- }
245-}
246-
247-func TestFuzzySearcher_VisitScore(t *testing.T) {
248- config := DefaultCacheConfig()
249- searcher := NewFuzzySearcher(config)
250-
251- tests := []struct {
252- name string
253- visitCount uint16
254- expected float64
255- delta float64
256- }{
257- {"zero visits", 0, 0.0, 0.0},
258- {"single visit", 1, 0.1, 0.05},
259- {"moderate visits", 10, 0.35, 0.1},
260- {"many visits", 100, 0.67, 0.1},
261- {"max visits", 1000, 1.0, 0.01},
262- }
263-
264- for _, tt := range tests {
265- t.Run(tt.name, func(t *testing.T) {
266- result := searcher.calculateVisitScore(tt.visitCount)
267-
268- if result < 0.0 || result > 1.0 {
269- t.Errorf("Score %f should be between 0.0 and 1.0", result)
270- }
271-
272- diff := result - tt.expected
273- if diff < 0 {
274- diff = -diff
275- }
276- if diff > tt.delta {
277- t.Errorf("Expected %f ±%f, got %f", tt.expected, tt.delta, result)
278- }
279- })
280- }
281-}
282-
283-func TestTextNormalization(t *testing.T) {
284- tests := []struct {
285- name string
286- input string
287- expected string
288- }{
289- {"basic url", "https://github.com", "github com"},
290- {"with www", "https://www.google.com", "google com"},
291- {"with path", "https://stackoverflow.com/questions/123", "stackoverflow com questions 123"},
292- {"mixed case", "GitHub.COM/User", "github com user"},
293- {"with special chars", "test-site_name.com", "test site name com"},
294- {"unicode chars", "café-münü.com", "café münü com"},
295- }
296-
297- for _, tt := range tests {
298- t.Run(tt.name, func(t *testing.T) {
299- result := normalizeText(tt.input)
300- if result != tt.expected {
301- t.Errorf("Expected %q, got %q", tt.expected, result)
302- }
303- })
304- }
305-}
306-
307-func TestTrigramExtraction(t *testing.T) {
308- tests := []struct {
309- name string
310- input string
311- expected []string
312- }{
313- {"short string", "ab", nil},
314- {"exact length", "abc", []string{"abc"}},
315- {"longer string", "github", []string{"git", "ith", "thu", "hub"}},
316- {"with spaces", "go lang", []string{"go ", "o l", " la", "lan", "ang"}},
317- }
318-
319- for _, tt := range tests {
320- t.Run(tt.name, func(t *testing.T) {
321- result := extractTrigrams(tt.input)
322- if len(result) != len(tt.expected) {
323- t.Errorf("Expected %d trigrams, got %d", len(tt.expected), len(result))
324- return
325- }
326- for i, expected := range tt.expected {
327- if result[i] != expected {
328- t.Errorf("Expected trigram %q at index %d, got %q", expected, i, result[i])
329- }
330- }
331- })
332- }
333-}
334-
335-func TestCompactEntry_BaseScore(t *testing.T) {
336- now := time.Now()
337-
338- tests := []struct {
339- name string
340- visitCount uint16
341- lastVisit time.Time
342- expectRange [2]uint16 // min, max expected score
343- }{
344- {"high visits, recent", 100, now.Add(-1 * time.Hour), [2]uint16{44000, 65535}},
345- {"low visits, recent", 5, now.Add(-1 * time.Hour), [2]uint16{35000, 50000}},
346- {"high visits, old", 100, now.Add(-90 * 24 * time.Hour), [2]uint16{5000, 25000}},
347- {"low visits, old", 5, now.Add(-90 * 24 * time.Hour), [2]uint16{1000, 15000}},
348- }
349-
350- for _, tt := range tests {
351- t.Run(tt.name, func(t *testing.T) {
352- entry := CompactEntry{
353- VisitCount: tt.visitCount,
354- LastVisit: DaysFromTime(tt.lastVisit),
355- }
356-
357- score := entry.calculateBaseScore()
358-
359- if score < tt.expectRange[0] || score > tt.expectRange[1] {
360- t.Errorf("Expected score between %d and %d, got %d",
361- tt.expectRange[0], tt.expectRange[1], score)
362- }
363- })
364- }
365-}
366-
367-func TestFuzzySearch_Integration(t *testing.T) {
368- ctrl := gomock.NewController(t)
369- defer ctrl.Finish()
370-
371- mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
372- history := createTestHistory()
373-
374- mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes()
375-
376- tempDir, err := os.MkdirTemp("", "dumber_search_test")
377- if err != nil {
378- t.Fatalf("Failed to create temp dir: %v", err)
379- }
380- defer func() {
381- if err := os.RemoveAll(tempDir); err != nil {
382- t.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
383- }
384- }()
385-
386- config := DefaultCacheConfig()
387- config.CacheFile = filepath.Join(tempDir, "search_cache.bin")
388- config.ScoreThreshold = 0.1
389-
390- manager := NewCacheManager(mockQuerier, config)
391- ctx := context.Background()
392-
393- // Critical fuzzy search tests
394- searchTests := []struct {
395- name string
396- query string
397- expectCount int
398- expectFirst string
399- expectMatchType MatchType
400- }{
401- {
402- name: "exact domain match",
403- query: "github",
404- expectCount: 1,
405- expectFirst: "https://github.com",
406- expectMatchType: MatchTypeExact,
407- },
408- {
409- name: "partial title match",
410- query: "programming",
411- expectCount: 1,
412- expectFirst: "https://golang.org",
413- expectMatchType: MatchTypeExact,
414- },
415- {
416- name: "fuzzy typo match",
417- query: "githb", // typo in github
418- expectCount: 1,
419- expectFirst: "https://github.com",
420- expectMatchType: MatchTypeFuzzy,
421- },
422- {
423- name: "prefix match",
424- query: "you",
425- expectCount: 1,
426- expectFirst: "https://youtube.com",
427- expectMatchType: MatchTypePrefix,
428- },
429- {
430- name: "no matches",
431- query: "nonexistentsite12345",
432- expectCount: 0,
433- },
434- }
435-
436- for _, tt := range searchTests {
437- t.Run(tt.name, func(t *testing.T) {
438- result, err := manager.Search(ctx, tt.query)
439- if err != nil {
440- t.Fatalf("Search failed: %v", err)
441- }
442-
443- if len(result.Matches) < tt.expectCount {
444- t.Errorf("Expected at least %d matches, got %d", tt.expectCount, len(result.Matches))
445- return
446- }
447-
448- if tt.expectCount == 0 {
449- return // No further checks needed
450- }
451-
452- // Check first result
453- if result.Matches[0].Entry.URL != tt.expectFirst {
454- t.Errorf("Expected first result %q, got %q",
455- tt.expectFirst, result.Matches[0].Entry.URL)
456- }
457-
458- // Verify results are sorted by score
459- for i := 1; i < len(result.Matches); i++ {
460- if result.Matches[i].Score > result.Matches[i-1].Score {
461- t.Errorf("Results not sorted by score: %f > %f at position %d",
462- result.Matches[i].Score, result.Matches[i-1].Score, i)
463- }
464- }
465-
466- // Verify all scores meet threshold
467- for i, match := range result.Matches {
468- if match.Score < config.ScoreThreshold {
469- t.Errorf("Match %d score %f below threshold %f",
470- i, match.Score, config.ScoreThreshold)
471- }
472- }
473-
474- // Verify query time is recorded
475- if result.QueryTime <= 0 {
476- t.Error("Query time should be positive")
477- }
478- })
479- }
480-}
481-
482-func TestCacheInvalidation(t *testing.T) {
483- ctrl := gomock.NewController(t)
484- defer ctrl.Finish()
485-
486- mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
487-
488- // First call returns initial history
489- firstHistory := createTestHistory()[:3] // Only 3 entries
490- secondHistory := createTestHistory() // All 5 entries
491-
492- // Set up multiple expectations for the different calls
493- gomock.InOrder(
494- mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(firstHistory, nil).Times(2), // Build + hash
495- mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(secondHistory, nil).AnyTimes(), // Subsequent calls
496- )
497-
498- tempDir, err := os.MkdirTemp("", "dumber_invalidation_test")
499- if err != nil {
500- t.Fatalf("Failed to create temp dir: %v", err)
501- }
502- defer func() {
503- if err := os.RemoveAll(tempDir); err != nil {
504- t.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
505- }
506- }()
507-
508- config := DefaultCacheConfig()
509- config.CacheFile = filepath.Join(tempDir, "invalidation_cache.bin")
510-
511- manager := NewCacheManager(mockQuerier, config)
512- ctx := context.Background()
513-
514- // Initial cache build
515- cache1, err := manager.GetCache(ctx)
516- if err != nil {
517- t.Fatalf("Failed to get initial cache: %v", err)
518- }
519-
520- if cache1.entryCount != 3 {
521- t.Errorf("Expected 3 entries in initial cache, got %d", cache1.entryCount)
522- }
523-
524- // Invalidate and refresh - wait for completion to prevent mock access after test ends
525- done := manager.InvalidateAndRefresh(ctx)
526- <-done
527-
528- // Get updated cache
529- cache2, err := manager.GetCache(ctx)
530- if err != nil {
531- t.Fatalf("Failed to get updated cache: %v", err)
532- }
533-
534- if cache2.entryCount != 5 {
535- t.Errorf("Expected 5 entries in updated cache, got %d", cache2.entryCount)
536- }
537-}
538-
539-// create40TestEntries creates a realistic dataset with 40 entries for benchmarking
540-func create40TestEntries() []db.History {
541- now := time.Now()
542- entries := make([]db.History, 40)
543-
544- urls := []string{
545- "https://github.com", "https://stackoverflow.com", "https://golang.org", "https://news.ycombinator.com",
546- "https://youtube.com", "https://google.com", "https://reddit.com", "https://twitter.com",
547- "https://linkedin.com", "https://medium.com", "https://dev.to", "https://docs.python.org",
548- "https://reactjs.org", "https://nodejs.org", "https://kubernetes.io", "https://docker.com",
549- "https://aws.amazon.com", "https://cloud.google.com", "https://azure.microsoft.com", "https://heroku.com",
550- "https://netlify.com", "https://vercel.com", "https://stripe.com", "https://shopify.com",
551- "https://gitlab.com", "https://bitbucket.org", "https://atlassian.com", "https://slack.com",
552- "https://discord.com", "https://telegram.org", "https://whatsapp.com", "https://facebook.com",
553- "https://instagram.com", "https://tiktok.com", "https://netflix.com", "https://spotify.com",
554- "https://apple.com", "https://microsoft.com", "https://amazon.com", "https://wikipedia.org",
555- }
556-
557- titles := []string{
558- "GitHub", "Stack Overflow", "Go Programming Language", "Hacker News",
559- "YouTube", "Google", "Reddit", "Twitter",
560- "LinkedIn", "Medium", "Dev.to", "Python Documentation",
561- "React", "Node.js", "Kubernetes", "Docker",
562- "AWS", "Google Cloud", "Microsoft Azure", "Heroku",
563- "Netlify", "Vercel", "Stripe", "Shopify",
564- "GitLab", "Bitbucket", "Atlassian", "Slack",
565- "Discord", "Telegram", "WhatsApp", "Facebook",
566- "Instagram", "TikTok", "Netflix", "Spotify",
567- "Apple", "Microsoft", "Amazon", "Wikipedia",
568- }
569-
570- for i := 0; i < 40; i++ {
571- entries[i] = db.History{
572- ID: int64(i + 1),
573- Url: urls[i],
574- Title: sql.NullString{String: titles[i], Valid: true},
575- VisitCount: sql.NullInt64{Int64: int64(40 - i), Valid: true}, // Decreasing visit counts
576- LastVisited: sql.NullTime{Time: now.Add(-time.Duration(i) * time.Hour), Valid: true},
577- }
578- }
579-
580- return entries
581-}
582-
583-// BenchmarkGetTopEntries benchmarks the critical path for dmenu performance
584-func BenchmarkGetTopEntries(b *testing.B) {
585- ctrl := gomock.NewController(b)
586- defer ctrl.Finish()
587-
588- mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
589-
590- // Setup 40 test entries
591- testHistory := create40TestEntries()
592- mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
593-
594- // Create temporary directory for cache file
595- tempDir, err := os.MkdirTemp("", "cache_benchmark")
596- if err != nil {
597- b.Fatalf("Failed to create temp dir: %v", err)
598- }
599- defer func() {
600- if err := os.RemoveAll(tempDir); err != nil {
601- b.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
602- }
603- }()
604-
605- config := DefaultCacheConfig()
606- config.CacheFile = filepath.Join(tempDir, "benchmark_cache.bin")
607- config.MaxResults = 50
608-
609- manager := NewCacheManager(mockQuerier, config)
610- ctx := context.Background()
611-
612- // Pre-build cache once (this isn't part of the benchmark)
613- _, err = manager.GetCache(ctx)
614- if err != nil {
615- b.Fatalf("Failed to pre-build cache: %v", err)
616- }
617-
618- b.ResetTimer() // Start timing from here
619- b.ReportAllocs()
620-
621- // This is the critical path that needs to be under 2ms
622- for i := 0; i < b.N; i++ {
623- result, err := manager.GetTopEntries(ctx)
624- if err != nil {
625- b.Fatalf("GetTopEntries failed: %v", err)
626- }
627- if len(result.Matches) == 0 {
628- b.Fatalf("Expected matches, got none")
629- }
630- }
631-}
632-
633-func TestGetBestPrefixMatch(t *testing.T) {
634- ctrl := gomock.NewController(t)
635- defer ctrl.Finish()
636-
637- mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
638- history := createTestHistory()
639-
640- mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes()
641-
642- tempDir, err := os.MkdirTemp("", "dumber_prefix_test")
643- if err != nil {
644- t.Fatalf("Failed to create temp dir: %v", err)
645- }
646- defer func() {
647- if err := os.RemoveAll(tempDir); err != nil {
648- t.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
649- }
650- }()
651-
652- config := DefaultCacheConfig()
653- config.CacheFile = filepath.Join(tempDir, "prefix_cache.bin")
654-
655- manager := NewCacheManager(mockQuerier, config)
656- ctx := context.Background()
657-
658- // Build cache first
659- _, err = manager.GetCache(ctx)
660- if err != nil {
661- t.Fatalf("Failed to get cache: %v", err)
662- }
663-
664- tests := []struct {
665- name string
666- prefix string
667- expectURL string
668- expectEmpty bool
669- }{
670- {
671- name: "exact prefix match - github",
672- prefix: "https://github",
673- expectURL: "https://github.com",
674- },
675- {
676- name: "partial prefix match - you",
677- prefix: "https://you",
678- expectURL: "https://youtube.com",
679- },
680- {
681- name: "partial prefix match - stack",
682- prefix: "https://stack",
683- expectURL: "https://stackoverflow.com",
684- },
685- {
686- name: "no match",
687- prefix: "https://nonexistent",
688- expectEmpty: true,
689- },
690- {
691- name: "empty prefix",
692- prefix: "",
693- expectEmpty: true,
694- },
695- {
696- name: "case insensitive match",
697- prefix: "https://GITHUB",
698- expectURL: "https://github.com",
699- },
700- }
701-
702- for _, tt := range tests {
703- t.Run(tt.name, func(t *testing.T) {
704- result := manager.GetBestPrefixMatch(ctx, tt.prefix)
705-
706- if tt.expectEmpty {
707- if result != "" {
708- t.Errorf("Expected empty result, got %q", result)
709- }
710- return
711- }
712-
713- if result != tt.expectURL {
714- t.Errorf("Expected %q, got %q", tt.expectURL, result)
715- }
716- })
717- }
718-}
719-
720-func TestGetBestPrefixMatch_ReturnsHighestScore(t *testing.T) {
721- ctrl := gomock.NewController(t)
722- defer ctrl.Finish()
723-
724- mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
725-
726- // Create history with multiple entries sharing the same prefix
727- // "https://go" matches both golang.org and google.com
728- now := time.Now()
729- history := []db.History{
730- {
731- ID: 1,
732- Url: "https://golang.org",
733- Title: sql.NullString{String: "Go Programming Language", Valid: true},
734- VisitCount: sql.NullInt64{Int64: 5, Valid: true},
735- LastVisited: sql.NullTime{Time: now.Add(-5 * time.Hour), Valid: true},
736- },
737- {
738- ID: 2,
739- Url: "https://google.com",
740- Title: sql.NullString{String: "Google", Valid: true},
741- VisitCount: sql.NullInt64{Int64: 100, Valid: true}, // Much higher visits
742- LastVisited: sql.NullTime{Time: now.Add(-1 * time.Hour), Valid: true}, // More recent
743- },
744- }
745-
746- mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(history, nil).AnyTimes()
747-
748- tempDir, err := os.MkdirTemp("", "dumber_prefix_score_test")
749- if err != nil {
750- t.Fatalf("Failed to create temp dir: %v", err)
751- }
752- defer func() {
753- if err := os.RemoveAll(tempDir); err != nil {
754- t.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
755- }
756- }()
757-
758- config := DefaultCacheConfig()
759- config.CacheFile = filepath.Join(tempDir, "prefix_score_cache.bin")
760-
761- manager := NewCacheManager(mockQuerier, config)
762- ctx := context.Background()
763-
764- // Build cache first
765- _, err = manager.GetCache(ctx)
766- if err != nil {
767- t.Fatalf("Failed to get cache: %v", err)
768- }
769-
770- // Query with prefix that matches both
771- result := manager.GetBestPrefixMatch(ctx, "https://go")
772-
773- // Should return google.com because it has higher score (more visits, more recent)
774- if result != "https://google.com" {
775- t.Errorf("Expected https://google.com (highest score), got %q", result)
776- }
777-}
778-
779-// BenchmarkGetTopEntriesParallel benchmarks concurrent access performance
780-func BenchmarkGetTopEntriesParallel(b *testing.B) {
781- ctrl := gomock.NewController(b)
782- defer ctrl.Finish()
783-
784- mockQuerier := mock_cache.NewMockHistoryQuerier(ctrl)
785-
786- // Setup 40 test entries
787- testHistory := create40TestEntries()
788- mockQuerier.EXPECT().GetHistory(gomock.Any(), gomock.Any()).Return(testHistory, nil).AnyTimes()
789-
790- // Create temporary directory for cache file
791- tempDir, err := os.MkdirTemp("", "cache_benchmark_parallel")
792- if err != nil {
793- b.Fatalf("Failed to create temp dir: %v", err)
794- }
795- defer func() {
796- if err := os.RemoveAll(tempDir); err != nil {
797- b.Logf("Warning: failed to remove temp dir %s: %v", tempDir, err)
798- }
799- }()
800-
801- config := DefaultCacheConfig()
802- config.CacheFile = filepath.Join(tempDir, "benchmark_parallel_cache.bin")
803- config.MaxResults = 50
804-
805- manager := NewCacheManager(mockQuerier, config)
806- ctx := context.Background()
807-
808- // Pre-build cache once (this isn't part of the benchmark)
809- _, err = manager.GetCache(ctx)
810- if err != nil {
811- b.Fatalf("Failed to pre-build cache: %v", err)
812- }
813-
814- b.ResetTimer()
815- b.ReportAllocs()
816-
817- b.RunParallel(func(pb *testing.PB) {
818- for pb.Next() {
819- result, err := manager.GetTopEntries(ctx)
820- if err != nil {
821- b.Fatalf("GetTopEntries failed: %v", err)
822- }
823- if len(result.Matches) == 0 {
824- b.Fatalf("Expected matches, got none")
825- }
826- }
827- })
828-}
D internal/cache/fuzzy_search.go +0 -398
@@ -1,398 +0,0 @@
1-package cache
2-
3-import (
4- "math"
5- "sort"
6- "strings"
7- "sync"
8- "time"
9-)
10-
11-// Fuzzy search constants
12-const (
13- prefixMatchBonus = 0.9 // Score bonus for prefix matches
14- prefixLength = 4 // Maximum prefix length to check
15- recencyDecayDays = 30.0 // Days over which recency score decays
16- maxVisitNormal = 1000.0 // Maximum visits for normalization
17-)
18-
19-// FuzzySearcher provides high-performance fuzzy search capabilities.
20-type FuzzySearcher struct {
21- config *CacheConfig
22- resultPool sync.Pool
23-}
24-
25-// NewFuzzySearcher creates a new fuzzy searcher with the given config.
26-func NewFuzzySearcher(config *CacheConfig) *FuzzySearcher {
27- fs := &FuzzySearcher{
28- config: config,
29- }
30-
31- // Initialize result pool for zero-allocation queries
32- fs.resultPool = sync.Pool{
33- New: func() interface{} {
34- return &FuzzyResult{
35- Matches: make([]FuzzyMatch, 0, config.MaxResults),
36- }
37- },
38- }
39-
40- return fs
41-}
42-
43-// Search performs a fuzzy search on the cache and returns ranked results.
44-func (c *DmenuFuzzyCache) Search(query string, config *CacheConfig) *FuzzyResult {
45- if len(query) == 0 {
46- return c.getTopEntries(config)
47- }
48-
49- startTime := time.Now()
50-
51- // Get result object from pool
52- searcher := NewFuzzySearcher(config)
53- result := searcher.resultPool.Get().(*FuzzyResult)
54- result.Reset()
55-
56- c.mu.RLock()
57- defer c.mu.RUnlock()
58-
59- // Phase 1: Fast candidate selection using indices
60- candidates := c.getAllCandidates(query)
61-
62- // Phase 2: Score candidates using fuzzy algorithms
63- matches := make([]FuzzyMatch, 0, len(candidates))
64-
65- if len(candidates) == 0 {
66- // Fallback: search all entries (should be rare)
67- candidates = make([]uint32, len(c.entries))
68- for i := range c.entries {
69- candidates[i] = uint32(i)
70- }
71- }
72-
73- // Limit candidates to avoid excessive computation
74- if len(candidates) > 1000 {
75- candidates = candidates[:1000]
76- }
77-
78- for _, entryID := range candidates {
79- if int(entryID) >= len(c.entries) {
80- continue
81- }
82-
83- match := searcher.scoreEntry(query, &c.entries[entryID])
84- if match.Score >= config.ScoreThreshold {
85- matches = append(matches, match)
86- }
87- }
88-
89- // Phase 3: Sort by score and apply limits
90- sort.Slice(matches, func(i, j int) bool {
91- return matches[i].Score > matches[j].Score
92- })
93-
94- if len(matches) > config.MaxResults {
95- matches = matches[:config.MaxResults]
96- }
97-
98- result.Matches = matches
99- result.QueryTime = time.Since(startTime)
100-
101- return result
102-}
103-
104-// getTopEntries returns the top entries when no query is provided.
105-func (c *DmenuFuzzyCache) getTopEntries(config *CacheConfig) *FuzzyResult {
106- c.mu.RLock()
107- defer c.mu.RUnlock()
108-
109- startTime := time.Now()
110-
111- // Use pre-sorted index for O(1) access to top entries
112- maxResults := config.MaxResults
113- if maxResults > len(c.sortedIndex) {
114- maxResults = len(c.sortedIndex)
115- }
116-
117- matches := make([]FuzzyMatch, maxResults)
118- for i := 0; i < maxResults; i++ {
119- entryID := c.sortedIndex[i]
120- matches[i] = FuzzyMatch{
121- Entry: &c.entries[entryID],
122- Score: float64(c.entries[entryID].Score) / 65535.0, // Normalize to 0-1
123- RecencyScore: 1.0, // No query, so full recency weight
124- VisitScore: 1.0, // No query, so full visit weight
125- MatchType: MatchTypeExact,
126- }
127- }
128-
129- return &FuzzyResult{
130- Matches: matches,
131- QueryTime: time.Since(startTime),
132- }
133-}
134-
135-// scoreEntry calculates the fuzzy match score for a single entry.
136-func (fs *FuzzySearcher) scoreEntry(query string, entry *CompactEntry) FuzzyMatch {
137- match := FuzzyMatch{
138- Entry: entry,
139- }
140-
141- queryNorm := normalizeText(query)
142- urlNorm := normalizeText(entry.URL)
143- titleNorm := normalizeText(entry.Title)
144-
145- // Calculate different types of similarity
146- match.URLScore = fs.calculateTextSimilarity(queryNorm, urlNorm)
147- match.TitleScore = fs.calculateTextSimilarity(queryNorm, titleNorm)
148-
149- // Boost exact matches
150- if strings.Contains(urlNorm, queryNorm) {
151- match.URLScore = math.Max(match.URLScore, exactMatchBonus)
152- match.MatchType = MatchTypeExact
153- }
154- if strings.Contains(titleNorm, queryNorm) {
155- match.TitleScore = math.Max(match.TitleScore, exactMatchBonus)
156- match.MatchType = MatchTypeExact
157- }
158-
159- // Boost prefix matches
160- if strings.HasPrefix(urlNorm, queryNorm) {
161- match.URLScore = math.Max(match.URLScore, prefixMatchBonus)
162- if match.MatchType != MatchTypeExact {
163- match.MatchType = MatchTypePrefix
164- }
165- }
166- if strings.HasPrefix(titleNorm, queryNorm) {
167- match.TitleScore = math.Max(match.TitleScore, prefixMatchBonus)
168- if match.MatchType != MatchTypeExact {
169- match.MatchType = MatchTypePrefix
170- }
171- }
172-
173- // Calculate recency and visit scores
174- match.RecencyScore = fs.calculateRecencyScore(entry.LastVisit)
175- match.VisitScore = fs.calculateVisitScore(entry.VisitCount)
176-
177- // Determine final match type if not already set
178- if match.MatchType == 0 {
179- if match.URLScore > 0.7 || match.TitleScore > 0.7 {
180- match.MatchType = MatchTypeFuzzy
181- } else {
182- match.MatchType = MatchTypeTrigram
183- }
184- }
185-
186- // Calculate weighted final score
187- match.Score = fs.config.URLWeight*match.URLScore +
188- fs.config.TitleWeight*match.TitleScore +
189- fs.config.RecencyWeight*match.RecencyScore +
190- fs.config.VisitWeight*match.VisitScore
191-
192- return match
193-}
194-
195-// calculateTextSimilarity computes similarity between query and text using multiple algorithms.
196-func (fs *FuzzySearcher) calculateTextSimilarity(query, text string) float64 {
197- if query == text {
198- return 1.0
199- }
200-
201- if len(query) == 0 || len(text) == 0 {
202- return 0.0
203- }
204-
205- // Use different algorithms based on text length for optimal performance
206- if len(query) <= 3 {
207- // For short queries, use substring matching
208- return fs.substringSimilarity(query, text)
209- } else if len(query) <= 10 {
210- // For medium queries, use Jaro-Winkler
211- return fs.jaroWinklerSimilarity(query, text)
212- }
213- // For long queries, use tokenized matching
214- return fs.tokenizedSimilarity(query, text)
215-}
216-
217-// substringSimilarity calculates similarity based on substring matches with position weighting.
218-func (fs *FuzzySearcher) substringSimilarity(query, text string) float64 {
219- index := strings.Index(text, query)
220- if index == -1 {
221- return 0.0
222- }
223-
224- // Base score
225- baseScore := float64(len(query)) / float64(len(text))
226-
227- // Position bonus (earlier matches are better)
228- positionBonus := 1.0
229- if index == 0 {
230- positionBonus = 1.5 // Start of string
231- } else if index < len(text)/3 {
232- positionBonus = 1.2 // First third
233- }
234-
235- score := baseScore * positionBonus
236- if score > 1.0 {
237- score = 1.0
238- }
239-
240- return score
241-}
242-
243-// jaroWinklerSimilarity calculates Jaro-Winkler similarity (optimized version).
244-func (fs *FuzzySearcher) jaroWinklerSimilarity(s1, s2 string) float64 {
245- if s1 == s2 {
246- return 1.0
247- }
248-
249- len1, len2 := len(s1), len(s2)
250- if len1 == 0 || len2 == 0 {
251- return 0.0
252- }
253-
254- // Calculate match window
255- matchWindow := (maxInt(len1, len2) / 2) - 1
256- if matchWindow < 0 {
257- matchWindow = 0
258- }
259-
260- matches1 := make([]bool, len1)
261- matches2 := make([]bool, len2)
262- matches := 0
263-
264- // Find matches
265- for i := 0; i < len1; i++ {
266- start := maxInt(0, i-matchWindow)
267- end := minInt(i+matchWindow+1, len2)
268-
269- for j := start; j < end; j++ {
270- if matches2[j] || s1[i] != s2[j] {
271- continue
272- }
273- matches1[i] = true
274- matches2[j] = true
275- matches++
276- break
277- }
278- }
279-
280- if matches == 0 {
281- return 0.0
282- }
283-
284- // Count transpositions
285- transpositions := 0
286- k := 0
287- for i := 0; i < len1; i++ {
288- if !matches1[i] {
289- continue
290- }
291- for !matches2[k] {
292- k++
293- }
294- if s1[i] != s2[k] {
295- transpositions++
296- }
297- k++
298- }
299-
300- // Calculate Jaro similarity
301- jaro := (float64(matches)/float64(len1) +
302- float64(matches)/float64(len2) +
303- float64(matches-transpositions/2)/float64(matches)) / 3.0
304-
305- // Jaro-Winkler modification
306- if jaro < 0.7 {
307- return jaro
308- }
309-
310- // Common prefix (up to 4 characters)
311- prefix := 0
312- for i := 0; i < minInt(minInt(len1, len2), prefixLength); i++ {
313- if s1[i] == s2[i] {
314- prefix++
315- } else {
316- break
317- }
318- }
319-
320- return jaro + (0.1 * float64(prefix) * (1 - jaro))
321-}
322-
323-// tokenizedSimilarity performs token-based matching for longer texts.
324-func (fs *FuzzySearcher) tokenizedSimilarity(query, text string) float64 {
325- queryTokens := strings.Fields(query)
326- textTokens := strings.Fields(text)
327-
328- if len(queryTokens) == 0 || len(textTokens) == 0 {
329- return 0.0
330- }
331-
332- totalScore := 0.0
333- matchedTokens := 0
334-
335- for _, queryToken := range queryTokens {
336- bestScore := 0.0
337- for _, textToken := range textTokens {
338- score := fs.jaroWinklerSimilarity(queryToken, textToken)
339- if score > bestScore {
340- bestScore = score
341- }
342- }
343- if bestScore >= 0.6 { // Token match threshold
344- totalScore += bestScore
345- matchedTokens++
346- }
347- }
348-
349- if matchedTokens == 0 {
350- return 0.0
351- }
352-
353- // Average score weighted by matched token ratio
354- avgScore := totalScore / float64(matchedTokens)
355- tokenRatio := float64(matchedTokens) / float64(len(queryTokens))
356-
357- return avgScore * tokenRatio
358-}
359-
360-// calculateRecencyScore calculates score based on how recently the entry was visited.
361-func (fs *FuzzySearcher) calculateRecencyScore(lastVisitDays uint32) float64 {
362- now := time.Now()
363- lastVisit := TimeFromDays(lastVisitDays)
364- daysSince := now.Sub(lastVisit).Hours() / 24
365-
366- // Exponential decay over 30 days
367- return math.Exp(-daysSince / recencyDecayDays)
368-}
369-
370-// calculateVisitScore calculates score based on visit count with logarithmic scaling.
371-func (fs *FuzzySearcher) calculateVisitScore(visitCount uint16) float64 {
372- if visitCount == 0 {
373- return 0.0
374- }
375-
376- // Logarithmic scaling to prevent highly visited sites from dominating
377- score := math.Log1p(float64(visitCount)) / math.Log1p(maxVisitNormal)
378- if score > 1.0 {
379- score = 1.0
380- }
381-
382- return score
383-}
384-
385-// Helper functions
386-func maxInt(a, b int) int {
387- if a > b {
388- return a
389- }
390- return b
391-}
392-
393-func minInt(a, b int) int {
394- if a < b {
395- return a
396- }
397- return b
398-}
D internal/cache/generic/README.md +0 -244
@@ -1,244 +0,0 @@
1-# Generic Cache Framework
2-
3-A type-safe, high-performance caching layer for Dumber Browser that provides RAM-first access with asynchronous database persistence.
4-
5-## Overview
6-
7-The generic cache framework provides a consistent caching pattern across all Dumber Browser caches:
8-- **RAM-First**: All reads from memory (no DB queries)
9-- **Async Writes**: Updates happen immediately in cache, persisted asynchronously
10-- **Bulk Load**: Load all data from DB at startup
11-- **Graceful Shutdown**: Flush all pending writes on shutdown
12-
13-## Architecture
14-
15-```
16-┌─────────────────────────────────────────────────────────────┐
17-│ Application Code │
18-│ (BrowserService, etc.) │
19-└──────────────────────────┬──────────────────────────────────┘
20- │
21- ▼
22-┌─────────────────────────────────────────────────────────────┐
23-│ Cache[K, V] Interface │
24-│ │
25-│ Load(ctx) - Bulk load at startup │
26-│ Get(key) - RAM lookup only (never DB) │
27-│ Set(key, value) - Immediate cache + async DB │
28-│ Delete(key) - Immediate cache + async DB │
29-│ List() - All cached values │
30-│ Flush() - Wait for pending writes │
31-└──────────────────────────┬──────────────────────────────────┘
32- │
33- ▼
34-┌─────────────────────────────────────────────────────────────┐
35-│ GenericCache[K, V] Implementation │
36-│ │
37-│ ┌──────────────┐ ┌──────────────────────────────────┐ │
38-│ │ sync.Map │ │ DatabaseOperations[K, V] │ │
39-│ │ (RAM cache) │ │ │ │
40-│ │ │ │ - LoadAll(ctx) → map[K]V │ │
41-│ │ │ │ - Persist(ctx, K, V) → error │ │
42-│ │ │ │ - Delete(ctx, K) → error │ │
43-│ └──────────────┘ └──────────────────────────────────┘ │
44-│ │
45-│ ┌────────────────────────────────────────────────────────┐ │
46-│ │ pendingWrites sync.WaitGroup │ │
47-│ │ (tracks async operations for graceful shutdown) │ │
48-│ └────────────────────────────────────────────────────────┘ │
49-└──────────────────────────┬──────────────────────────────────┘
50- │
51- ▼
52-┌─────────────────────────────────────────────────────────────┐
53-│ Database (SQLite) │
54-│ │
55-│ zoom_levels, shortcuts, certificate_validations, etc. │
56-└─────────────────────────────────────────────────────────────┘
57-```
58-
59-## Usage Example
60-
61-### 1. Implement DatabaseOperations
62-
63-First, create a struct that implements the `DatabaseOperations[K, V]` interface:
64-
65-```go
66-package cache
67-
68-import (
69- "context"
70- "github.com/bnema/dumber/internal/cache/generic"
71- "github.com/bnema/dumber/internal/db"
72-)
73-
74-// ZoomDBOperations implements DatabaseOperations for zoom levels
75-type ZoomDBOperations struct {
76- queries db.DatabaseQuerier
77-}
78-
79-func NewZoomDBOperations(queries db.DatabaseQuerier) *ZoomDBOperations {
80- return &ZoomDBOperations{queries: queries}
81-}
82-
83-func (z *ZoomDBOperations) LoadAll(ctx context.Context) (map[string]float64, error) {
84- levels, err := z.queries.ListZoomLevels(ctx)
85- if err != nil {
86- return nil, err
87- }
88-
89- result := make(map[string]float64, len(levels))
90- for _, level := range levels {
91- result[level.Domain] = level.ZoomFactor
92- }
93- return result, nil
94-}
95-
96-func (z *ZoomDBOperations) Persist(ctx context.Context, key string, value float64) error {
97- return z.queries.SetZoomLevel(ctx, key, value)
98-}
99-
100-func (z *ZoomDBOperations) Delete(ctx context.Context, key string) error {
101- return z.queries.DeleteZoomLevel(ctx, key)
102-}
103-```
104-
105-### 2. Create a Cache Instance
106-
107-```go
108-package cache
109-
110-import "github.com/bnema/dumber/internal/cache/generic"
111-
112-type ZoomCache struct {
113- *generic.GenericCache[string, float64]
114-}
115-
116-func NewZoomCache(queries db.DatabaseQuerier) *ZoomCache {
117- dbOps := NewZoomDBOperations(queries)
118- return &ZoomCache{
119- GenericCache: generic.NewGenericCache(dbOps),
120- }
121-}
122-```
123-
124-### 3. Use in Application Code
125-
126-```go
127-// At startup
128-zoomCache := cache.NewZoomCache(dbQueries)
129-if err := zoomCache.Load(ctx); err != nil {
130- log.Fatalf("Failed to load zoom cache: %v", err)
131-}
132-
133-// Get (always from RAM, never hits DB)
134-if zoomLevel, ok := zoomCache.Get("example.com"); ok {
135- fmt.Printf("Zoom level: %.2f\n", zoomLevel)
136-}
137-
138-// Set (immediate in cache, async to DB)
139-if err := zoomCache.Set("example.com", 1.5); err != nil {
140- log.Printf("Failed to set zoom level: %v", err)
141-}
142-
143-// On shutdown
144-if err := zoomCache.Flush(); err != nil {
145- log.Printf("Failed to flush cache: %v", err)
146-}
147-```
148-
149-## Performance Characteristics
150-
151-| Operation | Latency | Notes |
152-|-----------|-------------|--------------------------------------------|
153-| Load() | ~2-50ms | One-time at startup, bulk loads all data |
154-| Get() | <1µs | sync.Map lookup, never touches DB |
155-| Set() | <1µs | Returns immediately, DB write async |
156-| Delete() | <1µs | Returns immediately, DB delete async |
157-| List() | ~10-100µs | Iterates sync.Map, no DB access |
158-| Flush() | Varies | Blocks until all async writes complete |
159-
160-## Thread Safety
161-
162-All operations are thread-safe:
163-- `sync.Map` provides lock-free concurrent reads and writes
164-- `sync.WaitGroup` coordinates graceful shutdown
165-- Database operations are serialized per key (last write wins)
166-
167-## Testing
168-
169-The framework includes comprehensive unit tests with a mock implementation:
170-
171-```go
172-import "github.com/bnema/dumber/internal/cache/generic"
173-
174-func TestMyCache(t *testing.T) {
175- mock := generic.NewMockDatabaseOperations[string, int]()
176-
177- // Configure mock behavior
178- mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) {
179- return map[string]int{"key": 42}, nil
180- }
181-
182- cache := generic.NewGenericCache(mock)
183-
184- // Test cache operations
185- if err := cache.Load(context.Background()); err != nil {
186- t.Fatal(err)
187- }
188-
189- val, ok := cache.Get("key")
190- if !ok || val != 42 {
191- t.Errorf("Expected key=42, got %v, %v", val, ok)
192- }
193-
194- // Verify mock was called
195- if count := mock.GetLoadAllCallCount(); count != 1 {
196- t.Errorf("Expected LoadAll called once, got %d", count)
197- }
198-}
199-```
200-
201-## Design Decisions
202-
203-### Why Generic?
204-
205-Type safety and code reuse. The same cache implementation works for:
206-- `Cache[string, float64]` (zoom levels)
207-- `Cache[string, Shortcut]` (search shortcuts)
208-- `Cache[string, CertValidation]` (TLS certificates)
209-
210-### Why Async Persistence?
211-
212-- **Startup Performance**: <500ms startup time requirement
213-- **UI Responsiveness**: Never block user actions on DB writes
214-- **Write Coalescing**: Multiple rapid updates to same key only persist latest value
215-
216-### Why Bulk Load?
217-
218-- Small datasets (10-100 entries typical)
219-- Faster than incremental loading with query overhead
220-- Simpler code: no cache miss logic needed
221-
222-### Why sync.Map Instead of map + RWMutex?
223-
224-- Lock-free reads in common case
225-- Better performance under concurrent load
226-- Standard library, battle-tested
227-
228-## Limitations
229-
230-- **Not for large datasets**: Loads entire dataset into RAM
231-- **No TTL/expiration**: All entries cached indefinitely
232-- **No size limits**: Assumes datasets fit comfortably in memory
233-- **Last write wins**: No conflict resolution for concurrent updates
234-
235-For Dumber Browser's use case (small configuration tables), these tradeoffs are acceptable.
236-
237-## Future Enhancements
238-
239-Potential improvements if needed:
240-- [ ] Add metrics (cache hits, miss rate, persist latency)
241-- [ ] Add TTL support for time-sensitive data
242-- [ ] Add size-based eviction for large caches
243-- [ ] Add batch persistence for write-heavy workloads
244-- [ ] Add versioning/CAS for concurrent update detection
D internal/cache/generic/cache.go +0 -159
@@ -1,159 +0,0 @@
1-package generic
2-
3-import (
4- "context"
5- "fmt"
6- "sync"
7-
8- "github.com/bnema/dumber/internal/logging"
9-)
10-
11-// Cache provides a generic interface for caching any type of data.
12-// K is the key type (must be comparable), V is the value type.
13-// All operations are thread-safe via sync.Map.
14-//
15-// Design principles:
16-// - RAM-first: All reads from memory (no DB queries)
17-// - Async writes: Updates happen immediately in cache, persisted asynchronously
18-// - Bulk load: Load all data from DB at startup
19-// - Flush on shutdown: Ensure all pending writes complete gracefully
20-type Cache[K comparable, V any] interface {
21- // Load bulk-loads all data from storage into memory at startup
22- Load(ctx context.Context) error
23-
24- // Get retrieves a value from the cache (RAM only, never queries DB)
25- Get(key K) (V, bool)
26-
27- // Set updates the cache immediately and persists to DB asynchronously
28- Set(key K, value V) error
29-
30- // Delete removes from cache immediately and persists deletion asynchronously
31- Delete(key K) error
32-
33- // List returns all cached values as a slice
34- List() []V
35-
36- // Flush waits for all pending async writes to complete (call on shutdown)
37- Flush() error
38-}
39-
40-// DatabaseOperations defines the interface for database operations required by the cache.
41-// This interface allows for easy mocking in tests using gomock.
42-type DatabaseOperations[K comparable, V any] interface {
43- // LoadAll loads all entries from the database
44- LoadAll(ctx context.Context) (map[K]V, error)
45-
46- // Persist saves a single entry to the database
47- Persist(ctx context.Context, key K, value V) error
48-
49- // Delete removes a single entry from the database
50- Delete(ctx context.Context, key K) error
51-}
52-
53-// GenericCache implements Cache[K, V] using sync.Map for thread-safe storage
54-// and a DatabaseOperations interface for database operations.
55-type GenericCache[K comparable, V any] struct {
56- cache sync.Map
57- dbOps DatabaseOperations[K, V]
58-
59- // Track pending async operations for graceful shutdown
60- pendingWrites sync.WaitGroup
61-}
62-
63-// NewGenericCache creates a new cache with the provided database operations.
64-//
65-// Parameters:
66-// - dbOps: Implementation of DatabaseOperations for loading, persisting, and deleting data
67-func NewGenericCache[K comparable, V any](
68- dbOps DatabaseOperations[K, V],
69-) *GenericCache[K, V] {
70- return &GenericCache[K, V]{
71- dbOps: dbOps,
72- }
73-}
74-
75-// Load bulk-loads all data from the database into memory.
76-// Should be called once at application startup.
77-func (c *GenericCache[K, V]) Load(ctx context.Context) error {
78- data, err := c.dbOps.LoadAll(ctx)
79- if err != nil {
80- return err
81- }
82-
83- for k, v := range data {
84- c.cache.Store(k, v)
85- }
86-
87- return nil
88-}
89-
90-// Get retrieves a value from the cache. Returns (value, true) if found,
91-// or (zero value, false) if not found. Never queries the database.
92-func (c *GenericCache[K, V]) Get(key K) (V, bool) {
93- val, ok := c.cache.Load(key)
94- if !ok {
95- var zero V
96- return zero, false
97- }
98- return val.(V), true
99-}
100-
101-// Set updates the cache immediately (synchronous) and persists to the database
102-// asynchronously (non-blocking). Returns immediately without waiting for DB write.
103-func (c *GenericCache[K, V]) Set(key K, value V) error {
104- // Update cache immediately (sync, fast)
105- c.cache.Store(key, value)
106-
107- // Persist to DB asynchronously (non-blocking)
108- c.pendingWrites.Add(1)
109- go func() {
110- defer c.pendingWrites.Done()
111-
112- ctx := context.Background()
113- if err := c.dbOps.Persist(ctx, key, value); err != nil {
114- logging.Warn(fmt.Sprintf("Warning: async persist failed for key %v: %v", key, err))
115- }
116- }()
117-
118- return nil
119-}
120-
121-// Delete removes from cache immediately (synchronous) and persists deletion
122-// to the database asynchronously (non-blocking).
123-func (c *GenericCache[K, V]) Delete(key K) error {
124- // Remove from cache immediately (sync, fast)
125- c.cache.Delete(key)
126-
127- // Persist deletion to DB asynchronously (non-blocking)
128- c.pendingWrites.Add(1)
129- go func() {
130- defer c.pendingWrites.Done()
131-
132- ctx := context.Background()
133- if err := c.dbOps.Delete(ctx, key); err != nil {
134- logging.Warn(fmt.Sprintf("Warning: async delete failed for key %v: %v", key, err))
135- }
136- }()
137-
138- return nil
139-}
140-
141-// List returns all values currently in the cache as a slice.
142-// The order is not guaranteed.
143-func (c *GenericCache[K, V]) List() []V {
144- var values []V
145-
146- c.cache.Range(func(key, value interface{}) bool {
147- values = append(values, value.(V))
148- return true
149- })
150-
151- return values
152-}
153-
154-// Flush blocks until all pending async writes complete.
155-// Should be called during graceful shutdown to ensure no data loss.
156-func (c *GenericCache[K, V]) Flush() error {
157- c.pendingWrites.Wait()
158- return nil
159-}
D internal/cache/generic/cache_test.go +0 -461 collapsed to keep the page light
D internal/cache/generic/mock_db_ops.go +0 -137
@@ -1,137 +0,0 @@
1-package generic
2-
3-import (
4- "context"
5- "sync"
6-)
7-
8-// MockDatabaseOperations is a mock implementation of DatabaseOperations for testing.
9-// It's generic and thread-safe, suitable for testing GenericCache.
10-type MockDatabaseOperations[K comparable, V any] struct {
11- mu sync.Mutex
12-
13- // Behavior configuration
14- LoadAllFunc func(ctx context.Context) (map[K]V, error)
15- PersistFunc func(ctx context.Context, key K, value V) error
16- DeleteFunc func(ctx context.Context, key K) error
17-
18- // Call tracking
19- LoadAllCalls []context.Context
20- PersistCalls []struct {
21- Ctx context.Context
22- Key K
23- Value V
24- }
25- DeleteCalls []struct {
26- Ctx context.Context
27- Key K
28- }
29-}
30-
31-// NewMockDatabaseOperations creates a new mock with default no-op implementations.
32-func NewMockDatabaseOperations[K comparable, V any]() *MockDatabaseOperations[K, V] {
33- return &MockDatabaseOperations[K, V]{
34- LoadAllFunc: func(ctx context.Context) (map[K]V, error) {
35- return make(map[K]V), nil
36- },
37- PersistFunc: func(ctx context.Context, key K, value V) error {
38- return nil
39- },
40- DeleteFunc: func(ctx context.Context, key K) error {
41- return nil
42- },
43- }
44-}
45-
46-// LoadAll implements DatabaseOperations.LoadAll
47-func (m *MockDatabaseOperations[K, V]) LoadAll(ctx context.Context) (map[K]V, error) {
48- m.mu.Lock()
49- m.LoadAllCalls = append(m.LoadAllCalls, ctx)
50- m.mu.Unlock()
51-
52- return m.LoadAllFunc(ctx)
53-}
54-
55-// Persist implements DatabaseOperations.Persist
56-func (m *MockDatabaseOperations[K, V]) Persist(ctx context.Context, key K, value V) error {
57- m.mu.Lock()
58- m.PersistCalls = append(m.PersistCalls, struct {
59- Ctx context.Context
60- Key K
61- Value V
62- }{ctx, key, value})
63- m.mu.Unlock()
64-
65- return m.PersistFunc(ctx, key, value)
66-}
67-
68-// Delete implements DatabaseOperations.Delete
69-func (m *MockDatabaseOperations[K, V]) Delete(ctx context.Context, key K) error {
70- m.mu.Lock()
71- m.DeleteCalls = append(m.DeleteCalls, struct {
72- Ctx context.Context
73- Key K
74- }{ctx, key})
75- m.mu.Unlock()
76-
77- return m.DeleteFunc(ctx, key)
78-}
79-
80-// GetLoadAllCallCount returns the number of times LoadAll was called
81-func (m *MockDatabaseOperations[K, V]) GetLoadAllCallCount() int {
82- m.mu.Lock()
83- defer m.mu.Unlock()
84- return len(m.LoadAllCalls)
85-}
86-
87-// GetPersistCallCount returns the number of times Persist was called
88-func (m *MockDatabaseOperations[K, V]) GetPersistCallCount() int {
89- m.mu.Lock()
90- defer m.mu.Unlock()
91- return len(m.PersistCalls)
92-}
93-
94-// GetDeleteCallCount returns the number of times Delete was called
95-func (m *MockDatabaseOperations[K, V]) GetDeleteCallCount() int {
96- m.mu.Lock()
97- defer m.mu.Unlock()
98- return len(m.DeleteCalls)
99-}
100-
101-// GetLastPersistCall returns the last call to Persist, or nil if none
102-func (m *MockDatabaseOperations[K, V]) GetLastPersistCall() *struct {
103- Ctx context.Context
104- Key K
105- Value V
106-} {
107- m.mu.Lock()
108- defer m.mu.Unlock()
109- if len(m.PersistCalls) == 0 {
110- return nil
111- }
112- last := m.PersistCalls[len(m.PersistCalls)-1]
113- return &last
114-}
115-
116-// GetLastDeleteCall returns the last call to Delete, or nil if none
117-func (m *MockDatabaseOperations[K, V]) GetLastDeleteCall() *struct {
118- Ctx context.Context
119- Key K
120-} {
121- m.mu.Lock()
122- defer m.mu.Unlock()
123- if len(m.DeleteCalls) == 0 {
124- return nil
125- }
126- last := m.DeleteCalls[len(m.DeleteCalls)-1]
127- return &last
128-}
129-
130-// Reset clears all call tracking
131-func (m *MockDatabaseOperations[K, V]) Reset() {
132- m.mu.Lock()
133- defer m.mu.Unlock()
134- m.LoadAllCalls = nil
135- m.PersistCalls = nil
136- m.DeleteCalls = nil
137-}
D internal/cache/interfaces.go +0 -14
@@ -1,14 +0,0 @@
1-package cache
2-
3-import (
4- "context"
5-
6- "github.com/bnema/dumber/internal/db"
7-)
8-
9-//go:generate mockgen -source=interfaces.go -destination=mocks/mock_querier.go
10-
11-// HistoryQuerier defines the interface for querying history data.
12-type HistoryQuerier interface {
13- GetHistory(ctx context.Context, limit int64) ([]db.History, error)
14-}
D internal/cache/mocks/mock_querier.go +0 -57
@@ -1,57 +0,0 @@
1-// Code generated by MockGen. DO NOT EDIT.
2-// Source: interfaces.go
3-//
4-// Generated by this command:
5-//
6-// mockgen -source=interfaces.go -destination=mocks/mock_querier.go
7-//
8-
9-// Package mock_cache is a generated GoMock package.
10-package mock_cache
11-
12-import (
13- context "context"
14- reflect "reflect"
15-
16- db "github.com/bnema/dumber/internal/db"
17- gomock "go.uber.org/mock/gomock"
18-)
19-
20-// MockHistoryQuerier is a mock of HistoryQuerier interface.
21-type MockHistoryQuerier struct {
22- ctrl *gomock.Controller
23- recorder *MockHistoryQuerierMockRecorder
24- isgomock struct{}
25-}
26-
27-// MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier.
28-type MockHistoryQuerierMockRecorder struct {
29- mock *MockHistoryQuerier
30-}
31-
32-// NewMockHistoryQuerier creates a new mock instance.
33-func NewMockHistoryQuerier(ctrl *gomock.Controller) *MockHistoryQuerier {
34- mock := &MockHistoryQuerier{ctrl: ctrl}
35- mock.recorder = &MockHistoryQuerierMockRecorder{mock}
36- return mock
37-}
38-
39-// EXPECT returns an object that allows the caller to indicate expected use.
40-func (m *MockHistoryQuerier) EXPECT() *MockHistoryQuerierMockRecorder {
41- return m.recorder
42-}
43-
44-// GetHistory mocks base method.
45-func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) {
46- m.ctrl.T.Helper()
47- ret := m.ctrl.Call(m, "GetHistory", ctx, limit)
48- ret0, _ := ret[0].([]db.History)
49- ret1, _ := ret[1].(error)
50- return ret0, ret1
51-}
52-
53-// GetHistory indicates an expected call of GetHistory.
54-func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit any) *gomock.Call {
55- mr.mock.ctrl.T.Helper()
56- return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit)
57-}
D internal/cache/trie.go +0 -371 collapsed to keep the page light
D internal/cache/types.go +0 -206 collapsed to keep the page light
D internal/cache/zoom_cache.go +0 -62
@@ -1,62 +0,0 @@
1-package cache
2-
3-import (
4- "context"
5-
6- "github.com/bnema/dumber/internal/cache/generic"
7- "github.com/bnema/dumber/internal/db"
8-)
9-
10-// ZoomDBOperations implements DatabaseOperations for zoom level cache.
11-// Handles loading, persisting, and deleting zoom levels from the database.
12-type ZoomDBOperations struct {
13- queries db.DatabaseQuerier
14-}
15-
16-// NewZoomDBOperations creates a new ZoomDBOperations instance.
17-func NewZoomDBOperations(queries db.DatabaseQuerier) *ZoomDBOperations {
18- return &ZoomDBOperations{
19- queries: queries,
20- }
21-}
22-
23-// LoadAll loads all zoom levels from the database.
24-// Returns a map of domain -> zoom factor.
25-func (z *ZoomDBOperations) LoadAll(ctx context.Context) (map[string]float64, error) {
26- levels, err := z.queries.ListZoomLevels(ctx)
27- if err != nil {
28- return nil, err
29- }
30-
31- result := make(map[string]float64, len(levels))
32- for _, level := range levels {
33- result[level.Domain] = level.ZoomFactor
34- }
35-
36- return result, nil
37-}
38-
39-// Persist saves a zoom level to the database.
40-// Uses UPSERT (INSERT OR UPDATE) to handle both new and existing entries.
41-func (z *ZoomDBOperations) Persist(ctx context.Context, domain string, zoomFactor float64) error {
42- return z.queries.SetZoomLevel(ctx, domain, zoomFactor)
43-}
44-
45-// Delete removes a zoom level from the database.
46-func (z *ZoomDBOperations) Delete(ctx context.Context, domain string) error {
47- return z.queries.DeleteZoomLevel(ctx, domain)
48-}
49-
50-// ZoomCache is a specialized cache for zoom levels.
51-// It wraps GenericCache with domain-specific helper methods.
52-type ZoomCache struct {
53- *generic.GenericCache[string, float64]
54-}
55-
56-// NewZoomCache creates a new zoom level cache.
57-func NewZoomCache(queries db.DatabaseQuerier) *ZoomCache {
58- dbOps := NewZoomDBOperations(queries)
59- return &ZoomCache{
60- GenericCache: generic.NewGenericCache(dbOps),
61- }
62-}
D internal/cli/browse.go +0 -170 collapsed to keep the page light
D internal/cli/config.go +0 -60 collapsed to keep the page light
D internal/cli/dmenu.go +0 -347 collapsed to keep the page light
D internal/cli/history.go +0 -421 collapsed to keep the page light
D internal/cli/logs.go +0 -394 collapsed to keep the page light
D internal/cli/purge.go +0 -359 collapsed to keep the page light
D internal/cli/root.go +0 -160 collapsed to keep the page light
D internal/config/config.go +0 -918 collapsed to keep the page light
D internal/config/constants.go +0 -6
@@ -1,6 +0,0 @@
1-package config
2-
3-// Color scheme constants
4-const (
5- ThemeDefault = "default"
6-)
D internal/config/defaults.go +0 -280 collapsed to keep the page light
D internal/config/schema.go +0 -44 collapsed to keep the page light
D internal/config/validation.go +0 -184 collapsed to keep the page light
D internal/config/xdg.go +0 -171 collapsed to keep the page light
D internal/db/certificate_validations.sql.go +0 -125 collapsed to keep the page light
D internal/db/content_whitelist.sql.go +0 -71 collapsed to keep the page light
D internal/db/db.go +0 -31 collapsed to keep the page light
D internal/db/db_ext.go +0 -12 collapsed to keep the page light
D internal/db/favorite_folders.sql.go +0 -226 collapsed to keep the page light
D internal/db/favorite_tags.sql.go +0 -228 collapsed to keep the page light
D internal/db/favorites.sql.go +0 -162 collapsed to keep the page light
D internal/db/history.sql.go +0 -231 collapsed to keep the page light
D internal/db/history_extended.sql.go +0 -369 collapsed to keep the page light
D internal/db/history_fts.go +0 -89 collapsed to keep the page light
D internal/db/init.go +0 -72 collapsed to keep the page light
D internal/db/interfaces.go +0 -128 collapsed to keep the page light
D internal/db/mocks/mock_db.go +0 -2233 large diff (2233 lines changed): collapsed
D internal/db/models.go +0 -92 collapsed to keep the page light
D internal/db/querier.go +0 -120 collapsed to keep the page light
D internal/db/queries/certificate_validations.sql +0 -29 collapsed to keep the page light
D internal/db/queries/content_whitelist.sql +0 -16 collapsed to keep the page light
D internal/db/queries/favorite_folders.sql +0 -62 collapsed to keep the page light
D internal/db/queries/favorite_tags.sql +0 -40 collapsed to keep the page light
D internal/db/queries/favorites.sql +0 -58 collapsed to keep the page light
D internal/db/queries/history.sql +0 -51 collapsed to keep the page light
D internal/db/queries/history_extended.sql +0 -59 collapsed to keep the page light
D internal/db/queries/zoom.sql +0 -30 collapsed to keep the page light
D internal/db/zoom.sql.go +0 -100 collapsed to keep the page light
D internal/filtering/bypass.go +0 -57
@@ -1,57 +0,0 @@
1-// Package filtering provides filter management and compilation for content blocking.
2-package filtering
3-
4-import (
5- "sync"
6-)
7-
8-// BypassRegistry tracks one-time URL bypasses that are allowed through content blocking.
9-// This is an in-memory only registry that is cleared on browser restart.
10-type BypassRegistry struct {
11- mu sync.RWMutex
12- allowed map[string]bool // URL -> allowed once
13-}
14-
15-// NewBypassRegistry creates a new bypass registry.
16-func NewBypassRegistry() *BypassRegistry {
17- return &BypassRegistry{
18- allowed: make(map[string]bool),
19- }
20-}
21-
22-// AllowOnce marks a URL as allowed for one-time bypass.
23-// The next time IsAllowed is checked for this URL, it will return true
24-// and remove the entry.
25-func (r *BypassRegistry) AllowOnce(url string) {
26- r.mu.Lock()
27- defer r.mu.Unlock()
28- r.allowed[url] = true
29-}
30-
31-// IsAllowed checks if a URL is allowed for bypass.
32-// If the URL is in the registry, it returns true and removes the entry.
33-// This ensures the bypass is only valid for one navigation attempt.
34-func (r *BypassRegistry) IsAllowed(url string) bool {
35- r.mu.Lock()
36- defer r.mu.Unlock()
37-
38- if r.allowed[url] {
39- delete(r.allowed, url)
40- return true
41- }
42- return false
43-}
44-
45-// Clear removes all entries from the registry.
46-func (r *BypassRegistry) Clear() {
47- r.mu.Lock()
48- defer r.mu.Unlock()
49- r.allowed = make(map[string]bool)
50-}
51-
52-// Count returns the number of pending bypasses.
53-func (r *BypassRegistry) Count() int {
54- r.mu.RLock()
55- defer r.mu.RUnlock()
56- return len(r.allowed)
57-}
D internal/filtering/compiler.go +0 -97 collapsed to keep the page light
D internal/filtering/converter/easylist_to_webkit.go +0 -278 collapsed to keep the page light
D internal/filtering/converter/webkit_types.go +0 -52 collapsed to keep the page light
D internal/filtering/cosmetic/injector.go +0 -272 collapsed to keep the page light
D internal/filtering/cosmetic/injector.js +0 -276 collapsed to keep the page light
D internal/filtering/errors.go +0 -17 collapsed to keep the page light
D internal/filtering/integration.go +0 -33 collapsed to keep the page light
D internal/filtering/manager.go +0 -679 collapsed to keep the page light
D internal/filtering/service.go +0 -278 collapsed to keep the page light
D internal/filtering/store.go +0 -262 collapsed to keep the page light
D internal/filtering/store_test.go +0 -100 collapsed to keep the page light
D internal/filtering/updater.go +0 -535 collapsed to keep the page light
D internal/filtering/updater_test.go +0 -194 collapsed to keep the page light
D internal/logging/capture.go +0 -157 collapsed to keep the page light
A internal/logging/context.go +59 -0 collapsed to keep the page light
D internal/logging/crash.go +0 -108 collapsed to keep the page light
M internal/logging/logger.go +56 -216 collapsed to keep the page light
D internal/logging/rotator.go +0 -219 collapsed to keep the page light
D internal/migrations/001_initial.sql +0 -29 collapsed to keep the page light
D internal/migrations/002_zoom_persistence.sql +0 -11 collapsed to keep the page light
D internal/migrations/003_add_zoom_levels.sql +0 -15 collapsed to keep the page light
D internal/migrations/004_add_favicon_url.sql +0 -5
@@ -1,5 +0,0 @@
1--- Add favicon_url column to history table
2--- Store favicon URLs for better UI display in dmenu
3-
4-ALTER TABLE history ADD COLUMN favicon_url TEXT DEFAULT NULL;
5-CREATE INDEX IF NOT EXISTS idx_history_favicon ON history(favicon_url);⏎̸
D internal/migrations/005_add_certificate_validations.sql +0 -16 collapsed to keep the page light
D internal/migrations/006_add_fuzzy_cache_tables.sql +0 -22 collapsed to keep the page light
D internal/migrations/007_remove_shortcuts_table.sql +0 -6 collapsed to keep the page light
D internal/migrations/008_add_favorites.sql +0 -18 collapsed to keep the page light
D internal/migrations/009_add_visit_count_index.sql +0 -5 collapsed to keep the page light
D internal/migrations/010_homepage_redesign.sql +0 -74 collapsed to keep the page light
D internal/migrations/011_content_whitelist.sql +0 -11 collapsed to keep the page light
D internal/migrations/migrations.go +0 -243 collapsed to keep the page light
D internal/migrations/migrations_test.go +0 -438 collapsed to keep the page light
D internal/parser/fuzzy.go +0 -471 collapsed to keep the page light
D internal/parser/fuzzy_test.go +0 -513 collapsed to keep the page light
D internal/parser/history.go +0 -430 collapsed to keep the page light
D internal/parser/parser.go +0 -459 collapsed to keep the page light
D internal/parser/parser_test.go +0 -626 collapsed to keep the page light
D internal/parser/types.go +0 -171 collapsed to keep the page light
D internal/parser/url_helpers.go +0 -31 collapsed to keep the page light
D internal/parser/url_helpers_test.go +0 -32 collapsed to keep the page light
D internal/parser/validation.go +0 -417 collapsed to keep the page light
D internal/parser/validation_test.go +0 -526 collapsed to keep the page light
D internal/services/browser.go +0 -1306 collapsed to keep the page light
D internal/services/browser_fuzzy_test.go +0 -312 collapsed to keep the page light
D internal/services/favicon_service.go +0 -568 collapsed to keep the page light
D internal/services/parser.go +0 -198 collapsed to keep the page light
D internal/services/service.go +0 -33 collapsed to keep the page light
D main.go +0 -90 collapsed to keep the page light
D pkg/clipboard/clipboard.go +0 -122 collapsed to keep the page light
D pkg/gpu/detect.go +0 -491 collapsed to keep the page light
D pkg/webkit/content_blocking.go +0 -274 collapsed to keep the page light
D pkg/webkit/errors.go +0 -40 collapsed to keep the page light
D pkg/webkit/evaluate.go +0 -78 collapsed to keep the page light
D pkg/webkit/favicon_helpers.go +0 -163 collapsed to keep the page light
D pkg/webkit/helpers.go +0 -325 collapsed to keep the page light
D pkg/webkit/keyboard_bridge.go +0 -299 collapsed to keep the page light
D pkg/webkit/logging.go +0 -29 collapsed to keep the page light
D pkg/webkit/mainloop.go +0 -78 collapsed to keep the page light
D pkg/webkit/memory_fixes.go +0 -22 collapsed to keep the page light
D pkg/webkit/mouse_gestures.go +0 -67 collapsed to keep the page light
D pkg/webkit/network_session.go +0 -117 collapsed to keep the page light
D pkg/webkit/shortcuts.go +0 -168 collapsed to keep the page light
D pkg/webkit/tls_dialog_cgo.go +0 -34 collapsed to keep the page light
D pkg/webkit/tls_handler.go +0 -333 collapsed to keep the page light
D pkg/webkit/touchpad_gestures.go +0 -78 collapsed to keep the page light
D pkg/webkit/types.go +0 -84 collapsed to keep the page light
D pkg/webkit/uri_scheme.go +0 -71 collapsed to keep the page light
D pkg/webkit/user_content.go +0 -210 collapsed to keep the page light
D pkg/webkit/webview.go +0 -1160 collapsed to keep the page light
D pkg/webkit/webview_related.go +0 -120 collapsed to keep the page light
D pkg/webkit/widgets.go +0 -290 collapsed to keep the page light
D pkg/webkit/window.go +0 -106 collapsed to keep the page light
D tests/integration/browser_controls_integration_test.go +0 -400 collapsed to keep the page light

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