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) |
| 2 | 2 | ||||
| 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 |
| 4 | 4 | ||||
| 5 | # Load local overrides from .env.local if present (Makefile syntax) | 5 | # Load local overrides from .env.local if present (Makefile syntax) | ||
| 6 | ifneq (,$(wildcard .env.local)) | 6 | ifneq (,$(wildcard .env.local)) | ||
| @@ -10,19 +10,12 @@ endif | |||||
| 10 | 10 | ||||
| 11 | # Variables | 11 | # Variables | ||
| 12 | BINARY_NAME=dumber | 12 | BINARY_NAME=dumber | ||
| 13 | - | MAIN_PATH=. | 13 | + | MAIN_PATH=./cmd/dumber |
| 14 | DIST_DIR=dist | 14 | DIST_DIR=dist | ||
| 15 | 15 | ||||
| 16 | # Detect number of CPU cores for parallel compilation | 16 | # Detect number of CPU cores for parallel compilation | ||
| 17 | NPROCS?=$(shell nproc 2>/dev/null || echo 1) | 17 | NPROCS?=$(shell nproc 2>/dev/null || echo 1) | ||
| 18 | 18 | ||||
| 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 | - | ||||
| 26 | # Version information from git | 19 | # Version information from git | ||
| 27 | VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0-dev") | 20 | VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0-dev") | ||
| 28 | COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") | 21 | COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") | ||
| @@ -37,49 +30,32 @@ help: ## Show this help message | |||||
| 37 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | 30 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | ||
| 38 | 31 | ||||
| 39 | # Build targets | 32 | # Build targets | ||
| 40 | - | build: build-frontend ## Build the application with GUI (frontend assets, then Go binary with WebKitGTK) | 33 | + | build: build-frontend ## Build the application (pure Go, no CGO) |
| 41 | - | @echo "Building $(BINARY_NAME) $(VERSION) with GUI using $(NPROCS) cores..." | 34 | + | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." |
| 42 | - | @mkdir -p $(DIST_DIR) tmp tmp/go-cache tmp/go-mod | 35 | + | @mkdir -p $(DIST_DIR) |
| 43 | - | $(GOENV) CGO_ENABLED=1 go build -p $(NPROCS) $(LDFLAGS) -tags=webkit_cgo -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | 36 | + | go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) |
| 44 | - | @echo "✅ Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | 37 | + | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" |
| 45 | 38 | ||||
| 46 | build-frontend: ## Build Svelte GUI with Tailwind CSS and main-world script | 39 | 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..." |
| 48 | @cd gui && npm install --silent && npm run build | 41 | @cd gui && npm install --silent && npm run build | ||
| 49 | @echo "GUI build complete" | 42 | @echo "GUI build complete" | ||
| 50 | 43 | ||||
| 51 | - | build-no-gui: build-frontend ## Build binary without GUI (CGO disabled, CLI-only functionality) | 44 | + | build-quick: ## Build without frontend (faster for backend development) |
| 52 | - | @echo "Building $(BINARY_NAME) $(VERSION) (CLI-only, no GUI) using $(NPROCS) cores..." | 45 | + | @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..." |
| 53 | - | @mkdir -p $(DIST_DIR) tmp tmp/go-cache tmp/go-mod | 46 | + | @mkdir -p $(DIST_DIR) |
| 54 | - | $(GOENV) CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-no-gui $(MAIN_PATH) | 47 | + | go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) |
| 55 | - | @echo "✅ Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)-no-gui" | 48 | + | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" |
| 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)" | |||
| 77 | 49 | ||||
| 78 | # Development targets | 50 | # Development targets | ||
| 79 | dev: ## Run in development mode | 51 | dev: ## Run in development mode | ||
| 80 | @echo "Running in development mode..." | 52 | @echo "Running in development mode..." | ||
| 81 | go run $(MAIN_PATH) | 53 | go run $(MAIN_PATH) | ||
| 82 | 54 | ||||
| 55 | + | run: build-quick ## Build and run | |||
| 56 | + | @echo "Running $(BINARY_NAME)..." | |||
| 57 | + | $(DIST_DIR)/$(BINARY_NAME) | |||
| 58 | + | ||||
| 83 | generate: ## Generate code (SQLC) | 59 | generate: ## Generate code (SQLC) | ||
| 84 | @echo "Generating code with SQLC..." | 60 | @echo "Generating code with SQLC..." | ||
| 85 | sqlc generate | 61 | sqlc generate | ||
| @@ -88,16 +64,20 @@ generate: ## Generate code (SQLC) | |||||
| 88 | # Testing | 64 | # Testing | ||
| 89 | test: ## Run tests | 65 | test: ## Run tests | ||
| 90 | @echo "Running tests..." | 66 | @echo "Running tests..." | ||
| 91 | - | @mkdir -p tmp tmp/go-cache tmp/go-mod | 67 | + | go test -v ./... |
| 92 | - | $(GOENV) CGO_ENABLED=0 go test -v ./... | |||
| 93 | 68 | ||||
| 94 | test-race: ## Run tests with race detection | 69 | test-race: ## Run tests with race detection | ||
| 95 | @echo "Running tests with race detection..." | 70 | @echo "Running tests with race detection..." | ||
| 96 | - | @mkdir -p tmp tmp/go-cache tmp/go-mod | 71 | + | go test -race -v ./... |
| 97 | - | $(GOENV) CGO_ENABLED=1 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" | |||
| 98 | 78 | ||||
| 99 | # Linting | 79 | # Linting | ||
| 100 | - | lint: ## Run golangci-lint (excludes pkg/webkit CGO files via config) | 80 | + | lint: ## Run golangci-lint |
| 101 | @echo "Running golangci-lint..." | 81 | @echo "Running golangci-lint..." | ||
| 102 | golangci-lint run | 82 | golangci-lint run | ||
| 103 | 83 | ||||
| @@ -107,7 +87,7 @@ lint-fix: ## Run golangci-lint with --fix | |||||
| 107 | 87 | ||||
| 108 | # Format code | 88 | # Format code | ||
| 109 | fmt: ## Format Go code with gofmt | 89 | fmt: ## Format Go code with gofmt | ||
| 110 | - | @echo "Formatting Go code with gofmt..." | 90 | + | @echo "Formatting Go code..." |
| 111 | go fmt ./... | 91 | go fmt ./... | ||
| 112 | 92 | ||||
| 113 | # Tools installation | 93 | # Tools installation | ||
| @@ -121,41 +101,41 @@ install-tools: ## Install development tools | |||||
| 121 | clean: ## Clean build artifacts | 101 | clean: ## Clean build artifacts | ||
| 122 | @echo "Cleaning build artifacts..." | 102 | @echo "Cleaning build artifacts..." | ||
| 123 | rm -rf $(DIST_DIR) | 103 | rm -rf $(DIST_DIR) | ||
| 124 | - | rm -f $(BINARY_NAME) # Remove any old binaries in root | 104 | + | rm -f $(BINARY_NAME) |
| 125 | rm -rf gui/dist gui/node_modules | 105 | rm -rf gui/dist gui/node_modules | ||
| 126 | 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 | 106 | 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 | |||
| 127 | go clean -cache | 108 | go clean -cache | ||
| 128 | go clean -testcache | 109 | go clean -testcache | ||
| 129 | 110 | ||||
| 130 | # Project initialization | 111 | # Project initialization | ||
| 131 | init: install-tools ## Initialize project dependencies and tools | 112 | init: install-tools ## Initialize project dependencies and tools | ||
| 132 | - | @echo "Project dependencies and tools installed!" | 113 | + | @echo "Initializing project..." |
| 133 | - | @echo "Ready for development. Run 'make help' for available commands." | 114 | + | go mod tidy |
| 115 | + | @echo "Project initialized! Run 'make help' for available commands." | |||
| 134 | 116 | ||||
| 135 | # Check setup | 117 | # Check setup | ||
| 136 | check: ## Check that all tools and dependencies are working | 118 | check: ## Check that all tools and dependencies are working | ||
| 137 | @echo "Checking project setup..." | 119 | @echo "Checking project setup..." | ||
| 138 | @echo "Go version:" | 120 | @echo "Go version:" | ||
| 139 | - | @$(GOENV) go version | 121 | + | @go version |
| 140 | @echo "\nSQLC version:" | 122 | @echo "\nSQLC version:" | ||
| 141 | - | @$(GOENV) sqlc version | 123 | + | @sqlc version |
| 142 | @echo "\nGolangci-lint version:" | 124 | @echo "\nGolangci-lint version:" | ||
| 143 | - | @$(GOENV) golangci-lint version | 125 | + | @golangci-lint version |
| 144 | @echo "\nBuilding project..." | 126 | @echo "\nBuilding project..." | ||
| 145 | - | @$(MAKE) build | 127 | + | @$(MAKE) build-quick |
| 146 | - | @echo "\nTesting built binary..." | |||
| 147 | - | @$(DIST_DIR)/$(BINARY_NAME) version | |||
| 148 | @echo "\nRunning tests..." | 128 | @echo "\nRunning tests..." | ||
| 149 | @$(MAKE) test | 129 | @$(MAKE) test | ||
| 150 | - | @echo "\n✅ All checks passed! Project is ready for development." | 130 | + | @echo "\nAll checks passed!" |
| 151 | 131 | ||||
| 152 | # Native release targets | 132 | # Native release targets | ||
| 153 | .PHONY: release-snapshot release | 133 | .PHONY: release-snapshot release | ||
| 154 | 134 | ||||
| 155 | - | release-snapshot: build-frontend ## Build snapshot using native goreleaser (no git tags required) | 135 | + | release-snapshot: build-frontend ## Build snapshot using goreleaser |
| 156 | @echo "Building snapshot with goreleaser..." | 136 | @echo "Building snapshot with goreleaser..." | ||
| 157 | goreleaser release --snapshot --clean | 137 | goreleaser release --snapshot --clean | ||
| 158 | 138 | ||||
| 159 | - | release: ## Create full release (amd64 only) using native goreleaser | 139 | + | release: ## Create full release using goreleaser |
| 160 | @echo "Building release with goreleaser..." | 140 | @echo "Building release with goreleaser..." | ||
| 161 | GITHUB_TOKEN=$$(gh auth token) goreleaser release --clean | 141 | GITHUB_TOKEN=$$(gh auth token) goreleaser release --clean | ||
M go.mod +6 -43
| @@ -2,52 +2,15 @@ module github.com/bnema/dumber | |||||
| 2 | 2 | ||||
| 3 | go 1.25.3 | 3 | go 1.25.3 | ||
| 4 | 4 | ||||
| 5 | - | require ( | 5 | + | require github.com/rs/zerolog v1.33.0 |
| 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 | - | ) | |||
| 19 | 6 | ||||
| 20 | require ( | 7 | require ( | ||
| 21 | - | github.com/KarpelesLab/weak v0.1.1 // indirect | 8 | + | github.com/mattn/go-colorable v0.1.13 // indirect |
| 22 | - | github.com/bahlo/generic-list-go v0.2.0 // indirect | 9 | + | github.com/mattn/go-isatty v0.0.19 // indirect |
| 23 | - | github.com/buger/jsonparser v1.1.1 // indirect | 10 | + | golang.org/x/sys v0.36.0 // 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 | |||
| 46 | ) | 11 | ) | ||
| 47 | 12 | ||||
| 48 | replace ( | 13 | replace ( | ||
| 49 | - | github.com/diamondburned/gotk4 => github.com/bnema/gotk4 v0.3.4-0.20251017144723-f765b03fd751 | 14 | + | github.com/bnema/puregotk-webkit => /home/brice/projects/puregotk-webkit |
| 50 | - | github.com/diamondburned/gotk4-webkitgtk => github.com/bnema/gotk4-webkitgtk v0.1.2-0.20251017152300-b3012dbe285f | 15 | + | github.com/jwijenbergh/puregotk => /home/brice/projects/puregotk |
| 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 | |||
| 53 | ) | 16 | ) | ||
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 collapsed to keep the page light
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);⏎̸ | |||