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 | 5 | # Load local overrides from .env.local if present (Makefile syntax) | |
| 6 | 6 | ifneq (,$(wildcard .env.local)) | |
| @@ -10,19 +10,12 @@ endif | |||
| 10 | 10 | ||
| 11 | 11 | # Variables | |
| 12 | 12 | BINARY_NAME=dumber | |
| 13 | - | MAIN_PATH=. | |
| 13 | + | MAIN_PATH=./cmd/dumber | |
| 14 | 14 | DIST_DIR=dist | |
| 15 | 15 | ||
| 16 | 16 | # Detect number of CPU cores for parallel compilation | |
| 17 | 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 | 19 | # Version information from git | |
| 27 | 20 | VERSION=$(shell git describe --tags --always --dirty 2>/dev/null || echo "v0.0.0-dev") | |
| 28 | 21 | COMMIT=$(shell git rev-parse --short HEAD 2>/dev/null || echo "unknown") | |
| @@ -37,49 +30,32 @@ help: ## Show this help message | |||
| 37 | 30 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | |
| 38 | 31 | ||
| 39 | 32 | # Build targets | |
| 40 | - | build: build-frontend ## Build the application with GUI (frontend assets, then Go binary with WebKitGTK) | |
| 41 | - | @echo "Building $(BINARY_NAME) $(VERSION) with GUI using $(NPROCS) cores..." | |
| 42 | - | @mkdir -p $(DIST_DIR) tmp tmp/go-cache tmp/go-mod | |
| 43 | - | $(GOENV) CGO_ENABLED=1 go build -p $(NPROCS) $(LDFLAGS) -tags=webkit_cgo -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 44 | - | @echo "✅ Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 33 | + | build: build-frontend ## Build the application (pure Go, no CGO) | |
| 34 | + | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | |
| 35 | + | @mkdir -p $(DIST_DIR) | |
| 36 | + | go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 37 | + | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 45 | 38 | ||
| 46 | 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 | 41 | @cd gui && npm install --silent && npm run build | |
| 49 | 42 | @echo "GUI build complete" | |
| 50 | 43 | ||
| 51 | - | build-no-gui: build-frontend ## Build binary without GUI (CGO disabled, CLI-only functionality) | |
| 52 | - | @echo "Building $(BINARY_NAME) $(VERSION) (CLI-only, no GUI) using $(NPROCS) cores..." | |
| 53 | - | @mkdir -p $(DIST_DIR) tmp tmp/go-cache tmp/go-mod | |
| 54 | - | $(GOENV) CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-no-gui $(MAIN_PATH) | |
| 55 | - | @echo "✅ Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)-no-gui" | |
| 56 | - | ||
| 57 | - | build-static: build-no-gui ## Alias for build-no-gui (backward compatibility) | |
| 58 | - | @echo "Note: build-static is deprecated, use build-no-gui instead" | |
| 59 | - | ||
| 60 | - | # GUI build with WebKitGTK 6.0 (GTK4) | |
| 61 | - | .PHONY: build-gui run-gui | |
| 62 | - | build-gui: build ## Alias for default build (backward compatibility) | |
| 63 | - | @echo "Note: build-gui is now the default 'build' target" | |
| 64 | - | ||
| 65 | - | run-gui: ## Run the GUI with native WebKitGTK 6.0 (requires GTK4/WebKitGTK 6 dev packages) | |
| 66 | - | @echo "Running GUI (webkit_cgo) using $(NPROCS) cores…" | |
| 67 | - | @mkdir -p tmp tmp/go-cache tmp/go-mod | |
| 68 | - | $(GOENV) CGO_ENABLED=1 go run -p $(NPROCS) -tags=webkit_cgo $(MAIN_PATH) | |
| 69 | - | ||
| 70 | - | .PHONY: check-webkit | |
| 71 | - | check-webkit: ## Check system has GTK4/WebKitGTK 6.0/JavaScriptCore 6.0 | |
| 72 | - | @echo "Checking pkg-config versions..." | |
| 73 | - | @which pkg-config >/dev/null 2>&1 || (echo "pkg-config not found" && exit 1) | |
| 74 | - | @echo "gtk4: $$(pkg-config --modversion gtk4 2>/dev/null || echo not found)" | |
| 75 | - | @echo "webkitgtk-6.0: $$(pkg-config --modversion webkitgtk-6.0 2>/dev/null || echo not found)" | |
| 76 | - | @echo "javascriptcoregtk-6.0: $$(pkg-config --modversion javascriptcoregtk-6.0 2>/dev/null || echo not found)" | |
| 44 | + | build-quick: ## Build without frontend (faster for backend development) | |
| 45 | + | @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..." | |
| 46 | + | @mkdir -p $(DIST_DIR) | |
| 47 | + | go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 48 | + | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 77 | 49 | ||
| 78 | 50 | # Development targets | |
| 79 | 51 | dev: ## Run in development mode | |
| 80 | 52 | @echo "Running in development mode..." | |
| 81 | 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 | 59 | generate: ## Generate code (SQLC) | |
| 84 | 60 | @echo "Generating code with SQLC..." | |
| 85 | 61 | sqlc generate | |
| @@ -88,16 +64,20 @@ generate: ## Generate code (SQLC) | |||
| 88 | 64 | # Testing | |
| 89 | 65 | test: ## Run tests | |
| 90 | 66 | @echo "Running tests..." | |
| 91 | - | @mkdir -p tmp tmp/go-cache tmp/go-mod | |
| 92 | - | $(GOENV) CGO_ENABLED=0 go test -v ./... | |
| 67 | + | go test -v ./... | |
| 93 | 68 | ||
| 94 | 69 | test-race: ## Run tests with race detection | |
| 95 | 70 | @echo "Running tests with race detection..." | |
| 96 | - | @mkdir -p tmp tmp/go-cache tmp/go-mod | |
| 97 | - | $(GOENV) CGO_ENABLED=1 go test -race -v ./... | |
| 71 | + | go test -race -v ./... | |
| 72 | + | ||
| 73 | + | test-cover: ## Run tests with coverage | |
| 74 | + | @echo "Running tests with coverage..." | |
| 75 | + | go test -coverprofile=coverage.out ./... | |
| 76 | + | go tool cover -html=coverage.out -o coverage.html | |
| 77 | + | @echo "Coverage report: coverage.html" | |
| 98 | 78 | ||
| 99 | 79 | # Linting | |
| 100 | - | lint: ## Run golangci-lint (excludes pkg/webkit CGO files via config) | |
| 80 | + | lint: ## Run golangci-lint | |
| 101 | 81 | @echo "Running golangci-lint..." | |
| 102 | 82 | golangci-lint run | |
| 103 | 83 | ||
| @@ -107,7 +87,7 @@ lint-fix: ## Run golangci-lint with --fix | |||
| 107 | 87 | ||
| 108 | 88 | # Format code | |
| 109 | 89 | fmt: ## Format Go code with gofmt | |
| 110 | - | @echo "Formatting Go code with gofmt..." | |
| 90 | + | @echo "Formatting Go code..." | |
| 111 | 91 | go fmt ./... | |
| 112 | 92 | ||
| 113 | 93 | # Tools installation | |
| @@ -121,41 +101,41 @@ install-tools: ## Install development tools | |||
| 121 | 101 | clean: ## Clean build artifacts | |
| 122 | 102 | @echo "Cleaning build artifacts..." | |
| 123 | 103 | rm -rf $(DIST_DIR) | |
| 124 | - | rm -f $(BINARY_NAME) # Remove any old binaries in root | |
| 104 | + | rm -f $(BINARY_NAME) | |
| 125 | 105 | rm -rf gui/dist gui/node_modules | |
| 126 | 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 | 108 | go clean -cache | |
| 128 | 109 | go clean -testcache | |
| 129 | 110 | ||
| 130 | 111 | # Project initialization | |
| 131 | 112 | init: install-tools ## Initialize project dependencies and tools | |
| 132 | - | @echo "Project dependencies and tools installed!" | |
| 133 | - | @echo "Ready for development. Run 'make help' for available commands." | |
| 113 | + | @echo "Initializing project..." | |
| 114 | + | go mod tidy | |
| 115 | + | @echo "Project initialized! Run 'make help' for available commands." | |
| 134 | 116 | ||
| 135 | 117 | # Check setup | |
| 136 | 118 | check: ## Check that all tools and dependencies are working | |
| 137 | 119 | @echo "Checking project setup..." | |
| 138 | 120 | @echo "Go version:" | |
| 139 | - | @$(GOENV) go version | |
| 121 | + | @go version | |
| 140 | 122 | @echo "\nSQLC version:" | |
| 141 | - | @$(GOENV) sqlc version | |
| 123 | + | @sqlc version | |
| 142 | 124 | @echo "\nGolangci-lint version:" | |
| 143 | - | @$(GOENV) golangci-lint version | |
| 125 | + | @golangci-lint version | |
| 144 | 126 | @echo "\nBuilding project..." | |
| 145 | - | @$(MAKE) build | |
| 146 | - | @echo "\nTesting built binary..." | |
| 147 | - | @$(DIST_DIR)/$(BINARY_NAME) version | |
| 127 | + | @$(MAKE) build-quick | |
| 148 | 128 | @echo "\nRunning tests..." | |
| 149 | 129 | @$(MAKE) test | |
| 150 | - | @echo "\n✅ All checks passed! Project is ready for development." | |
| 130 | + | @echo "\nAll checks passed!" | |
| 151 | 131 | ||
| 152 | 132 | # Native release targets | |
| 153 | 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 | 136 | @echo "Building snapshot with goreleaser..." | |
| 157 | 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 | 140 | @echo "Building release with goreleaser..." | |
| 161 | 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 | 3 | go 1.25.3 | |
| 4 | 4 | ||
| 5 | - | require ( | |
| 6 | - | github.com/diamondburned/gotk4-webkitgtk/pkg v0.0.0-20240108031600-dee1973cf440 | |
| 7 | - | github.com/diamondburned/gotk4/pkg v0.3.1 | |
| 8 | - | github.com/fsnotify/fsnotify v1.9.0 | |
| 9 | - | github.com/golang/mock v1.6.0 | |
| 10 | - | github.com/invopop/jsonschema v0.13.0 | |
| 11 | - | github.com/ncruces/go-sqlite3 v0.29.0 | |
| 12 | - | github.com/spf13/cobra v1.10.1 | |
| 13 | - | github.com/spf13/viper v1.21.0 | |
| 14 | - | github.com/stretchr/testify v1.11.1 | |
| 15 | - | go.uber.org/mock v0.6.0 | |
| 16 | - | golang.org/x/sync v0.17.0 | |
| 17 | - | golang.org/x/sys v0.36.0 | |
| 18 | - | ) | |
| 5 | + | require github.com/rs/zerolog v1.33.0 | |
| 19 | 6 | ||
| 20 | 7 | require ( | |
| 21 | - | github.com/KarpelesLab/weak v0.1.1 // indirect | |
| 22 | - | github.com/bahlo/generic-list-go v0.2.0 // indirect | |
| 23 | - | github.com/buger/jsonparser v1.1.1 // indirect | |
| 24 | - | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 25 | - | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | |
| 26 | - | github.com/google/go-cmp v0.7.0 // indirect | |
| 27 | - | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 28 | - | github.com/mailru/easyjson v0.7.7 // indirect | |
| 29 | - | github.com/ncruces/julianday v1.0.0 // indirect | |
| 30 | - | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | |
| 31 | - | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 32 | - | github.com/rogpeppe/go-internal v1.12.0 // indirect | |
| 33 | - | github.com/sagikazarmark/locafero v0.11.0 // indirect | |
| 34 | - | github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect | |
| 35 | - | github.com/spf13/afero v1.15.0 // indirect | |
| 36 | - | github.com/spf13/cast v1.10.0 // indirect | |
| 37 | - | github.com/spf13/pflag v1.0.10 // indirect | |
| 38 | - | github.com/subosito/gotenv v1.6.0 // indirect | |
| 39 | - | github.com/tetratelabs/wazero v1.9.0 // indirect | |
| 40 | - | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect | |
| 41 | - | go.yaml.in/yaml/v3 v3.0.4 // indirect | |
| 42 | - | go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect | |
| 43 | - | golang.org/x/text v0.29.0 // indirect | |
| 44 | - | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect | |
| 45 | - | gopkg.in/yaml.v3 v3.0.1 // indirect | |
| 8 | + | github.com/mattn/go-colorable v0.1.13 // indirect | |
| 9 | + | github.com/mattn/go-isatty v0.0.19 // indirect | |
| 10 | + | golang.org/x/sys v0.36.0 // indirect | |
| 46 | 11 | ) | |
| 47 | 12 | ||
| 48 | 13 | replace ( | |
| 49 | - | github.com/diamondburned/gotk4 => github.com/bnema/gotk4 v0.3.4-0.20251017144723-f765b03fd751 | |
| 50 | - | github.com/diamondburned/gotk4-webkitgtk => github.com/bnema/gotk4-webkitgtk v0.1.2-0.20251017152300-b3012dbe285f | |
| 51 | - | github.com/diamondburned/gotk4-webkitgtk/pkg => github.com/bnema/gotk4-webkitgtk/pkg v0.1.2-0.20251017152300-b3012dbe285f | |
| 52 | - | github.com/diamondburned/gotk4/pkg => github.com/bnema/gotk4/pkg v0.3.4-0.20251017144723-f765b03fd751 | |
| 14 | + | github.com/bnema/puregotk-webkit => /home/brice/projects/puregotk-webkit | |
| 15 | + | github.com/jwijenbergh/puregotk => /home/brice/projects/puregotk | |
| 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);⏎̸ | |
D internal/migrations/005_add_certificate_validations.sql +0 -16 collapsed to keep the page light
D internal/migrations/006_add_fuzzy_cache_tables.sql +0 -22 collapsed to keep the page light
D internal/migrations/007_remove_shortcuts_table.sql +0 -6 collapsed to keep the page light
D internal/migrations/008_add_favorites.sql +0 -18 collapsed to keep the page light
D internal/migrations/009_add_visit_count_index.sql +0 -5 collapsed to keep the page light
D internal/migrations/010_homepage_redesign.sql +0 -74 collapsed to keep the page light
D internal/migrations/011_content_whitelist.sql +0 -11 collapsed to keep the page light
D internal/migrations/migrations.go +0 -243 collapsed to keep the page light
D internal/migrations/migrations_test.go +0 -438 collapsed to keep the page light
D internal/parser/fuzzy.go +0 -471 collapsed to keep the page light
D internal/parser/fuzzy_test.go +0 -513 collapsed to keep the page light
D internal/parser/history.go +0 -430 collapsed to keep the page light
D internal/parser/parser.go +0 -459 collapsed to keep the page light
D internal/parser/parser_test.go +0 -626 collapsed to keep the page light
D internal/parser/types.go +0 -171
| @@ -1,171 +0,0 @@ | |||
| 1 | - | // Package parser provides URL parsing and fuzzy finding functionality for dumber. | |
| 2 | - | package parser | |
| 3 | - | ||
| 4 | - | import ( | |
| 5 | - | "time" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/dumber/internal/config" | |
| 8 | - | "github.com/bnema/dumber/internal/db" | |
| 9 | - | ) | |
| 10 | - | ||
| 11 | - | // InputType represents the type of user input detected. | |
| 12 | - | type InputType int | |
| 13 | - | ||
| 14 | - | const ( | |
| 15 | - | // InputTypeDirectURL represents a direct URL input (e.g., "reddit.com", "https://example.com") | |
| 16 | - | InputTypeDirectURL InputType = iota | |
| 17 | - | // InputTypeSearchShortcut represents a search shortcut (e.g., "g: golang tutorial") | |
| 18 | - | InputTypeSearchShortcut | |
| 19 | - | // InputTypeHistorySearch represents a history search query | |
| 20 | - | InputTypeHistorySearch | |
| 21 | - | // InputTypeFallbackSearch represents a fallback web search | |
| 22 | - | InputTypeFallbackSearch | |
| 23 | - | ) | |
| 24 | - | ||
| 25 | - | // ParseResult represents the result of parsing user input. | |
| 26 | - | type ParseResult struct { | |
| 27 | - | // Type indicates the detected input type | |
| 28 | - | Type InputType `json:"type"` | |
| 29 | - | // URL is the final URL to navigate to | |
| 30 | - | URL string `json:"url"` | |
| 31 | - | // Query is the original user query | |
| 32 | - | Query string `json:"query"` | |
| 33 | - | // Confidence is the confidence score of the parsing (0.0-1.0) | |
| 34 | - | Confidence float64 `json:"confidence"` | |
| 35 | - | // FuzzyMatches contains fuzzy search results if applicable | |
| 36 | - | FuzzyMatches []FuzzyMatch `json:"fuzzy_matches,omitempty"` | |
| 37 | - | // Shortcut contains the detected shortcut information | |
| 38 | - | Shortcut *DetectedShortcut `json:"shortcut,omitempty"` | |
| 39 | - | // ProcessingTime is the time taken to process the input | |
| 40 | - | ProcessingTime time.Duration `json:"processing_time"` | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // DetectedShortcut represents a detected search shortcut. | |
| 44 | - | type DetectedShortcut struct { | |
| 45 | - | // Key is the shortcut key (e.g., "g", "gh") | |
| 46 | - | Key string `json:"key"` | |
| 47 | - | // Query is the search query part | |
| 48 | - | Query string `json:"query"` | |
| 49 | - | // URL is the constructed URL | |
| 50 | - | URL string `json:"url"` | |
| 51 | - | // Description is the shortcut description | |
| 52 | - | Description string `json:"description"` | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // FuzzyMatch represents a fuzzy search match from history. | |
| 56 | - | type FuzzyMatch struct { | |
| 57 | - | // HistoryEntry is the matched history entry | |
| 58 | - | HistoryEntry *db.History `json:"history_entry"` | |
| 59 | - | // Score is the similarity score (0.0-1.0, higher is better) | |
| 60 | - | Score float64 `json:"score"` | |
| 61 | - | // URLScore is the URL similarity score | |
| 62 | - | URLScore float64 `json:"url_score"` | |
| 63 | - | // TitleScore is the title similarity score | |
| 64 | - | TitleScore float64 `json:"title_score"` | |
| 65 | - | // RecencyScore is the recency score based on last visit | |
| 66 | - | RecencyScore float64 `json:"recency_score"` | |
| 67 | - | // VisitScore is the score based on visit count | |
| 68 | - | VisitScore float64 `json:"visit_score"` | |
| 69 | - | // MatchedField indicates which field matched (url, title, or both) | |
| 70 | - | MatchedField string `json:"matched_field"` | |
| 71 | - | } | |
| 72 | - | ||
| 73 | - | // HistoryProvider defines the interface for history data access. | |
| 74 | - | type HistoryProvider interface { | |
| 75 | - | // GetRecentHistory returns recent history entries limited by count | |
| 76 | - | GetRecentHistory(limit int) ([]*db.History, error) | |
| 77 | - | // GetAllHistory returns all history entries for fuzzy search | |
| 78 | - | GetAllHistory() ([]*db.History, error) | |
| 79 | - | // SearchHistory performs a basic text search in history | |
| 80 | - | SearchHistory(query string, limit int) ([]*db.History, error) | |
| 81 | - | // GetHistoryByURL retrieves history entry by exact URL match | |
| 82 | - | GetHistoryByURL(url string) (*db.History, error) | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // FuzzyConfig holds configuration for fuzzy matching algorithms. | |
| 86 | - | type FuzzyConfig struct { | |
| 87 | - | // MinSimilarityThreshold is the minimum similarity score to consider a match (0.0-1.0) | |
| 88 | - | MinSimilarityThreshold float64 `json:"min_similarity_threshold"` | |
| 89 | - | // MaxResults is the maximum number of fuzzy results to return | |
| 90 | - | MaxResults int `json:"max_results"` | |
| 91 | - | // URLWeight is the weight for URL similarity in scoring | |
| 92 | - | URLWeight float64 `json:"url_weight"` | |
| 93 | - | // TitleWeight is the weight for title similarity in scoring | |
| 94 | - | TitleWeight float64 `json:"title_weight"` | |
| 95 | - | // RecencyWeight is the weight for recency in scoring | |
| 96 | - | RecencyWeight float64 `json:"recency_weight"` | |
| 97 | - | // VisitWeight is the weight for visit count in scoring | |
| 98 | - | VisitWeight float64 `json:"visit_weight"` | |
| 99 | - | // RecencyDecayDays is the number of days for recency score decay | |
| 100 | - | RecencyDecayDays int `json:"recency_decay_days"` | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | // Parser handles URL parsing and fuzzy finding operations. | |
| 104 | - | type Parser struct { | |
| 105 | - | config *config.Config | |
| 106 | - | fuzzyConfig *FuzzyConfig | |
| 107 | - | historyProvider HistoryProvider | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | // ParserOption is a functional option for configuring the Parser. | |
| 111 | - | type ParserOption func(*Parser) | |
| 112 | - | ||
| 113 | - | // NewParser creates a new Parser instance with the given configuration. | |
| 114 | - | func NewParser(cfg *config.Config, historyProvider HistoryProvider, opts ...ParserOption) *Parser { | |
| 115 | - | p := &Parser{ | |
| 116 | - | config: cfg, | |
| 117 | - | historyProvider: historyProvider, | |
| 118 | - | fuzzyConfig: DefaultFuzzyConfig(), | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | for _, opt := range opts { | |
| 122 | - | opt(p) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | return p | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | // WithFuzzyConfig sets custom fuzzy matching configuration. | |
| 129 | - | func WithFuzzyConfig(fuzzyConfig *FuzzyConfig) ParserOption { | |
| 130 | - | return func(p *Parser) { | |
| 131 | - | p.fuzzyConfig = fuzzyConfig | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | // DefaultFuzzyConfig returns default fuzzy matching configuration. | |
| 136 | - | func DefaultFuzzyConfig() *FuzzyConfig { | |
| 137 | - | return &FuzzyConfig{ | |
| 138 | - | MinSimilarityThreshold: 0.3, | |
| 139 | - | MaxResults: 10, | |
| 140 | - | URLWeight: 0.4, | |
| 141 | - | TitleWeight: 0.3, | |
| 142 | - | RecencyWeight: 0.2, | |
| 143 | - | VisitWeight: 0.1, | |
| 144 | - | RecencyDecayDays: 30, | |
| 145 | - | } | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | // String returns a string representation of InputType. | |
| 149 | - | func (t InputType) String() string { | |
| 150 | - | switch t { | |
| 151 | - | case InputTypeDirectURL: | |
| 152 | - | return "direct_url" | |
| 153 | - | case InputTypeSearchShortcut: | |
| 154 | - | return "search_shortcut" | |
| 155 | - | case InputTypeHistorySearch: | |
| 156 | - | return "history_search" | |
| 157 | - | case InputTypeFallbackSearch: | |
| 158 | - | return "fallback_search" | |
| 159 | - | default: | |
| 160 | - | return "unknown" | |
| 161 | - | } | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | // IsValid returns true if the FuzzyConfig has valid values. | |
| 165 | - | func (fc *FuzzyConfig) IsValid() bool { | |
| 166 | - | return fc.MinSimilarityThreshold >= 0.0 && fc.MinSimilarityThreshold <= 1.0 && | |
| 167 | - | fc.MaxResults > 0 && | |
| 168 | - | fc.URLWeight >= 0.0 && fc.TitleWeight >= 0.0 && | |
| 169 | - | fc.RecencyWeight >= 0.0 && fc.VisitWeight >= 0.0 && | |
| 170 | - | fc.RecencyDecayDays > 0 | |
| 171 | - | } | |