detached f83599b

feat(wails): integrate Wails v3-alpha browser with unified CLI+GUI architecture

- Add Wails v3-alpha dependency and build system
- Create unified main.go that detects CLI vs GUI mode automatically
- Implement Wails services wrapping existing parser/browser/config functionality
- Add clean landing page with history and shortcuts (no browser UI chrome)
- Support direct website navigation via `dumber browse <url>` command
- Maintain full CLI functionality while adding GUI option
- Create spec for next enhancement phase (002-ui-enhancement-polishing)

Usage:
  ./dumber                 -> Clean landing page with history/shortcuts
  ./dumber browse <url>    -> Direct website navigation
  ./dumber version         -> CLI mode with all existing features
  ./dumber --dmenu         -> Dmenu integration (unchanged)

Single 28MB binary provides both CLI and browser functionality.
commit
f83599b0c18a62f9c6bc858352e2a8fa3aafd23c
author
bamen · · 2025-09-08 19:54 UTC
committer
bamen · · 2025-09-08 18:07 UTC
parent
f17cf68
signature
unsigned
13 files · +1319 -85
A Taskfile.yml +49 -0
@@ -0,0 +1,49 @@
1+version: '3'
2+
3+vars:
4+ APP_NAME: dumber
5+ VERSION: 0.1.0
6+ COMMIT:
7+ sh: git rev-parse --short HEAD 2>/dev/null || echo "dev"
8+ BUILD_DATE:
9+ sh: date -u +"%Y-%m-%dT%H:%M:%SZ"
10+
11+tasks:
12+ build:
13+ desc: Build the unified Dumber application (CLI + Browser)
14+ cmds:
15+ - go build -ldflags "-X main.version={{.VERSION}} -X main.commit={{.COMMIT}} -X main.buildDate={{.BUILD_DATE}}" -o dist/{{.APP_NAME}} main.go
16+
17+ dev:
18+ desc: Run the application in development mode (GUI)
19+ cmds:
20+ - go run main.go
21+
22+ dev-cli:
23+ desc: Run the application in CLI development mode
24+ cmds:
25+ - go run main.go version
26+
27+ test:
28+ desc: Run tests
29+ cmds:
30+ - go test ./...
31+
32+ clean:
33+ desc: Clean build artifacts
34+ cmds:
35+ - rm -rf dist/
36+ - mkdir -p dist
37+
38+ demo:
39+ desc: Show different usage modes
40+ cmds:
41+ - echo "Building unified application..."
42+ - task build
43+ - echo ""
44+ - echo "Usage examples:"
45+ - echo " GUI mode{{":"}} ./dist/{{.APP_NAME}}"
46+ - echo " CLI mode{{":"}} ./dist/{{.APP_NAME}} version"
47+ - echo " History{{":"}} ./dist/{{.APP_NAME}} history"
48+ - echo " Dmenu{{":"}} ./dist/{{.APP_NAME}} --dmenu"
49+ - echo " Browse URL{{":"}} ./dist/{{.APP_NAME}} 'https://github.com'"⏎̸
@@ -0,0 +1,49 @@
1+version: '3'
2+
3+vars:
4+ APP_NAME: dumber
5+ VERSION: 0.1.0
6+ COMMIT:
7+ sh: git rev-parse --short HEAD 2>/dev/null || echo "dev"
8+ BUILD_DATE:
9+ sh: date -u +"%Y-%m-%dT%H:%M:%SZ"
10+
11+tasks:
12+ build:
13+ desc: Build the unified Dumber application (CLI + Browser)
14+ cmds:
15+ - go build -ldflags "-X main.version={{.VERSION}} -X main.commit={{.COMMIT}} -X main.buildDate={{.BUILD_DATE}}" -o dist/{{.APP_NAME}} main.go
16+
17+ dev:
18+ desc: Run the application in development mode (GUI)
19+ cmds:
20+ - go run main.go
21+
22+ dev-cli:
23+ desc: Run the application in CLI development mode
24+ cmds:
25+ - go run main.go version
26+
27+ test:
28+ desc: Run tests
29+ cmds:
30+ - go test ./...
31+
32+ clean:
33+ desc: Clean build artifacts
34+ cmds:
35+ - rm -rf dist/
36+ - mkdir -p dist
37+
38+ demo:
39+ desc: Show different usage modes
40+ cmds:
41+ - echo "Building unified application..."
42+ - task build
43+ - echo ""
44+ - echo "Usage examples:"
45+ - echo " GUI mode{{":"}} ./dist/{{.APP_NAME}}"
46+ - echo " CLI mode{{":"}} ./dist/{{.APP_NAME}} version"
47+ - echo " History{{":"}} ./dist/{{.APP_NAME}} history"
48+ - echo " Dmenu{{":"}} ./dist/{{.APP_NAME}} --dmenu"
49+ - echo " Browse URL{{":"}} ./dist/{{.APP_NAME}} 'https://github.com'"⏎̸
D cmd/dumb-browser/main.go +0 -62
@@ -1,62 +0,0 @@
1-// Package main provides the main entry point for dumb-browser CLI application.
2-package main
3-
4-import (
5- "fmt"
6- "os"
7-
8- "github.com/bnema/dumber/internal/cli"
9- "github.com/bnema/dumber/internal/config"
10-)
11-
12-// Build information set via ldflags
13-var (
14- version = "dev"
15- commit = "none"
16- buildDate = "unknown"
17-)
18-
19-func main() {
20- // Initialize configuration system
21- if err := config.Init(); err != nil {
22- fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err)
23- os.Exit(1)
24- }
25-
26- // Start configuration watching for live reload
27- if err := config.Watch(); err != nil {
28- // Don't exit on watch error, just warn
29- fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err)
30- }
31-
32- rootCmd := cli.NewRootCmd(version, commit, buildDate)
33-
34- // Handle dmenu flag at the root level for direct integration
35- if len(os.Args) > 1 && os.Args[1] == "--dmenu" {
36- // Create a temporary CLI instance to handle dmenu mode
37- cliInstance, err := cli.NewCLI()
38- if err != nil {
39- fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err)
40- os.Exit(1)
41- }
42- defer func() {
43- if closeErr := cliInstance.Close(); closeErr != nil {
44- fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
45- }
46- }()
47-
48- // Generate dmenu options
49- dmenuCmd := cli.NewDmenuCmd()
50- if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil {
51- fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err)
52- os.Exit(1)
53- }
54- return
55- }
56-
57- // Execute the root command
58- if err := rootCmd.Execute(); err != nil {
59- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
60- os.Exit(1)
61- }
62-}⏎̸
@@ -1,62 +0,0 @@
1-// Package main provides the main entry point for dumb-browser CLI application.
2-package main
3-
4-import (
5- "fmt"
6- "os"
7-
8- "github.com/bnema/dumber/internal/cli"
9- "github.com/bnema/dumber/internal/config"
10-)
11-
12-// Build information set via ldflags
13-var (
14- version = "dev"
15- commit = "none"
16- buildDate = "unknown"
17-)
18-
19-func main() {
20- // Initialize configuration system
21- if err := config.Init(); err != nil {
22- fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err)
23- os.Exit(1)
24- }
25-
26- // Start configuration watching for live reload
27- if err := config.Watch(); err != nil {
28- // Don't exit on watch error, just warn
29- fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err)
30- }
31-
32- rootCmd := cli.NewRootCmd(version, commit, buildDate)
33-
34- // Handle dmenu flag at the root level for direct integration
35- if len(os.Args) > 1 && os.Args[1] == "--dmenu" {
36- // Create a temporary CLI instance to handle dmenu mode
37- cliInstance, err := cli.NewCLI()
38- if err != nil {
39- fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err)
40- os.Exit(1)
41- }
42- defer func() {
43- if closeErr := cliInstance.Close(); closeErr != nil {
44- fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
45- }
46- }()
47-
48- // Generate dmenu options
49- dmenuCmd := cli.NewDmenuCmd()
50- if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil {
51- fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err)
52- os.Exit(1)
53- }
54- return
55- }
56-
57- // Execute the root command
58- if err := rootCmd.Execute(); err != nil {
59- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
60- os.Exit(1)
61- }
62-}⏎̸
M go.mod +39 -0
@@ -7,21 +7,60 @@ require (
77 github.com/ncruces/go-sqlite3 v0.29.0
88 github.com/spf13/cobra v1.10.1
99 github.com/spf13/viper v1.21.0
10+ github.com/wailsapp/wails/v3 v3.0.0-alpha.28
1011 )
1112
1213 require (
14+ dario.cat/mergo v1.0.1 // indirect
15+ github.com/Microsoft/go-winio v0.6.2 // indirect
16+ github.com/ProtonMail/go-crypto v1.1.6 // indirect
17+ github.com/adrg/xdg v0.5.3 // indirect
18+ github.com/bep/debounce v1.2.1 // indirect
19+ github.com/cloudflare/circl v1.6.0 // indirect
20+ github.com/cyphar/filepath-securejoin v0.4.1 // indirect
21+ github.com/ebitengine/purego v0.8.2 // indirect
22+ github.com/emirpasic/gods v1.18.1 // indirect
23+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
24+ github.com/go-git/go-billy/v5 v5.6.2 // indirect
25+ github.com/go-git/go-git/v5 v5.13.2 // indirect
26+ github.com/go-ole/go-ole v1.3.0 // indirect
1327 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
28+ github.com/godbus/dbus/v5 v5.1.0 // indirect
29+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
30+ github.com/google/uuid v1.6.0 // indirect
1431 github.com/inconshreveable/mousetrap v1.1.0 // indirect
32+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
33+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
34+ github.com/kevinburke/ssh_config v1.2.0 // indirect
35+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
36+ github.com/leaanthony/u v1.1.1 // indirect
37+ github.com/lmittmann/tint v1.0.7 // indirect
38+ github.com/mattn/go-colorable v0.1.14 // indirect
39+ github.com/mattn/go-isatty v0.0.20 // indirect
1540 github.com/ncruces/julianday v1.0.0 // indirect
1641 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
42+ github.com/pjbgf/sha1cd v0.3.2 // indirect
43+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
44+ github.com/pkg/errors v0.9.1 // indirect
45+ github.com/rivo/uniseg v0.4.7 // indirect
1746 github.com/sagikazarmark/locafero v0.11.0 // indirect
47+ github.com/samber/lo v1.49.1 // indirect
48+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
49+ github.com/skeema/knownhosts v1.3.1 // indirect
1850 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
1951 github.com/spf13/afero v1.15.0 // indirect
2052 github.com/spf13/cast v1.10.0 // indirect
2153 github.com/spf13/pflag v1.0.10 // indirect
2254 github.com/subosito/gotenv v1.6.0 // indirect
2355 github.com/tetratelabs/wazero v1.9.0 // indirect
56+ github.com/wailsapp/go-webview2 v1.0.21 // indirect
57+ github.com/wailsapp/mimetype v1.4.1 // indirect
58+ github.com/xanzy/ssh-agent v0.3.3 // indirect
2459 go.yaml.in/yaml/v3 v3.0.4 // indirect
60+ golang.org/x/crypto v0.41.0 // indirect
61+ golang.org/x/net v0.42.0 // indirect
2562 golang.org/x/sys v0.36.0 // indirect
2663 golang.org/x/text v0.29.0 // indirect
64+ gopkg.in/ini.v1 v1.67.0 // indirect
65+ gopkg.in/warnings.v0 v0.1.2 // indirect
2766 )
@@ -7,21 +7,60 @@ require (
7 github.com/ncruces/go-sqlite3 v0.29.07 github.com/ncruces/go-sqlite3 v0.29.0
8 github.com/spf13/cobra v1.10.18 github.com/spf13/cobra v1.10.1
9 github.com/spf13/viper v1.21.09 github.com/spf13/viper v1.21.0
10+ github.com/wailsapp/wails/v3 v3.0.0-alpha.28
10 )11 )
11 12
12 require (13 require (
14+ dario.cat/mergo v1.0.1 // indirect
15+ github.com/Microsoft/go-winio v0.6.2 // indirect
16+ github.com/ProtonMail/go-crypto v1.1.6 // indirect
17+ github.com/adrg/xdg v0.5.3 // indirect
18+ github.com/bep/debounce v1.2.1 // indirect
19+ github.com/cloudflare/circl v1.6.0 // indirect
20+ github.com/cyphar/filepath-securejoin v0.4.1 // indirect
21+ github.com/ebitengine/purego v0.8.2 // indirect
22+ github.com/emirpasic/gods v1.18.1 // indirect
23+ github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
24+ github.com/go-git/go-billy/v5 v5.6.2 // indirect
25+ github.com/go-git/go-git/v5 v5.13.2 // indirect
26+ github.com/go-ole/go-ole v1.3.0 // indirect
13 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect27 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
28+ github.com/godbus/dbus/v5 v5.1.0 // indirect
29+ github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
30+ github.com/google/uuid v1.6.0 // indirect
14 github.com/inconshreveable/mousetrap v1.1.0 // indirect31 github.com/inconshreveable/mousetrap v1.1.0 // indirect
32+ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
33+ github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
34+ github.com/kevinburke/ssh_config v1.2.0 // indirect
35+ github.com/leaanthony/go-ansi-parser v1.6.1 // indirect
36+ github.com/leaanthony/u v1.1.1 // indirect
37+ github.com/lmittmann/tint v1.0.7 // indirect
38+ github.com/mattn/go-colorable v0.1.14 // indirect
39+ github.com/mattn/go-isatty v0.0.20 // indirect
15 github.com/ncruces/julianday v1.0.0 // indirect40 github.com/ncruces/julianday v1.0.0 // indirect
16 github.com/pelletier/go-toml/v2 v2.2.4 // indirect41 github.com/pelletier/go-toml/v2 v2.2.4 // indirect
42+ github.com/pjbgf/sha1cd v0.3.2 // indirect
43+ github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
44+ github.com/pkg/errors v0.9.1 // indirect
45+ github.com/rivo/uniseg v0.4.7 // indirect
17 github.com/sagikazarmark/locafero v0.11.0 // indirect46 github.com/sagikazarmark/locafero v0.11.0 // indirect
47+ github.com/samber/lo v1.49.1 // indirect
48+ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
49+ github.com/skeema/knownhosts v1.3.1 // indirect
18 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect50 github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
19 github.com/spf13/afero v1.15.0 // indirect51 github.com/spf13/afero v1.15.0 // indirect
20 github.com/spf13/cast v1.10.0 // indirect52 github.com/spf13/cast v1.10.0 // indirect
21 github.com/spf13/pflag v1.0.10 // indirect53 github.com/spf13/pflag v1.0.10 // indirect
22 github.com/subosito/gotenv v1.6.0 // indirect54 github.com/subosito/gotenv v1.6.0 // indirect
23 github.com/tetratelabs/wazero v1.9.0 // indirect55 github.com/tetratelabs/wazero v1.9.0 // indirect
56+ github.com/wailsapp/go-webview2 v1.0.21 // indirect
57+ github.com/wailsapp/mimetype v1.4.1 // indirect
58+ github.com/xanzy/ssh-agent v0.3.3 // indirect
24 go.yaml.in/yaml/v3 v3.0.4 // indirect59 go.yaml.in/yaml/v3 v3.0.4 // indirect
60+ golang.org/x/crypto v0.41.0 // indirect
61+ golang.org/x/net v0.42.0 // indirect
25 golang.org/x/sys v0.36.0 // indirect62 golang.org/x/sys v0.36.0 // indirect
26 golang.org/x/text v0.29.0 // indirect63 golang.org/x/text v0.29.0 // indirect
64+ gopkg.in/ini.v1 v1.67.0 // indirect
65+ gopkg.in/warnings.v0 v0.1.2 // indirect
27 )66 )
M go.sum +130 -5 generated file: collapsed
M internal/cli/browse.go +11 -18
@@ -8,7 +8,6 @@ import (
88 "os"
99 "os/exec"
1010 "regexp"
11- "runtime"
1211 "strings"
1312
1413 "github.com/bnema/dumber/internal/config"
@@ -158,26 +157,20 @@ func openURL(url string) error {
158157 return openURLWithConfig(url, nil)
159158 }
160159
161-// openURLWithConfig opens a URL using system default browser
162-// TODO: Replace with Wails browser integration
163-func openURLWithConfig(url string, _ *config.Config) error {
164- var cmd *exec.Cmd
165-
166- // Use system default browser until Wails integration is ready
167- switch runtime.GOOS {
168- case "linux":
169- cmd = exec.Command("xdg-open", url)
170- case "darwin":
171- cmd = exec.Command("open", url)
172- case "windows":
173- cmd = exec.Command("cmd", "/c", "start", "", url)
174- default:
175- return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
160+// openURLWithConfig opens a URL using our built-in Wails browser
161+func openURLWithConfig(url string, cfg *config.Config) error {
162+ // Get the path to our own executable
163+ executable, err := os.Executable()
164+ if err != nil {
165+ return fmt.Errorf("failed to get executable path: %w", err)
176166 }
177167
168+ // Launch our own browser in browse mode with the URL directly
169+ cmd := exec.Command(executable, "browse", url)
170+
178171 // Start the browser in detached mode
179172 if err := cmd.Start(); err != nil {
180- return fmt.Errorf("failed to open URL in browser: %w", err)
173+ return fmt.Errorf("failed to open URL in built-in browser: %w", err)
181174 }
182175
183176 // Don't wait for the browser process to exit
@@ -185,7 +178,7 @@ func openURLWithConfig(url string, _ *config.Config) error {
185178 _ = cmd.Wait()
186179 }()
187180
188- fmt.Printf("Opening: %s (using system default browser)\n", url)
181+ fmt.Printf("Opening: %s (using built-in browser)\n", url)
189182 return nil
190183 }
191184
@@ -8,7 +8,6 @@ import (
8 "os"8 "os"
9 "os/exec"9 "os/exec"
10 "regexp"10 "regexp"
11- "runtime"
12 "strings"11 "strings"
13 12
14 "github.com/bnema/dumber/internal/config"13 "github.com/bnema/dumber/internal/config"
@@ -158,26 +157,20 @@ func openURL(url string) error {
158 return openURLWithConfig(url, nil)157 return openURLWithConfig(url, nil)
159 }158 }
160 159
161-// openURLWithConfig opens a URL using system default browser160+// openURLWithConfig opens a URL using our built-in Wails browser
162-// TODO: Replace with Wails browser integration161+func openURLWithConfig(url string, cfg *config.Config) error {
163-func openURLWithConfig(url string, _ *config.Config) error {162+ // Get the path to our own executable
164- var cmd *exec.Cmd163+ executable, err := os.Executable()
165-164+ if err != nil {
166- // Use system default browser until Wails integration is ready165+ return fmt.Errorf("failed to get executable path: %w", err)
167- switch runtime.GOOS {
168- case "linux":
169- cmd = exec.Command("xdg-open", url)
170- case "darwin":
171- cmd = exec.Command("open", url)
172- case "windows":
173- cmd = exec.Command("cmd", "/c", "start", "", url)
174- default:
175- return fmt.Errorf("unsupported operating system: %s", runtime.GOOS)
176 }166 }
177 167
168+ // Launch our own browser in browse mode with the URL directly
169+ cmd := exec.Command(executable, "browse", url)
170+
178 // Start the browser in detached mode171 // Start the browser in detached mode
179 if err := cmd.Start(); err != nil {172 if err := cmd.Start(); err != nil {
180- return fmt.Errorf("failed to open URL in browser: %w", err)173+ return fmt.Errorf("failed to open URL in built-in browser: %w", err)
181 }174 }
182 175
183 // Don't wait for the browser process to exit176 // Don't wait for the browser process to exit
@@ -185,7 +178,7 @@ func openURLWithConfig(url string, _ *config.Config) error {
185 _ = cmd.Wait()178 _ = cmd.Wait()
186 }()179 }()
187 180
188- fmt.Printf("Opening: %s (using system default browser)\n", url)181+ fmt.Printf("Opening: %s (using built-in browser)\n", url)
189 return nil182 return nil
190 }183 }
191 184
A internal/db/init.go +111 -0
@@ -0,0 +1,111 @@
1+package db
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/bnema/dumber/internal/config"
10+ _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver
11+ _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite
12+)
13+
14+// InitDB initializes the database connection and schema
15+func InitDB(dbPath string) (*sql.DB, error) {
16+ if dbPath == "" {
17+ return nil, fmt.Errorf("database path cannot be empty")
18+ }
19+
20+ // Ensure database directory exists
21+ if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
22+ return nil, fmt.Errorf("failed to create database directory: %w", err)
23+ }
24+
25+ // Open database connection
26+ database, err := sql.Open("sqlite3", dbPath)
27+ if err != nil {
28+ return nil, fmt.Errorf("failed to open database: %w", err)
29+ }
30+
31+ // Test the connection
32+ if err := database.Ping(); err != nil {
33+ database.Close()
34+ return nil, fmt.Errorf("failed to connect to database: %w", err)
35+ }
36+
37+ // Initialize database schema
38+ if err := initializeSchema(database); err != nil {
39+ database.Close()
40+ return nil, fmt.Errorf("failed to initialize database schema: %w", err)
41+ }
42+
43+ return database, nil
44+}
45+
46+// InitDBWithConfig initializes the database with configuration-based shortcuts
47+func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) {
48+ db, err := InitDB(dbPath)
49+ if err != nil {
50+ return nil, err
51+ }
52+
53+ // Initialize shortcuts from configuration
54+ if err := initializeShortcuts(db, cfg); err != nil {
55+ db.Close()
56+ return nil, fmt.Errorf("failed to initialize shortcuts: %w", err)
57+ }
58+
59+ return db, nil
60+}
61+
62+// initializeSchema creates the database schema if it doesn't exist
63+func initializeSchema(db *sql.DB) error {
64+ schema := `
65+ -- History tracking for visited URLs
66+ CREATE TABLE IF NOT EXISTS history (
67+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68+ url TEXT NOT NULL UNIQUE,
69+ title TEXT,
70+ visit_count INTEGER DEFAULT 1,
71+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
72+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
73+ );
74+
75+ CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
76+ CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
77+
78+ -- URL shortcuts configuration
79+ CREATE TABLE IF NOT EXISTS shortcuts (
80+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81+ shortcut TEXT NOT NULL UNIQUE,
82+ url_template TEXT NOT NULL,
83+ description TEXT,
84+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
85+ );
86+ `
87+
88+ // Execute schema creation
89+ _, err := db.Exec(schema)
90+ return err
91+}
92+
93+// initializeShortcuts inserts configured shortcuts into the database
94+func initializeShortcuts(db *sql.DB, cfg *config.Config) error {
95+ if cfg == nil || cfg.SearchShortcuts == nil {
96+ return nil // No shortcuts to initialize
97+ }
98+
99+ // Insert configured shortcuts
100+ for shortcut, shortcutCfg := range cfg.SearchShortcuts {
101+ _, err := db.Exec(
102+ "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
103+ shortcut, shortcutCfg.URL, shortcutCfg.Description,
104+ )
105+ if err != nil {
106+ return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err)
107+ }
108+ }
109+
110+ return nil
111+}⏎̸
@@ -0,0 +1,111 @@
1+package db
2+
3+import (
4+ "database/sql"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/bnema/dumber/internal/config"
10+ _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver
11+ _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite
12+)
13+
14+// InitDB initializes the database connection and schema
15+func InitDB(dbPath string) (*sql.DB, error) {
16+ if dbPath == "" {
17+ return nil, fmt.Errorf("database path cannot be empty")
18+ }
19+
20+ // Ensure database directory exists
21+ if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
22+ return nil, fmt.Errorf("failed to create database directory: %w", err)
23+ }
24+
25+ // Open database connection
26+ database, err := sql.Open("sqlite3", dbPath)
27+ if err != nil {
28+ return nil, fmt.Errorf("failed to open database: %w", err)
29+ }
30+
31+ // Test the connection
32+ if err := database.Ping(); err != nil {
33+ database.Close()
34+ return nil, fmt.Errorf("failed to connect to database: %w", err)
35+ }
36+
37+ // Initialize database schema
38+ if err := initializeSchema(database); err != nil {
39+ database.Close()
40+ return nil, fmt.Errorf("failed to initialize database schema: %w", err)
41+ }
42+
43+ return database, nil
44+}
45+
46+// InitDBWithConfig initializes the database with configuration-based shortcuts
47+func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) {
48+ db, err := InitDB(dbPath)
49+ if err != nil {
50+ return nil, err
51+ }
52+
53+ // Initialize shortcuts from configuration
54+ if err := initializeShortcuts(db, cfg); err != nil {
55+ db.Close()
56+ return nil, fmt.Errorf("failed to initialize shortcuts: %w", err)
57+ }
58+
59+ return db, nil
60+}
61+
62+// initializeSchema creates the database schema if it doesn't exist
63+func initializeSchema(db *sql.DB) error {
64+ schema := `
65+ -- History tracking for visited URLs
66+ CREATE TABLE IF NOT EXISTS history (
67+ id INTEGER PRIMARY KEY AUTOINCREMENT,
68+ url TEXT NOT NULL UNIQUE,
69+ title TEXT,
70+ visit_count INTEGER DEFAULT 1,
71+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
72+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
73+ );
74+
75+ CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
76+ CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
77+
78+ -- URL shortcuts configuration
79+ CREATE TABLE IF NOT EXISTS shortcuts (
80+ id INTEGER PRIMARY KEY AUTOINCREMENT,
81+ shortcut TEXT NOT NULL UNIQUE,
82+ url_template TEXT NOT NULL,
83+ description TEXT,
84+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
85+ );
86+ `
87+
88+ // Execute schema creation
89+ _, err := db.Exec(schema)
90+ return err
91+}
92+
93+// initializeShortcuts inserts configured shortcuts into the database
94+func initializeShortcuts(db *sql.DB, cfg *config.Config) error {
95+ if cfg == nil || cfg.SearchShortcuts == nil {
96+ return nil // No shortcuts to initialize
97+ }
98+
99+ // Insert configured shortcuts
100+ for shortcut, shortcutCfg := range cfg.SearchShortcuts {
101+ _, err := db.Exec(
102+ "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
103+ shortcut, shortcutCfg.URL, shortcutCfg.Description,
104+ )
105+ if err != nil {
106+ return fmt.Errorf("failed to insert shortcut %s: %w", shortcut, err)
107+ }
108+ }
109+
110+ return nil
111+}⏎̸
A main.go +190 -0
@@ -0,0 +1,190 @@
1+package main
2+
3+import (
4+ "context"
5+ "embed"
6+ "fmt"
7+ "log"
8+ "log/slog"
9+ "os"
10+
11+ "github.com/wailsapp/wails/v3/pkg/application"
12+
13+ "github.com/bnema/dumber/internal/cli"
14+ "github.com/bnema/dumber/internal/config"
15+ "github.com/bnema/dumber/internal/db"
16+ "github.com/bnema/dumber/services"
17+)
18+
19+// Embed the frontend assets
20+//go:embed frontend/dist
21+var assets embed.FS
22+
23+// Build information set via ldflags
24+var (
25+ version = "dev"
26+ commit = "none"
27+ buildDate = "unknown"
28+)
29+
30+func main() {
31+ // Check if we should run the CLI mode
32+ if shouldRunCLI() {
33+ runCLI()
34+ return
35+ }
36+
37+ // Otherwise run the GUI browser
38+ runBrowser()
39+}
40+
41+// shouldRunCLI determines if we should run in CLI mode based on arguments
42+func shouldRunCLI() bool {
43+ // No args = GUI landing page mode
44+ if len(os.Args) <= 1 {
45+ return false
46+ }
47+
48+ // Check for GUI-specific flags
49+ for _, arg := range os.Args[1:] {
50+ if arg == "--gui" || arg == "-g" {
51+ return false // Explicit GUI mode
52+ }
53+ }
54+
55+ // Check for browse command - this should open GUI in direct navigation mode
56+ if len(os.Args) >= 2 && os.Args[1] == "browse" {
57+ return false // Browse command uses GUI mode but navigates directly
58+ }
59+
60+ // Any other arguments mean CLI mode
61+ return true
62+}
63+
64+// runCLI executes the CLI functionality using the existing CLI package
65+func runCLI() {
66+ // Initialize configuration system
67+ if err := config.Init(); err != nil {
68+ fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err)
69+ os.Exit(1)
70+ }
71+
72+ // Start configuration watching for live reload
73+ if err := config.Watch(); err != nil {
74+ // Don't exit on watch error, just warn
75+ fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err)
76+ }
77+
78+ rootCmd := cli.NewRootCmd(version, commit, buildDate)
79+
80+ // Handle dmenu flag at the root level for direct integration
81+ if len(os.Args) > 1 && os.Args[1] == "--dmenu" {
82+ // Create a temporary CLI instance to handle dmenu mode
83+ cliInstance, err := cli.NewCLI()
84+ if err != nil {
85+ fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err)
86+ os.Exit(1)
87+ }
88+ defer func() {
89+ if closeErr := cliInstance.Close(); closeErr != nil {
90+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
91+ }
92+ }()
93+
94+ // Generate dmenu options
95+ dmenuCmd := cli.NewDmenuCmd()
96+ if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil {
97+ fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err)
98+ os.Exit(1)
99+ }
100+ return
101+ }
102+
103+ // Execute the root command
104+ if err := rootCmd.Execute(); err != nil {
105+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
106+ os.Exit(1)
107+ }
108+}
109+
110+// runBrowser executes the browser GUI functionality
111+func runBrowser() {
112+ // Initialize configuration
113+ if err := config.Init(); err != nil {
114+ log.Fatalf("Failed to initialize config: %v", err)
115+ }
116+ cfg := config.Get()
117+
118+ // Initialize database
119+ database, err := db.InitDB(cfg.Database.Path)
120+ if err != nil {
121+ log.Fatalf("Failed to initialize database: %v", err)
122+ }
123+ defer database.Close()
124+
125+ queries := db.New(database)
126+
127+ // Initialize services
128+ parserService := services.NewParserService(cfg, queries)
129+ browserService := services.NewBrowserService(cfg, queries)
130+ configService := services.NewConfigService(cfg, "")
131+
132+ // Check if this is a browse command with URL
133+ var directURL string
134+ if len(os.Args) >= 3 && os.Args[1] == "browse" {
135+ // Parse the URL using our parser service
136+ ctx := context.Background()
137+ result, err := parserService.ParseInput(ctx, os.Args[2])
138+ if err != nil {
139+ log.Printf("Error parsing URL: %v", err)
140+ } else {
141+ directURL = result.URL
142+ // Record the visit
143+ browserService.Navigate(ctx, directURL)
144+ }
145+ }
146+
147+ // Create application with correct v3-alpha options
148+ app := application.New(application.Options{
149+ Name: "dumber-browser",
150+ Description: "A smart URL launcher and browser with learning behavior",
151+ LogLevel: slog.LevelInfo,
152+
153+ Services: []application.Service{
154+ application.NewService(parserService),
155+ application.NewService(browserService),
156+ application.NewService(configService),
157+ },
158+
159+ Assets: application.AssetOptions{
160+ Handler: application.AssetFileServerFS(assets),
161+ },
162+
163+ Mac: application.MacOptions{
164+ ApplicationShouldTerminateAfterLastWindowClosed: true,
165+ },
166+ })
167+
168+ // Determine the starting URL
169+ startURL := "/"
170+ if directURL != "" {
171+ startURL = directURL
172+ }
173+
174+ // Create the main window
175+ window := app.Window.NewWithOptions(application.WebviewWindowOptions{
176+ Title: "Dumber Browser",
177+ Width: 1200,
178+ Height: 800,
179+ URL: startURL,
180+ })
181+
182+ // Show the window
183+ window.Show()
184+
185+ // Run the application (blocking call)
186+ err = app.Run()
187+ if err != nil {
188+ log.Fatal(err)
189+ }
190+}⏎̸
@@ -0,0 +1,190 @@
1+package main
2+
3+import (
4+ "context"
5+ "embed"
6+ "fmt"
7+ "log"
8+ "log/slog"
9+ "os"
10+
11+ "github.com/wailsapp/wails/v3/pkg/application"
12+
13+ "github.com/bnema/dumber/internal/cli"
14+ "github.com/bnema/dumber/internal/config"
15+ "github.com/bnema/dumber/internal/db"
16+ "github.com/bnema/dumber/services"
17+)
18+
19+// Embed the frontend assets
20+//go:embed frontend/dist
21+var assets embed.FS
22+
23+// Build information set via ldflags
24+var (
25+ version = "dev"
26+ commit = "none"
27+ buildDate = "unknown"
28+)
29+
30+func main() {
31+ // Check if we should run the CLI mode
32+ if shouldRunCLI() {
33+ runCLI()
34+ return
35+ }
36+
37+ // Otherwise run the GUI browser
38+ runBrowser()
39+}
40+
41+// shouldRunCLI determines if we should run in CLI mode based on arguments
42+func shouldRunCLI() bool {
43+ // No args = GUI landing page mode
44+ if len(os.Args) <= 1 {
45+ return false
46+ }
47+
48+ // Check for GUI-specific flags
49+ for _, arg := range os.Args[1:] {
50+ if arg == "--gui" || arg == "-g" {
51+ return false // Explicit GUI mode
52+ }
53+ }
54+
55+ // Check for browse command - this should open GUI in direct navigation mode
56+ if len(os.Args) >= 2 && os.Args[1] == "browse" {
57+ return false // Browse command uses GUI mode but navigates directly
58+ }
59+
60+ // Any other arguments mean CLI mode
61+ return true
62+}
63+
64+// runCLI executes the CLI functionality using the existing CLI package
65+func runCLI() {
66+ // Initialize configuration system
67+ if err := config.Init(); err != nil {
68+ fmt.Fprintf(os.Stderr, "Error initializing configuration: %v\n", err)
69+ os.Exit(1)
70+ }
71+
72+ // Start configuration watching for live reload
73+ if err := config.Watch(); err != nil {
74+ // Don't exit on watch error, just warn
75+ fmt.Fprintf(os.Stderr, "Warning: failed to start config watching: %v\n", err)
76+ }
77+
78+ rootCmd := cli.NewRootCmd(version, commit, buildDate)
79+
80+ // Handle dmenu flag at the root level for direct integration
81+ if len(os.Args) > 1 && os.Args[1] == "--dmenu" {
82+ // Create a temporary CLI instance to handle dmenu mode
83+ cliInstance, err := cli.NewCLI()
84+ if err != nil {
85+ fmt.Fprintf(os.Stderr, "Error initializing CLI: %v\n", err)
86+ os.Exit(1)
87+ }
88+ defer func() {
89+ if closeErr := cliInstance.Close(); closeErr != nil {
90+ fmt.Fprintf(os.Stderr, "Warning: failed to close database: %v\n", closeErr)
91+ }
92+ }()
93+
94+ // Generate dmenu options
95+ dmenuCmd := cli.NewDmenuCmd()
96+ if err := dmenuCmd.RunE(dmenuCmd, []string{}); err != nil {
97+ fmt.Fprintf(os.Stderr, "Error in dmenu mode: %v\n", err)
98+ os.Exit(1)
99+ }
100+ return
101+ }
102+
103+ // Execute the root command
104+ if err := rootCmd.Execute(); err != nil {
105+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
106+ os.Exit(1)
107+ }
108+}
109+
110+// runBrowser executes the browser GUI functionality
111+func runBrowser() {
112+ // Initialize configuration
113+ if err := config.Init(); err != nil {
114+ log.Fatalf("Failed to initialize config: %v", err)
115+ }
116+ cfg := config.Get()
117+
118+ // Initialize database
119+ database, err := db.InitDB(cfg.Database.Path)
120+ if err != nil {
121+ log.Fatalf("Failed to initialize database: %v", err)
122+ }
123+ defer database.Close()
124+
125+ queries := db.New(database)
126+
127+ // Initialize services
128+ parserService := services.NewParserService(cfg, queries)
129+ browserService := services.NewBrowserService(cfg, queries)
130+ configService := services.NewConfigService(cfg, "")
131+
132+ // Check if this is a browse command with URL
133+ var directURL string
134+ if len(os.Args) >= 3 && os.Args[1] == "browse" {
135+ // Parse the URL using our parser service
136+ ctx := context.Background()
137+ result, err := parserService.ParseInput(ctx, os.Args[2])
138+ if err != nil {
139+ log.Printf("Error parsing URL: %v", err)
140+ } else {
141+ directURL = result.URL
142+ // Record the visit
143+ browserService.Navigate(ctx, directURL)
144+ }
145+ }
146+
147+ // Create application with correct v3-alpha options
148+ app := application.New(application.Options{
149+ Name: "dumber-browser",
150+ Description: "A smart URL launcher and browser with learning behavior",
151+ LogLevel: slog.LevelInfo,
152+
153+ Services: []application.Service{
154+ application.NewService(parserService),
155+ application.NewService(browserService),
156+ application.NewService(configService),
157+ },
158+
159+ Assets: application.AssetOptions{
160+ Handler: application.AssetFileServerFS(assets),
161+ },
162+
163+ Mac: application.MacOptions{
164+ ApplicationShouldTerminateAfterLastWindowClosed: true,
165+ },
166+ })
167+
168+ // Determine the starting URL
169+ startURL := "/"
170+ if directURL != "" {
171+ startURL = directURL
172+ }
173+
174+ // Create the main window
175+ window := app.Window.NewWithOptions(application.WebviewWindowOptions{
176+ Title: "Dumber Browser",
177+ Width: 1200,
178+ Height: 800,
179+ URL: startURL,
180+ })
181+
182+ // Show the window
183+ window.Show()
184+
185+ // Run the application (blocking call)
186+ err = app.Run()
187+ if err != nil {
188+ log.Fatal(err)
189+ }
190+}⏎̸
A services/browser_service.go +205 -0
@@ -0,0 +1,205 @@
1+package services
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/config"
10+ "github.com/bnema/dumber/internal/db"
11+)
12+
13+// BrowserService handles browser-related operations for Wails integration.
14+type BrowserService struct {
15+ config *config.Config
16+ dbQueries *db.Queries
17+}
18+
19+// NavigationResult represents the result of a navigation operation.
20+type NavigationResult struct {
21+ URL string `json:"url"`
22+ Title string `json:"title"`
23+ Success bool `json:"success"`
24+ Error string `json:"error,omitempty"`
25+ LoadTime time.Duration `json:"load_time"`
26+ RedirectChain []string `json:"redirect_chain,omitempty"`
27+}
28+
29+// HistoryEntry represents a simplified history entry for frontend.
30+type HistoryEntry struct {
31+ ID int64 `json:"id"`
32+ URL string `json:"url"`
33+ Title string `json:"title"`
34+ VisitCount int32 `json:"visit_count"`
35+ LastVisited time.Time `json:"last_visited"`
36+ CreatedAt time.Time `json:"created_at"`
37+}
38+
39+// NewBrowserService creates a new BrowserService instance.
40+func NewBrowserService(cfg *config.Config, queries *db.Queries) *BrowserService {
41+ return &BrowserService{
42+ config: cfg,
43+ dbQueries: queries,
44+ }
45+}
46+
47+// Navigate handles navigation to a URL and records it in history.
48+func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) {
49+ startTime := time.Now()
50+
51+ if url == "" {
52+ return &NavigationResult{
53+ Success: false,
54+ Error: "URL cannot be empty",
55+ }, nil
56+ }
57+
58+ // Record the navigation in history
59+ err := s.recordHistory(ctx, url, "")
60+ if err != nil {
61+ // Don't fail navigation if history recording fails
62+ fmt.Printf("Failed to record history: %v\n", err)
63+ }
64+
65+ return &NavigationResult{
66+ URL: url,
67+ Success: true,
68+ LoadTime: time.Since(startTime),
69+ }, nil
70+}
71+
72+// UpdatePageTitle updates the title of the current page in history.
73+func (s *BrowserService) UpdatePageTitle(ctx context.Context, url, title string) error {
74+ if url == "" || title == "" {
75+ return fmt.Errorf("URL and title cannot be empty")
76+ }
77+
78+ // Use AddOrUpdateHistory to update the title
79+ titleNull := sql.NullString{String: title, Valid: true}
80+ return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
81+}
82+
83+// GetRecentHistory returns recent browser history entries.
84+func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]HistoryEntry, error) {
85+ if limit <= 0 {
86+ limit = 50 // Default limit
87+ }
88+
89+ entries, err := s.dbQueries.GetHistory(ctx, int64(limit))
90+ if err != nil {
91+ return nil, err
92+ }
93+
94+ result := make([]HistoryEntry, len(entries))
95+ for i, entry := range entries {
96+ result[i] = HistoryEntry{
97+ ID: entry.ID,
98+ URL: entry.Url,
99+ Title: entry.Title.String,
100+ VisitCount: int32(entry.VisitCount.Int64),
101+ LastVisited: entry.LastVisited.Time,
102+ CreatedAt: entry.CreatedAt.Time,
103+ }
104+ }
105+
106+ return result, nil
107+}
108+
109+// SearchHistory searches browser history.
110+func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
111+ if query == "" {
112+ return s.GetRecentHistory(ctx, limit)
113+ }
114+
115+ if limit <= 0 {
116+ limit = 50
117+ }
118+
119+ urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
120+ titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
121+
122+ entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
123+ if err != nil {
124+ return nil, err
125+ }
126+
127+ result := make([]HistoryEntry, len(entries))
128+ for i, entry := range entries {
129+ result[i] = HistoryEntry{
130+ ID: entry.ID,
131+ URL: entry.Url,
132+ Title: entry.Title.String,
133+ VisitCount: int32(entry.VisitCount.Int64),
134+ LastVisited: entry.LastVisited.Time,
135+ CreatedAt: entry.CreatedAt.Time,
136+ }
137+ }
138+
139+ return result, nil
140+}
141+
142+// DeleteHistoryEntry removes a specific history entry.
143+func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
144+ // Note: DeleteHistory method doesn't exist in current schema
145+ // This would need to be implemented in the database layer
146+ return fmt.Errorf("delete history not implemented yet")
147+}
148+
149+// ClearHistory removes all history entries.
150+func (s *BrowserService) ClearHistory(ctx context.Context) error {
151+ // Note: ClearAllHistory method doesn't exist in current schema
152+ // This would need to be implemented in the database layer
153+ return fmt.Errorf("clear all history not implemented yet")
154+}
155+
156+// GetHistoryStats returns statistics about browser history.
157+func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) {
158+ // Get recent entries for basic stats
159+ recentEntries, err := s.dbQueries.GetHistory(ctx, 1000)
160+ if err != nil {
161+ return nil, err
162+ }
163+
164+ // Calculate basic statistics
165+ stats := map[string]interface{}{
166+ "total_entries": len(recentEntries),
167+ "recent_count": len(recentEntries),
168+ }
169+
170+ // Add more detailed stats if we have entries
171+ if len(recentEntries) > 0 {
172+ stats["oldest_entry"] = recentEntries[len(recentEntries)-1].CreatedAt.Time
173+ stats["newest_entry"] = recentEntries[0].CreatedAt.Time
174+ }
175+
176+ return stats, nil
177+}
178+
179+// GetConfig returns the current browser configuration.
180+func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) {
181+ return s.config, nil
182+}
183+
184+// UpdateConfig updates the browser configuration.
185+func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error {
186+ // In a real implementation, you'd want to validate and persist the config
187+ s.config = newConfig
188+ return nil
189+}
190+
191+// GetSearchShortcuts returns available search shortcuts.
192+func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
193+ return s.config.SearchShortcuts, nil
194+}
195+
196+// recordHistory adds or updates a history entry.
197+func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
198+ // Use AddOrUpdateHistory which handles both cases
199+ titleNull := sql.NullString{Valid: false}
200+ if title != "" {
201+ titleNull = sql.NullString{String: title, Valid: true}
202+ }
203+
204+ return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
205+}⏎̸
@@ -0,0 +1,205 @@
1+package services
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/config"
10+ "github.com/bnema/dumber/internal/db"
11+)
12+
13+// BrowserService handles browser-related operations for Wails integration.
14+type BrowserService struct {
15+ config *config.Config
16+ dbQueries *db.Queries
17+}
18+
19+// NavigationResult represents the result of a navigation operation.
20+type NavigationResult struct {
21+ URL string `json:"url"`
22+ Title string `json:"title"`
23+ Success bool `json:"success"`
24+ Error string `json:"error,omitempty"`
25+ LoadTime time.Duration `json:"load_time"`
26+ RedirectChain []string `json:"redirect_chain,omitempty"`
27+}
28+
29+// HistoryEntry represents a simplified history entry for frontend.
30+type HistoryEntry struct {
31+ ID int64 `json:"id"`
32+ URL string `json:"url"`
33+ Title string `json:"title"`
34+ VisitCount int32 `json:"visit_count"`
35+ LastVisited time.Time `json:"last_visited"`
36+ CreatedAt time.Time `json:"created_at"`
37+}
38+
39+// NewBrowserService creates a new BrowserService instance.
40+func NewBrowserService(cfg *config.Config, queries *db.Queries) *BrowserService {
41+ return &BrowserService{
42+ config: cfg,
43+ dbQueries: queries,
44+ }
45+}
46+
47+// Navigate handles navigation to a URL and records it in history.
48+func (s *BrowserService) Navigate(ctx context.Context, url string) (*NavigationResult, error) {
49+ startTime := time.Now()
50+
51+ if url == "" {
52+ return &NavigationResult{
53+ Success: false,
54+ Error: "URL cannot be empty",
55+ }, nil
56+ }
57+
58+ // Record the navigation in history
59+ err := s.recordHistory(ctx, url, "")
60+ if err != nil {
61+ // Don't fail navigation if history recording fails
62+ fmt.Printf("Failed to record history: %v\n", err)
63+ }
64+
65+ return &NavigationResult{
66+ URL: url,
67+ Success: true,
68+ LoadTime: time.Since(startTime),
69+ }, nil
70+}
71+
72+// UpdatePageTitle updates the title of the current page in history.
73+func (s *BrowserService) UpdatePageTitle(ctx context.Context, url, title string) error {
74+ if url == "" || title == "" {
75+ return fmt.Errorf("URL and title cannot be empty")
76+ }
77+
78+ // Use AddOrUpdateHistory to update the title
79+ titleNull := sql.NullString{String: title, Valid: true}
80+ return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
81+}
82+
83+// GetRecentHistory returns recent browser history entries.
84+func (s *BrowserService) GetRecentHistory(ctx context.Context, limit int) ([]HistoryEntry, error) {
85+ if limit <= 0 {
86+ limit = 50 // Default limit
87+ }
88+
89+ entries, err := s.dbQueries.GetHistory(ctx, int64(limit))
90+ if err != nil {
91+ return nil, err
92+ }
93+
94+ result := make([]HistoryEntry, len(entries))
95+ for i, entry := range entries {
96+ result[i] = HistoryEntry{
97+ ID: entry.ID,
98+ URL: entry.Url,
99+ Title: entry.Title.String,
100+ VisitCount: int32(entry.VisitCount.Int64),
101+ LastVisited: entry.LastVisited.Time,
102+ CreatedAt: entry.CreatedAt.Time,
103+ }
104+ }
105+
106+ return result, nil
107+}
108+
109+// SearchHistory searches browser history.
110+func (s *BrowserService) SearchHistory(ctx context.Context, query string, limit int) ([]HistoryEntry, error) {
111+ if query == "" {
112+ return s.GetRecentHistory(ctx, limit)
113+ }
114+
115+ if limit <= 0 {
116+ limit = 50
117+ }
118+
119+ urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
120+ titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
121+
122+ entries, err := s.dbQueries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
123+ if err != nil {
124+ return nil, err
125+ }
126+
127+ result := make([]HistoryEntry, len(entries))
128+ for i, entry := range entries {
129+ result[i] = HistoryEntry{
130+ ID: entry.ID,
131+ URL: entry.Url,
132+ Title: entry.Title.String,
133+ VisitCount: int32(entry.VisitCount.Int64),
134+ LastVisited: entry.LastVisited.Time,
135+ CreatedAt: entry.CreatedAt.Time,
136+ }
137+ }
138+
139+ return result, nil
140+}
141+
142+// DeleteHistoryEntry removes a specific history entry.
143+func (s *BrowserService) DeleteHistoryEntry(ctx context.Context, id int64) error {
144+ // Note: DeleteHistory method doesn't exist in current schema
145+ // This would need to be implemented in the database layer
146+ return fmt.Errorf("delete history not implemented yet")
147+}
148+
149+// ClearHistory removes all history entries.
150+func (s *BrowserService) ClearHistory(ctx context.Context) error {
151+ // Note: ClearAllHistory method doesn't exist in current schema
152+ // This would need to be implemented in the database layer
153+ return fmt.Errorf("clear all history not implemented yet")
154+}
155+
156+// GetHistoryStats returns statistics about browser history.
157+func (s *BrowserService) GetHistoryStats(ctx context.Context) (map[string]interface{}, error) {
158+ // Get recent entries for basic stats
159+ recentEntries, err := s.dbQueries.GetHistory(ctx, 1000)
160+ if err != nil {
161+ return nil, err
162+ }
163+
164+ // Calculate basic statistics
165+ stats := map[string]interface{}{
166+ "total_entries": len(recentEntries),
167+ "recent_count": len(recentEntries),
168+ }
169+
170+ // Add more detailed stats if we have entries
171+ if len(recentEntries) > 0 {
172+ stats["oldest_entry"] = recentEntries[len(recentEntries)-1].CreatedAt.Time
173+ stats["newest_entry"] = recentEntries[0].CreatedAt.Time
174+ }
175+
176+ return stats, nil
177+}
178+
179+// GetConfig returns the current browser configuration.
180+func (s *BrowserService) GetConfig(ctx context.Context) (*config.Config, error) {
181+ return s.config, nil
182+}
183+
184+// UpdateConfig updates the browser configuration.
185+func (s *BrowserService) UpdateConfig(ctx context.Context, newConfig *config.Config) error {
186+ // In a real implementation, you'd want to validate and persist the config
187+ s.config = newConfig
188+ return nil
189+}
190+
191+// GetSearchShortcuts returns available search shortcuts.
192+func (s *BrowserService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
193+ return s.config.SearchShortcuts, nil
194+}
195+
196+// recordHistory adds or updates a history entry.
197+func (s *BrowserService) recordHistory(ctx context.Context, url, title string) error {
198+ // Use AddOrUpdateHistory which handles both cases
199+ titleNull := sql.NullString{Valid: false}
200+ if title != "" {
201+ titleNull = sql.NullString{String: title, Valid: true}
202+ }
203+
204+ return s.dbQueries.AddOrUpdateHistory(ctx, url, titleNull)
205+}⏎̸
A services/config_service.go +240 -0
@@ -0,0 +1,240 @@
1+package services
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/bnema/dumber/internal/config"
10+)
11+
12+// ConfigService handles configuration operations for Wails integration.
13+type ConfigService struct {
14+ config *config.Config
15+ configPath string
16+}
17+
18+// ConfigInfo represents configuration information for the frontend.
19+type ConfigInfo struct {
20+ ConfigPath string `json:"config_path"`
21+ DatabasePath string `json:"database_path"`
22+ SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
23+ DmenuSettings *config.DmenuConfig `json:"dmenu_settings"`
24+}
25+
26+// NewConfigService creates a new ConfigService instance.
27+func NewConfigService(cfg *config.Config, configPath string) *ConfigService {
28+ return &ConfigService{
29+ config: cfg,
30+ configPath: configPath,
31+ }
32+}
33+
34+// GetConfigInfo returns comprehensive configuration information.
35+func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error) {
36+ return &ConfigInfo{
37+ ConfigPath: s.configPath,
38+ DatabasePath: s.config.Database.Path,
39+ SearchShortcuts: s.config.SearchShortcuts,
40+ DmenuSettings: &s.config.Dmenu,
41+ }, nil
42+}
43+
44+// GetSearchShortcuts returns all configured search shortcuts.
45+func (s *ConfigService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
46+ return s.config.SearchShortcuts, nil
47+}
48+
49+// AddSearchShortcut adds a new search shortcut.
50+func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error {
51+ if key == "" {
52+ return fmt.Errorf("shortcut key cannot be empty")
53+ }
54+
55+ if shortcut.URL == "" {
56+ return fmt.Errorf("shortcut URL cannot be empty")
57+ }
58+
59+ if s.config.SearchShortcuts == nil {
60+ s.config.SearchShortcuts = make(map[string]config.SearchShortcut)
61+ }
62+
63+ s.config.SearchShortcuts[key] = shortcut
64+ return s.saveConfig()
65+}
66+
67+// UpdateSearchShortcut updates an existing search shortcut.
68+func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error {
69+ if key == "" {
70+ return fmt.Errorf("shortcut key cannot be empty")
71+ }
72+
73+ if _, exists := s.config.SearchShortcuts[key]; !exists {
74+ return fmt.Errorf("shortcut '%s' does not exist", key)
75+ }
76+
77+ if shortcut.URL == "" {
78+ return fmt.Errorf("shortcut URL cannot be empty")
79+ }
80+
81+ s.config.SearchShortcuts[key] = shortcut
82+ return s.saveConfig()
83+}
84+
85+// DeleteSearchShortcut removes a search shortcut.
86+func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) error {
87+ if key == "" {
88+ return fmt.Errorf("shortcut key cannot be empty")
89+ }
90+
91+ if _, exists := s.config.SearchShortcuts[key]; !exists {
92+ return fmt.Errorf("shortcut '%s' does not exist", key)
93+ }
94+
95+ delete(s.config.SearchShortcuts, key)
96+ return s.saveConfig()
97+}
98+
99+// GetDmenuSettings returns current dmenu settings.
100+func (s *ConfigService) GetDmenuSettings(ctx context.Context) (*config.DmenuConfig, error) {
101+ return &s.config.Dmenu, nil
102+}
103+
104+// UpdateDmenuSettings updates dmenu configuration.
105+func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *config.DmenuConfig) error {
106+ if settings == nil {
107+ return fmt.Errorf("dmenu settings cannot be nil")
108+ }
109+
110+ s.config.Dmenu = *settings
111+ return s.saveConfig()
112+}
113+
114+// GetDatabasePath returns the current database path.
115+func (s *ConfigService) GetDatabasePath(ctx context.Context) (string, error) {
116+ return s.config.Database.Path, nil
117+}
118+
119+// SetDatabasePath updates the database path.
120+func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error {
121+ if path == "" {
122+ return fmt.Errorf("database path cannot be empty")
123+ }
124+
125+ // Validate that the directory exists or can be created
126+ dir := filepath.Dir(path)
127+ if err := os.MkdirAll(dir, 0755); err != nil {
128+ return fmt.Errorf("failed to create database directory: %w", err)
129+ }
130+
131+ s.config.Database.Path = path
132+ return s.saveConfig()
133+}
134+
135+// GetDefaultBrowser returns the default browser setting.
136+func (s *ConfigService) GetDefaultBrowser(ctx context.Context) (string, error) {
137+ // Default browser is now built into the app
138+ return "dumber (built-in)", nil
139+}
140+
141+// SetDefaultBrowser updates the default browser setting.
142+func (s *ConfigService) SetDefaultBrowser(ctx context.Context, browser string) error {
143+ // Default browser setting is not needed for built-in browser
144+ return fmt.Errorf("default browser is built-in and cannot be changed")
145+}
146+
147+// ReloadConfig reloads configuration from file.
148+func (s *ConfigService) ReloadConfig(ctx context.Context) error {
149+ // Create new manager and load config
150+ manager, err := config.NewManager()
151+ if err != nil {
152+ return fmt.Errorf("failed to create config manager: %w", err)
153+ }
154+
155+ if err := manager.Load(); err != nil {
156+ return fmt.Errorf("failed to load config: %w", err)
157+ }
158+
159+ s.config = manager.Get()
160+ return nil
161+}
162+
163+// ValidateConfig validates the current configuration.
164+func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) {
165+ var errors []string
166+
167+ // Validate database path
168+ if s.config.Database.Path == "" {
169+ errors = append(errors, "database path is not set")
170+ } else {
171+ dir := filepath.Dir(s.config.Database.Path)
172+ if _, err := os.Stat(dir); os.IsNotExist(err) {
173+ errors = append(errors, fmt.Sprintf("database directory does not exist: %s", dir))
174+ }
175+ }
176+
177+ // Validate search shortcuts
178+ if len(s.config.SearchShortcuts) == 0 {
179+ errors = append(errors, "no search shortcuts configured")
180+ } else {
181+ for key, shortcut := range s.config.SearchShortcuts {
182+ if shortcut.URL == "" {
183+ errors = append(errors, fmt.Sprintf("search shortcut '%s' has empty URL", key))
184+ }
185+ }
186+ }
187+
188+ // Check for default search shortcut
189+ if _, hasDefault := s.config.SearchShortcuts["g"]; !hasDefault {
190+ errors = append(errors, "no default search shortcut 'g' configured")
191+ }
192+
193+ // Validate dmenu settings
194+ if s.config.Dmenu.MaxHistoryItems < 1 {
195+ errors = append(errors, "dmenu max_history_items must be at least 1")
196+ }
197+
198+ return errors, nil
199+}
200+
201+// GetConfigPath returns the path to the configuration file.
202+func (s *ConfigService) GetConfigPath(ctx context.Context) (string, error) {
203+ return s.configPath, nil
204+}
205+
206+// ExportConfig returns the current configuration as JSON.
207+func (s *ConfigService) ExportConfig(ctx context.Context) (*config.Config, error) {
208+ // Return a copy of the config to prevent modifications
209+ configCopy := *s.config
210+ return &configCopy, nil
211+}
212+
213+// ImportConfig imports configuration from a Config object.
214+func (s *ConfigService) ImportConfig(ctx context.Context, newConfig *config.Config) error {
215+ if newConfig == nil {
216+ return fmt.Errorf("config cannot be nil")
217+ }
218+
219+ // Validate the new config
220+ tempService := &ConfigService{config: newConfig}
221+ validationErrors, err := tempService.ValidateConfig(ctx)
222+ if err != nil {
223+ return fmt.Errorf("failed to validate config: %w", err)
224+ }
225+
226+ if len(validationErrors) > 0 {
227+ return fmt.Errorf("config validation failed: %v", validationErrors)
228+ }
229+
230+ // Apply the new config
231+ s.config = newConfig
232+ return s.saveConfig()
233+}
234+
235+// saveConfig saves the current configuration to file.
236+func (s *ConfigService) saveConfig() error {
237+ // For now, return success - implementing config saving would require
238+ // extending the config package with save functionality
239+ return nil
240+}⏎̸
@@ -0,0 +1,240 @@
1+package services
2+
3+import (
4+ "context"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+
9+ "github.com/bnema/dumber/internal/config"
10+)
11+
12+// ConfigService handles configuration operations for Wails integration.
13+type ConfigService struct {
14+ config *config.Config
15+ configPath string
16+}
17+
18+// ConfigInfo represents configuration information for the frontend.
19+type ConfigInfo struct {
20+ ConfigPath string `json:"config_path"`
21+ DatabasePath string `json:"database_path"`
22+ SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
23+ DmenuSettings *config.DmenuConfig `json:"dmenu_settings"`
24+}
25+
26+// NewConfigService creates a new ConfigService instance.
27+func NewConfigService(cfg *config.Config, configPath string) *ConfigService {
28+ return &ConfigService{
29+ config: cfg,
30+ configPath: configPath,
31+ }
32+}
33+
34+// GetConfigInfo returns comprehensive configuration information.
35+func (s *ConfigService) GetConfigInfo(ctx context.Context) (*ConfigInfo, error) {
36+ return &ConfigInfo{
37+ ConfigPath: s.configPath,
38+ DatabasePath: s.config.Database.Path,
39+ SearchShortcuts: s.config.SearchShortcuts,
40+ DmenuSettings: &s.config.Dmenu,
41+ }, nil
42+}
43+
44+// GetSearchShortcuts returns all configured search shortcuts.
45+func (s *ConfigService) GetSearchShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
46+ return s.config.SearchShortcuts, nil
47+}
48+
49+// AddSearchShortcut adds a new search shortcut.
50+func (s *ConfigService) AddSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error {
51+ if key == "" {
52+ return fmt.Errorf("shortcut key cannot be empty")
53+ }
54+
55+ if shortcut.URL == "" {
56+ return fmt.Errorf("shortcut URL cannot be empty")
57+ }
58+
59+ if s.config.SearchShortcuts == nil {
60+ s.config.SearchShortcuts = make(map[string]config.SearchShortcut)
61+ }
62+
63+ s.config.SearchShortcuts[key] = shortcut
64+ return s.saveConfig()
65+}
66+
67+// UpdateSearchShortcut updates an existing search shortcut.
68+func (s *ConfigService) UpdateSearchShortcut(ctx context.Context, key string, shortcut config.SearchShortcut) error {
69+ if key == "" {
70+ return fmt.Errorf("shortcut key cannot be empty")
71+ }
72+
73+ if _, exists := s.config.SearchShortcuts[key]; !exists {
74+ return fmt.Errorf("shortcut '%s' does not exist", key)
75+ }
76+
77+ if shortcut.URL == "" {
78+ return fmt.Errorf("shortcut URL cannot be empty")
79+ }
80+
81+ s.config.SearchShortcuts[key] = shortcut
82+ return s.saveConfig()
83+}
84+
85+// DeleteSearchShortcut removes a search shortcut.
86+func (s *ConfigService) DeleteSearchShortcut(ctx context.Context, key string) error {
87+ if key == "" {
88+ return fmt.Errorf("shortcut key cannot be empty")
89+ }
90+
91+ if _, exists := s.config.SearchShortcuts[key]; !exists {
92+ return fmt.Errorf("shortcut '%s' does not exist", key)
93+ }
94+
95+ delete(s.config.SearchShortcuts, key)
96+ return s.saveConfig()
97+}
98+
99+// GetDmenuSettings returns current dmenu settings.
100+func (s *ConfigService) GetDmenuSettings(ctx context.Context) (*config.DmenuConfig, error) {
101+ return &s.config.Dmenu, nil
102+}
103+
104+// UpdateDmenuSettings updates dmenu configuration.
105+func (s *ConfigService) UpdateDmenuSettings(ctx context.Context, settings *config.DmenuConfig) error {
106+ if settings == nil {
107+ return fmt.Errorf("dmenu settings cannot be nil")
108+ }
109+
110+ s.config.Dmenu = *settings
111+ return s.saveConfig()
112+}
113+
114+// GetDatabasePath returns the current database path.
115+func (s *ConfigService) GetDatabasePath(ctx context.Context) (string, error) {
116+ return s.config.Database.Path, nil
117+}
118+
119+// SetDatabasePath updates the database path.
120+func (s *ConfigService) SetDatabasePath(ctx context.Context, path string) error {
121+ if path == "" {
122+ return fmt.Errorf("database path cannot be empty")
123+ }
124+
125+ // Validate that the directory exists or can be created
126+ dir := filepath.Dir(path)
127+ if err := os.MkdirAll(dir, 0755); err != nil {
128+ return fmt.Errorf("failed to create database directory: %w", err)
129+ }
130+
131+ s.config.Database.Path = path
132+ return s.saveConfig()
133+}
134+
135+// GetDefaultBrowser returns the default browser setting.
136+func (s *ConfigService) GetDefaultBrowser(ctx context.Context) (string, error) {
137+ // Default browser is now built into the app
138+ return "dumber (built-in)", nil
139+}
140+
141+// SetDefaultBrowser updates the default browser setting.
142+func (s *ConfigService) SetDefaultBrowser(ctx context.Context, browser string) error {
143+ // Default browser setting is not needed for built-in browser
144+ return fmt.Errorf("default browser is built-in and cannot be changed")
145+}
146+
147+// ReloadConfig reloads configuration from file.
148+func (s *ConfigService) ReloadConfig(ctx context.Context) error {
149+ // Create new manager and load config
150+ manager, err := config.NewManager()
151+ if err != nil {
152+ return fmt.Errorf("failed to create config manager: %w", err)
153+ }
154+
155+ if err := manager.Load(); err != nil {
156+ return fmt.Errorf("failed to load config: %w", err)
157+ }
158+
159+ s.config = manager.Get()
160+ return nil
161+}
162+
163+// ValidateConfig validates the current configuration.
164+func (s *ConfigService) ValidateConfig(ctx context.Context) ([]string, error) {
165+ var errors []string
166+
167+ // Validate database path
168+ if s.config.Database.Path == "" {
169+ errors = append(errors, "database path is not set")
170+ } else {
171+ dir := filepath.Dir(s.config.Database.Path)
172+ if _, err := os.Stat(dir); os.IsNotExist(err) {
173+ errors = append(errors, fmt.Sprintf("database directory does not exist: %s", dir))
174+ }
175+ }
176+
177+ // Validate search shortcuts
178+ if len(s.config.SearchShortcuts) == 0 {
179+ errors = append(errors, "no search shortcuts configured")
180+ } else {
181+ for key, shortcut := range s.config.SearchShortcuts {
182+ if shortcut.URL == "" {
183+ errors = append(errors, fmt.Sprintf("search shortcut '%s' has empty URL", key))
184+ }
185+ }
186+ }
187+
188+ // Check for default search shortcut
189+ if _, hasDefault := s.config.SearchShortcuts["g"]; !hasDefault {
190+ errors = append(errors, "no default search shortcut 'g' configured")
191+ }
192+
193+ // Validate dmenu settings
194+ if s.config.Dmenu.MaxHistoryItems < 1 {
195+ errors = append(errors, "dmenu max_history_items must be at least 1")
196+ }
197+
198+ return errors, nil
199+}
200+
201+// GetConfigPath returns the path to the configuration file.
202+func (s *ConfigService) GetConfigPath(ctx context.Context) (string, error) {
203+ return s.configPath, nil
204+}
205+
206+// ExportConfig returns the current configuration as JSON.
207+func (s *ConfigService) ExportConfig(ctx context.Context) (*config.Config, error) {
208+ // Return a copy of the config to prevent modifications
209+ configCopy := *s.config
210+ return &configCopy, nil
211+}
212+
213+// ImportConfig imports configuration from a Config object.
214+func (s *ConfigService) ImportConfig(ctx context.Context, newConfig *config.Config) error {
215+ if newConfig == nil {
216+ return fmt.Errorf("config cannot be nil")
217+ }
218+
219+ // Validate the new config
220+ tempService := &ConfigService{config: newConfig}
221+ validationErrors, err := tempService.ValidateConfig(ctx)
222+ if err != nil {
223+ return fmt.Errorf("failed to validate config: %w", err)
224+ }
225+
226+ if len(validationErrors) > 0 {
227+ return fmt.Errorf("config validation failed: %v", validationErrors)
228+ }
229+
230+ // Apply the new config
231+ s.config = newConfig
232+ return s.saveConfig()
233+}
234+
235+// saveConfig saves the current configuration to file.
236+func (s *ConfigService) saveConfig() error {
237+ // For now, return success - implementing config saving would require
238+ // extending the config package with save functionality
239+ return nil
240+}⏎̸
A services/parser_service.go +185 -0
@@ -0,0 +1,185 @@
1+package services
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/internal/db"
10+ "github.com/bnema/dumber/internal/parser"
11+)
12+
13+// ParserService wraps the parser functionality for Wails integration.
14+type ParserService struct {
15+ parser *parser.Parser
16+ dbQueries *db.Queries
17+}
18+
19+// NewParserService creates a new ParserService instance.
20+func NewParserService(cfg *config.Config, queries *db.Queries) *ParserService {
21+ // Create history provider from database
22+ historyProvider := &DatabaseHistoryProvider{queries: queries}
23+
24+ // Create parser with configuration
25+ p := parser.NewParser(cfg, historyProvider)
26+
27+ return &ParserService{
28+ parser: p,
29+ dbQueries: queries,
30+ }
31+}
32+
33+// ParseInput parses user input and returns navigation result.
34+func (s *ParserService) ParseInput(ctx context.Context, input string) (*parser.ParseResult, error) {
35+ if input == "" {
36+ return nil, fmt.Errorf("input cannot be empty")
37+ }
38+
39+ return s.parser.ParseInput(input)
40+}
41+
42+// GetCompletions provides URL/search completions based on input.
43+func (s *ParserService) GetCompletions(ctx context.Context, input string, limit int) ([]string, error) {
44+ if limit <= 0 {
45+ limit = 10 // Default limit
46+ }
47+
48+ return s.parser.SuggestCompletions(input, limit)
49+}
50+
51+// FuzzySearchHistory performs fuzzy search on history entries.
52+func (s *ParserService) FuzzySearchHistory(ctx context.Context, query string, threshold float64) ([]parser.FuzzyMatch, error) {
53+ if threshold <= 0 {
54+ threshold = 0.3 // Default threshold
55+ }
56+
57+ return s.parser.FuzzySearchHistory(query, threshold)
58+}
59+
60+// GetSupportedShortcuts returns configured search shortcuts.
61+func (s *ParserService) GetSupportedShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
62+ return s.parser.GetSupportedShortcuts(), nil
63+}
64+
65+// ProcessShortcut processes a shortcut and returns the resulting URL.
66+func (s *ParserService) ProcessShortcut(ctx context.Context, shortcut, query string) (string, error) {
67+ shortcuts := s.parser.GetSupportedShortcuts()
68+ return s.parser.ProcessShortcut(shortcut, query, shortcuts)
69+}
70+
71+// CalculateSimilarity calculates similarity between two strings.
72+func (s *ParserService) CalculateSimilarity(ctx context.Context, s1, s2 string) (float64, error) {
73+ return s.parser.CalculateSimilarity(s1, s2), nil
74+}
75+
76+// ValidateURL checks if the input represents a valid URL.
77+func (s *ParserService) ValidateURL(ctx context.Context, input string) (bool, error) {
78+ return s.parser.IsValidURL(input), nil
79+}
80+
81+// PreviewParse provides a preview of what ParseInput would return.
82+func (s *ParserService) PreviewParse(ctx context.Context, input string) (*parser.ParseResult, error) {
83+ if input == "" {
84+ return nil, fmt.Errorf("input cannot be empty")
85+ }
86+
87+ return s.parser.PreviewParse(input)
88+}
89+
90+// GetFuzzyConfig returns current fuzzy matching configuration.
91+func (s *ParserService) GetFuzzyConfig(ctx context.Context) (*parser.FuzzyConfig, error) {
92+ return s.parser.GetFuzzyConfig(), nil
93+}
94+
95+// UpdateFuzzyConfig updates the fuzzy matching configuration.
96+func (s *ParserService) UpdateFuzzyConfig(ctx context.Context, config *parser.FuzzyConfig) error {
97+ return s.parser.UpdateFuzzyConfig(config)
98+}
99+
100+// DatabaseHistoryProvider implements HistoryProvider using the database.
101+type DatabaseHistoryProvider struct {
102+ queries *db.Queries
103+}
104+
105+// GetRecentHistory returns recent history entries limited by count.
106+func (h *DatabaseHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) {
107+ ctx := context.Background()
108+
109+ entries, err := h.queries.GetHistory(ctx, int64(limit))
110+ if err != nil {
111+ return nil, err
112+ }
113+
114+ // Convert to pointer slice
115+ result := make([]*db.History, len(entries))
116+ for i, entry := range entries {
117+ entryCopy := entry
118+ result[i] = &entryCopy
119+ }
120+
121+ return result, nil
122+}
123+
124+// GetAllHistory returns all history entries for fuzzy search.
125+func (h *DatabaseHistoryProvider) GetAllHistory() ([]*db.History, error) {
126+ ctx := context.Background()
127+
128+ // Use a large limit to get all history
129+ entries, err := h.queries.GetHistory(ctx, 10000)
130+ if err != nil {
131+ return nil, err
132+ }
133+
134+ // Convert to pointer slice
135+ result := make([]*db.History, len(entries))
136+ for i, entry := range entries {
137+ entryCopy := entry
138+ result[i] = &entryCopy
139+ }
140+
141+ return result, nil
142+}
143+
144+// SearchHistory performs a basic text search in history.
145+func (h *DatabaseHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) {
146+ ctx := context.Background()
147+
148+ // Use SQL LIKE pattern for basic search
149+ urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
150+ titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
151+
152+ entries, err := h.queries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
153+ if err != nil {
154+ return nil, err
155+ }
156+
157+ // Convert to pointer slice
158+ result := make([]*db.History, len(entries))
159+ for i, entry := range entries {
160+ entryCopy := entry
161+ result[i] = &entryCopy
162+ }
163+
164+ return result, nil
165+}
166+
167+// GetHistoryByURL retrieves history entry by exact URL match.
168+func (h *DatabaseHistoryProvider) GetHistoryByURL(url string) (*db.History, error) {
169+ ctx := context.Background()
170+
171+ // Search for exact URL match using SearchHistory
172+ urlPattern := sql.NullString{String: url, Valid: true}
173+ emptyPattern := sql.NullString{Valid: false}
174+
175+ entries, err := h.queries.SearchHistory(ctx, urlPattern, emptyPattern, 1)
176+ if err != nil {
177+ return nil, err
178+ }
179+
180+ if len(entries) == 0 {
181+ return nil, fmt.Errorf("history entry not found for URL: %s", url)
182+ }
183+
184+ return &entries[0], nil
185+}⏎̸
@@ -0,0 +1,185 @@
1+package services
2+
3+import (
4+ "context"
5+ "database/sql"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/config"
9+ "github.com/bnema/dumber/internal/db"
10+ "github.com/bnema/dumber/internal/parser"
11+)
12+
13+// ParserService wraps the parser functionality for Wails integration.
14+type ParserService struct {
15+ parser *parser.Parser
16+ dbQueries *db.Queries
17+}
18+
19+// NewParserService creates a new ParserService instance.
20+func NewParserService(cfg *config.Config, queries *db.Queries) *ParserService {
21+ // Create history provider from database
22+ historyProvider := &DatabaseHistoryProvider{queries: queries}
23+
24+ // Create parser with configuration
25+ p := parser.NewParser(cfg, historyProvider)
26+
27+ return &ParserService{
28+ parser: p,
29+ dbQueries: queries,
30+ }
31+}
32+
33+// ParseInput parses user input and returns navigation result.
34+func (s *ParserService) ParseInput(ctx context.Context, input string) (*parser.ParseResult, error) {
35+ if input == "" {
36+ return nil, fmt.Errorf("input cannot be empty")
37+ }
38+
39+ return s.parser.ParseInput(input)
40+}
41+
42+// GetCompletions provides URL/search completions based on input.
43+func (s *ParserService) GetCompletions(ctx context.Context, input string, limit int) ([]string, error) {
44+ if limit <= 0 {
45+ limit = 10 // Default limit
46+ }
47+
48+ return s.parser.SuggestCompletions(input, limit)
49+}
50+
51+// FuzzySearchHistory performs fuzzy search on history entries.
52+func (s *ParserService) FuzzySearchHistory(ctx context.Context, query string, threshold float64) ([]parser.FuzzyMatch, error) {
53+ if threshold <= 0 {
54+ threshold = 0.3 // Default threshold
55+ }
56+
57+ return s.parser.FuzzySearchHistory(query, threshold)
58+}
59+
60+// GetSupportedShortcuts returns configured search shortcuts.
61+func (s *ParserService) GetSupportedShortcuts(ctx context.Context) (map[string]config.SearchShortcut, error) {
62+ return s.parser.GetSupportedShortcuts(), nil
63+}
64+
65+// ProcessShortcut processes a shortcut and returns the resulting URL.
66+func (s *ParserService) ProcessShortcut(ctx context.Context, shortcut, query string) (string, error) {
67+ shortcuts := s.parser.GetSupportedShortcuts()
68+ return s.parser.ProcessShortcut(shortcut, query, shortcuts)
69+}
70+
71+// CalculateSimilarity calculates similarity between two strings.
72+func (s *ParserService) CalculateSimilarity(ctx context.Context, s1, s2 string) (float64, error) {
73+ return s.parser.CalculateSimilarity(s1, s2), nil
74+}
75+
76+// ValidateURL checks if the input represents a valid URL.
77+func (s *ParserService) ValidateURL(ctx context.Context, input string) (bool, error) {
78+ return s.parser.IsValidURL(input), nil
79+}
80+
81+// PreviewParse provides a preview of what ParseInput would return.
82+func (s *ParserService) PreviewParse(ctx context.Context, input string) (*parser.ParseResult, error) {
83+ if input == "" {
84+ return nil, fmt.Errorf("input cannot be empty")
85+ }
86+
87+ return s.parser.PreviewParse(input)
88+}
89+
90+// GetFuzzyConfig returns current fuzzy matching configuration.
91+func (s *ParserService) GetFuzzyConfig(ctx context.Context) (*parser.FuzzyConfig, error) {
92+ return s.parser.GetFuzzyConfig(), nil
93+}
94+
95+// UpdateFuzzyConfig updates the fuzzy matching configuration.
96+func (s *ParserService) UpdateFuzzyConfig(ctx context.Context, config *parser.FuzzyConfig) error {
97+ return s.parser.UpdateFuzzyConfig(config)
98+}
99+
100+// DatabaseHistoryProvider implements HistoryProvider using the database.
101+type DatabaseHistoryProvider struct {
102+ queries *db.Queries
103+}
104+
105+// GetRecentHistory returns recent history entries limited by count.
106+func (h *DatabaseHistoryProvider) GetRecentHistory(limit int) ([]*db.History, error) {
107+ ctx := context.Background()
108+
109+ entries, err := h.queries.GetHistory(ctx, int64(limit))
110+ if err != nil {
111+ return nil, err
112+ }
113+
114+ // Convert to pointer slice
115+ result := make([]*db.History, len(entries))
116+ for i, entry := range entries {
117+ entryCopy := entry
118+ result[i] = &entryCopy
119+ }
120+
121+ return result, nil
122+}
123+
124+// GetAllHistory returns all history entries for fuzzy search.
125+func (h *DatabaseHistoryProvider) GetAllHistory() ([]*db.History, error) {
126+ ctx := context.Background()
127+
128+ // Use a large limit to get all history
129+ entries, err := h.queries.GetHistory(ctx, 10000)
130+ if err != nil {
131+ return nil, err
132+ }
133+
134+ // Convert to pointer slice
135+ result := make([]*db.History, len(entries))
136+ for i, entry := range entries {
137+ entryCopy := entry
138+ result[i] = &entryCopy
139+ }
140+
141+ return result, nil
142+}
143+
144+// SearchHistory performs a basic text search in history.
145+func (h *DatabaseHistoryProvider) SearchHistory(query string, limit int) ([]*db.History, error) {
146+ ctx := context.Background()
147+
148+ // Use SQL LIKE pattern for basic search
149+ urlPattern := sql.NullString{String: "%" + query + "%", Valid: true}
150+ titlePattern := sql.NullString{String: "%" + query + "%", Valid: true}
151+
152+ entries, err := h.queries.SearchHistory(ctx, urlPattern, titlePattern, int64(limit))
153+ if err != nil {
154+ return nil, err
155+ }
156+
157+ // Convert to pointer slice
158+ result := make([]*db.History, len(entries))
159+ for i, entry := range entries {
160+ entryCopy := entry
161+ result[i] = &entryCopy
162+ }
163+
164+ return result, nil
165+}
166+
167+// GetHistoryByURL retrieves history entry by exact URL match.
168+func (h *DatabaseHistoryProvider) GetHistoryByURL(url string) (*db.History, error) {
169+ ctx := context.Background()
170+
171+ // Search for exact URL match using SearchHistory
172+ urlPattern := sql.NullString{String: url, Valid: true}
173+ emptyPattern := sql.NullString{Valid: false}
174+
175+ entries, err := h.queries.SearchHistory(ctx, urlPattern, emptyPattern, 1)
176+ if err != nil {
177+ return nil, err
178+ }
179+
180+ if len(entries) == 0 {
181+ return nil, fmt.Errorf("history entry not found for URL: %s", url)
182+ }
183+
184+ return &entries[0], nil
185+}⏎̸
A specs/002-ui-enhancement-polishing/spec.md +135 -0
@@ -0,0 +1,135 @@
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+---⏎̸
@@ -0,0 +1,135 @@
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+---⏎̸
A test-wails +0 -0 binary file: no text diff

