detached 1dfcc1b

feat(browser): implement global keyboard controls with script injection

- Add injectable TypeScript controls script for global DOM access
- Implement script injection mechanism in BrowserService
- Add navigation monitoring with periodic re-injection
- Support keyboard shortcuts (Alt+arrows, Cmd+Shift+C) on all pages
- Add mouse navigation button support (buttons 3&4)
- Handle CSP restrictions with DOM-ready detection
commit
1dfcc1b54f1ef24cb05d6d22861aeaf6fcc3764c
author
bamen · · 2025-09-09 17:14 UTC
parent
780aff1
signature
unsigned
50 files · +8975 -167
M .gitignore +4 -1
@@ -56,4 +56,7 @@ config.local.*
5656
5757 # Build artifacts and caches
5858 .cache/
59-*.prof⏎̸
59+*.prof
60+
61+# Node/TypeScript related
62+node_modules/
A CLAUDE.md +55 -0
@@ -0,0 +1,55 @@
1+# dumb-browser Development Guidelines
2+
3+Auto-generated from all feature plans. Last updated: 2025-09-09
4+
5+## Active Technologies
6+- Go 1.25.1 + Wails v3-alpha.28, Cobra, Viper, SQLite3 (002-browser-controls-ui)
7+
8+## Project Structure
9+```
10+src/
11+├── models/ # Data models and entities
12+├── services/ # Business logic services
13+├── cli/ # Command line interface
14+└── lib/ # Shared libraries
15+
16+tests/
17+├── contract/ # Contract tests
18+├── integration/ # Integration tests
19+└── unit/ # Unit tests
20+
21+frontend/
22+└── dist/ # Embedded WebView assets
23+```
24+
25+## Commands
26+```bash
27+# Testing and linting
28+make test
29+make lint
30+
31+# Build application
32+make build
33+
34+# Development
35+make dev
36+
37+# Generate code (SQLC)
38+make generate
39+
40+# Database operations
41+sqlite3 ~/.config/dumber/browser.db
42+```
43+
44+## Code Style
45+Go 1.25.1: Follow standard Go conventions with gofmt
46+- Use structured logging (slog)
47+- Proper error handling (no panics)
48+- SQLC for type-safe database queries
49+- Constitutional simplicity principles
50+
51+## Recent Changes
52+- 002-browser-controls-ui: Added keyboard/mouse controls, zoom persistence, clipboard integration
53+
54+<!-- MANUAL ADDITIONS START -->
55+<!-- MANUAL ADDITIONS END -->⏎̸
M Makefile +15 -9
@@ -1,10 +1,10 @@
11 # Makefile for dumber
22
3-.PHONY: build test lint clean install-tools dev generate help
3+.PHONY: build build-frontend test lint clean install-tools dev generate help
44
55 # Variables
66 BINARY_NAME=dumber
7-MAIN_PATH=./cmd/dumber
7+MAIN_PATH=.
88 DIST_DIR=dist
99
1010 # Version information from git
@@ -21,15 +21,20 @@ help: ## Show this help message
2121 @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
2222
2323 # Build targets
24-build: ## Build the binary
25- @echo "Building $(BINARY_NAME) $(VERSION)..."
24+build: build-frontend ## Build the Wails app (builds frontend, then wails3 build)
25+ @echo "Building $(BINARY_NAME) $(VERSION) with Wails..."
2626 @mkdir -p $(DIST_DIR)
27- CGO_ENABLED=0 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
27+ CGO_ENABLED=1 wails3 build
2828
29-build-wails: ## Build with CGO enabled for Wails (when needed)
30- @echo "Building $(BINARY_NAME) $(VERSION) with CGO enabled..."
29+build-frontend: ## Build TypeScript frontend
30+ @echo "Building TypeScript frontend..."
31+ @cd frontend && npm ci --silent && npm run build
32+ @echo "Frontend build complete"
33+
34+build-static: ## Build static binary (CGO disabled, CLI-only functionality)
35+ @echo "Building static $(BINARY_NAME) $(VERSION) (CLI-only)..."
3136 @mkdir -p $(DIST_DIR)
32- CGO_ENABLED=1 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
37+ CGO_ENABLED=0 go build $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME)-static $(MAIN_PATH)
3338
3439 # Development targets
3540 dev: ## Run in development mode
@@ -72,6 +77,7 @@ clean: ## Clean build artifacts
7277 @echo "Cleaning build artifacts..."
7378 rm -rf $(DIST_DIR)
7479 rm -f $(BINARY_NAME) # Remove any old binaries in root
80+ rm -rf frontend/dist frontend/node_modules
7581 go clean -cache
7682 go clean -testcache
7783
@@ -97,4 +103,4 @@ check: ## Check that all tools and dependencies are working
97103 @$(DIST_DIR)/$(BINARY_NAME) version
98104 @echo "\nRunning tests..."
99105 @make test
100- @echo "\n✅ All checks passed! Project is ready for development."⏎̸
106+ @echo "\n✅ All checks passed! Project is ready for development."
D dumb-browser +0 -0 binary file: no text diff

binary file: no text diff

