Merge pull request #7 from bnema/dev
feat: keyboard shortcuts, cache improvements, and error handling
76 files · +3107 -794
M .gitignore +1 -0
| @@ -82,3 +82,4 @@ templates/ | |||||
| 82 | .serena/ | 82 | .serena/ | ||
| 83 | config.yaml | 83 | config.yaml | ||
| 84 | .dev/ | 84 | .dev/ | ||
| 85 | + | vendor/ | |||
M CHANGELOG.md +20 -0
| @@ -6,6 +6,26 @@ All notable changes to this project will be documented in this file. | |||||
| 6 | 6 | ||||
| 7 | ### Added | 7 | ### Added | ||
| 8 | - **Color scheme config**: `appearance.color_scheme` setting to force dark/light theme (`"prefer-dark"`, `"prefer-light"`, `"default"`) | 8 | - **Color scheme config**: `appearance.color_scheme` setting to force dark/light theme (`"prefer-dark"`, `"prefer-light"`, `"default"`) | ||
| 9 | + | - **DNS prefetch**: Added capability to prefetch DNS for links to speed up future requests | |||
| 10 | + | - **Page cache (bfcache)**: Enabled WebKit back/forward cache for instant navigation | |||
| 11 | + | - **Smooth scrolling**: Enabled WebKit smooth scrolling for better UX | |||
| 12 | + | - **JSON Schema**: Auto-generated config schema for IDE autocompletion and validation | |||
| 13 | + | - **Parallel cache loading**: Zoom, certificate, and fuzzy caches load concurrently at startup (<100ms target) | |||
| 14 | + | - **Certificate validation cache**: In-memory cache for TLS certificate decisions with graceful shutdown flushing | |||
| 15 | + | ||||
| 16 | + | ### Changed | |||
| 17 | + | - **Database performance**: Enabled WAL mode with optimized PRAGMAs (64MB cache, memory-mapped I/O, 5s busy timeout) for 70k reads/sec, 3.6k writes/sec | |||
| 18 | + | - **Zoom performance**: In-memory cache eliminates database reads during page transitions | |||
| 19 | + | - **History writes**: Batched writes with background processor (flush every 5s or 50 items) to reduce I/O overhead | |||
| 20 | + | - **BREAKING: Keyboard shortcuts**: All shortcuts now use `ctrl+` instead of `cmdorctrl+`. Existing configs with `cmdorctrl` still work via backward compatibility. Delete `~/.config/dumber/config.json` to regenerate with new defaults. | |||
| 21 | + | - **Cache management**: Graceful cache flushing on shutdown prevents data loss; startup timing instrumentation with performance warnings | |||
| 22 | + | ||||
| 23 | + | ### Fixed | |||
| 24 | + | - **Graceful shutdown deadlock**: Resolved GTK main loop deadlock on Ctrl+Q by removing mutex locks and GTK calls after main loop exit | |||
| 25 | + | - **CSS provider error handling**: Now properly handles errors from GTK CSS provider with logging | |||
| 26 | + | - **Cache operations**: Added context timeouts to cache flush and history queue operations for graceful failure | |||
| 27 | + | - Command typos now show proper error instead of opening in browser | |||
| 28 | + | - History queue now uses buffered channel to prevent blocking on shutdown | |||
| 9 | 29 | ||||
| 10 | ## [0.12.1] - 2025-10-18 | 30 | ## [0.12.1] - 2025-10-18 | ||
| 11 | 31 | ||||
M README.md +32 -128
| @@ -41,22 +41,22 @@ Dumber is a minimalist browser and launcher companion focused on speed and simpl | |||||
| 41 | | Shortcut | Action | Notes | | 41 | | Shortcut | Action | Notes | | ||
| 42 | |----------|--------|-------| | 42 | |----------|--------|-------| | ||
| 43 | | **F12** | Open Developer Tools | WebKit inspector | | 43 | | **F12** | Open Developer Tools | WebKit inspector | | ||
| 44 | - | | **Ctrl/Cmd+L** | Open Omnibox | URL/search input with history | | 44 | + | | **Ctrl+L** | Open Omnibox | URL/search input with history | |
| 45 | - | | **Ctrl/Cmd+F** | Find in Page | Search text within current page | | 45 | + | | **Ctrl+F** | Find in Page | Search text within current page | |
| 46 | - | | **Ctrl/Cmd+=** | Zoom In | Firefox-compatible zoom levels | | 46 | + | | **Ctrl+=** | Zoom In | Firefox-compatible zoom levels | |
| 47 | - | | **Ctrl/Cmd++** | Zoom In | Alternative plus key | | 47 | + | | **Ctrl++** | Zoom In | Alternative plus key | |
| 48 | - | | **Ctrl/Cmd+-** | Zoom Out | Works across keyboard layouts | | 48 | + | | **Ctrl+-** | Zoom Out | Works across keyboard layouts | |
| 49 | - | | **Ctrl/Cmd+0** | Reset Zoom | Return to 100% zoom | | 49 | + | | **Ctrl+0** | Reset Zoom | Return to 100% zoom | |
| 50 | - | | **Ctrl/Cmd+Shift+C** | Copy URL | Copy current URL to clipboard with toast | | 50 | + | | **Ctrl+Shift+C** | Copy URL | Copy current URL to clipboard with toast | |
| 51 | - | | **Ctrl/Cmd+Shift+P** | Print Page | Open native print dialog | | 51 | + | | **Ctrl+Shift+P** | Print Page | Open native print dialog | |
| 52 | - | | **Ctrl/Cmd+R** / **F5** | Reload Page | Refresh current page | | 52 | + | | **Ctrl+R** / **F5** | Reload Page | Refresh current page | |
| 53 | - | | **Ctrl/Cmd+Shift+R** | Hard Reload | Refresh ignoring cache | | 53 | + | | **Ctrl+Shift+R** | Hard Reload | Refresh ignoring cache | |
| 54 | | **Ctrl+←** / **Ctrl+→** | Navigate Back/Forward | Browser history navigation | | 54 | | **Ctrl+←** / **Ctrl+→** | Navigate Back/Forward | Browser history navigation | | ||
| 55 | 55 | ||||
| 56 | #### Zellij-Inspired Pane Management | 56 | #### Zellij-Inspired Pane Management | ||
| 57 | | Shortcut | Action | Notes | | 57 | | Shortcut | Action | Notes | | ||
| 58 | |----------|--------|-------| | 58 | |----------|--------|-------| | ||
| 59 | - | | **Ctrl/Cmd+P** | Enter Pane Mode | Modal mode for pane operations | | 59 | + | | **Ctrl+P** | Enter Pane Mode | Modal mode for pane operations | |
| 60 | | **→** / **R** (in pane mode) | Split Right | Create new pane to the right | | 60 | | **→** / **R** (in pane mode) | Split Right | Create new pane to the right | | ||
| 61 | | **←** / **L** (in pane mode) | Split Left | Create new pane to the left | | 61 | | **←** / **L** (in pane mode) | Split Left | Create new pane to the left | | ||
| 62 | | **↑** / **U** (in pane mode) | Split Up | Create new pane above | | 62 | | **↑** / **U** (in pane mode) | Split Up | Create new pane above | | ||
| @@ -178,7 +178,7 @@ The pane system is fully configurable via `config.json`: | |||||
| 178 | "workspace": { | 178 | "workspace": { | ||
| 179 | "enable_zellij_controls": true, | 179 | "enable_zellij_controls": true, | ||
| 180 | "pane_mode": { | 180 | "pane_mode": { | ||
| 181 | - | "activation_shortcut": "cmdorctrl+p", | 181 | + | "activation_shortcut": "ctrl+p", |
| 182 | "timeout_ms": 3000, | 182 | "timeout_ms": 3000, | ||
| 183 | "action_bindings": { | 183 | "action_bindings": { | ||
| 184 | "arrowright": "split-right", | 184 | "arrowright": "split-right", | ||
| @@ -208,134 +208,38 @@ Dumber follows the XDG Base Directory spec: | |||||
| 208 | 208 | ||||
| 209 | A default `config.json` is created on first run. Config changes are watched and applied at runtime when possible. | 209 | A default `config.json` is created on first run. Config changes are watched and applied at runtime when possible. | ||
| 210 | 210 | ||||
| 211 | - | ### Complete Configuration Reference | 211 | + | ### How Configuration Works |
| 212 | 212 | ||||
| 213 | - | #### Database Configuration | 213 | + | - **Config file**: `~/.config/dumber/config.json` (supports `.yaml` and `.toml` too) |
| 214 | - | ```json | 214 | + | - **Format**: JSON/YAML/TOML - auto-detected |
| 215 | - | "database": { | 215 | + | - **JSON Schema**: Auto-generated for IDE autocompletion and validation |
| 216 | - | "path": "~/.local/state/dumber/history.db", | 216 | + | - **Live reload**: Changes are automatically applied without restarting |
| 217 | - | "max_connections": 1, | 217 | + | - **Environment variables**: Override any setting with `DUMB_BROWSER_*` or `DUMBER_*` prefix |
| 218 | - | "max_idle_time": "5m0s", | 218 | + | - **Default values**: Sensible defaults for all settings |
| 219 | - | "query_timeout": "30s" | |||
| 220 | - | } | |||
| 221 | - | ``` | |||
| 222 | 219 | ||||
| 223 | - | #### History Management | 220 | + | ### Quick Examples |
| 224 | - | ```json | |||
| 225 | - | "history": { | |||
| 226 | - | "max_entries": 10000, | |||
| 227 | - | "retention_period_days": 365, | |||
| 228 | - | "cleanup_interval_days": 1 | |||
| 229 | - | } | |||
| 230 | - | ``` | |||
| 231 | 221 | ||||
| 232 | - | #### Search Shortcuts | |||
| 233 | ```json | 222 | ```json | ||
| 234 | - | "search_shortcuts": { | 223 | + | { |
| 235 | - | "g": { "url": "https://www.google.com/search?q=%s", "description": "Google search" }, | |||
| 236 | - | "gh": { "url": "https://github.com/search?q=%s", "description": "GitHub search" }, | |||
| 237 | - | "yt": { "url": "https://www.youtube.com/results?search_query=%s", "description": "YouTube search" }, | |||
| 238 | - | "w": { "url": "https://en.wikipedia.org/wiki/%s", "description": "Wikipedia search" }, | |||
| 239 | - | "ddg": { "url": "https://duckduckgo.com/?q=%s", "description": "DuckDuckGo search" }, | |||
| 240 | - | "so": { "url": "https://stackoverflow.com/search?q=%s", "description": "Stack Overflow search" }, | |||
| 241 | - | "r": { "url": "https://www.reddit.com/search?q=%s", "description": "Reddit search" }, | |||
| 242 | - | "npm": { "url": "https://www.npmjs.com/search?q=%s", "description": "npm package search" }, | |||
| 243 | - | "go": { "url": "https://pkg.go.dev/search?q=%s", "description": "Go package search" }, | |||
| 244 | - | "mdn": { "url": "https://developer.mozilla.org/en-US/search?q=%s", "description": "MDN Web Docs search" } | |||
| 245 | - | } | |||
| 246 | - | ``` | |||
| 247 | - | ||||
| 248 | - | #### Dmenu Integration | |||
| 249 | - | ```json | |||
| 250 | - | "dmenu": { | |||
| 251 | - | "max_history_items": 20, | |||
| 252 | - | "show_visit_count": true, | |||
| 253 | - | "show_last_visited": true, | |||
| 254 | - | "history_prefix": "🕒", | |||
| 255 | - | "shortcut_prefix": "🔍", | |||
| 256 | - | "url_prefix": "🌐", | |||
| 257 | - | "date_format": "2006-01-02 15:04", | |||
| 258 | - | "sort_by_visit_count": true | |||
| 259 | - | } | |||
| 260 | - | ``` | |||
| 261 | - | ||||
| 262 | - | #### Logging Configuration | |||
| 263 | - | ```json | |||
| 264 | - | "logging": { | |||
| 265 | - | "level": "info", | |||
| 266 | - | "format": "text", | |||
| 267 | - | "filename": "", | |||
| 268 | - | "max_size": 100, | |||
| 269 | - | "max_backups": 3, | |||
| 270 | - | "max_age": 7, | |||
| 271 | - | "compress": true | |||
| 272 | - | } | |||
| 273 | - | ``` | |||
| 274 | - | ||||
| 275 | - | #### Appearance Settings | |||
| 276 | - | ```json | |||
| 277 | - | "appearance": { | |||
| 278 | - | "color_scheme": "default", | |||
| 279 | - | "sans_font": "Fira Sans", | |||
| 280 | - | "serif_font": "Fira Sans", | |||
| 281 | - | "monospace_font": "Fira Code", | |||
| 282 | - | "default_font_size": 16, | |||
| 283 | "default_zoom": 1.2, | 224 | "default_zoom": 1.2, | ||
| 284 | - | "color_palette": { | 225 | + | "rendering_mode": "gpu", |
| 285 | - | "light": { | 226 | + | "search_shortcuts": { |
| 286 | - | "background": "#ffffff", | 227 | + | "g": { "url": "https://google.com/search?q=%s", "description": "Google" } |
| 287 | - | "surface": "#f5f5f5", | 228 | + | }, |
| 288 | - | "text": "#1a1a1a", | 229 | + | "workspace": { |
| 289 | - | "text_muted": "#6b7280", | 230 | + | "enable_zellij_controls": true, |
| 290 | - | "accent": "#3b82f6", | 231 | + | "popups": { "behavior": "split" } |
| 291 | - | "border": "#e5e7eb" | |||
| 292 | - | }, | |||
| 293 | - | "dark": { | |||
| 294 | - | "background": "#0a0a0a", | |||
| 295 | - | "surface": "#1a1a1a", | |||
| 296 | - | "text": "#e5e7eb", | |||
| 297 | - | "text_muted": "#9ca3af", | |||
| 298 | - | "accent": "#60a5fa", | |||
| 299 | - | "border": "#374151" | |||
| 300 | - | } | |||
| 301 | } | 232 | } | ||
| 302 | } | 233 | } | ||
| 303 | ``` | 234 | ``` | ||
| 304 | - | - `color_scheme`: `"prefer-dark"`, `"prefer-light"`, or `"default"` (follows system theme) | |||
| 305 | - | ||||
| 306 | - | #### Rendering Mode (GPU/CPU) | |||
| 307 | - | ```json | |||
| 308 | - | "rendering_mode": "auto" | |||
| 309 | - | ``` | |||
| 310 | - | - `auto`: Detect GPU availability and use GPU when available (default) | |||
| 311 | - | - `gpu`: Force GPU acceleration (Vulkan/OpenGL via GTK4) | |||
| 312 | - | - `cpu`: Force software rendering | |||
| 313 | - | ||||
| 314 | - | ### Environment Variables | |||
| 315 | - | All config values can be overridden with environment variables using the `DUMB_BROWSER_` prefix: | |||
| 316 | 235 | ||||
| 236 | + | Environment variable override: | |||
| 317 | ```bash | 237 | ```bash | ||
| 318 | - | # Database | 238 | + | DUMBER_RENDERING_MODE=cpu dumber |
| 319 | - | DUMB_BROWSER_DATABASE_PATH="./browser.db" | 239 | + | DUMB_BROWSER_LOGGING_LEVEL=debug dumber |
| 320 | - | DUMB_BROWSER_DATABASE_MAX_CONNECTIONS=5 | |||
| 321 | - | ||||
| 322 | - | # Rendering mode (special variable name) | |||
| 323 | - | DUMBER_RENDERING_MODE="gpu" | |||
| 324 | - | ||||
| 325 | - | # Dmenu settings | |||
| 326 | - | DUMB_BROWSER_DMENU_MAX_HISTORY_ITEMS=50 | |||
| 327 | - | DUMB_BROWSER_DMENU_SHOW_VISIT_COUNT=false | |||
| 328 | - | ||||
| 329 | - | # Logging | |||
| 330 | - | DUMB_BROWSER_LOGGING_LEVEL="debug" | |||
| 331 | - | DUMB_BROWSER_LOGGING_FORMAT="json" | |||
| 332 | ``` | 240 | ``` | ||
| 333 | 241 | ||||
| 334 | - | ### CLI Flags | 242 | + | **📖 Complete documentation**: See [docs/CONFIG.md](docs/CONFIG.md) for all available settings, defaults, and valid values. |
| 335 | - | The browse command supports additional runtime flags: | |||
| 336 | - | ```bash | |||
| 337 | - | dumber browse --rendering-mode=gpu https://example.com | |||
| 338 | - | ``` | |||
| 339 | 243 | ||||
| 340 | 244 | ||||
| 341 | ## Building With WebKitGTK 6 (GTK4) | 245 | ## Building With WebKitGTK 6 (GTK4) | ||
A docs/CONFIG.md +295 -0
| @@ -0,0 +1,295 @@ | |||||
| 1 | + | # Configuration Reference | |||
| 2 | + | ||||
| 3 | + | **Primary format**: JSON | |||
| 4 | + | **Location**: `~/.config/dumber/config.json` | |||
| 5 | + | **Also supports**: YAML (`.yaml`) and TOML (`.toml`) | |||
| 6 | + | **JSON Schema**: `~/.config/dumber/config.schema.json` (auto-generated) | |||
| 7 | + | ||||
| 8 | + | ## IDE Integration | |||
| 9 | + | ||||
| 10 | + | For autocompletion and validation in VS Code or other editors, add this to the top of your `config.json`: | |||
| 11 | + | ||||
| 12 | + | ```json | |||
| 13 | + | { | |||
| 14 | + | "$schema": "./config.schema.json", | |||
| 15 | + | "default_zoom": 1.2, | |||
| 16 | + | ... | |||
| 17 | + | } | |||
| 18 | + | ``` | |||
| 19 | + | ||||
| 20 | + | The schema is automatically generated when you first create a config file. | |||
| 21 | + | ||||
| 22 | + | ## Database | |||
| 23 | + | ||||
| 24 | + | | Key | Type | Default | Description | | |||
| 25 | + | |-----|------|---------|-------------| | |||
| 26 | + | | `database.path` | string | `~/.local/state/dumber/dumber.sqlite` | Database file path | | |||
| 27 | + | ||||
| 28 | + | ## History | |||
| 29 | + | ||||
| 30 | + | | Key | Type | Default | Valid Values | Description | | |||
| 31 | + | |-----|------|---------|--------------|-------------| | |||
| 32 | + | | `history.max_entries` | int | `10000` | > 0 | Maximum number of history entries | | |||
| 33 | + | | `history.retention_period_days` | int | `365` | > 0 | Days to keep history (1 year) | | |||
| 34 | + | | `history.cleanup_interval_days` | int | `1` | > 0 | How often to run cleanup | | |||
| 35 | + | ||||
| 36 | + | ## Search Shortcuts | |||
| 37 | + | ||||
| 38 | + | | Key | Type | Default | Description | | |||
| 39 | + | |-----|------|---------|-------------| | |||
| 40 | + | | `search_shortcuts` | map | See defaults | Map of shortcut aliases to URLs | | |||
| 41 | + | ||||
| 42 | + | **Default shortcuts:** | |||
| 43 | + | ```json | |||
| 44 | + | { | |||
| 45 | + | "search_shortcuts": { | |||
| 46 | + | "g": { | |||
| 47 | + | "url": "https://google.com/search?q=%s", | |||
| 48 | + | "description": "Google Search" | |||
| 49 | + | }, | |||
| 50 | + | "gh": { | |||
| 51 | + | "url": "https://github.com/search?q=%s", | |||
| 52 | + | "description": "GitHub" | |||
| 53 | + | }, | |||
| 54 | + | "yt": { | |||
| 55 | + | "url": "https://youtube.com/results?search_query=%s", | |||
| 56 | + | "description": "YouTube" | |||
| 57 | + | } | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ``` | |||
| 61 | + | ||||
| 62 | + | ## Dmenu / Launcher | |||
| 63 | + | ||||
| 64 | + | | Key | Type | Default | Description | | |||
| 65 | + | |-----|------|---------|-------------| | |||
| 66 | + | | `dmenu.max_history_items` | int | `20` | Max items shown in launcher | | |||
| 67 | + | | `dmenu.show_visit_count` | bool | `true` | Show visit counts | | |||
| 68 | + | | `dmenu.show_last_visited` | bool | `true` | Show last visited dates | | |||
| 69 | + | | `dmenu.history_prefix` | string | `"🕒"` | Prefix for history items | | |||
| 70 | + | | `dmenu.shortcut_prefix` | string | `"🔍"` | Prefix for shortcuts | | |||
| 71 | + | | `dmenu.url_prefix` | string | `"🌐"` | Prefix for URLs | | |||
| 72 | + | | `dmenu.date_format` | string | `"2006-01-02 15:04"` | Go time format string | | |||
| 73 | + | | `dmenu.sort_by_visit_count` | bool | `true` | Sort by popularity | | |||
| 74 | + | ||||
| 75 | + | ## Logging | |||
| 76 | + | ||||
| 77 | + | | Key | Type | Default | Valid Values | Description | | |||
| 78 | + | |-----|------|---------|--------------|-------------| | |||
| 79 | + | | `logging.level` | string | `"info"` | `debug`, `info`, `warn`, `error` | Log level | | |||
| 80 | + | | `logging.format` | string | `"text"` | `text`, `json` | Log format | | |||
| 81 | + | | `logging.filename` | string | `""` | any | Empty = stdout | | |||
| 82 | + | | `logging.max_size` | int | `100` | > 0 | Max log size in MB | | |||
| 83 | + | | `logging.max_backups` | int | `3` | >= 0 | Number of log backups | | |||
| 84 | + | | `logging.max_age` | int | `7` | >= 0 | Days to keep logs | | |||
| 85 | + | | `logging.compress` | bool | `true` | - | Compress old logs | | |||
| 86 | + | | `logging.log_dir` | string | `~/.local/state/dumber/logs/` | any | Log directory | | |||
| 87 | + | | `logging.enable_file_log` | bool | `true` | - | Enable file logging | | |||
| 88 | + | | `logging.capture_stdout` | bool | `false` | - | Capture stdout to log | | |||
| 89 | + | | `logging.capture_stderr` | bool | `false` | - | Capture stderr to log | | |||
| 90 | + | | `logging.capture_c_output` | bool | `false` | - | Capture C/CGO output | | |||
| 91 | + | | `logging.capture_console` | bool | `false` | - | Capture browser console | | |||
| 92 | + | | `logging.debug_file` | string | `"debug.log"` | any | Debug log filename | | |||
| 93 | + | | `logging.verbose_webkit` | bool | `false` | - | Verbose WebKit logs | | |||
| 94 | + | ||||
| 95 | + | ## Appearance | |||
| 96 | + | ||||
| 97 | + | | Key | Type | Default | Description | | |||
| 98 | + | |-----|------|---------|-------------| | |||
| 99 | + | | `appearance.sans_font` | string | `"Fira Sans"` | Sans-serif font | | |||
| 100 | + | | `appearance.serif_font` | string | `"Fira Sans"` | Serif font | | |||
| 101 | + | | `appearance.monospace_font` | string | `"Fira Code"` | Monospace font | | |||
| 102 | + | | `appearance.default_font_size` | int | `16` | Font size in points | | |||
| 103 | + | | `appearance.color_scheme` | string | `"default"` | `prefer-dark`, `prefer-light`, `default` | | |||
| 104 | + | ||||
| 105 | + | ### Color Palettes | |||
| 106 | + | ||||
| 107 | + | **Light palette:** | |||
| 108 | + | ```json | |||
| 109 | + | { | |||
| 110 | + | "appearance": { | |||
| 111 | + | "light_palette": { | |||
| 112 | + | "background": "#f8f8f8", | |||
| 113 | + | "surface": "#f2f2f2", | |||
| 114 | + | "surface_variant": "#ececec", | |||
| 115 | + | "text": "#1a1a1a", | |||
| 116 | + | "muted": "#6e6e6e", | |||
| 117 | + | "accent": "#404040", | |||
| 118 | + | "border": "#d2d2d2" | |||
| 119 | + | } | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ``` | |||
| 123 | + | ||||
| 124 | + | **Dark palette:** | |||
| 125 | + | ```json | |||
| 126 | + | { | |||
| 127 | + | "appearance": { | |||
| 128 | + | "dark_palette": { | |||
| 129 | + | "background": "#0e0e0e", | |||
| 130 | + | "surface": "#1a1a1a", | |||
| 131 | + | "surface_variant": "#141414", | |||
| 132 | + | "text": "#e4e4e4", | |||
| 133 | + | "muted": "#848484", | |||
| 134 | + | "accent": "#a8a8a8", | |||
| 135 | + | "border": "#363636" | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ``` | |||
| 140 | + | ||||
| 141 | + | ## Video Acceleration | |||
| 142 | + | ||||
| 143 | + | | Key | Type | Default | Description | | |||
| 144 | + | |-----|------|---------|-------------| | |||
| 145 | + | | `video_acceleration.enable_vaapi` | bool | `true` | Enable VA-API hardware accel | | |||
| 146 | + | | `video_acceleration.auto_detect_gpu` | bool | `true` | Auto-detect GPU driver | | |||
| 147 | + | | `video_acceleration.vaapi_driver_name` | string | `""` | Driver name (auto-detected) | | |||
| 148 | + | | `video_acceleration.enable_all_drivers` | bool | `true` | Enable all VA-API drivers | | |||
| 149 | + | | `video_acceleration.legacy_vaapi` | bool | `false` | Legacy VA-API mode | | |||
| 150 | + | ||||
| 151 | + | ## Codec Preferences | |||
| 152 | + | ||||
| 153 | + | | Key | Type | Default | Valid Values | Description | | |||
| 154 | + | |-----|------|---------|--------------|-------------| | |||
| 155 | + | | `codec_preferences.preferred_codecs` | string | `"av1,h264"` | Comma-separated | Codec priority order | | |||
| 156 | + | | `codec_preferences.force_av1` | bool | `false` | - | Force AV1 codec | | |||
| 157 | + | | `codec_preferences.block_vp9` | bool | `false` | - | Block VP9 codec | | |||
| 158 | + | | `codec_preferences.block_vp8` | bool | `true` | - | Block VP8 codec | | |||
| 159 | + | | `codec_preferences.av1_hardware_only` | bool | `false` | - | Disable AV1 software fallback | | |||
| 160 | + | | `codec_preferences.disable_vp9_hardware` | bool | `false` | - | Disable VP9 hardware accel | | |||
| 161 | + | | `codec_preferences.video_buffer_size_mb` | int | `16` | > 0 | Video buffer size in MB | | |||
| 162 | + | | `codec_preferences.queue_buffer_time_sec` | int | `10` | > 0 | Queue buffer time in seconds | | |||
| 163 | + | | `codec_preferences.custom_user_agent` | string | Chrome UA | any | Custom user agent string | | |||
| 164 | + | | `codec_preferences.av1_max_resolution` | string | `"1080p"` | `720p`, `1080p`, `1440p`, `4k`, `unlimited` | Max AV1 resolution | | |||
| 165 | + | | `codec_preferences.disable_twitch_codec_control` | bool | `true` | - | Disable codec control on Twitch | | |||
| 166 | + | ||||
| 167 | + | ## Debug Options | |||
| 168 | + | ||||
| 169 | + | | Key | Type | Default | Description | | |||
| 170 | + | |-----|------|---------|-------------| | |||
| 171 | + | | `debug.enable_webkit_debug` | bool | `false` | Enable WebKit debug logs | | |||
| 172 | + | | `debug.webkit_debug_categories` | string | `"Network:preconnectTo,ContentFilters"` | Debug categories | | |||
| 173 | + | | `debug.enable_filtering_debug` | bool | `false` | Content filter debug | | |||
| 174 | + | | `debug.enable_webview_debug` | bool | `false` | WebView state debug | | |||
| 175 | + | | `debug.log_webkit_crashes` | bool | `true` | Log WebKit crashes | | |||
| 176 | + | | `debug.enable_script_debug` | bool | `false` | Script injection debug | | |||
| 177 | + | | `debug.enable_general_debug` | bool | `false` | General debug mode | | |||
| 178 | + | | `debug.enable_workspace_debug` | bool | `false` | Workspace navigation debug | | |||
| 179 | + | | `debug.enable_focus_debug` | bool | `false` | Focus state debug | | |||
| 180 | + | | `debug.enable_css_debug` | bool | `false` | CSS reconciler debug | | |||
| 181 | + | | `debug.enable_focus_metrics` | bool | `false` | Focus metrics tracking | | |||
| 182 | + | | `debug.enable_pane_close_debug` | bool | `false` | Pane close debug | | |||
| 183 | + | ||||
| 184 | + | ## Rendering & Zoom | |||
| 185 | + | ||||
| 186 | + | | Key | Type | Default | Valid Values | Description | | |||
| 187 | + | |-----|------|---------|--------------|-------------| | |||
| 188 | + | | `rendering_mode` | string | `"gpu"` | `auto`, `gpu`, `cpu` | Rendering mode | | |||
| 189 | + | | `use_dom_zoom` | bool | `false` | - | Use DOM-based zoom | | |||
| 190 | + | | `default_zoom` | float | `1.2` | > 0 | Default zoom level (120%) | | |||
| 191 | + | ||||
| 192 | + | ## Workspace Configuration | |||
| 193 | + | ||||
| 194 | + | | Key | Type | Default | Description | | |||
| 195 | + | |-----|------|---------|-------------| | |||
| 196 | + | | `workspace.enable_zellij_controls` | bool | `true` | Enable Zellij-style keybindings | | |||
| 197 | + | ||||
| 198 | + | ### Pane Mode | |||
| 199 | + | ||||
| 200 | + | | Key | Type | Default | Description | | |||
| 201 | + | |-----|------|---------|-------------| | |||
| 202 | + | | `workspace.pane_mode.activation_shortcut` | string | `"ctrl+p"` | Pane mode activation key | | |||
| 203 | + | | `workspace.pane_mode.timeout_ms` | int | `3000` | Pane mode timeout (ms) | | |||
| 204 | + | | `workspace.pane_mode.action_bindings` | map | See below | Pane mode key bindings | | |||
| 205 | + | ||||
| 206 | + | **Default pane mode bindings:** | |||
| 207 | + | ```json | |||
| 208 | + | { | |||
| 209 | + | "workspace": { | |||
| 210 | + | "pane_mode": { | |||
| 211 | + | "action_bindings": { | |||
| 212 | + | "arrowright": "split-right", | |||
| 213 | + | "arrowleft": "split-left", | |||
| 214 | + | "arrowup": "split-up", | |||
| 215 | + | "arrowdown": "split-down", | |||
| 216 | + | "r": "split-right", | |||
| 217 | + | "l": "split-left", | |||
| 218 | + | "u": "split-up", | |||
| 219 | + | "d": "split-down", | |||
| 220 | + | "x": "close-pane", | |||
| 221 | + | "enter": "confirm", | |||
| 222 | + | "escape": "cancel" | |||
| 223 | + | } | |||
| 224 | + | } | |||
| 225 | + | } | |||
| 226 | + | } | |||
| 227 | + | ``` | |||
| 228 | + | ||||
| 229 | + | ### Tab Shortcuts | |||
| 230 | + | ||||
| 231 | + | | Key | Type | Default | Description | | |||
| 232 | + | |-----|------|---------|-------------| | |||
| 233 | + | | `workspace.tabs.new_tab` | string | `"ctrl+t"` | New tab shortcut | | |||
| 234 | + | | `workspace.tabs.close_tab` | string | `"ctrl+w"` | Close tab shortcut | | |||
| 235 | + | | `workspace.tabs.next_tab` | string | `"ctrl+tab"` | Next tab shortcut | | |||
| 236 | + | | `workspace.tabs.previous_tab` | string | `"ctrl+shift+tab"` | Previous tab shortcut | | |||
| 237 | + | ||||
| 238 | + | ### Popup Behavior | |||
| 239 | + | ||||
| 240 | + | | Key | Type | Default | Valid Values | Description | | |||
| 241 | + | |-----|------|---------|--------------|-------------| | |||
| 242 | + | | `workspace.popups.behavior` | string | `"split"` | `split`, `stacked`, `tabbed`, `windowed` | Popup opening mode | | |||
| 243 | + | | `workspace.popups.placement` | string | `"right"` | `right`, `left`, `top`, `bottom` | Split direction | | |||
| 244 | + | | `workspace.popups.open_in_new_pane` | bool | `true` | - | Open popups in workspace | | |||
| 245 | + | | `workspace.popups.follow_pane_context` | bool | `true` | - | Follow parent pane context | | |||
| 246 | + | | `workspace.popups.blank_target_behavior` | string | `"pane"` | `pane`, `tab` | `_blank` target handling | | |||
| 247 | + | | `workspace.popups.enable_smart_detection` | bool | `true` | - | Use WindowProperties for detection | | |||
| 248 | + | | `workspace.popups.oauth_auto_close` | bool | `true` | - | Auto-close OAuth popups | | |||
| 249 | + | ||||
| 250 | + | ### Workspace Styling | |||
| 251 | + | ||||
| 252 | + | | Key | Type | Default | Description | | |||
| 253 | + | |-----|------|---------|-------------| | |||
| 254 | + | | `workspace.styling.border_width` | int | `2` | Border width in pixels | | |||
| 255 | + | | `workspace.styling.border_color` | string | `"@theme_selected_bg_color"` | Border color (CSS or theme var) | | |||
| 256 | + | | `workspace.styling.pane_mode_border_color` | string | `"#FFA500"` | Pane mode border color (orange) | | |||
| 257 | + | | `workspace.styling.transition_duration` | int | `120` | Transition duration (ms) | | |||
| 258 | + | | `workspace.styling.border_radius` | int | `0` | Border radius in pixels | | |||
| 259 | + | ||||
| 260 | + | ## Content Filtering | |||
| 261 | + | ||||
| 262 | + | | Key | Type | Default | Description | | |||
| 263 | + | |-----|------|---------|-------------| | |||
| 264 | + | | `content_filtering_whitelist` | array | See below | Domains to skip ad blocking | | |||
| 265 | + | ||||
| 266 | + | **Default whitelist:** | |||
| 267 | + | ```json | |||
| 268 | + | { | |||
| 269 | + | "content_filtering_whitelist": [ | |||
| 270 | + | "twitch.tv", | |||
| 271 | + | "passport.twitch.tv", | |||
| 272 | + | "gql.twitch.tv" | |||
| 273 | + | ] | |||
| 274 | + | } | |||
| 275 | + | ``` | |||
| 276 | + | ||||
| 277 | + | ## Environment Variables | |||
| 278 | + | ||||
| 279 | + | All config values can be overridden via environment variables with the prefix `DUMB_BROWSER_`: | |||
| 280 | + | ||||
| 281 | + | ```bash | |||
| 282 | + | # Database | |||
| 283 | + | DUMB_BROWSER_DATABASE_PATH=/custom/path/db.sqlite | |||
| 284 | + | ||||
| 285 | + | # Rendering | |||
| 286 | + | DUMBER_RENDERING_MODE=cpu | |||
| 287 | + | DUMBER_DEFAULT_ZOOM=1.5 | |||
| 288 | + | ||||
| 289 | + | # Video/Codec | |||
| 290 | + | DUMBER_VIDEO_ACCELERATION_ENABLE=true | |||
| 291 | + | LIBVA_DRIVER_NAME=iHD | |||
| 292 | + | DUMBER_PREFERRED_CODECS=av1,vp9,h264 | |||
| 293 | + | ``` | |||
| 294 | + | ||||
| 295 | + | **Note:** Some video/codec env vars use `DUMBER_*` prefix and match GStreamer/VA-API conventions. | |||
M go.mod +8 -1
| @@ -7,17 +7,23 @@ require ( | |||||
| 7 | github.com/diamondburned/gotk4/pkg v0.3.1 | 7 | github.com/diamondburned/gotk4/pkg v0.3.1 | ||
| 8 | github.com/fsnotify/fsnotify v1.9.0 | 8 | github.com/fsnotify/fsnotify v1.9.0 | ||
| 9 | github.com/golang/mock v1.6.0 | 9 | github.com/golang/mock v1.6.0 | ||
| 10 | + | github.com/invopop/jsonschema v0.13.0 | |||
| 10 | github.com/ncruces/go-sqlite3 v0.29.0 | 11 | github.com/ncruces/go-sqlite3 v0.29.0 | ||
| 11 | github.com/spf13/cobra v1.10.1 | 12 | github.com/spf13/cobra v1.10.1 | ||
| 12 | github.com/spf13/viper v1.21.0 | 13 | github.com/spf13/viper v1.21.0 | ||
| 14 | + | go.uber.org/mock v0.6.0 | |||
| 15 | + | golang.org/x/sync v0.17.0 | |||
| 13 | golang.org/x/sys v0.36.0 | 16 | golang.org/x/sys v0.36.0 | ||
| 14 | ) | 17 | ) | ||
| 15 | 18 | ||||
| 16 | require ( | 19 | require ( | ||
| 17 | github.com/KarpelesLab/weak v0.1.1 // indirect | 20 | github.com/KarpelesLab/weak v0.1.1 // indirect | ||
| 21 | + | github.com/bahlo/generic-list-go v0.2.0 // indirect | |||
| 22 | + | github.com/buger/jsonparser v1.1.1 // indirect | |||
| 18 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | 23 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | ||
| 19 | github.com/google/go-cmp v0.7.0 // indirect | 24 | github.com/google/go-cmp v0.7.0 // indirect | ||
| 20 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 25 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 26 | + | github.com/mailru/easyjson v0.7.7 // indirect | |||
| 21 | github.com/ncruces/julianday v1.0.0 // indirect | 27 | github.com/ncruces/julianday v1.0.0 // indirect | ||
| 22 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | 28 | github.com/pelletier/go-toml/v2 v2.2.4 // indirect | ||
| 23 | github.com/rogpeppe/go-internal v1.12.0 // indirect | 29 | github.com/rogpeppe/go-internal v1.12.0 // indirect | ||
| @@ -28,11 +34,12 @@ require ( | |||||
| 28 | github.com/spf13/pflag v1.0.10 // indirect | 34 | github.com/spf13/pflag v1.0.10 // indirect | ||
| 29 | github.com/subosito/gotenv v1.6.0 // indirect | 35 | github.com/subosito/gotenv v1.6.0 // indirect | ||
| 30 | github.com/tetratelabs/wazero v1.9.0 // indirect | 36 | github.com/tetratelabs/wazero v1.9.0 // indirect | ||
| 37 | + | github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect | |||
| 31 | go.yaml.in/yaml/v3 v3.0.4 // indirect | 38 | go.yaml.in/yaml/v3 v3.0.4 // indirect | ||
| 32 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect | 39 | go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect | ||
| 33 | - | golang.org/x/sync v0.17.0 // indirect | |||
| 34 | golang.org/x/text v0.29.0 // indirect | 40 | golang.org/x/text v0.29.0 // indirect | ||
| 35 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect | 41 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect | ||
| 42 | + | gopkg.in/yaml.v3 v3.0.1 // indirect | |||
| 36 | ) | 43 | ) | ||
| 37 | 44 | ||||
| 38 | replace ( | 45 | replace ( | ||
M go.sum +13 -0 generated file: collapsed
M gui/package-lock.json +11 -15 generated file: collapsed
M gui/src/lib/keyboard/KeyboardService.ts +5 -10
| @@ -343,13 +343,8 @@ export class KeyboardService { | |||||
| 343 | const parts: string[] = []; | 343 | const parts: string[] = []; | ||
| 344 | 344 | ||||
| 345 | // Add modifiers using MODIFIER_KEYS mapping | 345 | // Add modifiers using MODIFIER_KEYS mapping | ||
| 346 | - | if (event.ctrlKey && event.metaKey) { | 346 | + | if (event.ctrlKey) { |
| 347 | - | // Both ctrl and meta pressed - use cmdorctrl | |||
| 348 | - | parts.push("cmdorctrl"); | |||
| 349 | - | } else if (event.ctrlKey) { | |||
| 350 | parts.push("ctrl"); | 347 | parts.push("ctrl"); | ||
| 351 | - | } else if (event.metaKey) { | |||
| 352 | - | parts.push("cmd"); | |||
| 353 | } | 348 | } | ||
| 354 | 349 | ||||
| 355 | if (event.altKey) parts.push("alt"); | 350 | if (event.altKey) parts.push("alt"); | ||
| @@ -407,20 +402,20 @@ export const keyboardService = new KeyboardService({ | |||||
| 407 | globalShortcuts: [ | 402 | globalShortcuts: [ | ||
| 408 | // Navigation shortcuts (migrated from controls module) | 403 | // Navigation shortcuts (migrated from controls module) | ||
| 409 | { | 404 | { | ||
| 410 | - | key: "cmdorctrl+arrowleft", | 405 | + | key: "ctrl+arrowleft", |
| 411 | handler: () => { | 406 | handler: () => { | ||
| 412 | if (window.history.length > 1) { | 407 | if (window.history.length > 1) { | ||
| 413 | window.history.back(); | 408 | window.history.back(); | ||
| 414 | } | 409 | } | ||
| 415 | }, | 410 | }, | ||
| 416 | - | description: "Navigate back (Ctrl/Cmd+Left Arrow)", | 411 | + | description: "Navigate back (Ctrl+Left Arrow)", |
| 417 | }, | 412 | }, | ||
| 418 | { | 413 | { | ||
| 419 | - | key: "cmdorctrl+arrowright", | 414 | + | key: "ctrl+arrowright", |
| 420 | handler: () => { | 415 | handler: () => { | ||
| 421 | window.history.forward(); | 416 | window.history.forward(); | ||
| 422 | }, | 417 | }, | ||
| 423 | - | description: "Navigate forward (Ctrl/Cmd+Right Arrow)", | 418 | + | description: "Navigate forward (Ctrl+Right Arrow)", |
| 424 | }, | 419 | }, | ||
| 425 | // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled | 420 | // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled | ||
| 426 | // directly by the native Go shortcut handlers to ensure proper active pane detection. | 421 | // directly by the native Go shortcut handlers to ensure proper active pane detection. | ||
M gui/src/lib/keyboard/types.ts +3 -5
| @@ -6,7 +6,7 @@ | |||||
| 6 | */ | 6 | */ | ||
| 7 | 7 | ||||
| 8 | export interface ShortcutConfig { | 8 | export interface ShortcutConfig { | ||
| 9 | - | /** The keyboard shortcut key combination (e.g., 'cmdorctrl+l', 'escape', 'enter') */ | 9 | + | /** The keyboard shortcut key combination (e.g., 'ctrl+l', 'escape', 'enter') */ |
| 10 | key: string; | 10 | key: string; | ||
| 11 | 11 | ||||
| 12 | /** Function to execute when the shortcut is triggered */ | 12 | /** Function to execute when the shortcut is triggered */ | ||
| @@ -78,8 +78,6 @@ export interface KeyboardEvent { | |||||
| 78 | */ | 78 | */ | ||
| 79 | export const MODIFIER_KEYS = { | 79 | export const MODIFIER_KEYS = { | ||
| 80 | ctrl: "ctrlKey", | 80 | ctrl: "ctrlKey", | ||
| 81 | - | cmd: "metaKey", | |||
| 82 | - | cmdorctrl: navigator.platform.includes("Mac") ? "metaKey" : "ctrlKey", | |||
| 83 | alt: "altKey", | 81 | alt: "altKey", | ||
| 84 | shift: "shiftKey", | 82 | shift: "shiftKey", | ||
| 85 | } as const; | 83 | } as const; | ||
| @@ -88,8 +86,8 @@ export const MODIFIER_KEYS = { | |||||
| 88 | * Common shortcut patterns used throughout the application | 86 | * Common shortcut patterns used throughout the application | ||
| 89 | */ | 87 | */ | ||
| 90 | export const COMMON_SHORTCUTS = { | 88 | export const COMMON_SHORTCUTS = { | ||
| 91 | - | OMNIBOX_OPEN: "cmdorctrl+l", | 89 | + | OMNIBOX_OPEN: "ctrl+l", |
| 92 | - | FIND_OPEN: "cmdorctrl+f", | 90 | + | FIND_OPEN: "ctrl+f", |
| 93 | ESCAPE: "escape", | 91 | ESCAPE: "escape", | ||
| 94 | ENTER: "enter", | 92 | ENTER: "enter", | ||
| 95 | ARROW_UP: "arrowup", | 93 | ARROW_UP: "arrowup", | ||
M internal/app/browser/browser.go +105 -2
| @@ -1,13 +1,16 @@ | |||||
| 1 | package browser | 1 | package browser | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "database/sql" | 5 | "database/sql" | ||
| 5 | "embed" | 6 | "embed" | ||
| 7 | + | "fmt" | |||
| 6 | "log" | 8 | "log" | ||
| 7 | "os" | 9 | "os" | ||
| 8 | "os/signal" | 10 | "os/signal" | ||
| 9 | "runtime" | 11 | "runtime" | ||
| 10 | "syscall" | 12 | "syscall" | ||
| 13 | + | "time" | |||
| 11 | 14 | ||||
| 12 | "github.com/bnema/dumber/internal/app/api" | 15 | "github.com/bnema/dumber/internal/app/api" | ||
| 13 | "github.com/bnema/dumber/internal/app/control" | 16 | "github.com/bnema/dumber/internal/app/control" | ||
| @@ -19,6 +22,7 @@ import ( | |||||
| 19 | "github.com/bnema/dumber/internal/logging" | 22 | "github.com/bnema/dumber/internal/logging" | ||
| 20 | "github.com/bnema/dumber/internal/services" | 23 | "github.com/bnema/dumber/internal/services" | ||
| 21 | "github.com/bnema/dumber/pkg/webkit" | 24 | "github.com/bnema/dumber/pkg/webkit" | ||
| 25 | + | "golang.org/x/sync/errgroup" | |||
| 22 | ) | 26 | ) | ||
| 23 | 27 | ||||
| 24 | // BrowserApp represents the main browser application | 28 | // BrowserApp represents the main browser application | ||
| @@ -61,6 +65,7 @@ type BrowserApp struct { | |||||
| 61 | 65 | ||||
| 62 | // Run starts the browser application | 66 | // Run starts the browser application | ||
| 63 | func Run(assets embed.FS, version, commit, buildDate string) { | 67 | func Run(assets embed.FS, version, commit, buildDate string) { | ||
| 68 | + | startupStart := time.Now() | |||
| 64 | log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()) | 69 | log.Printf("Starting GUI mode (webkit_cgo=%v)", webkit.IsNativeAvailable()) | ||
| 65 | 70 | ||||
| 66 | app := &BrowserApp{ | 71 | app := &BrowserApp{ | ||
| @@ -78,6 +83,12 @@ func Run(assets embed.FS, version, commit, buildDate string) { | |||||
| 78 | os.Exit(1) | 83 | os.Exit(1) | ||
| 79 | } | 84 | } | ||
| 80 | 85 | ||||
| 86 | + | startupElapsed := time.Since(startupStart) | |||
| 87 | + | log.Printf("[startup] Application initialized in %v", startupElapsed) | |||
| 88 | + | if startupElapsed > 500*time.Millisecond { | |||
| 89 | + | log.Printf("[startup] WARNING: Startup took %v (target: <500ms)", startupElapsed) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 81 | app.Run() | 92 | app.Run() | ||
| 82 | } | 93 | } | ||
| 83 | 94 | ||||
| @@ -136,6 +147,12 @@ func (app *BrowserApp) Initialize() error { | |||||
| 136 | app.parserService = services.NewParserService(app.config, app.queries) | 147 | app.parserService = services.NewParserService(app.config, app.queries) | ||
| 137 | app.browserService = services.NewBrowserService(app.config, app.queries) | 148 | app.browserService = services.NewBrowserService(app.config, app.queries) | ||
| 138 | 149 | ||||
| 150 | + | // Load all caches in parallel for fast startup (target: <100ms) | |||
| 151 | + | if err := app.loadCachesParallel(context.Background()); err != nil { | |||
| 152 | + | log.Printf("Warning: failed to load caches: %v", err) | |||
| 153 | + | // Non-fatal - caches will fall back to defaults or DB queries | |||
| 154 | + | } | |||
| 155 | + | ||||
| 139 | // Initialize handlers | 156 | // Initialize handlers | ||
| 140 | app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService) | 157 | app.schemeHandler = api.NewSchemeHandler(app.assets, app.parserService, app.browserService) | ||
| 141 | app.messageHandler = messaging.NewHandler(app.parserService, app.browserService) | 158 | app.messageHandler = messaging.NewHandler(app.parserService, app.browserService) | ||
| @@ -143,6 +160,57 @@ func (app *BrowserApp) Initialize() error { | |||||
| 143 | return nil | 160 | return nil | ||
| 144 | } | 161 | } | ||
| 145 | 162 | ||||
| 163 | + | // loadCachesParallel loads all caches in parallel for fast startup. | |||
| 164 | + | // Uses errgroup to coordinate parallel loads and capture any errors. | |||
| 165 | + | // Target: <100ms for all caches combined. | |||
| 166 | + | func (app *BrowserApp) loadCachesParallel(ctx context.Context) error { | |||
| 167 | + | startTime := time.Now() | |||
| 168 | + | ||||
| 169 | + | g, ctx := errgroup.WithContext(ctx) | |||
| 170 | + | ||||
| 171 | + | // Load zoom cache in parallel | |||
| 172 | + | g.Go(func() error { | |||
| 173 | + | if err := app.browserService.LoadZoomCacheFromDB(ctx); err != nil { | |||
| 174 | + | log.Printf("[cache] Failed to load zoom cache: %v", err) | |||
| 175 | + | return err | |||
| 176 | + | } | |||
| 177 | + | return nil | |||
| 178 | + | }) | |||
| 179 | + | ||||
| 180 | + | // Load certificate validation cache in parallel | |||
| 181 | + | g.Go(func() error { | |||
| 182 | + | if err := app.browserService.LoadCertCacheFromDB(ctx); err != nil { | |||
| 183 | + | log.Printf("[cache] Failed to load cert cache: %v", err) | |||
| 184 | + | return err | |||
| 185 | + | } | |||
| 186 | + | return nil | |||
| 187 | + | }) | |||
| 188 | + | ||||
| 189 | + | // Load fuzzy search cache in parallel for instant dmenu access | |||
| 190 | + | g.Go(func() error { | |||
| 191 | + | if err := app.browserService.LoadFuzzyCacheFromDB(ctx); err != nil { | |||
| 192 | + | log.Printf("[cache] Failed to load fuzzy cache: %v", err) | |||
| 193 | + | return err | |||
| 194 | + | } | |||
| 195 | + | return nil | |||
| 196 | + | }) | |||
| 197 | + | ||||
| 198 | + | // Wait for all cache loads to complete | |||
| 199 | + | if err := g.Wait(); err != nil { | |||
| 200 | + | return fmt.Errorf("cache loading failed: %w", err) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | elapsed := time.Since(startTime) | |||
| 204 | + | log.Printf("[cache] All caches loaded in %v", elapsed) | |||
| 205 | + | ||||
| 206 | + | // Warn if startup is slower than target | |||
| 207 | + | if elapsed > 100*time.Millisecond { | |||
| 208 | + | log.Printf("[cache] Warning: Cache loading took %v (target: <100ms)", elapsed) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | return nil | |||
| 212 | + | } | |||
| 213 | + | ||||
| 146 | // Run executes the main browser loop | 214 | // Run executes the main browser loop | ||
| 147 | func (app *BrowserApp) Run() { | 215 | func (app *BrowserApp) Run() { | ||
| 148 | defer app.cleanup() | 216 | defer app.cleanup() | ||
| @@ -211,11 +279,32 @@ func (app *BrowserApp) cleanup() { | |||||
| 211 | app.workspace = nil | 279 | app.workspace = nil | ||
| 212 | } | 280 | } | ||
| 213 | 281 | ||||
| 214 | - | // Close database last | 282 | + | // Flush all caches to ensure pending writes complete before database closes |
| 283 | + | if app.browserService != nil { | |||
| 284 | + | log.Printf("Flushing all caches...") | |||
| 285 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 286 | + | defer cancel() | |||
| 287 | + | if err := app.browserService.FlushAllCaches(ctx); err != nil { | |||
| 288 | + | log.Printf("Warning: failed to flush caches: %v", err) | |||
| 289 | + | } | |||
| 290 | + | } | |||
| 291 | + | ||||
| 292 | + | // Close database with WAL checkpoint | |||
| 215 | if app.database != nil { | 293 | if app.database != nil { | ||
| 216 | - | log.Printf("Closing database") | 294 | + | log.Printf("Performing WAL checkpoint and closing database...") |
| 295 | + | ||||
| 296 | + | // Run WAL checkpoint to commit all pending writes and truncate WAL file | |||
| 297 | + | if _, err := app.database.Exec("PRAGMA wal_checkpoint(TRUNCATE)"); err != nil { | |||
| 298 | + | log.Printf("Warning: WAL checkpoint failed: %v", err) | |||
| 299 | + | } else { | |||
| 300 | + | log.Printf("WAL checkpoint completed successfully") | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | // Close database connection | |||
| 217 | if closeErr := app.database.Close(); closeErr != nil { | 304 | if closeErr := app.database.Close(); closeErr != nil { | ||
| 218 | log.Printf("Warning: failed to close database: %v", closeErr) | 305 | log.Printf("Warning: failed to close database: %v", closeErr) | ||
| 306 | + | } else { | |||
| 307 | + | log.Printf("Database closed successfully") | |||
| 219 | } | 308 | } | ||
| 220 | } | 309 | } | ||
| 221 | 310 | ||||
| @@ -263,6 +352,20 @@ func (app *BrowserApp) runMainLoop() { | |||||
| 263 | log.Printf("Entering GTK main loop…") | 352 | log.Printf("Entering GTK main loop…") | ||
| 264 | webkit.RunMainLoop() | 353 | webkit.RunMainLoop() | ||
| 265 | log.Printf("GTK main loop exited") | 354 | log.Printf("GTK main loop exited") | ||
| 355 | + | ||||
| 356 | + | // Flush pending history writes immediately after main loop exit | |||
| 357 | + | // This ensures database operations complete while GTK is still in a valid state | |||
| 358 | + | // MUST happen before cleanup() deferred call, which happens after this function returns | |||
| 359 | + | if app.browserService != nil { | |||
| 360 | + | log.Printf("Flushing pending history writes...") | |||
| 361 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |||
| 362 | + | defer cancel() | |||
| 363 | + | if err := app.browserService.FlushHistoryQueue(ctx); err != nil { | |||
| 364 | + | log.Printf("Warning: history queue flush incomplete: %v", err) | |||
| 365 | + | } else { | |||
| 366 | + | log.Printf("History queue flushed successfully") | |||
| 367 | + | } | |||
| 368 | + | } | |||
| 266 | } else { | 369 | } else { | ||
| 267 | log.Printf("Not entering GUI loop (non-CGO build)") | 370 | log.Printf("Not entering GUI loop (non-CGO build)") | ||
| 268 | // In non-CGO mode, just wait for signals | 371 | // In non-CGO mode, just wait for signals | ||
A internal/app/browser/constants.go +19 -0
| @@ -0,0 +1,19 @@ | |||||
| 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 | + | ) | |||
M internal/app/browser/pane.go +0 -17
| @@ -104,23 +104,6 @@ func (p *BrowserPane) Cleanup() { | |||||
| 104 | p.messageHandler.SetWebView(nil) | 104 | p.messageHandler.SetWebView(nil) | ||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| 107 | - | // Notify JS to cleanup GUI | |||
| 108 | - | if p.webView != nil && p.hasGUI && !p.webView.IsDestroyed() { | |||
| 109 | - | cleanupScript := `try { | |||
| 110 | - | if (typeof window.__dumber_teardown === 'function') { | |||
| 111 | - | window.__dumber_teardown(); | |||
| 112 | - | } | |||
| 113 | - | if (window.__dumber_gui_manager) { | |||
| 114 | - | window.__dumber_gui_manager.destroy(); | |||
| 115 | - | } | |||
| 116 | - | } catch (err) { | |||
| 117 | - | console.warn('[dumber] GUI teardown failed', err); | |||
| 118 | - | }` | |||
| 119 | - | if err := p.webView.InjectScript(cleanupScript); err != nil { | |||
| 120 | - | log.Printf("[pane-%s] failed to inject cleanup script: %v", p.id, err) | |||
| 121 | - | } | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | // Destroy WebView to unregister from global registry and release resources | 107 | // Destroy WebView to unregister from global registry and release resources | ||
| 125 | if p.webView != nil && !p.webView.IsDestroyed() { | 108 | if p.webView != nil && !p.webView.IsDestroyed() { | ||
| 126 | if err := p.webView.Destroy(); err != nil { | 109 | if err := p.webView.Destroy(); err != nil { | ||
M internal/app/browser/shortcuts.go +2 -2
| @@ -67,7 +67,7 @@ func (s *ShortcutHandler) RegisterShortcuts() { | |||||
| 67 | // by WindowShortcutHandler at the window level to prevent duplicates | 67 | // by WindowShortcutHandler at the window level to prevent duplicates | ||
| 68 | 68 | ||||
| 69 | // Page refresh shortcuts | 69 | // Page refresh shortcuts | ||
| 70 | - | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", 0, func() { | 70 | + | _ = s.webView.RegisterKeyboardShortcut("ctrl+r", 0, func() { |
| 71 | if !s.isActivePane() { | 71 | if !s.isActivePane() { | ||
| 72 | return | 72 | return | ||
| 73 | } | 73 | } | ||
| @@ -75,7 +75,7 @@ func (s *ShortcutHandler) RegisterShortcuts() { | |||||
| 75 | _ = s.webView.Reload() | 75 | _ = s.webView.Reload() | ||
| 76 | }) | 76 | }) | ||
| 77 | 77 | ||||
| 78 | - | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", 0, func() { | 78 | + | _ = s.webView.RegisterKeyboardShortcut("ctrl+shift+r", 0, func() { |
| 79 | if !s.isActivePane() { | 79 | if !s.isActivePane() { | ||
| 80 | return | 80 | return | ||
| 81 | } | 81 | } | ||
M internal/app/browser/stacked_panes.go +7 -5
| @@ -430,7 +430,7 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) { | |||||
| 430 | if pane.pane != nil && pane.pane.webView != nil { | 430 | if pane.pane != nil && pane.pane.webView != nil { | ||
| 431 | title := pane.pane.webView.GetTitle() | 431 | title := pane.pane.webView.GetTitle() | ||
| 432 | if title == "" { | 432 | if title == "" { | ||
| 433 | - | title = "New Tab" | 433 | + | title = NewTabTitle |
| 434 | } | 434 | } | ||
| 435 | spm.updateTitleBarLabel(pane, title) | 435 | spm.updateTitleBarLabel(pane, title) | ||
| 436 | } | 436 | } | ||
| @@ -475,12 +475,12 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool { | |||||
| 475 | // Calculate new index based on direction | 475 | // Calculate new index based on direction | ||
| 476 | var newIndex int | 476 | var newIndex int | ||
| 477 | switch direction { | 477 | switch direction { | ||
| 478 | - | case "up": | 478 | + | case DirectionUp: |
| 479 | newIndex = currentIndex - 1 | 479 | newIndex = currentIndex - 1 | ||
| 480 | if newIndex < 0 { | 480 | if newIndex < 0 { | ||
| 481 | newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last | 481 | newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last | ||
| 482 | } | 482 | } | ||
| 483 | - | case "down": | 483 | + | case DirectionDown: |
| 484 | newIndex = currentIndex + 1 | 484 | newIndex = currentIndex + 1 | ||
| 485 | if newIndex >= len(stackNode.stackedPanes) { | 485 | if newIndex >= len(stackNode.stackedPanes) { | ||
| 486 | newIndex = 0 // Wrap to first | 486 | newIndex = 0 // Wrap to first | ||
| @@ -524,11 +524,11 @@ func (spm *StackedPaneManager) createTitleBar(pane *paneNode) gtk.Widgetter { | |||||
| 524 | if pane.pane != nil && pane.pane.webView != nil { | 524 | if pane.pane != nil && pane.pane.webView != nil { | ||
| 525 | titleText = pane.pane.webView.GetTitle() | 525 | titleText = pane.pane.webView.GetTitle() | ||
| 526 | if titleText == "" { | 526 | if titleText == "" { | ||
| 527 | - | titleText = "New Tab" // Fallback only when title is actually empty | 527 | + | titleText = NewTabTitle // Fallback only when title is actually empty |
| 528 | } | 528 | } | ||
| 529 | pageURL = pane.pane.webView.GetCurrentURL() | 529 | pageURL = pane.pane.webView.GetCurrentURL() | ||
| 530 | } else { | 530 | } else { | ||
| 531 | - | titleText = "New Tab" | 531 | + | titleText = NewTabTitle |
| 532 | } | 532 | } | ||
| 533 | 533 | ||||
| 534 | titleBar := spm.createTitleBarWithTitle(titleText, pageURL) | 534 | titleBar := spm.createTitleBarWithTitle(titleText, pageURL) | ||
| @@ -714,6 +714,8 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str | |||||
| 714 | return titleBox | 714 | return titleBox | ||
| 715 | } | 715 | } | ||
| 716 | 716 | ||||
| 717 | + | // TODO(#8): Refactor this function to reduce cyclomatic complexity (49 -> <30) | |||
| 718 | + | // | |||
| 717 | // CloseStackedPane handles closing a pane that is part of a stack. | 719 | // CloseStackedPane handles closing a pane that is part of a stack. | ||
| 718 | // The logic mirrors the regular close path: we rebuild the tree, update focus, | 720 | // The logic mirrors the regular close path: we rebuild the tree, update focus, | ||
| 719 | // and let WorkspaceManager perform the actual cleanup/destroy work so that all | 721 | // and let WorkspaceManager perform the actual cleanup/destroy work so that all | ||
M internal/app/browser/webview.go +13 -11
| @@ -43,17 +43,19 @@ func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) { | |||||
| 43 | 43 | ||||
| 44 | // Build WebKit configuration with data directories for persistence | 44 | // Build WebKit configuration with data directories for persistence | ||
| 45 | cfg := &webkit.Config{ | 45 | cfg := &webkit.Config{ | ||
| 46 | - | UserAgent: buildUserAgent(app.config), | 46 | + | UserAgent: buildUserAgent(app.config), |
| 47 | - | EnableJavaScript: true, | 47 | + | EnableJavaScript: true, |
| 48 | - | EnableWebGL: true, | 48 | + | EnableWebGL: true, |
| 49 | - | EnableMediaStream: true, | 49 | + | EnableMediaStream: true, |
| 50 | - | HardwareAcceleration: true, | 50 | + | HardwareAcceleration: true, |
| 51 | - | DefaultFontSize: app.config.Appearance.DefaultFontSize, | 51 | + | DefaultFontSize: app.config.Appearance.DefaultFontSize, |
| 52 | - | MinimumFontSize: 8, | 52 | + | MinimumFontSize: 8, |
| 53 | - | DataDir: webkitData, | 53 | + | EnablePageCache: true, // Instant back/forward navigation (bfcache) |
| 54 | - | CacheDir: webkitCache, | 54 | + | EnableSmoothScrolling: true, // Smooth scrolling animations |
| 55 | - | AppearanceConfigJSON: app.buildAppearanceConfigJSON(), | 55 | + | DataDir: webkitData, |
| 56 | - | CreateWindow: true, // Default to creating a window for standalone WebViews | 56 | + | CacheDir: webkitCache, |
| 57 | + | AppearanceConfigJSON: app.buildAppearanceConfigJSON(), | |||
| 58 | + | CreateWindow: true, // Default to creating a window for standalone WebViews | |||
| 57 | } | 59 | } | ||
| 58 | 60 | ||||
| 59 | return cfg, nil | 61 | return cfg, nil | ||
M internal/app/browser/window_shortcuts.go +4 -4
| @@ -69,10 +69,10 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error { | |||||
| 69 | {"ctrl+minus", h.handleZoomOut, "Zoom out"}, | 69 | {"ctrl+minus", h.handleZoomOut, "Zoom out"}, | ||
| 70 | {"ctrl+0", h.handleZoomReset, "Zoom reset"}, | 70 | {"ctrl+0", h.handleZoomReset, "Zoom reset"}, | ||
| 71 | // Workspace navigation shortcuts - global level for proper active pane targeting | 71 | // Workspace navigation shortcuts - global level for proper active pane targeting | ||
| 72 | - | {"alt+ArrowLeft", func() { h.handleWorkspaceNavigation("left") }, "Navigate left pane"}, | 72 | + | {"alt+ArrowLeft", func() { h.handleWorkspaceNavigation(DirectionLeft) }, "Navigate left pane"}, |
| 73 | - | {"alt+ArrowRight", func() { h.handleWorkspaceNavigation("right") }, "Navigate right pane"}, | 73 | + | {"alt+ArrowRight", func() { h.handleWorkspaceNavigation(DirectionRight) }, "Navigate right pane"}, |
| 74 | - | {"alt+ArrowUp", func() { h.handleWorkspaceNavigation("up") }, "Navigate up pane"}, | 74 | + | {"alt+ArrowUp", func() { h.handleWorkspaceNavigation(DirectionUp) }, "Navigate up pane"}, |
| 75 | - | {"alt+ArrowDown", func() { h.handleWorkspaceNavigation("down") }, "Navigate down pane"}, | 75 | + | {"alt+ArrowDown", func() { h.handleWorkspaceNavigation(DirectionDown) }, "Navigate down pane"}, |
| 76 | } | 76 | } | ||
| 77 | 77 | ||||
| 78 | for _, shortcut := range shortcuts { | 78 | for _, shortcut := range shortcuts { | ||
M internal/app/browser/workspace_css.go +3 -1
| @@ -185,7 +185,9 @@ func (wm *WorkspaceManager) ensureWorkspaceStyles() { | |||||
| 185 | 185 | ||||
| 186 | // Only apply CSS if it hasn't been applied globally or if content changed | 186 | // Only apply CSS if it hasn't been applied globally or if content changed | ||
| 187 | if !globalCSSInitialized || globalCSSContent != workspaceCSS { | 187 | if !globalCSSInitialized || globalCSSContent != workspaceCSS { | ||
| 188 | - | webkit.AddCSSProvider(workspaceCSS) | 188 | + | if err := webkit.AddCSSProvider(workspaceCSS); err != nil { |
| 189 | + | log.Printf("[workspace] WARNING: Failed to add CSS provider: %v", err) | |||
| 190 | + | } | |||
| 189 | globalCSSInitialized = true | 191 | globalCSSInitialized = true | ||
| 190 | globalCSSContent = workspaceCSS | 192 | globalCSSContent = workspaceCSS | ||
| 191 | log.Printf("[workspace] Applied workspace CSS styles (%d bytes)", len(workspaceCSS)) | 193 | log.Printf("[workspace] Applied workspace CSS styles (%d bytes)", len(workspaceCSS)) | ||
M internal/app/browser/workspace_geometry_validator.go +2 -2
| @@ -166,9 +166,9 @@ func (gv *GeometryValidator) ValidateSplit(node *paneNode, direction string) Val | |||||
| 166 | } | 166 | } | ||
| 167 | 167 | ||||
| 168 | switch direction { | 168 | switch direction { | ||
| 169 | - | case "left", "right": | 169 | + | case DirectionLeft, DirectionRight: |
| 170 | return gv.ValidateHorizontalSplit(node) | 170 | return gv.ValidateHorizontalSplit(node) | ||
| 171 | - | case "up", "down": | 171 | + | case DirectionUp, DirectionDown: |
| 172 | return gv.ValidateVerticalSplit(node) | 172 | return gv.ValidateVerticalSplit(node) | ||
| 173 | default: | 173 | default: | ||
| 174 | return ValidationResult{IsValid: false, Reason: fmt.Sprintf("unknown split direction: %s", direction)} | 174 | return ValidationResult{IsValid: false, Reason: fmt.Sprintf("unknown split direction: %s", direction)} | ||
M internal/app/browser/workspace_manager.go +1 -1
| @@ -259,7 +259,7 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||||
| 259 | wm.SetActivePane(node, SourceProgrammatic) | 259 | wm.SetActivePane(node, SourceProgrammatic) | ||
| 260 | direction := strings.ToLower(msg.Direction) | 260 | direction := strings.ToLower(msg.Direction) | ||
| 261 | if direction == "" { | 261 | if direction == "" { | ||
| 262 | - | direction = "right" | 262 | + | direction = DirectionRight |
| 263 | } | 263 | } | ||
| 264 | 264 | ||||
| 265 | if wm.shouldDebounce(source, 200*time.Millisecond) { | 265 | if wm.shouldDebounce(source, 200*time.Millisecond) { | ||
M internal/app/browser/workspace_navigation.go +23 -23
| @@ -105,21 +105,21 @@ func (wm *WorkspaceManager) detachFocus(node *paneNode) { | |||||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
| 107 | // FocusNeighbor moves focus to the nearest pane in the requested direction using the | 107 | // FocusNeighbor moves focus to the nearest pane in the requested direction using the | ||
| 108 | - | // actual widget geometry to determine adjacency. For stacked panes, "up" and "down" | 108 | + | // actual widget geometry to determine adjacency. For stacked panes, DirectionUp and DirectionDown |
| 109 | // navigate within the stack. | 109 | // navigate within the stack. | ||
| 110 | func (wm *WorkspaceManager) FocusNeighbor(direction string) bool { | 110 | func (wm *WorkspaceManager) FocusNeighbor(direction string) bool { | ||
| 111 | if wm == nil { | 111 | if wm == nil { | ||
| 112 | return false | 112 | return false | ||
| 113 | } | 113 | } | ||
| 114 | switch strings.ToLower(direction) { | 114 | switch strings.ToLower(direction) { | ||
| 115 | - | case "up", "down": | 115 | + | case DirectionUp, DirectionDown: |
| 116 | // Check if current pane is part of a stack and handle stack navigation | 116 | // Check if current pane is part of a stack and handle stack navigation | ||
| 117 | if wm.stackedPaneManager.NavigateStack(strings.ToLower(direction)) { | 117 | if wm.stackedPaneManager.NavigateStack(strings.ToLower(direction)) { | ||
| 118 | return true | 118 | return true | ||
| 119 | } | 119 | } | ||
| 120 | // Fall back to regular adjacency navigation | 120 | // Fall back to regular adjacency navigation | ||
| 121 | return wm.focusAdjacent(strings.ToLower(direction)) | 121 | return wm.focusAdjacent(strings.ToLower(direction)) | ||
| 122 | - | case "left", "right": | 122 | + | case DirectionLeft, DirectionRight: |
| 123 | return wm.focusAdjacent(strings.ToLower(direction)) | 123 | return wm.focusAdjacent(strings.ToLower(direction)) | ||
| 124 | default: | 124 | default: | ||
| 125 | log.Printf("[workspace] invalid focus direction: %s (expected: up, down, left, right)", direction) | 125 | log.Printf("[workspace] invalid focus direction: %s (expected: up, down, left, right)", direction) | ||
| @@ -169,12 +169,12 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool { | |||||
| 169 | // Calculate new index based on direction | 169 | // Calculate new index based on direction | ||
| 170 | var newIndex int | 170 | var newIndex int | ||
| 171 | switch direction { | 171 | switch direction { | ||
| 172 | - | case "up": | 172 | + | case DirectionUp: |
| 173 | newIndex = currentIndex - 1 | 173 | newIndex = currentIndex - 1 | ||
| 174 | if newIndex < 0 { | 174 | if newIndex < 0 { | ||
| 175 | newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last | 175 | newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last | ||
| 176 | } | 176 | } | ||
| 177 | - | case "down": | 177 | + | case DirectionDown: |
| 178 | newIndex = currentIndex + 1 | 178 | newIndex = currentIndex + 1 | ||
| 179 | if newIndex >= len(stackNode.stackedPanes) { | 179 | if newIndex >= len(stackNode.stackedPanes) { | ||
| 180 | newIndex = 0 // Wrap to first | 180 | newIndex = 0 // Wrap to first | ||
| @@ -235,29 +235,29 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||||
| 235 | 235 | ||||
| 236 | var score float64 | 236 | var score float64 | ||
| 237 | switch direction { | 237 | switch direction { | ||
| 238 | - | case "left": | 238 | + | case DirectionLeft: |
| 239 | if dx >= -focusEpsilon { | 239 | if dx >= -focusEpsilon { | ||
| 240 | continue | 240 | continue | ||
| 241 | } | 241 | } | ||
| 242 | score = math.Abs(dx)*1000 + math.Abs(dy) | 242 | score = math.Abs(dx)*1000 + math.Abs(dy) | ||
| 243 | - | case "right": | 243 | + | case DirectionRight: |
| 244 | if dx <= focusEpsilon { | 244 | if dx <= focusEpsilon { | ||
| 245 | continue | 245 | continue | ||
| 246 | } | 246 | } | ||
| 247 | score = math.Abs(dx)*1000 + math.Abs(dy) | 247 | score = math.Abs(dx)*1000 + math.Abs(dy) | ||
| 248 | - | case "up": | 248 | + | case DirectionUp: |
| 249 | if dy >= -focusEpsilon { | 249 | if dy >= -focusEpsilon { | ||
| 250 | continue | 250 | continue | ||
| 251 | } | 251 | } | ||
| 252 | score = math.Abs(dy)*1000 + math.Abs(dx) | 252 | score = math.Abs(dy)*1000 + math.Abs(dx) | ||
| 253 | - | case "down": | 253 | + | case DirectionDown: |
| 254 | if dy <= focusEpsilon { | 254 | if dy <= focusEpsilon { | ||
| 255 | continue | 255 | continue | ||
| 256 | } | 256 | } | ||
| 257 | score = math.Abs(dy)*1000 + math.Abs(dx) | 257 | score = math.Abs(dy)*1000 + math.Abs(dx) | ||
| 258 | } | 258 | } | ||
| 259 | 259 | ||||
| 260 | - | if direction == "up" || direction == "down" { | 260 | + | if direction == DirectionUp || direction == DirectionDown { |
| 261 | debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score)) | 261 | debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score)) | ||
| 262 | } | 262 | } | ||
| 263 | 263 | ||||
| @@ -287,29 +287,29 @@ func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) | |||||
| 287 | refX, refY, refWidth, refHeight := webkit.WidgetGetAllocation(node.container) | 287 | refX, refY, refWidth, refHeight := webkit.WidgetGetAllocation(node.container) | ||
| 288 | cx := float64(refX) + float64(refWidth)/2.0 | 288 | cx := float64(refX) + float64(refWidth)/2.0 | ||
| 289 | cy := float64(refY) + float64(refHeight)/2.0 | 289 | cy := float64(refY) + float64(refHeight)/2.0 | ||
| 290 | - | axisVertical := direction == "up" || direction == "down" | 290 | + | axisVertical := direction == DirectionUp || direction == DirectionDown |
| 291 | 291 | ||||
| 292 | for parent := node.parent; parent != nil; parent = parent.parent { | 292 | for parent := node.parent; parent != nil; parent = parent.parent { | ||
| 293 | switch direction { | 293 | switch direction { | ||
| 294 | - | case "up": | 294 | + | case DirectionUp: |
| 295 | if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node { | 295 | if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node { | ||
| 296 | if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil { | 296 | if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil { | ||
| 297 | return leaf | 297 | return leaf | ||
| 298 | } | 298 | } | ||
| 299 | } | 299 | } | ||
| 300 | - | case "down": | 300 | + | case DirectionDown: |
| 301 | if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node { | 301 | if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node { | ||
| 302 | if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil { | 302 | if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil { | ||
| 303 | return leaf | 303 | return leaf | ||
| 304 | } | 304 | } | ||
| 305 | } | 305 | } | ||
| 306 | - | case "left": | 306 | + | case DirectionLeft: |
| 307 | if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.right == node { | 307 | if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.right == node { | ||
| 308 | if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil { | 308 | if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil { | ||
| 309 | return leaf | 309 | return leaf | ||
| 310 | } | 310 | } | ||
| 311 | } | 311 | } | ||
| 312 | - | case "right": | 312 | + | case DirectionRight: |
| 313 | if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.left == node { | 313 | if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.left == node { | ||
| 314 | if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil { | 314 | if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil { | ||
| 315 | return leaf | 315 | return leaf | ||
| @@ -338,22 +338,22 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6 | |||||
| 338 | dy := ty - cy | 338 | dy := ty - cy | ||
| 339 | var score float64 | 339 | var score float64 | ||
| 340 | switch direction { | 340 | switch direction { | ||
| 341 | - | case "left": | 341 | + | case DirectionLeft: |
| 342 | if dx >= -focusEpsilon { | 342 | if dx >= -focusEpsilon { | ||
| 343 | continue | 343 | continue | ||
| 344 | } | 344 | } | ||
| 345 | score = math.Abs(dx)*1000 + math.Abs(dy) | 345 | score = math.Abs(dx)*1000 + math.Abs(dy) | ||
| 346 | - | case "right": | 346 | + | case DirectionRight: |
| 347 | if dx <= focusEpsilon { | 347 | if dx <= focusEpsilon { | ||
| 348 | continue | 348 | continue | ||
| 349 | } | 349 | } | ||
| 350 | score = math.Abs(dx)*1000 + math.Abs(dy) | 350 | score = math.Abs(dx)*1000 + math.Abs(dy) | ||
| 351 | - | case "up": | 351 | + | case DirectionUp: |
| 352 | if dy >= -focusEpsilon { | 352 | if dy >= -focusEpsilon { | ||
| 353 | continue | 353 | continue | ||
| 354 | } | 354 | } | ||
| 355 | score = math.Abs(dy)*1000 + math.Abs(dx) | 355 | score = math.Abs(dy)*1000 + math.Abs(dx) | ||
| 356 | - | case "down": | 356 | + | case DirectionDown: |
| 357 | if dy <= focusEpsilon { | 357 | if dy <= focusEpsilon { | ||
| 358 | continue | 358 | continue | ||
| 359 | } | 359 | } | ||
| @@ -393,22 +393,22 @@ func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction | |||||
| 393 | return node | 393 | return node | ||
| 394 | } | 394 | } | ||
| 395 | switch direction { | 395 | switch direction { | ||
| 396 | - | case "up": | 396 | + | case DirectionUp: |
| 397 | if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil { | 397 | if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil { | ||
| 398 | return leaf | 398 | return leaf | ||
| 399 | } | 399 | } | ||
| 400 | return wm.boundaryFallbackWithDepth(node.left, direction, depth+1) | 400 | return wm.boundaryFallbackWithDepth(node.left, direction, depth+1) | ||
| 401 | - | case "down": | 401 | + | case DirectionDown: |
| 402 | if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil { | 402 | if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil { | ||
| 403 | return leaf | 403 | return leaf | ||
| 404 | } | 404 | } | ||
| 405 | return wm.boundaryFallbackWithDepth(node.right, direction, depth+1) | 405 | return wm.boundaryFallbackWithDepth(node.right, direction, depth+1) | ||
| 406 | - | case "left": | 406 | + | case DirectionLeft: |
| 407 | if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil { | 407 | if leaf := wm.boundaryFallbackWithDepth(node.right, direction, depth+1); leaf != nil { | ||
| 408 | return leaf | 408 | return leaf | ||
| 409 | } | 409 | } | ||
| 410 | return wm.boundaryFallbackWithDepth(node.left, direction, depth+1) | 410 | return wm.boundaryFallbackWithDepth(node.left, direction, depth+1) | ||
| 411 | - | case "right": | 411 | + | case DirectionRight: |
| 412 | if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil { | 412 | if leaf := wm.boundaryFallbackWithDepth(node.left, direction, depth+1); leaf != nil { | ||
| 413 | return leaf | 413 | return leaf | ||
| 414 | } | 414 | } | ||
M internal/app/browser/workspace_navigation_test.go +8 -8
| @@ -77,10 +77,10 @@ func TestMapDirection(t *testing.T) { | |||||
| 77 | orientation gtk.Orientation | 77 | orientation gtk.Orientation | ||
| 78 | existingFirst bool | 78 | existingFirst bool | ||
| 79 | }{ | 79 | }{ | ||
| 80 | - | {"down", gtk.OrientationVertical, true}, | 80 | + | {DirectionDown, gtk.OrientationVertical, true}, |
| 81 | - | {"up", gtk.OrientationVertical, false}, | 81 | + | {DirectionUp, gtk.OrientationVertical, false}, |
| 82 | - | {"right", gtk.OrientationHorizontal, true}, | 82 | + | {DirectionRight, gtk.OrientationHorizontal, true}, |
| 83 | - | {"left", gtk.OrientationHorizontal, false}, | 83 | + | {DirectionLeft, gtk.OrientationHorizontal, false}, |
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| 86 | for _, tt := range tests { | 86 | for _, tt := range tests { | ||
| @@ -131,10 +131,10 @@ func TestCaseSensitiveDirections(t *testing.T) { | |||||
| 131 | "Down", | 131 | "Down", | ||
| 132 | "LEFT", | 132 | "LEFT", | ||
| 133 | "Right", | 133 | "Right", | ||
| 134 | - | "up", | 134 | + | DirectionUp, |
| 135 | - | "down", | 135 | + | DirectionDown, |
| 136 | - | "left", | 136 | + | DirectionLeft, |
| 137 | - | "right", | 137 | + | DirectionRight, |
| 138 | } | 138 | } | ||
| 139 | 139 | ||||
| 140 | for _, direction := range validDirections { | 140 | for _, direction := range validDirections { | ||
M internal/app/browser/workspace_operations.go +4 -4
| @@ -16,10 +16,10 @@ func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*pane | |||||
| 16 | 16 | ||||
| 17 | // Validate direction parameter | 17 | // Validate direction parameter | ||
| 18 | validDirections := map[string]bool{ | 18 | validDirections := map[string]bool{ | ||
| 19 | - | "left": true, | 19 | + | DirectionLeft: true, |
| 20 | - | "right": true, | 20 | + | DirectionRight: true, |
| 21 | - | "up": true, | 21 | + | DirectionUp: true, |
| 22 | - | "down": true, | 22 | + | DirectionDown: true, |
| 23 | } | 23 | } | ||
| 24 | if !validDirections[direction] { | 24 | if !validDirections[direction] { | ||
| 25 | return nil, fmt.Errorf("invalid split direction '%s', expected one of: left, right, up, down", direction) | 25 | return nil, fmt.Errorf("invalid split direction '%s', expected one of: left, right, up, down", direction) | ||
M internal/app/browser/workspace_pane_mode.go +5 -5
| @@ -124,17 +124,17 @@ func (wm *WorkspaceManager) handlePaneSplit(node *paneNode, action string) { | |||||
| 124 | return | 124 | return | ||
| 125 | } | 125 | } | ||
| 126 | 126 | ||||
| 127 | - | // Extract direction from action (e.g., "split-right" -> "right") | 127 | + | // Extract direction from action (e.g., "split-right" -> DirectionRight) |
| 128 | var direction string | 128 | var direction string | ||
| 129 | switch action { | 129 | switch action { | ||
| 130 | case "split-right": | 130 | case "split-right": | ||
| 131 | - | direction = "right" | 131 | + | direction = DirectionRight |
| 132 | case "split-left": | 132 | case "split-left": | ||
| 133 | - | direction = "left" | 133 | + | direction = DirectionLeft |
| 134 | case "split-up": | 134 | case "split-up": | ||
| 135 | - | direction = "up" | 135 | + | direction = DirectionUp |
| 136 | case "split-down": | 136 | case "split-down": | ||
| 137 | - | direction = "down" | 137 | + | direction = DirectionDown |
| 138 | default: | 138 | default: | ||
| 139 | log.Printf("[pane-mode] Invalid split action: %s", action) | 139 | log.Printf("[pane-mode] Invalid split action: %s", action) | ||
| 140 | return | 140 | return | ||
M internal/app/browser/workspace_pane_ops.go +4 -0
| @@ -299,6 +299,8 @@ func (wm *WorkspaceManager) removeFromAppPanes(pane *BrowserPane) { | |||||
| 299 | // This eliminates widget reparenting of existing WebViews, which was causing rendering corruption. | 299 | // This eliminates widget reparenting of existing WebViews, which was causing rendering corruption. | ||
| 300 | // See splitNode() for the unified implementation. | 300 | // See splitNode() for the unified implementation. | ||
| 301 | 301 | ||||
| 302 | + | // TODO(#8): Refactor this function to reduce cyclomatic complexity (65 -> <30) | |||
| 303 | + | // | |||
| 302 | // splitNode splits a target pane into two panes in the specified direction | 304 | // splitNode splits a target pane into two panes in the specified direction | ||
| 303 | // splitNode splits a target pane into two panes in the specified direction. | 305 | // splitNode splits a target pane into two panes in the specified direction. | ||
| 304 | // If existingPane is provided, it will be used instead of creating a new WebView. | 306 | // If existingPane is provided, it will be used instead of creating a new WebView. | ||
| @@ -922,6 +924,8 @@ func (wm *WorkspaceManager) setFocusToLeaf(node *paneNode) { | |||||
| 922 | } | 924 | } | ||
| 923 | } | 925 | } | ||
| 924 | 926 | ||||
| 927 | + | // TODO(#8): Refactor this function to reduce cyclomatic complexity (31 -> <30) | |||
| 928 | + | // | |||
| 925 | // closePane closes a specific pane and handles tree restructuring (simplified implementation) | 929 | // closePane closes a specific pane and handles tree restructuring (simplified implementation) | ||
| 926 | func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | 930 | func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | ||
| 927 | ctx := wm.beginClose(node) | 931 | ctx := wm.beginClose(node) | ||
M internal/app/browser/workspace_popup.go +7 -7
| @@ -37,7 +37,7 @@ func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView | |||||
| 37 | } | 37 | } | ||
| 38 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | 38 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | ||
| 39 | if direction == "" { | 39 | if direction == "" { | ||
| 40 | - | direction = "right" | 40 | + | direction = DirectionRight |
| 41 | } | 41 | } | ||
| 42 | // Use splitNode with existing pane instead of insertPopupPane | 42 | // Use splitNode with existing pane instead of insertPopupPane | ||
| 43 | if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil { | 43 | if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil { | ||
| @@ -65,7 +65,7 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView | |||||
| 65 | } | 65 | } | ||
| 66 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | 66 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | ||
| 67 | if direction == "" { | 67 | if direction == "" { | ||
| 68 | - | direction = "right" | 68 | + | direction = DirectionRight |
| 69 | } | 69 | } | ||
| 70 | // Use splitNode with existing pane instead of insertPopupPane | 70 | // Use splitNode with existing pane instead of insertPopupPane | ||
| 71 | if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil { | 71 | if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil { | ||
| @@ -427,7 +427,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) { | |||||
| 427 | // Split pane behavior (default) - use splitNode with existing popup pane | 427 | // Split pane behavior (default) - use splitNode with existing popup pane | ||
| 428 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | 428 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | ||
| 429 | if direction == "" { | 429 | if direction == "" { | ||
| 430 | - | direction = "right" | 430 | + | direction = DirectionRight |
| 431 | } | 431 | } | ||
| 432 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | 432 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | ||
| 433 | log.Printf("[workspace] Failed to insert split popup pane: %v", err) | 433 | log.Printf("[workspace] Failed to insert split popup pane: %v", err) | ||
| @@ -444,7 +444,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) { | |||||
| 444 | // Insert the popup pane in the same container - use splitNode with existing popup pane | 444 | // Insert the popup pane in the same container - use splitNode with existing popup pane | ||
| 445 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | 445 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | ||
| 446 | if direction == "" { | 446 | if direction == "" { | ||
| 447 | - | direction = "right" | 447 | + | direction = DirectionRight |
| 448 | } | 448 | } | ||
| 449 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | 449 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | ||
| 450 | log.Printf("[workspace] Failed to insert stacked popup pane: %v", err) | 450 | log.Printf("[workspace] Failed to insert stacked popup pane: %v", err) | ||
| @@ -456,7 +456,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) { | |||||
| 456 | log.Printf("[workspace] WARNING: Tabbed popup behavior not yet implemented, falling back to split") | 456 | log.Printf("[workspace] WARNING: Tabbed popup behavior not yet implemented, falling back to split") | ||
| 457 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | 457 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | ||
| 458 | if direction == "" { | 458 | if direction == "" { | ||
| 459 | - | direction = "right" | 459 | + | direction = DirectionRight |
| 460 | } | 460 | } | ||
| 461 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | 461 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | ||
| 462 | log.Printf("[workspace] Failed to insert tabbed popup pane: %v", err) | 462 | log.Printf("[workspace] Failed to insert tabbed popup pane: %v", err) | ||
| @@ -469,7 +469,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) { | |||||
| 469 | log.Printf("[workspace] WARNING: Windowed popup behavior not yet implemented, falling back to split") | 469 | log.Printf("[workspace] WARNING: Windowed popup behavior not yet implemented, falling back to split") | ||
| 470 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | 470 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | ||
| 471 | if direction == "" { | 471 | if direction == "" { | ||
| 472 | - | direction = "right" | 472 | + | direction = DirectionRight |
| 473 | } | 473 | } | ||
| 474 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | 474 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | ||
| 475 | log.Printf("[workspace] Failed to insert windowed popup pane: %v", err) | 475 | log.Printf("[workspace] Failed to insert windowed popup pane: %v", err) | ||
| @@ -481,7 +481,7 @@ func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) { | |||||
| 481 | log.Printf("[workspace] Unknown popup behavior '%s', falling back to split", behavior) | 481 | log.Printf("[workspace] Unknown popup behavior '%s', falling back to split", behavior) | ||
| 482 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | 482 | direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) | ||
| 483 | if direction == "" { | 483 | if direction == "" { | ||
| 484 | - | direction = "right" | 484 | + | direction = DirectionRight |
| 485 | } | 485 | } | ||
| 486 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | 486 | if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { | ||
| 487 | log.Printf("[workspace] Failed to insert popup pane: %v", err) | 487 | log.Printf("[workspace] Failed to insert popup pane: %v", err) | ||
M internal/app/browser/workspace_utils.go +3 -3
| @@ -35,11 +35,11 @@ func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) { | |||||
| 35 | // mapDirection maps a direction string to GTK orientation and positioning | 35 | // mapDirection maps a direction string to GTK orientation and positioning | ||
| 36 | func mapDirection(direction string) (gtk.Orientation, bool) { | 36 | func mapDirection(direction string) (gtk.Orientation, bool) { | ||
| 37 | switch direction { | 37 | switch direction { | ||
| 38 | - | case "left": | 38 | + | case DirectionLeft: |
| 39 | return gtk.OrientationHorizontal, false | 39 | return gtk.OrientationHorizontal, false | ||
| 40 | - | case "up": | 40 | + | case DirectionUp: |
| 41 | return gtk.OrientationVertical, false | 41 | return gtk.OrientationVertical, false | ||
| 42 | - | case "down": | 42 | + | case DirectionDown: |
| 43 | return gtk.OrientationVertical, true | 43 | return gtk.OrientationVertical, true | ||
| 44 | default: | 44 | default: | ||
| 45 | return gtk.OrientationHorizontal, true | 45 | return gtk.OrientationHorizontal, true | ||
M internal/app/control/navigation.go +6 -2
| @@ -55,7 +55,9 @@ func (n *NavigationController) NavigateToURL(input string) error { | |||||
| 55 | haveZoom = true | 55 | haveZoom = true | ||
| 56 | if n.webView != nil { | 56 | if n.webView != nil { | ||
| 57 | if n.webView.UsesDomZoom() { | 57 | if n.webView.UsesDomZoom() { | ||
| 58 | - | n.webView.SeedDomZoom(zoomLevel) | 58 | + | if err := n.webView.SeedDomZoom(zoomLevel); err != nil { |
| 59 | + | log.Printf("Warning: failed to seed DOM zoom for %s: %v", result.URL, err) | |||
| 60 | + | } | |||
| 59 | } else { | 61 | } else { | ||
| 60 | n.webView.RunOnMainThread(func() { | 62 | n.webView.RunOnMainThread(func() { | ||
| 61 | if err := n.webView.SetZoom(zoomLevel); err != nil { | 63 | if err := n.webView.SetZoom(zoomLevel); err != nil { | ||
| @@ -108,7 +110,9 @@ func (n *NavigationController) HandleBrowseCommand() { | |||||
| 108 | zoomLevel = level | 110 | zoomLevel = level | ||
| 109 | haveZoom = true | 111 | haveZoom = true | ||
| 110 | if n.webView.UsesDomZoom() { | 112 | if n.webView.UsesDomZoom() { | ||
| 111 | - | n.webView.SeedDomZoom(zoomLevel) | 113 | + | if err := n.webView.SeedDomZoom(zoomLevel); err != nil { |
| 114 | + | log.Printf("Warning: failed to seed DOM zoom for %s: %v", result.URL, err) | |||
| 115 | + | } | |||
| 112 | } else { | 116 | } else { | ||
| 113 | n.webView.RunOnMainThread(func() { | 117 | n.webView.RunOnMainThread(func() { | ||
| 114 | if err := n.webView.SetZoom(zoomLevel); err != nil { | 118 | if err := n.webView.SetZoom(zoomLevel); err != nil { | ||
M internal/app/messaging/handler.go +6 -25
| @@ -82,9 +82,7 @@ func (h *Handler) Handle(payload string) { | |||||
| 82 | case "navigate": | 82 | case "navigate": | ||
| 83 | h.handleNavigation(msg) | 83 | h.handleNavigation(msg) | ||
| 84 | case "query": | 84 | case "query": | ||
| 85 | - | log.Printf("[DEBUG] Handling query message: q=%s, limit=%d", msg.Q, msg.Limit) | |||
| 86 | if h.webView == nil { | 85 | if h.webView == nil { | ||
| 87 | - | log.Printf("[WARN] Dropping query response: webview unavailable for suggestions") | |||
| 88 | return | 86 | return | ||
| 89 | } | 87 | } | ||
| 90 | h.handleQuery(msg) | 88 | h.handleQuery(msg) | ||
| @@ -200,13 +198,7 @@ func (h *Handler) handleClosePopup(msg Message) { | |||||
| 200 | } | 198 | } | ||
| 201 | 199 | ||||
| 202 | func (h *Handler) handleQuery(msg Message) { | 200 | func (h *Handler) handleQuery(msg Message) { | ||
| 203 | - | if h.webView == nil { | 201 | + | if h.webView == nil || h.browserService == nil { |
| 204 | - | log.Printf("[WARN] Skipping query handling: webview is nil") | |||
| 205 | - | return | |||
| 206 | - | } | |||
| 207 | - | log.Printf("[DEBUG] handleQuery called: q='%s', limit=%d", msg.Q, msg.Limit) | |||
| 208 | - | if h.browserService == nil { | |||
| 209 | - | log.Printf("[ERROR] handleQuery: browserService is nil") | |||
| 210 | return | 202 | return | ||
| 211 | } | 203 | } | ||
| 212 | ctx := context.Background() | 204 | ctx := context.Background() | ||
| @@ -233,12 +225,8 @@ func (h *Handler) handleQuery(msg Message) { | |||||
| 233 | // History | 225 | // History | ||
| 234 | if len(results) < limit { | 226 | if len(results) < limit { | ||
| 235 | remaining := limit - len(results) | 227 | remaining := limit - len(results) | ||
| 236 | - | log.Printf("[DEBUG] Searching history for '%s' with limit %d", q, remaining) | 228 | + | if entries, err := h.browserService.SearchHistory(ctx, q, remaining); err == nil { |
| 237 | - | if entries, err := h.browserService.SearchHistory(ctx, q, remaining); err != nil { | 229 | + | for _, e := range entries { |
| 238 | - | log.Printf("[ERROR] Failed to search history: %v", err) | |||
| 239 | - | } else { | |||
| 240 | - | log.Printf("[DEBUG] Found %d history entries", len(entries)) | |||
| 241 | - | for i, e := range entries { | |||
| 242 | // JSON roundtrip to map to get url field agnostic of struct tag | 230 | // JSON roundtrip to map to get url field agnostic of struct tag | ||
| 243 | bb, _ := json.Marshal(e) | 231 | bb, _ := json.Marshal(e) | ||
| 244 | var m map[string]any | 232 | var m map[string]any | ||
| @@ -249,7 +237,6 @@ func (h *Handler) handleQuery(msg Message) { | |||||
| 249 | } else if s, ok := m["URL"].(string); ok { | 237 | } else if s, ok := m["URL"].(string); ok { | ||
| 250 | url = s | 238 | url = s | ||
| 251 | } | 239 | } | ||
| 252 | - | log.Printf("[DEBUG] History entry %d: url=%s", i, url) | |||
| 253 | if url == "" { | 240 | if url == "" { | ||
| 254 | continue | 241 | continue | ||
| 255 | } | 242 | } | ||
| @@ -271,17 +258,11 @@ func (h *Handler) handleQuery(msg Message) { | |||||
| 271 | } | 258 | } | ||
| 272 | 259 | ||||
| 273 | // Inject back to GUI | 260 | // Inject back to GUI | ||
| 274 | - | log.Printf("[DEBUG] Final results count: %d", len(results)) | 261 | + | if b, err := json.Marshal(results); err == nil { |
| 275 | - | if b, err := json.Marshal(results); err != nil { | |||
| 276 | - | log.Printf("[ERROR] Failed to marshal results: %v", err) | |||
| 277 | - | } else { | |||
| 278 | // Prefer unified page-world API; fallback to legacy global function | 262 | // Prefer unified page-world API; fallback to legacy global function | ||
| 279 | script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))" | 263 | script := "(window.__dumber?.omnibox?.suggestions ? window.__dumber.omnibox.suggestions(" + string(b) + ") : (window.__dumber_omnibox_suggestions && window.__dumber_omnibox_suggestions(" + string(b) + ")))" | ||
| 280 | - | log.Print("[DEBUG] Injecting omnibox suggestions") | 264 | + | if err := h.webView.InjectScript(script); err != nil { |
| 281 | - | if injErr := h.webView.InjectScript(script); injErr != nil { | 265 | + | log.Printf("[ERROR] Failed to inject omnibox suggestions: %v", err) |
| 282 | - | log.Printf("[ERROR] Failed to inject suggestions script: %v", injErr) | |||
| 283 | - | } else { | |||
| 284 | - | log.Printf("[DEBUG] Successfully injected omnibox suggestions") | |||
| 285 | } | 266 | } | ||
| 286 | } | 267 | } | ||
| 287 | } | 268 | } | ||
A internal/cache/cert_cache.go +78 -0
| @@ -0,0 +1,78 @@ | |||||
| 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 | + | } | |||
M internal/cache/filter_cache.go +45 -13
| @@ -89,7 +89,11 @@ func (fc *FilterCache) LoadMapped() (*FilterData, error) { | |||||
| 89 | if err != nil { | 89 | if err != nil { | ||
| 90 | return nil, fmt.Errorf("failed to open cache file: %w", err) | 90 | return nil, fmt.Errorf("failed to open cache file: %w", err) | ||
| 91 | } | 91 | } | ||
| 92 | - | defer file.Close() | 92 | + | defer func() { |
| 93 | + | if err := file.Close(); err != nil { | |||
| 94 | + | logging.Warn(fmt.Sprintf("failed to close cache file: %v", err)) | |||
| 95 | + | } | |||
| 96 | + | }() | |||
| 93 | 97 | ||||
| 94 | // Get file size | 98 | // Get file size | ||
| 95 | stat, err := file.Stat() | 99 | stat, err := file.Stat() | ||
| @@ -135,7 +139,9 @@ func (fc *FilterCache) LoadMapped() (*FilterData, error) { | |||||
| 135 | // Parse filter data without copying | 139 | // Parse filter data without copying | ||
| 136 | data, err := fc.parseInPlace(header) | 140 | data, err := fc.parseInPlace(header) | ||
| 137 | if err != nil { | 141 | if err != nil { | ||
| 138 | - | fc.Unmap() | 142 | + | if unmapErr := fc.Unmap(); unmapErr != nil { |
| 143 | + | logging.Warn(fmt.Sprintf("failed to unmap during error handling: %v", unmapErr)) | |||
| 144 | + | } | |||
| 139 | return nil, fmt.Errorf("failed to parse cache data: %w", err) | 145 | return nil, fmt.Errorf("failed to parse cache data: %w", err) | ||
| 140 | } | 146 | } | ||
| 141 | 147 | ||||
| @@ -221,7 +227,11 @@ func (fc *FilterCache) Write(data *FilterData) error { | |||||
| 221 | if err != nil { | 227 | if err != nil { | ||
| 222 | return fmt.Errorf("failed to create cache file: %w", err) | 228 | return fmt.Errorf("failed to create cache file: %w", err) | ||
| 223 | } | 229 | } | ||
| 224 | - | defer file.Close() | 230 | + | defer func() { |
| 231 | + | if err := file.Close(); err != nil { | |||
| 232 | + | logging.Warn(fmt.Sprintf("failed to close cache file: %v", err)) | |||
| 233 | + | } | |||
| 234 | + | }() | |||
| 225 | 235 | ||||
| 226 | // Calculate total rule count | 236 | // Calculate total rule count | ||
| 227 | totalRules := uint32(len(data.NetworkRules) + len(data.CosmeticRules)) | 237 | totalRules := uint32(len(data.NetworkRules) + len(data.CosmeticRules)) | ||
| @@ -237,7 +247,9 @@ func (fc *FilterCache) Write(data *FilterData) error { | |||||
| 237 | } | 247 | } | ||
| 238 | 248 | ||||
| 239 | if err := binary.Write(file, binary.LittleEndian, header); err != nil { | 249 | if err := binary.Write(file, binary.LittleEndian, header); err != nil { | ||
| 240 | - | os.Remove(tmpPath) | 250 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { |
| 251 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |||
| 252 | + | } | |||
| 241 | return fmt.Errorf("failed to write header: %w", err) | 253 | return fmt.Errorf("failed to write header: %w", err) | ||
| 242 | } | 254 | } | ||
| 243 | 255 | ||||
| @@ -246,7 +258,9 @@ func (fc *FilterCache) Write(data *FilterData) error { | |||||
| 246 | // Write network rules | 258 | // Write network rules | ||
| 247 | for _, rule := range data.NetworkRules { | 259 | for _, rule := range data.NetworkRules { | ||
| 248 | if err := fc.writeNetworkRule(file, rule); err != nil { | 260 | if err := fc.writeNetworkRule(file, rule); err != nil { | ||
| 249 | - | os.Remove(tmpPath) | 261 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { |
| 262 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |||
| 263 | + | } | |||
| 250 | return fmt.Errorf("failed to write network rule: %w", err) | 264 | return fmt.Errorf("failed to write network rule: %w", err) | ||
| 251 | } | 265 | } | ||
| 252 | } | 266 | } | ||
| @@ -254,7 +268,9 @@ func (fc *FilterCache) Write(data *FilterData) error { | |||||
| 254 | // Write cosmetic rules | 268 | // Write cosmetic rules | ||
| 255 | for _, rule := range data.CosmeticRules { | 269 | for _, rule := range data.CosmeticRules { | ||
| 256 | if err := fc.writeCosmeticRule(file, rule); err != nil { | 270 | if err := fc.writeCosmeticRule(file, rule); err != nil { | ||
| 257 | - | os.Remove(tmpPath) | 271 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { |
| 272 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |||
| 273 | + | } | |||
| 258 | return fmt.Errorf("failed to write cosmetic rule: %w", err) | 274 | return fmt.Errorf("failed to write cosmetic rule: %w", err) | ||
| 259 | } | 275 | } | ||
| 260 | } | 276 | } | ||
| @@ -265,7 +281,9 @@ func (fc *FilterCache) Write(data *FilterData) error { | |||||
| 265 | 281 | ||||
| 266 | // Update header with data size | 282 | // Update header with data size | ||
| 267 | if _, err := file.Seek(0, io.SeekStart); err != nil { | 283 | if _, err := file.Seek(0, io.SeekStart); err != nil { | ||
| 268 | - | os.Remove(tmpPath) | 284 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { |
| 285 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |||
| 286 | + | } | |||
| 269 | return err | 287 | return err | ||
| 270 | } | 288 | } | ||
| 271 | 289 | ||||
| @@ -273,20 +291,28 @@ func (fc *FilterCache) Write(data *FilterData) error { | |||||
| 273 | // TODO: Calculate and set checksum | 291 | // TODO: Calculate and set checksum | ||
| 274 | 292 | ||||
| 275 | if err := binary.Write(file, binary.LittleEndian, header); err != nil { | 293 | if err := binary.Write(file, binary.LittleEndian, header); err != nil { | ||
| 276 | - | os.Remove(tmpPath) | 294 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { |
| 295 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |||
| 296 | + | } | |||
| 277 | return fmt.Errorf("failed to update header: %w", err) | 297 | return fmt.Errorf("failed to update header: %w", err) | ||
| 278 | } | 298 | } | ||
| 279 | 299 | ||||
| 280 | if err := file.Sync(); err != nil { | 300 | if err := file.Sync(); err != nil { | ||
| 281 | - | os.Remove(tmpPath) | 301 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { |
| 302 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |||
| 303 | + | } | |||
| 282 | return fmt.Errorf("failed to sync cache file: %w", err) | 304 | return fmt.Errorf("failed to sync cache file: %w", err) | ||
| 283 | } | 305 | } | ||
| 284 | 306 | ||||
| 285 | - | file.Close() | 307 | + | if err := file.Close(); err != nil { |
| 308 | + | logging.Warn(fmt.Sprintf("failed to close cache file: %v", err)) | |||
| 309 | + | } | |||
| 286 | 310 | ||||
| 287 | // Atomic rename | 311 | // Atomic rename | ||
| 288 | if err := os.Rename(tmpPath, fc.path); err != nil { | 312 | if err := os.Rename(tmpPath, fc.path); err != nil { | ||
| 289 | - | os.Remove(tmpPath) | 313 | + | if removeErr := os.Remove(tmpPath); removeErr != nil { |
| 314 | + | logging.Warn(fmt.Sprintf("failed to remove temp cache file: %v", removeErr)) | |||
| 315 | + | } | |||
| 290 | return fmt.Errorf("failed to rename cache file: %w", err) | 316 | return fmt.Errorf("failed to rename cache file: %w", err) | ||
| 291 | } | 317 | } | ||
| 292 | 318 | ||||
| @@ -382,7 +408,11 @@ func (fc *FilterCache) IsValid() bool { | |||||
| 382 | if err != nil { | 408 | if err != nil { | ||
| 383 | return false | 409 | return false | ||
| 384 | } | 410 | } | ||
| 385 | - | defer file.Close() | 411 | + | defer func() { |
| 412 | + | if err := file.Close(); err != nil { | |||
| 413 | + | logging.Warn(fmt.Sprintf("failed to close file during validation: %v", err)) | |||
| 414 | + | } | |||
| 415 | + | }() | |||
| 386 | 416 | ||||
| 387 | var magic uint64 | 417 | var magic uint64 | ||
| 388 | if err := binary.Read(file, binary.LittleEndian, &magic); err != nil { | 418 | if err := binary.Read(file, binary.LittleEndian, &magic); err != nil { | ||
| @@ -394,6 +424,8 @@ func (fc *FilterCache) IsValid() bool { | |||||
| 394 | 424 | ||||
| 395 | // Remove deletes the cache file | 425 | // Remove deletes the cache file | ||
| 396 | func (fc *FilterCache) Remove() error { | 426 | func (fc *FilterCache) Remove() error { | ||
| 397 | - | fc.Unmap() // Unmap first if mapped | 427 | + | if err := fc.Unmap(); err != nil { |
| 428 | + | logging.Warn(fmt.Sprintf("failed to unmap file during removal: %v", err)) | |||
| 429 | + | } | |||
| 398 | return os.Remove(fc.path) | 430 | return os.Remove(fc.path) | ||
| 399 | } | 431 | } | ||
M internal/cache/fuzzy_cache.go +2 -2
| @@ -2,7 +2,7 @@ package cache | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "crypto/md5" | 5 | + | "crypto/sha256" |
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| @@ -286,7 +286,7 @@ func (cm *CacheManager) calculateDBHash(ctx context.Context) (string, error) { | |||||
| 286 | } | 286 | } | ||
| 287 | 287 | ||||
| 288 | // Create hash from URLs, visit counts, and last visited times | 288 | // Create hash from URLs, visit counts, and last visited times | ||
| 289 | - | hasher := md5.New() | 289 | + | hasher := sha256.New() |
| 290 | for _, entry := range recentHistory { | 290 | for _, entry := range recentHistory { | ||
| 291 | hasher.Write([]byte(entry.Url)) | 291 | hasher.Write([]byte(entry.Url)) | ||
| 292 | if entry.VisitCount.Valid { | 292 | if entry.VisitCount.Valid { | ||
M internal/cache/fuzzy_cache_test.go +1 -1
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | 10 | ||||
| 11 | mock_cache "github.com/bnema/dumber/internal/cache/mocks" | 11 | mock_cache "github.com/bnema/dumber/internal/cache/mocks" | ||
| 12 | "github.com/bnema/dumber/internal/db" | 12 | "github.com/bnema/dumber/internal/db" | ||
| 13 | - | "github.com/golang/mock/gomock" | 13 | + | "go.uber.org/mock/gomock" |
| 14 | ) | 14 | ) | ||
| 15 | 15 | ||||
| 16 | func createTestHistory() []db.History { | 16 | func createTestHistory() []db.History { | ||
A internal/cache/generic/README.md +244 -0
| @@ -0,0 +1,244 @@ | |||||
| 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 | |||
A internal/cache/generic/cache.go +157 -0
| @@ -0,0 +1,157 @@ | |||||
| 1 | + | package generic | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "log" | |||
| 6 | + | "sync" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // Cache provides a generic interface for caching any type of data. | |||
| 10 | + | // K is the key type (must be comparable), V is the value type. | |||
| 11 | + | // All operations are thread-safe via sync.Map. | |||
| 12 | + | // | |||
| 13 | + | // Design principles: | |||
| 14 | + | // - RAM-first: All reads from memory (no DB queries) | |||
| 15 | + | // - Async writes: Updates happen immediately in cache, persisted asynchronously | |||
| 16 | + | // - Bulk load: Load all data from DB at startup | |||
| 17 | + | // - Flush on shutdown: Ensure all pending writes complete gracefully | |||
| 18 | + | type Cache[K comparable, V any] interface { | |||
| 19 | + | // Load bulk-loads all data from storage into memory at startup | |||
| 20 | + | Load(ctx context.Context) error | |||
| 21 | + | ||||
| 22 | + | // Get retrieves a value from the cache (RAM only, never queries DB) | |||
| 23 | + | Get(key K) (V, bool) | |||
| 24 | + | ||||
| 25 | + | // Set updates the cache immediately and persists to DB asynchronously | |||
| 26 | + | Set(key K, value V) error | |||
| 27 | + | ||||
| 28 | + | // Delete removes from cache immediately and persists deletion asynchronously | |||
| 29 | + | Delete(key K) error | |||
| 30 | + | ||||
| 31 | + | // List returns all cached values as a slice | |||
| 32 | + | List() []V | |||
| 33 | + | ||||
| 34 | + | // Flush waits for all pending async writes to complete (call on shutdown) | |||
| 35 | + | Flush() error | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // DatabaseOperations defines the interface for database operations required by the cache. | |||
| 39 | + | // This interface allows for easy mocking in tests using gomock. | |||
| 40 | + | type DatabaseOperations[K comparable, V any] interface { | |||
| 41 | + | // LoadAll loads all entries from the database | |||
| 42 | + | LoadAll(ctx context.Context) (map[K]V, error) | |||
| 43 | + | ||||
| 44 | + | // Persist saves a single entry to the database | |||
| 45 | + | Persist(ctx context.Context, key K, value V) error | |||
| 46 | + | ||||
| 47 | + | // Delete removes a single entry from the database | |||
| 48 | + | Delete(ctx context.Context, key K) error | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | // GenericCache implements Cache[K, V] using sync.Map for thread-safe storage | |||
| 52 | + | // and a DatabaseOperations interface for database operations. | |||
| 53 | + | type GenericCache[K comparable, V any] struct { | |||
| 54 | + | cache sync.Map | |||
| 55 | + | dbOps DatabaseOperations[K, V] | |||
| 56 | + | ||||
| 57 | + | // Track pending async operations for graceful shutdown | |||
| 58 | + | pendingWrites sync.WaitGroup | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // NewGenericCache creates a new cache with the provided database operations. | |||
| 62 | + | // | |||
| 63 | + | // Parameters: | |||
| 64 | + | // - dbOps: Implementation of DatabaseOperations for loading, persisting, and deleting data | |||
| 65 | + | func NewGenericCache[K comparable, V any]( | |||
| 66 | + | dbOps DatabaseOperations[K, V], | |||
| 67 | + | ) *GenericCache[K, V] { | |||
| 68 | + | return &GenericCache[K, V]{ | |||
| 69 | + | dbOps: dbOps, | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | // Load bulk-loads all data from the database into memory. | |||
| 74 | + | // Should be called once at application startup. | |||
| 75 | + | func (c *GenericCache[K, V]) Load(ctx context.Context) error { | |||
| 76 | + | data, err := c.dbOps.LoadAll(ctx) | |||
| 77 | + | if err != nil { | |||
| 78 | + | return err | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | for k, v := range data { | |||
| 82 | + | c.cache.Store(k, v) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | return nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | // Get retrieves a value from the cache. Returns (value, true) if found, | |||
| 89 | + | // or (zero value, false) if not found. Never queries the database. | |||
| 90 | + | func (c *GenericCache[K, V]) Get(key K) (V, bool) { | |||
| 91 | + | val, ok := c.cache.Load(key) | |||
| 92 | + | if !ok { | |||
| 93 | + | var zero V | |||
| 94 | + | return zero, false | |||
| 95 | + | } | |||
| 96 | + | return val.(V), true | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // Set updates the cache immediately (synchronous) and persists to the database | |||
| 100 | + | // asynchronously (non-blocking). Returns immediately without waiting for DB write. | |||
| 101 | + | func (c *GenericCache[K, V]) Set(key K, value V) error { | |||
| 102 | + | // Update cache immediately (sync, fast) | |||
| 103 | + | c.cache.Store(key, value) | |||
| 104 | + | ||||
| 105 | + | // Persist to DB asynchronously (non-blocking) | |||
| 106 | + | c.pendingWrites.Add(1) | |||
| 107 | + | go func() { | |||
| 108 | + | defer c.pendingWrites.Done() | |||
| 109 | + | ||||
| 110 | + | ctx := context.Background() | |||
| 111 | + | if err := c.dbOps.Persist(ctx, key, value); err != nil { | |||
| 112 | + | log.Printf("Warning: async persist failed for key %v: %v", key, err) | |||
| 113 | + | } | |||
| 114 | + | }() | |||
| 115 | + | ||||
| 116 | + | return nil | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | // Delete removes from cache immediately (synchronous) and persists deletion | |||
| 120 | + | // to the database asynchronously (non-blocking). | |||
| 121 | + | func (c *GenericCache[K, V]) Delete(key K) error { | |||
| 122 | + | // Remove from cache immediately (sync, fast) | |||
| 123 | + | c.cache.Delete(key) | |||
| 124 | + | ||||
| 125 | + | // Persist deletion to DB asynchronously (non-blocking) | |||
| 126 | + | c.pendingWrites.Add(1) | |||
| 127 | + | go func() { | |||
| 128 | + | defer c.pendingWrites.Done() | |||
| 129 | + | ||||
| 130 | + | ctx := context.Background() | |||
| 131 | + | if err := c.dbOps.Delete(ctx, key); err != nil { | |||
| 132 | + | log.Printf("Warning: async delete failed for key %v: %v", key, err) | |||
| 133 | + | } | |||
| 134 | + | }() | |||
| 135 | + | ||||
| 136 | + | return nil | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | // List returns all values currently in the cache as a slice. | |||
| 140 | + | // The order is not guaranteed. | |||
| 141 | + | func (c *GenericCache[K, V]) List() []V { | |||
| 142 | + | var values []V | |||
| 143 | + | ||||
| 144 | + | c.cache.Range(func(key, value interface{}) bool { | |||
| 145 | + | values = append(values, value.(V)) | |||
| 146 | + | return true | |||
| 147 | + | }) | |||
| 148 | + | ||||
| 149 | + | return values | |||
| 150 | + | } | |||
| 151 | + | ||||
| 152 | + | // Flush blocks until all pending async writes complete. | |||
| 153 | + | // Should be called during graceful shutdown to ensure no data loss. | |||
| 154 | + | func (c *GenericCache[K, V]) Flush() error { | |||
| 155 | + | c.pendingWrites.Wait() | |||
| 156 | + | return nil | |||
| 157 | + | } | |||
A internal/cache/generic/cache_test.go +461 -0
| @@ -0,0 +1,461 @@ | |||||
| 1 | + | package generic | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // TestLoad verifies that Load correctly populates the cache from the database | |||
| 12 | + | func TestLoad(t *testing.T) { | |||
| 13 | + | mock := NewMockDatabaseOperations[string, int]() | |||
| 14 | + | mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) { | |||
| 15 | + | return map[string]int{ | |||
| 16 | + | "one": 1, | |||
| 17 | + | "two": 2, | |||
| 18 | + | "three": 3, | |||
| 19 | + | }, nil | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | cache := NewGenericCache(mock) | |||
| 23 | + | ||||
| 24 | + | err := cache.Load(context.Background()) | |||
| 25 | + | if err != nil { | |||
| 26 | + | t.Fatalf("Load() failed: %v", err) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | // Verify LoadAll was called once | |||
| 30 | + | if count := mock.GetLoadAllCallCount(); count != 1 { | |||
| 31 | + | t.Errorf("Expected LoadAll to be called once, got %d", count) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | // Verify all entries were loaded into cache | |||
| 35 | + | if val, ok := cache.Get("one"); !ok || val != 1 { | |||
| 36 | + | t.Errorf("Expected one=1, got %v, %v", val, ok) | |||
| 37 | + | } | |||
| 38 | + | if val, ok := cache.Get("two"); !ok || val != 2 { | |||
| 39 | + | t.Errorf("Expected two=2, got %v, %v", val, ok) | |||
| 40 | + | } | |||
| 41 | + | if val, ok := cache.Get("three"); !ok || val != 3 { | |||
| 42 | + | t.Errorf("Expected three=3, got %v, %v", val, ok) | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | // TestLoadError verifies that Load propagates errors from the database | |||
| 47 | + | func TestLoadError(t *testing.T) { | |||
| 48 | + | expectedErr := errors.New("database connection failed") | |||
| 49 | + | ||||
| 50 | + | mock := NewMockDatabaseOperations[string, int]() | |||
| 51 | + | mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) { | |||
| 52 | + | return nil, expectedErr | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | cache := NewGenericCache(mock) | |||
| 56 | + | ||||
| 57 | + | err := cache.Load(context.Background()) | |||
| 58 | + | if !errors.Is(err, expectedErr) { | |||
| 59 | + | t.Errorf("Expected error %v, got %v", expectedErr, err) | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | // Verify LoadAll was called | |||
| 63 | + | if count := mock.GetLoadAllCallCount(); count != 1 { | |||
| 64 | + | t.Errorf("Expected LoadAll to be called once, got %d", count) | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // TestGetNotFound verifies that Get returns false for non-existent keys | |||
| 69 | + | func TestGetNotFound(t *testing.T) { | |||
| 70 | + | mock := NewMockDatabaseOperations[string, int]() | |||
| 71 | + | cache := NewGenericCache(mock) | |||
| 72 | + | ||||
| 73 | + | if err := cache.Load(context.Background()); err != nil { | |||
| 74 | + | t.Fatalf("Load() failed: %v", err) | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | val, ok := cache.Get("nonexistent") | |||
| 78 | + | if ok { | |||
| 79 | + | t.Errorf("Expected Get to return false for nonexistent key, got %v", val) | |||
| 80 | + | } | |||
| 81 | + | if val != 0 { | |||
| 82 | + | t.Errorf("Expected zero value, got %v", val) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | // TestSet verifies that Set immediately updates the cache and persists asynchronously | |||
| 87 | + | func TestSet(t *testing.T) { | |||
| 88 | + | mock := NewMockDatabaseOperations[string, int]() | |||
| 89 | + | ||||
| 90 | + | cache := NewGenericCache(mock) | |||
| 91 | + | if err := cache.Load(context.Background()); err != nil { | |||
| 92 | + | t.Fatalf("Load() failed: %v", err) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Set should return immediately | |||
| 96 | + | err := cache.Set("test", 42) | |||
| 97 | + | if err != nil { | |||
| 98 | + | t.Fatalf("Set() failed: %v", err) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // Value should be in cache immediately | |||
| 102 | + | val, ok := cache.Get("test") | |||
| 103 | + | if !ok || val != 42 { | |||
| 104 | + | t.Errorf("Expected test=42 immediately after Set, got %v, %v", val, ok) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Wait for async persist to complete | |||
| 108 | + | if err := cache.Flush(); err != nil { | |||
| 109 | + | t.Fatalf("Flush() failed: %v", err) | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | // Verify Persist was called | |||
| 113 | + | if count := mock.GetPersistCallCount(); count != 1 { | |||
| 114 | + | t.Errorf("Expected Persist to be called once, got %d", count) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // Verify correct parameters | |||
| 118 | + | lastCall := mock.GetLastPersistCall() | |||
| 119 | + | if lastCall == nil { | |||
| 120 | + | t.Fatal("Expected Persist to be called") | |||
| 121 | + | } | |||
| 122 | + | if lastCall.Key != "test" || lastCall.Value != 42 { | |||
| 123 | + | t.Errorf("Expected Persist(test, 42), got Persist(%v, %v)", lastCall.Key, lastCall.Value) | |||
| 124 | + | } | |||
| 125 | + | } | |||
| 126 | + | ||||
| 127 | + | // TestSetAsyncPersist verifies that Set doesn't block on slow database writes | |||
| 128 | + | func TestSetAsyncPersist(t *testing.T) { | |||
| 129 | + | mock := NewMockDatabaseOperations[string, int]() | |||
| 130 | + | slowPersist := make(chan struct{}) | |||
| 131 | + | ||||
| 132 | + | mock.PersistFunc = func(ctx context.Context, key string, value int) error { | |||
| 133 | + | <-slowPersist // Block until we signal | |||
| 134 | + | return nil | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | cache := NewGenericCache(mock) | |||
| 138 | + | if err := cache.Load(context.Background()); err != nil { | |||
| 139 | + | t.Fatalf("Load() failed: %v", err) | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | // Set should return immediately even though persist is blocked | |||
| 143 | + | done := make(chan struct{}) | |||
| 144 | + | go func() { | |||
| 145 | + | if err := cache.Set("test", 42); err != nil { | |||
| 146 | + | t.Errorf("Set() failed: %v", err) | |||
| 147 | + | } | |||
| 148 | + | close(done) | |||
| 149 | + | }() | |||
| 150 | + | ||||
| 151 | + | select { | |||
| 152 | + | case <-done: | |||
| 153 | + | // Good, Set returned immediately | |||
| 154 | + | case <-time.After(100 * time.Millisecond): | |||
| 155 | + | t.Error("Set() blocked for too long") | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // Verify value is in cache even though persist hasn't completed | |||
| 159 | + | val, ok := cache.Get("test") | |||
| 160 | + | if !ok || val != 42 { | |||
| 161 | + | t.Errorf("Expected test=42 in cache before persist completes, got %v, %v", val, ok) | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // Now allow persist to complete | |||
| 165 | + | close(slowPersist) | |||
| 166 | + | if err := cache.Flush(); err != nil { | |||
| 167 | + | t.Fatalf("Flush() failed: %v", err) | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | // Verify Persist was called | |||
| 171 | + | if count := mock.GetPersistCallCount(); count != 1 { | |||
| 172 | + | t.Errorf("Expected Persist to be called once, got %d", count) | |||
| 173 | + | } | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | // TestDelete verifies that Delete immediately removes from cache and persists async | |||
| 177 | + | func TestDelete(t *testing.T) { | |||
| 178 | + | mock := NewMockDatabaseOperations[string, int]() | |||
| 179 | + | mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) { | |||
| 180 | + | return map[string]int{"test": 42}, nil | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | cache := NewGenericCache(mock) | |||
| 184 | + | if err := cache.Load(context.Background()); err != nil { | |||
| 185 | + | t.Fatalf("Load() failed: %v", err) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // Verify entry exists | |||
| 189 | + | if _, ok := cache.Get("test"); !ok { | |||
| 190 | + | t.Fatal("Expected test to exist before Delete") | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // Delete should return immediately | |||
| 194 | + | err := cache.Delete("test") | |||
| 195 | + | if err != nil { | |||
| 196 | + | t.Fatalf("Delete() failed: %v", err) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | // Value should be removed from cache immediately | |||
| 200 | + | if val, ok := cache.Get("test"); ok { | |||
| 201 | + | t.Errorf("Expected test to be deleted immediately, got %v", val) | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // Wait for async delete to complete | |||
| 205 | + | if err := cache.Flush(); err != nil { | |||
| 206 | + | t.Fatalf("Flush() failed: %v", err) | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | // Verify Delete was called | |||
| 210 | + | if count := mock.GetDeleteCallCount(); count != 1 { | |||
| 211 | + | t.Errorf("Expected Delete to be called once, got %d", count) | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | // Verify correct parameters | |||
| 215 | + | lastCall := mock.GetLastDeleteCall() | |||
| 216 | + | if lastCall == nil { | |||
| 217 | + | t.Fatal("Expected Delete to be called") | |||
| 218 | + | } | |||
| 219 | + | if lastCall.Key != "test" { | |||
| 220 | + | t.Errorf("Expected Delete(test), got Delete(%v)", lastCall.Key) | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | // TestList verifies that List returns all cached values | |||
| 225 | + | func TestList(t *testing.T) { | |||
| 226 | + | mock := NewMockDatabaseOperations[string, int]() | |||
| 227 | + | mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) { | |||
| 228 | + | return map[string]int{ | |||
| 229 | + | "one": 1, | |||
| 230 | + | "two": 2, | |||
| 231 | + | "three": 3, | |||
| 232 | + | }, nil | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | cache := NewGenericCache(mock) | |||
| 236 | + | if err := cache.Load(context.Background()); err != nil { | |||
| 237 | + | t.Fatalf("Load() failed: %v", err) | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | values := cache.List() | |||
| 241 | + | if len(values) != 3 { | |||
| 242 | + | t.Errorf("Expected 3 values, got %d", len(values)) | |||
| 243 | + | } | |||
| 244 | + | ||||
| 245 | + | // Verify all values are present (order not guaranteed) | |||
| 246 | + | found := make(map[int]bool) | |||
| 247 | + | for _, v := range values { | |||
| 248 | + | found[v] = true | |||
| 249 | + | } | |||
| 250 | + | ||||
| 251 | + | for _, expected := range []int{1, 2, 3} { | |||
| 252 | + | if !found[expected] { | |||
| 253 | + | t.Errorf("Expected value %d in List(), not found", expected) | |||
| 254 | + | } | |||
| 255 | + | } | |||
| 256 | + | } | |||
| 257 | + | ||||
| 258 | + | // TestFlush verifies that Flush waits for all pending operations | |||
| 259 | + | func TestFlush(t *testing.T) { | |||
| 260 | + | mock := NewMockDatabaseOperations[string, int]() | |||
| 261 | + | persistDelay := make(chan struct{}) | |||
| 262 | + | ||||
| 263 | + | mock.PersistFunc = func(ctx context.Context, key string, value int) error { | |||
| 264 | + | <-persistDelay // Wait for signal | |||
| 265 | + | return nil | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | cache := NewGenericCache(mock) | |||
| 269 | + | if err := cache.Load(context.Background()); err != nil { | |||
| 270 | + | t.Fatalf("Load() failed: %v", err) | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // Start multiple async operations | |||
| 274 | + | for i := 0; i < 5; i++ { | |||
| 275 | + | if err := cache.Set("test", i); err != nil { | |||
| 276 | + | t.Fatalf("Set() failed: %v", err) | |||
| 277 | + | } | |||
| 278 | + | } | |||
| 279 | + | ||||
| 280 | + | // Verify operations haven't completed yet | |||
| 281 | + | if count := mock.GetPersistCallCount(); count != 0 { | |||
| 282 | + | t.Logf("Note: %d persists completed before signal", count) | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // Allow all operations to proceed | |||
| 286 | + | close(persistDelay) | |||
| 287 | + | ||||
| 288 | + | // Flush should wait for all to complete | |||
| 289 | + | if err := cache.Flush(); err != nil { | |||
| 290 | + | t.Fatalf("Flush() failed: %v", err) | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // All persists should have completed | |||
| 294 | + | if count := mock.GetPersistCallCount(); count != 5 { | |||
| 295 | + | t.Errorf("Expected 5 persists after Flush, got %d", count) | |||
| 296 | + | } | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | // TestConcurrentAccess verifies thread-safety of cache operations | |||
| 300 | + | func TestConcurrentAccess(t *testing.T) { | |||
| 301 | + | mock := NewMockDatabaseOperations[int, int]() | |||
| 302 | + | ||||
| 303 | + | cache := NewGenericCache(mock) | |||
| 304 | + | if err := cache.Load(context.Background()); err != nil { | |||
| 305 | + | t.Fatalf("Load() failed: %v", err) | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | var wg sync.WaitGroup | |||
| 309 | + | numGoroutines := 10 | |||
| 310 | + | numOperations := 100 | |||
| 311 | + | ||||
| 312 | + | // Concurrent writes | |||
| 313 | + | for i := 0; i < numGoroutines; i++ { | |||
| 314 | + | wg.Add(1) | |||
| 315 | + | go func(id int) { | |||
| 316 | + | defer wg.Done() | |||
| 317 | + | for j := 0; j < numOperations; j++ { | |||
| 318 | + | key := id*numOperations + j | |||
| 319 | + | if err := cache.Set(key, key*2); err != nil { | |||
| 320 | + | t.Errorf("Set() failed: %v", err) | |||
| 321 | + | } | |||
| 322 | + | } | |||
| 323 | + | }(i) | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | // Concurrent reads | |||
| 327 | + | for i := 0; i < numGoroutines; i++ { | |||
| 328 | + | wg.Add(1) | |||
| 329 | + | go func() { | |||
| 330 | + | defer wg.Done() | |||
| 331 | + | for j := 0; j < numOperations; j++ { | |||
| 332 | + | cache.Get(j) // Result doesn't matter, just testing for races | |||
| 333 | + | } | |||
| 334 | + | }() | |||
| 335 | + | } | |||
| 336 | + | ||||
| 337 | + | wg.Wait() | |||
| 338 | + | if err := cache.Flush(); err != nil { | |||
| 339 | + | t.Fatalf("Flush() failed: %v", err) | |||
| 340 | + | } | |||
| 341 | + | ||||
| 342 | + | // Verify expected number of entries | |||
| 343 | + | values := cache.List() | |||
| 344 | + | expectedEntries := numGoroutines * numOperations | |||
| 345 | + | if len(values) != expectedEntries { | |||
| 346 | + | t.Errorf("Expected %d entries after concurrent writes, got %d", expectedEntries, len(values)) | |||
| 347 | + | } | |||
| 348 | + | ||||
| 349 | + | // Verify all Persists were called | |||
| 350 | + | if count := mock.GetPersistCallCount(); count != expectedEntries { | |||
| 351 | + | t.Errorf("Expected %d Persist calls, got %d", expectedEntries, count) | |||
| 352 | + | } | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | // TestPersistError verifies that persist errors are logged but don't break the cache | |||
| 356 | + | func TestPersistError(t *testing.T) { | |||
| 357 | + | mock := NewMockDatabaseOperations[string, int]() | |||
| 358 | + | expectedErr := errors.New("database write failed") | |||
| 359 | + | ||||
| 360 | + | mock.PersistFunc = func(ctx context.Context, key string, value int) error { | |||
| 361 | + | return expectedErr | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | cache := NewGenericCache(mock) | |||
| 365 | + | if err := cache.Load(context.Background()); err != nil { | |||
| 366 | + | t.Fatalf("Load() failed: %v", err) | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | // Set should succeed even if persist fails | |||
| 370 | + | err := cache.Set("test", 42) | |||
| 371 | + | if err != nil { | |||
| 372 | + | t.Errorf("Set() should not return error even if persist fails, got %v", err) | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | // Value should still be in cache | |||
| 376 | + | val, ok := cache.Get("test") | |||
| 377 | + | if !ok || val != 42 { | |||
| 378 | + | t.Errorf("Expected test=42 in cache despite persist error, got %v, %v", val, ok) | |||
| 379 | + | } | |||
| 380 | + | ||||
| 381 | + | // Flush should complete (errors are logged, not returned) | |||
| 382 | + | if err := cache.Flush(); err != nil { | |||
| 383 | + | t.Fatalf("Flush() failed: %v", err) | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | // Verify Persist was called despite error | |||
| 387 | + | if count := mock.GetPersistCallCount(); count != 1 { | |||
| 388 | + | t.Errorf("Expected Persist to be called once, got %d", count) | |||
| 389 | + | } | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | // TestDeleteError verifies that delete errors are logged but don't break the cache | |||
| 393 | + | func TestDeleteError(t *testing.T) { | |||
| 394 | + | mock := NewMockDatabaseOperations[string, int]() | |||
| 395 | + | expectedErr := errors.New("database delete failed") | |||
| 396 | + | ||||
| 397 | + | mock.LoadAllFunc = func(ctx context.Context) (map[string]int, error) { | |||
| 398 | + | return map[string]int{"test": 42}, nil | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | mock.DeleteFunc = func(ctx context.Context, key string) error { | |||
| 402 | + | return expectedErr | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | cache := NewGenericCache(mock) | |||
| 406 | + | if err := cache.Load(context.Background()); err != nil { | |||
| 407 | + | t.Fatalf("Load() failed: %v", err) | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | // Delete should succeed even if DB delete fails | |||
| 411 | + | err := cache.Delete("test") | |||
| 412 | + | if err != nil { | |||
| 413 | + | t.Errorf("Delete() should not return error even if DB delete fails, got %v", err) | |||
| 414 | + | } | |||
| 415 | + | ||||
| 416 | + | // Value should still be removed from cache | |||
| 417 | + | if val, ok := cache.Get("test"); ok { | |||
| 418 | + | t.Errorf("Expected test to be deleted from cache despite DB error, got %v", val) | |||
| 419 | + | } | |||
| 420 | + | ||||
| 421 | + | if err := cache.Flush(); err != nil { | |||
| 422 | + | t.Fatalf("Flush() failed: %v", err) | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | // Verify Delete was called despite error | |||
| 426 | + | if count := mock.GetDeleteCallCount(); count != 1 { | |||
| 427 | + | t.Errorf("Expected Delete to be called once, got %d", count) | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | // TestMultipleSetsAndDeletes verifies correct handling of multiple operations | |||
| 432 | + | func TestMultipleSetsAndDeletes(t *testing.T) { | |||
| 433 | + | mock := NewMockDatabaseOperations[string, int]() | |||
| 434 | + | ||||
| 435 | + | cache := NewGenericCache(mock) | |||
| 436 | + | if err := cache.Load(context.Background()); err != nil { | |||
| 437 | + | t.Fatalf("Load() failed: %v", err) | |||
| 438 | + | } | |||
| 439 | + | ||||
| 440 | + | // Perform multiple sets | |||
| 441 | + | for i := 0; i < 10; i++ { | |||
| 442 | + | if err := cache.Set("key", i); err != nil { | |||
| 443 | + | t.Fatalf("Set() failed: %v", err) | |||
| 444 | + | } | |||
| 445 | + | } | |||
| 446 | + | ||||
| 447 | + | // Latest value should be in cache | |||
| 448 | + | val, ok := cache.Get("key") | |||
| 449 | + | if !ok || val != 9 { | |||
| 450 | + | t.Errorf("Expected key=9, got %v, %v", val, ok) | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | // Flush and verify all persists were called | |||
| 454 | + | if err := cache.Flush(); err != nil { | |||
| 455 | + | t.Fatalf("Flush() failed: %v", err) | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | if count := mock.GetPersistCallCount(); count != 10 { | |||
| 459 | + | t.Errorf("Expected 10 Persist calls, got %d", count) | |||
| 460 | + | } | |||
| 461 | + | } | |||
A internal/cache/generic/mock_db_ops.go +137 -0
| @@ -0,0 +1,137 @@ | |||||
| 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 | + | } | |||
M internal/cache/interfaces.go +0 -1
| @@ -11,5 +11,4 @@ import ( | |||||
| 11 | // HistoryQuerier defines the interface for querying history data. | 11 | // HistoryQuerier defines the interface for querying history data. | ||
| 12 | type HistoryQuerier interface { | 12 | type HistoryQuerier interface { | ||
| 13 | GetHistory(ctx context.Context, limit int64) ([]db.History, error) | 13 | GetHistory(ctx context.Context, limit int64) ([]db.History, error) | ||
| 14 | - | GetShortcuts(ctx context.Context) ([]db.Shortcut, error) | |||
| 15 | } | 14 | } | ||
M internal/cache/mocks/mock_querier.go +8 -17
| @@ -1,5 +1,10 @@ | |||||
| 1 | // Code generated by MockGen. DO NOT EDIT. | 1 | // Code generated by MockGen. DO NOT EDIT. | ||
| 2 | // Source: interfaces.go | 2 | // Source: interfaces.go | ||
| 3 | + | // | |||
| 4 | + | // Generated by this command: | |||
| 5 | + | // | |||
| 6 | + | // mockgen -source=interfaces.go -destination=mocks/mock_querier.go | |||
| 7 | + | // | |||
| 3 | 8 | ||||
| 4 | // Package mock_cache is a generated GoMock package. | 9 | // Package mock_cache is a generated GoMock package. | ||
| 5 | package mock_cache | 10 | package mock_cache | ||
| @@ -9,13 +14,14 @@ import ( | |||||
| 9 | reflect "reflect" | 14 | reflect "reflect" | ||
| 10 | 15 | ||||
| 11 | db "github.com/bnema/dumber/internal/db" | 16 | db "github.com/bnema/dumber/internal/db" | ||
| 12 | - | gomock "github.com/golang/mock/gomock" | 17 | + | gomock "go.uber.org/mock/gomock" |
| 13 | ) | 18 | ) | ||
| 14 | 19 | ||||
| 15 | // MockHistoryQuerier is a mock of HistoryQuerier interface. | 20 | // MockHistoryQuerier is a mock of HistoryQuerier interface. | ||
| 16 | type MockHistoryQuerier struct { | 21 | type MockHistoryQuerier struct { | ||
| 17 | ctrl *gomock.Controller | 22 | ctrl *gomock.Controller | ||
| 18 | recorder *MockHistoryQuerierMockRecorder | 23 | recorder *MockHistoryQuerierMockRecorder | ||
| 24 | + | isgomock struct{} | |||
| 19 | } | 25 | } | ||
| 20 | 26 | ||||
| 21 | // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier. | 27 | // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier. | ||
| @@ -45,22 +51,7 @@ func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db. | |||||
| 45 | } | 51 | } | ||
| 46 | 52 | ||||
| 47 | // GetHistory indicates an expected call of GetHistory. | 53 | // GetHistory indicates an expected call of GetHistory. | ||
| 48 | - | func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call { | 54 | + | func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit any) *gomock.Call { |
| 49 | mr.mock.ctrl.T.Helper() | 55 | mr.mock.ctrl.T.Helper() | ||
| 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit) | 56 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit) | ||
| 51 | } | 57 | } | ||
| 52 | - | ||||
| 53 | - | // GetShortcuts mocks base method. | |||
| 54 | - | func (m *MockHistoryQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) { | |||
| 55 | - | m.ctrl.T.Helper() | |||
| 56 | - | ret := m.ctrl.Call(m, "GetShortcuts", ctx) | |||
| 57 | - | ret0, _ := ret[0].([]db.Shortcut) | |||
| 58 | - | ret1, _ := ret[1].(error) | |||
| 59 | - | return ret0, ret1 | |||
| 60 | - | } | |||
| 61 | - | ||||
| 62 | - | // GetShortcuts indicates an expected call of GetShortcuts. | |||
| 63 | - | func (mr *MockHistoryQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call { | |||
| 64 | - | mr.mock.ctrl.T.Helper() | |||
| 65 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockHistoryQuerier)(nil).GetShortcuts), ctx) | |||
| 66 | - | } | |||
A internal/cache/zoom_cache.go +62 -0
| @@ -0,0 +1,62 @@ | |||||
| 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 | + | } | |||
M internal/cli/dmenu.go +7 -10
| @@ -172,11 +172,8 @@ func generateOptionsFallback(cli *CLI) error { | |||||
| 172 | return fmt.Errorf("failed to get history: %w", err) | 172 | return fmt.Errorf("failed to get history: %w", err) | ||
| 173 | } | 173 | } | ||
| 174 | 174 | ||||
| 175 | - | // Get shortcuts | 175 | + | // Get shortcuts from configuration (not database) |
| 176 | - | shortcuts, err := cli.Queries.GetShortcuts(ctx) | 176 | + | shortcuts := cli.Config.SearchShortcuts |
| 177 | - | if err != nil { | |||
| 178 | - | return fmt.Errorf("failed to get shortcuts: %w", err) | |||
| 179 | - | } | |||
| 180 | 177 | ||||
| 181 | // Generate options | 178 | // Generate options | ||
| 182 | options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1) | 179 | options := make([]DmenuOption, 0, len(history)+len(shortcuts)+1) | ||
| @@ -190,15 +187,15 @@ func generateOptionsFallback(cli *CLI) error { | |||||
| 190 | }) | 187 | }) | ||
| 191 | 188 | ||||
| 192 | // Add shortcuts with examples | 189 | // Add shortcuts with examples | ||
| 193 | - | for _, shortcut := range shortcuts { | 190 | + | for shortcutKey, shortcut := range shortcuts { |
| 194 | - | desc := shortcut.Description.String | 191 | + | desc := shortcut.Description |
| 195 | - | if !shortcut.Description.Valid { | 192 | + | if desc == "" { |
| 196 | desc = "Custom shortcut" | 193 | desc = "Custom shortcut" | ||
| 197 | } | 194 | } | ||
| 198 | 195 | ||||
| 199 | options = append(options, DmenuOption{ | 196 | options = append(options, DmenuOption{ | ||
| 200 | - | Display: fmt.Sprintf("🔍 %s: (%s)", shortcut.Shortcut, desc), | 197 | + | Display: fmt.Sprintf("🔍 %s: (%s)", shortcutKey, desc), |
| 201 | - | Value: shortcut.Shortcut + ":", | 198 | + | Value: shortcutKey + ":", |
| 202 | Type: "shortcut", | 199 | Type: "shortcut", | ||
| 203 | Description: desc, | 200 | Description: desc, | ||
| 204 | }) | 201 | }) | ||
M internal/cli/logs.go +10 -2
| @@ -163,7 +163,11 @@ func tailFile(filePath string, lines int) error { | |||||
| 163 | } | 163 | } | ||
| 164 | return fmt.Errorf("failed to open log file: %w", err) | 164 | return fmt.Errorf("failed to open log file: %w", err) | ||
| 165 | } | 165 | } | ||
| 166 | - | defer file.Close() | 166 | + | defer func() { |
| 167 | + | if err := file.Close(); err != nil { | |||
| 168 | + | fmt.Fprintf(os.Stderr, "warning: failed to close log file: %v\n", err) | |||
| 169 | + | } | |||
| 170 | + | }() | |||
| 167 | 171 | ||||
| 168 | // Read all lines | 172 | // Read all lines | ||
| 169 | var allLines []string | 173 | var allLines []string | ||
| @@ -335,7 +339,11 @@ func tailConsoleMessages(filePath string, lines int, level string) error { | |||||
| 335 | } | 339 | } | ||
| 336 | return fmt.Errorf("failed to open log file: %w", err) | 340 | return fmt.Errorf("failed to open log file: %w", err) | ||
| 337 | } | 341 | } | ||
| 338 | - | defer file.Close() | 342 | + | defer func() { |
| 343 | + | if err := file.Close(); err != nil { | |||
| 344 | + | fmt.Fprintf(os.Stderr, "warning: failed to close log file: %v\n", err) | |||
| 345 | + | } | |||
| 346 | + | }() | |||
| 339 | 347 | ||||
| 340 | // Read all lines and filter for console messages | 348 | // Read all lines and filter for console messages | ||
| 341 | var consoleLines []string | 349 | var consoleLines []string | ||
M internal/cli/purge.go +17 -19
| @@ -45,10 +45,13 @@ func NewPurgeCmd() *cobra.Command { | |||||
| 45 | Long: `Purge various dumber data and cache files. By default, purges everything. | 45 | Long: `Purge various dumber data and cache files. By default, purges everything. | ||
| 46 | 46 | ||||
| 47 | Available purge targets: | 47 | Available purge targets: | ||
| 48 | - | --database, -d Purge the SQLite database (history, shortcuts, zoom levels) | 48 | + | --database, -d Purge the SQLite database (history, zoom levels, cookies, |
| 49 | - | --history-cache, -H Purge dmenu fuzzy search cache for history | 49 | + | certificate validations, and fuzzy cache metadata) |
| 50 | + | --history-cache, -H Purge dmenu fuzzy search cache for history (binary file, will be | |||
| 51 | + | database-backed in the future) | |||
| 50 | --browser-cache, -c Purge WebKit browser cache (cached images, files, etc.) | 52 | --browser-cache, -c Purge WebKit browser cache (cached images, files, etc.) | ||
| 51 | - | --browser-data, -b Purge WebKit browser data (cookies, localStorage, sessionStorage) | 53 | + | --browser-data, -b Purge WebKit browser data (localStorage, sessionStorage, IndexedDB) |
| 54 | + | Note: Cookies are now stored in the main database and deleted via --database | |||
| 52 | --filter-cache, -F Purge compiled ad blocking filters cache | 55 | --filter-cache, -F Purge compiled ad blocking filters cache | ||
| 53 | --state, -s Purge all state data (includes database and caches) | 56 | --state, -s Purge all state data (includes database and caches) | ||
| 54 | --config Purge configuration files | 57 | --config Purge configuration files | ||
| @@ -56,34 +59,29 @@ Available purge targets: | |||||
| 56 | 59 | ||||
| 57 | Use --force to skip the confirmation prompt. | 60 | Use --force to skip the confirmation prompt. | ||
| 58 | 61 | ||||
| 62 | + | Database Consolidation: | |||
| 63 | + | Since version with database consolidation, cookies are stored in dumber.sqlite. | |||
| 64 | + | Use --database to delete cookies along with history and other browser data. | |||
| 65 | + | The --browser-data flag now only affects localStorage and other WebKit-managed data. | |||
| 66 | + | ||||
| 59 | Examples: | 67 | Examples: | ||
| 60 | dumber purge # Purge everything (with confirmation) | 68 | dumber purge # Purge everything (with confirmation) | ||
| 61 | dumber purge --force # Purge everything (no confirmation) | 69 | dumber purge --force # Purge everything (no confirmation) | ||
| 62 | dumber purge -d -H -c -F # Purge database and all caches | 70 | dumber purge -d -H -c -F # Purge database and all caches | ||
| 63 | - | dumber purge --filter-cache -f # Force purge filter cache only`, | 71 | + | dumber purge --filter-cache -f # Force purge filter cache only |
| 72 | + | dumber purge -d # Purge database (includes cookies)`, | |||
| 64 | RunE: func(_ *cobra.Command, _ []string) error { | 73 | RunE: func(_ *cobra.Command, _ []string) error { | ||
| 65 | - | // Initialize CLI to get config paths | |||
| 66 | - | cli, err := NewCLI() | |||
| 67 | - | if err != nil { | |||
| 68 | - | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 69 | - | } | |||
| 70 | - | defer func() { | |||
| 71 | - | if closeErr := cli.Close(); closeErr != nil { | |||
| 72 | - | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 73 | - | } | |||
| 74 | - | }() | |||
| 75 | - | ||||
| 76 | return executePurge(flags) | 74 | return executePurge(flags) | ||
| 77 | }, | 75 | }, | ||
| 78 | } | 76 | } | ||
| 79 | 77 | ||||
| 80 | // Add flags | 78 | // Add flags | ||
| 81 | - | cmd.Flags().BoolVarP(&flags.Database, "database", "d", false, "Purge the SQLite database") | 79 | + | cmd.Flags().BoolVarP(&flags.Database, "database", "d", false, "Purge the SQLite database (includes cookies)") |
| 82 | - | cmd.Flags().BoolVarP(&flags.HistoryCache, "history-cache", "H", false, "Purge dmenu fuzzy search cache") | 80 | + | cmd.Flags().BoolVarP(&flags.HistoryCache, "history-cache", "H", false, "Purge dmenu fuzzy search cache (binary file)") |
| 83 | cmd.Flags().BoolVarP(&flags.BrowserCache, "browser-cache", "c", false, "Purge WebKit browser cache") | 81 | cmd.Flags().BoolVarP(&flags.BrowserCache, "browser-cache", "c", false, "Purge WebKit browser cache") | ||
| 84 | - | cmd.Flags().BoolVarP(&flags.BrowserData, "browser-data", "b", false, "Purge WebKit browser data (cookies, localStorage)") | 82 | + | cmd.Flags().BoolVarP(&flags.BrowserData, "browser-data", "b", false, "Purge WebKit browser data (localStorage, sessionStorage)") |
| 85 | cmd.Flags().BoolVarP(&flags.FilterCache, "filter-cache", "F", false, "Purge compiled ad blocking filters cache") | 83 | cmd.Flags().BoolVarP(&flags.FilterCache, "filter-cache", "F", false, "Purge compiled ad blocking filters cache") | ||
| 86 | - | cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data") | 84 | + | cmd.Flags().BoolVarP(&flags.State, "state", "s", false, "Purge all state data (database and caches)") |
| 87 | cmd.Flags().BoolVar(&flags.Config, "config", false, "Purge configuration files") | 85 | cmd.Flags().BoolVar(&flags.Config, "config", false, "Purge configuration files") | ||
| 88 | cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything") | 86 | cmd.Flags().BoolVarP(&flags.All, "all", "a", false, "Purge everything") | ||
| 89 | cmd.Flags().BoolVarP(&flags.Force, "force", "f", false, "Skip confirmation prompt") | 87 | cmd.Flags().BoolVarP(&flags.Force, "force", "f", false, "Skip confirmation prompt") | ||
M internal/cli/root.go +3 -45
| @@ -65,14 +65,6 @@ func NewCLI() (*CLI, error) { | |||||
| 65 | return nil, fmt.Errorf("failed to connect to database: %w", err) | 65 | return nil, fmt.Errorf("failed to connect to database: %w", err) | ||
| 66 | } | 66 | } | ||
| 67 | 67 | ||||
| 68 | - | // Initialize database schema with configuration-based shortcuts | |||
| 69 | - | if err := initializeDatabase(database, cfg); err != nil { | |||
| 70 | - | if err := database.Close(); err != nil { | |||
| 71 | - | log.Printf("Warning: failed to close database: %v", err) | |||
| 72 | - | } | |||
| 73 | - | return nil, fmt.Errorf("failed to initialize database: %w", err) | |||
| 74 | - | } | |||
| 75 | - | ||||
| 76 | queries := db.New(database) | 68 | queries := db.New(database) | ||
| 77 | parserService := services.NewParserService(cfg, queries) | 69 | parserService := services.NewParserService(cfg, queries) | ||
| 78 | 70 | ||||
| @@ -95,28 +87,11 @@ func (c *CLI) Close() error { | |||||
| 95 | // NewRootCmd creates the root command for dumber | 87 | // NewRootCmd creates the root command for dumber | ||
| 96 | func NewRootCmd(version, commit, buildDate string) *cobra.Command { | 88 | func NewRootCmd(version, commit, buildDate string) *cobra.Command { | ||
| 97 | rootCmd := &cobra.Command{ | 89 | rootCmd := &cobra.Command{ | ||
| 98 | - | Use: "dumber [url]", | 90 | + | Use: "dumber", |
| 99 | Short: "A dumb browser for Wayland window managers", | 91 | Short: "A dumb browser for Wayland window managers", | ||
| 100 | Long: `A fast, simple browser with rofi/dmenu integration for sway and hyprland window managers.`, | 92 | Long: `A fast, simple browser with rofi/dmenu integration for sway and hyprland window managers.`, | ||
| 101 | - | Args: cobra.MaximumNArgs(1), | 93 | + | RunE: func(cmd *cobra.Command, _ []string) error { |
| 102 | - | RunE: func(cmd *cobra.Command, args []string) error { | 94 | + | // Show help when no subcommand is provided |
| 103 | - | // Initialize CLI | |||
| 104 | - | cli, err := NewCLI() | |||
| 105 | - | if err != nil { | |||
| 106 | - | return fmt.Errorf("failed to initialize CLI: %w", err) | |||
| 107 | - | } | |||
| 108 | - | defer func() { | |||
| 109 | - | if closeErr := cli.Close(); closeErr != nil { | |||
| 110 | - | fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr) | |||
| 111 | - | } | |||
| 112 | - | }() | |||
| 113 | - | ||||
| 114 | - | // If URL provided, browse it directly | |||
| 115 | - | if len(args) > 0 { | |||
| 116 | - | return browse(cli, args[0]) | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | - | // Otherwise show help | |||
| 120 | return cmd.Help() | 95 | return cmd.Help() | ||
| 121 | }, | 96 | }, | ||
| 122 | } | 97 | } | ||
| @@ -183,20 +158,3 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command { | |||||
| 183 | 158 | ||||
| 184 | return rootCmd | 159 | return rootCmd | ||
| 185 | } | 160 | } | ||
| 186 | - | ||||
| 187 | - | // initializeDatabase ensures configured shortcuts are inserted | |||
| 188 | - | // Migrations are run automatically in db.InitDB() | |||
| 189 | - | func initializeDatabase(database *sql.DB, cfg *config.Config) error { | |||
| 190 | - | // Insert configured shortcuts (these are additive, won't override existing) | |||
| 191 | - | for shortcut, shortcutCfg := range cfg.SearchShortcuts { | |||
| 192 | - | _, err := database.Exec( | |||
| 193 | - | "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)", | |||
| 194 | - | shortcut, shortcutCfg.URL, shortcutCfg.Description, | |||
| 195 | - | ) | |||
| 196 | - | if err != nil { | |||
| 197 | - | return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err) | |||
| 198 | - | } | |||
| 199 | - | } | |||
| 200 | - | ||||
| 201 | - | return nil | |||
| 202 | - | } | |||
M internal/config/config.go +14 -94
| @@ -8,7 +8,6 @@ import ( | |||||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | "strings" | 9 | "strings" | ||
| 10 | "sync" | 10 | "sync" | ||
| 11 | - | "time" | |||
| 12 | 11 | ||||
| 13 | "github.com/bnema/dumber/internal/logging" | 12 | "github.com/bnema/dumber/internal/logging" | ||
| 14 | "github.com/bnema/dumber/pkg/gpu" | 13 | "github.com/bnema/dumber/pkg/gpu" | ||
| @@ -32,9 +31,7 @@ type Config struct { | |||||
| 32 | Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"` | 31 | Appearance AppearanceConfig `mapstructure:"appearance" yaml:"appearance"` | ||
| 33 | VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"` | 32 | VideoAcceleration VideoAccelerationConfig `mapstructure:"video_acceleration" yaml:"video_acceleration"` | ||
| 34 | CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences"` | 33 | CodecPreferences CodecConfig `mapstructure:"codec_preferences" yaml:"codec_preferences"` | ||
| 35 | - | WebkitMemory WebkitMemoryConfig `mapstructure:"webkit_memory" yaml:"webkit_memory"` | |||
| 36 | Debug DebugConfig `mapstructure:"debug" yaml:"debug"` | 34 | Debug DebugConfig `mapstructure:"debug" yaml:"debug"` | ||
| 37 | - | APISecurity APISecurityConfig `mapstructure:"api_security" yaml:"api_security"` | |||
| 38 | // RenderingMode controls GPU/CPU rendering selection for WebKit | 35 | // RenderingMode controls GPU/CPU rendering selection for WebKit | ||
| 39 | RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"` | 36 | RenderingMode RenderingMode `mapstructure:"rendering_mode" yaml:"rendering_mode"` | ||
| 40 | // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom. | 37 | // UseDomZoom toggles DOM-based zoom instead of native WebKit zoom. | ||
| @@ -58,10 +55,7 @@ const ( | |||||
| 58 | 55 | ||||
| 59 | // DatabaseConfig holds database-related configuration. | 56 | // DatabaseConfig holds database-related configuration. | ||
| 60 | type DatabaseConfig struct { | 57 | type DatabaseConfig struct { | ||
| 61 | - | Path string `mapstructure:"path" yaml:"path"` | 58 | + | Path string `mapstructure:"path" yaml:"path"` |
| 62 | - | MaxConnections int `mapstructure:"max_connections" yaml:"max_connections"` | |||
| 63 | - | MaxIdleTime time.Duration `mapstructure:"max_idle_time" yaml:"max_idle_time"` | |||
| 64 | - | QueryTimeout time.Duration `mapstructure:"query_timeout" yaml:"query_timeout"` | |||
| 65 | } | 59 | } | ||
| 66 | 60 | ||||
| 67 | // HistoryConfig holds history-related configuration. | 61 | // HistoryConfig holds history-related configuration. | ||
| @@ -178,35 +172,6 @@ type CodecConfig struct { | |||||
| 178 | DisableTwitchCodecControl bool `mapstructure:"disable_twitch_codec_control" yaml:"disable_twitch_codec_control"` | 172 | DisableTwitchCodecControl bool `mapstructure:"disable_twitch_codec_control" yaml:"disable_twitch_codec_control"` | ||
| 179 | } | 173 | } | ||
| 180 | 174 | ||||
| 181 | - | // WebkitMemoryConfig holds WebKit memory optimization settings | |||
| 182 | - | type WebkitMemoryConfig struct { | |||
| 183 | - | // Cache model (document_viewer | web_browser | primary_web_browser) | |||
| 184 | - | CacheModel string `mapstructure:"cache_model" yaml:"cache_model"` | |||
| 185 | - | ||||
| 186 | - | // Enable page cache for back/forward navigation | |||
| 187 | - | EnablePageCache bool `mapstructure:"enable_page_cache" yaml:"enable_page_cache"` | |||
| 188 | - | ||||
| 189 | - | // Memory limit in MB (0 = system default) | |||
| 190 | - | MemoryLimitMB int `mapstructure:"memory_limit_mb" yaml:"memory_limit_mb"` | |||
| 191 | - | ||||
| 192 | - | // Memory pressure thresholds (0.0-1.0) | |||
| 193 | - | ConservativeThreshold float64 `mapstructure:"conservative_threshold" yaml:"conservative_threshold"` | |||
| 194 | - | StrictThreshold float64 `mapstructure:"strict_threshold" yaml:"strict_threshold"` | |||
| 195 | - | KillThreshold float64 `mapstructure:"kill_threshold" yaml:"kill_threshold"` | |||
| 196 | - | ||||
| 197 | - | // Memory monitoring interval in seconds | |||
| 198 | - | PollIntervalSeconds float64 `mapstructure:"poll_interval_seconds" yaml:"poll_interval_seconds"` | |||
| 199 | - | ||||
| 200 | - | // Garbage collection interval in seconds (0 = disabled) | |||
| 201 | - | EnableGCInterval int `mapstructure:"enable_gc_interval" yaml:"enable_gc_interval"` | |||
| 202 | - | ||||
| 203 | - | // Process recycling threshold (number of page loads) | |||
| 204 | - | ProcessRecycleThreshold int `mapstructure:"process_recycle_threshold" yaml:"process_recycle_threshold"` | |||
| 205 | - | ||||
| 206 | - | // Enable memory monitoring logs | |||
| 207 | - | EnableMemoryMonitoring bool `mapstructure:"enable_memory_monitoring" yaml:"enable_memory_monitoring"` | |||
| 208 | - | } | |||
| 209 | - | ||||
| 210 | // DebugConfig holds debug and troubleshooting options | 175 | // DebugConfig holds debug and troubleshooting options | ||
| 211 | type DebugConfig struct { | 176 | type DebugConfig struct { | ||
| 212 | // Enable WebKit internal debug logging | 177 | // Enable WebKit internal debug logging | ||
| @@ -247,14 +212,6 @@ type DebugConfig struct { | |||||
| 247 | EnablePaneCloseDebug bool `mapstructure:"enable_pane_close_debug" yaml:"enable_pane_close_debug"` | 212 | EnablePaneCloseDebug bool `mapstructure:"enable_pane_close_debug" yaml:"enable_pane_close_debug"` | ||
| 248 | } | 213 | } | ||
| 249 | 214 | ||||
| 250 | - | // APISecurityConfig holds optional API key protection for dumb://api endpoints | |||
| 251 | - | type APISecurityConfig struct { | |||
| 252 | - | // If non-empty, token that requests must present via `token` query param | |||
| 253 | - | Token string `mapstructure:"token" yaml:"token"` | |||
| 254 | - | // If true, require token for all API endpoints (except a minimal allowlist) | |||
| 255 | - | RequireToken bool `mapstructure:"require_token" yaml:"require_token"` | |||
| 256 | - | } | |||
| 257 | - | ||||
| 258 | // WorkspaceConfig captures layout, pane, and tab behaviour preferences. | 215 | // WorkspaceConfig captures layout, pane, and tab behaviour preferences. | ||
| 259 | type WorkspaceConfig struct { | 216 | type WorkspaceConfig struct { | ||
| 260 | // EnableZellijControls toggles Zellij-inspired keybindings. | 217 | // EnableZellijControls toggles Zellij-inspired keybindings. | ||
| @@ -371,12 +328,6 @@ func NewManager() (*Manager, error) { | |||||
| 371 | // Bind specific environment variables | 328 | // Bind specific environment variables | ||
| 372 | bindings := map[string]string{ | 329 | bindings := map[string]string{ | ||
| 373 | "database.path": "DATABASE_PATH", | 330 | "database.path": "DATABASE_PATH", | ||
| 374 | - | "database.max_connections": "DATABASE_MAX_CONNECTIONS", | |||
| 375 | - | "database.max_idle_time": "DATABASE_MAX_IDLE_TIME", | |||
| 376 | - | "database.query_timeout": "DATABASE_QUERY_TIMEOUT", | |||
| 377 | - | "browser.command": "BROWSER_COMMAND", | |||
| 378 | - | "browser.timeout": "BROWSER_TIMEOUT", | |||
| 379 | - | "browser.detach_process": "BROWSER_DETACH_PROCESS", | |||
| 380 | "history.max_entries": "HISTORY_MAX_ENTRIES", | 331 | "history.max_entries": "HISTORY_MAX_ENTRIES", | ||
| 381 | "history.retention_period": "HISTORY_RETENTION_PERIOD", | 332 | "history.retention_period": "HISTORY_RETENTION_PERIOD", | ||
| 382 | "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL", | 333 | "history.cleanup_interval": "HISTORY_CLEANUP_INTERVAL", | ||
| @@ -395,9 +346,6 @@ func NewManager() (*Manager, error) { | |||||
| 395 | "logging.max_backups": "LOGGING_MAX_BACKUPS", | 346 | "logging.max_backups": "LOGGING_MAX_BACKUPS", | ||
| 396 | "logging.max_age": "LOGGING_MAX_AGE", | 347 | "logging.max_age": "LOGGING_MAX_AGE", | ||
| 397 | "logging.compress": "LOGGING_COMPRESS", | 348 | "logging.compress": "LOGGING_COMPRESS", | ||
| 398 | - | // API security | |||
| 399 | - | "api_security.token": "API_TOKEN", | |||
| 400 | - | "api_security.require_token": "API_REQUIRE_TOKEN", | |||
| 401 | } | 349 | } | ||
| 402 | 350 | ||||
| 403 | for key, env := range bindings { | 351 | for key, env := range bindings { | ||
| @@ -453,26 +401,6 @@ func NewManager() (*Manager, error) { | |||||
| 453 | } | 401 | } | ||
| 454 | } | 402 | } | ||
| 455 | 403 | ||||
| 456 | - | // WebKit memory environment variable bindings | |||
| 457 | - | memoryEnvBindings := map[string]string{ | |||
| 458 | - | "webkit_memory.cache_model": "DUMBER_CACHE_MODEL", | |||
| 459 | - | "webkit_memory.enable_page_cache": "DUMBER_ENABLE_PAGE_CACHE", | |||
| 460 | - | "webkit_memory.memory_limit_mb": "DUMBER_MEMORY_LIMIT_MB", | |||
| 461 | - | "webkit_memory.conservative_threshold": "DUMBER_MEMORY_CONSERVATIVE", | |||
| 462 | - | "webkit_memory.strict_threshold": "DUMBER_MEMORY_STRICT", | |||
| 463 | - | "webkit_memory.kill_threshold": "DUMBER_MEMORY_KILL", | |||
| 464 | - | "webkit_memory.poll_interval_seconds": "DUMBER_MEMORY_POLL_INTERVAL", | |||
| 465 | - | "webkit_memory.enable_gc_interval": "DUMBER_GC_INTERVAL", | |||
| 466 | - | "webkit_memory.process_recycle_threshold": "DUMBER_RECYCLE_THRESHOLD", | |||
| 467 | - | "webkit_memory.enable_memory_monitoring": "DUMBER_ENABLE_MEMORY_MONITORING", | |||
| 468 | - | } | |||
| 469 | - | ||||
| 470 | - | for key, env := range memoryEnvBindings { | |||
| 471 | - | if err := v.BindEnv(key, env); err != nil { | |||
| 472 | - | return nil, fmt.Errorf("failed to bind environment variable %s: %w", env, err) | |||
| 473 | - | } | |||
| 474 | - | } | |||
| 475 | - | ||||
| 476 | return &Manager{ | 404 | return &Manager{ | ||
| 477 | viper: v, | 405 | viper: v, | ||
| 478 | callbacks: make([]func(*Config), 0), | 406 | callbacks: make([]func(*Config), 0), | ||
| @@ -659,10 +587,7 @@ func (m *Manager) reload() error { | |||||
| 659 | func (m *Manager) setDefaults() { | 587 | func (m *Manager) setDefaults() { | ||
| 660 | defaults := DefaultConfig() | 588 | defaults := DefaultConfig() | ||
| 661 | 589 | ||||
| 662 | - | // Database defaults | 590 | + | // Note: Database.Path is set dynamically in Load(), no defaults needed |
| 663 | - | m.viper.SetDefault("database.max_connections", defaults.Database.MaxConnections) | |||
| 664 | - | m.viper.SetDefault("database.max_idle_time", defaults.Database.MaxIdleTime) | |||
| 665 | - | m.viper.SetDefault("database.query_timeout", defaults.Database.QueryTimeout) | |||
| 666 | 591 | ||||
| 667 | // Note: Browser config removed - we build our own browser | 592 | // Note: Browser config removed - we build our own browser | ||
| 668 | 593 | ||||
| @@ -743,18 +668,6 @@ func (m *Manager) setDefaults() { | |||||
| 743 | m.viper.SetDefault("codec_preferences.av1_max_resolution", defaults.CodecPreferences.AV1MaxResolution) | 668 | m.viper.SetDefault("codec_preferences.av1_max_resolution", defaults.CodecPreferences.AV1MaxResolution) | ||
| 744 | m.viper.SetDefault("codec_preferences.disable_twitch_codec_control", defaults.CodecPreferences.DisableTwitchCodecControl) | 669 | m.viper.SetDefault("codec_preferences.disable_twitch_codec_control", defaults.CodecPreferences.DisableTwitchCodecControl) | ||
| 745 | 670 | ||||
| 746 | - | // WebKit memory defaults | |||
| 747 | - | m.viper.SetDefault("webkit_memory.cache_model", defaults.WebkitMemory.CacheModel) | |||
| 748 | - | m.viper.SetDefault("webkit_memory.enable_page_cache", defaults.WebkitMemory.EnablePageCache) | |||
| 749 | - | m.viper.SetDefault("webkit_memory.memory_limit_mb", defaults.WebkitMemory.MemoryLimitMB) | |||
| 750 | - | m.viper.SetDefault("webkit_memory.conservative_threshold", defaults.WebkitMemory.ConservativeThreshold) | |||
| 751 | - | m.viper.SetDefault("webkit_memory.strict_threshold", defaults.WebkitMemory.StrictThreshold) | |||
| 752 | - | m.viper.SetDefault("webkit_memory.kill_threshold", defaults.WebkitMemory.KillThreshold) | |||
| 753 | - | m.viper.SetDefault("webkit_memory.poll_interval_seconds", defaults.WebkitMemory.PollIntervalSeconds) | |||
| 754 | - | m.viper.SetDefault("webkit_memory.enable_gc_interval", defaults.WebkitMemory.EnableGCInterval) | |||
| 755 | - | m.viper.SetDefault("webkit_memory.process_recycle_threshold", defaults.WebkitMemory.ProcessRecycleThreshold) | |||
| 756 | - | m.viper.SetDefault("webkit_memory.enable_memory_monitoring", defaults.WebkitMemory.EnableMemoryMonitoring) | |||
| 757 | - | ||||
| 758 | // Rendering defaults | 671 | // Rendering defaults | ||
| 759 | m.viper.SetDefault("rendering_mode", string(RenderingModeGPU)) | 672 | m.viper.SetDefault("rendering_mode", string(RenderingModeGPU)) | ||
| 760 | m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom) | 673 | m.viper.SetDefault("use_dom_zoom", defaults.UseDomZoom) | ||
| @@ -802,6 +715,13 @@ func (m *Manager) createDefaultConfig() error { | |||||
| 802 | } | 715 | } | ||
| 803 | 716 | ||||
| 804 | fmt.Printf("Created default configuration file: %s\n", configFile) | 717 | fmt.Printf("Created default configuration file: %s\n", configFile) | ||
| 718 | + | ||||
| 719 | + | // Generate JSON schema file | |||
| 720 | + | if err := GenerateSchemaFile(); err != nil { | |||
| 721 | + | // Log error but don't fail config creation | |||
| 722 | + | fmt.Fprintf(os.Stderr, "Warning: failed to generate config schema: %v\n", err) | |||
| 723 | + | } | |||
| 724 | + | ||||
| 805 | return nil | 725 | return nil | ||
| 806 | } | 726 | } | ||
| 807 | 727 | ||||
| @@ -907,17 +827,17 @@ func (m *Manager) validateAndConfigureCodecPreferences(config *Config) *Config { | |||||
| 907 | // validateColorScheme validates and normalizes the ColorScheme setting | 827 | // validateColorScheme validates and normalizes the ColorScheme setting | ||
| 908 | func (m *Manager) validateColorScheme(config *Config) { | 828 | func (m *Manager) validateColorScheme(config *Config) { | ||
| 909 | switch config.Appearance.ColorScheme { | 829 | switch config.Appearance.ColorScheme { | ||
| 910 | - | case "prefer-dark", "prefer-light", "default", "": | 830 | + | case "prefer-dark", "prefer-light", ThemeDefault, "": |
| 911 | // Valid values - no changes needed | 831 | // Valid values - no changes needed | ||
| 912 | // Empty string is treated the same as "default" | 832 | // Empty string is treated the same as "default" | ||
| 913 | if config.Appearance.ColorScheme == "" { | 833 | if config.Appearance.ColorScheme == "" { | ||
| 914 | - | config.Appearance.ColorScheme = "default" | 834 | + | config.Appearance.ColorScheme = ThemeDefault |
| 915 | - | logging.Info("Config: ColorScheme not set, defaulting to 'default' (follows system)") | 835 | + | logging.Info("Config: ColorScheme not set, defaulting to '" + ThemeDefault + "' (follows system)") |
| 916 | } | 836 | } | ||
| 917 | default: | 837 | default: | ||
| 918 | // Invalid value - warn and reset to default | 838 | // Invalid value - warn and reset to default | ||
| 919 | - | logging.Info(fmt.Sprintf("Config: Invalid color_scheme value '%s', valid values are: 'prefer-dark', 'prefer-light', 'default'. Resetting to 'default'", | 839 | + | logging.Info(fmt.Sprintf("Config: Invalid color_scheme value '%s', valid values are: 'prefer-dark', 'prefer-light', '"+ThemeDefault+"'. Resetting to '"+ThemeDefault+"'", |
| 920 | config.Appearance.ColorScheme)) | 840 | config.Appearance.ColorScheme)) | ||
| 921 | - | config.Appearance.ColorScheme = "default" | 841 | + | config.Appearance.ColorScheme = ThemeDefault |
| 922 | } | 842 | } | ||
| 923 | } | 843 | } | ||
A internal/config/constants.go +6 -0
| @@ -0,0 +1,6 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | // Color scheme constants | |||
| 4 | + | const ( | |||
| 5 | + | ThemeDefault = "default" | |||
| 6 | + | ) | |||
M internal/config/defaults.go +8 -34
| @@ -1,16 +1,8 @@ | |||||
| 1 | // Package config provides default configuration values for dumber. | 1 | // Package config provides default configuration values for dumber. | ||
| 2 | package config | 2 | package config | ||
| 3 | 3 | ||||
| 4 | - | import ( | |||
| 5 | - | "time" | |||
| 6 | - | ) | |||
| 7 | - | ||||
| 8 | // Default configuration constants | 4 | // Default configuration constants | ||
| 9 | const ( | 5 | const ( | ||
| 10 | - | // Database defaults | |||
| 11 | - | defaultMaxIdleTimeMin = 5 // minutes | |||
| 12 | - | defaultQueryTimeoutSec = 30 // seconds | |||
| 13 | - | ||||
| 14 | // History defaults | 6 | // History defaults | ||
| 15 | defaultMaxHistoryEntries = 10000 // entries | 7 | defaultMaxHistoryEntries = 10000 // entries | ||
| 16 | defaultRetentionDays = 365 // 1 year | 8 | defaultRetentionDays = 365 // 1 year | ||
| @@ -27,7 +19,7 @@ const ( | |||||
| 27 | defaultFontSize = 16 // points | 19 | defaultFontSize = 16 // points | ||
| 28 | 20 | ||||
| 29 | // Workspace defaults | 21 | // Workspace defaults | ||
| 30 | - | defaultPaneActivationShortcut = "cmdorctrl+p" | 22 | + | defaultPaneActivationShortcut = "ctrl+p" |
| 31 | defaultPaneTimeoutMilliseconds = 3000 | 23 | defaultPaneTimeoutMilliseconds = 3000 | ||
| 32 | defaultPopupPlacement = "right" | 24 | defaultPopupPlacement = "right" | ||
| 33 | 25 | ||||
| @@ -52,9 +44,7 @@ func getDefaultLogDir() string { | |||||
| 52 | func DefaultConfig() *Config { | 44 | func DefaultConfig() *Config { | ||
| 53 | return &Config{ | 45 | return &Config{ | ||
| 54 | Database: DatabaseConfig{ | 46 | Database: DatabaseConfig{ | ||
| 55 | - | MaxConnections: 1, | 47 | + | // Path is set dynamically in config.Load() |
| 56 | - | MaxIdleTime: time.Minute * defaultMaxIdleTimeMin, | |||
| 57 | - | QueryTimeout: time.Second * defaultQueryTimeoutSec, | |||
| 58 | }, | 48 | }, | ||
| 59 | History: HistoryConfig{ | 49 | History: HistoryConfig{ | ||
| 60 | MaxEntries: defaultMaxHistoryEntries, | 50 | MaxEntries: defaultMaxHistoryEntries, | ||
| @@ -131,20 +121,8 @@ func DefaultConfig() *Config { | |||||
| 131 | VideoBufferSizeMB: 16, // Larger buffer for AV1 streams | 121 | VideoBufferSizeMB: 16, // Larger buffer for AV1 streams | ||
| 132 | QueueBufferTimeSec: 10, // More buffering time for smooth playback | 122 | QueueBufferTimeSec: 10, // More buffering time for smooth playback | ||
| 133 | CustomUserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", // Chrome UA with AV1 support | 123 | CustomUserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", // Chrome UA with AV1 support | ||
| 134 | - | AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res | 124 | + | AV1MaxResolution: "1080p", // Optimal AV1 up to 1080p, fallback to VP9 for higher res |
| 135 | - | DisableTwitchCodecControl: true, // Disable codec control on Twitch by default (prevents theater/fullscreen freezing) | 125 | + | DisableTwitchCodecControl: true, // Disable codec control on Twitch by default (prevents theater/fullscreen freezing) |
| 136 | - | }, | |||
| 137 | - | WebkitMemory: WebkitMemoryConfig{ | |||
| 138 | - | CacheModel: "web_browser", // Aggressive caching for fast page loads | |||
| 139 | - | EnablePageCache: true, // Instant back/forward navigation | |||
| 140 | - | MemoryLimitMB: 0, // Use system default | |||
| 141 | - | ConservativeThreshold: 0.4, // Start cleanup at 40% | |||
| 142 | - | StrictThreshold: 0.6, // Strict cleanup at 60% | |||
| 143 | - | KillThreshold: 0.8, // Kill processes at 80% | |||
| 144 | - | PollIntervalSeconds: 45.0, // Check every 45 seconds | |||
| 145 | - | EnableGCInterval: 120, // GC every 2 minutes | |||
| 146 | - | ProcessRecycleThreshold: 50, // Recycle after 50 page loads | |||
| 147 | - | EnableMemoryMonitoring: true, // Monitor for production tuning | |||
| 148 | }, | 126 | }, | ||
| 149 | Debug: DebugConfig{ | 127 | Debug: DebugConfig{ | ||
| 150 | EnableWebKitDebug: false, | 128 | EnableWebKitDebug: false, | ||
| @@ -160,10 +138,6 @@ func DefaultConfig() *Config { | |||||
| 160 | EnableFocusMetrics: false, | 138 | EnableFocusMetrics: false, | ||
| 161 | EnablePaneCloseDebug: false, | 139 | EnablePaneCloseDebug: false, | ||
| 162 | }, | 140 | }, | ||
| 163 | - | APISecurity: APISecurityConfig{ | |||
| 164 | - | Token: "", | |||
| 165 | - | RequireToken: false, | |||
| 166 | - | }, | |||
| 167 | RenderingMode: RenderingModeGPU, | 141 | RenderingMode: RenderingModeGPU, | ||
| 168 | UseDomZoom: false, | 142 | UseDomZoom: false, | ||
| 169 | DefaultZoom: 1.2, // 120% default zoom for better readability | 143 | DefaultZoom: 1.2, // 120% default zoom for better readability | ||
| @@ -187,10 +161,10 @@ func DefaultConfig() *Config { | |||||
| 187 | }, | 161 | }, | ||
| 188 | }, | 162 | }, | ||
| 189 | Tabs: TabKeyConfig{ | 163 | Tabs: TabKeyConfig{ | ||
| 190 | - | NewTab: "cmdorctrl+t", | 164 | + | NewTab: "ctrl+t", |
| 191 | - | CloseTab: "cmdorctrl+w", | 165 | + | CloseTab: "ctrl+w", |
| 192 | - | NextTab: "cmdorctrl+tab", | 166 | + | NextTab: "ctrl+tab", |
| 193 | - | PreviousTab: "cmdorctrl+shift+tab", | 167 | + | PreviousTab: "ctrl+shift+tab", |
| 194 | }, | 168 | }, | ||
| 195 | Popups: PopupBehaviorConfig{ | 169 | Popups: PopupBehaviorConfig{ | ||
| 196 | Behavior: PopupBehaviorSplit, // Default: open popups in split panes | 170 | Behavior: PopupBehaviorSplit, // Default: open popups in split panes | ||
A internal/config/schema.go +44 -0
| @@ -0,0 +1,44 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "fmt" | |||
| 6 | + | "os" | |||
| 7 | + | "path/filepath" | |||
| 8 | + | ||||
| 9 | + | "github.com/invopop/jsonschema" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // GenerateSchemaFile generates a JSON schema file for the configuration. | |||
| 13 | + | // This is called automatically when a default config is created. | |||
| 14 | + | func GenerateSchemaFile() error { | |||
| 15 | + | configDir, err := GetConfigDir() | |||
| 16 | + | if err != nil { | |||
| 17 | + | return fmt.Errorf("failed to get config directory: %w", err) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | schemaFile := filepath.Join(configDir, "config.schema.json") | |||
| 21 | + | ||||
| 22 | + | // Create reflector and generate schema | |||
| 23 | + | r := new(jsonschema.Reflector) | |||
| 24 | + | schema := r.Reflect(&Config{}) | |||
| 25 | + | ||||
| 26 | + | // Set schema metadata | |||
| 27 | + | schema.ID = "https://github.com/bnema/dumber/config.schema.json" | |||
| 28 | + | schema.Title = "Dumber Browser Configuration" | |||
| 29 | + | schema.Description = "Configuration schema for Dumber, a minimalist browser for tiling window managers" | |||
| 30 | + | ||||
| 31 | + | // Marshal to pretty JSON | |||
| 32 | + | data, err := json.MarshalIndent(schema, "", " ") | |||
| 33 | + | if err != nil { | |||
| 34 | + | return fmt.Errorf("failed to marshal schema: %w", err) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | // Write schema file | |||
| 38 | + | if err := os.WriteFile(schemaFile, data, filePerm); err != nil { | |||
| 39 | + | return fmt.Errorf("failed to write schema file: %w", err) | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | fmt.Printf("Generated JSON schema: %s\n", schemaFile) | |||
| 43 | + | return nil | |||
| 44 | + | } | |||
M internal/config/xdg.go +9 -4
| @@ -6,7 +6,10 @@ import ( | |||||
| 6 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | - | const appName = "dumber" | 9 | + | const ( |
| 10 | + | appName = "dumber" | |||
| 11 | + | databaseName = "dumber.sqlite" | |||
| 12 | + | ) | |||
| 10 | 13 | ||||
| 11 | // XDGDirs holds the XDG Base Directory paths for the application. | 14 | // XDGDirs holds the XDG Base Directory paths for the application. | ||
| 12 | type XDGDirs struct { | 15 | type XDGDirs struct { | ||
| @@ -114,13 +117,15 @@ func GetConfigFile() (string, error) { | |||||
| 114 | return filepath.Join(configDir, "config.json"), nil | 117 | return filepath.Join(configDir, "config.json"), nil | ||
| 115 | } | 118 | } | ||
| 116 | 119 | ||||
| 117 | - | // GetDatabaseFile returns the path to the database file in the state directory. | 120 | + | // GetDatabaseFile returns the path to the database file in the data directory. |
| 121 | + | // The database contains important user data (history, cookies, preferences) and | |||
| 122 | + | // therefore belongs in XDG_DATA_HOME, not XDG_STATE_HOME. | |||
| 118 | func GetDatabaseFile() (string, error) { | 123 | func GetDatabaseFile() (string, error) { | ||
| 119 | - | stateDir, err := GetStateDir() | 124 | + | dataDir, err := GetDataDir() |
| 120 | if err != nil { | 125 | if err != nil { | ||
| 121 | return "", err | 126 | return "", err | ||
| 122 | } | 127 | } | ||
| 123 | - | return filepath.Join(stateDir, "history.db"), nil | 128 | + | return filepath.Join(dataDir, databaseName), nil |
| 124 | } | 129 | } | ||
| 125 | 130 | ||||
| 126 | // GetFilterCacheDir returns the XDG-compliant filter cache directory for dumber. | 131 | // GetFilterCacheDir returns the XDG-compliant filter cache directory for dumber. | ||
M internal/db/certificate_validations.sql.go +37 -1
| @@ -11,7 +11,7 @@ import ( | |||||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | const DeleteCertificateValidation = `-- name: DeleteCertificateValidation :exec | 13 | const DeleteCertificateValidation = `-- name: DeleteCertificateValidation :exec | ||
| 14 | - | DELETE FROM certificate_validations | 14 | + | DELETE FROM certificate_validations |
| 15 | WHERE hostname = ? AND certificate_hash = ? | 15 | WHERE hostname = ? AND certificate_hash = ? | ||
| 16 | ` | 16 | ` | ||
| 17 | 17 | ||||
| @@ -73,6 +73,42 @@ func (q *Queries) GetCertificateValidationByHostname(ctx context.Context, hostna | |||||
| 73 | return i, err | 73 | return i, err | ||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | + | const ListCertificateValidations = `-- name: ListCertificateValidations :many | |||
| 77 | + | SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations | |||
| 78 | + | WHERE (expires_at IS NULL OR expires_at > datetime('now')) | |||
| 79 | + | ORDER BY hostname | |||
| 80 | + | ` | |||
| 81 | + | ||||
| 82 | + | func (q *Queries) ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error) { | |||
| 83 | + | rows, err := q.db.QueryContext(ctx, ListCertificateValidations) | |||
| 84 | + | if err != nil { | |||
| 85 | + | return nil, err | |||
| 86 | + | } | |||
| 87 | + | defer rows.Close() | |||
| 88 | + | items := []CertificateValidation{} | |||
| 89 | + | for rows.Next() { | |||
| 90 | + | var i CertificateValidation | |||
| 91 | + | if err := rows.Scan( | |||
| 92 | + | &i.ID, | |||
| 93 | + | &i.Hostname, | |||
| 94 | + | &i.CertificateHash, | |||
| 95 | + | &i.UserDecision, | |||
| 96 | + | &i.CreatedAt, | |||
| 97 | + | &i.ExpiresAt, | |||
| 98 | + | ); err != nil { | |||
| 99 | + | return nil, err | |||
| 100 | + | } | |||
| 101 | + | items = append(items, i) | |||
| 102 | + | } | |||
| 103 | + | if err := rows.Close(); err != nil { | |||
| 104 | + | return nil, err | |||
| 105 | + | } | |||
| 106 | + | if err := rows.Err(); err != nil { | |||
| 107 | + | return nil, err | |||
| 108 | + | } | |||
| 109 | + | return items, nil | |||
| 110 | + | } | |||
| 111 | + | ||||
| 76 | const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec | 112 | const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec | ||
| 77 | INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at) | 113 | INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at) | ||
| 78 | VALUES (?, ?, ?, ?) | 114 | VALUES (?, ?, ?, ?) | ||
M internal/db/history.sql.go +0 -35
| @@ -133,41 +133,6 @@ func (q *Queries) GetHistoryWithOffset(ctx context.Context, limit int64, offset | |||||
| 133 | return items, nil | 133 | return items, nil | ||
| 134 | } | 134 | } | ||
| 135 | 135 | ||||
| 136 | - | const GetShortcuts = `-- name: GetShortcuts :many | |||
| 137 | - | SELECT id, shortcut, url_template, description, created_at | |||
| 138 | - | FROM shortcuts | |||
| 139 | - | ORDER BY shortcut | |||
| 140 | - | ` | |||
| 141 | - | ||||
| 142 | - | func (q *Queries) GetShortcuts(ctx context.Context) ([]Shortcut, error) { | |||
| 143 | - | rows, err := q.db.QueryContext(ctx, GetShortcuts) | |||
| 144 | - | if err != nil { | |||
| 145 | - | return nil, err | |||
| 146 | - | } | |||
| 147 | - | defer rows.Close() | |||
| 148 | - | items := []Shortcut{} | |||
| 149 | - | for rows.Next() { | |||
| 150 | - | var i Shortcut | |||
| 151 | - | if err := rows.Scan( | |||
| 152 | - | &i.ID, | |||
| 153 | - | &i.Shortcut, | |||
| 154 | - | &i.UrlTemplate, | |||
| 155 | - | &i.Description, | |||
| 156 | - | &i.CreatedAt, | |||
| 157 | - | ); err != nil { | |||
| 158 | - | return nil, err | |||
| 159 | - | } | |||
| 160 | - | items = append(items, i) | |||
| 161 | - | } | |||
| 162 | - | if err := rows.Close(); err != nil { | |||
| 163 | - | return nil, err | |||
| 164 | - | } | |||
| 165 | - | if err := rows.Err(); err != nil { | |||
| 166 | - | return nil, err | |||
| 167 | - | } | |||
| 168 | - | return items, nil | |||
| 169 | - | } | |||
| 170 | - | ||||
| 171 | const SearchHistory = `-- name: SearchHistory :many | 136 | const SearchHistory = `-- name: SearchHistory :many | ||
| 172 | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url | 137 | SELECT id, url, title, visit_count, last_visited, created_at, favicon_url | ||
| 173 | FROM history | 138 | FROM history | ||
M internal/db/init.go +22 -47
| @@ -7,7 +7,6 @@ import ( | |||||
| 7 | "os" | 7 | "os" | ||
| 8 | "path/filepath" | 8 | "path/filepath" | ||
| 9 | 9 | ||||
| 10 | - | "github.com/bnema/dumber/internal/config" | |||
| 11 | "github.com/bnema/dumber/internal/migrations" | 10 | "github.com/bnema/dumber/internal/migrations" | ||
| 12 | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver | 11 | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver | ||
| 13 | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite | 12 | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite | ||
| @@ -20,7 +19,7 @@ func InitDB(dbPath string) (*sql.DB, error) { | |||||
| 20 | } | 19 | } | ||
| 21 | 20 | ||||
| 22 | // Ensure database directory exists | 21 | // Ensure database directory exists | ||
| 23 | - | if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil { | 22 | + | if err := os.MkdirAll(filepath.Dir(dbPath), 0750); err != nil { |
| 24 | return nil, fmt.Errorf("failed to create database directory: %w", err) | 23 | return nil, fmt.Errorf("failed to create database directory: %w", err) | ||
| 25 | } | 24 | } | ||
| 26 | 25 | ||||
| @@ -38,59 +37,35 @@ func InitDB(dbPath string) (*sql.DB, error) { | |||||
| 38 | return nil, fmt.Errorf("failed to connect to database: %w", err) | 37 | return nil, fmt.Errorf("failed to connect to database: %w", err) | ||
| 39 | } | 38 | } | ||
| 40 | 39 | ||||
| 41 | - | // Run embedded migrations - single source of truth for schema initialization | 40 | + | // Configure SQLite for optimal performance |
| 42 | - | if err := migrations.RunEmbeddedMigrations(database); err != nil { | 41 | + | pragmas := []string{ |
| 43 | - | if err := database.Close(); err != nil { | 42 | + | "PRAGMA journal_mode = WAL", // Write-Ahead Logging: 70k reads/sec, 3.6k writes/sec, concurrent access |
| 44 | - | log.Printf("Warning: failed to close database: %v", err) | 43 | + | "PRAGMA synchronous = NORMAL", // Safe in WAL mode, only checkpoints wait for FSYNC |
| 45 | - | } | 44 | + | "PRAGMA cache_size = -64000", // 64MB cache (default is only ~2MB) |
| 46 | - | return nil, fmt.Errorf("failed to run database migrations: %w", err) | 45 | + | "PRAGMA temp_store = MEMORY", // Temporary tables in RAM |
| 46 | + | "PRAGMA mmap_size = 30000000000", // 30GB memory-mapped I/O (virtual memory, OS-managed) | |||
| 47 | + | "PRAGMA busy_timeout = 5000", // Wait 5 seconds on lock contention | |||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| 49 | - | // Verify all migrations are applied | 50 | + | for _, pragma := range pragmas { |
| 50 | - | if err := migrations.VerifyAllMigrationsApplied(database); err != nil { | 51 | + | if _, err := database.Exec(pragma); err != nil { |
| 51 | - | if err := database.Close(); err != nil { | 52 | + | if err := database.Close(); err != nil { |
| 52 | - | log.Printf("Warning: failed to close database: %v", err) | 53 | + | log.Printf("Warning: failed to close database: %v", err) |
| 54 | + | } | |||
| 55 | + | return nil, fmt.Errorf("failed to set pragma %q: %w", pragma, err) | |||
| 53 | } | 56 | } | ||
| 54 | - | return nil, fmt.Errorf("migration verification failed: %w", err) | |||
| 55 | } | 57 | } | ||
| 56 | 58 | ||||
| 57 | - | return database, nil | 59 | + | log.Printf("SQLite configured with WAL mode and performance optimizations") |
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | // InitDBWithConfig initializes the database with configuration-based shortcuts | |||
| 61 | - | func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) { | |||
| 62 | - | db, err := InitDB(dbPath) | |||
| 63 | - | if err != nil { | |||
| 64 | - | return nil, err | |||
| 65 | - | } | |||
| 66 | 60 | ||||
| 67 | - | // Initialize shortcuts from configuration | 61 | + | // Run embedded migrations - single source of truth for schema initialization |
| 68 | - | if err := initializeShortcuts(db, cfg); err != nil { | 62 | + | // RunEmbeddedMigrations is idempotent and checks if migrations are already applied |
| 69 | - | if err := db.Close(); err != nil { | 63 | + | if err := migrations.RunEmbeddedMigrations(database); err != nil { |
| 64 | + | if err := database.Close(); err != nil { | |||
| 70 | log.Printf("Warning: failed to close database: %v", err) | 65 | log.Printf("Warning: failed to close database: %v", err) | ||
| 71 | } | 66 | } | ||
| 72 | - | return nil, fmt.Errorf("failed to initialize shortcuts: %w", err) | 67 | + | return nil, fmt.Errorf("failed to run database migrations: %w", err) |
| 73 | - | } | |||
| 74 | - | ||||
| 75 | - | return db, nil | |||
| 76 | - | } | |||
| 77 | - | ||||
| 78 | - | // initializeShortcuts inserts configured shortcuts into the database | |||
| 79 | - | func initializeShortcuts(db *sql.DB, cfg *config.Config) error { | |||
| 80 | - | if cfg == nil || cfg.SearchShortcuts == nil { | |||
| 81 | - | return nil // No shortcuts to initialize | |||
| 82 | - | } | |||
| 83 | - | ||||
| 84 | - | // Insert configured shortcuts | |||
| 85 | - | for shortcut, shortcutCfg := range cfg.SearchShortcuts { | |||
| 86 | - | _, err := db.Exec( | |||
| 87 | - | "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)", | |||
| 88 | - | shortcut, shortcutCfg.URL, shortcutCfg.Description, | |||
| 89 | - | ) | |||
| 90 | - | if err != nil { | |||
| 91 | - | return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err) | |||
| 92 | - | } | |||
| 93 | } | 68 | } | ||
| 94 | 69 | ||||
| 95 | - | return nil | 70 | + | return database, nil |
| 96 | } | 71 | } | ||
M internal/db/interfaces.go +10 -4
| @@ -12,6 +12,7 @@ type ZoomQuerier interface { | |||||
| 12 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | 12 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | ||
| 13 | SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error | 13 | SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error | ||
| 14 | DeleteZoomLevel(ctx context.Context, domain string) error | 14 | DeleteZoomLevel(ctx context.Context, domain string) error | ||
| 15 | + | ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) | |||
| 15 | } | 16 | } | ||
| 16 | 17 | ||||
| 17 | // HistoryQuerier defines the interface for history-related database operations | 18 | // HistoryQuerier defines the interface for history-related database operations | ||
| @@ -25,16 +26,21 @@ type HistoryQuerier interface { | |||||
| 25 | DeleteHistory(ctx context.Context, id int64) error | 26 | DeleteHistory(ctx context.Context, id int64) error | ||
| 26 | } | 27 | } | ||
| 27 | 28 | ||||
| 28 | - | // ShortcutQuerier defines the interface for shortcut-related database operations | 29 | + | // CertificateQuerier defines the interface for certificate validation-related database operations |
| 29 | - | type ShortcutQuerier interface { | 30 | + | type CertificateQuerier interface { |
| 30 | - | GetShortcuts(ctx context.Context) ([]Shortcut, error) | 31 | + | ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error) |
| 32 | + | GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error) | |||
| 33 | + | GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error) | |||
| 34 | + | StoreCertificateValidation(ctx context.Context, hostname string, certificateHash string, userDecision string, expiresAt sql.NullTime) error | |||
| 35 | + | DeleteCertificateValidation(ctx context.Context, hostname string, certificateHash string) error | |||
| 36 | + | DeleteExpiredCertificateValidations(ctx context.Context) error | |||
| 31 | } | 37 | } | ||
| 32 | 38 | ||||
| 33 | // DatabaseQuerier combines all database operation interfaces | 39 | // DatabaseQuerier combines all database operation interfaces | ||
| 34 | type DatabaseQuerier interface { | 40 | type DatabaseQuerier interface { | ||
| 35 | ZoomQuerier | 41 | ZoomQuerier | ||
| 36 | HistoryQuerier | 42 | HistoryQuerier | ||
| 37 | - | ShortcutQuerier | 43 | + | CertificateQuerier |
| 38 | } | 44 | } | ||
| 39 | 45 | ||||
| 40 | // Ensure that *Queries implements DatabaseQuerier interface | 46 | // Ensure that *Queries implements DatabaseQuerier interface | ||
M internal/db/mocks/mock_db.go +337 -38
| @@ -1,5 +1,10 @@ | |||||
| 1 | // Code generated by MockGen. DO NOT EDIT. | 1 | // Code generated by MockGen. DO NOT EDIT. | ||
| 2 | // Source: interfaces.go | 2 | // Source: interfaces.go | ||
| 3 | + | // | |||
| 4 | + | // Generated by this command: | |||
| 5 | + | // | |||
| 6 | + | // mockgen -source=interfaces.go -destination=mocks/mock_db.go | |||
| 7 | + | // | |||
| 3 | 8 | ||||
| 4 | // Package mock_db is a generated GoMock package. | 9 | // Package mock_db is a generated GoMock package. | ||
| 5 | package mock_db | 10 | package mock_db | ||
| @@ -10,13 +15,14 @@ import ( | |||||
| 10 | reflect "reflect" | 15 | reflect "reflect" | ||
| 11 | 16 | ||||
| 12 | db "github.com/bnema/dumber/internal/db" | 17 | db "github.com/bnema/dumber/internal/db" | ||
| 13 | - | gomock "github.com/golang/mock/gomock" | 18 | + | gomock "go.uber.org/mock/gomock" |
| 14 | ) | 19 | ) | ||
| 15 | 20 | ||||
| 16 | // MockZoomQuerier is a mock of ZoomQuerier interface. | 21 | // MockZoomQuerier is a mock of ZoomQuerier interface. | ||
| 17 | type MockZoomQuerier struct { | 22 | type MockZoomQuerier struct { | ||
| 18 | ctrl *gomock.Controller | 23 | ctrl *gomock.Controller | ||
| 19 | recorder *MockZoomQuerierMockRecorder | 24 | recorder *MockZoomQuerierMockRecorder | ||
| 25 | + | isgomock struct{} | |||
| 20 | } | 26 | } | ||
| 21 | 27 | ||||
| 22 | // MockZoomQuerierMockRecorder is the mock recorder for MockZoomQuerier. | 28 | // MockZoomQuerierMockRecorder is the mock recorder for MockZoomQuerier. | ||
| @@ -45,7 +51,7 @@ func (m *MockZoomQuerier) DeleteZoomLevel(ctx context.Context, domain string) er | |||||
| 45 | } | 51 | } | ||
| 46 | 52 | ||||
| 47 | // DeleteZoomLevel indicates an expected call of DeleteZoomLevel. | 53 | // DeleteZoomLevel indicates an expected call of DeleteZoomLevel. | ||
| 48 | - | func (mr *MockZoomQuerierMockRecorder) DeleteZoomLevel(ctx, domain interface{}) *gomock.Call { | 54 | + | func (mr *MockZoomQuerierMockRecorder) DeleteZoomLevel(ctx, domain any) *gomock.Call { |
| 49 | mr.mock.ctrl.T.Helper() | 55 | mr.mock.ctrl.T.Helper() | ||
| 50 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).DeleteZoomLevel), ctx, domain) | 56 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).DeleteZoomLevel), ctx, domain) | ||
| 51 | } | 57 | } | ||
| @@ -60,11 +66,26 @@ func (m *MockZoomQuerier) GetZoomLevel(ctx context.Context, domain string) (floa | |||||
| 60 | } | 66 | } | ||
| 61 | 67 | ||||
| 62 | // GetZoomLevel indicates an expected call of GetZoomLevel. | 68 | // GetZoomLevel indicates an expected call of GetZoomLevel. | ||
| 63 | - | func (mr *MockZoomQuerierMockRecorder) GetZoomLevel(ctx, domain interface{}) *gomock.Call { | 69 | + | func (mr *MockZoomQuerierMockRecorder) GetZoomLevel(ctx, domain any) *gomock.Call { |
| 64 | mr.mock.ctrl.T.Helper() | 70 | mr.mock.ctrl.T.Helper() | ||
| 65 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).GetZoomLevel), ctx, domain) | 71 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).GetZoomLevel), ctx, domain) | ||
| 66 | } | 72 | } | ||
| 67 | 73 | ||||
| 74 | + | // ListZoomLevels mocks base method. | |||
| 75 | + | func (m *MockZoomQuerier) ListZoomLevels(ctx context.Context) ([]db.ZoomLevel, error) { | |||
| 76 | + | m.ctrl.T.Helper() | |||
| 77 | + | ret := m.ctrl.Call(m, "ListZoomLevels", ctx) | |||
| 78 | + | ret0, _ := ret[0].([]db.ZoomLevel) | |||
| 79 | + | ret1, _ := ret[1].(error) | |||
| 80 | + | return ret0, ret1 | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // ListZoomLevels indicates an expected call of ListZoomLevels. | |||
| 84 | + | func (mr *MockZoomQuerierMockRecorder) ListZoomLevels(ctx any) *gomock.Call { | |||
| 85 | + | mr.mock.ctrl.T.Helper() | |||
| 86 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListZoomLevels", reflect.TypeOf((*MockZoomQuerier)(nil).ListZoomLevels), ctx) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 68 | // SetZoomLevel mocks base method. | 89 | // SetZoomLevel mocks base method. | ||
| 69 | func (m *MockZoomQuerier) SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error { | 90 | func (m *MockZoomQuerier) SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error { | ||
| 70 | m.ctrl.T.Helper() | 91 | m.ctrl.T.Helper() | ||
| @@ -74,7 +95,7 @@ func (m *MockZoomQuerier) SetZoomLevel(ctx context.Context, domain string, zoomL | |||||
| 74 | } | 95 | } | ||
| 75 | 96 | ||||
| 76 | // SetZoomLevel indicates an expected call of SetZoomLevel. | 97 | // SetZoomLevel indicates an expected call of SetZoomLevel. | ||
| 77 | - | func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel interface{}) *gomock.Call { | 98 | + | func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel any) *gomock.Call { |
| 78 | mr.mock.ctrl.T.Helper() | 99 | mr.mock.ctrl.T.Helper() | ||
| 79 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel) | 100 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel) | ||
| 80 | } | 101 | } | ||
| @@ -83,6 +104,7 @@ func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel inter | |||||
| 83 | type MockHistoryQuerier struct { | 104 | type MockHistoryQuerier struct { | ||
| 84 | ctrl *gomock.Controller | 105 | ctrl *gomock.Controller | ||
| 85 | recorder *MockHistoryQuerierMockRecorder | 106 | recorder *MockHistoryQuerierMockRecorder | ||
| 107 | + | isgomock struct{} | |||
| 86 | } | 108 | } | ||
| 87 | 109 | ||||
| 88 | // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier. | 110 | // MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier. | ||
| @@ -111,11 +133,25 @@ func (m *MockHistoryQuerier) AddOrUpdateHistory(ctx context.Context, url string, | |||||
| 111 | } | 133 | } | ||
| 112 | 134 | ||||
| 113 | // AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory. | 135 | // AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory. | ||
| 114 | - | func (mr *MockHistoryQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title interface{}) *gomock.Call { | 136 | + | func (mr *MockHistoryQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title any) *gomock.Call { |
| 115 | mr.mock.ctrl.T.Helper() | 137 | mr.mock.ctrl.T.Helper() | ||
| 116 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).AddOrUpdateHistory), ctx, url, title) | 138 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).AddOrUpdateHistory), ctx, url, title) | ||
| 117 | } | 139 | } | ||
| 118 | 140 | ||||
| 141 | + | // DeleteHistory mocks base method. | |||
| 142 | + | func (m *MockHistoryQuerier) DeleteHistory(ctx context.Context, id int64) error { | |||
| 143 | + | m.ctrl.T.Helper() | |||
| 144 | + | ret := m.ctrl.Call(m, "DeleteHistory", ctx, id) | |||
| 145 | + | ret0, _ := ret[0].(error) | |||
| 146 | + | return ret0 | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | // DeleteHistory indicates an expected call of DeleteHistory. | |||
| 150 | + | func (mr *MockHistoryQuerierMockRecorder) DeleteHistory(ctx, id any) *gomock.Call { | |||
| 151 | + | mr.mock.ctrl.T.Helper() | |||
| 152 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).DeleteHistory), ctx, id) | |||
| 153 | + | } | |||
| 154 | + | ||||
| 119 | // GetHistory mocks base method. | 155 | // GetHistory mocks base method. | ||
| 120 | func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) { | 156 | func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) { | ||
| 121 | m.ctrl.T.Helper() | 157 | m.ctrl.T.Helper() | ||
| @@ -126,11 +162,41 @@ func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db. | |||||
| 126 | } | 162 | } | ||
| 127 | 163 | ||||
| 128 | // GetHistory indicates an expected call of GetHistory. | 164 | // GetHistory indicates an expected call of GetHistory. | ||
| 129 | - | func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call { | 165 | + | func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit any) *gomock.Call { |
| 130 | mr.mock.ctrl.T.Helper() | 166 | mr.mock.ctrl.T.Helper() | ||
| 131 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit) | 167 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit) | ||
| 132 | } | 168 | } | ||
| 133 | 169 | ||||
| 170 | + | // GetHistoryEntry mocks base method. | |||
| 171 | + | func (m *MockHistoryQuerier) GetHistoryEntry(ctx context.Context, url string) (db.History, error) { | |||
| 172 | + | m.ctrl.T.Helper() | |||
| 173 | + | ret := m.ctrl.Call(m, "GetHistoryEntry", ctx, url) | |||
| 174 | + | ret0, _ := ret[0].(db.History) | |||
| 175 | + | ret1, _ := ret[1].(error) | |||
| 176 | + | return ret0, ret1 | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | // GetHistoryEntry indicates an expected call of GetHistoryEntry. | |||
| 180 | + | func (mr *MockHistoryQuerierMockRecorder) GetHistoryEntry(ctx, url any) *gomock.Call { | |||
| 181 | + | mr.mock.ctrl.T.Helper() | |||
| 182 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryEntry", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistoryEntry), ctx, url) | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // GetHistoryWithOffset mocks base method. | |||
| 186 | + | func (m *MockHistoryQuerier) GetHistoryWithOffset(ctx context.Context, limit, offset int64) ([]db.History, error) { | |||
| 187 | + | m.ctrl.T.Helper() | |||
| 188 | + | ret := m.ctrl.Call(m, "GetHistoryWithOffset", ctx, limit, offset) | |||
| 189 | + | ret0, _ := ret[0].([]db.History) | |||
| 190 | + | ret1, _ := ret[1].(error) | |||
| 191 | + | return ret0, ret1 | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // GetHistoryWithOffset indicates an expected call of GetHistoryWithOffset. | |||
| 195 | + | func (mr *MockHistoryQuerierMockRecorder) GetHistoryWithOffset(ctx, limit, offset any) *gomock.Call { | |||
| 196 | + | mr.mock.ctrl.T.Helper() | |||
| 197 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryWithOffset", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistoryWithOffset), ctx, limit, offset) | |||
| 198 | + | } | |||
| 199 | + | ||||
| 134 | // SearchHistory mocks base method. | 200 | // SearchHistory mocks base method. | ||
| 135 | func (m *MockHistoryQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) { | 201 | func (m *MockHistoryQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) { | ||
| 136 | m.ctrl.T.Helper() | 202 | m.ctrl.T.Helper() | ||
| @@ -141,53 +207,141 @@ func (m *MockHistoryQuerier) SearchHistory(ctx context.Context, column1, column2 | |||||
| 141 | } | 207 | } | ||
| 142 | 208 | ||||
| 143 | // SearchHistory indicates an expected call of SearchHistory. | 209 | // SearchHistory indicates an expected call of SearchHistory. | ||
| 144 | - | func (mr *MockHistoryQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit interface{}) *gomock.Call { | 210 | + | func (mr *MockHistoryQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit any) *gomock.Call { |
| 145 | mr.mock.ctrl.T.Helper() | 211 | mr.mock.ctrl.T.Helper() | ||
| 146 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).SearchHistory), ctx, column1, column2, limit) | 212 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).SearchHistory), ctx, column1, column2, limit) | ||
| 147 | } | 213 | } | ||
| 148 | 214 | ||||
| 149 | - | // MockShortcutQuerier is a mock of ShortcutQuerier interface. | 215 | + | // UpdateHistoryFavicon mocks base method. |
| 150 | - | type MockShortcutQuerier struct { | 216 | + | func (m *MockHistoryQuerier) UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error { |
| 217 | + | m.ctrl.T.Helper() | |||
| 218 | + | ret := m.ctrl.Call(m, "UpdateHistoryFavicon", ctx, faviconUrl, url) | |||
| 219 | + | ret0, _ := ret[0].(error) | |||
| 220 | + | return ret0 | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // UpdateHistoryFavicon indicates an expected call of UpdateHistoryFavicon. | |||
| 224 | + | func (mr *MockHistoryQuerierMockRecorder) UpdateHistoryFavicon(ctx, faviconUrl, url any) *gomock.Call { | |||
| 225 | + | mr.mock.ctrl.T.Helper() | |||
| 226 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateHistoryFavicon", reflect.TypeOf((*MockHistoryQuerier)(nil).UpdateHistoryFavicon), ctx, faviconUrl, url) | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // MockCertificateQuerier is a mock of CertificateQuerier interface. | |||
| 230 | + | type MockCertificateQuerier struct { | |||
| 151 | ctrl *gomock.Controller | 231 | ctrl *gomock.Controller | ||
| 152 | - | recorder *MockShortcutQuerierMockRecorder | 232 | + | recorder *MockCertificateQuerierMockRecorder |
| 233 | + | isgomock struct{} | |||
| 153 | } | 234 | } | ||
| 154 | 235 | ||||
| 155 | - | // MockShortcutQuerierMockRecorder is the mock recorder for MockShortcutQuerier. | 236 | + | // MockCertificateQuerierMockRecorder is the mock recorder for MockCertificateQuerier. |
| 156 | - | type MockShortcutQuerierMockRecorder struct { | 237 | + | type MockCertificateQuerierMockRecorder struct { |
| 157 | - | mock *MockShortcutQuerier | 238 | + | mock *MockCertificateQuerier |
| 158 | } | 239 | } | ||
| 159 | 240 | ||||
| 160 | - | // NewMockShortcutQuerier creates a new mock instance. | 241 | + | // NewMockCertificateQuerier creates a new mock instance. |
| 161 | - | func NewMockShortcutQuerier(ctrl *gomock.Controller) *MockShortcutQuerier { | 242 | + | func NewMockCertificateQuerier(ctrl *gomock.Controller) *MockCertificateQuerier { |
| 162 | - | mock := &MockShortcutQuerier{ctrl: ctrl} | 243 | + | mock := &MockCertificateQuerier{ctrl: ctrl} |
| 163 | - | mock.recorder = &MockShortcutQuerierMockRecorder{mock} | 244 | + | mock.recorder = &MockCertificateQuerierMockRecorder{mock} |
| 164 | return mock | 245 | return mock | ||
| 165 | } | 246 | } | ||
| 166 | 247 | ||||
| 167 | // EXPECT returns an object that allows the caller to indicate expected use. | 248 | // EXPECT returns an object that allows the caller to indicate expected use. | ||
| 168 | - | func (m *MockShortcutQuerier) EXPECT() *MockShortcutQuerierMockRecorder { | 249 | + | func (m *MockCertificateQuerier) EXPECT() *MockCertificateQuerierMockRecorder { |
| 169 | return m.recorder | 250 | return m.recorder | ||
| 170 | } | 251 | } | ||
| 171 | 252 | ||||
| 172 | - | // GetShortcuts mocks base method. | 253 | + | // DeleteCertificateValidation mocks base method. |
| 173 | - | func (m *MockShortcutQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) { | 254 | + | func (m *MockCertificateQuerier) DeleteCertificateValidation(ctx context.Context, hostname, certificateHash string) error { |
| 174 | m.ctrl.T.Helper() | 255 | m.ctrl.T.Helper() | ||
| 175 | - | ret := m.ctrl.Call(m, "GetShortcuts", ctx) | 256 | + | ret := m.ctrl.Call(m, "DeleteCertificateValidation", ctx, hostname, certificateHash) |
| 176 | - | ret0, _ := ret[0].([]db.Shortcut) | 257 | + | ret0, _ := ret[0].(error) |
| 258 | + | return ret0 | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | // DeleteCertificateValidation indicates an expected call of DeleteCertificateValidation. | |||
| 262 | + | func (mr *MockCertificateQuerierMockRecorder) DeleteCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call { | |||
| 263 | + | mr.mock.ctrl.T.Helper() | |||
| 264 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCertificateValidation", reflect.TypeOf((*MockCertificateQuerier)(nil).DeleteCertificateValidation), ctx, hostname, certificateHash) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | // DeleteExpiredCertificateValidations mocks base method. | |||
| 268 | + | func (m *MockCertificateQuerier) DeleteExpiredCertificateValidations(ctx context.Context) error { | |||
| 269 | + | m.ctrl.T.Helper() | |||
| 270 | + | ret := m.ctrl.Call(m, "DeleteExpiredCertificateValidations", ctx) | |||
| 271 | + | ret0, _ := ret[0].(error) | |||
| 272 | + | return ret0 | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // DeleteExpiredCertificateValidations indicates an expected call of DeleteExpiredCertificateValidations. | |||
| 276 | + | func (mr *MockCertificateQuerierMockRecorder) DeleteExpiredCertificateValidations(ctx any) *gomock.Call { | |||
| 277 | + | mr.mock.ctrl.T.Helper() | |||
| 278 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExpiredCertificateValidations", reflect.TypeOf((*MockCertificateQuerier)(nil).DeleteExpiredCertificateValidations), ctx) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | // GetCertificateValidation mocks base method. | |||
| 282 | + | func (m *MockCertificateQuerier) GetCertificateValidation(ctx context.Context, hostname, certificateHash string) (db.CertificateValidation, error) { | |||
| 283 | + | m.ctrl.T.Helper() | |||
| 284 | + | ret := m.ctrl.Call(m, "GetCertificateValidation", ctx, hostname, certificateHash) | |||
| 285 | + | ret0, _ := ret[0].(db.CertificateValidation) | |||
| 286 | + | ret1, _ := ret[1].(error) | |||
| 287 | + | return ret0, ret1 | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // GetCertificateValidation indicates an expected call of GetCertificateValidation. | |||
| 291 | + | func (mr *MockCertificateQuerierMockRecorder) GetCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call { | |||
| 292 | + | mr.mock.ctrl.T.Helper() | |||
| 293 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidation", reflect.TypeOf((*MockCertificateQuerier)(nil).GetCertificateValidation), ctx, hostname, certificateHash) | |||
| 294 | + | } | |||
| 295 | + | ||||
| 296 | + | // GetCertificateValidationByHostname mocks base method. | |||
| 297 | + | func (m *MockCertificateQuerier) GetCertificateValidationByHostname(ctx context.Context, hostname string) (db.CertificateValidation, error) { | |||
| 298 | + | m.ctrl.T.Helper() | |||
| 299 | + | ret := m.ctrl.Call(m, "GetCertificateValidationByHostname", ctx, hostname) | |||
| 300 | + | ret0, _ := ret[0].(db.CertificateValidation) | |||
| 301 | + | ret1, _ := ret[1].(error) | |||
| 302 | + | return ret0, ret1 | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | // GetCertificateValidationByHostname indicates an expected call of GetCertificateValidationByHostname. | |||
| 306 | + | func (mr *MockCertificateQuerierMockRecorder) GetCertificateValidationByHostname(ctx, hostname any) *gomock.Call { | |||
| 307 | + | mr.mock.ctrl.T.Helper() | |||
| 308 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidationByHostname", reflect.TypeOf((*MockCertificateQuerier)(nil).GetCertificateValidationByHostname), ctx, hostname) | |||
| 309 | + | } | |||
| 310 | + | ||||
| 311 | + | // ListCertificateValidations mocks base method. | |||
| 312 | + | func (m *MockCertificateQuerier) ListCertificateValidations(ctx context.Context) ([]db.CertificateValidation, error) { | |||
| 313 | + | m.ctrl.T.Helper() | |||
| 314 | + | ret := m.ctrl.Call(m, "ListCertificateValidations", ctx) | |||
| 315 | + | ret0, _ := ret[0].([]db.CertificateValidation) | |||
| 177 | ret1, _ := ret[1].(error) | 316 | ret1, _ := ret[1].(error) | ||
| 178 | return ret0, ret1 | 317 | return ret0, ret1 | ||
| 179 | } | 318 | } | ||
| 180 | 319 | ||||
| 181 | - | // GetShortcuts indicates an expected call of GetShortcuts. | 320 | + | // ListCertificateValidations indicates an expected call of ListCertificateValidations. |
| 182 | - | func (mr *MockShortcutQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call { | 321 | + | func (mr *MockCertificateQuerierMockRecorder) ListCertificateValidations(ctx any) *gomock.Call { |
| 322 | + | mr.mock.ctrl.T.Helper() | |||
| 323 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCertificateValidations", reflect.TypeOf((*MockCertificateQuerier)(nil).ListCertificateValidations), ctx) | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | // StoreCertificateValidation mocks base method. | |||
| 327 | + | func (m *MockCertificateQuerier) StoreCertificateValidation(ctx context.Context, hostname, certificateHash, userDecision string, expiresAt sql.NullTime) error { | |||
| 328 | + | m.ctrl.T.Helper() | |||
| 329 | + | ret := m.ctrl.Call(m, "StoreCertificateValidation", ctx, hostname, certificateHash, userDecision, expiresAt) | |||
| 330 | + | ret0, _ := ret[0].(error) | |||
| 331 | + | return ret0 | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | // StoreCertificateValidation indicates an expected call of StoreCertificateValidation. | |||
| 335 | + | func (mr *MockCertificateQuerierMockRecorder) StoreCertificateValidation(ctx, hostname, certificateHash, userDecision, expiresAt any) *gomock.Call { | |||
| 183 | mr.mock.ctrl.T.Helper() | 336 | mr.mock.ctrl.T.Helper() | ||
| 184 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockShortcutQuerier)(nil).GetShortcuts), ctx) | 337 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreCertificateValidation", reflect.TypeOf((*MockCertificateQuerier)(nil).StoreCertificateValidation), ctx, hostname, certificateHash, userDecision, expiresAt) |
| 185 | } | 338 | } | ||
| 186 | 339 | ||||
| 187 | // MockDatabaseQuerier is a mock of DatabaseQuerier interface. | 340 | // MockDatabaseQuerier is a mock of DatabaseQuerier interface. | ||
| 188 | type MockDatabaseQuerier struct { | 341 | type MockDatabaseQuerier struct { | ||
| 189 | ctrl *gomock.Controller | 342 | ctrl *gomock.Controller | ||
| 190 | recorder *MockDatabaseQuerierMockRecorder | 343 | recorder *MockDatabaseQuerierMockRecorder | ||
| 344 | + | isgomock struct{} | |||
| 191 | } | 345 | } | ||
| 192 | 346 | ||||
| 193 | // MockDatabaseQuerierMockRecorder is the mock recorder for MockDatabaseQuerier. | 347 | // MockDatabaseQuerierMockRecorder is the mock recorder for MockDatabaseQuerier. | ||
| @@ -216,11 +370,53 @@ func (m *MockDatabaseQuerier) AddOrUpdateHistory(ctx context.Context, url string | |||||
| 216 | } | 370 | } | ||
| 217 | 371 | ||||
| 218 | // AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory. | 372 | // AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory. | ||
| 219 | - | func (mr *MockDatabaseQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title interface{}) *gomock.Call { | 373 | + | func (mr *MockDatabaseQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title any) *gomock.Call { |
| 220 | mr.mock.ctrl.T.Helper() | 374 | mr.mock.ctrl.T.Helper() | ||
| 221 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).AddOrUpdateHistory), ctx, url, title) | 375 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).AddOrUpdateHistory), ctx, url, title) | ||
| 222 | } | 376 | } | ||
| 223 | 377 | ||||
| 378 | + | // DeleteCertificateValidation mocks base method. | |||
| 379 | + | func (m *MockDatabaseQuerier) DeleteCertificateValidation(ctx context.Context, hostname, certificateHash string) error { | |||
| 380 | + | m.ctrl.T.Helper() | |||
| 381 | + | ret := m.ctrl.Call(m, "DeleteCertificateValidation", ctx, hostname, certificateHash) | |||
| 382 | + | ret0, _ := ret[0].(error) | |||
| 383 | + | return ret0 | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | // DeleteCertificateValidation indicates an expected call of DeleteCertificateValidation. | |||
| 387 | + | func (mr *MockDatabaseQuerierMockRecorder) DeleteCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call { | |||
| 388 | + | mr.mock.ctrl.T.Helper() | |||
| 389 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCertificateValidation", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteCertificateValidation), ctx, hostname, certificateHash) | |||
| 390 | + | } | |||
| 391 | + | ||||
| 392 | + | // DeleteExpiredCertificateValidations mocks base method. | |||
| 393 | + | func (m *MockDatabaseQuerier) DeleteExpiredCertificateValidations(ctx context.Context) error { | |||
| 394 | + | m.ctrl.T.Helper() | |||
| 395 | + | ret := m.ctrl.Call(m, "DeleteExpiredCertificateValidations", ctx) | |||
| 396 | + | ret0, _ := ret[0].(error) | |||
| 397 | + | return ret0 | |||
| 398 | + | } | |||
| 399 | + | ||||
| 400 | + | // DeleteExpiredCertificateValidations indicates an expected call of DeleteExpiredCertificateValidations. | |||
| 401 | + | func (mr *MockDatabaseQuerierMockRecorder) DeleteExpiredCertificateValidations(ctx any) *gomock.Call { | |||
| 402 | + | mr.mock.ctrl.T.Helper() | |||
| 403 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteExpiredCertificateValidations", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteExpiredCertificateValidations), ctx) | |||
| 404 | + | } | |||
| 405 | + | ||||
| 406 | + | // DeleteHistory mocks base method. | |||
| 407 | + | func (m *MockDatabaseQuerier) DeleteHistory(ctx context.Context, id int64) error { | |||
| 408 | + | m.ctrl.T.Helper() | |||
| 409 | + | ret := m.ctrl.Call(m, "DeleteHistory", ctx, id) | |||
| 410 | + | ret0, _ := ret[0].(error) | |||
| 411 | + | return ret0 | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | // DeleteHistory indicates an expected call of DeleteHistory. | |||
| 415 | + | func (mr *MockDatabaseQuerierMockRecorder) DeleteHistory(ctx, id any) *gomock.Call { | |||
| 416 | + | mr.mock.ctrl.T.Helper() | |||
| 417 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteHistory), ctx, id) | |||
| 418 | + | } | |||
| 419 | + | ||||
| 224 | // DeleteZoomLevel mocks base method. | 420 | // DeleteZoomLevel mocks base method. | ||
| 225 | func (m *MockDatabaseQuerier) DeleteZoomLevel(ctx context.Context, domain string) error { | 421 | func (m *MockDatabaseQuerier) DeleteZoomLevel(ctx context.Context, domain string) error { | ||
| 226 | m.ctrl.T.Helper() | 422 | m.ctrl.T.Helper() | ||
| @@ -230,11 +426,41 @@ func (m *MockDatabaseQuerier) DeleteZoomLevel(ctx context.Context, domain string | |||||
| 230 | } | 426 | } | ||
| 231 | 427 | ||||
| 232 | // DeleteZoomLevel indicates an expected call of DeleteZoomLevel. | 428 | // DeleteZoomLevel indicates an expected call of DeleteZoomLevel. | ||
| 233 | - | func (mr *MockDatabaseQuerierMockRecorder) DeleteZoomLevel(ctx, domain interface{}) *gomock.Call { | 429 | + | func (mr *MockDatabaseQuerierMockRecorder) DeleteZoomLevel(ctx, domain any) *gomock.Call { |
| 234 | mr.mock.ctrl.T.Helper() | 430 | mr.mock.ctrl.T.Helper() | ||
| 235 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteZoomLevel), ctx, domain) | 431 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteZoomLevel), ctx, domain) | ||
| 236 | } | 432 | } | ||
| 237 | 433 | ||||
| 434 | + | // GetCertificateValidation mocks base method. | |||
| 435 | + | func (m *MockDatabaseQuerier) GetCertificateValidation(ctx context.Context, hostname, certificateHash string) (db.CertificateValidation, error) { | |||
| 436 | + | m.ctrl.T.Helper() | |||
| 437 | + | ret := m.ctrl.Call(m, "GetCertificateValidation", ctx, hostname, certificateHash) | |||
| 438 | + | ret0, _ := ret[0].(db.CertificateValidation) | |||
| 439 | + | ret1, _ := ret[1].(error) | |||
| 440 | + | return ret0, ret1 | |||
| 441 | + | } | |||
| 442 | + | ||||
| 443 | + | // GetCertificateValidation indicates an expected call of GetCertificateValidation. | |||
| 444 | + | func (mr *MockDatabaseQuerierMockRecorder) GetCertificateValidation(ctx, hostname, certificateHash any) *gomock.Call { | |||
| 445 | + | mr.mock.ctrl.T.Helper() | |||
| 446 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidation", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetCertificateValidation), ctx, hostname, certificateHash) | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | // GetCertificateValidationByHostname mocks base method. | |||
| 450 | + | func (m *MockDatabaseQuerier) GetCertificateValidationByHostname(ctx context.Context, hostname string) (db.CertificateValidation, error) { | |||
| 451 | + | m.ctrl.T.Helper() | |||
| 452 | + | ret := m.ctrl.Call(m, "GetCertificateValidationByHostname", ctx, hostname) | |||
| 453 | + | ret0, _ := ret[0].(db.CertificateValidation) | |||
| 454 | + | ret1, _ := ret[1].(error) | |||
| 455 | + | return ret0, ret1 | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | // GetCertificateValidationByHostname indicates an expected call of GetCertificateValidationByHostname. | |||
| 459 | + | func (mr *MockDatabaseQuerierMockRecorder) GetCertificateValidationByHostname(ctx, hostname any) *gomock.Call { | |||
| 460 | + | mr.mock.ctrl.T.Helper() | |||
| 461 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCertificateValidationByHostname", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetCertificateValidationByHostname), ctx, hostname) | |||
| 462 | + | } | |||
| 463 | + | ||||
| 238 | // GetHistory mocks base method. | 464 | // GetHistory mocks base method. | ||
| 239 | func (m *MockDatabaseQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) { | 465 | func (m *MockDatabaseQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) { | ||
| 240 | m.ctrl.T.Helper() | 466 | m.ctrl.T.Helper() | ||
| @@ -245,24 +471,39 @@ func (m *MockDatabaseQuerier) GetHistory(ctx context.Context, limit int64) ([]db | |||||
| 245 | } | 471 | } | ||
| 246 | 472 | ||||
| 247 | // GetHistory indicates an expected call of GetHistory. | 473 | // GetHistory indicates an expected call of GetHistory. | ||
| 248 | - | func (mr *MockDatabaseQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call { | 474 | + | func (mr *MockDatabaseQuerierMockRecorder) GetHistory(ctx, limit any) *gomock.Call { |
| 249 | mr.mock.ctrl.T.Helper() | 475 | mr.mock.ctrl.T.Helper() | ||
| 250 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistory), ctx, limit) | 476 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistory), ctx, limit) | ||
| 251 | } | 477 | } | ||
| 252 | 478 | ||||
| 253 | - | // GetShortcuts mocks base method. | 479 | + | // GetHistoryEntry mocks base method. |
| 254 | - | func (m *MockDatabaseQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) { | 480 | + | func (m *MockDatabaseQuerier) GetHistoryEntry(ctx context.Context, url string) (db.History, error) { |
| 255 | m.ctrl.T.Helper() | 481 | m.ctrl.T.Helper() | ||
| 256 | - | ret := m.ctrl.Call(m, "GetShortcuts", ctx) | 482 | + | ret := m.ctrl.Call(m, "GetHistoryEntry", ctx, url) |
| 257 | - | ret0, _ := ret[0].([]db.Shortcut) | 483 | + | ret0, _ := ret[0].(db.History) |
| 258 | ret1, _ := ret[1].(error) | 484 | ret1, _ := ret[1].(error) | ||
| 259 | return ret0, ret1 | 485 | return ret0, ret1 | ||
| 260 | } | 486 | } | ||
| 261 | 487 | ||||
| 262 | - | // GetShortcuts indicates an expected call of GetShortcuts. | 488 | + | // GetHistoryEntry indicates an expected call of GetHistoryEntry. |
| 263 | - | func (mr *MockDatabaseQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call { | 489 | + | func (mr *MockDatabaseQuerierMockRecorder) GetHistoryEntry(ctx, url any) *gomock.Call { |
| 264 | mr.mock.ctrl.T.Helper() | 490 | mr.mock.ctrl.T.Helper() | ||
| 265 | - | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetShortcuts), ctx) | 491 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryEntry", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistoryEntry), ctx, url) |
| 492 | + | } | |||
| 493 | + | ||||
| 494 | + | // GetHistoryWithOffset mocks base method. | |||
| 495 | + | func (m *MockDatabaseQuerier) GetHistoryWithOffset(ctx context.Context, limit, offset int64) ([]db.History, error) { | |||
| 496 | + | m.ctrl.T.Helper() | |||
| 497 | + | ret := m.ctrl.Call(m, "GetHistoryWithOffset", ctx, limit, offset) | |||
| 498 | + | ret0, _ := ret[0].([]db.History) | |||
| 499 | + | ret1, _ := ret[1].(error) | |||
| 500 | + | return ret0, ret1 | |||
| 501 | + | } | |||
| 502 | + | ||||
| 503 | + | // GetHistoryWithOffset indicates an expected call of GetHistoryWithOffset. | |||
| 504 | + | func (mr *MockDatabaseQuerierMockRecorder) GetHistoryWithOffset(ctx, limit, offset any) *gomock.Call { | |||
| 505 | + | mr.mock.ctrl.T.Helper() | |||
| 506 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistoryWithOffset", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistoryWithOffset), ctx, limit, offset) | |||
| 266 | } | 507 | } | ||
| 267 | 508 | ||||
| 268 | // GetZoomLevel mocks base method. | 509 | // GetZoomLevel mocks base method. | ||
| @@ -275,11 +516,41 @@ func (m *MockDatabaseQuerier) GetZoomLevel(ctx context.Context, domain string) ( | |||||
| 275 | } | 516 | } | ||
| 276 | 517 | ||||
| 277 | // GetZoomLevel indicates an expected call of GetZoomLevel. | 518 | // GetZoomLevel indicates an expected call of GetZoomLevel. | ||
| 278 | - | func (mr *MockDatabaseQuerierMockRecorder) GetZoomLevel(ctx, domain interface{}) *gomock.Call { | 519 | + | func (mr *MockDatabaseQuerierMockRecorder) GetZoomLevel(ctx, domain any) *gomock.Call { |
| 279 | mr.mock.ctrl.T.Helper() | 520 | mr.mock.ctrl.T.Helper() | ||
| 280 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetZoomLevel), ctx, domain) | 521 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetZoomLevel), ctx, domain) | ||
| 281 | } | 522 | } | ||
| 282 | 523 | ||||
| 524 | + | // ListCertificateValidations mocks base method. | |||
| 525 | + | func (m *MockDatabaseQuerier) ListCertificateValidations(ctx context.Context) ([]db.CertificateValidation, error) { | |||
| 526 | + | m.ctrl.T.Helper() | |||
| 527 | + | ret := m.ctrl.Call(m, "ListCertificateValidations", ctx) | |||
| 528 | + | ret0, _ := ret[0].([]db.CertificateValidation) | |||
| 529 | + | ret1, _ := ret[1].(error) | |||
| 530 | + | return ret0, ret1 | |||
| 531 | + | } | |||
| 532 | + | ||||
| 533 | + | // ListCertificateValidations indicates an expected call of ListCertificateValidations. | |||
| 534 | + | func (mr *MockDatabaseQuerierMockRecorder) ListCertificateValidations(ctx any) *gomock.Call { | |||
| 535 | + | mr.mock.ctrl.T.Helper() | |||
| 536 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListCertificateValidations", reflect.TypeOf((*MockDatabaseQuerier)(nil).ListCertificateValidations), ctx) | |||
| 537 | + | } | |||
| 538 | + | ||||
| 539 | + | // ListZoomLevels mocks base method. | |||
| 540 | + | func (m *MockDatabaseQuerier) ListZoomLevels(ctx context.Context) ([]db.ZoomLevel, error) { | |||
| 541 | + | m.ctrl.T.Helper() | |||
| 542 | + | ret := m.ctrl.Call(m, "ListZoomLevels", ctx) | |||
| 543 | + | ret0, _ := ret[0].([]db.ZoomLevel) | |||
| 544 | + | ret1, _ := ret[1].(error) | |||
| 545 | + | return ret0, ret1 | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | // ListZoomLevels indicates an expected call of ListZoomLevels. | |||
| 549 | + | func (mr *MockDatabaseQuerierMockRecorder) ListZoomLevels(ctx any) *gomock.Call { | |||
| 550 | + | mr.mock.ctrl.T.Helper() | |||
| 551 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListZoomLevels", reflect.TypeOf((*MockDatabaseQuerier)(nil).ListZoomLevels), ctx) | |||
| 552 | + | } | |||
| 553 | + | ||||
| 283 | // SearchHistory mocks base method. | 554 | // SearchHistory mocks base method. | ||
| 284 | func (m *MockDatabaseQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) { | 555 | func (m *MockDatabaseQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) { | ||
| 285 | m.ctrl.T.Helper() | 556 | m.ctrl.T.Helper() | ||
| @@ -290,7 +561,7 @@ func (m *MockDatabaseQuerier) SearchHistory(ctx context.Context, column1, column | |||||
| 290 | } | 561 | } | ||
| 291 | 562 | ||||
| 292 | // SearchHistory indicates an expected call of SearchHistory. | 563 | // SearchHistory indicates an expected call of SearchHistory. | ||
| 293 | - | func (mr *MockDatabaseQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit interface{}) *gomock.Call { | 564 | + | func (mr *MockDatabaseQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit any) *gomock.Call { |
| 294 | mr.mock.ctrl.T.Helper() | 565 | mr.mock.ctrl.T.Helper() | ||
| 295 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).SearchHistory), ctx, column1, column2, limit) | 566 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).SearchHistory), ctx, column1, column2, limit) | ||
| 296 | } | 567 | } | ||
| @@ -304,7 +575,35 @@ func (m *MockDatabaseQuerier) SetZoomLevel(ctx context.Context, domain string, z | |||||
| 304 | } | 575 | } | ||
| 305 | 576 | ||||
| 306 | // SetZoomLevel indicates an expected call of SetZoomLevel. | 577 | // SetZoomLevel indicates an expected call of SetZoomLevel. | ||
| 307 | - | func (mr *MockDatabaseQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel interface{}) *gomock.Call { | 578 | + | func (mr *MockDatabaseQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel any) *gomock.Call { |
| 308 | mr.mock.ctrl.T.Helper() | 579 | mr.mock.ctrl.T.Helper() | ||
| 309 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel) | 580 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel) | ||
| 310 | } | 581 | } | ||
| 582 | + | ||||
| 583 | + | // StoreCertificateValidation mocks base method. | |||
| 584 | + | func (m *MockDatabaseQuerier) StoreCertificateValidation(ctx context.Context, hostname, certificateHash, userDecision string, expiresAt sql.NullTime) error { | |||
| 585 | + | m.ctrl.T.Helper() | |||
| 586 | + | ret := m.ctrl.Call(m, "StoreCertificateValidation", ctx, hostname, certificateHash, userDecision, expiresAt) | |||
| 587 | + | ret0, _ := ret[0].(error) | |||
| 588 | + | return ret0 | |||
| 589 | + | } | |||
| 590 | + | ||||
| 591 | + | // StoreCertificateValidation indicates an expected call of StoreCertificateValidation. | |||
| 592 | + | func (mr *MockDatabaseQuerierMockRecorder) StoreCertificateValidation(ctx, hostname, certificateHash, userDecision, expiresAt any) *gomock.Call { | |||
| 593 | + | mr.mock.ctrl.T.Helper() | |||
| 594 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "StoreCertificateValidation", reflect.TypeOf((*MockDatabaseQuerier)(nil).StoreCertificateValidation), ctx, hostname, certificateHash, userDecision, expiresAt) | |||
| 595 | + | } | |||
| 596 | + | ||||
| 597 | + | // UpdateHistoryFavicon mocks base method. | |||
| 598 | + | func (m *MockDatabaseQuerier) UpdateHistoryFavicon(ctx context.Context, faviconUrl sql.NullString, url string) error { | |||
| 599 | + | m.ctrl.T.Helper() | |||
| 600 | + | ret := m.ctrl.Call(m, "UpdateHistoryFavicon", ctx, faviconUrl, url) | |||
| 601 | + | ret0, _ := ret[0].(error) | |||
| 602 | + | return ret0 | |||
| 603 | + | } | |||
| 604 | + | ||||
| 605 | + | // UpdateHistoryFavicon indicates an expected call of UpdateHistoryFavicon. | |||
| 606 | + | func (mr *MockDatabaseQuerierMockRecorder) UpdateHistoryFavicon(ctx, faviconUrl, url any) *gomock.Call { | |||
| 607 | + | mr.mock.ctrl.T.Helper() | |||
| 608 | + | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateHistoryFavicon", reflect.TypeOf((*MockDatabaseQuerier)(nil).UpdateHistoryFavicon), ctx, faviconUrl, url) | |||
| 609 | + | } | |||
M internal/db/models.go +15 -8
| @@ -17,6 +17,21 @@ type CertificateValidation struct { | |||||
| 17 | ExpiresAt sql.NullTime `json:"expires_at"` | 17 | ExpiresAt sql.NullTime `json:"expires_at"` | ||
| 18 | } | 18 | } | ||
| 19 | 19 | ||||
| 20 | + | type FuzzyCacheMetadatum struct { | |||
| 21 | + | ID int64 `json:"id"` | |||
| 22 | + | Version int64 `json:"version"` | |||
| 23 | + | EntryCount int64 `json:"entry_count"` | |||
| 24 | + | LastModified int64 `json:"last_modified"` | |||
| 25 | + | EntriesHash string `json:"entries_hash"` | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | type FuzzyCacheStructure struct { | |||
| 29 | + | ID int64 `json:"id"` | |||
| 30 | + | TrigramIndex []byte `json:"trigram_index"` | |||
| 31 | + | PrefixTrie []byte `json:"prefix_trie"` | |||
| 32 | + | SortedIndex []byte `json:"sorted_index"` | |||
| 33 | + | } | |||
| 34 | + | ||||
| 20 | type History struct { | 35 | type History struct { | ||
| 21 | ID int64 `json:"id"` | 36 | ID int64 `json:"id"` | ||
| 22 | Url string `json:"url"` | 37 | Url string `json:"url"` | ||
| @@ -27,14 +42,6 @@ type History struct { | |||||
| 27 | FaviconUrl sql.NullString `json:"favicon_url"` | 42 | FaviconUrl sql.NullString `json:"favicon_url"` | ||
| 28 | } | 43 | } | ||
| 29 | 44 | ||||
| 30 | - | type Shortcut struct { | |||
| 31 | - | ID int64 `json:"id"` | |||
| 32 | - | Shortcut string `json:"shortcut"` | |||
| 33 | - | UrlTemplate string `json:"url_template"` | |||
| 34 | - | Description sql.NullString `json:"description"` | |||
| 35 | - | CreatedAt sql.NullTime `json:"created_at"` | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | type ZoomLevel struct { | 45 | type ZoomLevel struct { | ||
| 39 | Domain string `json:"domain"` | 46 | Domain string `json:"domain"` | ||
| 40 | ZoomFactor float64 `json:"zoom_factor"` | 47 | ZoomFactor float64 `json:"zoom_factor"` | ||
M internal/db/querier.go +1 -1
| @@ -23,11 +23,11 @@ type Querier interface { | |||||
| 23 | GetHistory(ctx context.Context, limit int64) ([]History, error) | 23 | GetHistory(ctx context.Context, limit int64) ([]History, error) | ||
| 24 | GetHistoryEntry(ctx context.Context, url string) (History, error) | 24 | GetHistoryEntry(ctx context.Context, url string) (History, error) | ||
| 25 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | 25 | GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error) | ||
| 26 | - | GetShortcuts(ctx context.Context) ([]Shortcut, error) | |||
| 27 | // Get zoom level for a specific domain | 26 | // Get zoom level for a specific domain | ||
| 28 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | 27 | GetZoomLevel(ctx context.Context, domain string) (float64, error) | ||
| 29 | // Get zoom level for domain with default fallback | 28 | // Get zoom level for domain with default fallback | ||
| 30 | GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error) | 29 | GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error) | ||
| 30 | + | ListCertificateValidations(ctx context.Context) ([]CertificateValidation, error) | |||
| 31 | // List all zoom level settings ordered by most recently updated | 31 | // List all zoom level settings ordered by most recently updated | ||
| 32 | ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) | 32 | ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) | ||
| 33 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | 33 | SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error) | ||
M internal/db/queries/certificate_validations.sql +7 -2
| @@ -20,5 +20,10 @@ DELETE FROM certificate_validations | |||||
| 20 | WHERE expires_at IS NOT NULL AND expires_at <= datetime('now'); | 20 | WHERE expires_at IS NOT NULL AND expires_at <= datetime('now'); | ||
| 21 | 21 | ||||
| 22 | -- name: DeleteCertificateValidation :exec | 22 | -- name: DeleteCertificateValidation :exec | ||
| 23 | - | DELETE FROM certificate_validations | 23 | + | DELETE FROM certificate_validations |
| 24 | - | WHERE hostname = ? AND certificate_hash = ?;⏎̸ | 24 | + | WHERE hostname = ? AND certificate_hash = ?; |
| 25 | + | ||||
| 26 | + | -- name: ListCertificateValidations :many | |||
| 27 | + | SELECT * FROM certificate_validations | |||
| 28 | + | WHERE (expires_at IS NULL OR expires_at > datetime('now')) | |||
| 29 | + | ORDER BY hostname;⏎̸ | |||
M internal/db/queries/history.sql +1 -6
| @@ -39,9 +39,4 @@ LIMIT 1; | |||||
| 39 | 39 | ||||
| 40 | -- name: DeleteHistory :exec | 40 | -- name: DeleteHistory :exec | ||
| 41 | DELETE FROM history | 41 | DELETE FROM history | ||
| 42 | - | WHERE id = ?; | 42 | + | WHERE id = ?;⏎̸ |
| 43 | - | ||||
| 44 | - | -- name: GetShortcuts :many | |||
| 45 | - | SELECT id, shortcut, url_template, description, created_at | |||
| 46 | - | FROM shortcuts | |||
| 47 | - | ORDER BY shortcut;⏎̸ | |||
M internal/filtering/updater.go +11 -2
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "crypto/sha256" | 5 | "crypto/sha256" | ||
| 6 | "fmt" | 6 | "fmt" | ||
| 7 | + | "log" | |||
| 7 | "net/http" | 8 | "net/http" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | "sync" | 10 | "sync" | ||
| @@ -138,7 +139,11 @@ func (fu *FilterUpdater) checkSingleUpdate(ctx context.Context, url string) (*Fi | |||||
| 138 | if err != nil { | 139 | if err != nil { | ||
| 139 | return nil, fmt.Errorf("HEAD request failed: %w", err) | 140 | return nil, fmt.Errorf("HEAD request failed: %w", err) | ||
| 140 | } | 141 | } | ||
| 141 | - | defer resp.Body.Close() | 142 | + | defer func() { |
| 143 | + | if err := resp.Body.Close(); err != nil { | |||
| 144 | + | log.Printf("warning: failed to close response body: %v", err) | |||
| 145 | + | } | |||
| 146 | + | }() | |||
| 142 | 147 | ||||
| 143 | if resp.StatusCode != http.StatusOK { | 148 | if resp.StatusCode != http.StatusOK { | ||
| 144 | return nil, fmt.Errorf("HEAD request returned %d", resp.StatusCode) | 149 | return nil, fmt.Errorf("HEAD request returned %d", resp.StatusCode) | ||
| @@ -183,7 +188,11 @@ func (fu *FilterUpdater) downloadUpdate(ctx context.Context, url, lastModified, | |||||
| 183 | if err != nil { | 188 | if err != nil { | ||
| 184 | return nil, fmt.Errorf("GET request failed: %w", err) | 189 | return nil, fmt.Errorf("GET request failed: %w", err) | ||
| 185 | } | 190 | } | ||
| 186 | - | defer resp.Body.Close() | 191 | + | defer func() { |
| 192 | + | if err := resp.Body.Close(); err != nil { | |||
| 193 | + | log.Printf("warning: failed to close response body: %v", err) | |||
| 194 | + | } | |||
| 195 | + | }() | |||
| 187 | 196 | ||||
| 188 | if resp.StatusCode != http.StatusOK { | 197 | if resp.StatusCode != http.StatusOK { | ||
| 189 | return nil, fmt.Errorf("GET request returned %d", resp.StatusCode) | 198 | return nil, fmt.Errorf("GET request returned %d", resp.StatusCode) | ||
M internal/logging/capture.go +37 -12
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "bufio" | 4 | "bufio" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "io" | 6 | "io" | ||
| 7 | + | "log" | |||
| 7 | "os" | 8 | "os" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | "time" | 10 | "time" | ||
| @@ -46,8 +47,12 @@ func (c *OutputCapture) Start() error { | |||||
| 46 | // Create pipes for stderr | 47 | // Create pipes for stderr | ||
| 47 | stderrR, stderrW, err := os.Pipe() | 48 | stderrR, stderrW, err := os.Pipe() | ||
| 48 | if err != nil { | 49 | if err != nil { | ||
| 49 | - | stdoutR.Close() | 50 | + | if closeErr := stdoutR.Close(); closeErr != nil { |
| 50 | - | stdoutW.Close() | 51 | + | log.Printf("failed to close stdout read pipe: %v", closeErr) |
| 52 | + | } | |||
| 53 | + | if closeErr := stdoutW.Close(); closeErr != nil { | |||
| 54 | + | log.Printf("failed to close stdout write pipe: %v", closeErr) | |||
| 55 | + | } | |||
| 51 | return err | 56 | return err | ||
| 52 | } | 57 | } | ||
| 53 | 58 | ||||
| @@ -62,8 +67,12 @@ func (c *OutputCapture) Start() error { | |||||
| 62 | os.Stderr = stderrW | 67 | os.Stderr = stderrW | ||
| 63 | 68 | ||||
| 64 | // Also redirect file descriptors at syscall level for C code | 69 | // Also redirect file descriptors at syscall level for C code | ||
| 65 | - | unix.Dup3(int(stdoutW.Fd()), 1, 0) | 70 | + | if err := unix.Dup3(int(stdoutW.Fd()), 1, 0); err != nil { |
| 66 | - | unix.Dup3(int(stderrW.Fd()), 2, 0) | 71 | + | log.Printf("failed to redirect stdout: %v", err) |
| 72 | + | } | |||
| 73 | + | if err := unix.Dup3(int(stderrW.Fd()), 2, 0); err != nil { | |||
| 74 | + | log.Printf("failed to redirect stderr: %v", err) | |||
| 75 | + | } | |||
| 67 | 76 | ||||
| 68 | // Start goroutines to read and log | 77 | // Start goroutines to read and log | ||
| 69 | go c.pipeToLogger(stdoutR, "STDOUT") | 78 | go c.pipeToLogger(stdoutR, "STDOUT") | ||
| @@ -85,21 +94,33 @@ func (c *OutputCapture) Stop() { | |||||
| 85 | os.Stderr = c.originalStderr | 94 | os.Stderr = c.originalStderr | ||
| 86 | 95 | ||||
| 87 | // Restore file descriptors | 96 | // Restore file descriptors | ||
| 88 | - | unix.Dup3(int(c.originalStdout.Fd()), 1, 0) | 97 | + | if err := unix.Dup3(int(c.originalStdout.Fd()), 1, 0); err != nil { |
| 89 | - | unix.Dup3(int(c.originalStderr.Fd()), 2, 0) | 98 | + | log.Printf("failed to restore stdout: %v", err) |
| 99 | + | } | |||
| 100 | + | if err := unix.Dup3(int(c.originalStderr.Fd()), 2, 0); err != nil { | |||
| 101 | + | log.Printf("failed to restore stderr: %v", err) | |||
| 102 | + | } | |||
| 90 | 103 | ||||
| 91 | // Close pipes | 104 | // Close pipes | ||
| 92 | if c.stdoutWrite != nil { | 105 | if c.stdoutWrite != nil { | ||
| 93 | - | c.stdoutWrite.Close() | 106 | + | if err := c.stdoutWrite.Close(); err != nil { |
| 107 | + | log.Printf("failed to close stdout write pipe: %v", err) | |||
| 108 | + | } | |||
| 94 | } | 109 | } | ||
| 95 | if c.stderrWrite != nil { | 110 | if c.stderrWrite != nil { | ||
| 96 | - | c.stderrWrite.Close() | 111 | + | if err := c.stderrWrite.Close(); err != nil { |
| 112 | + | log.Printf("failed to close stderr write pipe: %v", err) | |||
| 113 | + | } | |||
| 97 | } | 114 | } | ||
| 98 | if c.stdoutRead != nil { | 115 | if c.stdoutRead != nil { | ||
| 99 | - | c.stdoutRead.Close() | 116 | + | if err := c.stdoutRead.Close(); err != nil { |
| 117 | + | log.Printf("failed to close stdout read pipe: %v", err) | |||
| 118 | + | } | |||
| 100 | } | 119 | } | ||
| 101 | if c.stderrRead != nil { | 120 | if c.stderrRead != nil { | ||
| 102 | - | c.stderrRead.Close() | 121 | + | if err := c.stderrRead.Close(); err != nil { |
| 122 | + | log.Printf("failed to close stderr read pipe: %v", err) | |||
| 123 | + | } | |||
| 103 | } | 124 | } | ||
| 104 | 125 | ||||
| 105 | c.started = false | 126 | c.started = false | ||
| @@ -121,9 +142,13 @@ func (c *OutputCapture) pipeToLogger(r io.Reader, prefix string) { | |||||
| 121 | timestamp := time.Now().Format("2006-01-02 15:04:05") | 142 | timestamp := time.Now().Format("2006-01-02 15:04:05") | ||
| 122 | logLine := fmt.Sprintf("[%s] INFO [%s] %s\n", timestamp, prefix, line) | 143 | logLine := fmt.Sprintf("[%s] INFO [%s] %s\n", timestamp, prefix, line) | ||
| 123 | if prefix == "STDERR" { | 144 | if prefix == "STDERR" { | ||
| 124 | - | c.originalStderr.WriteString(logLine) | 145 | + | if _, err := c.originalStderr.WriteString(logLine); err != nil { |
| 146 | + | log.Printf("failed to write to stderr: %v", err) | |||
| 147 | + | } | |||
| 125 | } else { | 148 | } else { | ||
| 126 | - | c.originalStdout.WriteString(logLine) | 149 | + | if _, err := c.originalStdout.WriteString(logLine); err != nil { |
| 150 | + | log.Printf("failed to write to stdout: %v", err) | |||
| 151 | + | } | |||
| 127 | } | 152 | } | ||
| 128 | } | 153 | } | ||
| 129 | } | 154 | } | ||
M internal/logging/crash.go +3 -1
| @@ -67,7 +67,9 @@ func handleCrash(sig os.Signal) { | |||||
| 67 | 67 | ||||
| 68 | // Force flush logs | 68 | // Force flush logs | ||
| 69 | if logger.rotator != nil { | 69 | if logger.rotator != nil { | ||
| 70 | - | logger.rotator.Close() | 70 | + | if err := logger.rotator.Close(); err != nil { |
| 71 | + | fmt.Fprintf(os.Stderr, "warning: failed to close log rotator: %v\n", err) | |||
| 72 | + | } | |||
| 71 | } | 73 | } | ||
| 72 | 74 | ||||
| 73 | // Exit with error code | 75 | // Exit with error code | ||
M internal/logging/logger.go +7 -3
| @@ -97,7 +97,7 @@ func Init(logDir string, level string, format string, enableFileLog bool, maxSiz | |||||
| 97 | globalLoggerMux.Lock() | 97 | globalLoggerMux.Lock() | ||
| 98 | defer globalLoggerMux.Unlock() | 98 | defer globalLoggerMux.Unlock() | ||
| 99 | 99 | ||||
| 100 | - | err := os.MkdirAll(logDir, 0755) | 100 | + | err := os.MkdirAll(logDir, 0750) |
| 101 | if err != nil { | 101 | if err != nil { | ||
| 102 | return fmt.Errorf("failed to create log directory: %w", err) | 102 | return fmt.Errorf("failed to create log directory: %w", err) | ||
| 103 | } | 103 | } | ||
| @@ -158,7 +158,9 @@ func (l *Logger) writeLog(level LogLevel, message string, source string) { | |||||
| 158 | formatted := l.formatter.Format(level, message, source) | 158 | formatted := l.formatter.Format(level, message, source) | ||
| 159 | 159 | ||||
| 160 | for _, output := range l.outputs { | 160 | for _, output := range l.outputs { | ||
| 161 | - | output.Write([]byte(formatted)) | 161 | + | if _, err := output.Write([]byte(formatted)); err != nil { |
| 162 | + | fmt.Fprintf(os.Stderr, "failed to write log output: %v\n", err) | |||
| 163 | + | } | |||
| 162 | } | 164 | } | ||
| 163 | } | 165 | } | ||
| 164 | 166 | ||||
| @@ -180,7 +182,9 @@ func (l *Logger) WriteFileOnly(level LogLevel, message string, source string) { | |||||
| 180 | // Write only to file outputs (skip stdout) | 182 | // Write only to file outputs (skip stdout) | ||
| 181 | for _, output := range l.outputs { | 183 | for _, output := range l.outputs { | ||
| 182 | if output != os.Stdout { | 184 | if output != os.Stdout { | ||
| 183 | - | output.Write([]byte(formatted)) | 185 | + | if _, err := output.Write([]byte(formatted)); err != nil { |
| 186 | + | fmt.Fprintf(os.Stderr, "failed to write log output: %v\n", err) | |||
| 187 | + | } | |||
| 184 | } | 188 | } | ||
| 185 | } | 189 | } | ||
| 186 | } | 190 | } | ||
M internal/logging/rotator.go +28 -8
| @@ -49,7 +49,7 @@ func (r *LogRotator) openCurrentFile() error { | |||||
| 49 | r.currentSize = info.Size() | 49 | r.currentSize = info.Size() | ||
| 50 | } | 50 | } | ||
| 51 | 51 | ||||
| 52 | - | file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644) | 52 | + | file, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) |
| 53 | if err != nil { | 53 | if err != nil { | ||
| 54 | return fmt.Errorf("failed to open log file: %w", err) | 54 | return fmt.Errorf("failed to open log file: %w", err) | ||
| 55 | } | 55 | } | ||
| @@ -86,7 +86,9 @@ func (r *LogRotator) Write(p []byte) (n int, err error) { | |||||
| 86 | 86 | ||||
| 87 | func (r *LogRotator) rotate() error { | 87 | func (r *LogRotator) rotate() error { | ||
| 88 | if r.currentFile != nil { | 88 | if r.currentFile != nil { | ||
| 89 | - | r.currentFile.Close() | 89 | + | if err := r.currentFile.Close(); err != nil { |
| 90 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close current log file: %v\n", err) | |||
| 91 | + | } | |||
| 90 | } | 92 | } | ||
| 91 | 93 | ||||
| 92 | // Generate backup filename with timestamp | 94 | // Generate backup filename with timestamp | ||
| @@ -108,7 +110,9 @@ func (r *LogRotator) rotate() error { | |||||
| 108 | fmt.Fprintf(os.Stderr, "Warning: failed to compress log file %s: %v\n", backupPath, err) | 110 | fmt.Fprintf(os.Stderr, "Warning: failed to compress log file %s: %v\n", backupPath, err) | ||
| 109 | } else { | 111 | } else { | ||
| 110 | // Remove uncompressed file after successful compression | 112 | // Remove uncompressed file after successful compression | ||
| 111 | - | os.Remove(backupPath) | 113 | + | if err := os.Remove(backupPath); err != nil { |
| 114 | + | fmt.Fprintf(os.Stderr, "Warning: failed to remove uncompressed log file %s: %v\n", backupPath, err) | |||
| 115 | + | } | |||
| 112 | } | 116 | } | ||
| 113 | } | 117 | } | ||
| 114 | 118 | ||||
| @@ -125,17 +129,29 @@ func (r *LogRotator) compressFile(filePath string) error { | |||||
| 125 | if err != nil { | 129 | if err != nil { | ||
| 126 | return err | 130 | return err | ||
| 127 | } | 131 | } | ||
| 128 | - | defer inputFile.Close() | 132 | + | defer func() { |
| 133 | + | if err := inputFile.Close(); err != nil { | |||
| 134 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close input file during compression: %v\n", err) | |||
| 135 | + | } | |||
| 136 | + | }() | |||
| 129 | 137 | ||||
| 130 | outputPath := filePath + ".gz" | 138 | outputPath := filePath + ".gz" | ||
| 131 | outputFile, err := os.Create(outputPath) | 139 | outputFile, err := os.Create(outputPath) | ||
| 132 | if err != nil { | 140 | if err != nil { | ||
| 133 | return err | 141 | return err | ||
| 134 | } | 142 | } | ||
| 135 | - | defer outputFile.Close() | 143 | + | defer func() { |
| 144 | + | if err := outputFile.Close(); err != nil { | |||
| 145 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close output file during compression: %v\n", err) | |||
| 146 | + | } | |||
| 147 | + | }() | |||
| 136 | 148 | ||||
| 137 | gzipWriter := gzip.NewWriter(outputFile) | 149 | gzipWriter := gzip.NewWriter(outputFile) | ||
| 138 | - | defer gzipWriter.Close() | 150 | + | defer func() { |
| 151 | + | if err := gzipWriter.Close(); err != nil { | |||
| 152 | + | fmt.Fprintf(os.Stderr, "Warning: failed to close gzip writer: %v\n", err) | |||
| 153 | + | } | |||
| 154 | + | }() | |||
| 139 | 155 | ||||
| 140 | _, err = io.Copy(gzipWriter, inputFile) | 156 | _, err = io.Copy(gzipWriter, inputFile) | ||
| 141 | return err | 157 | return err | ||
| @@ -167,7 +183,9 @@ func (r *LogRotator) cleanup() { | |||||
| 167 | 183 | ||||
| 168 | // Remove files older than maxAge | 184 | // Remove files older than maxAge | ||
| 169 | if r.maxAge > 0 && now.Sub(info.ModTime()) > r.maxAge { | 185 | if r.maxAge > 0 && now.Sub(info.ModTime()) > r.maxAge { | ||
| 170 | - | os.Remove(filepath.Join(r.baseDir, name)) | 186 | + | if err := os.Remove(filepath.Join(r.baseDir, name)); err != nil { |
| 187 | + | fmt.Fprintf(os.Stderr, "warning: failed to remove old log file: %v\n", err) | |||
| 188 | + | } | |||
| 171 | continue | 189 | continue | ||
| 172 | } | 190 | } | ||
| 173 | 191 | ||||
| @@ -183,7 +201,9 @@ func (r *LogRotator) cleanup() { | |||||
| 183 | 201 | ||||
| 184 | // Remove oldest files | 202 | // Remove oldest files | ||
| 185 | for i := 0; i < len(backupFiles)-r.maxBackups; i++ { | 203 | for i := 0; i < len(backupFiles)-r.maxBackups; i++ { | ||
| 186 | - | os.Remove(filepath.Join(r.baseDir, backupFiles[i].Name())) | 204 | + | if err := os.Remove(filepath.Join(r.baseDir, backupFiles[i].Name())); err != nil { |
| 205 | + | fmt.Fprintf(os.Stderr, "warning: failed to remove excess backup file: %v\n", err) | |||
| 206 | + | } | |||
| 187 | } | 207 | } | ||
| 188 | } | 208 | } | ||
| 189 | } | 209 | } | ||
A internal/migrations/006_add_fuzzy_cache_tables.sql +22 -0
| @@ -0,0 +1,22 @@ | |||||
| 1 | + | -- Database consolidation: Move fuzzy cache from binary file to database | |||
| 2 | + | -- Replaces ~/.local/state/dumber/dmenu_fuzzy_cache.bin with proper SQL tables | |||
| 3 | + | -- This enables single-database architecture: dumber.sqlite | |||
| 4 | + | ||||
| 5 | + | -- Metadata table: stores cache statistics and versioning | |||
| 6 | + | CREATE TABLE IF NOT EXISTS fuzzy_cache_metadata ( | |||
| 7 | + | id INTEGER PRIMARY KEY CHECK (id = 1), -- Singleton table: only one row allowed | |||
| 8 | + | version INTEGER NOT NULL, | |||
| 9 | + | entry_count INTEGER NOT NULL, | |||
| 10 | + | last_modified INTEGER NOT NULL, -- Unix timestamp | |||
| 11 | + | entries_hash TEXT NOT NULL -- MD5 hash for cache invalidation detection | |||
| 12 | + | ); | |||
| 13 | + | ||||
| 14 | + | -- Structures table: stores serialized fuzzy search data structures | |||
| 15 | + | CREATE TABLE IF NOT EXISTS fuzzy_cache_structures ( | |||
| 16 | + | id INTEGER PRIMARY KEY CHECK (id = 1), -- Singleton table: only one row allowed | |||
| 17 | + | trigram_index BLOB NOT NULL, -- Serialized trigram index: map[string][]int | |||
| 18 | + | prefix_trie BLOB NOT NULL, -- Serialized prefix trie for fast lookups | |||
| 19 | + | sorted_index BLOB NOT NULL -- Pre-sorted entries for ranking | |||
| 20 | + | ); | |||
| 21 | + | ||||
| 22 | + | -- Note: No indexes needed - singleton tables with single row access pattern | |||
A internal/migrations/007_remove_shortcuts_table.sql +6 -0
| @@ -0,0 +1,6 @@ | |||||
| 1 | + | -- Remove shortcuts table | |||
| 2 | + | -- Shortcuts are now managed exclusively through configuration files | |||
| 3 | + | -- This table was redundant as shortcuts were synced from config on startup | |||
| 4 | + | -- but never queried during runtime (parser reads from config directly) | |||
| 5 | + | ||||
| 6 | + | DROP TABLE IF EXISTS shortcuts; | |||
M internal/migrations/migrations.go +8 -2
| @@ -132,7 +132,9 @@ func applyMigration(db *sql.DB, migration Migration) error { | |||||
| 132 | } | 132 | } | ||
| 133 | defer func() { | 133 | defer func() { | ||
| 134 | if err != nil { | 134 | if err != nil { | ||
| 135 | - | tx.Rollback() | 135 | + | if rollbackErr := tx.Rollback(); rollbackErr != nil { |
| 136 | + | log.Printf("failed to rollback migration transaction: %v", rollbackErr) | |||
| 137 | + | } | |||
| 136 | } | 138 | } | ||
| 137 | }() | 139 | }() | ||
| 138 | 140 | ||||
| @@ -175,7 +177,11 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) { | |||||
| 175 | if err != nil { | 177 | if err != nil { | ||
| 176 | return nil, fmt.Errorf("failed to query applied migrations: %w", err) | 178 | return nil, fmt.Errorf("failed to query applied migrations: %w", err) | ||
| 177 | } | 179 | } | ||
| 178 | - | defer rows.Close() | 180 | + | defer func() { |
| 181 | + | if err := rows.Close(); err != nil { | |||
| 182 | + | log.Printf("failed to close migration query rows: %v", err) | |||
| 183 | + | } | |||
| 184 | + | }() | |||
| 179 | 185 | ||||
| 180 | var versions []int | 186 | var versions []int | ||
| 181 | for rows.Next() { | 187 | for rows.Next() { | ||
M internal/migrations/migrations_test.go +13 -13
| @@ -20,7 +20,7 @@ func TestMigrationsAreIdempotent(t *testing.T) { | |||||
| 20 | if err != nil { | 20 | if err != nil { | ||
| 21 | t.Fatalf("Failed to open test database: %v", err) | 21 | t.Fatalf("Failed to open test database: %v", err) | ||
| 22 | } | 22 | } | ||
| 23 | - | defer db.Close() | 23 | + | defer func() { _ = db.Close() }() |
| 24 | 24 | ||||
| 25 | // Run migrations first time | 25 | // Run migrations first time | ||
| 26 | if err := RunEmbeddedMigrations(db); err != nil { | 26 | if err := RunEmbeddedMigrations(db); err != nil { | ||
| @@ -83,7 +83,7 @@ func TestMigrationTrackingPreventsReapplication(t *testing.T) { | |||||
| 83 | if err != nil { | 83 | if err != nil { | ||
| 84 | t.Fatalf("Failed to open test database: %v", err) | 84 | t.Fatalf("Failed to open test database: %v", err) | ||
| 85 | } | 85 | } | ||
| 86 | - | defer db.Close() | 86 | + | defer func() { _ = db.Close() }() |
| 87 | 87 | ||||
| 88 | // Run migrations | 88 | // Run migrations | ||
| 89 | if err := RunEmbeddedMigrations(db); err != nil { | 89 | if err := RunEmbeddedMigrations(db); err != nil { | ||
| @@ -106,7 +106,7 @@ func TestMigrationTrackingPreventsReapplication(t *testing.T) { | |||||
| 106 | if err != nil { | 106 | if err != nil { | ||
| 107 | t.Fatalf("Failed to check for duplicate migration entries: %v", err) | 107 | t.Fatalf("Failed to check for duplicate migration entries: %v", err) | ||
| 108 | } | 108 | } | ||
| 109 | - | defer rows.Close() | 109 | + | defer func() { _ = rows.Close() }() |
| 110 | 110 | ||||
| 111 | duplicates := []int{} | 111 | duplicates := []int{} | ||
| 112 | for rows.Next() { | 112 | for rows.Next() { | ||
| @@ -131,7 +131,7 @@ func TestAllMigrationsApplyOnFreshDatabase(t *testing.T) { | |||||
| 131 | if err != nil { | 131 | if err != nil { | ||
| 132 | t.Fatalf("Failed to open test database: %v", err) | 132 | t.Fatalf("Failed to open test database: %v", err) | ||
| 133 | } | 133 | } | ||
| 134 | - | defer db.Close() | 134 | + | defer func() { _ = db.Close() }() |
| 135 | 135 | ||||
| 136 | // Get expected migrations | 136 | // Get expected migrations | ||
| 137 | expectedMigrations, err := GetMigrations() | 137 | expectedMigrations, err := GetMigrations() | ||
| @@ -207,7 +207,7 @@ func TestMigrationTableCreationIsIdempotent(t *testing.T) { | |||||
| 207 | if err != nil { | 207 | if err != nil { | ||
| 208 | t.Fatalf("Failed to open test database: %v", err) | 208 | t.Fatalf("Failed to open test database: %v", err) | ||
| 209 | } | 209 | } | ||
| 210 | - | defer db.Close() | 210 | + | defer func() { _ = db.Close() }() |
| 211 | 211 | ||||
| 212 | // Create tracking table multiple times | 212 | // Create tracking table multiple times | ||
| 213 | for i := 0; i < 3; i++ { | 213 | for i := 0; i < 3; i++ { | ||
| @@ -236,7 +236,7 @@ func TestMigrationTableCreationIsIdempotent(t *testing.T) { | |||||
| 236 | if err != nil { | 236 | if err != nil { | ||
| 237 | t.Fatalf("Failed to get table info: %v", err) | 237 | t.Fatalf("Failed to get table info: %v", err) | ||
| 238 | } | 238 | } | ||
| 239 | - | defer rows.Close() | 239 | + | defer func() { _ = rows.Close() }() |
| 240 | 240 | ||||
| 241 | expectedColumns := map[string]bool{ | 241 | expectedColumns := map[string]bool{ | ||
| 242 | "version": false, | 242 | "version": false, | ||
| @@ -275,7 +275,7 @@ func TestPartialMigrationState(t *testing.T) { | |||||
| 275 | if err != nil { | 275 | if err != nil { | ||
| 276 | t.Fatalf("Failed to open test database: %v", err) | 276 | t.Fatalf("Failed to open test database: %v", err) | ||
| 277 | } | 277 | } | ||
| 278 | - | defer db.Close() | 278 | + | defer func() { _ = db.Close() }() |
| 279 | 279 | ||||
| 280 | // Create tracking table | 280 | // Create tracking table | ||
| 281 | if err := createMigrationsTable(db); err != nil { | 281 | if err := createMigrationsTable(db); err != nil { | ||
| @@ -300,12 +300,12 @@ func TestPartialMigrationState(t *testing.T) { | |||||
| 300 | } | 300 | } | ||
| 301 | 301 | ||||
| 302 | if _, err := tx.Exec(firstMigration.SQL); err != nil { | 302 | if _, err := tx.Exec(firstMigration.SQL); err != nil { | ||
| 303 | - | tx.Rollback() | 303 | + | _ = tx.Rollback() |
| 304 | t.Fatalf("Failed to execute first migration: %v", err) | 304 | t.Fatalf("Failed to execute first migration: %v", err) | ||
| 305 | } | 305 | } | ||
| 306 | 306 | ||||
| 307 | if _, err := tx.Exec("INSERT INTO schema_migrations (version, name) VALUES (?, ?)", firstMigration.Version, firstMigration.Name); err != nil { | 307 | if _, err := tx.Exec("INSERT INTO schema_migrations (version, name) VALUES (?, ?)", firstMigration.Version, firstMigration.Name); err != nil { | ||
| 308 | - | tx.Rollback() | 308 | + | _ = tx.Rollback() |
| 309 | t.Fatalf("Failed to record first migration: %v", err) | 309 | t.Fatalf("Failed to record first migration: %v", err) | ||
| 310 | } | 310 | } | ||
| 311 | 311 | ||||
| @@ -353,7 +353,7 @@ func TestGetAppliedMigrationsEmptyDatabase(t *testing.T) { | |||||
| 353 | if err != nil { | 353 | if err != nil { | ||
| 354 | t.Fatalf("Failed to open test database: %v", err) | 354 | t.Fatalf("Failed to open test database: %v", err) | ||
| 355 | } | 355 | } | ||
| 356 | - | defer db.Close() | 356 | + | defer func() { _ = db.Close() }() |
| 357 | 357 | ||||
| 358 | // Query applied migrations on empty database | 358 | // Query applied migrations on empty database | ||
| 359 | applied, err := GetAppliedMigrations(db) | 359 | applied, err := GetAppliedMigrations(db) | ||
| @@ -375,7 +375,7 @@ func TestVerifyAllMigrationsApplied(t *testing.T) { | |||||
| 375 | if err != nil { | 375 | if err != nil { | ||
| 376 | t.Fatalf("Failed to open test database: %v", err) | 376 | t.Fatalf("Failed to open test database: %v", err) | ||
| 377 | } | 377 | } | ||
| 378 | - | defer db.Close() | 378 | + | defer func() { _ = db.Close() }() |
| 379 | 379 | ||||
| 380 | // Run migrations | 380 | // Run migrations | ||
| 381 | if err := RunEmbeddedMigrations(db); err != nil { | 381 | if err := RunEmbeddedMigrations(db); err != nil { | ||
| @@ -409,7 +409,7 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) { | |||||
| 409 | if err != nil { | 409 | if err != nil { | ||
| 410 | t.Fatalf("Failed to open test database: %v", err) | 410 | t.Fatalf("Failed to open test database: %v", err) | ||
| 411 | } | 411 | } | ||
| 412 | - | defer db.Close() | 412 | + | defer func() { _ = db.Close() }() |
| 413 | 413 | ||||
| 414 | // Run migrations | 414 | // Run migrations | ||
| 415 | if err := RunEmbeddedMigrations(db); err != nil { | 415 | if err := RunEmbeddedMigrations(db); err != nil { | ||
| @@ -420,9 +420,9 @@ func TestExpectedTablesExistAfterMigrations(t *testing.T) { | |||||
| 420 | expectedTables := []string{ | 420 | expectedTables := []string{ | ||
| 421 | "schema_migrations", // Migration tracking | 421 | "schema_migrations", // Migration tracking | ||
| 422 | "history", // 001_initial.sql | 422 | "history", // 001_initial.sql | ||
| 423 | - | "shortcuts", // 001_initial.sql | |||
| 424 | "zoom_levels", // 003_add_zoom_levels.sql | 423 | "zoom_levels", // 003_add_zoom_levels.sql | ||
| 425 | "certificate_validations", // 005_add_certificate_validations.sql | 424 | "certificate_validations", // 005_add_certificate_validations.sql | ||
| 425 | + | // Note: shortcuts table removed in 007_remove_shortcuts_table.sql | |||
| 426 | } | 426 | } | ||
| 427 | 427 | ||||
| 428 | // Check each expected table exists | 428 | // Check each expected table exists | ||
M internal/services/browser.go +219 -23
| @@ -5,13 +5,14 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "database/sql" | 6 | "database/sql" | ||
| 7 | "embed" | 7 | "embed" | ||
| 8 | - | "errors" | |||
| 9 | "fmt" | 8 | "fmt" | ||
| 10 | "log" | 9 | "log" | ||
| 11 | "math" | 10 | "math" | ||
| 12 | neturl "net/url" | 11 | neturl "net/url" | ||
| 12 | + | "sync" | |||
| 13 | "time" | 13 | "time" | ||
| 14 | 14 | ||||
| 15 | + | "github.com/bnema/dumber/internal/cache" | |||
| 15 | "github.com/bnema/dumber/internal/config" | 16 | "github.com/bnema/dumber/internal/config" | ||
| 16 | "github.com/bnema/dumber/internal/db" | 17 | "github.com/bnema/dumber/internal/db" | ||
| 17 | "github.com/bnema/dumber/pkg/webkit" | 18 | "github.com/bnema/dumber/pkg/webkit" | ||
| @@ -33,6 +34,12 @@ type WindowTitleUpdater interface { | |||||
| 33 | SetTitle(title string) | 34 | SetTitle(title string) | ||
| 34 | } | 35 | } | ||
| 35 | 36 | ||||
| 37 | + | // historyUpdate represents a pending history write operation | |||
| 38 | + | type historyUpdate struct { | |||
| 39 | + | url string | |||
| 40 | + | title sql.NullString | |||
| 41 | + | } | |||
| 42 | + | ||||
| 36 | // BrowserService handles browser-related operations for the built-in browser. | 43 | // BrowserService handles browser-related operations for the built-in browser. | ||
| 37 | type BrowserService struct { | 44 | type BrowserService struct { | ||
| 38 | config *config.Config | 45 | config *config.Config | ||
| @@ -41,6 +48,16 @@ type BrowserService struct { | |||||
| 41 | webView *webkit.WebView | 48 | webView *webkit.WebView | ||
| 42 | initialURL string | 49 | initialURL string | ||
| 43 | guiBundle string | 50 | guiBundle string | ||
| 51 | + | zoomCache *cache.ZoomCache // In-memory cache for zoom levels | |||
| 52 | + | certCache *cache.CertValidationsCache // In-memory cache for certificate validations | |||
| 53 | + | fuzzyCache *cache.CacheManager // Async fuzzy search cache for dmenu | |||
| 54 | + | historyQueue chan historyUpdate // Queue for batched history writes | |||
| 55 | + | historyFlushDone chan bool // Signal when history flush is complete (buffered) | |||
| 56 | + | flushOnce sync.Once // Ensures FlushHistoryQueue only runs once | |||
| 57 | + | ||||
| 58 | + | // Fuzzy cache refresh tracking | |||
| 59 | + | historySinceRefresh int // Count of history entries added since last cache refresh | |||
| 60 | + | lastCacheRefresh time.Time // Timestamp of last fuzzy cache refresh | |||
| 44 | } | 61 | } | ||
| 45 | 62 | ||||
| 46 | // ServiceName returns the service name for frontend binding | 63 | // ServiceName returns the service name for frontend binding | ||
| @@ -71,14 +88,26 @@ type HistoryEntry struct { | |||||
| 71 | 88 | ||||
| 72 | // NewBrowserService creates a new BrowserService instance. | 89 | // NewBrowserService creates a new BrowserService instance. | ||
| 73 | func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService { | 90 | func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService { | ||
| 74 | - | return &BrowserService{ | 91 | + | s := &BrowserService{ |
| 75 | - | config: cfg, | 92 | + | config: cfg, |
| 76 | - | dbQueries: queries, | 93 | + | dbQueries: queries, |
| 77 | - | windowTitleUpdater: nil, | 94 | + | windowTitleUpdater: nil, |
| 78 | - | webView: nil, | 95 | + | webView: nil, |
| 79 | - | initialURL: "", | 96 | + | initialURL: "", |
| 80 | - | guiBundle: "", | 97 | + | guiBundle: "", |
| 98 | + | zoomCache: cache.NewZoomCache(queries), | |||
| 99 | + | certCache: cache.NewCertValidationsCache(queries), | |||
| 100 | + | fuzzyCache: cache.NewCacheManager(queries, nil), // nil = use defaults | |||
| 101 | + | historyQueue: make(chan historyUpdate, 100), // Buffer 100 history updates | |||
| 102 | + | historyFlushDone: make(chan bool, 1), // Buffered to prevent blocking | |||
| 103 | + | historySinceRefresh: 0, | |||
| 104 | + | lastCacheRefresh: time.Now(), // Initialize to now | |||
| 81 | } | 105 | } | ||
| 106 | + | ||||
| 107 | + | // Start background batch processor for history writes | |||
| 108 | + | go s.processHistoryQueue() | |||
| 109 | + | ||||
| 110 | + | return s | |||
| 82 | } | 111 | } | ||
| 83 | 112 | ||||
| 84 | // SetWindowTitleUpdater sets the window title updater interface | 113 | // SetWindowTitleUpdater sets the window title updater interface | ||
| @@ -359,14 +388,113 @@ func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]con | |||||
| 359 | } | 388 | } | ||
| 360 | 389 | ||||
| 361 | // recordHistory adds or updates a history entry. | 390 | // recordHistory adds or updates a history entry. | ||
| 391 | + | // Now queues the update for batched processing instead of immediate DB write. | |||
| 362 | func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error { | 392 | func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error { | ||
| 363 | - | // Use AddOrUpdateHistory which handles both cases | |||
| 364 | titleNull := sql.NullString{Valid: false} | 393 | titleNull := sql.NullString{Valid: false} | ||
| 365 | if title != "" { | 394 | if title != "" { | ||
| 366 | titleNull = sql.NullString{String: title, Valid: true} | 395 | titleNull = sql.NullString{String: title, Valid: true} | ||
| 367 | } | 396 | } | ||
| 368 | 397 | ||||
| 369 | - | return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull) | 398 | + | // Queue the history update (non-blocking with buffer) |
| 399 | + | select { | |||
| 400 | + | case s.historyQueue <- historyUpdate{url: url, title: titleNull}: | |||
| 401 | + | // Successfully queued | |||
| 402 | + | return nil | |||
| 403 | + | default: | |||
| 404 | + | // Queue full, write directly (fallback) | |||
| 405 | + | log.Printf("Warning: history queue full, writing directly") | |||
| 406 | + | return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull) | |||
| 407 | + | } | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | // processHistoryQueue processes batched history writes in the background. | |||
| 411 | + | // Flushes every 5 seconds or when a batch reaches 50 items. | |||
| 412 | + | func (s *BrowserService) processHistoryQueue() { | |||
| 413 | + | ticker := time.NewTicker(5 * time.Second) | |||
| 414 | + | defer ticker.Stop() | |||
| 415 | + | ||||
| 416 | + | batch := make([]historyUpdate, 0, 50) | |||
| 417 | + | const maxBatchSize = 50 | |||
| 418 | + | ||||
| 419 | + | flush := func() { | |||
| 420 | + | if len(batch) == 0 { | |||
| 421 | + | return | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | ctx := context.Background() | |||
| 425 | + | // Process all updates in the batch | |||
| 426 | + | // Note: We can't use a transaction with sqlc-generated code easily, | |||
| 427 | + | // but we can still batch the operations to reduce overhead | |||
| 428 | + | for _, update := range batch { | |||
| 429 | + | if err := s.dbQueries.AddOrUpdateHistory(ctx, update.url, update.title); err != nil { | |||
| 430 | + | log.Printf("Warning: failed to write history for %s: %v", update.url, err) | |||
| 431 | + | } | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | log.Printf("Flushed %d history updates to database", len(batch)) | |||
| 435 | + | ||||
| 436 | + | // Track history entries for fuzzy cache refresh | |||
| 437 | + | batchSize := len(batch) | |||
| 438 | + | batch = batch[:0] // Clear batch | |||
| 439 | + | ||||
| 440 | + | // Smart fuzzy cache refresh: rebuild after N entries AND M minutes | |||
| 441 | + | const minEntries = 10 // Minimum new entries before refresh | |||
| 442 | + | const minInterval = 5 * time.Minute // Minimum time between refreshes | |||
| 443 | + | ||||
| 444 | + | s.historySinceRefresh += batchSize | |||
| 445 | + | timeSinceRefresh := time.Since(s.lastCacheRefresh) | |||
| 446 | + | ||||
| 447 | + | if s.historySinceRefresh >= minEntries && timeSinceRefresh >= minInterval { | |||
| 448 | + | log.Printf("[cache] Triggering async fuzzy cache refresh (%d new entries, %v since last refresh)", | |||
| 449 | + | s.historySinceRefresh, timeSinceRefresh) | |||
| 450 | + | ||||
| 451 | + | // Async refresh in background (non-blocking) | |||
| 452 | + | s.fuzzyCache.InvalidateAndRefresh(ctx) | |||
| 453 | + | ||||
| 454 | + | // Reset counters | |||
| 455 | + | s.historySinceRefresh = 0 | |||
| 456 | + | s.lastCacheRefresh = time.Now() | |||
| 457 | + | } | |||
| 458 | + | } | |||
| 459 | + | ||||
| 460 | + | for { | |||
| 461 | + | select { | |||
| 462 | + | case update, ok := <-s.historyQueue: | |||
| 463 | + | if !ok { | |||
| 464 | + | // Channel closed, flush remaining and exit | |||
| 465 | + | flush() | |||
| 466 | + | s.historyFlushDone <- true // Send signal (non-blocking due to buffer) | |||
| 467 | + | return | |||
| 468 | + | } | |||
| 469 | + | batch = append(batch, update) | |||
| 470 | + | if len(batch) >= maxBatchSize { | |||
| 471 | + | flush() | |||
| 472 | + | } | |||
| 473 | + | ||||
| 474 | + | case <-ticker.C: | |||
| 475 | + | flush() | |||
| 476 | + | } | |||
| 477 | + | } | |||
| 478 | + | } | |||
| 479 | + | ||||
| 480 | + | // FlushHistoryQueue flushes any pending history writes and stops the processor. | |||
| 481 | + | // Call this during graceful shutdown. Safe to call multiple times. | |||
| 482 | + | // Respects context cancellation with a timeout to prevent hanging. | |||
| 483 | + | func (s *BrowserService) FlushHistoryQueue(ctx context.Context) error { | |||
| 484 | + | var flushErr error | |||
| 485 | + | s.flushOnce.Do(func() { | |||
| 486 | + | close(s.historyQueue) | |||
| 487 | + | ||||
| 488 | + | // Wait for completion with context cancellation support | |||
| 489 | + | select { | |||
| 490 | + | case <-s.historyFlushDone: | |||
| 491 | + | log.Printf("History queue flushed and closed") | |||
| 492 | + | case <-ctx.Done(): | |||
| 493 | + | flushErr = fmt.Errorf("history queue flush cancelled: %w", ctx.Err()) | |||
| 494 | + | log.Printf("Warning: %v", flushErr) | |||
| 495 | + | } | |||
| 496 | + | }) | |||
| 497 | + | return flushErr | |||
| 370 | } | 498 | } | ||
| 371 | 499 | ||||
| 372 | // Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500% | 500 | // Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500% | ||
| @@ -409,31 +537,97 @@ func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, er | |||||
| 409 | return s.setZoom(ctx, url, 1.0) | 537 | return s.setZoom(ctx, url, 1.0) | ||
| 410 | } | 538 | } | ||
| 411 | 539 | ||||
| 540 | + | // LoadZoomCacheFromDB loads all zoom levels from database into memory cache on startup. | |||
| 541 | + | // This eliminates database reads during page transitions. | |||
| 542 | + | func (s *BrowserService) LoadZoomCacheFromDB(ctx context.Context) error { | |||
| 543 | + | if err := s.zoomCache.Load(ctx); err != nil { | |||
| 544 | + | return fmt.Errorf("failed to load zoom levels: %w", err) | |||
| 545 | + | } | |||
| 546 | + | ||||
| 547 | + | loadedCount := len(s.zoomCache.List()) | |||
| 548 | + | log.Printf("Loaded %d zoom levels into cache", loadedCount) | |||
| 549 | + | return nil | |||
| 550 | + | } | |||
| 551 | + | ||||
| 552 | + | // LoadCertCacheFromDB loads all certificate validations from the database into the cache. | |||
| 553 | + | // This is typically called once at startup for fast RAM-first access. | |||
| 554 | + | func (s *BrowserService) LoadCertCacheFromDB(ctx context.Context) error { | |||
| 555 | + | if err := s.certCache.Load(ctx); err != nil { | |||
| 556 | + | return fmt.Errorf("failed to load certificate validations: %w", err) | |||
| 557 | + | } | |||
| 558 | + | ||||
| 559 | + | loadedCount := len(s.certCache.List()) | |||
| 560 | + | log.Printf("Loaded %d certificate validations into cache", loadedCount) | |||
| 561 | + | return nil | |||
| 562 | + | } | |||
| 563 | + | ||||
| 564 | + | // LoadFuzzyCacheFromDB builds or loads the fuzzy search cache from database history. | |||
| 565 | + | // This ensures dmenu has instant access to pre-built search structures. | |||
| 566 | + | func (s *BrowserService) LoadFuzzyCacheFromDB(ctx context.Context) error { | |||
| 567 | + | // GetCache automatically loads from binary file if valid, or builds from DB | |||
| 568 | + | _, err := s.fuzzyCache.GetCache(ctx) | |||
| 569 | + | if err != nil { | |||
| 570 | + | return fmt.Errorf("failed to load fuzzy cache: %w", err) | |||
| 571 | + | } | |||
| 572 | + | ||||
| 573 | + | stats := s.fuzzyCache.Stats() | |||
| 574 | + | log.Printf("Loaded fuzzy cache: %d entries, %d trigrams", | |||
| 575 | + | stats.EntryCount, stats.TrigramCount) | |||
| 576 | + | return nil | |||
| 577 | + | } | |||
| 578 | + | ||||
| 579 | + | // FlushAllCaches flushes all in-memory caches to ensure pending writes complete. | |||
| 580 | + | // This is typically called during shutdown to prevent data loss. | |||
| 581 | + | func (s *BrowserService) FlushAllCaches(ctx context.Context) error { | |||
| 582 | + | // Flush zoom cache | |||
| 583 | + | if err := s.zoomCache.Flush(); err != nil { | |||
| 584 | + | return fmt.Errorf("failed to flush zoom cache: %w", err) | |||
| 585 | + | } | |||
| 586 | + | ||||
| 587 | + | // Flush certificate validation cache | |||
| 588 | + | if err := s.certCache.Flush(); err != nil { | |||
| 589 | + | return fmt.Errorf("failed to flush cert cache: %w", err) | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | // Save fuzzy cache to binary file for instant dmenu access | |||
| 593 | + | s.fuzzyCache.OnApplicationExit(ctx) | |||
| 594 | + | ||||
| 595 | + | // Flush history queue (existing mechanism) | |||
| 596 | + | if err := s.FlushHistoryQueue(ctx); err != nil { | |||
| 597 | + | return fmt.Errorf("failed to flush history queue: %w", err) | |||
| 598 | + | } | |||
| 599 | + | ||||
| 600 | + | log.Printf("All caches flushed successfully") | |||
| 601 | + | return nil | |||
| 602 | + | } | |||
| 603 | + | ||||
| 412 | // GetZoomLevel retrieves the saved zoom level for a URL. | 604 | // GetZoomLevel retrieves the saved zoom level for a URL. | ||
| 605 | + | // Now reads from in-memory cache instead of database for instant access. | |||
| 413 | func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) { | 606 | func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) { | ||
| 414 | if url == "" { | 607 | if url == "" { | ||
| 415 | return s.config.DefaultZoom, nil | 608 | return s.config.DefaultZoom, nil | ||
| 416 | } | 609 | } | ||
| 417 | 610 | ||||
| 418 | key := zoomKeyFromURL(url) | 611 | key := zoomKeyFromURL(url) | ||
| 419 | - | zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, key) | 612 | + | |
| 420 | - | if err != nil { | 613 | + | // Check cache first (fast RAM lookup) |
| 421 | - | if errors.Is(err, sql.ErrNoRows) { | 614 | + | if zoom, ok := s.zoomCache.Get(key); ok { |
| 422 | - | // Fallback to exact URL record if present (backward compatibility) | 615 | + | return zoom, nil |
| 423 | - | zl, err2 := s.dbQueries.GetZoomLevel(ctx, url) | 616 | + | } |
| 424 | - | if err2 == nil { | 617 | + | |
| 425 | - | return zl, nil | 618 | + | // Fallback to exact URL record for backward compatibility (cache miss) |
| 426 | - | } | 619 | + | if key != url { |
| 427 | - | // No zoom setting found, return configured default | 620 | + | if zoom, ok := s.zoomCache.Get(url); ok { |
| 428 | - | return s.config.DefaultZoom, nil | 621 | + | return zoom, nil |
| 429 | } | 622 | } | ||
| 430 | - | return s.config.DefaultZoom, err | |||
| 431 | } | 623 | } | ||
| 432 | 624 | ||||
| 433 | - | return zoomLevel, nil | 625 | + | // No zoom setting found, return configured default |
| 626 | + | return s.config.DefaultZoom, nil | |||
| 434 | } | 627 | } | ||
| 435 | 628 | ||||
| 436 | // SetZoomLevel sets the zoom level for a URL. | 629 | // SetZoomLevel sets the zoom level for a URL. | ||
| 630 | + | // Updates cache immediately (instant) and persists to DB asynchronously. | |||
| 437 | func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error { | 631 | func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error { | ||
| 438 | if url == "" { | 632 | if url == "" { | ||
| 439 | return fmt.Errorf("URL cannot be empty") | 633 | return fmt.Errorf("URL cannot be empty") | ||
| @@ -447,7 +641,9 @@ func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel | |||||
| 447 | } | 641 | } | ||
| 448 | 642 | ||||
| 449 | key := zoomKeyFromURL(url) | 643 | key := zoomKeyFromURL(url) | ||
| 450 | - | return s.dbQueries.SetZoomLevel(ctx, key, zoomLevel) | 644 | + | |
| 645 | + | // Update cache immediately (fast, synchronous) and persist async | |||
| 646 | + | return s.zoomCache.Set(key, zoomLevel) | |||
| 451 | } | 647 | } | ||
| 452 | 648 | ||||
| 453 | // GetCurrentURL returns the current URL (this would be implemented by the frontend) | 649 | // GetCurrentURL returns the current URL (this would be implemented by the frontend) | ||
A internal/services/browser_fuzzy_test.go +312 -0
| @@ -0,0 +1,312 @@ | |||||
| 1 | + | package services | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "database/sql" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/config" | |||
| 10 | + | "github.com/bnema/dumber/internal/db" | |||
| 11 | + | mock_db "github.com/bnema/dumber/internal/db/mocks" | |||
| 12 | + | "go.uber.org/mock/gomock" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // TestBrowserService_FuzzyCacheIntegration tests the fuzzy cache lifecycle in BrowserService | |||
| 16 | + | func TestBrowserService_FuzzyCacheIntegration(t *testing.T) { | |||
| 17 | + | ctrl := gomock.NewController(t) | |||
| 18 | + | defer ctrl.Finish() | |||
| 19 | + | ||||
| 20 | + | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | |||
| 21 | + | ctx := context.Background() | |||
| 22 | + | ||||
| 23 | + | // Mock test history data | |||
| 24 | + | testHistory := []db.History{ | |||
| 25 | + | { | |||
| 26 | + | ID: 1, | |||
| 27 | + | Url: "https://github.com", | |||
| 28 | + | Title: sql.NullString{ | |||
| 29 | + | String: "GitHub", | |||
| 30 | + | Valid: true, | |||
| 31 | + | }, | |||
| 32 | + | VisitCount: sql.NullInt64{Int64: 5, Valid: true}, | |||
| 33 | + | }, | |||
| 34 | + | { | |||
| 35 | + | ID: 2, | |||
| 36 | + | Url: "https://google.com", | |||
| 37 | + | Title: sql.NullString{ | |||
| 38 | + | String: "Google", | |||
| 39 | + | Valid: true, | |||
| 40 | + | }, | |||
| 41 | + | VisitCount: sql.NullInt64{Int64: 10, Valid: true}, | |||
| 42 | + | }, | |||
| 43 | + | { | |||
| 44 | + | ID: 3, | |||
| 45 | + | Url: "https://stackoverflow.com", | |||
| 46 | + | Title: sql.NullString{ | |||
| 47 | + | String: "Stack Overflow", | |||
| 48 | + | Valid: true, | |||
| 49 | + | }, | |||
| 50 | + | VisitCount: sql.NullInt64{Int64: 3, Valid: true}, | |||
| 51 | + | }, | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | // Expect GetHistory to be called when loading fuzzy cache | |||
| 55 | + | mockDB.EXPECT(). | |||
| 56 | + | GetHistory(gomock.Any(), gomock.Any()). | |||
| 57 | + | Return(testHistory, nil). | |||
| 58 | + | MinTimes(1) | |||
| 59 | + | ||||
| 60 | + | // Create browser service (fuzzy cache initialized) | |||
| 61 | + | cfg := &config.Config{} | |||
| 62 | + | service := NewBrowserService(cfg, mockDB) | |||
| 63 | + | ||||
| 64 | + | // Test 1: Verify fuzzy cache was initialized | |||
| 65 | + | if service.fuzzyCache == nil { | |||
| 66 | + | t.Fatal("Fuzzy cache should be initialized") | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | // Test 2: Load fuzzy cache from DB | |||
| 70 | + | err := service.LoadFuzzyCacheFromDB(ctx) | |||
| 71 | + | if err != nil { | |||
| 72 | + | t.Fatalf("Failed to load fuzzy cache: %v", err) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // Test 3: Verify cache has entries | |||
| 76 | + | stats := service.fuzzyCache.Stats() | |||
| 77 | + | if stats.EntryCount == 0 { | |||
| 78 | + | t.Error("Fuzzy cache should have entries after loading") | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | t.Logf("Fuzzy cache loaded: %d entries, %d trigrams", stats.EntryCount, stats.TrigramCount) | |||
| 82 | + | ||||
| 83 | + | // Test 4: Verify refresh tracking is initialized | |||
| 84 | + | if service.historySinceRefresh != 0 { | |||
| 85 | + | t.Error("historySinceRefresh should start at 0") | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | if service.lastCacheRefresh.IsZero() { | |||
| 89 | + | t.Error("lastCacheRefresh should be initialized") | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | // Test 5: Flush all caches (should include fuzzy cache) | |||
| 93 | + | err = service.FlushAllCaches(ctx) | |||
| 94 | + | if err != nil { | |||
| 95 | + | t.Fatalf("Failed to flush caches: %v", err) | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | t.Log("Fuzzy cache integration test passed") | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | // TestBrowserService_FuzzyCacheSmartRefresh tests the smart refresh logic | |||
| 102 | + | func TestBrowserService_FuzzyCacheSmartRefresh(t *testing.T) { | |||
| 103 | + | ctrl := gomock.NewController(t) | |||
| 104 | + | defer ctrl.Finish() | |||
| 105 | + | ||||
| 106 | + | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | |||
| 107 | + | cfg := &config.Config{} | |||
| 108 | + | ||||
| 109 | + | // Expect GetHistory to be called when loading fuzzy cache | |||
| 110 | + | mockDB.EXPECT(). | |||
| 111 | + | GetHistory(gomock.Any(), gomock.Any()). | |||
| 112 | + | Return([]db.History{}, nil). | |||
| 113 | + | AnyTimes() | |||
| 114 | + | ||||
| 115 | + | service := NewBrowserService(cfg, mockDB) | |||
| 116 | + | ||||
| 117 | + | // Load initial cache | |||
| 118 | + | ctx := context.Background() | |||
| 119 | + | err := service.LoadFuzzyCacheFromDB(ctx) | |||
| 120 | + | if err != nil { | |||
| 121 | + | t.Fatalf("Failed to load fuzzy cache: %v", err) | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // Test 1: Verify initial state | |||
| 125 | + | if service.historySinceRefresh != 0 { | |||
| 126 | + | t.Error("historySinceRefresh should start at 0") | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | initialRefreshTime := service.lastCacheRefresh | |||
| 130 | + | ||||
| 131 | + | // Test 2: Simulate adding less than threshold entries (< 10) | |||
| 132 | + | service.historySinceRefresh = 5 | |||
| 133 | + | service.lastCacheRefresh = time.Now().Add(-10 * time.Minute) // Old enough | |||
| 134 | + | ||||
| 135 | + | // This should NOT trigger refresh (not enough entries) | |||
| 136 | + | // Manually check the condition | |||
| 137 | + | const minEntries = 10 | |||
| 138 | + | const minInterval = 5 * time.Minute | |||
| 139 | + | ||||
| 140 | + | shouldRefresh := service.historySinceRefresh >= minEntries && | |||
| 141 | + | time.Since(service.lastCacheRefresh) >= minInterval | |||
| 142 | + | ||||
| 143 | + | if shouldRefresh { | |||
| 144 | + | t.Error("Should not trigger refresh with only 5 entries") | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | // Test 3: Simulate enough entries but too recent | |||
| 148 | + | service.historySinceRefresh = 15 | |||
| 149 | + | service.lastCacheRefresh = time.Now() // Just refreshed | |||
| 150 | + | ||||
| 151 | + | shouldRefresh = service.historySinceRefresh >= minEntries && | |||
| 152 | + | time.Since(service.lastCacheRefresh) >= minInterval | |||
| 153 | + | ||||
| 154 | + | if shouldRefresh { | |||
| 155 | + | t.Error("Should not trigger refresh when last refresh was too recent") | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // Test 4: Simulate conditions that SHOULD trigger refresh | |||
| 159 | + | service.historySinceRefresh = 15 | |||
| 160 | + | service.lastCacheRefresh = time.Now().Add(-10 * time.Minute) | |||
| 161 | + | ||||
| 162 | + | shouldRefresh = service.historySinceRefresh >= minEntries && | |||
| 163 | + | time.Since(service.lastCacheRefresh) >= minInterval | |||
| 164 | + | ||||
| 165 | + | if !shouldRefresh { | |||
| 166 | + | t.Error("Should trigger refresh with 15 entries and 10 minutes passed") | |||
| 167 | + | } | |||
| 168 | + | ||||
| 169 | + | t.Logf("Smart refresh logic validated: %d entries, %v since refresh, should refresh: %v", | |||
| 170 | + | service.historySinceRefresh, | |||
| 171 | + | time.Since(service.lastCacheRefresh), | |||
| 172 | + | shouldRefresh) | |||
| 173 | + | ||||
| 174 | + | // Test 5: Verify counter would reset after refresh | |||
| 175 | + | if shouldRefresh { | |||
| 176 | + | // Simulate the reset that happens in the actual code | |||
| 177 | + | service.historySinceRefresh = 0 | |||
| 178 | + | service.lastCacheRefresh = time.Now() | |||
| 179 | + | ||||
| 180 | + | if service.historySinceRefresh != 0 { | |||
| 181 | + | t.Error("Counter should reset after refresh") | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // Test 6: Verify old refresh time was updated | |||
| 186 | + | if !service.lastCacheRefresh.After(initialRefreshTime) { | |||
| 187 | + | t.Error("Refresh time should be updated") | |||
| 188 | + | } | |||
| 189 | + | ||||
| 190 | + | t.Log("Smart refresh logic test passed") | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | // TestBrowserService_FuzzyCacheThresholds tests the threshold constants | |||
| 194 | + | func TestBrowserService_FuzzyCacheThresholds(t *testing.T) { | |||
| 195 | + | // This test documents and validates the threshold values | |||
| 196 | + | ||||
| 197 | + | const minEntries = 10 // From the actual code | |||
| 198 | + | const minInterval = 5 * time.Minute // From the actual code | |||
| 199 | + | ||||
| 200 | + | t.Logf("Fuzzy cache refresh thresholds:") | |||
| 201 | + | t.Logf(" - Minimum entries: %d", minEntries) | |||
| 202 | + | t.Logf(" - Minimum interval: %v", minInterval) | |||
| 203 | + | ||||
| 204 | + | // Test reasonable threshold values | |||
| 205 | + | if minEntries < 5 { | |||
| 206 | + | t.Error("minEntries too low - will refresh too frequently") | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | if minEntries > 100 { | |||
| 210 | + | t.Error("minEntries too high - cache will be stale") | |||
| 211 | + | } | |||
| 212 | + | ||||
| 213 | + | if minInterval < 1*time.Minute { | |||
| 214 | + | t.Error("minInterval too low - will refresh too frequently") | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | if minInterval > 30*time.Minute { | |||
| 218 | + | t.Error("minInterval too high - cache will be stale") | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // Test that both conditions are required | |||
| 222 | + | // This prevents: | |||
| 223 | + | // - Refreshing after 1 entry but 10 minutes (would be wasteful) | |||
| 224 | + | // - Refreshing after 100 entries but 1 second (would be too frequent) | |||
| 225 | + | ||||
| 226 | + | t.Log("Threshold validation passed - both conditions required for refresh") | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | // TestBrowserService_FuzzyCacheLoadFromDB tests loading cache at startup | |||
| 230 | + | func TestBrowserService_FuzzyCacheLoadFromDB(t *testing.T) { | |||
| 231 | + | ctrl := gomock.NewController(t) | |||
| 232 | + | defer ctrl.Finish() | |||
| 233 | + | ||||
| 234 | + | mockDB := mock_db.NewMockDatabaseQuerier(ctrl) | |||
| 235 | + | ctx := context.Background() | |||
| 236 | + | ||||
| 237 | + | // Mock test history entries | |||
| 238 | + | testHistory := []db.History{ | |||
| 239 | + | { | |||
| 240 | + | ID: 1, | |||
| 241 | + | Url: "https://github.com/user/repo", | |||
| 242 | + | Title: sql.NullString{String: "GitHub Repository", Valid: true}, | |||
| 243 | + | VisitCount: sql.NullInt64{Int64: 10, Valid: true}, | |||
| 244 | + | }, | |||
| 245 | + | { | |||
| 246 | + | ID: 2, | |||
| 247 | + | Url: "https://stackoverflow.com/questions/123", | |||
| 248 | + | Title: sql.NullString{String: "Stack Overflow Question", Valid: true}, | |||
| 249 | + | VisitCount: sql.NullInt64{Int64: 5, Valid: true}, | |||
| 250 | + | }, | |||
| 251 | + | { | |||
| 252 | + | ID: 3, | |||
| 253 | + | Url: "https://golang.org/doc", | |||
| 254 | + | Title: sql.NullString{String: "Go Documentation", Valid: true}, | |||
| 255 | + | VisitCount: sql.NullInt64{Int64: 15, Valid: true}, | |||
| 256 | + | }, | |||
| 257 | + | { | |||
| 258 | + | ID: 4, | |||
| 259 | + | Url: "https://reddit.com/r/golang", | |||
| 260 | + | Title: sql.NullString{String: "Golang Subreddit", Valid: true}, | |||
| 261 | + | VisitCount: sql.NullInt64{Int64: 3, Valid: true}, | |||
| 262 | + | }, | |||
| 263 | + | { | |||
| 264 | + | ID: 5, | |||
| 265 | + | Url: "https://news.ycombinator.com", | |||
| 266 | + | Title: sql.NullString{String: "Hacker News", Valid: true}, | |||
| 267 | + | VisitCount: sql.NullInt64{Int64: 20, Valid: true}, | |||
| 268 | + | }, | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | // Expect GetHistory to be called when loading fuzzy cache | |||
| 272 | + | mockDB.EXPECT(). | |||
| 273 | + | GetHistory(gomock.Any(), gomock.Any()). | |||
| 274 | + | Return(testHistory, nil). | |||
| 275 | + | MinTimes(1) | |||
| 276 | + | ||||
| 277 | + | // Create service and load cache | |||
| 278 | + | cfg := &config.Config{} | |||
| 279 | + | service := NewBrowserService(cfg, mockDB) | |||
| 280 | + | ||||
| 281 | + | // Load cache from DB | |||
| 282 | + | err := service.LoadFuzzyCacheFromDB(ctx) | |||
| 283 | + | if err != nil { | |||
| 284 | + | t.Fatalf("Failed to load fuzzy cache from DB: %v", err) | |||
| 285 | + | } | |||
| 286 | + | ||||
| 287 | + | // Verify cache has loaded entries | |||
| 288 | + | stats := service.fuzzyCache.Stats() | |||
| 289 | + | if stats.EntryCount < len(testHistory) { | |||
| 290 | + | t.Errorf("Expected at least %d entries, got %d", len(testHistory), stats.EntryCount) | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | if stats.TrigramCount == 0 { | |||
| 294 | + | t.Error("Expected trigram index to be built") | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | t.Logf("Loaded fuzzy cache: %d entries, %d trigrams", stats.EntryCount, stats.TrigramCount) | |||
| 298 | + | ||||
| 299 | + | // Test search functionality | |||
| 300 | + | result, err := service.fuzzyCache.Search(ctx, "github") | |||
| 301 | + | if err != nil { | |||
| 302 | + | t.Fatalf("Failed to search fuzzy cache: %v", err) | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | if len(result.Matches) == 0 { | |||
| 306 | + | t.Error("Expected to find matches for 'github'") | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | t.Logf("Search for 'github' found %d matches", len(result.Matches)) | |||
| 310 | + | ||||
| 311 | + | t.Log("Load from DB test passed") | |||
| 312 | + | } | |||
M internal/services/favicon_service.go +6 -3
| @@ -4,6 +4,7 @@ package services | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "database/sql" | 6 | "database/sql" | ||
| 7 | + | "errors" | |||
| 7 | "fmt" | 8 | "fmt" | ||
| 8 | "log" | 9 | "log" | ||
| 9 | "os" | 10 | "os" | ||
| @@ -52,7 +53,7 @@ func NewFaviconService(faviconDB *webkit.FaviconDatabase, queries db.DatabaseQue | |||||
| 52 | 53 | ||||
| 53 | // Create export directory for CLI access | 54 | // Create export directory for CLI access | ||
| 54 | exportDir := filepath.Join(dataDir, "favicons-export") | 55 | exportDir := filepath.Join(dataDir, "favicons-export") | ||
| 55 | - | if err := os.MkdirAll(exportDir, 0755); err != nil { | 56 | + | if err := os.MkdirAll(exportDir, 0750); err != nil { |
| 56 | return nil, fmt.Errorf("failed to create favicon export directory: %w", err) | 57 | return nil, fmt.Errorf("failed to create favicon export directory: %w", err) | ||
| 57 | } | 58 | } | ||
| 58 | 59 | ||||
| @@ -291,7 +292,7 @@ func (fs *FaviconService) lookupFaviconURI(ctx context.Context, pageURL string) | |||||
| 291 | if entry.FaviconUrl.Valid && entry.FaviconUrl.String != "" { | 292 | if entry.FaviconUrl.Valid && entry.FaviconUrl.String != "" { | ||
| 292 | return entry.FaviconUrl.String, nil | 293 | return entry.FaviconUrl.String, nil | ||
| 293 | } | 294 | } | ||
| 294 | - | } else if err != sql.ErrNoRows { | 295 | + | } else if !errors.Is(err, sql.ErrNoRows) { |
| 295 | log.Printf("[favicon] failed to query history entry for %s: %v", pageURL, err) | 296 | log.Printf("[favicon] failed to query history entry for %s: %v", pageURL, err) | ||
| 296 | } | 297 | } | ||
| 297 | 298 | ||||
| @@ -439,7 +440,9 @@ func (fs *FaviconService) CleanOldExports() error { | |||||
| 439 | } | 440 | } | ||
| 440 | 441 | ||||
| 441 | if info.ModTime().Before(cutoff) { | 442 | if info.ModTime().Before(cutoff) { | ||
| 442 | - | os.Remove(filepath.Join(fs.exportDir, entry.Name())) | 443 | + | if err := os.Remove(filepath.Join(fs.exportDir, entry.Name())); err != nil { |
| 444 | + | fmt.Fprintf(os.Stderr, "warning: failed to remove old favicon: %v\n", err) | |||
| 445 | + | } | |||
| 443 | } | 446 | } | ||
| 444 | } | 447 | } | ||
| 445 | 448 | ||||
M pkg/webkit/keyboard_bridge.go +8 -6
| @@ -11,9 +11,9 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | // Keyboard shortcut modifier string constants | 12 | // Keyboard shortcut modifier string constants | ||
| 13 | const ( | 13 | const ( | ||
| 14 | - | modifierCmdOrCtrl = "cmdorctrl" | 14 | + | modifierCtrl = "ctrl" |
| 15 | - | modifierShift = "shift" | 15 | + | modifierShift = "shift" |
| 16 | - | modifierAlt = "alt" | 16 | + | modifierAlt = "alt" |
| 17 | ) | 17 | ) | ||
| 18 | 18 | ||||
| 19 | // GDK key constants for special keys | 19 | // GDK key constants for special keys | ||
| @@ -94,6 +94,8 @@ var shortcutActions = map[string]string{ | |||||
| 94 | altmod + "+" + keyArrowUp: directionUp, | 94 | altmod + "+" + keyArrowUp: directionUp, | ||
| 95 | } | 95 | } | ||
| 96 | 96 | ||||
| 97 | + | // TODO(#8): Refactor this function to reduce cyclomatic complexity (52 -> <30) | |||
| 98 | + | // | |||
| 97 | // AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript | 99 | // AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript | ||
| 98 | // This is critical for allowing JavaScript KeyboardService to receive keyboard events | 100 | // This is critical for allowing JavaScript KeyboardService to receive keyboard events | ||
| 99 | // that would otherwise be consumed by GTK shortcuts | 101 | // that would otherwise be consumed by GTK shortcuts | ||
| @@ -127,9 +129,9 @@ func (w *WebView) AttachKeyboardBridge() { | |||||
| 127 | // Build modifier prefix using switch on modifier combination | 129 | // Build modifier prefix using switch on modifier combination | ||
| 128 | switch { | 130 | switch { | ||
| 129 | case ctrl && shift: | 131 | case ctrl && shift: | ||
| 130 | - | parts = append(parts, modifierCmdOrCtrl, modifierShift) | 132 | + | parts = append(parts, modifierCtrl, modifierShift) |
| 131 | case ctrl && !shift: | 133 | case ctrl && !shift: | ||
| 132 | - | parts = append(parts, modifierCmdOrCtrl) | 134 | + | parts = append(parts, modifierCtrl) |
| 133 | case alt: | 135 | case alt: | ||
| 134 | parts = append(parts, modifierAlt) | 136 | parts = append(parts, modifierAlt) | ||
| 135 | case shift: | 137 | case shift: | ||
| @@ -199,7 +201,7 @@ func (w *WebView) AttachKeyboardBridge() { | |||||
| 199 | 201 | ||||
| 200 | if shortcut != "" { | 202 | if shortcut != "" { | ||
| 201 | // Special case: Ctrl+P enters pane mode | 203 | // Special case: Ctrl+P enters pane mode | ||
| 202 | - | if shortcut == modifierCmdOrCtrl+"+"+keyP { | 204 | + | if shortcut == modifierCtrl+"+"+keyP { |
| 203 | w.mu.RLock() | 205 | w.mu.RLock() | ||
| 204 | handler := w.onPaneModeShortcut | 206 | handler := w.onPaneModeShortcut | ||
| 205 | w.mu.RUnlock() | 207 | w.mu.RUnlock() | ||
M pkg/webkit/mainloop.go +0 -1
| @@ -9,7 +9,6 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | var ( | 10 | var ( | ||
| 11 | mainLoop *glib.MainLoop | 11 | mainLoop *glib.MainLoop | ||
| 12 | - | mainThreadID = runtime.LockOSThread // Store the main thread | |||
| 13 | isInitialized bool | 12 | isInitialized bool | ||
| 14 | ) | 13 | ) | ||
| 15 | 14 | ||||
M pkg/webkit/network_session.go +13 -0
| @@ -102,3 +102,16 @@ func InitPersistentSession(dataDir, cacheDir string) error { | |||||
| 102 | func GetGlobalNetworkSession() *webkit.NetworkSession { | 102 | func GetGlobalNetworkSession() *webkit.NetworkSession { | ||
| 103 | return globalNetworkSession | 103 | return globalNetworkSession | ||
| 104 | } | 104 | } | ||
| 105 | + | ||||
| 106 | + | // PrefetchDNS prefetches DNS for the given hostname to speed up future requests. | |||
| 107 | + | // This is useful for preloading DNS for links that the user might click. | |||
| 108 | + | // Safe to call even if the hostname is invalid or session is not initialized. | |||
| 109 | + | func PrefetchDNS(hostname string) { | |||
| 110 | + | if globalNetworkSession == nil { | |||
| 111 | + | return // Session not initialized yet | |||
| 112 | + | } | |||
| 113 | + | if hostname == "" { | |||
| 114 | + | return // Nothing to prefetch | |||
| 115 | + | } | |||
| 116 | + | globalNetworkSession.PrefetchDns(hostname) | |||
| 117 | + | } | |||
M pkg/webkit/tls_handler.go +17 -36
| @@ -2,8 +2,8 @@ package webkit | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | - | "crypto/sha256" | |||
| 6 | "database/sql" | 5 | "database/sql" | ||
| 6 | + | "errors" | |||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "log" | 8 | "log" | ||
| 9 | "net/url" | 9 | "net/url" | ||
| @@ -83,37 +83,6 @@ func extractHostname(uri string) string { | |||||
| 83 | return host | 83 | return host | ||
| 84 | } | 84 | } | ||
| 85 | 85 | ||||
| 86 | - | // formatCertificateInfo formats certificate information for display and hashing | |||
| 87 | - | func formatCertificateInfo(certificate gio.TLSCertificater) string { | |||
| 88 | - | // Convert to TLSCertificate to access methods | |||
| 89 | - | tlsCert := gio.BaseTLSCertificate(certificate) | |||
| 90 | - | if tlsCert == nil { | |||
| 91 | - | // Fallback: use the object pointer address as a weak identifier | |||
| 92 | - | return fmt.Sprintf("%p", certificate) | |||
| 93 | - | } | |||
| 94 | - | ||||
| 95 | - | // Build a unique string from certificate properties | |||
| 96 | - | // Use subject, issuer, and validity period | |||
| 97 | - | subject := tlsCert.SubjectName() | |||
| 98 | - | issuer := "" | |||
| 99 | - | if issuerCert := tlsCert.Issuer(); issuerCert != nil { | |||
| 100 | - | issuer = gio.BaseTLSCertificate(issuerCert).SubjectName() | |||
| 101 | - | } | |||
| 102 | - | ||||
| 103 | - | notBefore := tlsCert.NotValidBefore() | |||
| 104 | - | notAfter := tlsCert.NotValidAfter() | |||
| 105 | - | ||||
| 106 | - | // Combine properties into a unique string | |||
| 107 | - | return fmt.Sprintf("subject=%s,issuer=%s,notBefore=%v,notAfter=%v", | |||
| 108 | - | subject, issuer, notBefore, notAfter) | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | // getCertificateHash generates a SHA256 hash of the certificate info | |||
| 112 | - | func getCertificateHash(certificateInfo string) string { | |||
| 113 | - | hash := sha256.Sum256([]byte(certificateInfo)) | |||
| 114 | - | return fmt.Sprintf("%x", hash) | |||
| 115 | - | } | |||
| 116 | - | ||||
| 117 | // checkStoredCertificateDecision checks if user has previously decided on this hostname | 86 | // checkStoredCertificateDecision checks if user has previously decided on this hostname | ||
| 118 | func checkStoredCertificateDecision(hostname string) (string, bool) { | 87 | func checkStoredCertificateDecision(hostname string) (string, bool) { | ||
| 119 | cfg := config.Get() | 88 | cfg := config.Get() | ||
| @@ -133,7 +102,11 @@ func checkStoredCertificateDecision(hostname string) (string, bool) { | |||||
| 133 | log.Printf("[tls] Failed to open database: %v", err) | 102 | log.Printf("[tls] Failed to open database: %v", err) | ||
| 134 | return "", false | 103 | return "", false | ||
| 135 | } | 104 | } | ||
| 136 | - | defer database.Close() | 105 | + | defer func() { |
| 106 | + | if err := database.Close(); err != nil { | |||
| 107 | + | log.Printf("[tls] warning: failed to close database: %v", err) | |||
| 108 | + | } | |||
| 109 | + | }() | |||
| 137 | 110 | ||||
| 138 | queries := db.New(database) | 111 | queries := db.New(database) | ||
| 139 | ctx := context.Background() | 112 | ctx := context.Background() | ||
| @@ -141,7 +114,7 @@ func checkStoredCertificateDecision(hostname string) (string, bool) { | |||||
| 141 | // Get any validation for this hostname (we don't verify cert hash anymore) | 114 | // Get any validation for this hostname (we don't verify cert hash anymore) | ||
| 142 | validation, err := queries.GetCertificateValidationByHostname(ctx, hostname) | 115 | validation, err := queries.GetCertificateValidationByHostname(ctx, hostname) | ||
| 143 | if err != nil { | 116 | if err != nil { | ||
| 144 | - | if err == sql.ErrNoRows { | 117 | + | if errors.Is(err, sql.ErrNoRows) { |
| 145 | return "", false | 118 | return "", false | ||
| 146 | } | 119 | } | ||
| 147 | log.Printf("[tls] Error checking certificate validation: %v", err) | 120 | log.Printf("[tls] Error checking certificate validation: %v", err) | ||
| @@ -167,7 +140,11 @@ func storeCertificateDecision(hostname, decision string, expiresAt sql.NullTime) | |||||
| 167 | if err != nil { | 140 | if err != nil { | ||
| 168 | return fmt.Errorf("failed to open database: %w", err) | 141 | return fmt.Errorf("failed to open database: %w", err) | ||
| 169 | } | 142 | } | ||
| 170 | - | defer database.Close() | 143 | + | defer func() { |
| 144 | + | if err := database.Close(); err != nil { | |||
| 145 | + | log.Printf("[tls] warning: failed to close database: %v", err) | |||
| 146 | + | } | |||
| 147 | + | }() | |||
| 171 | 148 | ||||
| 172 | queries := db.New(database) | 149 | queries := db.New(database) | ||
| 173 | ctx := context.Background() | 150 | ctx := context.Background() | ||
| @@ -338,7 +315,11 @@ func CleanupExpiredCertificateValidations() error { | |||||
| 338 | if err != nil { | 315 | if err != nil { | ||
| 339 | return fmt.Errorf("failed to open database: %w", err) | 316 | return fmt.Errorf("failed to open database: %w", err) | ||
| 340 | } | 317 | } | ||
| 341 | - | defer database.Close() | 318 | + | defer func() { |
| 319 | + | if err := database.Close(); err != nil { | |||
| 320 | + | log.Printf("[tls] warning: failed to close database: %v", err) | |||
| 321 | + | } | |||
| 322 | + | }() | |||
| 342 | 323 | ||||
| 343 | queries := db.New(database) | 324 | queries := db.New(database) | ||
| 344 | ctx := context.Background() | 325 | ctx := context.Background() | ||
M pkg/webkit/types.go +17 -8
| @@ -45,6 +45,13 @@ type Config struct { | |||||
| 45 | // MinimumFontSize in pixels | 45 | // MinimumFontSize in pixels | ||
| 46 | MinimumFontSize int | 46 | MinimumFontSize int | ||
| 47 | 47 | ||||
| 48 | + | // Performance optimizations | |||
| 49 | + | // EnablePageCache enables back/forward cache for instant navigation | |||
| 50 | + | EnablePageCache bool | |||
| 51 | + | ||||
| 52 | + | // EnableSmoothScrolling enables smooth scrolling animations | |||
| 53 | + | EnableSmoothScrolling bool | |||
| 54 | + | ||||
| 48 | // DataDir is the directory for persistent data (cookies, localStorage, etc.) | 55 | // DataDir is the directory for persistent data (cookies, localStorage, etc.) | ||
| 49 | DataDir string | 56 | DataDir string | ||
| 50 | 57 | ||||
| @@ -63,13 +70,15 @@ type Config struct { | |||||
| 63 | // GetDefaultConfig returns a Config with sensible defaults | 70 | // GetDefaultConfig returns a Config with sensible defaults | ||
| 64 | func GetDefaultConfig() *Config { | 71 | func GetDefaultConfig() *Config { | ||
| 65 | return &Config{ | 72 | return &Config{ | ||
| 66 | - | UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", | 73 | + | UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", |
| 67 | - | EnableJavaScript: true, | 74 | + | EnableJavaScript: true, |
| 68 | - | EnableWebGL: true, | 75 | + | EnableWebGL: true, |
| 69 | - | EnableMediaStream: true, | 76 | + | EnableMediaStream: true, |
| 70 | - | HardwareAcceleration: true, | 77 | + | HardwareAcceleration: true, |
| 71 | - | DefaultFontSize: 16, | 78 | + | DefaultFontSize: 16, |
| 72 | - | MinimumFontSize: 8, | 79 | + | MinimumFontSize: 8, |
| 73 | - | CreateWindow: true, // Default to standalone window | 80 | + | EnablePageCache: true, // Instant back/forward navigation |
| 81 | + | EnableSmoothScrolling: true, // Better UX | |||
| 82 | + | CreateWindow: true, // Default to standalone window | |||
| 74 | } | 83 | } | ||
| 75 | } | 84 | } | ||
M pkg/webkit/webview.go +19 -10
| @@ -197,6 +197,13 @@ func (w *WebView) applyConfig() error { | |||||
| 197 | // Enable hardware acceleration if configured | 197 | // Enable hardware acceleration if configured | ||
| 198 | settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlways) | 198 | settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlways) | ||
| 199 | 199 | ||||
| 200 | + | // Performance optimizations for faster page transitions | |||
| 201 | + | // Enable page cache for instant back/forward navigation (bfcache) | |||
| 202 | + | settings.SetEnablePageCache(w.config.EnablePageCache) | |||
| 203 | + | ||||
| 204 | + | // Enable smooth scrolling for better UX | |||
| 205 | + | settings.SetEnableSmoothScrolling(w.config.EnableSmoothScrolling) | |||
| 206 | + | ||||
| 200 | return nil | 207 | return nil | ||
| 201 | } | 208 | } | ||
| 202 | 209 | ||||
| @@ -229,11 +236,6 @@ func (w *WebView) setupEventHandlers() { | |||||
| 229 | // Favicon changed - connect to FaviconDatabase | 236 | // Favicon changed - connect to FaviconDatabase | ||
| 230 | w.setupFaviconHandlers() | 237 | w.setupFaviconHandlers() | ||
| 231 | 238 | ||||
| 232 | - | // Load changed | |||
| 233 | - | w.view.ConnectLoadChanged(func(event webkit.LoadEvent) { | |||
| 234 | - | // Handle load events if needed | |||
| 235 | - | }) | |||
| 236 | - | ||||
| 237 | // TLS error handling - connect to load-failed-with-tls-errors signal | 239 | // TLS error handling - connect to load-failed-with-tls-errors signal | ||
| 238 | w.setupTLSErrorHandler() | 240 | w.setupTLSErrorHandler() | ||
| 239 | 241 | ||||
| @@ -418,8 +420,12 @@ func (w *WebView) Hide() error { | |||||
| 418 | 420 | ||||
| 419 | // Destroy destroys the WebView and releases resources | 421 | // Destroy destroys the WebView and releases resources | ||
| 420 | func (w *WebView) Destroy() error { | 422 | func (w *WebView) Destroy() error { | ||
| 421 | - | w.mu.Lock() | 423 | + | // DO NOT acquire w.mu lock here to avoid deadlock! |
| 422 | - | defer w.mu.Unlock() | 424 | + | // After GTK main loop exits, any goroutine holding the lock and calling GTK methods |
| 425 | + | // will block forever waiting for the main loop. This causes Destroy() to deadlock | |||
| 426 | + | // trying to acquire the same lock. | |||
| 427 | + | // | |||
| 428 | + | // During shutdown, thread safety is not critical since we're tearing everything down. | |||
| 423 | 429 | ||||
| 424 | if w.destroyed { | 430 | if w.destroyed { | ||
| 425 | return nil | 431 | return nil | ||
| @@ -432,14 +438,17 @@ func (w *WebView) Destroy() error { | |||||
| 432 | delete(viewRegistry, w.id) | 438 | delete(viewRegistry, w.id) | ||
| 433 | viewMu.Unlock() | 439 | viewMu.Unlock() | ||
| 434 | 440 | ||||
| 435 | - | // The GTK widget will be cleaned up by Go GC | 441 | + | // DO NOT call StopLoading() or any other GTK methods here! |
| 442 | + | // After the main loop exits, GTK calls will block forever. | |||
| 443 | + | // The GTK widget will be cleaned up by Go GC. | |||
| 444 | + | ||||
| 436 | return nil | 445 | return nil | ||
| 437 | } | 446 | } | ||
| 438 | 447 | ||||
| 439 | // IsDestroyed returns true if the WebView has been destroyed | 448 | // IsDestroyed returns true if the WebView has been destroyed | ||
| 440 | func (w *WebView) IsDestroyed() bool { | 449 | func (w *WebView) IsDestroyed() bool { | ||
| 441 | - | w.mu.RLock() | 450 | + | // DO NOT acquire lock - read of bool is atomic on all platforms Go supports |
| 442 | - | defer w.mu.RUnlock() | 451 | + | // This prevents deadlock during shutdown when GTK signal handlers check IsDestroyed |
| 443 | return w.destroyed | 452 | return w.destroyed | ||
| 444 | } | 453 | } | ||
| 445 | 454 | ||||
M tests/integration/browser_controls_integration_test.go +8 -1
| @@ -93,10 +93,17 @@ func setupTestDB(t *testing.T) (*sql.DB, *db.Queries, func()) { | |||||
| 93 | func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) { | 93 | func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) { | ||
| 94 | _, queries, cleanup := setupTestDB(t) | 94 | _, queries, cleanup := setupTestDB(t) | ||
| 95 | 95 | ||||
| 96 | - | cfg := &config.Config{} | 96 | + | cfg := &config.Config{ |
| 97 | + | DefaultZoom: 1.0, // Set default zoom level | |||
| 98 | + | } | |||
| 97 | 99 | ||||
| 98 | service := services.NewBrowserService(cfg, queries) | 100 | service := services.NewBrowserService(cfg, queries) | ||
| 99 | 101 | ||||
| 102 | + | // Load zoom cache (simulates startup behavior) | |||
| 103 | + | if err := service.LoadZoomCacheFromDB(context.Background()); err != nil { | |||
| 104 | + | t.Fatalf("Failed to load zoom cache: %v", err) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 100 | return service, cleanup | 107 | return service, cleanup | ||
| 101 | } | 108 | } | ||
| 102 | 109 | ||||