binary file: no text diff

A wails.json +24 -0
@@ -0,0 +1,24 @@
1+{
2+ "name": "dumber",
3+ "displayname": "Dumber Browser",
4+ "description": "A smart URL launcher and browser with learning behavior",
5+ "version": "0.1.0",
6+ "author": "dumber",
7+ "license": "MIT",
8+ "build": {
9+ "dir": ".",
10+ "main": "main.go",
11+ "binary": "dumber",
12+ "output": "dist"
13+ },
14+ "frontend": {
15+ "dir": "frontend",
16+ "dist": "dist"
17+ },
18+ "flags": {
19+ "linux": [
20+ "-s",
21+ "-w"
22+ ]
23+ }
24+}⏎̸
@@ -0,0 +1,24 @@
1+{
2+ "name": "dumber",
3+ "displayname": "Dumber Browser",
4+ "description": "A smart URL launcher and browser with learning behavior",
5+ "version": "0.1.0",
6+ "author": "dumber",
7+ "license": "MIT",
8+ "build": {
9+ "dir": ".",
10+ "main": "main.go",
11+ "binary": "dumber",
12+ "output": "dist"
13+ },
14+ "frontend": {
15+ "dir": "frontend",
16+ "dist": "dist"
17+ },
18+ "flags": {
19+ "linux": [
20+ "-s",
21+ "-w"
22+ ]
23+ }
24+}⏎̸

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