detached 0b09e9e

Merge pull request #7 from bnema/dev

feat: keyboard shortcuts, cache improvements, and error handling
commit
0b09e9ea9b3740f405e04e0a836e9cde031638d1
author
bnema · · 2025-10-24 17:53 UTC
committer
GitHub · · 2025-10-24 17:53 UTC
merge of
eedaf29 + d814c2c · diff against the first parent
signature
unsigned
76 files · +3107 -794
M .gitignore +1 -0
@@ -82,3 +82,4 @@ templates/
82 .serena/82 .serena/
83 config.yaml83 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 ### Added7 ### 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-1830 ## [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 Management56 #### 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 Reference211+### How Configuration Works
212 212
213-#### Database Configuration213+- **Config file**: `~/.config/dumber/config.json` (supports `.yaml` and `.toml` too)
214-```json214+- **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 Management220+### 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 ```json222 ```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 ```bash237 ```bash
318-# Database238+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 Flags242+**📖 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.17 github.com/diamondburned/gotk4/pkg v0.3.1
8 github.com/fsnotify/fsnotify v1.9.08 github.com/fsnotify/fsnotify v1.9.0
9 github.com/golang/mock v1.6.09 github.com/golang/mock v1.6.0
10+ github.com/invopop/jsonschema v0.13.0
10 github.com/ncruces/go-sqlite3 v0.29.011 github.com/ncruces/go-sqlite3 v0.29.0
11 github.com/spf13/cobra v1.10.112 github.com/spf13/cobra v1.10.1
12 github.com/spf13/viper v1.21.013 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.016 golang.org/x/sys v0.36.0
14 )17 )
15 18
16 require (19 require (
17 github.com/KarpelesLab/weak v0.1.1 // indirect20 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 // indirect23 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
19 github.com/google/go-cmp v0.7.0 // indirect24 github.com/google/go-cmp v0.7.0 // indirect
20 github.com/inconshreveable/mousetrap v1.1.0 // indirect25 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 // indirect27 github.com/ncruces/julianday v1.0.0 // indirect
22 github.com/pelletier/go-toml/v2 v2.2.4 // indirect28 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
23 github.com/rogpeppe/go-internal v1.12.0 // indirect29 github.com/rogpeppe/go-internal v1.12.0 // indirect
@@ -28,11 +34,12 @@ require (
28 github.com/spf13/pflag v1.0.10 // indirect34 github.com/spf13/pflag v1.0.10 // indirect
29 github.com/subosito/gotenv v1.6.0 // indirect35 github.com/subosito/gotenv v1.6.0 // indirect
30 github.com/tetratelabs/wazero v1.9.0 // indirect36 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 // indirect38 go.yaml.in/yaml/v3 v3.0.4 // indirect
32 go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect39 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 // indirect40 golang.org/x/text v0.29.0 // indirect
35 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect41 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 mapping345 // 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 handled420 // 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 application86 * 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 browser1 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 application28 // BrowserApp represents the main browser application
@@ -61,6 +65,7 @@ type BrowserApp struct {
61 65
62 // Run starts the browser application66 // 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 handlers156 // 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 nil160 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 loop214 // 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 = nil279 app.workspace = nil
212 }280 }
213 281
214- // Close database last282+ // 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 signals371 // 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 resources107 // 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 duplicates67 // by WindowShortcutHandler at the window level to prevent duplicates
68 68
69 // Page refresh shortcuts69 // 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 return72 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 return80 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 direction475 // Calculate new index based on direction
476 var newIndex int476 var newIndex int
477 switch direction {477 switch direction {
478- case "up":478+ case DirectionUp:
479 newIndex = currentIndex - 1479 newIndex = currentIndex - 1
480 if newIndex < 0 {480 if newIndex < 0 {
481 newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last481 newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
482 }482 }
483- case "down":483+ case DirectionDown:
484 newIndex = currentIndex + 1484 newIndex = currentIndex + 1
485 if newIndex >= len(stackNode.stackedPanes) {485 if newIndex >= len(stackNode.stackedPanes) {
486 newIndex = 0 // Wrap to first486 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 empty527+ 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 titleBox714 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 all721 // 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 persistence44 // 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 WebViews56+ CacheDir: webkitCache,
57+ AppearanceConfigJSON: app.buildAppearanceConfigJSON(),
58+ CreateWindow: true, // Default to creating a window for standalone WebViews
57 }59 }
58 60
59 return cfg, nil61 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 targeting71 // 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 changed186 // 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 = true191 globalCSSInitialized = true
190 globalCSSContent = workspaceCSS192 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 the107 // 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 false112 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 navigation116 // 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 true118 return true
119 }119 }
120 // Fall back to regular adjacency navigation120 // 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 direction169 // Calculate new index based on direction
170 var newIndex int170 var newIndex int
171 switch direction {171 switch direction {
172- case "up":172+ case DirectionUp:
173 newIndex = currentIndex - 1173 newIndex = currentIndex - 1
174 if newIndex < 0 {174 if newIndex < 0 {
175 newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last175 newIndex = len(stackNode.stackedPanes) - 1 // Wrap to last
176 }176 }
177- case "down":177+ case DirectionDown:
178 newIndex = currentIndex + 1178 newIndex = currentIndex + 1
179 if newIndex >= len(stackNode.stackedPanes) {179 if newIndex >= len(stackNode.stackedPanes) {
180 newIndex = 0 // Wrap to first180 newIndex = 0 // Wrap to first
@@ -235,29 +235,29 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
235 235
236 var score float64236 var score float64
237 switch direction {237 switch direction {
238- case "left":238+ case DirectionLeft:
239 if dx >= -focusEpsilon {239 if dx >= -focusEpsilon {
240 continue240 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 continue245 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 continue250 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 continue255 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.0288 cx := float64(refX) + float64(refWidth)/2.0
289 cy := float64(refY) + float64(refHeight)/2.0289 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 leaf297 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 leaf303 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 leaf309 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 leaf315 return leaf
@@ -338,22 +338,22 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6
338 dy := ty - cy338 dy := ty - cy
339 var score float64339 var score float64
340 switch direction {340 switch direction {
341- case "left":341+ case DirectionLeft:
342 if dx >= -focusEpsilon {342 if dx >= -focusEpsilon {
343 continue343 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 continue348 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 continue353 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 continue358 continue
359 }359 }
@@ -393,22 +393,22 @@ func (wm *WorkspaceManager) boundaryFallbackWithDepth(node *paneNode, direction
393 return node393 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 leaf398 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 leaf403 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 leaf408 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 leaf413 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.Orientation77 orientation gtk.Orientation
78 existingFirst bool78 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 parameter17 // 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 return124 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 string128 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 return140 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 direction304 // 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 insertPopupPane42 // 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 insertPopupPane70 // 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 pane427 // 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 pane444 // 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 positioning35 // 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, false39 return gtk.OrientationHorizontal, false
40- case "up":40+ case DirectionUp:
41 return gtk.OrientationVertical, false41 return gtk.OrientationVertical, false
42- case "down":42+ case DirectionDown:
43 return gtk.OrientationVertical, true43 return gtk.OrientationVertical, true
44 default:44 default:
45 return gtk.OrientationHorizontal, true45 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 = true55 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 = level110 zoomLevel = level
109 haveZoom = true111 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 return86 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 return202 return
211 }203 }
212 ctx := context.Background()204 ctx := context.Background()
@@ -233,12 +225,8 @@ func (h *Handler) handleQuery(msg Message) {
233 // History225 // 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 tag230 // 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]any232 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 = s238 url = s
251 }239 }
252- log.Printf("[DEBUG] History entry %d: url=%s", i, url)
253 if url == "" {240 if url == "" {
254 continue241 continue
255 }242 }
@@ -271,17 +258,11 @@ func (h *Handler) handleQuery(msg Message) {
271 }258 }
272 259
273 // Inject back to GUI260 // 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 function262 // 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 size98 // 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 copying139 // 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 count236 // 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 rules258 // 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 rules268 // 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 size282 // 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 err287 return err
270 }288 }
271 289
@@ -273,20 +291,28 @@ func (fc *FilterCache) Write(data *FilterData) error {
273 // TODO: Calculate and set checksum291 // 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 rename311 // 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 false409 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 uint64417 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 file425 // Remove deletes the cache file
396 func (fc *FilterCache) Remove() error {426 func (fc *FilterCache) Remove() error {
397- fc.Unmap() // Unmap first if mapped427+ 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 times288 // 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.go2 // 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_cache10 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.Controller22 ctrl *gomock.Controller
18 recorder *MockHistoryQuerierMockRecorder23 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 shortcuts175+ // 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 options178 // 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 examples189 // Add shortcuts with examples
193- for _, shortcut := range shortcuts {190+ for shortcutKey, shortcut := range shortcuts {
194- desc := shortcut.Description.String191+ 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 lines172 // Read all lines
169 var allLines []string173 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 messages348 // Read all lines and filter for console messages
341 var consoleLines []string349 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 history49+ 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 cache55 --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 files57 --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 caches70 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 flags78 // 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 dumber87 // 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 rootCmd159 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 WebKit35 // 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 options175 // DebugConfig holds debug and troubleshooting options
211 type DebugConfig struct {176 type DebugConfig struct {
212 // Enable WebKit internal debug logging177 // 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 variables328 // 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 defaults590+ // 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 browser592 // 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 defaults671 // 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 nil725 return nil
806 }726 }
807 727
@@ -907,17 +827,17 @@ func (m *Manager) validateAndConfigureCodecPreferences(config *Config) *Config {
907 // validateColorScheme validates and normalizes the ColorScheme setting827 // 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 needed831 // 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 default838 // 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 config2 package config
3 3
4-import (
5- "time"
6-)
7-
8 // Default configuration constants4 // Default configuration constants
9 const (5 const (
10- // Database defaults
11- defaultMaxIdleTimeMin = 5 // minutes
12- defaultQueryTimeoutSec = 30 // seconds
13-
14 // History defaults6 // History defaults
15 defaultMaxHistoryEntries = 10000 // entries7 defaultMaxHistoryEntries = 10000 // entries
16 defaultRetentionDays = 365 // 1 year8 defaultRetentionDays = 365 // 1 year
@@ -27,7 +19,7 @@ const (
27 defaultFontSize = 16 // points19 defaultFontSize = 16 // points
28 20
29 // Workspace defaults21 // Workspace defaults
30- defaultPaneActivationShortcut = "cmdorctrl+p"22+ defaultPaneActivationShortcut = "ctrl+p"
31 defaultPaneTimeoutMilliseconds = 300023 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 streams121 VideoBufferSizeMB: 16, // Larger buffer for AV1 streams
132 QueueBufferTimeSec: 10, // More buffering time for smooth playback122 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 support123 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 res124+ 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 readability143 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 panes170 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"), nil117 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 "", err126 return "", err
122 }127 }
123- return filepath.Join(stateDir, "history.db"), nil128+ 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 :exec13 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, err73 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 :exec112 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, nil133 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 :many136 const SearchHistory = `-- name: SearchHistory :many
172 SELECT id, url, title, visit_count, last_visited, created_at, favicon_url137 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 driver11 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver
13 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite12 _ "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 exists21 // 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 initialization40+ // 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 applied50+ 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, nil59+ 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 configuration61+ // 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 nil70+ 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) error13 SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error
14 DeleteZoomLevel(ctx context.Context, domain string) error14 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 operations18 // HistoryQuerier defines the interface for history-related database operations
@@ -25,16 +26,21 @@ type HistoryQuerier interface {
25 DeleteHistory(ctx context.Context, id int64) error26 DeleteHistory(ctx context.Context, id int64) error
26 }27 }
27 28
28-// ShortcutQuerier defines the interface for shortcut-related database operations29+// 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 interfaces39 // DatabaseQuerier combines all database operation interfaces
34 type DatabaseQuerier interface {40 type DatabaseQuerier interface {
35 ZoomQuerier41 ZoomQuerier
36 HistoryQuerier42 HistoryQuerier
37- ShortcutQuerier43+ CertificateQuerier
38 }44 }
39 45
40 // Ensure that *Queries implements DatabaseQuerier interface46 // 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.go2 // 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_db10 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.Controller23 ctrl *gomock.Controller
19 recorder *MockZoomQuerierMockRecorder24 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.Controller105 ctrl *gomock.Controller
85 recorder *MockHistoryQuerierMockRecorder106 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.Controller231 ctrl *gomock.Controller
152- recorder *MockShortcutQuerierMockRecorder232+ 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 *MockShortcutQuerier238+ 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 mock245 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.recorder250 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, ret1317 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.Controller342 ctrl *gomock.Controller
190 recorder *MockDatabaseQuerierMockRecorder343 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, ret1485 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 domain26 // 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 fallback28 // 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 updated31 // 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 :exec22 -- 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 :exec40 -- name: DeleteHistory :exec
41 DELETE FROM history41 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 stderr47 // 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 err56 return err
52 }57 }
53 58
@@ -62,8 +67,12 @@ func (c *OutputCapture) Start() error {
62 os.Stderr = stderrW67 os.Stderr = stderrW
63 68
64 // Also redirect file descriptors at syscall level for C code69 // 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 log77 // 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.originalStderr94 os.Stderr = c.originalStderr
86 95
87 // Restore file descriptors96 // 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 pipes104 // 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 = false126 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 logs68 // 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 code75 // 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 timestamp94 // 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 compression112 // 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 err130 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 err141 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 err157 return err
@@ -167,7 +183,9 @@ func (r *LogRotator) cleanup() {
167 183
168 // Remove files older than maxAge184 // 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 continue189 continue
172 }190 }
173 191
@@ -183,7 +201,9 @@ func (r *LogRotator) cleanup() {
183 201
184 // Remove oldest files202 // 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 []int186 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 time25 // 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 migrations88 // 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 migrations136 // 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 times212 // 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 table280 // 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 database358 // 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 migrations380 // 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 migrations414 // 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 tracking421 "schema_migrations", // Migration tracking
422 "history", // 001_initial.sql422 "history", // 001_initial.sql
423- "shortcuts", // 001_initial.sql
424 "zoom_levels", // 003_add_zoom_levels.sql423 "zoom_levels", // 003_add_zoom_levels.sql
425 "certificate_validations", // 005_add_certificate_validations.sql424 "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 exists428 // 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.Config45 config *config.Config
@@ -41,6 +48,16 @@ type BrowserService struct {
41 webView *webkit.WebView48 webView *webkit.WebView
42 initialURL string49 initialURL string
43 guiBundle string50 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 binding63 // 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 interface113 // 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, nil608 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, nil618+ // Fallback to exact URL record for backward compatibility (cache miss)
426- }619+ if key != url {
427- // No zoom setting found, return configured default620+ if zoom, ok := s.zoomCache.Get(url); ok {
428- return s.config.DefaultZoom, nil621+ return zoom, nil
429 }622 }
430- return s.config.DefaultZoom, err
431 }623 }
432 624
433- return zoomLevel, nil625+ // 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 access54 // 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, nil293 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 constants12 // 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 keys19 // 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 JavaScript99 // AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript
98 // This is critical for allowing JavaScript KeyboardService to receive keyboard events100 // This is critical for allowing JavaScript KeyboardService to receive keyboard events
99 // that would otherwise be consumed by GTK shortcuts101 // that would otherwise be consumed by GTK shortcuts
@@ -127,9 +129,9 @@ func (w *WebView) AttachKeyboardBridge() {
127 // Build modifier prefix using switch on modifier combination129 // 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 mode203 // 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.onPaneModeShortcut206 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.MainLoop11 mainLoop *glib.MainLoop
12- mainThreadID = runtime.LockOSThread // Store the main thread
13 isInitialized bool12 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 globalNetworkSession103 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 host83 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 hostname86 // 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 "", false103 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 "", false118 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 pixels45 // MinimumFontSize in pixels
46 MinimumFontSize int46 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 string56 DataDir string
50 57
@@ -63,13 +70,15 @@ type Config struct {
63 // GetDefaultConfig returns a Config with sensible defaults70 // 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 window80+ 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 configured197 // 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 nil207 return nil
201 }208 }
202 209
@@ -229,11 +236,6 @@ func (w *WebView) setupEventHandlers() {
229 // Favicon changed - connect to FaviconDatabase236 // 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 signal239 // 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 resources421 // 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 nil431 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 GC441+ // 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 nil445 return nil
437 }446 }
438 447
439 // IsDestroyed returns true if the WebView has been destroyed448 // 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.destroyed452 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, cleanup107 return service, cleanup
101 }108 }
102 109

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close