A frontend/bindings/database/sql/index.ts +8 -0
@@ -0,0 +1,8 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+export {
5+ NullInt64,
6+ NullString,
7+ NullTime
8+} from "./models.js";
A frontend/bindings/database/sql/models.ts +121 -0
@@ -0,0 +1,121 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
5+// @ts-ignore: Unused imports
6+import { Create as $Create } from "@wailsio/runtime";
7+
8+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
9+// @ts-ignore: Unused imports
10+import * as time$0 from "../../time/models.js";
11+
12+/**
13+ * NullInt64 represents an int64 that may be null.
14+ * NullInt64 implements the [Scanner] interface so
15+ * it can be used as a scan destination, similar to [NullString].
16+ */
17+export class NullInt64 {
18+ "Int64": number;
19+
20+ /**
21+ * Valid is true if Int64 is not NULL
22+ */
23+ "Valid": boolean;
24+
25+ /** Creates a new NullInt64 instance. */
26+ constructor($$source: Partial<NullInt64> = {}) {
27+ if (!("Int64" in $$source)) {
28+ this["Int64"] = 0;
29+ }
30+ if (!("Valid" in $$source)) {
31+ this["Valid"] = false;
32+ }
33+
34+ Object.assign(this, $$source);
35+ }
36+
37+ /**
38+ * Creates a new NullInt64 instance from a string or object.
39+ */
40+ static createFrom($$source: any = {}): NullInt64 {
41+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
42+ return new NullInt64($$parsedSource as Partial<NullInt64>);
43+ }
44+}
45+
46+/**
47+ * NullString represents a string that may be null.
48+ * NullString implements the [Scanner] interface so
49+ * it can be used as a scan destination:
50+ *
51+ * var s NullString
52+ * err := db.QueryRow("SELECT name FROM foo WHERE id=?", id).Scan(&s)
53+ * ...
54+ * if s.Valid {
55+ * // use s.String
56+ * } else {
57+ * // NULL value
58+ * }
59+ */
60+export class NullString {
61+ "String": string;
62+
63+ /**
64+ * Valid is true if String is not NULL
65+ */
66+ "Valid": boolean;
67+
68+ /** Creates a new NullString instance. */
69+ constructor($$source: Partial<NullString> = {}) {
70+ if (!("String" in $$source)) {
71+ this["String"] = "";
72+ }
73+ if (!("Valid" in $$source)) {
74+ this["Valid"] = false;
75+ }
76+
77+ Object.assign(this, $$source);
78+ }
79+
80+ /**
81+ * Creates a new NullString instance from a string or object.
82+ */
83+ static createFrom($$source: any = {}): NullString {
84+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
85+ return new NullString($$parsedSource as Partial<NullString>);
86+ }
87+}
88+
89+/**
90+ * NullTime represents a [time.Time] that may be null.
91+ * NullTime implements the [Scanner] interface so
92+ * it can be used as a scan destination, similar to [NullString].
93+ */
94+export class NullTime {
95+ "Time": time$0.Time;
96+
97+ /**
98+ * Valid is true if Time is not NULL
99+ */
100+ "Valid": boolean;
101+
102+ /** Creates a new NullTime instance. */
103+ constructor($$source: Partial<NullTime> = {}) {
104+ if (!("Time" in $$source)) {
105+ this["Time"] = null;
106+ }
107+ if (!("Valid" in $$source)) {
108+ this["Valid"] = false;
109+ }
110+
111+ Object.assign(this, $$source);
112+ }
113+
114+ /**
115+ * Creates a new NullTime instance from a string or object.
116+ */
117+ static createFrom($$source: any = {}): NullTime {
118+ let $$parsedSource = typeof $$source === 'string' ? JSON.parse($$source) : $$source;
119+ return new NullTime($$parsedSource as Partial<NullTime>);
120+ }
121+}
A frontend/bindings/time/index.ts +10 -0
@@ -0,0 +1,10 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+export {
5+ Duration
6+} from "./models.js";
7+
8+export type {
9+ Time
10+} from "./models.js";
A frontend/bindings/time/models.ts +87 -0
@@ -0,0 +1,87 @@
1+// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
2+// This file is automatically generated. DO NOT EDIT
3+
4+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
5+// @ts-ignore: Unused imports
6+import { Create as $Create } from "@wailsio/runtime";
7+
8+/**
9+ * A Duration represents the elapsed time between two instants
10+ * as an int64 nanosecond count. The representation limits the
11+ * largest representable duration to approximately 290 years.
12+ */
13+export enum Duration {
14+ /**
15+ * The Go zero value for the underlying type of the enum.
16+ */
17+ $zero = 0,
18+
19+ minDuration = -9223372036854775808,
20+ maxDuration = 9223372036854775807,
21+
22+ /**
23+ * Common durations. There is no definition for units of Day or larger
24+ * to avoid confusion across daylight savings time zone transitions.
25+ *
26+ * To count the number of units in a [Duration], divide:
27+ *
28+ * second := time.Second
29+ * fmt.Print(int64(second/time.Millisecond)) // prints 1000
30+ *
31+ * To convert an integer number of units to a Duration, multiply:
32+ *
33+ * seconds := 10
34+ * fmt.Print(time.Duration(seconds)*time.Second) // prints 10s
35+ */
36+ Nanosecond = 1,
37+ Microsecond = 1000,
38+ Millisecond = 1000000,
39+ Second = 1000000000,
40+ Minute = 60000000000,
41+ Hour = 3600000000000,
42+};
43+
44+/**
45+ * A Time represents an instant in time with nanosecond precision.
46+ *
47+ * Programs using times should typically store and pass them as values,
48+ * not pointers. That is, time variables and struct fields should be of
49+ * type [time.Time], not *time.Time.
50+ *
51+ * A Time value can be used by multiple goroutines simultaneously except
52+ * that the methods [Time.GobDecode], [Time.UnmarshalBinary], [Time.UnmarshalJSON] and
53+ * [Time.UnmarshalText] are not concurrency-safe.
54+ *
55+ * Time instants can be compared using the [Time.Before], [Time.After], and [Time.Equal] methods.
56+ * The [Time.Sub] method subtracts two instants, producing a [Duration].
57+ * The [Time.Add] method adds a Time and a Duration, producing a Time.
58+ *
59+ * The zero value of type Time is January 1, year 1, 00:00:00.000000000 UTC.
60+ * As this time is unlikely to come up in practice, the [Time.IsZero] method gives
61+ * a simple way of detecting a time that has not been initialized explicitly.
62+ *
63+ * Each time has an associated [Location]. The methods [Time.Local], [Time.UTC], and Time.In return a
64+ * Time with a specific Location. Changing the Location of a Time value with
65+ * these methods does not change the actual instant it represents, only the time
66+ * zone in which to interpret it.
67+ *
68+ * Representations of a Time value saved by the [Time.GobEncode], [Time.MarshalBinary], [Time.AppendBinary],
69+ * [Time.MarshalJSON], [Time.MarshalText] and [Time.AppendText] methods store the [Time.Location]'s offset,
70+ * but not the location name. They therefore lose information about Daylight Saving Time.
71+ *
72+ * In addition to the required “wall clock” reading, a Time may contain an optional
73+ * reading of the current process's monotonic clock, to provide additional precision
74+ * for comparison or subtraction.
75+ * See the “Monotonic Clocks” section in the package documentation for details.
76+ *
77+ * Note that the Go == operator compares not just the time instant but also the
78+ * Location and the monotonic clock reading. Therefore, Time values should not
79+ * be used as map or database keys without first guaranteeing that the
80+ * identical Location has been set for all values, which can be achieved
81+ * through use of the UTC or Local method, and that the monotonic clock reading
82+ * has been stripped by setting t = t.Round(0). In general, prefer t.Equal(u)
83+ * to t == u, since t.Equal uses the most accurate comparison available and
84+ * correctly handles the case when only one of its arguments has a monotonic
85+ * clock reading.
86+ */
87+export type Time = any;
A frontend/eslint.config.js +35 -0
@@ -0,0 +1,35 @@
1+import js from '@eslint/js';
2+import tseslint from 'typescript-eslint';
3+
4+export default tseslint.config(
5+ js.configs.recommended,
6+ ...tseslint.configs.recommended,
7+ {
8+ files: ['**/*.{js,mjs,cjs,ts}'],
9+ languageOptions: {
10+ ecmaVersion: 2024,
11+ sourceType: 'module',
12+ },
13+ rules: {
14+ // Modern TypeScript best practices
15+ '@typescript-eslint/no-unused-vars': ['error', {
16+ 'argsIgnorePattern': '^_',
17+ 'varsIgnorePattern': '^_'
18+ }],
19+ '@typescript-eslint/no-explicit-any': 'warn',
20+ '@typescript-eslint/no-empty-function': 'off',
21+ '@typescript-eslint/consistent-type-imports': 'error',
22+ '@typescript-eslint/no-import-type-side-effects': 'error',
23+
24+ // Modern JavaScript/TypeScript patterns
25+ 'prefer-const': 'error',
26+ 'no-var': 'error',
27+ 'object-shorthand': 'error',
28+ 'prefer-template': 'error',
29+ },
30+ },
31+ {
32+ files: ['**/*.js', '**/*.mjs'],
33+ ...tseslint.configs.disableTypeChecked,
34+ }
35+);⏎̸
A frontend/package-lock.json +3840 -0 generated file: collapsed
A frontend/package.json +25 -0
@@ -0,0 +1,25 @@
1+{
2+ "name": "@dumber/frontend",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "scripts": {
7+ "build": "tsup && node dist/build-html.js",
8+ "dev": "tsup --watch",
9+ "type-check": "tsc --noEmit",
10+ "lint": "eslint src --fix",
11+ "generate:types": "node ../scripts/generate-types.js"
12+ },
13+ "devDependencies": {
14+ "@types/node": "^20.0.0",
15+ "@typescript-eslint/eslint-plugin": "^7.0.0",
16+ "@typescript-eslint/parser": "^7.0.0",
17+ "eslint": "^8.57.0",
18+ "tsup": "^8.0.0",
19+ "typescript": "^5.3.0",
20+ "typescript-eslint": "^8.43.0"
21+ },
22+ "dependencies": {
23+ "@wailsio/runtime": "^3.0.0-alpha.66"
24+ }
25+}
A frontend/src/build-html.ts +21 -0
@@ -0,0 +1,21 @@
1+import { writeFileSync } from 'fs';
2+import { join } from 'path';
3+import { HTMLBuilder } from './utils/html-builder.js';
4+
5+// Generate the HTML file
6+function buildHTML() {
7+ try {
8+ const html = HTMLBuilder.createBaseHTML();
9+ const outputPath = join(process.cwd(), 'dist', 'index.html');
10+ writeFileSync(outputPath, html);
11+ console.log('✓ Generated index.html');
12+ } catch (error) {
13+ console.error('✗ Failed to generate HTML:', error);
14+ process.exit(1);
15+ }
16+}
17+
18+// Only run when called directly (not imported)
19+if (import.meta.url === `file://${process.argv[1]}`) {
20+ buildHTML();
21+}⏎̸
A frontend/src/injected-controls.ts +124 -0
@@ -0,0 +1,124 @@
1+/**
2+ * Injectable Controls Script
3+ *
4+ * This script is injected into every page loaded in the browser to provide
5+ * global keyboard and mouse controls that work on all domains.
6+ *
7+ * Only includes essential controls that can work cross-origin:
8+ * - Navigation (back/forward)
9+ * - URL copying
10+ * - Mouse navigation buttons
11+ *
12+ * Zoom controls are handled natively by Wails in main.go
13+ */
14+
15+(function() {
16+ 'use strict';
17+
18+ // Prevent multiple injections with a unique timestamp
19+ const injectionId = '__dumberControlsInjected_' + Date.now();
20+ if ((window as any).__dumberControlsInjected) {
21+ console.log('🔄 Dumber Browser controls already injected, skipping');
22+ return;
23+ }
24+ (window as any).__dumberControlsInjected = injectionId;
25+
26+ console.log('🚀 Dumber Browser controls injected on:', window.location.href);
27+
28+ // Keyboard event handler for global controls
29+ function handleKeyboardEvent(event: KeyboardEvent): void {
30+ const { ctrlKey, metaKey, shiftKey, altKey, key } = event;
31+ const isCmd = ctrlKey || metaKey;
32+
33+ try {
34+ // Copy URL (Cmd+Shift+C)
35+ if (isCmd && shiftKey && key === 'C') {
36+ event.preventDefault();
37+ event.stopPropagation();
38+
39+ // Copy current URL to clipboard
40+ if (navigator.clipboard && navigator.clipboard.writeText) {
41+ navigator.clipboard.writeText(window.location.href)
42+ .then(() => console.log('🔗 URL copied:', window.location.href))
43+ .catch((err) => console.error('❌ Failed to copy URL:', err));
44+ } else {
45+ console.warn('⚠️ Clipboard API not available');
46+ }
47+ return;
48+ }
49+
50+ // Navigation: Alt + Left Arrow (Back)
51+ if (altKey && key === 'ArrowLeft') {
52+ event.preventDefault();
53+ event.stopPropagation();
54+
55+ if (window.history.length > 1) {
56+ console.log('⬅️ Navigating back');
57+ window.history.back();
58+ }
59+ return;
60+ }
61+
62+ // Navigation: Alt + Right Arrow (Forward)
63+ if (altKey && key === 'ArrowRight') {
64+ event.preventDefault();
65+ event.stopPropagation();
66+
67+ console.log('➡️ Navigating forward');
68+ window.history.forward();
69+ return;
70+ }
71+
72+ } catch (error) {
73+ console.error('❌ Error in keyboard handler:', error);
74+ }
75+ }
76+
77+ // Mouse event handler for navigation buttons
78+ function handleMouseEvent(event: MouseEvent): void {
79+ try {
80+ // Mouse button 3 (back button)
81+ if (event.button === 3) {
82+ event.preventDefault();
83+ event.stopPropagation();
84+
85+ if (window.history.length > 1) {
86+ console.log('⬅️ Mouse back button pressed');
87+ window.history.back();
88+ }
89+ return;
90+ }
91+
92+ // Mouse button 4 (forward button)
93+ if (event.button === 4) {
94+ event.preventDefault();
95+ event.stopPropagation();
96+
97+ console.log('➡️ Mouse forward button pressed');
98+ window.history.forward();
99+ return;
100+ }
101+
102+ } catch (error) {
103+ console.error('❌ Error in mouse handler:', error);
104+ }
105+ }
106+
107+ // Add event listeners with high priority (capture phase)
108+ try {
109+ document.addEventListener('keydown', handleKeyboardEvent, true);
110+ document.addEventListener('mousedown', handleMouseEvent, true);
111+
112+ console.log('✅ Dumber Browser global controls active');
113+
114+ // Cleanup function for when page is unloaded
115+ window.addEventListener('beforeunload', () => {
116+ document.removeEventListener('keydown', handleKeyboardEvent, true);
117+ document.removeEventListener('mousedown', handleMouseEvent, true);
118+ console.log('🧹 Dumber Browser controls cleaned up');
119+ });
120+
121+ } catch (error) {
122+ console.error('❌ Failed to initialize Dumber Browser controls:', error);
123+ }
124+})();⏎̸
A frontend/src/main.ts +73 -0
@@ -0,0 +1,73 @@
1+import { AppService } from './services/app.js';
2+import { BrowserControlsService } from './services/browser-controls.js';
3+import { DOMRenderer } from './utils/dom.js';
4+
5+class DumberBrowserApp {
6+ private appService: AppService;
7+ private browserControls: BrowserControlsService;
8+ private domRenderer: DOMRenderer;
9+
10+ constructor() {
11+ this.appService = new AppService();
12+ this.browserControls = new BrowserControlsService();
13+ this.domRenderer = new DOMRenderer();
14+ }
15+
16+ async initialize(): Promise<void> {
17+ try {
18+ // Show loading states
19+ this.domRenderer.showLoading('history');
20+ this.domRenderer.showLoading('shortcuts');
21+
22+ // Initialize services
23+ await this.appService.initialize();
24+
25+ // Initialize browser controls
26+ await this.browserControls.initialize();
27+
28+ // Render the UI
29+ this.render();
30+
31+ console.log('✓ Dumber Browser initialized successfully');
32+ } catch (error) {
33+ console.error('Failed to initialize Dumber Browser:', error);
34+ this.handleInitializationError();
35+ }
36+ }
37+
38+ private render(): void {
39+ try {
40+ // Render history
41+ const history = this.appService.getHistory();
42+ this.domRenderer.displayHistory(history);
43+
44+ // Render shortcuts
45+ const shortcuts = this.appService.getShortcuts();
46+ this.domRenderer.displayShortcuts(shortcuts);
47+ } catch (error) {
48+ console.error('Failed to render UI:', error);
49+ this.domRenderer.showError('history', 'Failed to load history');
50+ this.domRenderer.showError('shortcuts', 'Failed to load shortcuts');
51+ }
52+ }
53+
54+ private handleInitializationError(): void {
55+ this.domRenderer.showError('history', 'Failed to initialize application');
56+ this.domRenderer.showError('shortcuts', 'Failed to initialize application');
57+ }
58+}
59+
60+// Initialize the application when the DOM is ready
61+function initializeApp(): void {
62+ const app = new DumberBrowserApp();
63+ app.initialize().catch(error => {
64+ console.error('Critical error during app initialization:', error);
65+ });
66+}
67+
68+// Wait for DOM to be ready
69+if (document.readyState === 'loading') {
70+ document.addEventListener('DOMContentLoaded', initializeApp);
71+} else {
72+ initializeApp();
73+}⏎̸
A frontend/src/services/app.ts +90 -0
@@ -0,0 +1,90 @@
1+import { BrowserService, ConfigService, HistoryEntry } from '../../bindings/github.com/bnema/dumber/services/index';
2+
3+export class AppService {
4+ private history: HistoryEntry[] = [];
5+ private shortcuts: Record<string, any> = {};
6+
7+ constructor() {
8+ // Using Wails v3 service bindings directly
9+ }
10+
11+ async initialize(): Promise<void> {
12+ try {
13+ await Promise.all([
14+ this.loadHistory(),
15+ this.loadShortcuts()
16+ ]);
17+ } catch (error) {
18+ console.error('Failed to initialize app:', error);
19+ }
20+ }
21+
22+ async loadHistory(): Promise<void> {
23+ try {
24+ const recentHistory = await BrowserService.GetRecentHistory(50);
25+ this.history = recentHistory;
26+ } catch (error) {
27+ console.error('Failed to load history:', error);
28+ // Mock history for development/fallback
29+ this.history = [
30+ {
31+ id: 1,
32+ url: 'https://github.com/wailsapp/wails',
33+ title: 'Wails Framework',
34+ visit_count: 3,
35+ last_visited: new Date().toISOString(),
36+ created_at: new Date().toISOString()
37+ },
38+ {
39+ id: 2,
40+ url: 'https://go.dev',
41+ title: 'The Go Programming Language',
42+ visit_count: 2,
43+ last_visited: new Date().toISOString(),
44+ created_at: new Date().toISOString()
45+ },
46+ {
47+ id: 3,
48+ url: 'https://developer.mozilla.org',
49+ title: 'MDN Web Docs',
50+ visit_count: 1,
51+ last_visited: new Date().toISOString(),
52+ created_at: new Date().toISOString()
53+ }
54+ ];
55+ }
56+ }
57+
58+ async loadShortcuts(): Promise<void> {
59+ try {
60+ const shortcutsData = await BrowserService.GetSearchShortcuts();
61+ this.shortcuts = shortcutsData;
62+ } catch (error) {
63+ console.error('Failed to load shortcuts:', error);
64+ // Mock shortcuts for development/fallback
65+ this.shortcuts = {
66+ 'g': { description: 'Google Search', url: 'https://google.com/search?q={query}' },
67+ 'gh': { description: 'GitHub Search', url: 'https://github.com/search?q={query}' },
68+ 'so': { description: 'Stack Overflow', url: 'https://stackoverflow.com/search?q={query}' },
69+ 'w': { description: 'Wikipedia', url: 'https://en.wikipedia.org/wiki/Special:Search?search={query}' }
70+ };
71+ }
72+ }
73+
74+ getHistory(): HistoryEntry[] {
75+ return this.history;
76+ }
77+
78+ getShortcuts(): Record<string, any> {
79+ return this.shortcuts;
80+ }
81+
82+ async copyToClipboard(text: string): Promise<void> {
83+ try {
84+ await navigator.clipboard.writeText(text);
85+ console.log('Copied to clipboard:', text);
86+ } catch (err) {
87+ console.error('Failed to copy:', err);
88+ }
89+ }
90+}⏎̸
A frontend/src/services/browser-controls.ts +312 -0
@@ -0,0 +1,312 @@
1+import { BrowserService } from '../../bindings/github.com/bnema/dumber/services/index';
2+import { Window } from '@wailsio/runtime';
3+import { NotificationService } from './notifications.js';
4+import { DebugConsoleService } from './debug-console.js';
5+
6+export class BrowserControlsService {
7+ private currentURL = '';
8+ private currentZoom = 1.0;
9+ private notifications = new NotificationService();
10+ private debugConsole = new DebugConsoleService();
11+
12+ constructor() {
13+ // Debug logging with enhanced details
14+ this.debugConsole.addLog('info', 'frontend', 'init', 'BrowserControlsService initializing...');
15+ this.debugConsole.addLog('info', 'frontend', 'init', 'Using Wails v3 service bindings');
16+
17+ this.initializeEventListeners();
18+ this.debugConsole.addLog('info', 'frontend', 'init', 'BrowserControlsService initialized ✓');
19+ }
20+
21+
22+ private initializeEventListeners(): void {
23+ // Keyboard event handler for browser controls
24+ document.addEventListener('keydown', this.handleKeyboardEvent.bind(this));
25+
26+ // Mouse button handler for navigation
27+ document.addEventListener('mousedown', this.handleMouseEvent.bind(this));
28+
29+ // URL change listener (for SPA navigation)
30+ window.addEventListener('popstate', this.handleURLChange.bind(this));
31+ }
32+
33+ private async handleKeyboardEvent(event: KeyboardEvent): Promise<void> {
34+ // Skip F12 (debug console toggle)
35+ if (event.key === 'F12') {
36+ return;
37+ }
38+
39+ // Log all keyboard events to debug console
40+ this.debugConsole.logKeyboardEvent(event);
41+
42+ const { ctrlKey, metaKey, shiftKey, altKey, key } = event;
43+ const isCmd = ctrlKey || metaKey;
44+
45+ // Zoom controls
46+ if (isCmd && (key === '+' || key === '=')) {
47+ this.debugConsole.addLog('info', 'frontend', 'action', 'Zoom In triggered');
48+ event.preventDefault();
49+ await this.zoomIn();
50+ return;
51+ }
52+
53+ if (isCmd && key === '-') {
54+ this.debugConsole.addLog('info', 'frontend', 'action', 'Zoom Out triggered');
55+ event.preventDefault();
56+ await this.zoomOut();
57+ return;
58+ }
59+
60+ if (isCmd && key === '0') {
61+ this.debugConsole.addLog('info', 'frontend', 'action', 'Zoom Reset triggered');
62+ event.preventDefault();
63+ await this.resetZoom();
64+ return;
65+ }
66+
67+ // Copy URL
68+ if (isCmd && shiftKey && key === 'C') {
69+ this.debugConsole.addLog('info', 'frontend', 'action', 'Copy URL triggered');
70+ event.preventDefault();
71+ await this.copyCurrentURL();
72+ return;
73+ }
74+
75+ // Navigation
76+ if (altKey && key === 'ArrowLeft') {
77+ this.debugConsole.addLog('info', 'frontend', 'action', 'Go Back triggered');
78+ event.preventDefault();
79+ await this.goBack();
80+ return;
81+ }
82+
83+ if (altKey && key === 'ArrowRight') {
84+ this.debugConsole.addLog('info', 'frontend', 'action', 'Go Forward triggered');
85+ event.preventDefault();
86+ await this.goForward();
87+ return;
88+ }
89+ }
90+
91+ private async handleMouseEvent(event: MouseEvent): Promise<void> {
92+ // Log all mouse events to debug console
93+ this.debugConsole.logMouseEvent(event);
94+
95+ // Mouse button 3 (back button)
96+ if (event.button === 3) {
97+ this.debugConsole.addLog('info', 'frontend', 'action', 'Mouse Back button triggered');
98+ event.preventDefault();
99+ await this.goBack();
100+ return;
101+ }
102+
103+ // Mouse button 4 (forward button)
104+ if (event.button === 4) {
105+ this.debugConsole.addLog('info', 'frontend', 'action', 'Mouse Forward button triggered');
106+ event.preventDefault();
107+ await this.goForward();
108+ return;
109+ }
110+ }
111+
112+ private handleURLChange(): void {
113+ this.setCurrentURL(window.location.href);
114+ this.loadSavedZoom();
115+ }
116+
117+ async zoomIn(): Promise<void> {
118+ const url = this.getCurrentURL();
119+ this.debugConsole.addLog('info', 'frontend', 'service', `Calling ZoomIn with URL: ${url}`);
120+
121+ try {
122+ const newZoom = await BrowserService.ZoomIn(url);
123+ this.debugConsole.logServiceCall('BrowserService', 'ZoomIn', [url], newZoom);
124+
125+ await this.applyZoom(newZoom);
126+ this.notifications.showZoom(`Zoom: ${Math.round(newZoom * 100)}%`);
127+
128+ this.debugConsole.addLog('info', 'frontend', 'zoom', `Zoom level changed to ${Math.round(newZoom * 100)}%`);
129+ } catch (error) {
130+ this.debugConsole.logServiceCall('BrowserService', 'ZoomIn', [url], null, error as Error);
131+ console.error('Failed to zoom in:', error);
132+ }
133+ }
134+
135+ async zoomOut(): Promise<void> {
136+ const url = this.getCurrentURL();
137+ this.debugConsole.addLog('info', 'frontend', 'service', `Calling ZoomOut with URL: ${url}`);
138+
139+ try {
140+ const newZoom = await BrowserService.ZoomOut(url);
141+ this.debugConsole.logServiceCall('BrowserService', 'ZoomOut', [url], newZoom);
142+
143+ await this.applyZoom(newZoom);
144+ this.notifications.showZoom(`Zoom: ${Math.round(newZoom * 100)}%`);
145+
146+ this.debugConsole.addLog('info', 'frontend', 'zoom', `Zoom level changed to ${Math.round(newZoom * 100)}%`);
147+ } catch (error) {
148+ this.debugConsole.logServiceCall('BrowserService', 'ZoomOut', [url], null, error as Error);
149+ console.error('Failed to zoom out:', error);
150+ }
151+ }
152+
153+ async resetZoom(): Promise<void> {
154+ const url = this.getCurrentURL();
155+ this.debugConsole.addLog('info', 'frontend', 'service', `Calling ResetZoom with URL: ${url}`);
156+
157+ try {
158+ const newZoom = await BrowserService.ResetZoom(url);
159+ this.debugConsole.logServiceCall('BrowserService', 'ResetZoom', [url], newZoom);
160+
161+ await this.applyZoom(newZoom);
162+ this.notifications.showZoom(`Zoom: ${Math.round(newZoom * 100)}%`);
163+
164+ this.debugConsole.addLog('info', 'frontend', 'zoom', `Zoom reset to ${Math.round(newZoom * 100)}%`);
165+ } catch (error) {
166+ this.debugConsole.logServiceCall('BrowserService', 'ResetZoom', [url], null, error as Error);
167+ console.error('Failed to reset zoom:', error);
168+ }
169+ }
170+
171+ private async applyZoom(zoomLevel: number): Promise<void> {
172+ this.currentZoom = zoomLevel;
173+ // Reset CSS zoom by default so we don't double-scale when native works
174+ try { document.body.style.zoom = ''; } catch {}
175+ try {
176+ if (zoomLevel >= 1.0) {
177+ await Window.SetZoom(zoomLevel);
178+ return;
179+ }
180+ // Try native first for <1.0; if platform clamps (e.g., Linux/WebKitGTK),
181+ // detect mismatch and fallback to CSS-based zoom for "unzoom" behavior.
182+ await Window.SetZoom(zoomLevel);
183+ const actual = await Window.GetZoom();
184+ if (actual >= 1.0 && zoomLevel < 1.0 && Math.abs(actual - zoomLevel) > 0.01) {
185+ await Window.SetZoom(1.0);
186+ document.body.style.zoom = zoomLevel.toString();
187+ }
188+ } catch (e) {
189+ console.warn('Failed to apply native zoom, falling back to CSS zoom', e);
190+ document.body.style.zoom = zoomLevel.toString();
191+ }
192+ }
193+
194+ async copyCurrentURL(): Promise<void> {
195+ try {
196+ const url = this.getCurrentURL();
197+ await navigator.clipboard.writeText(url);
198+
199+ // URL copied successfully - no backend notification needed
200+
201+ this.notifications.show('URL copied to clipboard!', 'success');
202+ } catch (error) {
203+ console.error('Failed to copy URL:', error);
204+ this.notifications.show('Failed to copy URL', 'error');
205+ }
206+ }
207+
208+ async goBack(): Promise<void> {
209+ this.debugConsole.addLog('info', 'frontend', 'service', 'Calling GoBack');
210+
211+ try {
212+ await BrowserService.GoBack();
213+ this.debugConsole.logServiceCall('BrowserService', 'GoBack', []);
214+ history.back();
215+ this.debugConsole.addLog('info', 'frontend', 'navigation', 'Browser navigated back');
216+ } catch (error) {
217+ this.debugConsole.logServiceCall('BrowserService', 'GoBack', [], null, error as Error);
218+ console.error('Failed to go back:', error);
219+ }
220+ }
221+
222+ async goForward(): Promise<void> {
223+ this.debugConsole.addLog('info', 'frontend', 'service', 'Calling GoForward');
224+
225+ try {
226+ await BrowserService.GoForward();
227+ this.debugConsole.logServiceCall('BrowserService', 'GoForward', []);
228+ history.forward();
229+ this.debugConsole.addLog('info', 'frontend', 'navigation', 'Browser navigated forward');
230+ } catch (error) {
231+ this.debugConsole.logServiceCall('BrowserService', 'GoForward', [], null, error as Error);
232+ console.error('Failed to go forward:', error);
233+ }
234+ }
235+
236+ getCurrentURL(): string {
237+ return this.currentURL || window.location.href;
238+ }
239+
240+ setCurrentURL(url: string): void {
241+ this.currentURL = url;
242+ this.updateWindowTitle(url);
243+ }
244+
245+ private async updateWindowTitle(url: string): Promise<void> {
246+ try {
247+ // Extract domain or path for title
248+ let title = 'Dumber Browser';
249+ if (url && url !== '/') {
250+ const urlObj = new URL(url);
251+ title = `Dumber - ${urlObj.hostname}`;
252+ }
253+
254+ document.title = title;
255+
256+ // Notify backend service
257+ try {
258+ await BrowserService.UpdatePageTitle(url, title);
259+ } catch (error) {
260+ console.warn('Failed to update page title on backend:', error);
261+ }
262+ } catch (error) {
263+ console.error('Failed to update title:', error);
264+ }
265+ }
266+
267+ async loadSavedZoom(): Promise<void> {
268+ try {
269+ const savedZoom = await BrowserService.GetZoomLevel(this.getCurrentURL());
270+ if (savedZoom && savedZoom !== 1.0) {
271+ await this.applyZoom(savedZoom);
272+ }
273+ } catch (error) {
274+ console.error('Failed to load saved zoom:', error);
275+ }
276+ }
277+
278+ async initialize(): Promise<void> {
279+ // Check if there's an initial URL from backend (direct navigation)
280+ try {
281+ const initialURL = await BrowserService.GetInitialURL();
282+ if (initialURL && initialURL !== '') {
283+ this.debugConsole.addLog('info', 'frontend', 'init', `Found initial URL from backend: ${initialURL}`);
284+ this.debugConsole.addLog('info', 'frontend', 'navigation', `Auto-navigating to: ${initialURL}`);
285+
286+ // Set the URL and navigate to it
287+ this.setCurrentURL(initialURL);
288+ // Apply saved zoom natively before navigation so it persists into the next page
289+ try {
290+ const savedZoom = await BrowserService.GetZoomLevel(initialURL);
291+ if (savedZoom && savedZoom > 0) {
292+ this.applyZoom(savedZoom);
293+ }
294+ } catch {}
295+
296+ // Navigate to the URL in the browser
297+ window.location.href = initialURL;
298+ return; // Don't load zoom for homepage, we're navigating away
299+ } else {
300+ // Set initial URL from browser location (homepage)
301+ this.setCurrentURL(window.location.href);
302+ }
303+ } catch (error) {
304+ console.error('Failed to get initial URL from backend:', error);
305+ // Fallback to browser location
306+ this.setCurrentURL(window.location.href);
307+ }
308+
309+ // Load saved zoom level (only if we're staying on homepage)
310+ await this.loadSavedZoom();
311+ }
312+}
A frontend/src/services/debug-console.ts +300 -0
@@ -0,0 +1,300 @@
1+export interface DebugLogEntry {
2+ timestamp: string;
3+ level: 'info' | 'warn' | 'error' | 'debug';
4+ source: 'frontend' | 'backend';
5+ category: string;
6+ message: string;
7+ data?: any;
8+}
9+
10+export class DebugConsoleService {
11+ private logs: DebugLogEntry[] = [];
12+ private maxLogs = 1000;
13+ private isVisible = false;
14+ private debugElement: HTMLElement | null = null;
15+ private logsContainer: HTMLElement | null = null;
16+
17+ constructor() {
18+ this.createDebugUI();
19+ this.setupKeyboardShortcut();
20+ this.interceptConsole();
21+ }
22+
23+ private createDebugUI(): void {
24+ // Create debug panel
25+ this.debugElement = document.createElement('div');
26+ this.debugElement.id = 'debug-console';
27+ this.debugElement.style.cssText = `
28+ position: fixed;
29+ top: 0;
30+ right: 0;
31+ width: 50%;
32+ height: 100%;
33+ background: rgba(0, 0, 0, 0.95);
34+ color: #00ff00;
35+ font-family: 'Courier New', monospace;
36+ font-size: 12px;
37+ z-index: 10000;
38+ display: none;
39+ border-left: 2px solid #00ff00;
40+ `;
41+
42+ // Create header
43+ const header = document.createElement('div');
44+ header.style.cssText = `
45+ padding: 10px;
46+ background: rgba(0, 255, 0, 0.1);
47+ border-bottom: 1px solid #00ff00;
48+ font-weight: bold;
49+ display: flex;
50+ justify-content: space-between;
51+ align-items: center;
52+ `;
53+ header.innerHTML = `
54+ <span>🐛 DUMBER DEBUG CONSOLE</span>
55+ <div style="font-size: 10px;">
56+ <span style="color: #888; margin-right: 10px;">F11: Inspect Services | F12: Toggle</span>
57+ <button id="clear-logs" style="background: none; border: 1px solid #00ff00; color: #00ff00; padding: 2px 8px; margin-right: 5px; cursor: pointer;">Clear</button>
58+ <button id="close-debug" style="background: none; border: 1px solid #00ff00; color: #00ff00; padding: 2px 8px; cursor: pointer;">Close</button>
59+ </div>
60+ `;
61+
62+ // Create logs container
63+ this.logsContainer = document.createElement('div');
64+ this.logsContainer.style.cssText = `
65+ height: calc(100% - 50px);
66+ overflow-y: auto;
67+ padding: 10px;
68+ `;
69+
70+ this.debugElement.appendChild(header);
71+ this.debugElement.appendChild(this.logsContainer);
72+ document.body.appendChild(this.debugElement);
73+
74+ // Setup button handlers
75+ header.querySelector('#clear-logs')?.addEventListener('click', () => this.clearLogs());
76+ header.querySelector('#close-debug')?.addEventListener('click', () => this.hide());
77+ }
78+
79+ private setupKeyboardShortcut(): void {
80+ document.addEventListener('keydown', (event) => {
81+ // F12 to toggle debug console
82+ if (event.key === 'F12') {
83+ event.preventDefault();
84+ this.toggle();
85+ }
86+
87+ // F11 to inspect window.go in detail
88+ if (event.key === 'F11') {
89+ event.preventDefault();
90+ this.inspectWailsServices();
91+ }
92+ });
93+ }
94+
95+ private inspectWailsServices(): void {
96+ this.addLog('info', 'frontend', 'debug', '=== WAILS SERVICES INSPECTION ===');
97+ this.addLog('info', 'frontend', 'debug', `typeof window: ${typeof window}`);
98+ this.addLog('info', 'frontend', 'debug', `window.go exists: ${!!window.go}`);
99+
100+ if (window.go) {
101+ this.addLog('info', 'frontend', 'debug', `typeof window.go: ${typeof window.go}`, window.go);
102+ this.addLog('info', 'frontend', 'debug', `window.go.services exists: ${!!window.go.services}`);
103+
104+ if (window.go.services) {
105+ const services = window.go.services;
106+ this.addLog('info', 'frontend', 'debug', `typeof window.go.services: ${typeof services}`, services);
107+
108+ const serviceNames = Object.keys(services);
109+ this.addLog('info', 'frontend', 'debug', `Service count: ${serviceNames.length}`);
110+ this.addLog('info', 'frontend', 'debug', `Service names: [${serviceNames.join(', ')}]`);
111+
112+ serviceNames.forEach(name => {
113+ const service = services[name];
114+ this.addLog('info', 'frontend', 'debug', `${name}: ${typeof service}`, service);
115+
116+ if (service && typeof service === 'object') {
117+ const methods = Object.getOwnPropertyNames(service).filter(prop => typeof service[prop] === 'function');
118+ this.addLog('info', 'frontend', 'debug', `${name} methods: [${methods.join(', ')}]`);
119+ }
120+ });
121+ } else {
122+ this.addLog('error', 'frontend', 'debug', 'window.go.services is null/undefined');
123+ }
124+ } else {
125+ this.addLog('error', 'frontend', 'debug', 'window.go is null/undefined');
126+ }
127+
128+ this.addLog('info', 'frontend', 'debug', '=== END INSPECTION ===');
129+ }
130+
131+ private interceptConsole(): void {
132+ const originalConsole = {
133+ log: console.log,
134+ warn: console.warn,
135+ error: console.error,
136+ info: console.info,
137+ debug: console.debug
138+ };
139+
140+ console.log = (...args) => {
141+ this.addLog('info', 'frontend', 'console', args.join(' '));
142+ originalConsole.log(...args);
143+ };
144+
145+ console.warn = (...args) => {
146+ this.addLog('warn', 'frontend', 'console', args.join(' '));
147+ originalConsole.warn(...args);
148+ };
149+
150+ console.error = (...args) => {
151+ this.addLog('error', 'frontend', 'console', args.join(' '));
152+ originalConsole.error(...args);
153+ };
154+
155+ console.info = (...args) => {
156+ this.addLog('info', 'frontend', 'console', args.join(' '));
157+ originalConsole.info(...args);
158+ };
159+
160+ console.debug = (...args) => {
161+ this.addLog('debug', 'frontend', 'console', args.join(' '));
162+ originalConsole.debug(...args);
163+ };
164+ }
165+
166+ public addLog(level: DebugLogEntry['level'], source: DebugLogEntry['source'], category: string, message: string, data?: any): void {
167+ const entry: DebugLogEntry = {
168+ timestamp: new Date().toISOString().split('T')[1].split('.')[0], // HH:MM:SS format
169+ level,
170+ source,
171+ category,
172+ message,
173+ data
174+ };
175+
176+ this.logs.push(entry);
177+
178+ // Keep only the last maxLogs entries
179+ if (this.logs.length > this.maxLogs) {
180+ this.logs.shift();
181+ }
182+
183+ this.updateDisplay();
184+ }
185+
186+ private updateDisplay(): void {
187+ if (!this.logsContainer) return;
188+
189+ const logHTML = this.logs.map(log => {
190+ const levelColor = {
191+ info: '#00ff00',
192+ warn: '#ffff00',
193+ error: '#ff0000',
194+ debug: '#00ffff'
195+ }[log.level];
196+
197+ const sourceIcon = log.source === 'backend' ? '🖥️' : '🌐';
198+
199+ return `
200+ <div style="margin-bottom: 2px; font-size: 11px;">
201+ <span style="color: #888;">[${log.timestamp}]</span>
202+ <span style="color: ${levelColor};">${sourceIcon} ${log.level.toUpperCase()}</span>
203+ <span style="color: #aaa;">[${log.category}]</span>
204+ <span style="color: #fff;">${log.message}</span>
205+ ${log.data ? `<pre style="color: #ccc; font-size: 10px; margin: 2px 0;">${JSON.stringify(log.data, null, 2)}</pre>` : ''}
206+ </div>
207+ `;
208+ }).join('');
209+
210+ this.logsContainer.innerHTML = logHTML;
211+
212+ // Auto-scroll to bottom
213+ this.logsContainer.scrollTop = this.logsContainer.scrollHeight;
214+ }
215+
216+ public logKeyboardEvent(event: KeyboardEvent): void {
217+ const modifiers = [];
218+ if (event.ctrlKey) modifiers.push('Ctrl');
219+ if (event.shiftKey) modifiers.push('Shift');
220+ if (event.altKey) modifiers.push('Alt');
221+ if (event.metaKey) modifiers.push('Meta');
222+
223+ const shortcut = modifiers.length > 0 ? `${modifiers.join('+')}+${event.key}` : event.key;
224+
225+ this.addLog('info', 'frontend', 'keyboard', `Key pressed: ${shortcut}`, {
226+ key: event.key,
227+ code: event.code,
228+ modifiers: modifiers,
229+ target: event.target?.constructor.name
230+ });
231+ }
232+
233+ public logMouseEvent(event: MouseEvent): void {
234+ const buttonNames = {
235+ 0: 'Left',
236+ 1: 'Middle',
237+ 2: 'Right',
238+ 3: 'Back',
239+ 4: 'Forward'
240+ };
241+
242+ this.addLog('info', 'frontend', 'mouse', `Mouse ${event.type}: ${buttonNames[event.button] || `Button${event.button}`}`, {
243+ button: event.button,
244+ clientX: event.clientX,
245+ clientY: event.clientY,
246+ target: event.target?.constructor.name
247+ });
248+ }
249+
250+ public logServiceCall(serviceName: string, methodName: string, params: any[], result?: any, error?: Error): void {
251+ if (error) {
252+ this.addLog('error', 'frontend', 'service', `${serviceName}.${methodName}() failed`, {
253+ params,
254+ error: error.message
255+ });
256+ } else {
257+ this.addLog('info', 'frontend', 'service', `${serviceName}.${methodName}() called`, {
258+ params,
259+ result
260+ });
261+ }
262+ }
263+
264+ public logBackendMessage(message: string, data?: any): void {
265+ this.addLog('info', 'backend', 'service', message, data);
266+ }
267+
268+ public show(): void {
269+ if (this.debugElement) {
270+ this.debugElement.style.display = 'block';
271+ this.isVisible = true;
272+ this.addLog('info', 'frontend', 'debug', 'Debug console opened');
273+ }
274+ }
275+
276+ public hide(): void {
277+ if (this.debugElement) {
278+ this.debugElement.style.display = 'none';
279+ this.isVisible = false;
280+ }
281+ }
282+
283+ public toggle(): void {
284+ if (this.isVisible) {
285+ this.hide();
286+ } else {
287+ this.show();
288+ }
289+ }
290+
291+ public clearLogs(): void {
292+ this.logs = [];
293+ this.updateDisplay();
294+ this.addLog('info', 'frontend', 'debug', 'Logs cleared');
295+ }
296+
297+ public isDebugVisible(): boolean {
298+ return this.isVisible;
299+ }
300+}⏎̸
A frontend/src/services/notifications.ts +91 -0
@@ -0,0 +1,91 @@
1+import type { NotificationOptions } from '../types/app.js';
2+
3+export class NotificationService {
4+ private zoomNotification: HTMLElement | null = null;
5+ private generalNotification: HTMLElement | null = null;
6+
7+ showZoom(message: string, duration = 2000): void {
8+ // Create or update zoom notification
9+ if (!this.zoomNotification) {
10+ this.zoomNotification = this.createNotificationElement('zoom-notification', {
11+ top: '20px',
12+ right: '20px',
13+ background: 'rgba(0, 0, 0, 0.8)',
14+ color: 'white'
15+ });
16+ }
17+
18+ this.updateNotification(this.zoomNotification, message, duration);
19+ }
20+
21+ show(message: string, type: NotificationOptions['type'] = 'info', duration = 3000): void {
22+ // Create or update general notification
23+ if (!this.generalNotification) {
24+ this.generalNotification = this.createNotificationElement('general-notification', {
25+ top: '60px',
26+ right: '20px'
27+ });
28+ }
29+
30+ // Set colors based on type
31+ const colors = this.getNotificationColors(type);
32+ this.generalNotification.style.background = colors.background;
33+ this.generalNotification.style.color = colors.color;
34+
35+ this.updateNotification(this.generalNotification, message, duration);
36+ }
37+
38+ private createNotificationElement(id: string, baseStyles: Record<string, string>): HTMLElement {
39+ const notification = document.createElement('div');
40+ notification.id = id;
41+
42+ const styles = {
43+ position: 'fixed',
44+ padding: '10px 15px',
45+ borderRadius: '5px',
46+ fontSize: '14px',
47+ zIndex: '10000',
48+ transition: 'opacity 0.3s',
49+ opacity: '0',
50+ ...baseStyles
51+ };
52+
53+ notification.style.cssText = Object.entries(styles)
54+ .map(([key, value]) => `${this.camelToKebab(key)}: ${value}`)
55+ .join('; ');
56+
57+ document.body.appendChild(notification);
58+ return notification;
59+ }
60+
61+ private updateNotification(element: HTMLElement, message: string, duration: number): void {
62+ element.textContent = message;
63+ element.style.opacity = '1';
64+
65+ // Clear any existing timeout
66+ const existingTimeout = (element as any).timeout;
67+ if (existingTimeout) {
68+ clearTimeout(existingTimeout);
69+ }
70+
71+ // Hide after duration
72+ (element as any).timeout = setTimeout(() => {
73+ element.style.opacity = '0';
74+ }, duration);
75+ }
76+
77+ private getNotificationColors(type: NotificationOptions['type']): { background: string; color: string } {
78+ switch (type) {
79+ case 'error':
80+ return { background: 'rgba(220, 53, 69, 0.9)', color: 'white' };
81+ case 'success':
82+ return { background: 'rgba(25, 135, 84, 0.9)', color: 'white' };
83+ default:
84+ return { background: 'rgba(13, 110, 253, 0.9)', color: 'white' };
85+ }
86+ }
87+
88+ private camelToKebab(str: string): string {
89+ return str.replace(/[A-Z]/g, letter => `-${letter.toLowerCase()}`);
90+ }
91+}⏎̸
A frontend/src/types/app.ts +26 -0
@@ -0,0 +1,26 @@
1+import type { HistoryEntry, SearchShortcut } from './generated.js';
2+
3+export interface AppState {
4+ currentURL: string;
5+ currentZoom: number;
6+ history: HistoryEntry[];
7+ shortcuts: Record<string, SearchShortcut>;
8+ isLoading: boolean;
9+}
10+
11+export interface NotificationOptions {
12+ message: string;
13+ type?: 'info' | 'success' | 'error';
14+ duration?: number;
15+}
16+
17+export interface KeyboardShortcut {
18+ key: string;
19+ ctrlKey?: boolean;
20+ metaKey?: boolean;
21+ shiftKey?: boolean;
22+ altKey?: boolean;
23+ handler: () => void | Promise<void>;
24+}
25+
26+export { HistoryEntry, SearchShortcut } from './generated.js';⏎̸
A frontend/src/types/generated.ts +80 -0
@@ -0,0 +1,80 @@
1+// GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
2+// Auto-generated TypeScript types from Go structs
3+// Generated on: 2025-09-09T09:57:41.059Z
4+// Generator script: scripts/generate-types.js
5+// Source files: internal/db/models.go, internal/parser/types.go
6+// Do not edit manually - run 'node scripts/generate-types.js' to regenerate
7+
8+export interface History {
9+ id: number;
10+ url: string;
11+ title: string | null;
12+ visit_count: number | null;
13+ last_visited: string | null;
14+ created_at: string | null;
15+}
16+
17+export interface Shortcut {
18+ id: number;
19+ shortcut: string;
20+ url_template: string;
21+ description: string | null;
22+ created_at: string | null;
23+}
24+
25+export interface ZoomSetting {
26+ id: number;
27+ url: string;
28+ zoom_level: number;
29+ last_updated: string | null;
30+}
31+
32+export interface ParseResult {
33+ type: InputType;
34+ url: string;
35+ query: string;
36+ confidence: number;
37+ fuzzy_matches?: FuzzyMatch[];
38+ shortcut?: DetectedShortcut;
39+ processing_time: number;
40+}
41+
42+export interface DetectedShortcut {
43+ key: string;
44+ query: string;
45+ url: string;
46+ description: string;
47+}
48+
49+export interface FuzzyMatch {
50+ history_entry?: History;
51+ score: number;
52+ url_score: number;
53+ title_score: number;
54+ recency_score: number;
55+ visit_score: number;
56+ matched_field: string;
57+}
58+
59+export interface FuzzyConfig {
60+ min_similarity_threshold: number;
61+ max_results: number;
62+ url_weight: number;
63+ title_weight: number;
64+ recency_weight: number;
65+ visit_weight: number;
66+ recency_decay_days: number;
67+}
68+
69+// Manual types and enums
70+export enum InputType {
71+ InputTypeDirectURL = 0,
72+ InputTypeSearchShortcut = 1,
73+ InputTypeHistorySearch = 2,
74+ InputTypeFallbackSearch = 3
75+}
76+
77+// Type aliases for frontend usage
78+export type HistoryEntry = History;
79+export type SearchShortcut = Shortcut;
80+export type ZoomEntry = ZoomSetting;
A frontend/src/types/wails.ts +77 -0
@@ -0,0 +1,77 @@
1+// Wails service interfaces matching Go backend
2+
3+export interface NavigationResult {
4+ url: string;
5+ title: string;
6+ success: boolean;
7+ error?: string;
8+ load_time: number;
9+ redirect_chain?: string[];
10+}
11+
12+export interface HistoryEntry {
13+ id: number;
14+ url: string;
15+ title: string;
16+ visit_count: number;
17+ last_visited: string;
18+ created_at: string;
19+}
20+
21+export interface SearchShortcut {
22+ description: string;
23+ url: string;
24+}
25+
26+export interface Config {
27+ SearchShortcuts: Record<string, SearchShortcut>;
28+}
29+
30+export interface BrowserService {
31+ Navigate(url: string): Promise<NavigationResult>;
32+ UpdatePageTitle(url: string, title: string): Promise<void>;
33+ GetRecentHistory(limit: number): Promise<HistoryEntry[]>;
34+ SearchHistory(query: string, limit: number): Promise<HistoryEntry[]>;
35+ DeleteHistoryEntry(id: number): Promise<void>;
36+ ClearHistory(): Promise<void>;
37+ GetHistoryStats(): Promise<Record<string, any>>;
38+ GetConfig(): Promise<Config>;
39+ UpdateConfig(config: Config): Promise<void>;
40+ GetSearchShortcuts(): Promise<Record<string, SearchShortcut>>;
41+
42+ // Zoom functionality
43+ ZoomIn(url: string): Promise<number>;
44+ ZoomOut(url: string): Promise<number>;
45+ ResetZoom(url: string): Promise<number>;
46+ GetZoomLevel(url: string): Promise<number>;
47+ SetZoomLevel(url: string, zoomLevel: number): Promise<void>;
48+
49+ // Navigation
50+ GetCurrentURL(): Promise<string>;
51+ CopyCurrentURL(url: string): Promise<void>;
52+ GoBack(): Promise<void>;
53+ GoForward(): Promise<void>;
54+}
55+
56+export interface ParserService {
57+ ParseInput(input: string): Promise<any>;
58+}
59+
60+export interface ConfigService {
61+ GetSearchShortcuts(): Promise<Record<string, SearchShortcut>>;
62+}
63+
64+export interface WailsServices {
65+ BrowserService: BrowserService;
66+ ParserService: ParserService;
67+ ConfigService: ConfigService;
68+}
69+
70+// Global Wails interface
71+declare global {
72+ interface Window {
73+ go?: {
74+ services?: WailsServices;
75+ };
76+ }
77+}⏎̸
A frontend/src/utils/dom.ts +151 -0
@@ -0,0 +1,151 @@
1+import type { HistoryEntry, SearchShortcut } from '../types/wails.js';
2+import { BrowserService } from '../../bindings/github.com/bnema/dumber/services/index';
3+import { Window } from '@wailsio/runtime';
4+
5+export class DOMRenderer {
6+ private historyListElement: HTMLElement | null = null;
7+ private shortcutsElement: HTMLElement | null = null;
8+
9+ constructor() {
10+ this.historyListElement = document.getElementById('historyList');
11+ this.shortcutsElement = document.getElementById('shortcuts');
12+ }
13+
14+ displayHistory(historyEntries: HistoryEntry[]): void {
15+ if (!this.historyListElement) return;
16+
17+ if (!historyEntries || historyEntries.length === 0) {
18+ this.historyListElement.innerHTML = `
19+ <div class="empty-state">
20+ <h3>No history yet</h3>
21+ <p>Use CLI commands to browse websites</p>
22+ </div>
23+ `;
24+ return;
25+ }
26+
27+ this.historyListElement.innerHTML = '';
28+ historyEntries.forEach(item => {
29+ const historyItem = this.createHistoryItem(item);
30+ this.historyListElement!.appendChild(historyItem);
31+ });
32+ }
33+
34+ displayShortcuts(shortcutsData: Record<string, SearchShortcut>): void {
35+ if (!this.shortcutsElement) return;
36+
37+ if (!shortcutsData || Object.keys(shortcutsData).length === 0) {
38+ this.shortcutsElement.innerHTML = `
39+ <div class="empty-state">
40+ <h3>No shortcuts configured</h3>
41+ <p>Add shortcuts to your config file</p>
42+ </div>
43+ `;
44+ return;
45+ }
46+
47+ this.shortcutsElement.innerHTML = '';
48+ Object.entries(shortcutsData).forEach(([key, shortcut]) => {
49+ const shortcutEl = this.createShortcutElement(key, shortcut);
50+ this.shortcutsElement!.appendChild(shortcutEl);
51+ });
52+ }
53+
54+ showLoading(container: 'history' | 'shortcuts'): void {
55+ const element = container === 'history' ? this.historyListElement : this.shortcutsElement;
56+ if (element) {
57+ element.innerHTML = '<div class="loading">Loading...</div>';
58+ }
59+ }
60+
61+ showError(container: 'history' | 'shortcuts', message: string): void {
62+ const element = container === 'history' ? this.historyListElement : this.shortcutsElement;
63+ if (element) {
64+ element.innerHTML = `
65+ <div class="empty-state">
66+ <h3>Error</h3>
67+ <p>${message}</p>
68+ </div>
69+ `;
70+ }
71+ }
72+
73+ private createHistoryItem(item: HistoryEntry): HTMLElement {
74+ const historyItem = document.createElement('div');
75+ historyItem.className = 'history-item';
76+ historyItem.innerHTML = `
77+ <div class="history-url">${this.escapeHtml(item.url)}</div>
78+ <div class="history-title">${this.escapeHtml(item.title || 'Untitled')}</div>
79+ `;
80+
81+ historyItem.addEventListener('click', () => {
82+ this.navigateToUrl(item.url);
83+ });
84+
85+ return historyItem;
86+ }
87+
88+ private createShortcutElement(key: string, shortcut: SearchShortcut): HTMLElement {
89+ const shortcutEl = document.createElement('div');
90+ shortcutEl.className = 'shortcut';
91+ shortcutEl.innerHTML = `
92+ <div class="shortcut-key">${this.escapeHtml(key)}:</div>
93+ <div class="shortcut-desc">${this.escapeHtml(shortcut.description)}</div>
94+ `;
95+
96+ shortcutEl.addEventListener('click', () => {
97+ // Navigate to the base URL of the shortcut (remove %s template parameter)
98+ const baseUrl = this.extractBaseUrl(shortcut.url);
99+ this.navigateToUrl(baseUrl);
100+ });
101+
102+ return shortcutEl;
103+ }
104+
105+ private async navigateToUrl(url: string): Promise<void> {
106+ console.log('Navigating to:', url);
107+ try {
108+ const savedZoom = await BrowserService.GetZoomLevel(url);
109+ if (savedZoom && savedZoom > 0) {
110+ try { Window.SetZoom(savedZoom); } catch {}
111+ }
112+ } catch (e) {
113+ // Ignore zoom retrieval errors
114+ }
115+ window.location.href = url;
116+ }
117+
118+ private extractBaseUrl(templateUrl: string): string {
119+ try {
120+ const url = new URL(templateUrl);
121+ // Remove query parameters that contain %s template
122+ const params = new URLSearchParams(url.search);
123+ for (const [key, value] of params.entries()) {
124+ if (value.includes('%s')) {
125+ params.delete(key);
126+ }
127+ }
128+ url.search = params.toString();
129+ return url.toString();
130+ } catch (error) {
131+ // If URL parsing fails, return the original URL
132+ console.warn('Failed to parse shortcut URL:', templateUrl);
133+ return templateUrl;
134+ }
135+ }
136+
137+ private async copyToClipboard(text: string): Promise<void> {
138+ try {
139+ await navigator.clipboard.writeText(text);
140+ console.log('Copied to clipboard:', text);
141+ } catch (err) {
142+ console.error('Failed to copy:', err);
143+ }
144+ }
145+
146+ private escapeHtml(text: string): string {
147+ const div = document.createElement('div');
148+ div.textContent = text;
149+ return div.innerHTML;
150+ }
151+}
A frontend/src/utils/html-builder.ts +180 -0
@@ -0,0 +1,180 @@
1+export class HTMLBuilder {
2+ static createBaseHTML(): string {
3+ return `
4+<!DOCTYPE html>
5+<html lang="en">
6+<head>
7+ <meta charset="UTF-8">
8+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
9+ <title>Dumber Browser</title>
10+ <style>
11+ ${this.getStyles()}
12+ </style>
13+</head>
14+<body>
15+ <div class="container">
16+ <div class="history-section">
17+ <h2 class="section-title">Recent History</h2>
18+ <div id="historyList" class="history-list">
19+ <div class="loading">Loading history...</div>
20+ </div>
21+ </div>
22+
23+ <div class="shortcuts-section">
24+ <h2 class="section-title">Search Shortcuts</h2>
25+ <div id="shortcuts" class="shortcuts-grid">
26+ <div class="loading">Loading shortcuts...</div>
27+ </div>
28+ </div>
29+ </div>
30+ <script type="module" src="./main.js"></script>
31+</body>
32+</html>`;
33+ }
34+
35+ private static getStyles(): string {
36+ return `
37+ * {
38+ margin: 0;
39+ padding: 0;
40+ box-sizing: border-box;
41+ }
42+
43+ body {
44+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
45+ background: #1a1a1a;
46+ color: #ffffff;
47+ height: 100vh;
48+ display: flex;
49+ flex-direction: column;
50+ }
51+
52+ .container {
53+ flex: 1;
54+ display: flex;
55+ max-width: 1200px;
56+ margin: 0 auto;
57+ width: 100%;
58+ padding: 2rem;
59+ gap: 2rem;
60+ }
61+
62+ .history-section {
63+ flex: 1;
64+ min-height: 0;
65+ }
66+
67+ .shortcuts-section {
68+ flex: 1;
69+ min-height: 0;
70+ }
71+
72+ .section-title {
73+ font-size: 1.5rem;
74+ margin-bottom: 1rem;
75+ color: #ffffff;
76+ border-bottom: 2px solid #404040;
77+ padding-bottom: 0.5rem;
78+ }
79+
80+ .history-list {
81+ overflow-y: auto;
82+ max-height: calc(100vh - 8rem);
83+ }
84+
85+ .history-item {
86+ padding: 0.75rem;
87+ margin-bottom: 0.5rem;
88+ background: #2d2d2d;
89+ border-radius: 6px;
90+ cursor: pointer;
91+ transition: background 0.2s;
92+ border-left: 3px solid #404040;
93+ }
94+
95+ .history-item:hover {
96+ background: #3d3d3d;
97+ border-left-color: #0066cc;
98+ }
99+
100+ .history-url {
101+ font-size: 0.9rem;
102+ color: #cccccc;
103+ margin-bottom: 0.25rem;
104+ word-break: break-all;
105+ }
106+
107+ .history-title {
108+ font-size: 0.8rem;
109+ color: #888;
110+ overflow: hidden;
111+ text-overflow: ellipsis;
112+ white-space: nowrap;
113+ }
114+
115+ .shortcuts-grid {
116+ display: grid;
117+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
118+ gap: 1rem;
119+ max-height: calc(100vh - 8rem);
120+ overflow-y: auto;
121+ }
122+
123+ .shortcut {
124+ padding: 1rem;
125+ background: #2d2d2d;
126+ border: 2px solid #404040;
127+ border-radius: 8px;
128+ cursor: pointer;
129+ transition: all 0.2s;
130+ text-align: center;
131+ }
132+
133+ .shortcut:hover {
134+ background: #3d3d3d;
135+ border-color: #0066cc;
136+ transform: translateY(-2px);
137+ }
138+
139+ .shortcut-key {
140+ font-weight: bold;
141+ color: #0066cc;
142+ margin-bottom: 0.5rem;
143+ font-size: 1.1rem;
144+ }
145+
146+ .shortcut-desc {
147+ color: #888;
148+ font-size: 0.9rem;
149+ }
150+
151+ .loading {
152+ padding: 2rem;
153+ text-align: center;
154+ color: #888;
155+ }
156+
157+ .empty-state {
158+ padding: 3rem 2rem;
159+ text-align: center;
160+ color: #666;
161+ }
162+
163+ .empty-state h3 {
164+ margin-bottom: 1rem;
165+ color: #888;
166+ }
167+
168+ @media (max-width: 768px) {
169+ .container {
170+ flex-direction: column;
171+ padding: 1rem;
172+ }
173+
174+ .shortcuts-grid {
175+ grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
176+ }
177+ }
178+ `;
179+ }
180+}⏎̸
A frontend/tsconfig.json +22 -0
@@ -0,0 +1,22 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "bundler",
6+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
7+ "allowImportingTsExtensions": true,
8+ "moduleDetection": "force",
9+ "noEmit": true,
10+ "strict": true,
11+ "noUnusedLocals": true,
12+ "noUnusedParameters": true,
13+ "noFallthroughCasesInSwitch": true,
14+ "noUncheckedIndexedAccess": true,
15+ "skipLibCheck": true,
16+ "esModuleInterop": true,
17+ "allowSyntheticDefaultImports": true,
18+ "forceConsistentCasingInFileNames": true
19+ },
20+ "include": ["src/**/*"],
21+ "exclude": ["node_modules", "dist"]
22+}⏎̸
A frontend/tsup.config.ts +54 -0
@@ -0,0 +1,54 @@
1+import { defineConfig } from 'tsup'
2+
3+export default defineConfig([
4+ // Browser bundle
5+ {
6+ entry: ['src/main.ts'],
7+ format: ['esm'],
8+ outDir: 'dist',
9+ outExtension: () => ({ js: '.js' }),
10+ target: 'es2024',
11+ platform: 'browser',
12+ minify: false,
13+ sourcemap: true,
14+ clean: true,
15+ bundle: true,
16+ splitting: false,
17+ external: [],
18+ noExternal: ['@wailsio/runtime'],
19+ },
20+ // Injectable controls script (minified for injection)
21+ {
22+ entry: ['src/injected-controls.ts'],
23+ format: ['iife'],
24+ outDir: 'dist',
25+ outExtension: () => ({ js: '.min.js' }),
26+ target: 'es2024',
27+ platform: 'browser',
28+ minify: true,
29+ sourcemap: false,
30+ bundle: true,
31+ splitting: false,
32+ globalName: '__dumberControls',
33+ external: [],
34+ onSuccess: async () => {
35+ console.log('✓ Injectable controls script compiled successfully')
36+ }
37+ },
38+ // Node.js build script
39+ {
40+ entry: ['src/build-html.ts', 'src/utils/html-builder.ts'],
41+ format: ['esm'],
42+ outDir: 'dist',
43+ outExtension: () => ({ js: '.js' }),
44+ target: 'es2024',
45+ platform: 'node',
46+ minify: false,
47+ sourcemap: false,
48+ bundle: true,
49+ splitting: false,
50+ onSuccess: async () => {
51+ console.log('✓ Frontend TypeScript compiled successfully')
52+ }
53+ }
54+])⏎̸
M go.mod +1 -0
@@ -4,6 +4,7 @@ go 1.25.1
44
55 require (
66 github.com/fsnotify/fsnotify v1.9.0
7+ github.com/golang/mock v1.6.0
78 github.com/ncruces/go-sqlite3 v0.29.0
89 github.com/spf13/cobra v1.10.1
910 github.com/spf13/viper v1.21.0
M go.sum +22 -0 generated file: collapsed
M internal/db/init.go +9 -0
@@ -83,6 +83,15 @@ func initializeSchema(db *sql.DB) error {
8383 description TEXT,
8484 created_at DATETIME DEFAULT CURRENT_TIMESTAMP
8585 );
86+
87+ -- Zoom persistence per domain (not full URL)
88+ CREATE TABLE IF NOT EXISTS zoom_levels (
89+ domain TEXT PRIMARY KEY,
90+ zoom_factor REAL NOT NULL DEFAULT 1.0 CHECK(zoom_factor >= 0.3 AND zoom_factor <= 5.0),
91+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
92+ );
93+
94+ CREATE INDEX IF NOT EXISTS idx_zoom_levels_updated_at ON zoom_levels(updated_at);
8695 `
8796
8897 // Execute schema creation
A internal/db/interfaces.go +37 -0
@@ -0,0 +1,37 @@
1+package db
2+
3+import (
4+ "context"
5+ "database/sql"
6+)
7+
8+//go:generate mockgen -source=interfaces.go -destination=mocks/mock_db.go
9+
10+// ZoomQuerier defines the interface for zoom-related database operations
11+type ZoomQuerier interface {
12+ GetZoomLevel(ctx context.Context, domain string) (float64, error)
13+ SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error
14+ DeleteZoomLevel(ctx context.Context, domain string) error
15+}
16+
17+// HistoryQuerier defines the interface for history-related database operations
18+type HistoryQuerier interface {
19+ GetHistory(ctx context.Context, limit int64) ([]History, error)
20+ SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
21+ AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
22+}
23+
24+// ShortcutQuerier defines the interface for shortcut-related database operations
25+type ShortcutQuerier interface {
26+ GetShortcuts(ctx context.Context) ([]Shortcut, error)
27+}
28+
29+// DatabaseQuerier combines all database operation interfaces
30+type DatabaseQuerier interface {
31+ ZoomQuerier
32+ HistoryQuerier
33+ ShortcutQuerier
34+}
35+
36+// Ensure that *Queries implements DatabaseQuerier interface
37+var _ DatabaseQuerier = (*Queries)(nil)⏎̸
A internal/db/mocks/mock_db.go +310 -0
@@ -0,0 +1,310 @@
1+// Code generated by MockGen. DO NOT EDIT.
2+// Source: interfaces.go
3+
4+// Package mock_db is a generated GoMock package.
5+package mock_db
6+
7+import (
8+ context "context"
9+ sql "database/sql"
10+ reflect "reflect"
11+
12+ db "github.com/bnema/dumber/internal/db"
13+ gomock "github.com/golang/mock/gomock"
14+)
15+
16+// MockZoomQuerier is a mock of ZoomQuerier interface.
17+type MockZoomQuerier struct {
18+ ctrl *gomock.Controller
19+ recorder *MockZoomQuerierMockRecorder
20+}
21+
22+// MockZoomQuerierMockRecorder is the mock recorder for MockZoomQuerier.
23+type MockZoomQuerierMockRecorder struct {
24+ mock *MockZoomQuerier
25+}
26+
27+// NewMockZoomQuerier creates a new mock instance.
28+func NewMockZoomQuerier(ctrl *gomock.Controller) *MockZoomQuerier {
29+ mock := &MockZoomQuerier{ctrl: ctrl}
30+ mock.recorder = &MockZoomQuerierMockRecorder{mock}
31+ return mock
32+}
33+
34+// EXPECT returns an object that allows the caller to indicate expected use.
35+func (m *MockZoomQuerier) EXPECT() *MockZoomQuerierMockRecorder {
36+ return m.recorder
37+}
38+
39+// DeleteZoomLevel mocks base method.
40+func (m *MockZoomQuerier) DeleteZoomLevel(ctx context.Context, domain string) error {
41+ m.ctrl.T.Helper()
42+ ret := m.ctrl.Call(m, "DeleteZoomLevel", ctx, domain)
43+ ret0, _ := ret[0].(error)
44+ return ret0
45+}
46+
47+// DeleteZoomLevel indicates an expected call of DeleteZoomLevel.
48+func (mr *MockZoomQuerierMockRecorder) DeleteZoomLevel(ctx, domain interface{}) *gomock.Call {
49+ mr.mock.ctrl.T.Helper()
50+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).DeleteZoomLevel), ctx, domain)
51+}
52+
53+// GetZoomLevel mocks base method.
54+func (m *MockZoomQuerier) GetZoomLevel(ctx context.Context, domain string) (float64, error) {
55+ m.ctrl.T.Helper()
56+ ret := m.ctrl.Call(m, "GetZoomLevel", ctx, domain)
57+ ret0, _ := ret[0].(float64)
58+ ret1, _ := ret[1].(error)
59+ return ret0, ret1
60+}
61+
62+// GetZoomLevel indicates an expected call of GetZoomLevel.
63+func (mr *MockZoomQuerierMockRecorder) GetZoomLevel(ctx, domain interface{}) *gomock.Call {
64+ mr.mock.ctrl.T.Helper()
65+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).GetZoomLevel), ctx, domain)
66+}
67+
68+// SetZoomLevel mocks base method.
69+func (m *MockZoomQuerier) SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error {
70+ m.ctrl.T.Helper()
71+ ret := m.ctrl.Call(m, "SetZoomLevel", ctx, domain, zoomLevel)
72+ ret0, _ := ret[0].(error)
73+ return ret0
74+}
75+
76+// SetZoomLevel indicates an expected call of SetZoomLevel.
77+func (mr *MockZoomQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel interface{}) *gomock.Call {
78+ mr.mock.ctrl.T.Helper()
79+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockZoomQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel)
80+}
81+
82+// MockHistoryQuerier is a mock of HistoryQuerier interface.
83+type MockHistoryQuerier struct {
84+ ctrl *gomock.Controller
85+ recorder *MockHistoryQuerierMockRecorder
86+}
87+
88+// MockHistoryQuerierMockRecorder is the mock recorder for MockHistoryQuerier.
89+type MockHistoryQuerierMockRecorder struct {
90+ mock *MockHistoryQuerier
91+}
92+
93+// NewMockHistoryQuerier creates a new mock instance.
94+func NewMockHistoryQuerier(ctrl *gomock.Controller) *MockHistoryQuerier {
95+ mock := &MockHistoryQuerier{ctrl: ctrl}
96+ mock.recorder = &MockHistoryQuerierMockRecorder{mock}
97+ return mock
98+}
99+
100+// EXPECT returns an object that allows the caller to indicate expected use.
101+func (m *MockHistoryQuerier) EXPECT() *MockHistoryQuerierMockRecorder {
102+ return m.recorder
103+}
104+
105+// AddOrUpdateHistory mocks base method.
106+func (m *MockHistoryQuerier) AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error {
107+ m.ctrl.T.Helper()
108+ ret := m.ctrl.Call(m, "AddOrUpdateHistory", ctx, url, title)
109+ ret0, _ := ret[0].(error)
110+ return ret0
111+}
112+
113+// AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory.
114+func (mr *MockHistoryQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title interface{}) *gomock.Call {
115+ mr.mock.ctrl.T.Helper()
116+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).AddOrUpdateHistory), ctx, url, title)
117+}
118+
119+// GetHistory mocks base method.
120+func (m *MockHistoryQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) {
121+ m.ctrl.T.Helper()
122+ ret := m.ctrl.Call(m, "GetHistory", ctx, limit)
123+ ret0, _ := ret[0].([]db.History)
124+ ret1, _ := ret[1].(error)
125+ return ret0, ret1
126+}
127+
128+// GetHistory indicates an expected call of GetHistory.
129+func (mr *MockHistoryQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call {
130+ mr.mock.ctrl.T.Helper()
131+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).GetHistory), ctx, limit)
132+}
133+
134+// SearchHistory mocks base method.
135+func (m *MockHistoryQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) {
136+ m.ctrl.T.Helper()
137+ ret := m.ctrl.Call(m, "SearchHistory", ctx, column1, column2, limit)
138+ ret0, _ := ret[0].([]db.History)
139+ ret1, _ := ret[1].(error)
140+ return ret0, ret1
141+}
142+
143+// SearchHistory indicates an expected call of SearchHistory.
144+func (mr *MockHistoryQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit interface{}) *gomock.Call {
145+ mr.mock.ctrl.T.Helper()
146+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockHistoryQuerier)(nil).SearchHistory), ctx, column1, column2, limit)
147+}
148+
149+// MockShortcutQuerier is a mock of ShortcutQuerier interface.
150+type MockShortcutQuerier struct {
151+ ctrl *gomock.Controller
152+ recorder *MockShortcutQuerierMockRecorder
153+}
154+
155+// MockShortcutQuerierMockRecorder is the mock recorder for MockShortcutQuerier.
156+type MockShortcutQuerierMockRecorder struct {
157+ mock *MockShortcutQuerier
158+}
159+
160+// NewMockShortcutQuerier creates a new mock instance.
161+func NewMockShortcutQuerier(ctrl *gomock.Controller) *MockShortcutQuerier {
162+ mock := &MockShortcutQuerier{ctrl: ctrl}
163+ mock.recorder = &MockShortcutQuerierMockRecorder{mock}
164+ return mock
165+}
166+
167+// EXPECT returns an object that allows the caller to indicate expected use.
168+func (m *MockShortcutQuerier) EXPECT() *MockShortcutQuerierMockRecorder {
169+ return m.recorder
170+}
171+
172+// GetShortcuts mocks base method.
173+func (m *MockShortcutQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) {
174+ m.ctrl.T.Helper()
175+ ret := m.ctrl.Call(m, "GetShortcuts", ctx)
176+ ret0, _ := ret[0].([]db.Shortcut)
177+ ret1, _ := ret[1].(error)
178+ return ret0, ret1
179+}
180+
181+// GetShortcuts indicates an expected call of GetShortcuts.
182+func (mr *MockShortcutQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call {
183+ mr.mock.ctrl.T.Helper()
184+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockShortcutQuerier)(nil).GetShortcuts), ctx)
185+}
186+
187+// MockDatabaseQuerier is a mock of DatabaseQuerier interface.
188+type MockDatabaseQuerier struct {
189+ ctrl *gomock.Controller
190+ recorder *MockDatabaseQuerierMockRecorder
191+}
192+
193+// MockDatabaseQuerierMockRecorder is the mock recorder for MockDatabaseQuerier.
194+type MockDatabaseQuerierMockRecorder struct {
195+ mock *MockDatabaseQuerier
196+}
197+
198+// NewMockDatabaseQuerier creates a new mock instance.
199+func NewMockDatabaseQuerier(ctrl *gomock.Controller) *MockDatabaseQuerier {
200+ mock := &MockDatabaseQuerier{ctrl: ctrl}
201+ mock.recorder = &MockDatabaseQuerierMockRecorder{mock}
202+ return mock
203+}
204+
205+// EXPECT returns an object that allows the caller to indicate expected use.
206+func (m *MockDatabaseQuerier) EXPECT() *MockDatabaseQuerierMockRecorder {
207+ return m.recorder
208+}
209+
210+// AddOrUpdateHistory mocks base method.
211+func (m *MockDatabaseQuerier) AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error {
212+ m.ctrl.T.Helper()
213+ ret := m.ctrl.Call(m, "AddOrUpdateHistory", ctx, url, title)
214+ ret0, _ := ret[0].(error)
215+ return ret0
216+}
217+
218+// AddOrUpdateHistory indicates an expected call of AddOrUpdateHistory.
219+func (mr *MockDatabaseQuerierMockRecorder) AddOrUpdateHistory(ctx, url, title interface{}) *gomock.Call {
220+ mr.mock.ctrl.T.Helper()
221+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddOrUpdateHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).AddOrUpdateHistory), ctx, url, title)
222+}
223+
224+// DeleteZoomLevel mocks base method.
225+func (m *MockDatabaseQuerier) DeleteZoomLevel(ctx context.Context, domain string) error {
226+ m.ctrl.T.Helper()
227+ ret := m.ctrl.Call(m, "DeleteZoomLevel", ctx, domain)
228+ ret0, _ := ret[0].(error)
229+ return ret0
230+}
231+
232+// DeleteZoomLevel indicates an expected call of DeleteZoomLevel.
233+func (mr *MockDatabaseQuerierMockRecorder) DeleteZoomLevel(ctx, domain interface{}) *gomock.Call {
234+ mr.mock.ctrl.T.Helper()
235+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).DeleteZoomLevel), ctx, domain)
236+}
237+
238+// GetHistory mocks base method.
239+func (m *MockDatabaseQuerier) GetHistory(ctx context.Context, limit int64) ([]db.History, error) {
240+ m.ctrl.T.Helper()
241+ ret := m.ctrl.Call(m, "GetHistory", ctx, limit)
242+ ret0, _ := ret[0].([]db.History)
243+ ret1, _ := ret[1].(error)
244+ return ret0, ret1
245+}
246+
247+// GetHistory indicates an expected call of GetHistory.
248+func (mr *MockDatabaseQuerierMockRecorder) GetHistory(ctx, limit interface{}) *gomock.Call {
249+ mr.mock.ctrl.T.Helper()
250+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetHistory), ctx, limit)
251+}
252+
253+// GetShortcuts mocks base method.
254+func (m *MockDatabaseQuerier) GetShortcuts(ctx context.Context) ([]db.Shortcut, error) {
255+ m.ctrl.T.Helper()
256+ ret := m.ctrl.Call(m, "GetShortcuts", ctx)
257+ ret0, _ := ret[0].([]db.Shortcut)
258+ ret1, _ := ret[1].(error)
259+ return ret0, ret1
260+}
261+
262+// GetShortcuts indicates an expected call of GetShortcuts.
263+func (mr *MockDatabaseQuerierMockRecorder) GetShortcuts(ctx interface{}) *gomock.Call {
264+ mr.mock.ctrl.T.Helper()
265+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetShortcuts", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetShortcuts), ctx)
266+}
267+
268+// GetZoomLevel mocks base method.
269+func (m *MockDatabaseQuerier) GetZoomLevel(ctx context.Context, domain string) (float64, error) {
270+ m.ctrl.T.Helper()
271+ ret := m.ctrl.Call(m, "GetZoomLevel", ctx, domain)
272+ ret0, _ := ret[0].(float64)
273+ ret1, _ := ret[1].(error)
274+ return ret0, ret1
275+}
276+
277+// GetZoomLevel indicates an expected call of GetZoomLevel.
278+func (mr *MockDatabaseQuerierMockRecorder) GetZoomLevel(ctx, domain interface{}) *gomock.Call {
279+ mr.mock.ctrl.T.Helper()
280+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).GetZoomLevel), ctx, domain)
281+}
282+
283+// SearchHistory mocks base method.
284+func (m *MockDatabaseQuerier) SearchHistory(ctx context.Context, column1, column2 sql.NullString, limit int64) ([]db.History, error) {
285+ m.ctrl.T.Helper()
286+ ret := m.ctrl.Call(m, "SearchHistory", ctx, column1, column2, limit)
287+ ret0, _ := ret[0].([]db.History)
288+ ret1, _ := ret[1].(error)
289+ return ret0, ret1
290+}
291+
292+// SearchHistory indicates an expected call of SearchHistory.
293+func (mr *MockDatabaseQuerierMockRecorder) SearchHistory(ctx, column1, column2, limit interface{}) *gomock.Call {
294+ mr.mock.ctrl.T.Helper()
295+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SearchHistory", reflect.TypeOf((*MockDatabaseQuerier)(nil).SearchHistory), ctx, column1, column2, limit)
296+}
297+
298+// SetZoomLevel mocks base method.
299+func (m *MockDatabaseQuerier) SetZoomLevel(ctx context.Context, domain string, zoomLevel float64) error {
300+ m.ctrl.T.Helper()
301+ ret := m.ctrl.Call(m, "SetZoomLevel", ctx, domain, zoomLevel)
302+ ret0, _ := ret[0].(error)
303+ return ret0
304+}
305+
306+// SetZoomLevel indicates an expected call of SetZoomLevel.
307+func (mr *MockDatabaseQuerierMockRecorder) SetZoomLevel(ctx, domain, zoomLevel interface{}) *gomock.Call {
308+ mr.mock.ctrl.T.Helper()
309+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetZoomLevel", reflect.TypeOf((*MockDatabaseQuerier)(nil).SetZoomLevel), ctx, domain, zoomLevel)
310+}
M internal/db/models.go +6 -0
@@ -24,3 +24,9 @@ type Shortcut struct {
2424 Description sql.NullString `json:"description"`
2525 CreatedAt sql.NullTime `json:"created_at"`
2626 }
27+
28+type ZoomLevel struct {
29+ Domain string `json:"domain"`
30+ ZoomFactor float64 `json:"zoom_factor"`
31+ UpdatedAt sql.NullTime `json:"updated_at"`
32+}
M internal/db/querier.go +12 -0
@@ -11,9 +11,21 @@ import (
1111
1212 type Querier interface {
1313 AddOrUpdateHistory(ctx context.Context, url string, title sql.NullString) error
14+ // Cleanup zoom level entries older than specified days
15+ CleanupOldZoomLevels(ctx context.Context, dollar_1 sql.NullString) error
16+ // Delete zoom level setting for a domain
17+ DeleteZoomLevel(ctx context.Context, domain string) error
1418 GetHistory(ctx context.Context, limit int64) ([]History, error)
1519 GetShortcuts(ctx context.Context) ([]Shortcut, error)
20+ // Get zoom level for a specific domain
21+ GetZoomLevel(ctx context.Context, domain string) (float64, error)
22+ // Get zoom level for domain with default fallback
23+ GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error)
24+ // List all zoom level settings ordered by most recently updated
25+ ListZoomLevels(ctx context.Context) ([]ZoomLevel, error)
1626 SearchHistory(ctx context.Context, column1 sql.NullString, column2 sql.NullString, limit int64) ([]History, error)
27+ // Set or update zoom level for a domain with validation
28+ SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error
1729 }
1830
1931 var _ Querier = (*Queries)(nil)
A internal/db/queries/zoom.sql +30 -0
@@ -0,0 +1,30 @@
1+-- name: GetZoomLevel :one
2+-- Get zoom level for a specific domain
3+SELECT zoom_factor FROM zoom_levels WHERE domain = ? LIMIT 1;
4+
5+-- name: SetZoomLevel :exec
6+-- Set or update zoom level for a domain with validation
7+INSERT INTO zoom_levels (domain, zoom_factor, updated_at)
8+VALUES (?, ?, CURRENT_TIMESTAMP)
9+ON CONFLICT(domain) DO UPDATE SET
10+ zoom_factor = excluded.zoom_factor,
11+ updated_at = excluded.updated_at;
12+
13+-- name: DeleteZoomLevel :exec
14+-- Delete zoom level setting for a domain
15+DELETE FROM zoom_levels WHERE domain = ?;
16+
17+-- name: ListZoomLevels :many
18+-- List all zoom level settings ordered by most recently updated
19+SELECT domain, zoom_factor, updated_at FROM zoom_levels ORDER BY updated_at DESC;
20+
21+-- name: CleanupOldZoomLevels :exec
22+-- Cleanup zoom level entries older than specified days
23+DELETE FROM zoom_levels WHERE updated_at < datetime('now', '-' || ? || ' days');
24+
25+-- name: GetZoomLevelWithDefault :one
26+-- Get zoom level for domain with default fallback
27+SELECT COALESCE(
28+ (SELECT zoom_factor FROM zoom_levels WHERE domain = ? LIMIT 1),
29+ 1.0
30+) as zoom_factor;⏎̸
A internal/db/zoom.sql.go +100 -0
@@ -0,0 +1,100 @@
1+// Code generated by sqlc. DO NOT EDIT.
2+// versions:
3+// sqlc v1.30.0
4+// source: zoom.sql
5+
6+package db
7+
8+import (
9+ "context"
10+ "database/sql"
11+)
12+
13+const CleanupOldZoomLevels = `-- name: CleanupOldZoomLevels :exec
14+DELETE FROM zoom_levels WHERE updated_at < datetime('now', '-' || ? || ' days')
15+`
16+
17+// Cleanup zoom level entries older than specified days
18+func (q *Queries) CleanupOldZoomLevels(ctx context.Context, dollar_1 sql.NullString) error {
19+ _, err := q.db.ExecContext(ctx, CleanupOldZoomLevels, dollar_1)
20+ return err
21+}
22+
23+const DeleteZoomLevel = `-- name: DeleteZoomLevel :exec
24+DELETE FROM zoom_levels WHERE domain = ?
25+`
26+
27+// Delete zoom level setting for a domain
28+func (q *Queries) DeleteZoomLevel(ctx context.Context, domain string) error {
29+ _, err := q.db.ExecContext(ctx, DeleteZoomLevel, domain)
30+ return err
31+}
32+
33+const GetZoomLevel = `-- name: GetZoomLevel :one
34+SELECT zoom_factor FROM zoom_levels WHERE domain = ? LIMIT 1
35+`
36+
37+// Get zoom level for a specific domain
38+func (q *Queries) GetZoomLevel(ctx context.Context, domain string) (float64, error) {
39+ row := q.db.QueryRowContext(ctx, GetZoomLevel, domain)
40+ var zoom_factor float64
41+ err := row.Scan(&zoom_factor)
42+ return zoom_factor, err
43+}
44+
45+const GetZoomLevelWithDefault = `-- name: GetZoomLevelWithDefault :one
46+SELECT COALESCE(
47+ (SELECT zoom_factor FROM zoom_levels WHERE domain = ? LIMIT 1),
48+ 1.0
49+) as zoom_factor
50+`
51+
52+// Get zoom level for domain with default fallback
53+func (q *Queries) GetZoomLevelWithDefault(ctx context.Context, domain string) (interface{}, error) {
54+ row := q.db.QueryRowContext(ctx, GetZoomLevelWithDefault, domain)
55+ var zoom_factor interface{}
56+ err := row.Scan(&zoom_factor)
57+ return zoom_factor, err
58+}
59+
60+const ListZoomLevels = `-- name: ListZoomLevels :many
61+SELECT domain, zoom_factor, updated_at FROM zoom_levels ORDER BY updated_at DESC
62+`
63+
64+// List all zoom level settings ordered by most recently updated
65+func (q *Queries) ListZoomLevels(ctx context.Context) ([]ZoomLevel, error) {
66+ rows, err := q.db.QueryContext(ctx, ListZoomLevels)
67+ if err != nil {
68+ return nil, err
69+ }
70+ defer rows.Close()
71+ items := []ZoomLevel{}
72+ for rows.Next() {
73+ var i ZoomLevel
74+ if err := rows.Scan(&i.Domain, &i.ZoomFactor, &i.UpdatedAt); err != nil {
75+ return nil, err
76+ }
77+ items = append(items, i)
78+ }
79+ if err := rows.Close(); err != nil {
80+ return nil, err
81+ }
82+ if err := rows.Err(); err != nil {
83+ return nil, err
84+ }
85+ return items, nil
86+}
87+
88+const SetZoomLevel = `-- name: SetZoomLevel :exec
89+INSERT INTO zoom_levels (domain, zoom_factor, updated_at)
90+VALUES (?, ?, CURRENT_TIMESTAMP)
91+ON CONFLICT(domain) DO UPDATE SET
92+ zoom_factor = excluded.zoom_factor,
93+ updated_at = excluded.updated_at
94+`
95+
96+// Set or update zoom level for a domain with validation
97+func (q *Queries) SetZoomLevel(ctx context.Context, domain string, zoomFactor float64) error {
98+ _, err := q.db.ExecContext(ctx, SetZoomLevel, domain, zoomFactor)
99+ return err
100+}
M main.go +78 -15
@@ -7,6 +7,7 @@ import (
77 "log"
88 "log/slog"
99 "os"
10+ "time"
1011
1112 "github.com/wailsapp/wails/v3/pkg/application"
1213
@@ -17,6 +18,7 @@ import (
1718 )
1819
1920 // Embed the frontend assets
21+//
2022 //go:embed frontend/dist
2123 var assets embed.FS
2224
@@ -68,13 +70,13 @@ func runCLI() {
6870 fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err)
6971 os.Exit(1)
7072 }
71-
73+
7274 // Start configuration watching for live reload
7375 if err := config.Watch(); err != nil {
7476 // Don't exit on watch error, just warn
7577 fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err)
7678 }
77-
79+
7880 rootCmd := cli.NewRootCmd(version, commit, buildDate)
7981
8082 // Handle dmenu flag at the root level for direct integration
@@ -129,8 +131,14 @@ func runBrowser() {
129131 browserService := services.NewBrowserService(cfg, queries)
130132 configService := services.NewConfigService(cfg, "")
131133
134+ // Load the injectable script into the browser service
135+ if err := browserService.LoadInjectableScript(assets); err != nil {
136+ log.Printf("Warning: Failed to load injectable script: %v", err)
137+ }
138+
132139 // Check if this is a browse command with URL
133140 var directURL string
141+ var initialZoom float64 = 1.0
134142 if len(os.Args) >= 3 && os.Args[1] == "browse" {
135143 // Parse the URL using our parser service
136144 ctx := context.Background()
@@ -141,6 +149,10 @@ func runBrowser() {
141149 directURL = result.URL
142150 // Record the visit
143151 browserService.Navigate(ctx, directURL)
152+ // Load saved zoom level for the destination domain to apply natively
153+ if z, zerr := browserService.GetZoomLevel(ctx, directURL); zerr == nil && z > 0 {
154+ initialZoom = z
155+ }
144156 }
145157 }
146158
@@ -149,42 +161,93 @@ func runBrowser() {
149161 Name: "dumber-browser",
150162 Description: "A smart URL launcher and browser with learning behavior",
151163 LogLevel: slog.LevelInfo,
152-
164+
153165 Services: []application.Service{
154166 application.NewService(parserService),
155167 application.NewService(browserService),
156168 application.NewService(configService),
157169 },
158-
170+
159171 Assets: application.AssetOptions{
160172 Handler: application.AssetFileServerFS(assets),
161173 },
162-
174+
163175 Mac: application.MacOptions{
164176 ApplicationShouldTerminateAfterLastWindowClosed: true,
165177 },
166178 })
167179
168- // Determine the starting URL
180+ // Always start with the browser UI, never directly navigate to external URLs
169181 startURL := "/"
170- if directURL != "" {
171- startURL = directURL
172- }
173182
174183 // Create the main window
175184 window := app.Window.NewWithOptions(application.WebviewWindowOptions{
176- Title: "Dumber Browser",
177- Width: 1200,
178- Height: 800,
179- URL: startURL,
185+ Title: "Dumber Browser",
186+ URL: startURL,
187+ Zoom: initialZoom,
188+ // Ensure the window can take full available space in tilers like Hyprland
189+ // by avoiding a too-small max size hint from GTK. Also start maximised.
190+ StartState: application.WindowStateMaximised,
191+ DisableResize: false,
192+ MinWidth: 800,
193+ MinHeight: 600,
194+ // Use large max size hints so compositor isn't constrained by logical monitor size
195+ MaxWidth: 10000,
196+ MaxHeight: 10000,
197+ // Enable devtools and add native keybindings so they work on any page
198+ DevToolsEnabled: true,
199+ KeyBindings: map[string]func(window application.Window){
200+ // DevTools toggle (native; works on any page)
201+ "F12": func(w application.Window) { w.OpenDevTools() },
202+ // Native zoom shortcuts so they work on any origin
203+ // Note: current implementation does not persist zoom changes made via these shortcuts.
204+ // Zoom In variants
205+ "cmdorctrl+plus": func(w application.Window) { w.ZoomIn() },
206+ "cmdorctrl+=": func(w application.Window) { w.ZoomIn() },
207+ "cmdorctrl+shift+=": func(w application.Window) { w.ZoomIn() },
208+ "cmdorctrl+shift+plus": func(w application.Window) { w.ZoomIn() },
209+ "cmdorctrl+-": func(w application.Window) { w.ZoomOut() },
210+ "cmdorctrl+0": func(w application.Window) { w.ZoomReset() },
211+ },
180212 })
181-
213+
214+ // Connect the browser service to the window for title updates and script injection
215+ browserService.SetWindowTitleUpdater(window)
216+ browserService.SetScriptInjector(window)
217+
182218 // Show the window
183219 window.Show()
184220
221+ // Set up navigation monitoring and script injection
222+ // We'll inject the script periodically to ensure it's available on all pages
223+ go func() {
224+ ctx := context.Background()
225+
226+ // Initial injection after a short delay to let the page load
227+ time.Sleep(1 * time.Second)
228+ if err := browserService.InjectControlScript(ctx); err != nil {
229+ log.Printf("Initial script injection failed: %v", err)
230+ }
231+
232+ // More aggressive re-injection to handle navigation and CSP restrictions
233+ ticker := time.NewTicker(1 * time.Second)
234+ defer ticker.Stop()
235+
236+ for {
237+ select {
238+ case <-ticker.C:
239+ // Try to inject script - this will be ignored if already injected
240+ if err := browserService.InjectControlScript(ctx); err != nil {
241+ // Silently fail - this is expected on some pages with strict CSP
242+ continue
243+ }
244+ }
245+ }
246+ }()
247+
185248 // Run the application (blocking call)
186249 err = app.Run()
187250 if err != nil {
188251 log.Fatal(err)
189252 }
190-}⏎̸
253+}
A migrations/002_zoom_persistence.sql +11 -0
@@ -0,0 +1,11 @@
1+-- Add zoom persistence table
2+-- Store zoom levels per URL for restoration between sessions
3+
4+CREATE TABLE IF NOT EXISTS zoom_settings (
5+ id INTEGER PRIMARY KEY AUTOINCREMENT,
6+ url TEXT NOT NULL UNIQUE,
7+ zoom_level REAL NOT NULL DEFAULT 1.0,
8+ last_updated DATETIME DEFAULT CURRENT_TIMESTAMP
9+);
10+
11+CREATE INDEX idx_zoom_settings_url ON zoom_settings(url);⏎̸
A migrations/003_add_zoom_levels.sql +15 -0
@@ -0,0 +1,15 @@
1+-- Add zoom_levels table to replace zoom_settings with spec requirements
2+-- Store zoom levels per domain (not full URL) with Firefox zoom constraints
3+
4+-- Drop existing zoom_settings table
5+DROP TABLE IF EXISTS zoom_settings;
6+
7+-- Create new zoom_levels table following spec requirements
8+CREATE TABLE zoom_levels (
9+ domain TEXT PRIMARY KEY,
10+ zoom_factor REAL NOT NULL DEFAULT 1.0 CHECK(zoom_factor >= 0.3 AND zoom_factor <= 5.0),
11+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
12+);
13+
14+-- Create index for performance on updated_at for cleanup operations
15+CREATE INDEX idx_zoom_levels_updated_at ON zoom_levels(updated_at);⏎̸
A scripts/generate-types.js +291 -0
@@ -0,0 +1,291 @@
1+#!/usr/bin/env node
2+
3+import { execSync } from 'child_process';
4+import { writeFileSync, readFileSync, existsSync, statSync, mkdirSync } from 'fs';
5+import { join, dirname } from 'path';
6+
7+/**
8+ * Generate TypeScript types from Go DTOs by parsing Go struct definitions
9+ * Watches for changes in Go files and regenerates automatically
10+ */
11+
12+const GO_MODELS_PATH = 'internal/db/';
13+const GO_PARSER_PATH = 'internal/parser/';
14+const TS_OUTPUT_PATH = 'frontend/src/types/generated.ts';
15+
16+// Helper function to convert Go types to TypeScript
17+function convertGoTypeToTS(goType) {
18+ const cleanType = goType.trim().replace(/^\*/, ''); // Remove pointer indicator
19+
20+ // Handle Go slices: []Type -> Type[]
21+ if (cleanType.startsWith('[]')) {
22+ const elementType = cleanType.slice(2); // Remove []
23+ return convertGoTypeToTS(elementType) + '[]';
24+ }
25+
26+ // Handle sql.Null types
27+ if (cleanType.includes('sql.NullString')) return 'string | null';
28+ if (cleanType.includes('sql.NullInt64')) return 'number | null';
29+ if (cleanType.includes('sql.NullTime')) return 'string | null';
30+ if (cleanType.includes('sql.NullBool')) return 'boolean | null';
31+ if (cleanType.includes('sql.NullFloat64')) return 'number | null';
32+
33+ // Handle Go package prefixes
34+ if (cleanType.includes('*db.History')) return 'History | null';
35+ if (cleanType.includes('db.History')) return 'History';
36+
37+ if (cleanType.includes('time.Time')) return 'string'; // ISO date strings
38+ if (cleanType.includes('time.Duration')) return 'number'; // Nanoseconds
39+ if (cleanType.includes('map[string]interface{}')) return 'Record<string, unknown>';
40+ if (cleanType.includes('map[string]string')) return 'Record<string, string>';
41+ if (cleanType.includes('map[string]any')) return 'Record<string, unknown>';
42+ if (cleanType.includes('string')) return 'string';
43+ if (cleanType.includes('int') || cleanType.includes('float')) return 'number';
44+ if (cleanType.includes('bool')) return 'boolean';
45+ if (cleanType.match(/^[A-Z][a-zA-Z0-9]*$/)) return cleanType; // Custom struct types
46+
47+ return 'unknown'; // Fallback
48+}
49+
50+// Parse Go struct to extract JSON field names and types
51+function parseGoStruct(goCode, structName) {
52+ // More sophisticated regex to handle nested braces in type definitions
53+ const structStart = new RegExp(`type\\s+${structName}\\s+struct\\s*{`, 's');
54+ const startMatch = goCode.match(structStart);
55+
56+ if (!startMatch) return null;
57+
58+ // Find the matching closing brace by counting braces
59+ const startIndex = startMatch.index + startMatch[0].length;
60+ let braceCount = 1;
61+ let endIndex = startIndex;
62+
63+ for (let i = startIndex; i < goCode.length && braceCount > 0; i++) {
64+ if (goCode[i] === '{') braceCount++;
65+ if (goCode[i] === '}') braceCount--;
66+ endIndex = i;
67+ }
68+
69+ if (braceCount !== 0) return null; // Unmatched braces
70+
71+ const structContent = goCode.substring(startIndex, endIndex);
72+ const match = [null, structContent]; // Simulate regex match format
73+
74+ if (!match) return null;
75+
76+ // First, reconstruct the struct content by merging lines that belong together
77+ const rawContent = match[1];
78+ const lines = rawContent.split('\n').map(line => line.trim()).filter(line => line && !line.startsWith('//'));
79+
80+ // Merge multi-line field definitions
81+ const mergedFields = [];
82+ let currentField = '';
83+
84+ for (const line of lines) {
85+ // If line starts with a field name (capital letter), it's a new field
86+ if (/^\w+\s+/.test(line)) {
87+ // Save previous field if exists
88+ if (currentField) {
89+ mergedFields.push(currentField);
90+ }
91+ currentField = line;
92+ } else {
93+ // This line continues the previous field
94+ currentField += ' ' + line;
95+ }
96+ }
97+
98+ // Don't forget the last field
99+ if (currentField) {
100+ mergedFields.push(currentField);
101+ }
102+
103+ const fields = mergedFields.map(fieldLine => {
104+ // Check for embedded struct (no field name, just type)
105+ const embeddedMatch = fieldLine.match(/^([A-Z][a-zA-Z0-9]*)\s*$/);
106+ if (embeddedMatch) {
107+ const embeddedType = embeddedMatch[1];
108+ // Return a special marker for embedded structs
109+ return {
110+ embedded: true,
111+ type: embeddedType
112+ };
113+ }
114+
115+ // Parse: FieldName type `json:"field_name,omitempty"`
116+ const fieldMatch = fieldLine.match(/^(\w+)\s+([^`]+?)\s*`json:"([^"]+)".*?`/);
117+ if (!fieldMatch) return null;
118+
119+ const [, fieldName, goType, jsonTag] = fieldMatch;
120+ const jsonField = jsonTag.split(',')[0]; // Remove omitempty, etc.
121+ const isOptional = jsonTag.includes('omitempty') || jsonTag.includes(',omitempty') || goType.trim().startsWith('*');
122+
123+ // Convert Go types to TypeScript using helper function
124+ const tsType = convertGoTypeToTS(goType.trim());
125+
126+ return {
127+ name: jsonField,
128+ type: tsType,
129+ optional: isOptional
130+ };
131+ })
132+ .filter(Boolean);
133+
134+ // Handle embedded structs by resolving their fields
135+ const resolvedFields = [];
136+ for (const field of fields) {
137+ if (field.embedded) {
138+ // Find the embedded struct and get its fields
139+ const embeddedFields = parseGoStruct(goCode, field.type);
140+ if (embeddedFields) {
141+ resolvedFields.push(...embeddedFields);
142+ }
143+ } else {
144+ resolvedFields.push(field);
145+ }
146+ }
147+
148+ return resolvedFields;
149+}
150+
151+function generateTSInterface(structName, fields) {
152+ const interfaceFields = fields.map(field =>
153+ ` ${field.name}${field.optional ? '?' : ''}: ${field.type};`
154+ ).join('\n');
155+
156+ return `export interface ${structName} {\n${interfaceFields}\n}`;
157+}
158+
159+// Function to discover all struct names in Go code
160+function findAllStructs(goCode) {
161+ const structRegex = /type\s+([A-Z][a-zA-Z0-9]*)\s+struct\s*{/g;
162+ const structs = [];
163+ let match;
164+
165+ while ((match = structRegex.exec(goCode)) !== null) {
166+ structs.push(match[1]);
167+ }
168+
169+ return structs;
170+}
171+
172+// Function to find Go enums (const declarations)
173+function findGoEnums(goCode) {
174+ const enums = [];
175+
176+ // Look for InputType specifically
177+ if (goCode.includes('type InputType int')) {
178+ // Find the const block that contains InputType values
179+ const constRegex = /const\s*\(\s*([\s\S]*?)\)/;
180+ const matches = goCode.match(constRegex);
181+
182+ if (matches) {
183+ const constBody = matches[1];
184+ // Look for any identifier that starts with InputType
185+ const lines = constBody.split('\n');
186+ const values = [];
187+ let foundInputTypeBlock = false;
188+
189+ for (const line of lines) {
190+ const trimmedLine = line.trim();
191+
192+ // Skip comment lines
193+ if (trimmedLine.startsWith('//')) continue;
194+
195+ // Check if this line contains an InputType identifier
196+ const identifierMatch = trimmedLine.match(/^\s*(InputType\w+)/);
197+ if (identifierMatch) {
198+ values.push(identifierMatch[1]);
199+ foundInputTypeBlock = true;
200+ } else if (foundInputTypeBlock && trimmedLine === ')') {
201+ // End of const block
202+ break;
203+ }
204+ }
205+
206+ if (values.length > 0) {
207+ enums.push({ name: 'InputType', values });
208+ }
209+ }
210+ }
211+
212+ return enums;
213+}
214+
215+function generateTSEnum(enumName, values) {
216+ const enumValues = values.map((value, index) => ` ${value} = ${index}`).join(',\n');
217+ return `export enum ${enumName} {\n${enumValues}\n}`;
218+}
219+
220+function generateTypes() {
221+ console.log('🔧 Parsing Go structs and generating TypeScript types...');
222+
223+ const sourceFiles = [
224+ { path: GO_MODELS_PATH, files: ['models.go'] },
225+ { path: GO_PARSER_PATH, files: ['types.go'] }
226+ ];
227+
228+ let combinedTypes = `// GENERATED FROM GO FILES: SEE THE GENERATOR SCRIPT
229+// Auto-generated TypeScript types from Go structs
230+// Generated on: ${new Date().toISOString()}
231+// Generator script: scripts/generate-types.js
232+// Source files: internal/db/models.go, internal/parser/types.go
233+// Do not edit manually - run 'node scripts/generate-types.js' to regenerate
234+
235+`;
236+
237+ for (const { path, files } of sourceFiles) {
238+ for (const filename of files) {
239+ const filePath = join(path, filename);
240+ if (!existsSync(filePath)) continue;
241+
242+ const goCode = readFileSync(filePath, 'utf-8');
243+ console.log(` 📄 Processing ${filename}...`);
244+
245+ // Automatically discover all structs in this file
246+ const structsInFile = findAllStructs(goCode);
247+ const enumsInFile = findGoEnums(goCode);
248+
249+ // Generate enums first
250+ console.log(` Found ${enumsInFile.length} enums in ${filename}`);
251+ for (const enumDef of enumsInFile) {
252+ const tsEnum = generateTSEnum(enumDef.name, enumDef.values);
253+ combinedTypes += tsEnum + '\n\n';
254+ console.log(` ✓ Generated enum ${enumDef.name} with values: ${enumDef.values.join(', ')}`);
255+ }
256+
257+ for (const structName of structsInFile) {
258+ const fields = parseGoStruct(goCode, structName);
259+ if (fields && fields.length > 0) {
260+ const tsInterface = generateTSInterface(structName, fields);
261+ combinedTypes += tsInterface + '\n\n';
262+ console.log(` ✓ Generated ${structName}`);
263+ }
264+ }
265+ }
266+ }
267+
268+ // Add manual types that are hard to auto-generate
269+ combinedTypes += `// Manual types and enums
270+export enum InputType {
271+ InputTypeDirectURL = 0,
272+ InputTypeSearchShortcut = 1,
273+ InputTypeHistorySearch = 2,
274+ InputTypeFallbackSearch = 3
275+}
276+
277+// Type aliases for frontend usage
278+export type HistoryEntry = History;
279+export type SearchShortcut = Shortcut;
280+export type ZoomEntry = ZoomSetting;
281+`;
282+
283+ // Ensure output directory exists
284+ mkdirSync(dirname(TS_OUTPUT_PATH), { recursive: true });
285+
286+ writeFileSync(TS_OUTPUT_PATH, combinedTypes);
287+ console.log(`✅ TypeScript types generated at: ${TS_OUTPUT_PATH}`);
288+}
289+
290+// One-time generation
291+generateTypes();⏎̸
M services/browser_service.go +291 -7
@@ -3,17 +3,40 @@ package services
33 import (
44 "context"
55 "database/sql"
6+ "embed"
67 "fmt"
8+ "net/url"
79 "time"
810
11+ "github.com/wailsapp/wails/v3/pkg/application"
912 "github.com/bnema/dumber/internal/config"
1013 "github.com/bnema/dumber/internal/db"
1114 )
1215
16+// WindowTitleUpdater interface allows the service to update the window title
17+// This matches the Wails v3 WebviewWindow.SetTitle method signature that returns application.Window
18+type WindowTitleUpdater interface {
19+ SetTitle(title string) application.Window
20+}
21+
22+// ScriptInjector interface allows the service to inject JavaScript into the webview
23+type ScriptInjector interface {
24+ ExecJS(js string)
25+}
26+
1327 // BrowserService handles browser-related operations for Wails integration.
1428 type BrowserService struct {
15- config *config.Config
16- dbQueries *db.Queries
29+ config *config.Config
30+ dbQueries db.DatabaseQuerier
31+ windowTitleUpdater WindowTitleUpdater
32+ scriptInjector ScriptInjector
33+ initialURL string
34+ injectableScript string
35+}
36+
37+// ServiceName returns the service name for Wails frontend binding
38+func (s *BrowserService) ServiceName() string {
39+ return "BrowserService"
1740 }
1841
1942 // NavigationResult represents the result of a navigation operation.
@@ -37,13 +60,75 @@ type HistoryEntry struct {
3760 }
3861
3962 // NewBrowserService creates a new BrowserService instance.
40-func NewBrowserService(cfg *config.Config, queries *db.Queries) *BrowserService {
63+func NewBrowserService(cfg *config.Config, queries db.DatabaseQuerier) *BrowserService {
4164 return &BrowserService{
42- config: cfg,
43- dbQueries: queries,
65+ config: cfg,
66+ dbQueries: queries,
67+ windowTitleUpdater: nil,
68+ scriptInjector: nil,
69+ initialURL: "",
70+ injectableScript: "",
4471 }
4572 }
4673
74+// SetWindowTitleUpdater sets the window title updater interface
75+func (s *BrowserService) SetWindowTitleUpdater(updater WindowTitleUpdater) {
76+ s.windowTitleUpdater = updater
77+}
78+
79+// SetScriptInjector sets the script injector interface
80+func (s *BrowserService) SetScriptInjector(injector ScriptInjector) {
81+ s.scriptInjector = injector
82+}
83+
84+// LoadInjectableScript loads the minified injectable controls script from assets
85+func (s *BrowserService) LoadInjectableScript(assets embed.FS) error {
86+ scriptBytes, err := assets.ReadFile("frontend/dist/injected-controls.min.js")
87+ if err != nil {
88+ return fmt.Errorf("failed to load injectable script: %w", err)
89+ }
90+ s.injectableScript = string(scriptBytes)
91+ return nil
92+}
93+
94+// InjectControlScript injects the global controls script into the current page
95+func (s *BrowserService) InjectControlScript(ctx context.Context) error {
96+ if s.scriptInjector == nil {
97+ return fmt.Errorf("script injector not set")
98+ }
99+
100+ if s.injectableScript == "" {
101+ return fmt.Errorf("injectable script not loaded")
102+ }
103+
104+ // Wrap the script in a try-catch and DOM ready check for better compatibility
105+ wrappedScript := fmt.Sprintf(`
106+ (function() {
107+ try {
108+ // Check if DOM is ready
109+ if (document.readyState === 'loading') {
110+ document.addEventListener('DOMContentLoaded', function() {
111+ try {
112+ %s
113+ } catch (e) {
114+ console.warn('Dumber Browser controls failed to load on DOMContentLoaded:', e);
115+ }
116+ });
117+ } else {
118+ // DOM is already ready
119+ %s
120+ }
121+ } catch (e) {
122+ console.warn('Dumber Browser controls injection failed:', e);
123+ }
124+ })();
125+ `, s.injectableScript, s.injectableScript)
126+
127+ // Inject the wrapped script
128+ s.scriptInjector.ExecJS(wrappedScript)
129+ return nil
130+}
131+
47132 // Navigate handles navigation to a URL and records it in history.
48133 func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) {
49134 startTime := time.Now()
@@ -55,6 +140,11 @@ func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationR
55140 }, nil
56141 }
57142
143+ // Store the initial URL for frontend synchronization
144+ if s.initialURL == "" {
145+ s.initialURL = url
146+ }
147+
58148 // Record the navigation in history
59149 err := s.recordHistory(ctx, url, "")
60150 if err != nil {
@@ -69,7 +159,7 @@ func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationR
69159 }, nil
70160 }
71161
72-// UpdatePageTitle updates the title of the current page in history.
162+// UpdatePageTitle updates the title of the current page in history and the window title.
73163 func (s *BrowserService) UpdatePageTitle(ctx context.Context, url, title string) error {
74164 if url == "" || title == "" {
75165 return fmt.Errorf("URL and title cannot be empty")
@@ -77,7 +167,18 @@ func (s *BrowserService) UpdatePageTitle(ctx context.Context, url, title string)
77167
78168 // Use AddOrUpdateHistory to update the title
79169 titleNull := sql.NullString{String: title, Valid: true}
80- return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
170+ err := s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
171+ if err != nil {
172+ return err
173+ }
174+
175+ // Update the window title if we have a title updater
176+ if s.windowTitleUpdater != nil && title != "" {
177+ windowTitle := fmt.Sprintf("Dumber - %s", title)
178+ s.windowTitleUpdater.SetTitle(windowTitle)
179+ }
180+
181+ return nil
81182 }
82183
83184 // GetRecentHistory returns recent browser history entries.
@@ -202,4 +303,187 @@ func (s *BrowserService) recordHistory(ctx context.Context, url, title string) e
202303 }
203304
204305 return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
306+}
307+
308+// Firefox zoom levels: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%
309+var firefoxZoomLevels = []float64{0.3, 0.5, 0.67, 0.8, 0.9, 1.0, 1.1, 1.2, 1.33, 1.5, 1.7, 2.0, 2.4, 3.0, 4.0, 5.0}
310+
311+// ZoomIn increases the zoom level to the next Firefox zoom level.
312+func (s *BrowserService) ZoomIn(ctx context.Context, url string) (float64, error) {
313+ currentZoom, err := s.GetZoomLevel(ctx, url)
314+ if err != nil {
315+ return 1.0, err
316+ }
317+
318+ newZoom := s.getNextZoomLevel(currentZoom, true)
319+ err = s.SetZoomLevel(ctx, url, newZoom)
320+ if err != nil {
321+ return currentZoom, err
322+ }
323+
324+ return newZoom, nil
325+}
326+
327+// ZoomOut decreases the zoom level to the previous Firefox zoom level.
328+func (s *BrowserService) ZoomOut(ctx context.Context, url string) (float64, error) {
329+ currentZoom, err := s.GetZoomLevel(ctx, url)
330+ if err != nil {
331+ return 1.0, err
332+ }
333+
334+ newZoom := s.getNextZoomLevel(currentZoom, false)
335+ err = s.SetZoomLevel(ctx, url, newZoom)
336+ if err != nil {
337+ return currentZoom, err
338+ }
339+
340+ return newZoom, nil
341+}
342+
343+// ResetZoom resets the zoom level to 1.0 for the current URL.
344+func (s *BrowserService) ResetZoom(ctx context.Context, url string) (float64, error) {
345+ return s.setZoom(ctx, url, 1.0)
346+}
347+
348+// GetZoomLevel retrieves the saved zoom level for a URL.
349+func (s *BrowserService) GetZoomLevel(ctx context.Context, url string) (float64, error) {
350+ if url == "" {
351+ return 1.0, nil
352+ }
353+
354+ // Extract domain for consistent storage across pages of the same site
355+ domain := s.extractDomain(url)
356+
357+ zoomLevel, err := s.dbQueries.GetZoomLevel(ctx, domain)
358+ if err != nil {
359+ if err == sql.ErrNoRows {
360+ // No zoom setting found, return default
361+ return 1.0, nil
362+ }
363+ return 1.0, err
364+ }
365+
366+ return zoomLevel, nil
367+}
368+
369+// SetZoomLevel sets the zoom level for a URL.
370+func (s *BrowserService) SetZoomLevel(ctx context.Context, url string, zoomLevel float64) error {
371+ if url == "" {
372+ return fmt.Errorf("URL cannot be empty")
373+ }
374+
375+ // Clamp zoom level to reasonable bounds
376+ if zoomLevel < 0.25 {
377+ zoomLevel = 0.25
378+ } else if zoomLevel > 5.0 {
379+ zoomLevel = 5.0
380+ }
381+
382+ domain := s.extractDomain(url)
383+ return s.dbQueries.SetZoomLevel(ctx, domain, zoomLevel)
384+}
385+
386+// GetCurrentURL returns the current URL (this would be implemented by the frontend)
387+func (s *BrowserService) GetCurrentURL(ctx context.Context) (string, error) {
388+ // This would be implemented by getting the current URL from the webview
389+ // For now, return empty as this will be called by the frontend
390+ return "", nil
391+}
392+
393+// GetInitialURL returns the initially navigated URL for frontend synchronization
394+func (s *BrowserService) GetInitialURL(ctx context.Context) (string, error) {
395+ return s.initialURL, nil
396+}
397+
398+// CopyCurrentURL copies the current URL to clipboard (frontend-initiated)
399+func (s *BrowserService) CopyCurrentURL(ctx context.Context, url string) error {
400+ // This is primarily for logging/recording the copy action
401+ // The actual clipboard operation is handled by the frontend
402+ if url != "" {
403+ // Could record this action in analytics if needed
404+ fmt.Printf("URL copied to clipboard: %s\n", url)
405+ }
406+ return nil
407+}
408+
409+// GoBack provides navigation back functionality
410+func (s *BrowserService) GoBack(ctx context.Context) error {
411+ // This will be handled by the frontend's webview
412+ // Backend just logs the action
413+ fmt.Println("Navigation: Go back")
414+ return nil
415+}
416+
417+// GoForward provides navigation forward functionality
418+func (s *BrowserService) GoForward(ctx context.Context) error {
419+ // This will be handled by the frontend's webview
420+ // Backend just logs the action
421+ fmt.Println("Navigation: Go forward")
422+ return nil
423+}
424+
425+// getNextZoomLevel returns the next Firefox zoom level in the specified direction
426+func (s *BrowserService) getNextZoomLevel(currentZoom float64, zoomIn bool) float64 {
427+ // Find the closest current zoom level
428+ closestIndex := 0
429+ minDiff := 10.0 // Large initial value
430+
431+ for i, level := range firefoxZoomLevels {
432+ diff := currentZoom - level
433+ if diff < 0 {
434+ diff = -diff // abs value
435+ }
436+ if diff < minDiff {
437+ minDiff = diff
438+ closestIndex = i
439+ }
440+ }
441+
442+ if zoomIn {
443+ // Move to next higher zoom level
444+ if closestIndex < len(firefoxZoomLevels)-1 {
445+ return firefoxZoomLevels[closestIndex+1]
446+ }
447+ return firefoxZoomLevels[closestIndex] // Already at max
448+ } else {
449+ // Move to next lower zoom level
450+ if closestIndex > 0 {
451+ return firefoxZoomLevels[closestIndex-1]
452+ }
453+ return firefoxZoomLevels[closestIndex] // Already at min
454+ }
455+}
456+
457+// setZoom sets the zoom level to a specific value
458+func (s *BrowserService) setZoom(ctx context.Context, url string, zoomLevel float64) (float64, error) {
459+ err := s.SetZoomLevel(ctx, url, zoomLevel)
460+ if err != nil {
461+ return 1.0, err
462+ }
463+ return zoomLevel, nil
464+}
465+
466+// extractDomain extracts the domain from a URL for zoom level storage
467+// Domain-based zoom allows settings to persist across different pages of the same site
468+func (s *BrowserService) extractDomain(rawURL string) string {
469+ if rawURL == "" {
470+ return ""
471+ }
472+
473+ // Parse the URL to extract the domain
474+ parsedURL, err := url.Parse(rawURL)
475+ if err != nil {
476+ // If URL parsing fails, use the raw URL as fallback
477+ // This handles edge cases like local files or malformed URLs
478+ return rawURL
479+ }
480+
481+ // Return the hostname (domain) for consistent storage
482+ domain := parsedURL.Hostname()
483+ if domain == "" {
484+ // Fallback to the original URL if no hostname can be extracted
485+ return rawURL
486+ }
487+
488+ return domain
205489 }⏎̸
A specs/002-browser-controls-ui/contracts/browser-controls-api.yaml +258 -0
@@ -0,0 +1,258 @@
1+openapi: 3.0.3
2+info:
3+ title: Browser Controls API
4+ description: Wails backend API for browser keyboard and mouse controls
5+ version: 0.1.0
6+
7+paths:
8+ /zoom/in:
9+ post:
10+ summary: Zoom in to next higher level
11+ description: Increases zoom level to next Firefox standard zoom level (FR-001)
12+ requestBody:
13+ required: true
14+ content:
15+ application/json:
16+ schema:
17+ type: object
18+ properties:
19+ domain:
20+ type: string
21+ description: Current website domain for persistence
22+ example: "github.com"
23+ required:
24+ - domain
25+ responses:
26+ '200':
27+ description: Zoom level successfully increased
28+ content:
29+ application/json:
30+ schema:
31+ $ref: '#/components/schemas/ZoomResponse'
32+ '400':
33+ description: Invalid domain or already at maximum zoom
34+ content:
35+ application/json:
36+ schema:
37+ $ref: '#/components/schemas/ErrorResponse'
38+
39+ /zoom/out:
40+ post:
41+ summary: Zoom out to next lower level
42+ description: Decreases zoom level to next Firefox standard zoom level (FR-002)
43+ requestBody:
44+ required: true
45+ content:
46+ application/json:
47+ schema:
48+ type: object
49+ properties:
50+ domain:
51+ type: string
52+ description: Current website domain for persistence
53+ example: "stackoverflow.com"
54+ required:
55+ - domain
56+ responses:
57+ '200':
58+ description: Zoom level successfully decreased
59+ content:
60+ application/json:
61+ schema:
62+ $ref: '#/components/schemas/ZoomResponse'
63+ '400':
64+ description: Invalid domain or already at minimum zoom
65+ content:
66+ application/json:
67+ schema:
68+ $ref: '#/components/schemas/ErrorResponse'
69+
70+ /navigation/back:
71+ post:
72+ summary: Navigate to previous page in history
73+ description: Navigates to previous page if history exists (FR-003, FR-010)
74+ responses:
75+ '200':
76+ description: Successfully navigated back
77+ content:
78+ application/json:
79+ schema:
80+ $ref: '#/components/schemas/NavigationResponse'
81+ '204':
82+ description: No history available, no action taken
83+ '500':
84+ description: Navigation error
85+ content:
86+ application/json:
87+ schema:
88+ $ref: '#/components/schemas/ErrorResponse'
89+
90+ /navigation/forward:
91+ post:
92+ summary: Navigate to next page in history
93+ description: Navigates to next page if forward history exists (FR-004, FR-010)
94+ responses:
95+ '200':
96+ description: Successfully navigated forward
97+ content:
98+ application/json:
99+ schema:
100+ $ref: '#/components/schemas/NavigationResponse'
101+ '204':
102+ description: No forward history available, no action taken
103+ '500':
104+ description: Navigation error
105+ content:
106+ application/json:
107+ schema:
108+ $ref: '#/components/schemas/ErrorResponse'
109+
110+ /clipboard/copy-url:
111+ post:
112+ summary: Copy current URL to system clipboard
113+ description: Copies current page URL using wlcopy/xclip/xsel fallback (FR-005, FR-006, FR-011)
114+ requestBody:
115+ required: true
116+ content:
117+ application/json:
118+ schema:
119+ type: object
120+ properties:
121+ url:
122+ type: string
123+ format: uri
124+ description: Current page URL to copy
125+ example: "https://github.com/bnema/dumber"
126+ required:
127+ - url
128+ responses:
129+ '200':
130+ description: URL successfully copied to clipboard
131+ content:
132+ application/json:
133+ schema:
134+ type: object
135+ properties:
136+ success:
137+ type: boolean
138+ example: true
139+ tool_used:
140+ type: string
141+ enum: ["wlcopy", "xclip", "xsel"]
142+ example: "wlcopy"
143+ message:
144+ type: string
145+ example: "URL copied to clipboard using wlcopy"
146+ '500':
147+ description: All clipboard tools failed
148+ content:
149+ application/json:
150+ schema:
151+ $ref: '#/components/schemas/ErrorResponse'
152+
153+ /zoom/get-level:
154+ get:
155+ summary: Get current zoom level for domain
156+ description: Retrieves persisted zoom level for specific domain (FR-009)
157+ parameters:
158+ - name: domain
159+ in: query
160+ required: true
161+ schema:
162+ type: string
163+ description: Website domain to query
164+ example: "reddit.com"
165+ responses:
166+ '200':
167+ description: Zoom level retrieved
168+ content:
169+ application/json:
170+ schema:
171+ $ref: '#/components/schemas/ZoomResponse'
172+ '404':
173+ description: No zoom level stored for domain (defaults to 100%)
174+ content:
175+ application/json:
176+ schema:
177+ $ref: '#/components/schemas/ZoomResponse'
178+
179+components:
180+ schemas:
181+ ZoomResponse:
182+ type: object
183+ properties:
184+ zoom_factor:
185+ type: number
186+ format: float
187+ description: Current zoom level as decimal (1.0 = 100%)
188+ minimum: 0.3
189+ maximum: 5.0
190+ example: 1.2
191+ zoom_percentage:
192+ type: integer
193+ description: Zoom level as percentage for UI display
194+ minimum: 30
195+ maximum: 500
196+ example: 120
197+ is_firefox_level:
198+ type: boolean
199+ description: Whether zoom level matches Firefox standard levels
200+ example: true
201+ domain:
202+ type: string
203+ description: Domain this zoom level applies to
204+ example: "github.com"
205+
206+ NavigationResponse:
207+ type: object
208+ properties:
209+ url:
210+ type: string
211+ format: uri
212+ description: Target URL to navigate to
213+ example: "https://previous-page.com"
214+ title:
215+ type: string
216+ description: Page title if available
217+ example: "Previous Page Title"
218+ history_index:
219+ type: integer
220+ description: New position in history after navigation
221+ example: 2
222+ can_go_back:
223+ type: boolean
224+ description: Whether back navigation is still available
225+ example: true
226+ can_go_forward:
227+ type: boolean
228+ description: Whether forward navigation is available
229+ example: false
230+
231+ ErrorResponse:
232+ type: object
233+ properties:
234+ error:
235+ type: string
236+ description: Human-readable error message
237+ example: "Already at maximum zoom level"
238+ error_code:
239+ type: string
240+ description: Machine-readable error identifier
241+ example: "MAX_ZOOM_REACHED"
242+ timestamp:
243+ type: string
244+ format: date-time
245+ description: Error occurrence timestamp
246+ example: "2025-09-09T10:30:00Z"
247+
248+# Wails Service Methods (Go Implementation)
249+# These correspond to the HTTP API but are called directly via Wails context:
250+#
251+# type BrowserControlService struct{}
252+#
253+# func (s *BrowserControlService) ZoomIn(ctx context.Context, domain string) (*ZoomResponse, error)
254+# func (s *BrowserControlService) ZoomOut(ctx context.Context, domain string) (*ZoomResponse, error)
255+# func (s *BrowserControlService) NavigateBack(ctx context.Context) (*NavigationResponse, error)
256+# func (s *BrowserControlService) NavigateForward(ctx context.Context) (*NavigationResponse, error)
257+# func (s *BrowserControlService) CopyCurrentURL(ctx context.Context, url string) (*ClipboardResponse, error)
258+# func (s *BrowserControlService) GetZoomLevel(ctx context.Context, domain string) (*ZoomResponse, error)⏎̸
A specs/002-browser-controls-ui/data-model.md +165 -0
@@ -0,0 +1,165 @@
1+# Data Model: Browser Controls UI
2+
3+## Entities
4+
5+### 1. ZoomLevel
6+Represents domain-specific zoom preferences for persistent user experience.
7+
8+**Fields**:
9+- `Domain` (string, primary key): Website domain (e.g., "github.com", "stackoverflow.com")
10+- `ZoomFactor` (float64): Zoom multiplier (0.3 to 5.0 range, default 1.0)
11+- `UpdatedAt` (time.Time): Timestamp of last zoom level change
12+
13+**Validation Rules**:
14+- Domain must be valid hostname format
15+- ZoomFactor must be within Firefox range: 0.30 ≤ zoom ≤ 5.00
16+- ZoomFactor must match standard Firefox zoom levels (30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%)
17+- UpdatedAt automatically set on creation/update
18+
19+**Relationships**:
20+- One-to-one with website domains
21+- No foreign key relationships (standalone entity)
22+
23+### 2. BrowserState (Extended)
24+Existing entity extended with zoom and navigation state management.
25+
26+**New Fields**:
27+- `CurrentZoomLevel` (float64): Active zoom level for current page
28+- `NavigationHistory` ([]HistoryEntry): Back/forward navigation stack
29+- `HistoryIndex` (int): Current position in navigation history
30+
31+**State Transitions**:
32+- Zoom In: CurrentZoomLevel → Next higher Firefox zoom level
33+- Zoom Out: CurrentZoomLevel → Next lower Firefox zoom level
34+- Navigate Back: HistoryIndex decrements (if > 0)
35+- Navigate Forward: HistoryIndex increments (if < len(NavigationHistory)-1)
36+- URL Change: Update CurrentZoomLevel from ZoomLevel persistence
37+
38+**Validation Rules**:
39+- CurrentZoomLevel follows same rules as ZoomLevel.ZoomFactor
40+- HistoryIndex must be valid array index (0 ≤ index < len(NavigationHistory))
41+- NavigationHistory maintained as bounded array (configurable max size)
42+
43+### 3. KeyboardEvent (Transient)
44+Represents keyboard input events for processing.
45+
46+**Fields**:
47+- `Key` (string): Key identifier ("=", "-", "c")
48+- `Modifiers` ([]string): Active modifier keys (["ctrl"], ["ctrl", "shift"])
49+- `Timestamp` (time.Time): Event occurrence time
50+
51+**Validation Rules**:
52+- Key must be non-empty string
53+- Modifiers must be valid modifier keys ("ctrl", "shift", "alt", "meta")
54+- Timestamp must be recent (within reasonable event processing window)
55+
56+**State Transitions**:
57+- Ctrl+Plus → ZoomIn action
58+- Ctrl+Minus → ZoomOut action
59+- Ctrl+Shift+C → CopyURL action
60+
61+### 4. MouseEvent (Transient)
62+Represents mouse button events for navigation.
63+
64+**Fields**:
65+- `Button` (int): Mouse button identifier (3=back, 4=forward)
66+- `EventType` (string): Event type ("mousedown", "mouseup")
67+- `Timestamp` (time.Time): Event occurrence time
68+
69+**Validation Rules**:
70+- Button must be 3 (back) or 4 (forward)
71+- EventType must be valid mouse event type
72+- Only "mousedown" events trigger navigation actions
73+
74+**State Transitions**:
75+- Button 3 mousedown → NavigateBack action
76+- Button 4 mousedown → NavigateForward action
77+
78+## Database Schema Changes
79+
80+### New Table: zoom_levels
81+```sql
82+CREATE TABLE zoom_levels (
83+ domain TEXT PRIMARY KEY,
84+ zoom_factor REAL NOT NULL DEFAULT 1.0 CHECK(zoom_factor >= 0.3 AND zoom_factor <= 5.0),
85+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
86+);
87+
88+CREATE INDEX idx_zoom_levels_updated_at ON zoom_levels(updated_at);
89+```
90+
91+### SQLC Queries
92+```sql
93+-- name: GetZoomLevel :one
94+SELECT zoom_factor FROM zoom_levels WHERE domain = ?;
95+
96+-- name: SetZoomLevel :exec
97+INSERT OR REPLACE INTO zoom_levels (domain, zoom_factor, updated_at)
98+VALUES (?, ?, CURRENT_TIMESTAMP);
99+
100+-- name: CleanupOldZoomLevels :exec
101+DELETE FROM zoom_levels WHERE updated_at < date('now', '-30 days');
102+```
103+
104+## Data Flow
105+
106+### Zoom Operations
107+1. User triggers zoom event → JavaScript captures keyboard event
108+2. Frontend calls backend `ZoomIn()` or `ZoomOut()` method
109+3. Backend calculates next valid Firefox zoom level
110+4. Backend updates BrowserState.CurrentZoomLevel
111+5. Backend persists to database via `SetZoomLevel`
112+6. Frontend applies CSS zoom styling
113+7. Optional: Visual feedback notification
114+
115+### Navigation Operations
116+1. User triggers mouse back/forward → JavaScript captures mouse event
117+2. Frontend calls backend `NavigateBack()` or `NavigateForward()` method
118+3. Backend validates navigation history bounds
119+4. Backend updates BrowserState.HistoryIndex
120+5. Backend returns target URL from history
121+6. Frontend navigates WebView to target URL
122+
123+### URL Copy Operation
124+1. User triggers Ctrl+Shift+C → JavaScript captures keyboard event
125+2. Frontend calls backend `CopyCurrentURL()` method
126+3. Backend retrieves current URL from BrowserState
127+4. Backend executes clipboard tool chain (wlcopy → xclip → xsel)
128+5. Backend returns success/failure status
129+6. Optional: Visual feedback notification
130+
131+## Validation Strategy
132+
133+### Input Validation
134+- All user inputs validated at service layer entry points
135+- Zoom levels constrained to Firefox standard values
136+- Navigation bounds checking prevents array index errors
137+- URL validation for copy operations
138+
139+### Data Integrity
140+- SQLite constraints enforce zoom level ranges
141+- Database transactions for atomic state updates
142+- Error handling for clipboard tool failures
143+- Graceful degradation when tools unavailable
144+
145+### Performance Considerations
146+- Zoom level persistence debounced to prevent excessive DB writes
147+- Navigation history bounded to prevent memory growth
148+- Database cleanup for old zoom preferences
149+- Prepared statements for repeated queries
150+
151+## Error Handling
152+
153+### Domain Errors
154+- Invalid zoom level → clamp to nearest valid value
155+- Empty navigation history → silent no-op
156+- Invalid clipboard tool → try next in fallback chain
157+- Malformed URL → log error, return failure status
158+
159+### System Errors
160+- Database unavailable → continue with in-memory state only
161+- Clipboard tools unavailable → user notification, graceful degradation
162+- WebView navigation failure → restore previous state
163+- Event processing errors → log and continue
164+
165+All data models maintain constitutional principles of simplicity and performance while providing robust user experience for browser control operations.⏎̸
A specs/002-browser-controls-ui/plan.md +237 -0
@@ -0,0 +1,237 @@
1+# Implementation Plan: Browser Controls UI
2+
3+**Branch**: `002-browser-controls-ui` | **Date**: 2025-09-09 | **Spec**: [spec.md](./spec.md)
4+**Input**: Feature specification from `/home/brice/dev/projects/dumb-browser/specs/002-browser-controls-ui/spec.md`
5+
6+## Execution Flow (/plan command scope)
7+```
8+1. Load feature spec from Input path
9+ → If not found: ERROR "No feature spec at {path}"
10+2. Fill Technical Context (scan for NEEDS CLARIFICATION)
11+ → Detect Project Type from context (web=frontend+backend, mobile=app+api)
12+ → Set Structure Decision based on project type
13+3. Evaluate Constitution Check section below
14+ → If violations exist: Document in Complexity Tracking
15+ → If no justification possible: ERROR "Simplify approach first"
16+ → Update Progress Tracking: Initial Constitution Check
17+4. Execute Phase 0 → research.md
18+ → If NEEDS CLARIFICATION remain: ERROR "Resolve unknowns"
19+5. Execute Phase 1 → contracts, data-model.md, quickstart.md, agent-specific template file (e.g., `CLAUDE.md` for Claude Code, `.github/copilot-instructions.md` for GitHub Copilot, or `GEMINI.md` for Gemini CLI).
20+6. Re-evaluate Constitution Check section
21+ → If new violations: Refactor design, return to Phase 1
22+ → Update Progress Tracking: Post-Design Constitution Check
23+7. Plan Phase 2 → Describe task generation approach (DO NOT create tasks.md)
24+8. STOP - Ready for /tasks command
25+```
26+
27+**IMPORTANT**: The /plan command STOPS at step 7. Phases 2-4 are executed by other commands:
28+- Phase 2: /tasks command creates tasks.md
29+- Phase 3-4: Implementation execution (manual or via tools)
30+
31+## Summary
32+Implement keyboard and mouse navigation controls for the Wails-based browser: zoom in/out with Ctrl+/-, back/forward navigation with mouse buttons, URL copying with Ctrl+Shift+C, and dynamic window title updates. This feature enhances user experience with standard browser controls while maintaining the application's speed-first principle and single-binary architecture.
33+
34+## Technical Context
35+**Language/Version**: Go 1.25.1
36+**Primary Dependencies**: Wails v3-alpha.28 (WebView), Cobra (CLI), Viper (config), SQLite3
37+**Storage**: SQLite3 database for history/zoom persistence
38+**Testing**: Go standard testing, manual verification with keyboard/mouse controls
39+**Target Platform**: Linux desktop (Wayland native, tiling WM compatible)
40+**Project Type**: single (desktop application with embedded frontend)
41+**Performance Goals**: <500ms startup, <100MB baseline memory, <50ms history search
42+**Constraints**: Single binary, no external runtime deps, WebKit2GTK system integration
43+**Scale/Scope**: Single user desktop app, ~12 functional requirements, 5 new keyboard/mouse controls
44+
45+## Constitution Check
46+*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
47+
48+**Simplicity**:
49+- Projects: 1 (single desktop app with embedded frontend) ✓
50+- Using framework directly? Yes (direct Wails v3 integration, no wrappers) ✓
51+- Single data model? Yes (existing browser state, minimal zoom state addition) ✓
52+- Avoiding patterns? Yes (direct service calls, no unnecessary abstractions) ✓
53+
54+**Architecture**:
55+- EVERY feature as library? Yes (keyboard/mouse controls as separate service methods) ✓
56+- Libraries listed: BrowserService (navigation/zoom), ConfigService (persistence)
57+- CLI per library: Existing CLI structure maintained, no new commands needed ✓
58+- Library docs: N/A (internal desktop app, not library distribution)
59+
60+**Testing (NON-NEGOTIABLE)**:
61+- RED-GREEN-Refactor cycle enforced? Yes (will create failing tests first) ✓
62+- Git commits show tests before implementation? Yes (test commits before feature commits) ✓
63+- Order: Contract→Integration→E2E→Unit strictly followed? Yes ✓
64+- Real dependencies used? Yes (real SQLite, real Wails WebView) ✓
65+- Integration tests for: Keyboard event handling, zoom persistence, clipboard integration ✓
66+- FORBIDDEN: Implementation before test, skipping RED phase ✓
67+
68+**Observability**:
69+- Structured logging included? Yes (existing slog usage maintained) ✓
70+- Frontend logs → backend? Yes (Wails provides unified logging) ✓
71+- Error context sufficient? Yes (proper Go error handling) ✓
72+
73+**Versioning**:
74+- Version number assigned? Using existing versioning (0.1.0 → 0.1.1) ✓
75+- BUILD increments on every change? Yes (patch version bump) ✓
76+- Breaking changes handled? None expected (additive feature only) ✓
77+
78+## Project Structure
79+
80+### Documentation (this feature)
81+```
82+specs/[###-feature]/
83+├── plan.md # This file (/plan command output)
84+├── research.md # Phase 0 output (/plan command)
85+├── data-model.md # Phase 1 output (/plan command)
86+├── quickstart.md # Phase 1 output (/plan command)
87+├── contracts/ # Phase 1 output (/plan command)
88+└── tasks.md # Phase 2 output (/tasks command - NOT created by /plan)
89+```
90+
91+### Source Code (repository root)
92+```
93+# Option 1: Single project (DEFAULT)
94+src/
95+├── models/
96+├── services/
97+├── cli/
98+└── lib/
99+
100+tests/
101+├── contract/
102+├── integration/
103+└── unit/
104+
105+# Option 2: Web application (when "frontend" + "backend" detected)
106+backend/
107+├── src/
108+│ ├── models/
109+│ ├── services/
110+│ └── api/
111+└── tests/
112+
113+frontend/
114+├── src/
115+│ ├── components/
116+│ ├── pages/
117+│ └── services/
118+└── tests/
119+
120+# Option 3: Mobile + API (when "iOS/Android" detected)
121+api/
122+└── [same as backend above]
123+
124+ios/ or android/
125+└── [platform-specific structure]
126+```
127+
128+**Structure Decision**: Option 1 (Single project) - Desktop application with embedded frontend, existing structure maintained
129+
130+## Phase 0: Outline & Research
131+1. **Extract unknowns from Technical Context** above:
132+ - For each NEEDS CLARIFICATION → research task
133+ - For each dependency → best practices task
134+ - For each integration → patterns task
135+
136+2. **Generate and dispatch research agents**:
137+ ```
138+ For each unknown in Technical Context:
139+ Task: "Research {unknown} for {feature context}"
140+ For each technology choice:
141+ Task: "Find best practices for {tech} in {domain}"
142+ ```
143+
144+3. **Consolidate findings** in `research.md` using format:
145+ - Decision: [what was chosen]
146+ - Rationale: [why chosen]
147+ - Alternatives considered: [what else evaluated]
148+
149+**Output**: research.md with all NEEDS CLARIFICATION resolved
150+
151+## Phase 1: Design & Contracts
152+*Prerequisites: research.md complete*
153+
154+1. **Extract entities from feature spec** → `data-model.md`:
155+ - Entity name, fields, relationships
156+ - Validation rules from requirements
157+ - State transitions if applicable
158+
159+2. **Generate API contracts** from functional requirements:
160+ - For each user action → endpoint
161+ - Use standard REST/GraphQL patterns
162+ - Output OpenAPI/GraphQL schema to `/contracts/`
163+
164+3. **Generate contract tests** from contracts:
165+ - One test file per endpoint
166+ - Assert request/response schemas
167+ - Tests must fail (no implementation yet)
168+
169+4. **Extract test scenarios** from user stories:
170+ - Each story → integration test scenario
171+ - Quickstart test = story validation steps
172+
173+5. **Update agent file incrementally** (O(1) operation):
174+ - Run `/scripts/update-agent-context.sh [claude|gemini|copilot]` for your AI assistant
175+ - If exists: Add only NEW tech from current plan
176+ - Preserve manual additions between markers
177+ - Update recent changes (keep last 3)
178+ - Keep under 150 lines for token efficiency
179+ - Output to repository root
180+
181+**Output**: data-model.md, /contracts/*, failing tests, quickstart.md, agent-specific file
182+
183+## Phase 2: Task Planning Approach
184+*This section describes what the /tasks command will do - DO NOT execute during /plan*
185+
186+**Task Generation Strategy**:
187+- Load `/templates/tasks-template.md` as base
188+- Generate tasks from Phase 1 design docs (contracts, data model, quickstart)
189+- Each contract → contract test task [P]
190+- Each entity → model creation task [P]
191+- Each user story → integration test task
192+- Implementation tasks to make tests pass
193+
194+**Ordering Strategy**:
195+- TDD order: Tests before implementation
196+- Dependency order: Models before services before UI
197+- Mark [P] for parallel execution (independent files)
198+
199+**Estimated Output**: 25-30 numbered, ordered tasks in tasks.md
200+
201+**IMPORTANT**: This phase is executed by the /tasks command, NOT by /plan
202+
203+## Phase 3+: Future Implementation
204+*These phases are beyond the scope of the /plan command*
205+
206+**Phase 3**: Task execution (/tasks command creates tasks.md)
207+**Phase 4**: Implementation (execute tasks.md following constitutional principles)
208+**Phase 5**: Validation (run tests, execute quickstart.md, performance validation)
209+
210+## Complexity Tracking
211+*Fill ONLY if Constitution Check has violations that must be justified*
212+
213+| Violation | Why Needed | Simpler Alternative Rejected Because |
214+|-----------|------------|-------------------------------------|
215+| [e.g., 4th project] | [current need] | [why 3 projects insufficient] |
216+| [e.g., Repository pattern] | [specific problem] | [why direct DB access insufficient] |
217+
218+
219+## Progress Tracking
220+*This checklist is updated during execution flow*
221+
222+**Phase Status**:
223+- [x] Phase 0: Research complete (/plan command)
224+- [x] Phase 1: Design complete (/plan command)
225+- [x] Phase 2: Task planning complete (/plan command - describe approach only)
226+- [ ] Phase 3: Tasks generated (/tasks command)
227+- [ ] Phase 4: Implementation complete
228+- [ ] Phase 5: Validation passed
229+
230+**Gate Status**:
231+- [x] Initial Constitution Check: PASS
232+- [x] Post-Design Constitution Check: PASS
233+- [x] All NEEDS CLARIFICATION resolved
234+- [x] Complexity deviations documented (none required)
235+
236+---
237+*Based on Constitution v2.1.1 - See `/memory/constitution.md`*⏎̸
A specs/002-browser-controls-ui/quickstart.md +184 -0
@@ -0,0 +1,184 @@
1+# Quickstart: Browser Controls UI
2+
3+## Quick Validation Steps
4+
5+### Prerequisites
6+- Dumber browser built and running
7+- SQLite database initialized
8+- WebView displaying a webpage
9+- Linux system with clipboard tools (wlcopy/xclip/xsel)
10+
11+### Test Scenario 1: Zoom Controls (FR-001, FR-002, FR-007, FR-008)
12+**Goal**: Verify zoom in/out functionality with visual feedback
13+
14+1. **Setup**: Navigate to any webpage (e.g., `dumber browse github.com`)
15+2. **Test Zoom In**:
16+ - Press `Ctrl` + `+` (plus key)
17+ - **Expected**: Page content becomes larger, visual feedback shown
18+ - **Verify**: Zoom level increases from 100% to 110%
19+3. **Test Zoom Out**:
20+ - Press `Ctrl` + `-` (minus key)
21+ - **Expected**: Page content becomes smaller
22+ - **Verify**: Zoom level decreases to 100%
23+4. **Test Zoom Limits**:
24+ - Press `Ctrl` + `-` repeatedly until 30% reached
25+ - Press `Ctrl` + `-` again
26+ - **Expected**: No further zoom reduction, remains at 30%
27+ - Repeat for upper limit (500%)
28+
29+**Pass Criteria**: ✅ Zoom changes are visible and bounded correctly
30+
31+### Test Scenario 2: Navigation Controls (FR-003, FR-004, FR-010)
32+**Goal**: Verify mouse back/forward button functionality
33+
34+1. **Setup**: Navigate to multiple pages to build history:
35+ - `dumber browse github.com`
36+ - Navigate to `github.com/bnema`
37+ - Navigate to any repository page
38+2. **Test Back Navigation**:
39+ - Click mouse button 4 (back button)
40+ - **Expected**: Browser navigates to previous page
41+ - **Verify**: URL changes to previous page in history
42+3. **Test Forward Navigation**:
43+ - Click mouse button 5 (forward button)
44+ - **Expected**: Browser navigates forward in history
45+ - **Verify**: URL changes to next page
46+4. **Test Empty History Edge Cases**:
47+ - Navigate back to beginning of history
48+ - Click back button again
49+ - **Expected**: No navigation occurs, no error shown
50+
51+**Pass Criteria**: ✅ Navigation works and handles edge cases gracefully
52+
53+### Test Scenario 3: URL Copy (FR-005, FR-006, FR-011)
54+**Goal**: Verify URL copying with clipboard tool fallbacks
55+
56+1. **Setup**: Ensure at least one clipboard tool is available:
57+ ```bash
58+ which wlcopy || which xclip || which xsel
59+ ```
60+2. **Test URL Copy**:
61+ - Navigate to any webpage
62+ - Press `Ctrl` + `Shift` + `C`
63+ - **Expected**: Current URL copied to clipboard
64+3. **Test Clipboard Content**:
65+ ```bash
66+ # Wayland
67+ wl-paste
68+ # X11
69+ xclip -o -selection clipboard
70+ ```
71+ - **Verify**: Output matches current browser URL
72+4. **Test Fallback Chain** (if multiple tools available):
73+ - Temporarily rename `wlcopy` to test fallback
74+ - Repeat copy operation
75+ - **Expected**: Falls back to `xclip` or `xsel`
76+
77+**Pass Criteria**: ✅ URL copying works with proper fallback behavior
78+
79+### Test Scenario 4: Zoom Persistence (FR-009)
80+**Goal**: Verify per-domain zoom level persistence
81+
82+1. **Setup**: Clear any existing zoom preferences
83+2. **Test Domain Persistence**:
84+ - Navigate to `github.com`
85+ - Set zoom to 120% (Ctrl + + twice from 100%)
86+ - Navigate away to `stackoverflow.com`
87+ - Set zoom to 80% (Ctrl + - twice from 100%)
88+ - Navigate back to `github.com`
89+ - **Expected**: Zoom automatically restores to 120%
90+3. **Test Session Persistence**:
91+ - Close and restart browser
92+ - Navigate to `github.com`
93+ - **Expected**: Zoom level still at 120%
94+
95+**Pass Criteria**: ✅ Zoom levels persist per-domain across sessions
96+
97+### Test Scenario 5: Window Title Updates (FR-012)
98+**Goal**: Verify dynamic window title updates
99+
100+1. **Setup**: Browser window visible with title bar
101+2. **Test Title Updates**:
102+ - Navigate to `github.com`
103+ - **Expected**: Window title shows "Dumber - GitHub"
104+ - Navigate to a specific repository
105+ - **Expected**: Window title updates to show repository name
106+ - Navigate to `stackoverflow.com`
107+ - **Expected**: Window title shows "Dumber - Stack Overflow"
108+
109+**Pass Criteria**: ✅ Window title dynamically reflects current page
110+
111+## Integration Test Checklist
112+
113+### Database Integration
114+- [ ] Zoom levels stored in `zoom_levels` table
115+- [ ] Domain-based lookups work correctly
116+- [ ] Old zoom preferences cleaned up periodically
117+- [ ] Database transactions complete successfully
118+
119+### WebView Integration
120+- [ ] Keyboard events captured from WebView
121+- [ ] Mouse events captured and processed
122+- [ ] CSS zoom applied correctly
123+- [ ] Navigation history maintained
124+
125+### System Integration
126+- [ ] Clipboard tools detected correctly
127+- [ ] Fallback chain executes in order
128+- [ ] Error handling for missing tools
129+- [ ] No system dependencies beyond WebKit2GTK
130+
131+## Performance Verification
132+
133+### Response Time Tests
134+1. **Zoom Operations**:
135+ - Measure time from keypress to visual change
136+ - **Target**: < 100ms response time
137+2. **Navigation Operations**:
138+ - Measure time from mouse click to page load
139+ - **Target**: < 200ms for local history navigation
140+3. **Database Operations**:
141+ - Measure zoom level persistence write time
142+ - **Target**: < 50ms for database writes
143+
144+### Memory Usage Tests
145+1. **Baseline**: Measure memory before feature use
146+2. **After Usage**: Use all features extensively for 10 minutes
147+3. **Memory Growth**: Should be < 10MB additional usage
148+4. **No Leaks**: Memory should stabilize, not continuously grow
149+
150+## Troubleshooting
151+
152+### Common Issues
153+1. **Zoom not working**: Check JavaScript console for errors
154+2. **Mouse buttons not responding**: Verify WebView focus state
155+3. **Clipboard copy failing**: Check available clipboard tools
156+4. **Database errors**: Verify SQLite file permissions
157+5. **Title not updating**: Check UpdatePageTitle service calls
158+
159+### Debug Commands
160+```bash
161+# Check clipboard tools
162+which wlcopy xclip xsel
163+
164+# Verify database schema
165+sqlite3 ~/.config/dumber/browser.db ".schema zoom_levels"
166+
167+# Check zoom level data
168+sqlite3 ~/.config/dumber/browser.db "SELECT * FROM zoom_levels;"
169+
170+# Monitor application logs
171+dumber --debug
172+```
173+
174+## Success Criteria
175+
176+All test scenarios must pass with:
177+- ✅ No crashes or error dialogs
178+- ✅ Responsive UI (< 100ms feedback)
179+- ✅ Data persistence working correctly
180+- ✅ Graceful error handling
181+- ✅ Cross-platform clipboard compatibility
182+- ✅ Memory usage within constitutional limits
183+
184+**Ready for Production**: All scenarios pass consistently across test runs⏎̸
A specs/002-browser-controls-ui/research.md +133 -0
@@ -0,0 +1,133 @@
1+# Research: Browser Controls UI
2+
3+## Research Tasks
4+
5+### 1. Wails v3 Keyboard Event Handling
6+**Task**: Research keyboard event handling in Wails v3-alpha for desktop applications
7+
8+**Decision**: Use Wails context menu and event system with JavaScript frontend integration
9+**Rationale**:
10+- Wails v3 provides native keyboard event handling through WebView2/WebKit integration
11+- Frontend JavaScript can capture keyboard events and call backend Go methods via context
12+- Maintains separation between UI (JavaScript) and business logic (Go services)
13+- No additional dependencies required beyond existing Wails framework
14+
15+**Alternatives considered**:
16+- Direct GTK keyboard hooks: Rejected due to complexity and Wails abstraction
17+- OS-level global hotkeys: Rejected as not needed (app-specific shortcuts only)
18+- Third-party hotkey libraries: Rejected to maintain constitution's simplicity principle
19+
20+### 2. Mouse Button Event Handling (Back/Forward)
21+**Task**: Research mouse button 4/5 (back/forward) event handling in WebView context
22+
23+**Decision**: Use JavaScript mouse event listeners with event.button detection
24+**Rationale**:
25+- Standard web platform approach using addEventListener('mousedown')
26+- event.button === 3 (back) and event.button === 4 (forward) detection
27+- Works consistently across WebKit2GTK implementations
28+- No platform-specific code required
29+
30+**Alternatives considered**:
31+- GTK mouse event capture: Rejected due to Wails abstraction layer
32+- X11/Wayland direct event handling: Rejected as breaks single-binary principle
33+
34+### 3. Zoom Level Management and Persistence
35+**Task**: Research zoom level implementation patterns and domain-based persistence
36+
37+**Decision**: Use WebView zoom API with SQLite domain-based storage
38+**Rationale**:
39+- WebView provides native zoom functionality (setZoomFactor)
40+- Firefox zoom levels provide proven UX pattern (30%-500% range)
41+- SQLite table for domain → zoom_level mapping enables per-site persistence
42+- Existing database infrastructure can be extended
43+
44+**Alternatives considered**:
45+- CSS transform scaling: Rejected due to layout issues and non-standard behavior
46+- Browser-level zoom only: Rejected as doesn't persist per-domain
47+- Configuration file storage: Rejected as database is faster for lookups
48+
49+### 4. Linux Clipboard Integration (wlcopy fallback chain)
50+**Task**: Research Linux clipboard tool availability and fallback patterns
51+
52+**Decision**: Implement command execution chain: wlcopy → xclip → xsel
53+**Rationale**:
54+- wlcopy is Wayland-native (primary target environment)
55+- xclip and xsel provide X11 compatibility
56+- exec.Command provides clean abstraction for tool detection
57+- Graceful degradation maintains usability across Linux variants
58+
59+**Alternatives considered**:
60+- Go clipboard libraries: Rejected due to additional dependencies
61+- D-Bus clipboard access: Rejected as complex and not universally available
62+- Only wlcopy support: Rejected as limits platform compatibility
63+
64+### 5. WebView Zoom API Integration
65+**Task**: Research WebView zoom control APIs in Wails v3 context
66+
67+**Decision**: Use frontend JavaScript with backend coordination for zoom state
68+**Rationale**:
69+- document.body.style.zoom CSS property for immediate visual feedback
70+- Backend Go service manages zoom level persistence and bounds checking
71+- Maintains constitutional separation between UI and business logic
72+- No direct WebView API dependency required
73+
74+**Alternatives considered**:
75+- Direct WebView SetZoom API: Not available in Wails v3-alpha abstraction
76+- Pure CSS zoom transforms: Rejected due to layout calculation issues
77+
78+## Key Technical Findings
79+
80+### Firefox Zoom Levels
81+Standard zoom progression: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%
82+
83+### Event Handling Architecture
84+```
85+Frontend (JavaScript) → Wails Context → Backend (Go) → Services → Database
86+ ↑ ↓
87+ UI Events State Persistence
88+```
89+
90+### Clipboard Tool Detection Pattern
91+```go
92+var clipboardTools = []string{"wlcopy", "xclip", "xsel"}
93+for _, tool := range clipboardTools {
94+ if _, err := exec.LookPath(tool); err == nil {
95+ // Use this tool
96+ break
97+ }
98+}
99+```
100+
101+### Database Schema Extension
102+New table: `zoom_levels`
103+```sql
104+CREATE TABLE zoom_levels (
105+ domain TEXT PRIMARY KEY,
106+ zoom_factor REAL NOT NULL DEFAULT 1.0,
107+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
108+);
109+```
110+
111+## Risks and Mitigation
112+
113+1. **Risk**: WebView zoom behavior inconsistency across platforms
114+ **Mitigation**: Use CSS zoom with bounds checking, test on target platforms
115+
116+2. **Risk**: Mouse button events not captured in WebView
117+ **Mitigation**: Implement JavaScript event delegation on document level
118+
119+3. **Risk**: Clipboard tools unavailable on target system
120+ **Mitigation**: Graceful degradation with user notification
121+
122+4. **Risk**: Zoom persistence performance impact
123+ **Mitigation**: Debounce zoom level saves, use SQLite prepared statements
124+
125+## Implementation Dependencies
126+
127+- No new external dependencies required
128+- Leverage existing: Wails v3, SQLite, standard Go library
129+- Frontend JavaScript for event capture
130+- Backend Go services for state management
131+- Database schema extension for zoom persistence
132+
133+All research findings support constitutional principles of speed, simplicity, and single-binary architecture.⏎̸
A specs/002-browser-controls-ui/spec.md +118 -0
@@ -0,0 +1,118 @@
1+# Feature Specification: Browser Controls UI
2+
3+**Feature Branch**: `002-browser-controls-ui`
4+**Created**: 2025-09-09
5+**Status**: Draft
6+**Input**: User description: "Browser Controls UI, I want to add button and keyboard controls to the web browser, for now ctrl + to zoom, Ctrl - to unzoom, back/next mouse buttons, and ctrl shift c to copy url into wlcopy"
7+
8+## Execution Flow (main)
9+```
10+1. Parse user description from Input
11+ → Feature: Browser navigation and zoom controls with clipboard integration
12+2. Extract key concepts from description
13+ → Actors: Browser users
14+ → Actions: Zoom in/out, navigate back/forward, copy URL
15+ → Data: Current page URL, zoom level
16+ → Constraints: Linux clipboard integration (wlcopy)
17+3. For each unclear aspect:
18+ → Zoom levels follow Firefox behavior (30%-500%, per-domain persistence)
19+ → Navigation history edge cases handle empty history silently
20+4. Fill User Scenarios & Testing section
21+ → User can zoom and navigate using keyboard and mouse
22+5. Generate Functional Requirements
23+ → Each requirement focuses on user capabilities
24+6. Identify Key Entities
25+ → Browser state (zoom, history, current URL)
26+7. Run Review Checklist
27+ → All requirements clarified and specified
28+8. Return: SUCCESS (spec ready for planning)
29+```
30+
31+---
32+
33+## ⚡ Quick Guidelines
34+- ✅ Focus on WHAT users need and WHY
35+- ❌ Avoid HOW to implement (no tech stack, APIs, code structure)
36+- 👥 Written for business stakeholders, not developers
37+
38+---
39+
40+## User Scenarios & Testing *(mandatory)*
41+
42+### Primary User Story
43+A user browsing web content wants to quickly navigate and adjust their viewing experience using familiar keyboard shortcuts and mouse controls. They need to zoom in to read small text, zoom out to see more content, navigate through their browsing history, and easily share the current page URL with others.
44+
45+### Acceptance Scenarios
46+1. **Given** the browser is displaying a webpage, **When** the user presses Ctrl+Plus, **Then** the page zooms in and content becomes larger
47+2. **Given** the browser is displaying a webpage, **When** the user presses Ctrl+Minus, **Then** the page zooms out and more content becomes visible
48+3. **Given** the user has navigated to previous pages, **When** the user clicks the back mouse button, **Then** the browser navigates to the previous page in history
49+4. **Given** the user has navigated back in history, **When** the user clicks the forward mouse button, **Then** the browser navigates to the next page in history
50+5. **Given** the browser is displaying a webpage, **When** the user presses Ctrl+Shift+C, **Then** the current page URL is copied to the system clipboard via wlcopy
51+
52+### Edge Cases
53+- What happens when user tries to zoom beyond 30% or 500% limits? (System ignores further zoom attempts)
54+- How does system handle zoom keyboard shortcuts when no page is loaded? (System applies zoom to next loaded page)
55+- What happens when back/forward mouse buttons are pressed but no history exists? (System does nothing/ignores silently)
56+- How does system handle clipboard copy when wlcopy command is not available? (System tries fallback: xclip → xsel)
57+
58+## Requirements *(mandatory)*
59+
60+### Functional Requirements
61+- **FR-001**: System MUST respond to Ctrl+Plus keyboard shortcut to zoom in the current webpage
62+- **FR-002**: System MUST respond to Ctrl+Minus keyboard shortcut to zoom out the current webpage
63+- **FR-003**: System MUST respond to back mouse button clicks to navigate to previous page in browsing history
64+- **FR-004**: System MUST respond to forward mouse button clicks to navigate to next page in browsing history
65+- **FR-005**: System MUST respond to Ctrl+Shift+C keyboard shortcut to copy current page URL to system clipboard
66+- **FR-006**: System MUST integrate with wlcopy command for clipboard operations on Linux systems
67+- **FR-007**: System MUST provide visual feedback when zoom level changes
68+- **FR-008**: System MUST implement Firefox-style zoom levels (30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%)
69+- **FR-009**: System MUST persist zoom levels per-domain between browser sessions
70+- **FR-011**: System MUST attempt clipboard fallback sequence (wlcopy → xclip → xsel) when primary clipboard tool fails
71+- **FR-010**: System MUST handle cases where navigation history is empty (disable/ignore navigation attempts)
72+- **FR-012**: System MUST update window title dynamically when page title changes (format: "Dumber - [Page Title]")
73+
74+### Key Entities *(include if feature involves data)*
75+- **Browser State**: Represents current browsing context including zoom level, navigation history, and active URL
76+- **Navigation History**: Collection of previously visited pages enabling back/forward functionality
77+- **Zoom Level**: Current magnification setting for webpage display
78+
79+---
80+
81+## Review & Acceptance Checklist
82+*GATE: Automated checks run during main() execution*
83+
84+### Content Quality
85+- [x] No implementation details (languages, frameworks, APIs)
86+- [x] Focused on user value and business needs
87+- [x] Written for non-technical stakeholders
88+- [x] All mandatory sections completed
89+
90+### Requirement Completeness
91+- [x] No [NEEDS CLARIFICATION] markers remain
92+- [x] Requirements are testable and unambiguous
93+- [x] Success criteria are measurable
94+- [x] Scope is clearly bounded
95+- [x] Dependencies and assumptions identified
96+
97+---
98+
99+## Execution Status
100+*Updated by main() during processing*
101+
102+- [x] User description parsed
103+- [x] Key concepts extracted
104+- [x] Ambiguities marked
105+- [x] User scenarios defined
106+- [x] Requirements generated
107+- [x] Entities identified
108+- [x] Review checklist passed
109+
110+---
111+
112+## Implementation Notes
113+
114+### Frontend Integration Requirements
115+- The frontend needs to call `UpdatePageTitle` when the page loads to trigger the title update. This can be done via JavaScript when the page finishes loading.
116+- Window title format: "Dumber - [Page Title]" (e.g., "Dumber - Google", "Dumber - Wikipedia")
117+
118+---⏎̸
A specs/002-browser-controls-ui/tasks.md +186 -0
@@ -0,0 +1,186 @@
1+# Tasks: Browser Controls UI
2+
3+**Input**: Design documents from `/home/brice/dev/projects/dumb-browser/specs/002-browser-controls-ui/`
4+**Prerequisites**: plan.md, research.md, data-model.md, contracts/browser-controls-api.yaml
5+
6+## Feature Summary
7+Implement keyboard and mouse navigation controls for the Wails-based browser: zoom in/out with Ctrl+/-, back/forward navigation with mouse buttons, URL copying with Ctrl+Shift+C, and dynamic window title updates with SQLite persistence.
8+
9+## Path Conventions
10+Single project structure (desktop application):
11+- **Source**: `src/models/`, `src/services/`, `src/cli/`, `src/lib/`
12+- **Tests**: `tests/contract/`, `tests/integration/`, `tests/unit/`
13+- **Frontend**: `frontend/src/`
14+- **Database**: SQLite with SQLC generated queries
15+
16+## Phase 3.1: Setup
17+
18+- [ ] T001 Create zoom_levels database table with SQLite schema migration in `src/db/migrations/003_add_zoom_levels.sql`
19+- [ ] T002 Generate SQLC queries for zoom level operations in `src/db/queries/zoom_levels.sql`
20+- [ ] T003 [P] Add frontend JavaScript event handlers in `frontend/src/events/keyboard.js`
21+- [ ] T004 [P] Add frontend mouse event handlers in `frontend/src/events/mouse.js`
22+
23+## Phase 3.2: Tests First (TDD) ⚠️ MUST COMPLETE BEFORE 3.3
24+
25+**CRITICAL: These tests MUST be written and MUST FAIL before ANY implementation**
26+
27+### Contract Tests [P]
28+- [ ] T005 [P] Contract test POST /zoom/in endpoint in `tests/contract/zoom_in_test.go`
29+- [ ] T006 [P] Contract test POST /zoom/out endpoint in `tests/contract/zoom_out_test.go`
30+- [ ] T007 [P] Contract test POST /navigation/back endpoint in `tests/contract/navigation_back_test.go`
31+- [ ] T008 [P] Contract test POST /navigation/forward endpoint in `tests/contract/navigation_forward_test.go`
32+- [ ] T009 [P] Contract test POST /clipboard/copy-url endpoint in `tests/contract/clipboard_copy_test.go`
33+- [ ] T010 [P] Contract test GET /zoom/get-level endpoint in `tests/contract/zoom_get_test.go`
34+
35+### Integration Tests [P]
36+- [ ] T011 [P] Integration test zoom persistence across domains in `tests/integration/zoom_persistence_test.go`
37+- [ ] T012 [P] Integration test keyboard event handling in `tests/integration/keyboard_events_test.go`
38+- [ ] T013 [P] Integration test mouse navigation in `tests/integration/mouse_navigation_test.go`
39+- [ ] T014 [P] Integration test clipboard tool fallback chain in `tests/integration/clipboard_fallback_test.go`
40+- [ ] T015 [P] Integration test window title updates in `tests/integration/window_title_test.go`
41+
42+## Phase 3.3: Core Implementation (ONLY after tests are failing)
43+
44+### Data Models [P]
45+- [ ] T016 [P] ZoomLevel model with validation in `src/models/zoom_level.go`
46+- [ ] T017 [P] BrowserState model extensions in `src/models/browser_state.go`
47+- [ ] T018 [P] KeyboardEvent model for event processing in `src/models/keyboard_event.go`
48+- [ ] T019 [P] MouseEvent model for navigation in `src/models/mouse_event.go`
49+
50+### Core Services [P]
51+- [ ] T020 [P] ZoomService with Firefox zoom levels in `src/services/zoom_service.go`
52+- [ ] T021 [P] NavigationService with history management in `src/services/navigation_service.go`
53+- [ ] T022 [P] ClipboardService with tool fallback in `src/services/clipboard_service.go`
54+- [ ] T023 [P] WindowTitleService for dynamic updates in `src/services/window_title_service.go`
55+
56+### Wails Service Layer
57+- [ ] T024 BrowserControlService main service in `src/services/browser_control_service.go`
58+- [ ] T025 ZoomIn method implementation with domain persistence
59+- [ ] T026 ZoomOut method implementation with bounds checking
60+- [ ] T027 NavigateBack method with history validation
61+- [ ] T028 NavigateForward method with forward history
62+- [ ] T029 CopyCurrentURL method with clipboard tool detection
63+- [ ] T030 GetZoomLevel method for domain lookup
64+
65+### Database Layer
66+- [ ] T031 ZoomLevelRepository with SQLC integration in `src/repositories/zoom_level_repository.go`
67+- [ ] T032 Database transaction management for zoom operations
68+- [ ] T033 Cleanup job for old zoom level entries
69+
70+## Phase 3.4: Integration
71+
72+### Frontend Integration
73+- [ ] T034 Wire keyboard events to Wails backend calls in `frontend/src/events/keyboard.js`
74+- [ ] T035 Wire mouse events to navigation methods in `frontend/src/events/mouse.js`
75+- [ ] T036 CSS zoom application with visual feedback in `frontend/src/styles/zoom.css`
76+- [ ] T037 Window title update integration with page changes
77+
78+### Backend Integration
79+- [ ] T038 Register BrowserControlService with Wails context in `src/app.go`
80+- [ ] T039 Database migration execution on startup
81+- [ ] T040 Error handling and logging for all service methods
82+- [ ] T041 Service method validation and bounds checking
83+
84+## Phase 3.5: Polish
85+
86+### Unit Tests [P]
87+- [ ] T042 [P] Unit tests for Firefox zoom level validation in `tests/unit/zoom_levels_test.go`
88+- [ ] T043 [P] Unit tests for clipboard tool detection in `tests/unit/clipboard_tools_test.go`
89+- [ ] T044 [P] Unit tests for navigation history bounds in `tests/unit/navigation_bounds_test.go`
90+- [ ] T045 [P] Unit tests for domain validation in `tests/unit/domain_validation_test.go`
91+
92+### Performance & Documentation
93+- [ ] T046 Performance test: zoom operations < 100ms response in `tests/performance/zoom_perf_test.go`
94+- [ ] T047 Performance test: navigation < 200ms response in `tests/performance/nav_perf_test.go`
95+- [ ] T048 Memory usage test: < 10MB additional usage over 10 minutes
96+- [ ] T049 Update README with new keyboard shortcuts and features
97+- [ ] T050 Run quickstart.md validation scenarios
98+
99+## Dependencies
100+
101+**Critical Dependencies:**
102+- Database setup (T001-T002) blocks all repository tasks (T031-T033)
103+- All tests (T005-T015) MUST complete and FAIL before implementation (T016-T041)
104+- Models (T016-T019) block services (T020-T023)
105+- Services block Wails integration (T024-T030)
106+- Core implementation blocks frontend integration (T034-T041)
107+
108+**Parallel Groups:**
109+- Setup frontend events: T003, T004
110+- Contract tests: T005-T010
111+- Integration tests: T011-T015
112+- Data models: T016-T019
113+- Core services: T020-T023
114+- Unit tests: T042-T045
115+
116+## Parallel Execution Examples
117+
118+```bash
119+# Phase 3.1 Setup (parallel frontend)
120+Task: "Add frontend JavaScript event handlers in frontend/src/events/keyboard.js"
121+Task: "Add frontend mouse event handlers in frontend/src/events/mouse.js"
122+
123+# Phase 3.2 Contract Tests (all parallel)
124+Task: "Contract test POST /zoom/in endpoint in tests/contract/zoom_in_test.go"
125+Task: "Contract test POST /zoom/out endpoint in tests/contract/zoom_out_test.go"
126+Task: "Contract test POST /navigation/back endpoint in tests/contract/navigation_back_test.go"
127+Task: "Contract test POST /navigation/forward endpoint in tests/contract/navigation_forward_test.go"
128+Task: "Contract test POST /clipboard/copy-url endpoint in tests/contract/clipboard_copy_test.go"
129+Task: "Contract test GET /zoom/get-level endpoint in tests/contract/zoom_get_test.go"
130+
131+# Phase 3.2 Integration Tests (all parallel)
132+Task: "Integration test zoom persistence across domains in tests/integration/zoom_persistence_test.go"
133+Task: "Integration test keyboard event handling in tests/integration/keyboard_events_test.go"
134+Task: "Integration test mouse navigation in tests/integration/mouse_navigation_test.go"
135+Task: "Integration test clipboard tool fallback chain in tests/integration/clipboard_fallback_test.go"
136+Task: "Integration test window title updates in tests/integration/window_title_test.go"
137+
138+# Phase 3.3 Data Models (all parallel)
139+Task: "ZoomLevel model with validation in src/models/zoom_level.go"
140+Task: "BrowserState model extensions in src/models/browser_state.go"
141+Task: "KeyboardEvent model for event processing in src/models/keyboard_event.go"
142+Task: "MouseEvent model for navigation in src/models/mouse_event.go"
143+
144+# Phase 3.3 Core Services (all parallel)
145+Task: "ZoomService with Firefox zoom levels in src/services/zoom_service.go"
146+Task: "NavigationService with history management in src/services/navigation_service.go"
147+Task: "ClipboardService with tool fallback in src/services/clipboard_service.go"
148+Task: "WindowTitleService for dynamic updates in src/services/window_title_service.go"
149+```
150+
151+## Firefox Zoom Levels Reference
152+Standard zoom progression: 30%, 50%, 67%, 80%, 90%, 100%, 110%, 120%, 133%, 150%, 170%, 200%, 240%, 300%, 400%, 500%
153+
154+## Clipboard Tool Fallback Chain
155+1. `wlcopy` (Wayland native)
156+2. `xclip` (X11 primary)
157+3. `xsel` (X11 fallback)
158+
159+## Database Schema
160+```sql
161+CREATE TABLE zoom_levels (
162+ domain TEXT PRIMARY KEY,
163+ zoom_factor REAL NOT NULL DEFAULT 1.0 CHECK(zoom_factor >= 0.3 AND zoom_factor <= 5.0),
164+ updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
165+);
166+CREATE INDEX idx_zoom_levels_updated_at ON zoom_levels(updated_at);
167+```
168+
169+## Validation Checklist
170+- [x] All contracts have corresponding tests (T005-T010)
171+- [x] All entities have model tasks (T016-T019)
172+- [x] All tests come before implementation (Phase 3.2 before 3.3)
173+- [x] Parallel tasks truly independent (different files)
174+- [x] Each task specifies exact file path
175+- [x] No task modifies same file as another [P] task
176+
177+## Success Criteria
178+- All quickstart.md test scenarios pass
179+- Performance targets met (< 100ms zoom, < 200ms navigation)
180+- Memory usage within constitutional limits (< 10MB additional)
181+- Cross-platform clipboard compatibility maintained
182+- No regressions in existing browser functionality
183+
184+**Total Tasks**: 50 tasks across 5 phases
185+**Estimated Completion**: 2-3 days with parallel execution
186+**Critical Path**: Database setup → Tests → Models → Services → Integration⏎̸
D specs/002-ui-enhancement-polishing/spec.md +0 -135
@@ -1,135 +0,0 @@
1-# Feature Specification: UI Enhancement & Polishing + Browser Identity
2-
3-**Feature Branch**: `002-ui-enhancement-polishing`
4-**Created**: 2025-09-09
5-**Status**: Draft
6-**Input**: User description: "Polish UI, add browser identity/user agent, enhance performance"
7-
8-## Execution Flow (main)
9-```
10-1. Parse user description from Input
11- → "Enhance UI/UX, create browser identity, optimize performance"
12-2. Extract key concepts from description
13- → Actors: users, website analytics, system performance
14- → Actions: polish, identify, optimize, enhance
15- → Data: user agent strings, UI components, performance metrics
16- → Constraints: maintain compatibility, respect privacy
17-3. For each unclear aspect:
18- → No major ambiguities - build on existing foundation
19-4. Fill User Scenarios & Testing section
20- → Clear flow: user experiences polished interface and proper browser identification
21-5. Generate Functional Requirements
22- → Each requirement testable through UI/performance validation
23-6. Identify Key Entities: User agent, UI components, performance metrics, browser identity
24-7. Run Review Checklist
25- → No [NEEDS CLARIFICATION] markers needed
26- → Focus on polish and optimization over new features
27-8. Return: SUCCESS (spec ready for planning)
28-```
29-
30----
31-
32-## ⚡ Quick Guidelines
33-- ✅ Focus on WHAT users need and WHY
34-- ❌ Avoid HOW to implement (no tech stack, APIs, code structure)
35-- 👥 Written for business stakeholders, not developers
36-
37----
38-
39-## User Scenarios & Testing *(mandatory)*
40-
41-### Primary User Story
42-As a Dumber Browser user, I need a polished, fast, and properly identified browser experience so that websites recognize my browser correctly, the interface feels professional, and performance meets my daily usage expectations without friction.
43-
44-### Acceptance Scenarios
45-
46-#### Browser Identity & Recognition
47-1. **Given** I visit a website with analytics, **When** I browse normally, **Then** the site logs show "Dumber Browser" in user agent statistics
48-2. **Given** a website checks browser compatibility, **When** I access it, **Then** the site recognizes WebKit compatibility and loads properly
49-3. **Given** I want privacy control, **When** I configure user agent settings, **Then** I can choose between branded identity and anonymous browsing
50-
51-#### UI Enhancement & Polish
52-4. **Given** I launch the landing page, **When** it loads, **Then** the interface appears within 500ms with smooth transitions
53-5. **Given** I view my browsing history, **When** scanning entries, **Then** URLs and titles are clearly readable with proper formatting and visual hierarchy
54-6. **Given** I use the application on different screen sizes, **When** resizing, **Then** the layout adapts gracefully without breaking
55-7. **Given** I interact with shortcuts, **When** hovering or clicking, **Then** visual feedback is immediate and intuitive
56-
57-#### Performance & Optimization
58-8. **Given** I start the application, **When** timing startup, **Then** launch completes in under 500ms
59-9. **Given** I monitor system resources, **When** running normally, **Then** memory usage stays below 100MB baseline
60-10. **Given** I switch between CLI and GUI modes, **When** testing transitions, **Then** mode switching is seamless without delays
61-
62-### Edge Cases
63-- What happens when user agent is blocked by websites?
64-- How does the UI behave with extremely long URLs or titles?
65-- What occurs when system resources are constrained?
66-- How does the interface handle empty states (no history, no shortcuts)?
67-
68-## Requirements *(mandatory)*
69-
70-### Functional Requirements
71-
72-#### Browser Identity
73-- **FR-001**: System MUST provide a custom Dumber Browser user agent string
74-- **FR-002**: System MUST include version information in user agent for debugging
75-- **FR-003**: System MUST maintain WebKit compatibility strings for site compatibility
76-- **FR-004**: System MUST allow user agent customization per-site if needed
77-- **FR-005**: System MUST provide privacy mode with anonymous/generic user agent
78-
79-#### UI Enhancement
80-- **FR-006**: System MUST display loading states clearly during data fetching
81-- **FR-007**: System MUST provide visual feedback for all interactive elements
82-- **FR-008**: System MUST format URLs and titles for optimal readability
83-- **FR-009**: System MUST implement responsive design for various screen sizes
84-- **FR-010**: System MUST show empty states gracefully when no data exists
85-- **FR-011**: System MUST provide smooth transitions between different views
86-
87-#### Performance Optimization
88-- **FR-012**: System MUST start within 500ms from launch command
89-- **FR-013**: System MUST maintain memory usage below 100MB during normal operation
90-- **FR-014**: System MUST load landing page data within 200ms
91-- **FR-015**: System MUST respond to user interactions within 50ms
92-
93-#### Integration Polish
94-- **FR-016**: System MUST provide clear error messages with actionable guidance
95-- **FR-017**: System MUST handle CLI-to-GUI transitions seamlessly
96-- **FR-018**: System MUST format dmenu output for optimal launcher integration
97-- **FR-019**: System MUST maintain consistent visual design language throughout
98-
99-### Key Entities
100-- **Browser Identity**: User agent string, version info, compatibility data
101-- **UI Components**: Landing page, history list, shortcuts grid, loading states
102-- **Performance Metrics**: Startup time, memory usage, response times
103-- **Visual Design**: Color scheme, typography, spacing, transitions
104-- **User Experience**: Interactions, feedback, error states, accessibility
105-
106----
107-
108-## Review & Acceptance Checklist
109-
110-### Content Quality
111-- [x] No implementation details (languages, frameworks, APIs)
112-- [x] Focused on user value and business needs
113-- [x] Written for non-technical stakeholders
114-- [x] All mandatory sections completed
115-
116-### Requirement Completeness
117-- [x] No [NEEDS CLARIFICATION] markers remain
118-- [x] Requirements are testable and unambiguous
119-- [x] Success criteria are measurable
120-- [x] Scope is clearly bounded (polish existing, not new features)
121-- [x] Dependencies and assumptions identified
122-
123----
124-
125-## Execution Status
126-
127-- [x] User description parsed
128-- [x] Key concepts extracted
129-- [x] Ambiguities marked (none found)
130-- [x] User scenarios defined
131-- [x] Requirements generated
132-- [x] Entities identified
133-- [x] Review checklist passed
134-
135----⏎̸
D test-wails +0 -0 binary file: no text diff

binary file: no text diff

A tests/contract/browser_controls_test.go +307 -0
@@ -0,0 +1,307 @@
1+package contract
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "math"
8+ "testing"
9+
10+ "github.com/bnema/dumber/services"
11+ "github.com/bnema/dumber/internal/config"
12+ mock_db "github.com/bnema/dumber/internal/db/mocks"
13+ "github.com/wailsapp/wails/v3/pkg/application"
14+ "github.com/golang/mock/gomock"
15+)
16+
17+// floatMatcher implements gomock.Matcher for floating point comparison with tolerance
18+type floatMatcher struct {
19+ expected float64
20+ tolerance float64
21+}
22+
23+func (f floatMatcher) Matches(x interface{}) bool {
24+ if actual, ok := x.(float64); ok {
25+ return math.Abs(actual - f.expected) < f.tolerance
26+ }
27+ return false
28+}
29+
30+func (f floatMatcher) String() string {
31+ return fmt.Sprintf("is within %f of %f", f.tolerance, f.expected)
32+}
33+
34+// floatEq creates a matcher for floating point values with tolerance
35+func floatEq(expected float64) gomock.Matcher {
36+ return floatMatcher{expected: expected, tolerance: 1e-10}
37+}
38+
39+// MockWindowTitleUpdater implements the WindowTitleUpdater interface for testing
40+type MockWindowTitleUpdater struct {
41+ lastTitle string
42+ callCount int
43+}
44+
45+func (m *MockWindowTitleUpdater) SetTitle(title string) application.Window {
46+ m.lastTitle = title
47+ m.callCount++
48+ return nil
49+}
50+
51+// Contract Test: BrowserService Zoom Controls
52+// Tests the zoom functionality contracts as specified in 002-browser-controls-ui
53+func TestBrowserService_ZoomControls_Contract(t *testing.T) {
54+ ctrl := gomock.NewController(t)
55+ defer ctrl.Finish()
56+
57+ mockDB := mock_db.NewMockDatabaseQuerier(ctrl)
58+ cfg := &config.Config{}
59+ service := services.NewBrowserService(cfg, mockDB)
60+
61+ ctx := context.Background()
62+ testURL := "https://example.com"
63+
64+ t.Run("ZoomIn increases zoom level by 0.1", func(t *testing.T) {
65+ // Setup mock expectations
66+ mockDB.EXPECT().
67+ GetZoomLevel(ctx, testURL).
68+ Return(1.0, nil)
69+
70+ mockDB.EXPECT().
71+ SetZoomLevel(ctx, testURL, floatEq(1.1)).
72+ Return(nil)
73+
74+ // Contract: ZoomIn should increase zoom by 0.1 and return new level
75+ newZoom, err := service.ZoomIn(ctx, testURL)
76+
77+ // Contract assertions
78+ if err != nil {
79+ t.Fatalf("ZoomIn should not return error: %v", err)
80+ }
81+
82+ expectedZoom := 1.1
83+ if newZoom != expectedZoom {
84+ t.Errorf("Expected zoom level %f, got %f", expectedZoom, newZoom)
85+ }
86+ })
87+
88+ t.Run("ZoomOut decreases zoom level by 0.1", func(t *testing.T) {
89+ // Setup mock expectations for current zoom level
90+ mockDB.EXPECT().
91+ GetZoomLevel(ctx, testURL).
92+ Return(1.2, nil)
93+
94+ mockDB.EXPECT().
95+ SetZoomLevel(ctx, testURL, floatEq(1.1)).
96+ Return(nil)
97+
98+ // Contract: ZoomOut should decrease zoom by 0.1 and return new level
99+ newZoom, err := service.ZoomOut(ctx, testURL)
100+
101+ // Contract assertions
102+ if err != nil {
103+ t.Fatalf("ZoomOut should not return error: %v", err)
104+ }
105+
106+ expectedZoom := 1.1
107+ if math.Abs(newZoom - expectedZoom) > 1e-10 {
108+ t.Errorf("Expected zoom level %f, got %f", expectedZoom, newZoom)
109+ }
110+ })
111+
112+ t.Run("ZoomReset sets zoom to 1.0", func(t *testing.T) {
113+ // Setup mock expectations
114+ mockDB.EXPECT().
115+ SetZoomLevel(ctx, testURL, floatEq(1.0)).
116+ Return(nil)
117+
118+ // Contract: ZoomReset should always return zoom to default (1.0)
119+ newZoom, err := service.ResetZoom(ctx, testURL)
120+
121+ // Contract assertions
122+ if err != nil {
123+ t.Fatalf("ResetZoom should not return error: %v", err)
124+ }
125+
126+ expectedZoom := 1.0
127+ if newZoom != expectedZoom {
128+ t.Errorf("ResetZoom should return 1.0, got %f", expectedZoom)
129+ }
130+ })
131+
132+ t.Run("GetZoomLevel returns current zoom level", func(t *testing.T) {
133+ // Setup mock expectations
134+ expectedZoom := 1.5
135+ mockDB.EXPECT().
136+ GetZoomLevel(ctx, testURL).
137+ Return(expectedZoom, nil)
138+
139+ // Contract: GetZoomLevel should return current zoom level
140+ currentZoom, err := service.GetZoomLevel(ctx, testURL)
141+
142+ // Contract assertions
143+ if err != nil {
144+ t.Fatalf("GetZoomLevel should not return error: %v", err)
145+ }
146+
147+ if currentZoom != expectedZoom {
148+ t.Errorf("Expected zoom level %f, got %f", expectedZoom, currentZoom)
149+ }
150+ })
151+
152+ t.Run("GetZoomLevel returns default 1.0 for new URLs", func(t *testing.T) {
153+ // Setup mock expectations for non-existent URL
154+ mockDB.EXPECT().
155+ GetZoomLevel(ctx, testURL).
156+ Return(0.0, sql.ErrNoRows)
157+
158+ // Contract: GetZoomLevel should return 1.0 for URLs without zoom settings
159+ currentZoom, err := service.GetZoomLevel(ctx, testURL)
160+
161+ // Contract assertions
162+ if err != nil {
163+ t.Fatalf("GetZoomLevel should not return error for new URLs: %v", err)
164+ }
165+
166+ expectedZoom := 1.0
167+ if currentZoom != expectedZoom {
168+ t.Errorf("Expected default zoom level %f for new URL, got %f", expectedZoom, currentZoom)
169+ }
170+ })
171+}
172+
173+// Contract Test: BrowserService Navigation Controls
174+func TestBrowserService_NavigationControls_Contract(t *testing.T) {
175+ ctrl := gomock.NewController(t)
176+ defer ctrl.Finish()
177+
178+ mockDB := mock_db.NewMockDatabaseQuerier(ctrl)
179+ cfg := &config.Config{}
180+ service := services.NewBrowserService(cfg, mockDB)
181+
182+ ctx := context.Background()
183+
184+ t.Run("GoBack handles gracefully when no history", func(t *testing.T) {
185+ // Contract: GoBack should handle empty history gracefully
186+ err := service.GoBack(ctx)
187+
188+ // May return error for empty history, which is acceptable
189+ if err != nil {
190+ t.Logf("GoBack returned error (acceptable for empty history): %v", err)
191+ }
192+ })
193+
194+ t.Run("GoForward handles gracefully when no forward history", func(t *testing.T) {
195+ // Contract: GoForward should handle empty forward history gracefully
196+ err := service.GoForward(ctx)
197+
198+ // May return error for empty forward history, which is acceptable
199+ if err != nil {
200+ t.Logf("GoForward returned error (acceptable for empty forward history): %v", err)
201+ }
202+ })
203+}
204+
205+// Contract Test: WindowTitleUpdater Integration
206+func TestBrowserService_WindowTitleUpdate_Contract(t *testing.T) {
207+ ctrl := gomock.NewController(t)
208+ defer ctrl.Finish()
209+
210+ mockDB := mock_db.NewMockDatabaseQuerier(ctrl)
211+ cfg := &config.Config{}
212+ service := services.NewBrowserService(cfg, mockDB)
213+
214+ // Setup mock window title updater
215+ mockUpdater := &MockWindowTitleUpdater{}
216+ service.SetWindowTitleUpdater(mockUpdater)
217+
218+ t.Run("SetWindowTitleUpdater accepts WindowTitleUpdater interface", func(t *testing.T) {
219+ // Contract: Service should accept any WindowTitleUpdater implementation
220+ newMockUpdater := &MockWindowTitleUpdater{}
221+
222+ // This should not panic or error
223+ service.SetWindowTitleUpdater(newMockUpdater)
224+
225+ // Verify the updater was set properly
226+ if newMockUpdater.callCount < 0 {
227+ t.Errorf("WindowTitleUpdater should be properly initialized")
228+ }
229+ })
230+}
231+
232+// Contract Test: URL Operations
233+func TestBrowserService_URLOperations_Contract(t *testing.T) {
234+ ctrl := gomock.NewController(t)
235+ defer ctrl.Finish()
236+
237+ mockDB := mock_db.NewMockDatabaseQuerier(ctrl)
238+ cfg := &config.Config{}
239+ service := services.NewBrowserService(cfg, mockDB)
240+
241+ t.Run("GetCurrentURL returns current URL", func(t *testing.T) {
242+ // Contract: GetCurrentURL should return the current URL string
243+ ctx := context.Background()
244+
245+ url, err := service.GetCurrentURL(ctx)
246+
247+ // Contract assertions
248+ if err != nil {
249+ t.Fatalf("GetCurrentURL should not return error: %v", err)
250+ }
251+
252+ // URL should be a valid string (can be empty for new browser)
253+ if url != "" && len(url) < 4 { // Minimum valid URL length
254+ t.Errorf("Invalid URL returned: %s", url)
255+ }
256+ })
257+
258+ t.Run("CopyCurrentURL accepts URL parameter", func(t *testing.T) {
259+ // Contract: CopyCurrentURL should accept URL and handle clipboard
260+ ctx := context.Background()
261+ testURL := "https://example.com"
262+
263+ err := service.CopyCurrentURL(ctx, testURL)
264+
265+ // Contract assertions - should not return error for valid URL
266+ if err != nil {
267+ t.Fatalf("CopyCurrentURL should not return error for valid URL: %v", err)
268+ }
269+ })
270+}
271+
272+// Contract Test: Error Handling
273+func TestBrowserService_ErrorHandling_Contract(t *testing.T) {
274+ // Test with nil config but valid mock database
275+ ctrl := gomock.NewController(t)
276+ defer ctrl.Finish()
277+
278+ mockDB := mock_db.NewMockDatabaseQuerier(ctrl)
279+ service := services.NewBrowserService(nil, mockDB)
280+ ctx := context.Background()
281+
282+ t.Run("Service handles nil config gracefully", func(t *testing.T) {
283+ // Contract: Service methods should handle nil config gracefully
284+
285+ // Setup mock expectations for operations that might still work
286+ mockDB.EXPECT().
287+ GetZoomLevel(ctx, "test").
288+ Return(1.0, nil).
289+ AnyTimes()
290+
291+ mockDB.EXPECT().
292+ SetZoomLevel(ctx, gomock.Any(), gomock.Any()).
293+ Return(nil).
294+ AnyTimes()
295+
296+ // These should work with valid database but nil config
297+ _, err := service.ZoomIn(ctx, "test")
298+ if err != nil {
299+ t.Logf("ZoomIn returned error with nil config (may be expected): %v", err)
300+ }
301+
302+ err = service.GoBack(ctx)
303+ if err != nil {
304+ t.Logf("GoBack returned error with nil config (may be expected): %v", err)
305+ }
306+ })
307+}⏎̸
A tests/integration/browser_controls_integration_test.go +373 -0
@@ -0,0 +1,373 @@
1+package integration
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "os"
7+ "path/filepath"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/dumber/internal/config"
13+ "github.com/bnema/dumber/internal/db"
14+ "github.com/bnema/dumber/services"
15+ "github.com/wailsapp/wails/v3/pkg/application"
16+ _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver
17+ _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite
18+)
19+
20+// MockWindowUpdater for integration testing
21+type MockWindowUpdater struct {
22+ titles []string
23+ mutex sync.Mutex
24+}
25+
26+// Implement the WindowTitleUpdater interface method
27+func (m *MockWindowUpdater) SetTitle(title string) application.Window {
28+ m.mutex.Lock()
29+ defer m.mutex.Unlock()
30+ m.titles = append(m.titles, title)
31+ return nil
32+}
33+
34+// setupTestDB creates a temporary SQLite database for integration testing
35+func setupTestDB(t *testing.T) (*sql.DB, *db.Queries, func()) {
36+ // Create temporary database file
37+ tmpDir := t.TempDir()
38+ dbPath := filepath.Join(tmpDir, "test_browser.db")
39+
40+ database, err := sql.Open("sqlite3", dbPath)
41+ if err != nil {
42+ t.Fatalf("Failed to open test database: %v", err)
43+ }
44+
45+ // Create tables using the migration schema
46+ createTablesSQL := `
47+ CREATE TABLE IF NOT EXISTS history (
48+ id INTEGER PRIMARY KEY AUTOINCREMENT,
49+ url TEXT NOT NULL,
50+ title TEXT,
51+ visit_count INTEGER DEFAULT 1,
52+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
53+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
54+ );
55+
56+ CREATE TABLE IF NOT EXISTS shortcuts (
57+ id INTEGER PRIMARY KEY AUTOINCREMENT,
58+ shortcut TEXT NOT NULL UNIQUE,
59+ url_template TEXT NOT NULL,
60+ description TEXT,
61+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
62+ );
63+
64+ CREATE TABLE IF NOT EXISTS zoom_settings (
65+ id INTEGER PRIMARY KEY AUTOINCREMENT,
66+ url TEXT NOT NULL UNIQUE,
67+ zoom_level REAL NOT NULL DEFAULT 1.0,
68+ last_updated DATETIME DEFAULT CURRENT_TIMESTAMP
69+ );
70+ `
71+
72+ if _, err := database.Exec(createTablesSQL); err != nil {
73+ t.Fatalf("Failed to create test tables: %v", err)
74+ }
75+
76+ queries := db.New(database)
77+
78+ cleanup := func() {
79+ database.Close()
80+ os.Remove(dbPath)
81+ }
82+
83+ return database, queries, cleanup
84+}
85+
86+// setupTestBrowserService creates a BrowserService with test database
87+func setupTestBrowserService(t *testing.T) (*services.BrowserService, func()) {
88+ _, queries, cleanup := setupTestDB(t)
89+
90+ cfg := &config.Config{}
91+
92+ service := services.NewBrowserService(cfg, queries)
93+
94+ return service, cleanup
95+}
96+
97+// Integration Test: Zoom Persistence
98+func TestBrowserControls_ZoomPersistence_Integration(t *testing.T) {
99+ service, cleanup := setupTestBrowserService(t)
100+ defer cleanup()
101+
102+ ctx := context.Background()
103+ testURL := "https://example.com"
104+
105+ t.Run("Zoom level persists across service operations", func(t *testing.T) {
106+ // Set initial zoom level
107+ zoomLevel, err := service.ZoomIn(ctx, testURL)
108+ if err != nil {
109+ t.Fatalf("Failed to zoom in: %v", err)
110+ }
111+
112+ expectedZoom := 1.1
113+ if zoomLevel != expectedZoom {
114+ t.Errorf("Expected zoom %f, got %f", expectedZoom, zoomLevel)
115+ }
116+
117+ // Zoom in again
118+ zoomLevel, err = service.ZoomIn(ctx, testURL)
119+ if err != nil {
120+ t.Fatalf("Failed to zoom in again: %v", err)
121+ }
122+
123+ expectedZoom = 1.2
124+ if zoomLevel != expectedZoom {
125+ t.Errorf("Expected zoom %f, got %f", expectedZoom, zoomLevel)
126+ }
127+
128+ // Test that zoom persists for the same URL
129+ // Simulate getting zoom level (this would typically happen on page load)
130+ currentZoom, err := service.GetZoomLevel(ctx, testURL)
131+ if err != nil {
132+ t.Fatalf("Failed to get zoom level: %v", err)
133+ }
134+
135+ if currentZoom != expectedZoom {
136+ t.Errorf("Zoom level not persisted. Expected %f, got %f", expectedZoom, currentZoom)
137+ }
138+ })
139+
140+ t.Run("Different URLs have independent zoom levels", func(t *testing.T) {
141+ url1 := "https://example.com"
142+ url2 := "https://google.com"
143+
144+ // Set different zoom levels for different URLs
145+ zoom1, err := service.ZoomIn(ctx, url1)
146+ if err != nil {
147+ t.Fatalf("Failed to zoom in for url1: %v", err)
148+ }
149+
150+ zoom2, err := service.ZoomOut(ctx, url2)
151+ if err != nil {
152+ t.Fatalf("Failed to zoom out for url2: %v", err)
153+ }
154+
155+ // Verify they are different
156+ if zoom1 == zoom2 {
157+ t.Errorf("Expected different zoom levels for different URLs, both got %f", zoom1)
158+ }
159+
160+ // Verify persistence for each URL
161+ persistedZoom1, err := service.GetZoomLevel(ctx, url1)
162+ if err != nil {
163+ t.Fatalf("Failed to get zoom level for url1: %v", err)
164+ }
165+
166+ persistedZoom2, err := service.GetZoomLevel(ctx, url2)
167+ if err != nil {
168+ t.Fatalf("Failed to get zoom level for url2: %v", err)
169+ }
170+
171+ if persistedZoom1 != zoom1 {
172+ t.Errorf("URL1 zoom not persisted. Expected %f, got %f", zoom1, persistedZoom1)
173+ }
174+
175+ if persistedZoom2 != zoom2 {
176+ t.Errorf("URL2 zoom not persisted. Expected %f, got %f", zoom2, persistedZoom2)
177+ }
178+ })
179+
180+ t.Run("Zoom reset works correctly", func(t *testing.T) {
181+ testURL := "https://test.com"
182+
183+ // Change zoom level
184+ service.ZoomIn(ctx, testURL)
185+ service.ZoomIn(ctx, testURL)
186+
187+ // Reset zoom
188+ resetZoom, err := service.ResetZoom(ctx, testURL)
189+ if err != nil {
190+ t.Fatalf("Failed to reset zoom: %v", err)
191+ }
192+
193+ expectedZoom := 1.0
194+ if resetZoom != expectedZoom {
195+ t.Errorf("Expected reset zoom %f, got %f", expectedZoom, resetZoom)
196+ }
197+
198+ // Verify persistence of reset
199+ persistedZoom, err := service.GetZoomLevel(ctx, testURL)
200+ if err != nil {
201+ t.Fatalf("Failed to get zoom level after reset: %v", err)
202+ }
203+
204+ if persistedZoom != expectedZoom {
205+ t.Errorf("Reset zoom not persisted. Expected %f, got %f", expectedZoom, persistedZoom)
206+ }
207+ })
208+}
209+
210+// Integration Test: Navigation with History
211+func TestBrowserControls_NavigationHistory_Integration(t *testing.T) {
212+ _, cleanup := setupTestBrowserService(t)
213+ defer cleanup()
214+
215+ ctx := context.Background()
216+
217+ t.Run("Navigation builds history correctly", func(t *testing.T) {
218+ // This test assumes the service has methods to add history entries
219+ // and that navigation uses real history data
220+
221+ // Add some test history entries directly to database for testing
222+ database, queries, dbCleanup := setupTestDB(t)
223+ defer dbCleanup()
224+
225+ // Insert test history entries
226+ testURLs := []string{
227+ "https://example.com",
228+ "https://google.com",
229+ "https://github.com",
230+ }
231+
232+ for _, url := range testURLs {
233+ _, err := database.Exec(
234+ `INSERT INTO history (url, title, visit_count) VALUES (?, ?, ?)`,
235+ url, "Test Title", 1,
236+ )
237+ if err != nil {
238+ t.Fatalf("Failed to insert test history: %v", err)
239+ }
240+ }
241+
242+ // Create service with populated database
243+ serviceWithHistory := services.NewBrowserService(&config.Config{}, queries)
244+
245+ // Test navigation operations
246+ err := serviceWithHistory.GoBack(ctx)
247+ if err != nil {
248+ t.Logf("GoBack returned error (acceptable for test): %v", err)
249+ }
250+
251+ err = serviceWithHistory.GoForward(ctx)
252+ if err != nil {
253+ t.Logf("GoForward returned error (acceptable for test): %v", err)
254+ }
255+ })
256+}
257+
258+// Integration Test: WindowTitleUpdater Integration
259+func TestBrowserControls_WindowTitleUpdater_Integration(t *testing.T) {
260+ service, cleanup := setupTestBrowserService(t)
261+ defer cleanup()
262+
263+ // Create mock window title updater
264+ mockUpdater := &MockWindowUpdater{
265+ titles: make([]string, 0),
266+ }
267+
268+ // This test would need the actual WindowTitleUpdater interface to be implemented
269+ // For now, we test that the service accepts the updater
270+ t.Run("Service integrates with WindowTitleUpdater", func(t *testing.T) {
271+ // This is a placeholder test - would need actual implementation
272+ // to verify title updates during navigation operations
273+
274+ service.SetWindowTitleUpdater(mockUpdater)
275+
276+ // Simulate navigation that should trigger title update
277+ ctx := context.Background()
278+ service.GoBack(ctx)
279+
280+ // In a full integration test, we would verify that:
281+ // 1. Title updates were called
282+ // 2. Correct titles were set
283+ // 3. Updates happened at appropriate times
284+
285+ // For now, just verify no panic occurred
286+ if len(mockUpdater.titles) < 0 {
287+ t.Errorf("WindowTitleUpdater integration failed")
288+ }
289+ })
290+}
291+
292+// Integration Test: URL Copying with System Integration
293+func TestBrowserControls_URLCopying_Integration(t *testing.T) {
294+ service, cleanup := setupTestBrowserService(t)
295+ defer cleanup()
296+
297+ t.Run("URL copying returns current browser URL", func(t *testing.T) {
298+ ctx := context.Background()
299+
300+ // Test URL copying functionality
301+ testURL := "https://example.com"
302+ err := service.CopyCurrentURL(ctx, testURL)
303+ if err != nil {
304+ t.Fatalf("CopyCurrentURL failed: %v", err)
305+ }
306+
307+ // In a full integration test, we would also verify:
308+ // 1. System clipboard is updated
309+ // 2. Correct URL format
310+ // 3. Special handling for local/file URLs
311+ })
312+}
313+
314+// Integration Test: Performance Requirements
315+func TestBrowserControls_Performance_Integration(t *testing.T) {
316+ service, cleanup := setupTestBrowserService(t)
317+ defer cleanup()
318+
319+ ctx := context.Background()
320+ testURL := "https://example.com"
321+
322+ t.Run("Zoom operations complete within performance requirements", func(t *testing.T) {
323+ // Spec requires <50ms for history search, similar expectation for zoom
324+ start := time.Now()
325+
326+ _, err := service.ZoomIn(ctx, testURL)
327+ if err != nil {
328+ t.Fatalf("ZoomIn failed: %v", err)
329+ }
330+
331+ duration := time.Since(start)
332+
333+ // Zoom operations should be fast (<10ms typically)
334+ maxDuration := 50 * time.Millisecond
335+ if duration > maxDuration {
336+ t.Errorf("Zoom operation too slow: %v (max: %v)", duration, maxDuration)
337+ }
338+ })
339+
340+ t.Run("Navigation operations complete within reasonable time", func(t *testing.T) {
341+ start := time.Now()
342+
343+ err := service.GoBack(ctx)
344+ if err != nil {
345+ t.Fatalf("GoBack failed: %v", err)
346+ }
347+
348+ duration := time.Since(start)
349+
350+ // Navigation should be reasonably fast
351+ maxDuration := 100 * time.Millisecond
352+ if duration > maxDuration {
353+ t.Errorf("Navigation operation too slow: %v (max: %v)", duration, maxDuration)
354+ }
355+ })
356+}
357+
358+// Helper function to verify database state
359+func verifyZoomInDatabase(t *testing.T, queries *db.Queries, url string, expectedZoom float64) {
360+ _ = context.Background() // Test placeholder
361+
362+ // This would use the actual SQLC generated methods
363+ // For now, it's a placeholder for the verification logic
364+
365+ // zoom, err := queries.GetZoomLevel(ctx, url)
366+ // if err != nil {
367+ // t.Fatalf("Failed to get zoom from database: %v", err)
368+ // }
369+ //
370+ // if zoom != expectedZoom {
371+ // t.Errorf("Database zoom mismatch. Expected %f, got %f", expectedZoom, zoom)
372+ // }
373+}⏎̸

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