detached b360a0d

Merge pull request #235 from bnema/feat/systemviews-wasm

Replace webui with WASM systemviews
commit
b360a0ded8b348f678185eec66248f0f7cb0ae8c
author
bnema · · 2026-04-26 18:17 UTC
committer
GitHub · · 2026-04-26 18:17 UTC
merge of
35d15a2 + c4ed781 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
260 files · +21413 -17442
M .github/dependabot.yml +0 -9
@@ -9,15 +9,6 @@ updates:
99 patterns:
1010 - "*"
1111
12- - package-ecosystem: npm
13- directory: /webui
14- schedule:
15- interval: weekly
16- groups:
17- npm-dependencies:
18- patterns:
19- - "*"
20-
2112 - package-ecosystem: github-actions
2213 directory: /
2314 schedule:
M .github/workflows/aur.yml +1 -1
@@ -51,7 +51,7 @@ jobs:
5151 "arch=('x86_64')" \
5252 'url="https://github.com/bnema/dumber"' \
5353 "license=('MIT')" \
54- "makedepends=('go>=1.25' 'npm' 'git')" \
54+ "makedepends=('go>=1.26' 'git' 'brotli')" \
5555 "depends=('gtk4' 'webkitgtk-6.0')" \
5656 'optdepends=(' \
5757 " 'gst-plugins-base: Base media codecs'" \
M .github/workflows/ci.yml +8 -22
@@ -7,11 +7,11 @@ on:
77 - '**.go'
88 - 'go.mod'
99 - 'go.sum'
10- - 'webui/**'
1110 - '.golangci.yml'
1211 - 'Makefile'
1312 - 'sqlc.yaml'
1413 - 'assets/**'
14+ - '.github/workflows/**'
1515 pull_request:
1616
1717 jobs:
@@ -31,11 +31,11 @@ jobs:
3131 - '**.go'
3232 - 'go.mod'
3333 - 'go.sum'
34- - 'webui/**'
3534 - '.golangci.yml'
3635 - 'Makefile'
3736 - 'sqlc.yaml'
3837 - 'assets/**'
38+ - '.github/workflows/**'
3939
4040 lint:
4141 needs: changes
@@ -47,7 +47,7 @@ jobs:
4747 - name: Install dependencies
4848 run: |
4949 pacman -Syu --noconfirm
50- pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 gobject-introspection base-devel
50+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 gobject-introspection base-devel brotli
5151 - uses: actions/checkout@v6
5252 - name: Cache Go modules
5353 uses: actions/cache@v5
@@ -63,15 +63,8 @@ jobs:
6363 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
6464 restore-keys: |
6565 ${{ runner.os }}-go-build-
66- - name: Cache npm
67- uses: actions/cache@v5
68- with:
69- path: ~/.npm
70- key: ${{ runner.os }}-npm-${{ hashFiles('webui/package-lock.json') }}
71- restore-keys: |
72- ${{ runner.os }}-npm-
73- - name: Build frontend assets
74- run: make build-frontend
66+ - name: Build application and systemviews assets
67+ run: make build
7568 - name: Install golangci-lint
7669 run: |
7770 curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/HEAD/install.sh | sh -s -- -b /usr/local/bin latest
@@ -90,7 +83,7 @@ jobs:
9083 - name: Install dependencies
9184 run: |
9285 pacman -Syu --noconfirm
93- pacman -S --noconfirm git go nodejs npm webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel
86+ pacman -S --noconfirm git go webkitgtk-6.0 gtk4 libadwaita gobject-introspection base-devel brotli
9487 - uses: actions/checkout@v6
9588 - name: Cache Go modules
9689 uses: actions/cache@v5
@@ -106,15 +99,8 @@ jobs:
10699 key: ${{ runner.os }}-go-build-${{ hashFiles('**/go.sum') }}
107100 restore-keys: |
108101 ${{ runner.os }}-go-build-
109- - name: Cache npm
110- uses: actions/cache@v5
111- with:
112- path: ~/.npm
113- key: ${{ runner.os }}-npm-${{ hashFiles('webui/package-lock.json') }}
114- restore-keys: |
115- ${{ runner.os }}-npm-
116- - name: Build frontend assets
117- run: make build-frontend
102+ - name: Build application and systemviews assets
103+ run: make build
118104 - name: Run tests
119105 env:
120106 GDK_BACKEND: wayland
D .github/workflows/webui.yml +0 -30
@@ -1,30 +0,0 @@
1-name: WebUI CI
2-
3-on:
4- push:
5- branches: [dev, main]
6- paths:
7- - 'webui/**'
8- pull_request:
9- paths:
10- - 'webui/**'
11-
12-jobs:
13- check:
14- runs-on: ubuntu-latest
15- steps:
16- - uses: actions/checkout@v6
17-
18- - uses: actions/setup-node@v6
19- with:
20- node-version: '24'
21- cache: 'npm'
22- cache-dependency-path: 'webui/package-lock.json'
23-
24- - name: Install dependencies
25- run: npm ci
26- working-directory: webui
27-
28- - name: Run checks
29- run: npm run check
30- working-directory: webui
M .gitignore +3 -0
@@ -64,6 +64,9 @@ tmp/go-cache/
6464 tmp/go-mod/
6565 tmp/
6666 assets/webui
67+assets/systemviews/systemviews.wasm
68+assets/systemviews/systemviews.wasm.br
69+assets/systemviews/wasm_exec.js
6770
6871 # Node/TypeScript related
6972 node_modules/
M .goreleaser.yaml +2 -2
@@ -4,8 +4,8 @@ version: 2
44
55 before:
66 hooks:
7- # Build frontend assets before building binaries
8- - make build-frontend
7+ # Build systemviews assets before building binaries
8+ - make build-systemviews
99
1010 builds:
1111 # Pure Go build (no CGO required)
M .mockery.yaml +1 -0
@@ -88,6 +88,7 @@ packages:
8888 UpdateChecker: {}
8989 UpdateDownloader: {}
9090 WebUIConfigSaver: {}
91+ SystemviewConfigReader: {}
9192 XDGPaths: {}
9293 github.com/bnema/dumber/internal/domain/repository:
9394 config:
M CONTRIBUTING.md +3 -4
@@ -34,10 +34,9 @@ git push origin my-fix
3434 2. Run `make test` and ensure tests pass
3535 3. Run `go fmt` on any Go code changes
3636
37-**WebUI code (in `webui/`):**
38-1. Run `npm run lint` to fix linting issues
39-2. Run `npm run fmt` to format with Prettier
40-3. Run `npm run check` to run svelte-check
37+**Systemviews assets:**
38+1. If you modified systemviews files such as `cmd/systemviews/` or `.templ` components, run `make build-systemviews` to regenerate embedded WASM assets
39+2. Run `make check` to verify build, tests, and tools before committing
4140
4241 Keep commits focused and use single-line commit messages.
4342
M Makefile +22 -14
@@ -1,6 +1,6 @@
11 # Makefile for dumber (Clean Architecture - puregotk)
22
3-.PHONY: build build-frontend build-quick install-local test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
3+.PHONY: build build-systemviews generate-systemviews build-quick install-local test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check
44
55 # Load local overrides from .env.local if present (Makefile syntax)
66 ifneq (,$(wildcard .env.local))
@@ -39,22 +39,31 @@ help: ## Show this help message
3939 @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
4040
4141 # Build targets
42-build: build-frontend ## Build the application (pure Go, no CGO)
42+build: build-systemviews ## Build the application (pure Go, no CGO)
4343 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
4444 @mkdir -p $(DIST_DIR)
45- CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
45+ CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
4646 @rm -f $(DIST_DIR)/cef-helper
4747 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
4848
49-build-frontend: ## Build homepage and error pages
50- @echo "Building webui pages (homepage + error)..."
51- @cd webui && npm install --silent && npm run build
52- @echo "Frontend build complete"
53-
54-build-quick: ## Build without frontend (faster for backend development)
55- @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..."
49+generate-systemviews: ## Generate Go code from systemviews templ components
50+ @echo "Generating systemviews templ components..."
51+ go tool templ generate -path internal/ui/systemviews -include-version=false
52+ @echo "Systemviews templ generation complete"
53+
54+build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
55+ @echo "Building systemviews wasm assets..."
56+ @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; }
57+ @mkdir -p assets/systemviews
58+ @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
59+ GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
60+ brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm
61+ @echo "Systemviews build complete"
62+
63+build-quick: ## Build quickly for backend development
64+ @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."
5665 @mkdir -p $(DIST_DIR)
57- CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
66+ CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH)
5867 @rm -f $(DIST_DIR)/cef-helper
5968 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
6069
@@ -137,8 +146,7 @@ clean: ## Clean build artifacts
137146 @echo "Cleaning build artifacts..."
138147 rm -rf $(DIST_DIR)
139148 rm -f $(BINARY_NAME)
140- rm -rf webui/dist webui/node_modules
141- rm -f assets/webui/homepage.min.js assets/webui/error.min.js assets/webui/config.min.js assets/webui/index.html assets/webui/error.html assets/webui/config.html assets/webui/style.css
149+ rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm assets/systemviews/systemviews.wasm.br
142150 rm -f coverage.out coverage.html
143151 go clean -cache
144152 go clean -testcache
@@ -174,7 +182,7 @@ man: build-quick ## Install man pages to ~/.local/share/man/man1/
174182 # Native release targets
175183 .PHONY: release-snapshot release
176184
177-release-snapshot: build-frontend ## Build snapshot using goreleaser
185+release-snapshot: build-systemviews ## Build snapshot using goreleaser
178186 @echo "Building snapshot with goreleaser..."
179187 goreleaser release --snapshot --clean
180188
A assets/systemviews/index.html +71 -0
@@ -0,0 +1,71 @@
1+<!doctype html>
2+<html lang="en">
3+<head>
4+ <meta charset="utf-8">
5+ <meta name="viewport" content="width=device-width, initial-scale=1">
6+ <meta name="color-scheme" content="dark light">
7+ <title>systemviews</title>
8+ <link rel="stylesheet" href="./systemviews.css">
9+ <script src="./wasm_exec.js"></script>
10+ <script>
11+ function renderWasmError(prefixText, err) {
12+ const render = () => {
13+ const app = document.getElementById('app');
14+ if (!app) return;
15+ app.innerHTML = '';
16+ const message = document.createElement('div');
17+ message.className = 'sv-error';
18+ const prefix = document.createElement('span');
19+ prefix.textContent = prefixText;
20+ const details = document.createElement('span');
21+ details.textContent = err && err.message ? err.message : String(err || 'unsupported browser');
22+ message.append(prefix, details);
23+ app.append(message);
24+ };
25+ if (document.readyState === 'loading' && !document.getElementById('app')) {
26+ document.addEventListener('DOMContentLoaded', render, { once: true });
27+ } else {
28+ render();
29+ }
30+ console.error(prefixText, err);
31+ }
32+
33+ function instantiateWasm(go, wasmURL) {
34+ const instantiateFromBytes = () => fetch(wasmURL)
35+ .then((response) => {
36+ if (!response.ok) {
37+ throw new Error(`HTTP ${response.status} loading ${wasmURL}`);
38+ }
39+ return response.arrayBuffer();
40+ })
41+ .then((bytes) => WebAssembly.instantiate(bytes, go.importObject));
42+
43+ if (!WebAssembly.instantiateStreaming) {
44+ return instantiateFromBytes();
45+ }
46+ return WebAssembly.instantiateStreaming(fetch(wasmURL), go.importObject)
47+ .catch((err) => {
48+ console.error(`Streaming WebAssembly load failed for ${wasmURL}; falling back to ArrayBuffer instantiation`, err);
49+ return instantiateFromBytes();
50+ });
51+ }
52+
53+ if (typeof WebAssembly !== 'object') {
54+ renderWasmError('WebAssembly is not supported in this browser: ');
55+ } else if (typeof Go !== 'function') {
56+ renderWasmError('Go WebAssembly runtime failed to load: ');
57+ } else {
58+ const go = new Go();
59+ instantiateWasm(go, './systemviews.wasm')
60+ .then((result) => go.run(result.instance))
61+ .catch((err) => renderWasmError('Failed to load systemviews WebAssembly: ', err));
62+ }
63+ </script>
64+</head>
65+<body>
66+ <main id="app">Loading systemviews…</main>
67+ <noscript>
68+ <p>JavaScript is required to run Dumber system views. Enable JavaScript for <code>dumb://history</code>, <code>dumb://favorites</code>, and <code>dumb://config</code>.</p>
69+ </noscript>
70+</body>
71+</html>
A assets/systemviews/systemviews.css +977 -0
@@ -0,0 +1,977 @@
1+:root {
2+ color-scheme: dark;
3+ --sv-background: #0f1115;
4+ --sv-surface: #171a21;
5+ --sv-surface-variant: #1d2129;
6+ --sv-text: #e8ebf0;
7+ --sv-muted: #9aa3b2;
8+ --sv-border: #2a313d;
9+ --sv-success: var(--sv-accent, #8ab4ff);
10+ --sv-danger: #ef4444;
11+ --sv-danger-text: #fecaca;
12+ --sv-radius-card: 0.5rem;
13+ --sv-radius-control: 0.35rem;
14+ --sv-radius-chip: 0.45rem;
15+}
16+
17+body {
18+ margin: 0;
19+ font-family: var(--sv-font-sans, Inter), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
20+ font-size: var(--sv-font-size, 14px);
21+ background: var(--sv-background, #0f1115);
22+ color: var(--sv-text, #e8ebf0);
23+}
24+
25+.sv-app {
26+ min-height: 100vh;
27+ background: var(--sv-background, #0f1115);
28+ color: var(--sv-text, #e8ebf0);
29+}
30+
31+.sv-app.sv-dark {
32+ color-scheme: dark;
33+}
34+
35+.sv-app.sv-light {
36+ color-scheme: light;
37+ --sv-background: #f8fafc;
38+ --sv-surface: #ffffff;
39+ --sv-surface-variant: #eef2f7;
40+ --sv-text: #111827;
41+ --sv-muted: #64748b;
42+ --sv-border: #d7dee8;
43+ --sv-primary: #2563eb;
44+ --sv-danger: #dc2626;
45+ --sv-danger-text: #7f1d1d;
46+}
47+
48+
49+.sv-shell {
50+ display: grid;
51+ gap: 1rem;
52+ padding: 1rem;
53+}
54+
55+.sv-title {
56+ display: flex;
57+ flex-wrap: wrap;
58+ gap: 0.45rem;
59+ align-items: baseline;
60+ margin: 0;
61+ font-size: clamp(1.35rem, 2.4vw, 2rem);
62+ line-height: 1.15;
63+}
64+
65+.sv-title-detail {
66+ color: var(--sv-muted);
67+ font-size: clamp(0.9rem, 1.25vw, 1.05rem);
68+ font-weight: 600;
69+ letter-spacing: 0;
70+}
71+
72+.sv-content {
73+ display: grid;
74+ gap: 1rem;
75+}
76+
77+.sv-section > h2 {
78+ margin: 0 0 0.75rem;
79+ font-size: 1rem;
80+ letter-spacing: 0.02em;
81+}
82+
83+.sv-list {
84+ display: grid;
85+ gap: 0.6rem;
86+ margin: 0.75rem 0 0;
87+ padding: 0;
88+ list-style: none;
89+}
90+
91+.sv-list-row {
92+ padding: 0.75rem;
93+ border: 1px solid var(--sv-border, #2a313d);
94+ border-radius: var(--sv-radius-card, 0.5rem);
95+ background: color-mix(in srgb, var(--sv-surface-variant, #1d2129) 72%, transparent);
96+}
97+
98+.sv-link {
99+ color: var(--sv-accent, #8ab4ff);
100+ font-weight: 700;
101+ text-decoration: none;
102+}
103+
104+.sv-link:hover {
105+ text-decoration: underline;
106+}
107+
108+.sv-badge {
109+ display: inline-flex;
110+ align-items: center;
111+ border: 1px solid var(--sv-border, #2a313d);
112+ border-radius: 999px;
113+ padding: 0.15rem 0.45rem;
114+ background: var(--sv-surface-variant, #1d2129);
115+ color: var(--sv-muted, #9aa3b2);
116+ font-size: 12px;
117+ font-weight: 700;
118+}
119+
120+.sv-header {
121+ background: var(--sv-surface-variant, #1d2129);
122+ border: 1px solid var(--sv-border, #2a313d);
123+ border-radius: var(--sv-radius-card, 0.5rem);
124+ padding: 1rem;
125+}
126+
127+.sv-section {
128+ background: var(--sv-surface, #171a21);
129+ border: 1px solid var(--sv-border, #2a313d);
130+ border-radius: var(--sv-radius-card, 0.5rem);
131+ padding: 1rem;
132+}
133+
134+.sv-meta {
135+ color: var(--sv-muted, #9aa3b2);
136+ font-size: 12px;
137+}
138+
139+.sv-group {
140+ margin-top: 1rem;
141+ padding: 1rem;
142+ border: 1px solid var(--sv-border, #2a313d);
143+ border-radius: var(--sv-radius-card, 0.5rem);
144+ background: var(--sv-surface, #171a21);
145+}
146+
147+.sv-group-header {
148+ display: flex;
149+ justify-content: space-between;
150+ gap: 1rem;
151+ align-items: baseline;
152+}
153+
154+.sv-group-header h4 {
155+ margin: 0;
156+}
157+
158+.sv-group-body {
159+ display: grid;
160+ gap: 0.75rem;
161+ margin-top: 0.75rem;
162+}
163+
164+.sv-binding {
165+ padding-top: 0.75rem;
166+ border-top: 1px solid var(--sv-border, #2a313d);
167+ display: grid;
168+ gap: 0.5rem;
169+}
170+
171+.sv-group-body > .sv-binding:first-child {
172+ padding-top: 0;
173+ border-top: 0;
174+}
175+
176+.sv-binding-header {
177+ display: flex;
178+ justify-content: space-between;
179+ gap: 1rem;
180+ align-items: flex-start;
181+}
182+
183+.sv-binding-description {
184+ font-weight: 600;
185+}
186+
187+.sv-binding-keys {
188+ display: flex;
189+ flex-wrap: wrap;
190+ gap: 0.35rem;
191+ justify-content: flex-end;
192+}
193+
194+.sv-key {
195+ display: inline-flex;
196+ align-items: center;
197+ padding: 0.2rem 0.45rem;
198+ border-radius: var(--sv-radius-control, 0.35rem);
199+ border: 1px solid var(--sv-border, #2a313d);
200+ background: var(--sv-surface-variant, #1d2129);
201+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
202+ font-size: 12px;
203+}
204+
205+.sv-binding-meta {
206+ display: flex;
207+ flex-wrap: wrap;
208+ gap: 0.35rem 1rem;
209+ color: var(--sv-muted, #9aa3b2);
210+ font-size: 12px;
211+}
212+
213+.sv-empty {
214+ color: var(--sv-muted, #9aa3b2);
215+}
216+
217+.sv-loading-panel,
218+.sv-loading-card {
219+ display: inline-flex;
220+ align-items: center;
221+ gap: 0.55rem;
222+ color: var(--sv-muted, #9aa3b2);
223+}
224+
225+.sv-loading-panel {
226+ min-height: 4rem;
227+ border: 1px solid var(--sv-border, #2a313d);
228+ border-radius: var(--sv-radius-card, 0.5rem);
229+ padding: 0.85rem;
230+ background: var(--sv-surface-variant, #1d2129);
231+}
232+
233+.sv-spinner {
234+ width: 1rem;
235+ height: 1rem;
236+ border: 2px solid color-mix(in srgb, var(--sv-muted, #9aa3b2) 35%, transparent);
237+ border-top-color: var(--sv-accent, #8ab4ff);
238+ border-radius: 999px;
239+ animation: sv-spin 0.75s linear infinite;
240+}
241+
242+.sv-history-load-more {
243+ display: flex;
244+ justify-content: center;
245+ padding: 1rem 0 0.25rem;
246+}
247+
248+@keyframes sv-spin {
249+ to {
250+ transform: rotate(360deg);
251+ }
252+}
253+
254+.sv-stat-grid {
255+ display: grid;
256+ grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
257+ gap: 0.75rem;
258+ margin-top: 0.75rem;
259+}
260+
261+.sv-stat-card {
262+ display: grid;
263+ gap: 0.15rem;
264+ padding: 0.75rem;
265+ border: 1px solid var(--sv-border, #2a313d);
266+ border-radius: var(--sv-radius-card, 0.5rem);
267+ background: var(--sv-surface-variant, #1d2129);
268+}
269+
270+.sv-stat-value {
271+ color: var(--sv-accent, #8ab4ff);
272+ font-size: 1.35rem;
273+ font-weight: 700;
274+ line-height: 1.05;
275+ font-variant-numeric: tabular-nums;
276+}
277+
278+.sv-stat-label {
279+ color: var(--sv-muted, #9aa3b2);
280+ font-size: 12px;
281+ text-transform: uppercase;
282+ letter-spacing: 0.08em;
283+}
284+
285+.sv-domain-list {
286+ display: flex;
287+ flex-wrap: wrap;
288+ gap: 0.35rem;
289+ max-height: 4rem;
290+ margin-top: 0.5rem;
291+ overflow: hidden;
292+}
293+
294+.sv-domain-chip {
295+ display: inline-flex;
296+ gap: 0.35rem;
297+ align-items: center;
298+ max-width: 13rem;
299+ min-width: 0;
300+ padding: 0.25rem 0.35rem 0.25rem 0.5rem;
301+ border: 1px solid var(--sv-border, #2a313d);
302+ border-radius: var(--sv-radius-chip, 0.45rem);
303+ background: var(--sv-surface-variant, #1d2129);
304+}
305+
306+.sv-domain-chip-main {
307+ display: inline-flex;
308+ gap: 0.3rem;
309+ align-items: baseline;
310+ min-width: 0;
311+}
312+
313+.sv-domain-meta {
314+ flex: 0 0 auto;
315+ white-space: nowrap;
316+}
317+
318+.sv-domain-meta,
319+.sv-history-meta,
320+.sv-history-url {
321+ color: var(--sv-muted, #9aa3b2);
322+ font-size: 12px;
323+}
324+
325+.sv-timeline-group + .sv-timeline-group {
326+ margin-top: 1rem;
327+}
328+
329+.sv-timeline-group h3 {
330+ margin: 0 0 0.5rem;
331+ color: var(--sv-muted, #9aa3b2);
332+ font-size: 12px;
333+ letter-spacing: 0.08em;
334+ text-transform: uppercase;
335+}
336+
337+.sv-history-item {
338+ display: grid;
339+ grid-template-columns: minmax(0, 1fr) auto;
340+ gap: 1rem;
341+ align-items: start;
342+}
343+
344+.sv-history-content {
345+ display: grid;
346+ grid-template-columns: 24px minmax(0, 1fr);
347+ gap: 0.75rem;
348+ align-items: start;
349+ min-width: 0;
350+}
351+
352+.sv-history-favicon {
353+ position: relative;
354+ display: inline-grid;
355+ place-items: center;
356+ width: 24px;
357+ height: 24px;
358+ overflow: hidden;
359+ color: var(--sv-muted, #9aa3b2);
360+ font-size: 0.75rem;
361+ font-weight: 800;
362+ line-height: 1;
363+ text-transform: uppercase;
364+}
365+
366+.sv-history-favicon img {
367+ position: absolute;
368+ inset: 0;
369+ z-index: 1;
370+ width: 24px;
371+ height: 24px;
372+ object-fit: contain;
373+}
374+
375+.sv-history-favicon-fallback {
376+ position: relative;
377+ z-index: 0;
378+}
379+
380+.sv-history-favicon-has-image .sv-history-favicon-fallback {
381+ visibility: hidden;
382+}
383+
384+.sv-history-main {
385+ min-width: 0;
386+}
387+
388+.sv-history-url,
389+.sv-history-meta {
390+ margin: 0.25rem 0 0;
391+}
392+
393+.sv-history-url {
394+ display: -webkit-box;
395+ max-width: 100%;
396+ overflow: hidden;
397+ overflow-wrap: anywhere;
398+ -webkit-box-orient: vertical;
399+ -webkit-line-clamp: 2;
400+ line-clamp: 2;
401+}
402+
403+.sv-app.sv-dark .sv-history-meta {
404+ color: var(--sv-text, #e8ebf0);
405+}
406+
407+.sv-error {
408+ border: 1px solid color-mix(in srgb, var(--sv-danger, #ef4444) 65%, var(--sv-border, #2a313d));
409+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 12%, var(--sv-surface, #171a21));
410+ border-radius: var(--sv-radius-card, 0.5rem);
411+ color: var(--sv-text, #e8ebf0);
412+ padding: 1rem;
413+}
414+
415+.sv-error p {
416+ margin: 0;
417+}
418+
419+.sv-error p + p {
420+ margin-top: 0.5rem;
421+}
422+
423+.sv-kv {
424+ margin: 0.75rem 0 0;
425+ display: grid;
426+ gap: 0.25rem;
427+}
428+
429+.sv-kv-row {
430+ display: grid;
431+ grid-template-columns: minmax(12rem, 35%) minmax(0, 1fr);
432+ gap: 0.25rem 1rem;
433+ padding: 0.5rem 0;
434+}
435+
436+.sv-kv-row + .sv-kv-row {
437+ border-top: 1px solid var(--sv-border, #2a313d);
438+}
439+
440+.sv-kv-row dt,
441+.sv-kv-row dd {
442+ margin: 0;
443+ min-width: 0;
444+ overflow-wrap: anywhere;
445+}
446+
447+.sv-kv-row dt {
448+ color: var(--sv-muted, #9aa3b2);
449+ font-weight: 600;
450+}
451+.sv-alert {
452+ border-radius: var(--sv-radius-card, 0.5rem);
453+ padding: 0.75rem 1rem;
454+ border: 1px solid var(--sv-border, #2a313d);
455+ transition: opacity 180ms ease, transform 180ms ease;
456+}
457+
458+.sv-alert-dismiss {
459+ opacity: 0;
460+ transform: translateY(-0.25rem);
461+}
462+
463+.sv-alert-success {
464+ background: color-mix(in srgb, var(--sv-success, #8ab4ff) 14%, var(--sv-surface, #171a21));
465+ border-color: color-mix(in srgb, var(--sv-success, #8ab4ff) 62%, var(--sv-border, #2a313d));
466+}
467+
468+.sv-alert-error {
469+ color: var(--sv-danger-text, #fecaca);
470+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 12%, var(--sv-surface, #171a21));
471+ border-color: color-mix(in srgb, var(--sv-danger, #ef4444) 72%, var(--sv-border, #2a313d));
472+}
473+
474+.sv-toolbar,
475+.sv-button-row,
476+.sv-active-filters,
477+.sv-history-actions-panel,
478+.sv-history-row-actions {
479+ display: flex;
480+ flex-wrap: wrap;
481+ gap: 0.5rem;
482+ align-items: center;
483+}
484+
485+.sv-toolbar {
486+ align-items: end;
487+}
488+
489+.sv-history-actions-panel {
490+ margin-top: 0.75rem;
491+ justify-content: space-between;
492+}
493+
494+.sv-search-label {
495+ display: grid;
496+ gap: 0.25rem;
497+ min-width: min(24rem, 100%);
498+ color: var(--sv-muted, #9aa3b2);
499+ font-size: 12px;
500+ font-weight: 600;
501+}
502+
503+.sv-search-label input {
504+ border: 1px solid var(--sv-border, #2a313d);
505+ border-radius: var(--sv-radius-control, 0.35rem);
506+ padding: 0.55rem 0.65rem;
507+ background: var(--sv-surface-variant, #1d2129);
508+ color: var(--sv-text, #e8ebf0);
509+ font: inherit;
510+}
511+
512+.sv-button {
513+ box-sizing: border-box;
514+ display: inline-flex;
515+ align-items: center;
516+ justify-content: center;
517+ gap: 0.4rem;
518+ min-height: 2rem;
519+ border: 1px solid var(--sv-accent, #8ab4ff);
520+ border-radius: var(--sv-radius-control, 0.35rem);
521+ padding: 0.35rem 0.65rem;
522+ background: var(--sv-accent, #8ab4ff);
523+ color: var(--sv-background, #0f1115);
524+ cursor: pointer;
525+ font: inherit;
526+ font-weight: 600;
527+ text-decoration: none;
528+}
529+
530+.sv-button-secondary {
531+ border-color: var(--sv-border, #2a313d);
532+ background: var(--sv-surface-variant, #1d2129);
533+ color: var(--sv-text, #e8ebf0);
534+}
535+
536+.sv-button-danger {
537+ border-color: color-mix(in srgb, var(--sv-danger, #ef4444) 78%, var(--sv-border, #2a313d));
538+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 14%, var(--sv-surface-variant, #1d2129));
539+ color: var(--sv-danger-text, #fecaca);
540+}
541+
542+.sv-button-danger:hover {
543+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 24%, var(--sv-surface-variant, #1d2129));
544+}
545+
546+.sv-button-danger:disabled {
547+ color: var(--sv-danger-text, #fecaca);
548+ opacity: 0.7;
549+}
550+
551+.sv-button-icon-only {
552+ width: 2rem;
553+ min-width: 2rem;
554+ min-height: 2rem;
555+ padding: 0.35rem;
556+}
557+
558+.sv-danger-link {
559+ border: 0;
560+ padding: 0.15rem 0.2rem;
561+ background: transparent;
562+ color: var(--sv-danger-text, #fecaca);
563+ cursor: pointer;
564+ font: inherit;
565+ font-weight: 650;
566+ text-decoration: none;
567+}
568+
569+.sv-danger-link:hover {
570+ color: var(--sv-danger, #ef4444);
571+ text-decoration: underline;
572+}
573+
574+.sv-button:focus-visible,
575+.sv-icon-button:focus-visible,
576+.sv-domain-filter:focus-visible,
577+.sv-danger-link:focus-visible,
578+.sv-filter-chip button:focus-visible,
579+.sv-link:focus-visible,
580+.sv-search-label input:focus-visible,
581+.sv-card-form input:focus-visible,
582+.sv-card-form select:focus-visible,
583+.sv-inline-form input:focus-visible,
584+.sv-inline-form select:focus-visible,
585+.sv-fieldset input:focus-visible,
586+.sv-fieldset select:focus-visible {
587+ outline: 2px solid var(--sv-accent, #8ab4ff);
588+ outline-offset: 2px;
589+}
590+
591+.sv-button:disabled {
592+ cursor: not-allowed;
593+ opacity: 0.45;
594+}
595+
596+.sv-filter-chip {
597+ display: inline-flex;
598+ gap: 0.35rem;
599+ align-items: center;
600+ border: 1px solid var(--sv-border, #2a313d);
601+ border-radius: var(--sv-radius-chip, 0.45rem);
602+ padding: 0.25rem 0.55rem;
603+ background: var(--sv-surface-variant, #1d2129);
604+}
605+
606+.sv-filter-chip button,
607+.sv-domain-filter,
608+.sv-domain-delete,
609+.sv-icon-button {
610+ border: 0;
611+ background: transparent;
612+ color: inherit;
613+ cursor: pointer;
614+ font: inherit;
615+}
616+
617+.sv-domain-filter {
618+ min-width: 0;
619+ overflow: hidden;
620+ color: var(--sv-text, #e8ebf0);
621+ font-weight: 700;
622+ padding: 0;
623+ text-align: left;
624+ text-overflow: ellipsis;
625+ white-space: nowrap;
626+}
627+
628+.sv-domain-delete {
629+ display: inline-grid;
630+ place-items: center;
631+ flex: 0 0 auto;
632+ width: 1rem;
633+ height: 1rem;
634+ padding: 0;
635+ border-radius: 999px;
636+ color: var(--sv-danger-text, #fecaca);
637+ font-size: 0.85rem;
638+ font-weight: 700;
639+ line-height: 1;
640+}
641+
642+.sv-domain-delete:hover {
643+ background: color-mix(in srgb, var(--sv-danger, #ef4444) 18%, transparent);
644+}
645+
646+.sv-icon-button {
647+ display: inline-flex;
648+ align-items: center;
649+ justify-content: center;
650+ width: 1.75rem;
651+ height: 1.75rem;
652+ border-radius: 999px;
653+}
654+
655+.sv-icon {
656+ flex: 0 0 auto;
657+ width: 1rem;
658+ height: 1rem;
659+ stroke-width: 2.15;
660+}
661+
662+.sv-icon-button .sv-icon {
663+ width: 0.95rem;
664+ height: 0.95rem;
665+}
666+
667+.sv-sr-only {
668+ position: absolute;
669+ width: 1px;
670+ height: 1px;
671+ padding: 0;
672+ margin: -1px;
673+ overflow: hidden;
674+ -webkit-clip-path: inset(50%);
675+ clip-path: inset(50%);
676+ white-space: nowrap;
677+ border: 0;
678+}
679+
680+.sv-danger {
681+ color: var(--sv-danger-text, #fecaca);
682+}
683+
684+.sv-icon-button:hover,
685+.sv-filter-chip button:hover,
686+.sv-domain-filter:hover {
687+ background: color-mix(in srgb, var(--sv-accent, #8ab4ff) 12%, transparent);
688+}
689+
690+.sv-confirm-backdrop {
691+ position: fixed;
692+ inset: 0;
693+ z-index: 1000;
694+ display: grid;
695+ place-items: center;
696+ padding: 1rem;
697+ background: rgba(0, 0, 0, 0.55);
698+ backdrop-filter: blur(2px);
699+}
700+
701+.sv-confirm-dialog {
702+ width: min(26rem, 100%);
703+ border: 1px solid color-mix(in srgb, var(--sv-danger, #ef4444) 48%, var(--sv-border, #2a313d));
704+ border-radius: var(--sv-radius-card, 0.5rem);
705+ padding: 1rem;
706+ background: var(--sv-surface, #171a21);
707+ color: var(--sv-text, #e8ebf0);
708+ box-shadow: 0 1.25rem 3rem rgba(0, 0, 0, 0.35);
709+}
710+
711+.sv-confirm-dialog h3 {
712+ margin: 0;
713+ font-size: 1rem;
714+}
715+
716+.sv-confirm-dialog p {
717+ margin: 0.65rem 0 0;
718+ color: var(--sv-muted, #9aa3b2);
719+}
720+
721+.sv-confirm-actions {
722+ display: flex;
723+ justify-content: flex-end;
724+ gap: 0.5rem;
725+ margin-top: 1rem;
726+}
727+
728+.sv-history-item.sv-focused {
729+ outline: 2px solid var(--sv-accent, #8ab4ff);
730+ outline-offset: 0.35rem;
731+ border-radius: var(--sv-radius-control, 0.35rem);
732+}
733+
734+.sv-key-hints kbd {
735+ border: 1px solid var(--sv-border, #2a313d);
736+ border-radius: 0.25rem;
737+ padding: 0.1rem 0.3rem;
738+ background: var(--sv-surface-variant, #1d2129);
739+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
740+}
741+
742+.sv-card-form,
743+.sv-inline-form {
744+ display: flex;
745+ flex-wrap: wrap;
746+ gap: 0.65rem;
747+ align-items: end;
748+ margin-top: 0.75rem;
749+}
750+
751+.sv-card-form {
752+ padding: 0.75rem;
753+ border: 1px solid var(--sv-border, #2a313d);
754+ border-radius: var(--sv-radius-card, 0.5rem);
755+ background: var(--sv-surface-variant, #1d2129);
756+}
757+
758+.sv-card-form label,
759+.sv-inline-form label,
760+.sv-fieldset label {
761+ display: grid;
762+ gap: 0.25rem;
763+ min-width: min(14rem, 100%);
764+ color: var(--sv-muted, #9aa3b2);
765+ font-size: 12px;
766+ font-weight: 600;
767+}
768+
769+.sv-card-form input,
770+.sv-card-form select,
771+.sv-inline-form input,
772+.sv-inline-form select,
773+.sv-fieldset input,
774+.sv-fieldset select {
775+ border: 1px solid var(--sv-border, #2a313d);
776+ border-radius: var(--sv-radius-control, 0.35rem);
777+ padding: 0.5rem 0.6rem;
778+ background: var(--sv-surface-variant, #1d2129);
779+ color: var(--sv-text, #e8ebf0);
780+ font: inherit;
781+}
782+
783+.sv-card-form input[type="color"],
784+.sv-inline-form input[type="color"],
785+.sv-fieldset input[type="color"] {
786+ width: 4rem;
787+ min-width: 4rem;
788+ height: 2.1rem;
789+ padding: 0.15rem;
790+ background: transparent;
791+ cursor: pointer;
792+}
793+
794+.sv-card-form input[type="color"]::-webkit-color-swatch-wrapper,
795+.sv-inline-form input[type="color"]::-webkit-color-swatch-wrapper,
796+.sv-fieldset input[type="color"]::-webkit-color-swatch-wrapper {
797+ padding: 0;
798+}
799+
800+.sv-card-form input[type="color"]::-webkit-color-swatch,
801+.sv-inline-form input[type="color"]::-webkit-color-swatch,
802+.sv-fieldset input[type="color"]::-webkit-color-swatch {
803+ border: 0;
804+ border-radius: calc(var(--sv-radius-control, 0.35rem) - 0.12rem);
805+}
806+
807+.sv-config-form,
808+.sv-fieldset {
809+ display: flex;
810+ flex-wrap: wrap;
811+ gap: 0.65rem;
812+ align-items: end;
813+}
814+
815+.sv-fieldset {
816+ width: 100%;
817+ margin: 0;
818+ padding: 0.75rem;
819+ border: 1px solid var(--sv-border, #2a313d);
820+ border-radius: var(--sv-radius-card, 0.5rem);
821+}
822+
823+.sv-fieldset legend {
824+ color: var(--sv-muted, #9aa3b2);
825+ font-weight: 700;
826+ padding: 0 0.35rem;
827+}
828+
829+.sv-keybinding-row {
830+ display: grid;
831+ grid-template-columns: minmax(0, 1fr) minmax(18rem, auto);
832+ gap: 1rem;
833+ align-items: start;
834+}
835+
836+.sv-filter-panel {
837+ display: grid;
838+ gap: 0.6rem;
839+ margin-top: 0.75rem;
840+}
841+
842+.sv-history-row-actions .sv-button {
843+ min-width: 4.75rem;
844+}
845+
846+.sv-history-row-actions .sv-button-icon-only {
847+ width: 2rem;
848+ min-width: 2rem;
849+ flex: 0 0 2rem;
850+}
851+
852+.sv-button-active {
853+ border-color: var(--sv-accent, #8ab4ff);
854+ box-shadow: 0 0 0 1px var(--sv-accent, #8ab4ff) inset;
855+}
856+
857+.sv-tag-picker {
858+ display: grid;
859+ gap: 0.45rem;
860+ min-width: min(22rem, 100%);
861+ margin: 0;
862+ padding: 0;
863+ border: 0;
864+}
865+
866+.sv-tag-picker legend {
867+ color: var(--sv-muted, #9aa3b2);
868+ font-size: 12px;
869+ font-weight: 600;
870+}
871+
872+.sv-card-form .sv-tag-toggle,
873+.sv-tag-toggle {
874+ display: inline-flex;
875+ min-width: 0;
876+ color: inherit;
877+ font-size: inherit;
878+ font-weight: inherit;
879+ cursor: pointer;
880+}
881+
882+.sv-tag-toggle input {
883+ position: absolute;
884+ inline-size: 1px;
885+ block-size: 1px;
886+ opacity: 0;
887+ pointer-events: none;
888+}
889+
890+.sv-tag-toggle span,
891+.sv-tag-button {
892+ display: inline-flex;
893+ align-items: center;
894+ gap: 0.35rem;
895+}
896+
897+.sv-tag-toggle > span {
898+ min-height: 1.45rem;
899+ border: 1px solid var(--sv-border, #2a313d);
900+ border-radius: var(--sv-radius-chip, 0.45rem);
901+ padding: 0.16rem 0.45rem;
902+ background: var(--sv-surface-variant, #1d2129);
903+ color: var(--sv-text, #e8ebf0);
904+ font-size: 12px;
905+ font-weight: 700;
906+}
907+
908+.sv-tag-toggle input:checked + span,
909+.sv-tag-button-active {
910+ border-color: var(--sv-accent, #8ab4ff);
911+ background: var(--sv-accent, #8ab4ff);
912+ color: var(--sv-background, #0f1115);
913+}
914+
915+.sv-favorite-item {
916+ display: grid;
917+ gap: 0.75rem;
918+}
919+
920+.sv-favorite-header {
921+ display: flex;
922+ justify-content: space-between;
923+ gap: 1rem;
924+ align-items: start;
925+}
926+
927+.sv-tag-controls {
928+ display: flex;
929+ flex-wrap: wrap;
930+ gap: 0.45rem;
931+ align-items: center;
932+}
933+
934+.sv-tag-dot {
935+ display: inline-block;
936+ width: 0.65rem;
937+ height: 0.65rem;
938+ border-radius: 999px;
939+ margin-right: 0.35rem;
940+ border: 1px solid color-mix(in srgb, var(--sv-border, #2a313d) 65%, transparent);
941+}
942+
943+.sv-favorite-item.sv-focused {
944+ outline: 2px solid var(--sv-accent, #8ab4ff);
945+ outline-offset: 0.35rem;
946+ border-radius: var(--sv-radius-control, 0.35rem);
947+}
948+
949+@media (max-width: 720px) {
950+ .sv-favorite-header,
951+ .sv-keybinding-row,
952+ .sv-history-item,
953+ .sv-history-actions-panel,
954+ .sv-toolbar {
955+ display: grid;
956+ grid-template-columns: 1fr;
957+ }
958+
959+ .sv-kv-row {
960+ grid-template-columns: 1fr;
961+ }
962+
963+ .sv-card-form,
964+ .sv-inline-form,
965+ .sv-fieldset,
966+ .sv-button-row {
967+ align-items: stretch;
968+ }
969+
970+ .sv-card-form label,
971+ .sv-inline-form label,
972+ .sv-fieldset label,
973+ .sv-button,
974+ .sv-button-secondary {
975+ width: 100%;
976+ }
977+}
M assets/webui_embed.go +5 -3
@@ -4,10 +4,12 @@ import (
44 "embed"
55 )
66
7-// WebUIAssets contains the embedded webui build output (homepage, error, config, and webrtc pages).
8-// Used by the scheme handler to serve dumb://home, dumb://error, dumb://config, and dumb://webrtc.
7+// WebUIAssets contains the embedded systemviews build output.
8+// The legacy WebUIAssets name is kept for compatibility with existing callers.
9+// Used by the scheme handlers to serve dumb://error, dumb://config,
10+// dumb://history, and dumb://favorites.
911 //
10-//go:embed webui/*
12+//go:embed systemviews/*
1113 var WebUIAssets embed.FS
1214
1315 // LogoSVG contains the dumber logo as SVG for desktop icon installation.
M aur/dumber-browser-git/.SRCINFO +0 -1
@@ -6,7 +6,6 @@ pkgbase = dumber-browser-git
66 arch = x86_64
77 license = MIT
88 makedepends = go>=1.25
9- makedepends = npm
109 makedepends = git
1110 depends = gtk4
1211 depends = webkitgtk-6.0
M aur/dumber-browser-git/PKGBUILD +1 -1
@@ -6,7 +6,7 @@ pkgdesc="A minimal keyboard-driven browser for tiling WMs (git version)"
66 arch=('x86_64')
77 url="https://github.com/bnema/dumber"
88 license=('MIT')
9-makedepends=('go>=1.25' 'npm' 'git')
9+makedepends=('go>=1.26' 'git' 'brotli')
1010 depends=('gtk4' 'webkitgtk-6.0')
1111 optdepends=(
1212 'gst-plugins-base: Base media codecs'
A cmd/systemviews/bootstrap.go +64 -0
@@ -0,0 +1,64 @@
1+package main
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/ui/systemviews"
9+)
10+
11+type bridgeServices interface {
12+ port.SystemviewHistoryService
13+ port.SystemviewFavoritesService
14+ port.SystemviewConfigService
15+}
16+
17+func newBridgeApp(dom systemviews.DOM, locationURI string, bridge bridgeServices) *systemviews.App {
18+ route := systemviews.ParseRoute(locationURI)
19+ return systemviews.NewApp(systemviews.Dependencies{
20+ DOM: dom,
21+ History: bridge,
22+ Favorites: bridge,
23+ Config: bridgeConfigProxy{bridge: bridge, route: route},
24+ LocationURI: locationURI,
25+ })
26+}
27+
28+type bridgeConfigProxy struct {
29+ bridge bridgeServices
30+ route systemviews.Route
31+}
32+
33+func (p bridgeConfigProxy) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
34+ return p.bridge.Current(ctx)
35+}
36+
37+func (p bridgeConfigProxy) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
38+ return p.bridge.Default(ctx)
39+}
40+
41+func (p bridgeConfigProxy) Save(ctx context.Context, cfg dto.WebUIConfig) error {
42+ return p.bridge.Save(ctx, cfg)
43+}
44+
45+func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
46+ // Keybindings are only rendered by dumb://config; skip the bridge load for
47+ // other systemview routes to keep initial history/favorites loads cheap.
48+ if p.route != systemviews.RouteConfig {
49+ return port.KeybindingsConfig{}, nil
50+ }
51+ return p.bridge.GetKeybindings(ctx)
52+}
53+
54+func (p bridgeConfigProxy) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
55+ return p.bridge.SetKeybinding(ctx, req)
56+}
57+
58+func (p bridgeConfigProxy) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
59+ return p.bridge.ResetKeybinding(ctx, req)
60+}
61+
62+func (p bridgeConfigProxy) ResetAllKeybindings(ctx context.Context) error {
63+ return p.bridge.ResetAllKeybindings(ctx)
64+}
A cmd/systemviews/bootstrap_test.go +258 -0
@@ -0,0 +1,258 @@
1+package main
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync/atomic"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestNewBridgeApp_WiresHistoryService(t *testing.T) {
18+ t.Parallel()
19+
20+ bridge := &fakeBridgeService{historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}}}
21+ app := newBridgeApp(&fakeDOM{}, "dumb://history", bridge)
22+
23+ require.NoError(t, app.LoadInitial(context.Background()))
24+ assert.True(t, bridge.calledHistory.Load())
25+ assert.False(t, bridge.calledFavorites.Load())
26+ assert.True(t, bridge.calledConfig.Load())
27+ assert.False(t, bridge.calledKeybindings.Load())
28+}
29+
30+func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {
31+ t.Parallel()
32+
33+ bridge := &fakeBridgeService{
34+ historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}},
35+ currentConfig: dto.SystemviewConfigPayload{
36+ Appearance: dto.WebUIAppearanceConfig{
37+ ColorScheme: "prefer-dark",
38+ DarkPalette: dto.ColorPalette{
39+ Background: "#111111",
40+ Surface: "#1a1a1a",
41+ SurfaceVariant: "#2a2a2a",
42+ Text: "#f5f5f5",
43+ Muted: "#a0a0a0",
44+ Accent: "#66aaff",
45+ Border: "#333333",
46+ },
47+ },
48+ },
49+ }
50+ dom := &fakeDOM{mounts: make(chan string, 4)}
51+ app := newBridgeApp(dom, "dumb://history", bridge)
52+
53+ require.NoError(t, app.Run())
54+ require.Eventually(t, bridge.calledHistory.Load, time.Second, 10*time.Millisecond)
55+ assert.True(t, bridge.calledConfig.Load())
56+ assert.False(t, bridge.calledKeybindings.Load())
57+ html := receiveMountContaining(t, dom.mounts, `class="sv-app sv-dark"`, `--sv-background: #111111;`)
58+ assert.Contains(t, html, `class="sv-app sv-dark"`)
59+ assert.Contains(t, html, `--sv-background: #111111;`)
60+}
61+
62+func TestNewBridgeApp_WiresFavoritesService(t *testing.T) {
63+ t.Parallel()
64+
65+ bridge := &fakeBridgeService{
66+ favorites: []*entity.Favorite{{Title: "Example", URL: "https://example.com"}},
67+ folders: []*entity.Folder{{Name: "Read Later"}},
68+ tags: []*entity.Tag{{Name: "Go"}},
69+ }
70+ app := newBridgeApp(&fakeDOM{}, "dumb://favorites", bridge)
71+
72+ require.NoError(t, app.LoadInitial(context.Background()))
73+ assert.False(t, bridge.calledHistory.Load())
74+ assert.True(t, bridge.calledFavorites.Load())
75+ assert.True(t, bridge.calledConfig.Load())
76+ assert.False(t, bridge.calledKeybindings.Load())
77+}
78+
79+func TestNewBridgeApp_WiresConfigService(t *testing.T) {
80+ t.Parallel()
81+
82+ bridge := &fakeBridgeService{
83+ currentConfig: dto.SystemviewConfigPayload{EngineType: "webkit"},
84+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{DisplayName: "Default"}}},
85+ }
86+ app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge)
87+
88+ require.NoError(t, app.LoadInitial(context.Background()))
89+ assert.False(t, bridge.calledHistory.Load())
90+ assert.False(t, bridge.calledFavorites.Load())
91+ assert.True(t, bridge.calledConfig.Load())
92+ assert.True(t, bridge.calledKeybindings.Load())
93+}
94+
95+type fakeDOM struct {
96+ html string
97+ mounts chan string
98+}
99+
100+func (f *fakeDOM) Mount(html string) error {
101+ f.html = html
102+ if f.mounts != nil {
103+ select {
104+ case f.mounts <- html:
105+ default:
106+ }
107+ }
108+ return nil
109+}
110+
111+func receiveMountContaining(t *testing.T, mounts <-chan string, values ...string) string {
112+ t.Helper()
113+ deadline := time.After(time.Second)
114+ for {
115+ select {
116+ case html := <-mounts:
117+ matched := true
118+ for _, value := range values {
119+ if !strings.Contains(html, value) {
120+ matched = false
121+ break
122+ }
123+ }
124+ if matched {
125+ return html
126+ }
127+ case <-deadline:
128+ t.Fatalf("timed out waiting for DOM mount containing %q", values)
129+ return ""
130+ }
131+ }
132+}
133+
134+// Handwritten fake intentionally tracks state across history, favorites, config,
135+// and keybindings boundaries for the composite bootstrap assertions.
136+type fakeBridgeService struct {
137+ calledHistory atomic.Bool
138+ calledFavorites atomic.Bool
139+ calledConfig atomic.Bool
140+ calledKeybindings atomic.Bool
141+
142+ historyEntries []*entity.HistoryEntry
143+ favorites []*entity.Favorite
144+ folders []*entity.Folder
145+ tags []*entity.Tag
146+ currentConfig dto.SystemviewConfigPayload
147+ keybindings port.KeybindingsConfig
148+}
149+
150+func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) {
151+ f.calledHistory.Store(true)
152+ return f.historyEntries, nil
153+}
154+
155+func (f *fakeBridgeService) TimelineByDomain(context.Context, string, int, int) ([]*entity.HistoryEntry, error) {
156+ f.calledHistory.Store(true)
157+ return f.historyEntries, nil
158+}
159+
160+func (f *fakeBridgeService) TimelineWindow(_ context.Context, before time.Time, _ string) (*entity.HistoryWindow, error) {
161+ f.calledHistory.Store(true)
162+ return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil
163+}
164+
165+func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) {
166+ return nil, nil
167+}
168+
169+func (*fakeBridgeService) DeleteEntry(context.Context, int64) error { return nil }
170+
171+func (*fakeBridgeService) DeleteRange(context.Context, string) error { return nil }
172+
173+func (*fakeBridgeService) Stats(context.Context) (*entity.HistoryStats, error) {
174+ return nil, nil
175+}
176+
177+func (*fakeBridgeService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
178+ return nil, nil
179+}
180+
181+func (*fakeBridgeService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {
182+ return nil, nil
183+}
184+
185+func (*fakeBridgeService) DeleteDomain(context.Context, string) error { return nil }
186+
187+func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) {
188+ f.calledFavorites.Store(true)
189+ return f.favorites, nil
190+}
191+
192+func (*fakeBridgeService) CreateFavorite(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error) {
193+ return nil, nil
194+}
195+
196+func (*fakeBridgeService) UpdateFavorite(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error) {
197+ return nil, nil
198+}
199+
200+func (*fakeBridgeService) DeleteFavorite(context.Context, int64) error { return nil }
201+
202+func (f *fakeBridgeService) ListFolders(context.Context) ([]*entity.Folder, error) {
203+ return f.folders, nil
204+}
205+
206+func (f *fakeBridgeService) ListTags(context.Context) ([]*entity.Tag, error) {
207+ return f.tags, nil
208+}
209+
210+func (*fakeBridgeService) SetShortcut(context.Context, int64, *int) error { return nil }
211+
212+func (*fakeBridgeService) SetFolder(context.Context, int64, *int64) error { return nil }
213+
214+func (*fakeBridgeService) CreateFolder(context.Context, string, string, *int64) (*entity.Folder, error) {
215+ return nil, nil
216+}
217+
218+func (*fakeBridgeService) UpdateFolder(context.Context, int64, string, string) error { return nil }
219+
220+func (*fakeBridgeService) DeleteFolder(context.Context, int64) error { return nil }
221+
222+func (*fakeBridgeService) CreateTag(context.Context, string, string) (*entity.Tag, error) {
223+ return nil, nil
224+}
225+
226+func (*fakeBridgeService) UpdateTag(context.Context, int64, string, string) error { return nil }
227+
228+func (*fakeBridgeService) DeleteTag(context.Context, int64) error { return nil }
229+
230+func (*fakeBridgeService) AssignTag(context.Context, int64, int64) error { return nil }
231+
232+func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil }
233+
234+func (f *fakeBridgeService) Current(context.Context) (dto.SystemviewConfigPayload, error) {
235+ f.calledConfig.Store(true)
236+ return f.currentConfig, nil
237+}
238+
239+func (*fakeBridgeService) Default(context.Context) (dto.SystemviewConfigPayload, error) {
240+ return dto.SystemviewConfigPayload{}, nil
241+}
242+
243+func (*fakeBridgeService) Save(context.Context, dto.WebUIConfig) error { return nil }
244+
245+func (f *fakeBridgeService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
246+ f.calledKeybindings.Store(true)
247+ return f.keybindings, nil
248+}
249+
250+func (*fakeBridgeService) SetKeybinding(context.Context, port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
251+ return port.SetKeybindingResponse{}, nil
252+}
253+
254+func (*fakeBridgeService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error {
255+ return nil
256+}
257+
258+func (*fakeBridgeService) ResetAllKeybindings(context.Context) error { return nil }
A cmd/systemviews/main.go +30 -0
@@ -0,0 +1,30 @@
1+//go:build js && wasm
2+
3+package main
4+
5+import (
6+ "syscall/js"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/systemviewsbridge"
9+ "github.com/bnema/dumber/internal/ui/systemviews"
10+)
11+
12+func main() {
13+ bridge := systemviewsbridge.NewBrowserClient()
14+ href := ""
15+ location := js.Global().Get("location")
16+ if location.Truthy() {
17+ hrefValue := location.Get("href")
18+ if hrefValue.Truthy() && hrefValue.Type() == js.TypeString {
19+ href = hrefValue.String()
20+ }
21+ }
22+ app := newBridgeApp(systemviews.NewDOM(), href, bridge)
23+
24+ if err := app.Run(); err != nil {
25+ js.Global().Get("console").Call("error", err.Error())
26+ panic(err)
27+ }
28+
29+ select {}
30+}
A cmd/systemviews/main_stub.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !js || !wasm
2+
3+package main
4+
5+func main() {}
M docs/config/index.md +1 -1
@@ -204,7 +204,7 @@ border = "#363636"
204204 **Example:**
205205 ```toml
206206 [workspace]
207-new_pane_url = "dumb://home"
207+new_pane_url = "dumb://history"
208208 ```
209209
210210 ### Pane Mode
M docs/reference/keybindings.md +15 -2
@@ -80,6 +80,9 @@ These work outside modal modes:
8080 | Action | Keys |
8181 |--------|------|
8282 | Toggle floating pane | `Alt+F` |
83+| Toggle History system view in right split (may conflict with the browser's default History shortcut; behavior can vary by browser) | `Ctrl+H` |
84+| Toggle Favorites system view in right split | unbound by default |
85+| Toggle Config system view in right split | unbound by default |
8386 | Close pane (or release floating pane) | `Ctrl+W` |
8487 | Next tab | `Ctrl+Tab` |
8588 | Previous tab | `Ctrl+Shift+Tab` |
@@ -90,6 +93,7 @@ These work outside modal modes:
9093
9194 - `Alt+F` is the only floating-pane shortcut enabled by default.
9295 - `Alt+F` toggles floating visibility and keeps floating pane state intact.
96+- `Ctrl+H` toggles `dumb://history`: it focuses an existing History pane, opens it in a right split if missing, or closes it when already active. Depending on the browser, the default History shortcut may intercept `Ctrl+H`, so behavior can vary.
9397 - `Ctrl+W` closes the active pane; when the floating pane is active, it fully releases that floating session.
9498 - Any URL shortcut (for example `Alt+G`) must be defined explicitly in `workspace.floating_pane.profiles`.
9599 - Floating profile shortcuts support modifier combos with `ctrl`, `shift`, and `alt` (for example `ctrl+shift+y` or `ctrl+alt+m`).
@@ -108,12 +112,21 @@ All keybindings can be customized in `~/.config/dumber/config.toml`:
108112 split-right = ["arrowright", "r"]
109113 close-pane = ["x", "q"]
110114
111-[workspace.shortcuts.actions.close_pane]
115+[workspace.shortcuts.actions.close-pane]
112116 keys = ["ctrl+w"]
113117
114-[workspace.shortcuts.actions.toggle_floating_pane]
118+[workspace.shortcuts.actions.toggle-floating-pane]
115119 keys = ["alt+f"]
116120
121+[workspace.shortcuts.actions.toggle-history-systemview]
122+keys = ["ctrl+h"]
123+
124+[workspace.shortcuts.actions.toggle-favorites-systemview]
125+keys = []
126+
127+[workspace.shortcuts.actions.toggle-config-systemview]
128+keys = []
129+
117130 [workspace.floating_pane]
118131 width_pct = 0.82
119132 height_pct = 0.72
A docs/systemviews-wasm-crud-plan.md +115 -0
@@ -0,0 +1,115 @@
1+# Systemviews WASM CRUD and Architecture Plan
2+
3+This note captures the parity target for the `feat/systemviews-wasm` branch after merging `main`.
4+
5+## Current branch baseline
6+
7+- Worktree: `.worktrees/feat/systemviews-wasm`
8+- Branch: `feat/systemviews-wasm`
9+- Merge baseline: `d84415e5` (`Merge branch 'main' into feat/systemviews-wasm`)
10+- Vendor baseline: `go mod vendor` has been run in the worktree and `go list -mod=vendor ./...` succeeds there.
11+- Root-checkout vendoring diagnostics were corrected separately with `go mod vendor`; branch validation should still use `-mod=vendor` after dependency changes.
12+
13+## Clean architecture contract
14+
15+Keep the dependency direction explicit:
16+
17+| Layer | Role for systemviews | Must not do |
18+| --- | --- | --- |
19+| `internal/domain` | Pure entities/value objects and pure helpers such as history range parsing. | Import ports, usecases, adapters, GTK/CEF/WebKit, or config packages. |
20+| `internal/application/usecase` | Own favorites/history/config/keybinding behavior and validation. | Import infrastructure adapters or UI packages. |
21+| `internal/application/port` | Define the systemview-facing contracts and DTOs. | Depend on CEF/WebKit/SQLite/config concrete types. |
22+| `internal/infrastructure/*` | Implement handlers, scheme serving, bridge transports, persistence, config payload readers. | Put UI workflow or business rules here. |
23+| `internal/ui/systemviews` | Render and orchestrate WASM UI through ports. | Call repositories, config package, CEF/WebKit, or SQLite directly. |
24+| `internal/ui/dispatcher` / coordinators | Convert native actions into pane/tab/navigation orchestration. | Bypass usecases for domain state changes. |
25+
26+Preferred direction for new work:
27+
28+1. Add behavior to existing usecases first when it is business logic.
29+2. Expose it through application ports.
30+3. Implement WebUI/systemview handlers in `internal/infrastructure/handlers`.
31+4. Consume those handlers from `internal/infrastructure/systemviewsbridge`.
32+5. Render and bind user interactions in `internal/ui/systemviews`.
33+
34+## Old Svelte UI CRUD matrix
35+
36+### History (`webui/src/pages/homepage/*`)
37+
38+| Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap |
39+| --- | --- | --- | --- | --- |
40+| Read timeline | `HistoryPanel.svelte`, `HistoryTimeline.svelte`, `HistoryItem.svelte` | `history_timeline` | Present, but bare list only (`internal/ui/systemviews/history_view.go`). | Add pagination, loading/error/empty states, row metadata, favicons, visit counts. |
41+| Read/search | `HistorySearch.svelte`, `keyboard.ts` local fuzzy + FTS | `history_search_fts` | Bridge client supports it. | Add search UI, debounce/local filtering, FTS fallback, visible result count. |
42+| Read filters/stats | `HistoryFilters.svelte`, `AnalyticsPanel.svelte` | `history_domain_stats`, `history_analytics` | Bridge client supports both; config route renders keybinding/config summaries only. | Add domain chips, stats summary, analytics cards/charts or text equivalent. |
43+| Create | Browser navigation writes history outside the systemview UI. | Existing history repository/usecases. | Not a user-facing CRUD action. | No manual create needed. |
44+| Update | Not supported in old UI. | None. | None. | Keep out of scope unless a future title-edit requirement appears. |
45+| Delete one | `HistoryItem.svelte`, `keyboard.ts` | `history_delete_entry` | Bridge client supports it; UI not interactive. | Add row delete action with confirmation and refresh. |
46+| Delete range/all | `HistoryCleanup.svelte`, `keyboard.ts` | `history_delete_range`, `history_clear_all` | Bridge client supports range; route UI not interactive. | Add cleanup modal/actions for hour/day/week/month/all. |
47+| Delete domain | `keyboard.ts`, `HistoryFilters.svelte` | `history_delete_domain` | Bridge client supports it. | Add domain-action UI and confirmation. |
48+| Open row | `navigateTo(entry.url)` | Browser navigation | Static links exist. | Add keyboard open and active-row focus management. |
49+
50+### Favorites, folders, tags (`webui/src/pages/homepage/favorites/*`)
51+
52+| Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap |
53+| --- | --- | --- | --- | --- |
54+| Read favorites | `FavoritesPanel.svelte`, `FavoriteGrid.svelte`, `FavoriteCard.svelte` | `favorite_list` | Present, but bare link list (`favorites_view.go`). | Add rich rows/cards, favicons, shortcut badges, folder/tag metadata. |
55+| Create favorite | Old homepage hints imply adding/pinning from history; native omnibox toggle exists outside this page. | `ManageFavoritesUseCase.Add` exists, but no homepage/systemview message. | Missing from `port.HomepageFavorites`/`SystemviewFavoritesService` and handlers. | Add `favorite_create` handler/bridge and UI. |
56+| Update favorite | `FavoriteEditor.svelte` locally updates title and uses folder/shortcut/tag handlers. | `favorite_set_shortcut`, `favorite_set_folder`, `tag_assign`, `tag_remove`; no title update handler. | Partial bridge support. | Add `favorite_update` for title/favicon/url policy; keep folder/shortcut/tag specialized calls or fold into update after design. |
57+| Delete favorite | `FavoriteEditor.svelte` local-only delete placeholder. | `ManageFavoritesUseCase.Remove` exists, but no homepage/systemview message. | Missing. | Add `favorite_delete` handler/bridge/UI. |
58+| Read folders | `FolderList.svelte` | `folder_list` | Present as static list. | Add counts, selected filter, empty state. |
59+| Create folder | `FolderList.svelte` inline create | `folder_create` | Bridge and handler exist. | Add interactive UI. |
60+| Update folder | Handler exists (`folder_update`). | `folder_update` | Bridge exists. | Add rename/icon UI. |
61+| Delete folder | Handler exists (`folder_delete`). | `folder_delete` | Bridge exists. | Add UI, confirm, refresh favorite folder state. |
62+| Read tags | `TagCloud.svelte` | `tag_list` | Present as static list. | Add color chips and selection. |
63+| Create tag | `TagCloud.svelte` inline create | `tag_create` | Bridge and handler exist. | Add UI with color picker/palette. |
64+| Update tag | Handler exists (`tag_update`). | `tag_update` | Bridge exists. | Add rename/color edit UI. |
65+| Delete tag | Handler exists (`tag_delete`). | `tag_delete` | Bridge exists. | Add confirm UI and refresh assignments. |
66+| Assign/remove tag | `FavoriteEditor.svelte` | `tag_assign`, `tag_remove` | Bridge exists. | Add editor UI. |
67+
68+### Config and keybindings (`webui/src/pages/ConfigPage.svelte`, `webui/src/pages/config/*`)
69+
70+| Operation | Old Svelte surface | Backend message/API | Current WASM status | Gap |
71+| --- | --- | --- | --- | --- |
72+| Read config | `ConfigPage.svelte` | `/api/config` | Bridge supports direct API and route renders flattened values. | Replace flattened read-only view with grouped editable forms. |
73+| Read defaults | Reset dialog in `ConfigPage.svelte` | `/api/config/default` | Bridge supports direct API. | Add reset-to-default flow. |
74+| Save config | `save_config` via native bridge | `save_config` | Bridge supports it. Existing app config watcher hot-reloads appearance/keybindings/engine settings. | Add form save flow, validation, success/error UI, refresh after save. |
75+| Appearance update | Fonts, UI scale, color scheme, palettes | `save_config` | Payload shape exists in `SystemviewConfigPayload`. | Add form controls and palette validation. |
76+| Search shortcuts CRUD | `ShortcutsTable.svelte` | Saved inside config payload | Missing interactive UI. | Add table/list editing and `%s`/`{query}` validation. |
77+| Performance update | Performance tab | `save_config` | Payload shape exists; CEF gating needed. | Add profile/custom fields and restart warning. |
78+| Keybindings read | `KeybindingsTab.svelte` | `get_keybindings` | Route renders read-only keybinding groups. | Add editable keybinding UI. |
79+| Keybindings update | `KeyCaptureModal.svelte` | `set_keybinding`, `reset_keybinding`, `reset_all_keybindings` | Bridge supports message types. | Add capture/reset UI and conflict display. |
80+
81+## Native shortcut and split-pane target
82+
83+Add first-class global actions:
84+
85+| Action | Default key | Target behavior |
86+| --- | --- | --- |
87+| `toggle_history_systemview` | `ctrl+h` | Split active pane to the right with `dumb://history`, focus an existing history systemview pane, or close it if already active. |
88+| `toggle_favorites_systemview` | Unset by default. | Split/focus/toggle `dumb://favorites`. |
89+| `toggle_config_systemview` | Unset by default; user can bind in config. | Split/focus/toggle `dumb://config`. |
90+
91+Implementation path:
92+
93+1. Add input actions in `internal/ui/input/shortcuts.go` and config defaults/schema in `internal/infrastructure/config`.
94+2. Map the actions in global shortcuts and keybinding DTOs.
95+3. Add a coordinator method such as `WorkspaceCoordinator.SplitURL(ctx, SplitRight, "dumb://history")` so dispatcher does not manipulate pane internals.
96+4. Use existing `ManagePanesUseCase.Split` with `SplitPaneInput.InitialURL` to keep domain/usecase clean.
97+5. Add tests in input, dispatcher, config defaults/schema, and workspace coordinator.
98+
99+## Completed branch notes
100+
101+- Rendering now uses typed `github.com/a-h/templ` components under `internal/ui/systemviews/*.templ`; generated `*_templ.go` files are committed alongside source templates.
102+- `make build-systemviews` runs `go tool templ generate -path internal/ui/systemviews -include-version=false` before building the WASM runtime.
103+- History supports search, domain filters, pagination, analytics/domain summaries, single/range/domain deletion, row open actions, alerts, and keyboard navigation.
104+- Favorites supports create/update/delete for favorites, folders, and tags; folder/tag filters; shortcut editing; tag assignment/removal; alerts; and keyboard navigation.
105+- Config supports editable appearance, search defaults, search shortcut CRUD, performance profile/custom values, keybinding set/reset/reset-all, success/error states, and refreshes the config payload/theme after save.
106+- Native global actions use `toggle_*_systemview` names and open/focus/close History/Favorites/Config system views through the coordinator/right-split path.
107+- Trust-boundary hardening includes templ HTML escaping, URL scheme sanitization for rendered links, sanitized CSS color use for tag swatches, serialized DOM action handling, and route-level error states.
108+
109+## Implementation notes
110+
111+- Do not resurrect `webui/`; the branch intentionally removed Svelte assets.
112+- Keep WASM UI code small and stateful. Avoid pushing business rules into HTML string helpers.
113+- Prefer explicit bridge request methods over ad-hoc message strings in UI code.
114+- Every destructive action needs confirmation and a visible refresh/error path.
115+- Every feature should remain usable by keyboard because these pages are browser management surfaces, not marketing pages.
M go.mod +13 -1
@@ -3,8 +3,9 @@ module github.com/bnema/dumber
33 go 1.26.1
44
55 require (
6+ github.com/a-h/templ v0.3.1001
67 github.com/bnema/purego v0.11.0-bnema.2
7- github.com/bnema/purego-cef v0.10.2
8+ github.com/bnema/purego-cef v0.11.0
89 github.com/bnema/purego-ffmpeg v0.3.1
910 github.com/bnema/purego-pipewire v0.1.2
1011 github.com/bnema/purego-sqlite v0.1.2
@@ -27,18 +28,23 @@ require (
2728 )
2829
2930 require (
31+ github.com/a-h/parse v0.0.0-20250122154542-74294addb73e // indirect
32+ github.com/andybalholm/brotli v1.2.0 // indirect
3033 github.com/atotto/clipboard v0.1.4 // indirect
3134 github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
35+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
3236 github.com/charmbracelet/colorprofile v0.4.1 // indirect
3337 github.com/charmbracelet/x/ansi v0.11.6 // indirect
3438 github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
3539 github.com/charmbracelet/x/term v0.2.2 // indirect
40+ github.com/cli/browser v1.3.0 // indirect
3641 github.com/clipperhouse/displaywidth v0.9.0 // indirect
3742 github.com/clipperhouse/stringish v0.1.1 // indirect
3843 github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
3944 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
4045 github.com/davecgh/go-spew v1.1.1 // indirect
4146 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
47+ github.com/fatih/color v1.16.0 // indirect
4248 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
4349 github.com/inconshreveable/mousetrap v1.1.0 // indirect
4450 github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -50,6 +56,7 @@ require (
5056 github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
5157 github.com/muesli/cancelreader v0.2.2 // indirect
5258 github.com/muesli/termenv v0.16.0 // indirect
59+ github.com/natefinch/atomic v1.0.1 // indirect
5360 github.com/pmezard/go-difflib v1.0.0 // indirect
5461 github.com/rivo/uniseg v0.4.7 // indirect
5562 github.com/russross/blackfriday/v2 v2.1.0 // indirect
@@ -64,8 +71,13 @@ require (
6471 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
6572 go.uber.org/multierr v1.11.0 // indirect
6673 go.yaml.in/yaml/v3 v3.0.4 // indirect
74+ golang.org/x/mod v0.34.0 // indirect
75+ golang.org/x/net v0.52.0 // indirect
6776 golang.org/x/sync v0.20.0 // indirect
6877 golang.org/x/text v0.36.0 // indirect
78+ golang.org/x/tools v0.43.0 // indirect
6979 gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
7080 modernc.org/sqlite v1.47.0 // indirect
7181 )
82+
83+tool github.com/a-h/templ/cmd/templ
M go.sum +24 -0 generated file: collapsed
A internal/application/dto/favorites.go +21 -0
@@ -0,0 +1,21 @@
1+package dto
2+
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// FavoriteCreateInput contains fields needed to create a favorite from a UI port.
6+type FavoriteCreateInput struct {
7+ URL string `json:"url"`
8+ Title string `json:"title"`
9+ FaviconURL string `json:"favicon_url"`
10+ FolderID *entity.FolderID `json:"folder_id"`
11+ Tags []entity.TagID `json:"tags"`
12+}
13+
14+// FavoriteUpdateInput contains editable favorite metadata.
15+type FavoriteUpdateInput struct {
16+ ID entity.FavoriteID `json:"id"`
17+ Title string `json:"title"`
18+ FaviconURL string `json:"favicon_url"`
19+ FolderID *entity.FolderID `json:"folder_id"`
20+ ShortcutKey *int `json:"shortcut_key"`
21+}
A internal/application/dto/history.go +14 -0
@@ -0,0 +1,14 @@
1+package dto
2+
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// HistorySearchInput holds search parameters for history search use cases.
6+type HistorySearchInput struct {
7+ Query string
8+ Limit int
9+}
10+
11+// HistorySearchOutput holds search results for history search use cases.
12+type HistorySearchOutput struct {
13+ Matches []entity.HistoryMatch
14+}
A internal/application/dto/webui_config.go +92 -0
@@ -0,0 +1,92 @@
1+package dto
2+
3+// WebUIConfig represents the subset of configuration editable in dumb://config.
4+type WebUIConfig struct {
5+ Appearance WebUIAppearanceConfig `json:"appearance"`
6+ Performance WebUIPerformanceConfig `json:"performance"`
7+ DefaultUIScale float64 `json:"default_ui_scale"`
8+ DefaultSearchEngine string `json:"default_search_engine"`
9+ SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
10+}
11+
12+// SystemviewCustomPerformancePayload is the editable custom performance profile shape.
13+type SystemviewCustomPerformancePayload = WebUICustomPerformanceConfig
14+
15+// SystemviewResolvedPerformancePayload is the resolved effective performance shape.
16+type SystemviewResolvedPerformancePayload struct {
17+ SkiaCPUThreads int `json:"skia_cpu_threads"`
18+ SkiaGPUThreads int `json:"skia_gpu_threads"`
19+ WebProcessMemoryMB int `json:"web_process_memory_mb"`
20+ NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
21+ WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
22+ ConservativeThreshold float64 `json:"conservative_threshold"`
23+ StrictThreshold float64 `json:"strict_threshold"`
24+}
25+
26+// SystemviewHardwarePayload exposes detected hardware to the settings UI.
27+type SystemviewHardwarePayload struct {
28+ CPUCores int `json:"cpu_cores"`
29+ CPUThreads int `json:"cpu_threads"`
30+ TotalRAMMB int `json:"total_ram_mb"`
31+ GPUVendor string `json:"gpu_vendor"`
32+ GPUName string `json:"gpu_name"`
33+ VRAMMB int `json:"vram_mb"`
34+}
35+
36+// SystemviewPerformancePayload is the performance tab payload expected by the UI.
37+type SystemviewPerformancePayload struct {
38+ Profile string `json:"profile"`
39+ Custom SystemviewCustomPerformancePayload `json:"custom"`
40+ Resolved SystemviewResolvedPerformancePayload `json:"resolved"`
41+ Hardware SystemviewHardwarePayload `json:"hardware"`
42+}
43+
44+// SystemviewConfigPayload is the full JSON shape expected by dumb://config.
45+type SystemviewConfigPayload struct {
46+ Appearance WebUIAppearanceConfig `json:"appearance"`
47+ Performance SystemviewPerformancePayload `json:"performance"`
48+ DefaultUIScale float64 `json:"default_ui_scale"`
49+ DefaultSearchEngine string `json:"default_search_engine"`
50+ SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
51+ EngineType string `json:"engine_type"`
52+}
53+
54+// WebUIPerformanceConfig represents performance settings editable in dumb://config.
55+type WebUIPerformanceConfig struct {
56+ Profile string `json:"profile"`
57+ Custom WebUICustomPerformanceConfig `json:"custom"`
58+}
59+
60+// WebUICustomPerformanceConfig holds user-editable fields for custom profile.
61+type WebUICustomPerformanceConfig struct {
62+ SkiaCPUThreads int `json:"skia_cpu_threads"`
63+ SkiaGPUThreads int `json:"skia_gpu_threads"`
64+ WebProcessMemoryMB int `json:"web_process_memory_mb"`
65+ NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
66+ WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
67+}
68+
69+type WebUIAppearanceConfig struct {
70+ SansFont string `json:"sans_font"`
71+ SerifFont string `json:"serif_font"`
72+ MonospaceFont string `json:"monospace_font"`
73+ DefaultFontSize int `json:"default_font_size"`
74+ ColorScheme string `json:"color_scheme"`
75+ LightPalette ColorPalette `json:"light_palette"`
76+ DarkPalette ColorPalette `json:"dark_palette"`
77+}
78+
79+type ColorPalette struct {
80+ Background string `json:"background"`
81+ Surface string `json:"surface"`
82+ SurfaceVariant string `json:"surface_variant"`
83+ Text string `json:"text"`
84+ Muted string `json:"muted"`
85+ Accent string `json:"accent"`
86+ Border string `json:"border"`
87+}
88+
89+type SearchShortcut struct {
90+ URL string `json:"url"`
91+ Description string `json:"description"`
92+}
M internal/application/port/favicon_service.go +7 -0
@@ -9,6 +9,7 @@ type FaviconService interface {
99 DiskPathPNG(domain string) string
1010 HasPNGOnDisk(domain string) bool
1111 HasPNGSizedOnDisk(domain string, size int) bool
12+ DiskPathPNGSized(domain string, size int) string
1213 EnsureSizedPNG(ctx context.Context, domain string, size int) error
1314 EnsureCacheDir() error
1415 // EnsureDiskCache ensures a favicon is written to the on-disk cache for the given
@@ -17,3 +18,9 @@ type FaviconService interface {
1718 EnsureDiskCache(ctx context.Context, domain string)
1819 Close()
1920 }
21+
22+// SystemviewFaviconServiceSetter is an optional engine capability that lets the
23+// UI expose cached favicons to internal systemview pages without remote image loads.
24+type SystemviewFaviconServiceSetter interface {
25+ SetSystemviewFaviconService(service FaviconService)
26+}
M internal/application/port/handler_deps.go +2 -1
@@ -3,6 +3,7 @@ package port
33 import (
44 "context"
55
6+ "github.com/bnema/dumber/internal/application/dto"
67 "github.com/bnema/dumber/internal/domain/entity"
78 )
89
@@ -43,7 +44,7 @@ type AutoCopyConfig interface {
4344 // A struct is used here to avoid reverse dependencies between the bootstrap and
4445 // UI layers, which both need to reference this shared dependency container.
4546 type HandlerDeps struct {
46- SaveConfig func(context.Context, WebUIConfig) error
47+ SaveConfig func(context.Context, dto.WebUIConfig) error
4748 SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error
4849 KeybindingsGetter KeybindingsGetter
4950 KeybindingSetter KeybindingSetter
M internal/application/port/homepage.go +10 -13
@@ -4,27 +4,21 @@ import (
44 "context"
55 "time"
66
7+ "github.com/bnema/dumber/internal/application/dto"
78 "github.com/bnema/dumber/internal/domain/entity"
89 )
910
10-// HistorySearchInput holds search parameters (moved from usecase.SearchInput).
11-type HistorySearchInput struct {
12- Query string
13- Limit int
14-}
15-
16-// HistorySearchOutput holds search results (moved from usecase.SearchOutput).
17-type HistorySearchOutput struct {
18- Matches []entity.HistoryMatch
19-}
20-
2111 // HomepageHistory provides history operations needed by the WebUI homepage handlers.
2212 type HomepageHistory interface {
2313 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
24- Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error)
14+ GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
15+ GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)
16+ Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)
2517 Delete(ctx context.Context, id int64) error
18+ ClearRange(ctx context.Context, rangeID string) error
2619 ClearAll(ctx context.Context) error
2720 ClearOlderThan(ctx context.Context, before time.Time) error
21+ GetStats(ctx context.Context) (*entity.HistoryStats, error)
2822 GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error)
2923 GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error)
3024 DeleteByDomain(ctx context.Context, domain string) error
@@ -34,11 +28,14 @@ type HomepageHistory interface {
3428 // needed by the WebUI homepage handlers.
3529 type HomepageFavorites interface {
3630 GetAll(ctx context.Context) ([]*entity.Favorite, error)
31+ AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
32+ UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
33+ DeleteFavorite(ctx context.Context, id entity.FavoriteID) error
3734 SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
3835 GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)
3936 Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error
4037 GetAllFolders(ctx context.Context) ([]*entity.Folder, error)
41- CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error)
38+ CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error)
4239 DeleteFolder(ctx context.Context, id entity.FolderID) error
4340 UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error
4441 GetAllTags(ctx context.Context) ([]*entity.Tag, error)
M internal/application/port/mocks/mocks.go +709 -33
@@ -5052,6 +5052,63 @@ func (_c *MockFaviconService_DiskPathPNG_Call) RunAndReturn(run func(domain stri
50525052 return _c
50535053 }
50545054
5055+// DiskPathPNGSized provides a mock function for the type MockFaviconService
5056+func (_mock *MockFaviconService) DiskPathPNGSized(domain string, size int) string {
5057+ ret := _mock.Called(domain, size)
5058+
5059+ if len(ret) == 0 {
5060+ panic("no return value specified for DiskPathPNGSized")
5061+ }
5062+
5063+ var r0 string
5064+ if returnFunc, ok := ret.Get(0).(func(string, int) string); ok {
5065+ r0 = returnFunc(domain, size)
5066+ } else {
5067+ r0 = ret.Get(0).(string)
5068+ }
5069+ return r0
5070+}
5071+
5072+// MockFaviconService_DiskPathPNGSized_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DiskPathPNGSized'
5073+type MockFaviconService_DiskPathPNGSized_Call struct {
5074+ *mock.Call
5075+}
5076+
5077+// DiskPathPNGSized is a helper method to define mock.On call
5078+// - domain string
5079+// - size int
5080+func (_e *MockFaviconService_Expecter) DiskPathPNGSized(domain interface{}, size interface{}) *MockFaviconService_DiskPathPNGSized_Call {
5081+ return &MockFaviconService_DiskPathPNGSized_Call{Call: _e.mock.On("DiskPathPNGSized", domain, size)}
5082+}
5083+
5084+func (_c *MockFaviconService_DiskPathPNGSized_Call) Run(run func(domain string, size int)) *MockFaviconService_DiskPathPNGSized_Call {
5085+ _c.Call.Run(func(args mock.Arguments) {
5086+ var arg0 string
5087+ if args[0] != nil {
5088+ arg0 = args[0].(string)
5089+ }
5090+ var arg1 int
5091+ if args[1] != nil {
5092+ arg1 = args[1].(int)
5093+ }
5094+ run(
5095+ arg0,
5096+ arg1,
5097+ )
5098+ })
5099+ return _c
5100+}
5101+
5102+func (_c *MockFaviconService_DiskPathPNGSized_Call) Return(s string) *MockFaviconService_DiskPathPNGSized_Call {
5103+ _c.Call.Return(s)
5104+ return _c
5105+}
5106+
5107+func (_c *MockFaviconService_DiskPathPNGSized_Call) RunAndReturn(run func(domain string, size int) string) *MockFaviconService_DiskPathPNGSized_Call {
5108+ _c.Call.Return(run)
5109+ return _c
5110+}
5111+
50555112 // EnsureCacheDir provides a mock function for the type MockFaviconService
50565113 func (_mock *MockFaviconService) EnsureCacheDir() error {
50575114 ret := _mock.Called()
@@ -6547,6 +6604,63 @@ func (_c *MockHomepageHistory_ClearOlderThan_Call) RunAndReturn(run func(ctx con
65476604 return _c
65486605 }
65496606
6607+// ClearRange provides a mock function for the type MockHomepageHistory
6608+func (_mock *MockHomepageHistory) ClearRange(ctx context.Context, rangeID string) error {
6609+ ret := _mock.Called(ctx, rangeID)
6610+
6611+ if len(ret) == 0 {
6612+ panic("no return value specified for ClearRange")
6613+ }
6614+
6615+ var r0 error
6616+ if returnFunc, ok := ret.Get(0).(func(context.Context, string) error); ok {
6617+ r0 = returnFunc(ctx, rangeID)
6618+ } else {
6619+ r0 = ret.Error(0)
6620+ }
6621+ return r0
6622+}
6623+
6624+// MockHomepageHistory_ClearRange_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ClearRange'
6625+type MockHomepageHistory_ClearRange_Call struct {
6626+ *mock.Call
6627+}
6628+
6629+// ClearRange is a helper method to define mock.On call
6630+// - ctx context.Context
6631+// - rangeID string
6632+func (_e *MockHomepageHistory_Expecter) ClearRange(ctx interface{}, rangeID interface{}) *MockHomepageHistory_ClearRange_Call {
6633+ return &MockHomepageHistory_ClearRange_Call{Call: _e.mock.On("ClearRange", ctx, rangeID)}
6634+}
6635+
6636+func (_c *MockHomepageHistory_ClearRange_Call) Run(run func(ctx context.Context, rangeID string)) *MockHomepageHistory_ClearRange_Call {
6637+ _c.Call.Run(func(args mock.Arguments) {
6638+ var arg0 context.Context
6639+ if args[0] != nil {
6640+ arg0 = args[0].(context.Context)
6641+ }
6642+ var arg1 string
6643+ if args[1] != nil {
6644+ arg1 = args[1].(string)
6645+ }
6646+ run(
6647+ arg0,
6648+ arg1,
6649+ )
6650+ })
6651+ return _c
6652+}
6653+
6654+func (_c *MockHomepageHistory_ClearRange_Call) Return(err error) *MockHomepageHistory_ClearRange_Call {
6655+ _c.Call.Return(err)
6656+ return _c
6657+}
6658+
6659+func (_c *MockHomepageHistory_ClearRange_Call) RunAndReturn(run func(ctx context.Context, rangeID string) error) *MockHomepageHistory_ClearRange_Call {
6660+ _c.Call.Return(run)
6661+ return _c
6662+}
6663+
65506664 // Delete provides a mock function for the type MockHomepageHistory
65516665 func (_mock *MockHomepageHistory) Delete(ctx context.Context, id int64) error {
65526666 ret := _mock.Called(ctx, id)
@@ -6865,27 +6979,243 @@ func (_c *MockHomepageHistory_GetRecent_Call) RunAndReturn(run func(ctx context.
68656979 return _c
68666980 }
68676981
6982+// GetRecentByDomain provides a mock function for the type MockHomepageHistory
6983+func (_mock *MockHomepageHistory) GetRecentByDomain(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error) {
6984+ ret := _mock.Called(ctx, domain, limit, offset)
6985+
6986+ if len(ret) == 0 {
6987+ panic("no return value specified for GetRecentByDomain")
6988+ }
6989+
6990+ var r0 []*entity.HistoryEntry
6991+ var r1 error
6992+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) ([]*entity.HistoryEntry, error)); ok {
6993+ return returnFunc(ctx, domain, limit, offset)
6994+ }
6995+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) []*entity.HistoryEntry); ok {
6996+ r0 = returnFunc(ctx, domain, limit, offset)
6997+ } else {
6998+ if ret.Get(0) != nil {
6999+ r0 = ret.Get(0).([]*entity.HistoryEntry)
7000+ }
7001+ }
7002+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, int) error); ok {
7003+ r1 = returnFunc(ctx, domain, limit, offset)
7004+ } else {
7005+ r1 = ret.Error(1)
7006+ }
7007+ return r0, r1
7008+}
7009+
7010+// MockHomepageHistory_GetRecentByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentByDomain'
7011+type MockHomepageHistory_GetRecentByDomain_Call struct {
7012+ *mock.Call
7013+}
7014+
7015+// GetRecentByDomain is a helper method to define mock.On call
7016+// - ctx context.Context
7017+// - domain string
7018+// - limit int
7019+// - offset int
7020+func (_e *MockHomepageHistory_Expecter) GetRecentByDomain(ctx interface{}, domain interface{}, limit interface{}, offset interface{}) *MockHomepageHistory_GetRecentByDomain_Call {
7021+ return &MockHomepageHistory_GetRecentByDomain_Call{Call: _e.mock.On("GetRecentByDomain", ctx, domain, limit, offset)}
7022+}
7023+
7024+func (_c *MockHomepageHistory_GetRecentByDomain_Call) Run(run func(ctx context.Context, domain string, limit int, offset int)) *MockHomepageHistory_GetRecentByDomain_Call {
7025+ _c.Call.Run(func(args mock.Arguments) {
7026+ var arg0 context.Context
7027+ if args[0] != nil {
7028+ arg0 = args[0].(context.Context)
7029+ }
7030+ var arg1 string
7031+ if args[1] != nil {
7032+ arg1 = args[1].(string)
7033+ }
7034+ var arg2 int
7035+ if args[2] != nil {
7036+ arg2 = args[2].(int)
7037+ }
7038+ var arg3 int
7039+ if args[3] != nil {
7040+ arg3 = args[3].(int)
7041+ }
7042+ run(
7043+ arg0,
7044+ arg1,
7045+ arg2,
7046+ arg3,
7047+ )
7048+ })
7049+ return _c
7050+}
7051+
7052+func (_c *MockHomepageHistory_GetRecentByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHomepageHistory_GetRecentByDomain_Call {
7053+ _c.Call.Return(historyEntrys, err)
7054+ return _c
7055+}
7056+
7057+func (_c *MockHomepageHistory_GetRecentByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHomepageHistory_GetRecentByDomain_Call {
7058+ _c.Call.Return(run)
7059+ return _c
7060+}
7061+
7062+// GetRecentWindow provides a mock function for the type MockHomepageHistory
7063+func (_mock *MockHomepageHistory) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
7064+ ret := _mock.Called(ctx, before, domain)
7065+
7066+ if len(ret) == 0 {
7067+ panic("no return value specified for GetRecentWindow")
7068+ }
7069+
7070+ var r0 *entity.HistoryWindow
7071+ var r1 error
7072+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) (*entity.HistoryWindow, error)); ok {
7073+ return returnFunc(ctx, before, domain)
7074+ }
7075+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, string) *entity.HistoryWindow); ok {
7076+ r0 = returnFunc(ctx, before, domain)
7077+ } else {
7078+ if ret.Get(0) != nil {
7079+ r0 = ret.Get(0).(*entity.HistoryWindow)
7080+ }
7081+ }
7082+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, string) error); ok {
7083+ r1 = returnFunc(ctx, before, domain)
7084+ } else {
7085+ r1 = ret.Error(1)
7086+ }
7087+ return r0, r1
7088+}
7089+
7090+// MockHomepageHistory_GetRecentWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindow'
7091+type MockHomepageHistory_GetRecentWindow_Call struct {
7092+ *mock.Call
7093+}
7094+
7095+// GetRecentWindow is a helper method to define mock.On call
7096+// - ctx context.Context
7097+// - before time.Time
7098+// - domain string
7099+func (_e *MockHomepageHistory_Expecter) GetRecentWindow(ctx interface{}, before interface{}, domain interface{}) *MockHomepageHistory_GetRecentWindow_Call {
7100+ return &MockHomepageHistory_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, domain)}
7101+}
7102+
7103+func (_c *MockHomepageHistory_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, domain string)) *MockHomepageHistory_GetRecentWindow_Call {
7104+ _c.Call.Run(func(args mock.Arguments) {
7105+ var arg0 context.Context
7106+ if args[0] != nil {
7107+ arg0 = args[0].(context.Context)
7108+ }
7109+ var arg1 time.Time
7110+ if args[1] != nil {
7111+ arg1 = args[1].(time.Time)
7112+ }
7113+ var arg2 string
7114+ if args[2] != nil {
7115+ arg2 = args[2].(string)
7116+ }
7117+ run(
7118+ arg0,
7119+ arg1,
7120+ arg2,
7121+ )
7122+ })
7123+ return _c
7124+}
7125+
7126+func (_c *MockHomepageHistory_GetRecentWindow_Call) Return(historyWindow *entity.HistoryWindow, err error) *MockHomepageHistory_GetRecentWindow_Call {
7127+ _c.Call.Return(historyWindow, err)
7128+ return _c
7129+}
7130+
7131+func (_c *MockHomepageHistory_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)) *MockHomepageHistory_GetRecentWindow_Call {
7132+ _c.Call.Return(run)
7133+ return _c
7134+}
7135+
7136+// GetStats provides a mock function for the type MockHomepageHistory
7137+func (_mock *MockHomepageHistory) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
7138+ ret := _mock.Called(ctx)
7139+
7140+ if len(ret) == 0 {
7141+ panic("no return value specified for GetStats")
7142+ }
7143+
7144+ var r0 *entity.HistoryStats
7145+ var r1 error
7146+ if returnFunc, ok := ret.Get(0).(func(context.Context) (*entity.HistoryStats, error)); ok {
7147+ return returnFunc(ctx)
7148+ }
7149+ if returnFunc, ok := ret.Get(0).(func(context.Context) *entity.HistoryStats); ok {
7150+ r0 = returnFunc(ctx)
7151+ } else {
7152+ if ret.Get(0) != nil {
7153+ r0 = ret.Get(0).(*entity.HistoryStats)
7154+ }
7155+ }
7156+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
7157+ r1 = returnFunc(ctx)
7158+ } else {
7159+ r1 = ret.Error(1)
7160+ }
7161+ return r0, r1
7162+}
7163+
7164+// MockHomepageHistory_GetStats_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetStats'
7165+type MockHomepageHistory_GetStats_Call struct {
7166+ *mock.Call
7167+}
7168+
7169+// GetStats is a helper method to define mock.On call
7170+// - ctx context.Context
7171+func (_e *MockHomepageHistory_Expecter) GetStats(ctx interface{}) *MockHomepageHistory_GetStats_Call {
7172+ return &MockHomepageHistory_GetStats_Call{Call: _e.mock.On("GetStats", ctx)}
7173+}
7174+
7175+func (_c *MockHomepageHistory_GetStats_Call) Run(run func(ctx context.Context)) *MockHomepageHistory_GetStats_Call {
7176+ _c.Call.Run(func(args mock.Arguments) {
7177+ var arg0 context.Context
7178+ if args[0] != nil {
7179+ arg0 = args[0].(context.Context)
7180+ }
7181+ run(
7182+ arg0,
7183+ )
7184+ })
7185+ return _c
7186+}
7187+
7188+func (_c *MockHomepageHistory_GetStats_Call) Return(historyStats *entity.HistoryStats, err error) *MockHomepageHistory_GetStats_Call {
7189+ _c.Call.Return(historyStats, err)
7190+ return _c
7191+}
7192+
7193+func (_c *MockHomepageHistory_GetStats_Call) RunAndReturn(run func(ctx context.Context) (*entity.HistoryStats, error)) *MockHomepageHistory_GetStats_Call {
7194+ _c.Call.Return(run)
7195+ return _c
7196+}
7197+
68687198 // Search provides a mock function for the type MockHomepageHistory
6869-func (_mock *MockHomepageHistory) Search(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error) {
7199+func (_mock *MockHomepageHistory) Search(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error) {
68707200 ret := _mock.Called(ctx, input)
68717201
68727202 if len(ret) == 0 {
68737203 panic("no return value specified for Search")
68747204 }
68757205
6876- var r0 *port.HistorySearchOutput
7206+ var r0 *dto.HistorySearchOutput
68777207 var r1 error
6878- if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) (*port.HistorySearchOutput, error)); ok {
7208+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) (*dto.HistorySearchOutput, error)); ok {
68797209 return returnFunc(ctx, input)
68807210 }
6881- if returnFunc, ok := ret.Get(0).(func(context.Context, port.HistorySearchInput) *port.HistorySearchOutput); ok {
7211+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.HistorySearchInput) *dto.HistorySearchOutput); ok {
68827212 r0 = returnFunc(ctx, input)
68837213 } else {
68847214 if ret.Get(0) != nil {
6885- r0 = ret.Get(0).(*port.HistorySearchOutput)
7215+ r0 = ret.Get(0).(*dto.HistorySearchOutput)
68867216 }
68877217 }
6888- if returnFunc, ok := ret.Get(1).(func(context.Context, port.HistorySearchInput) error); ok {
7218+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.HistorySearchInput) error); ok {
68897219 r1 = returnFunc(ctx, input)
68907220 } else {
68917221 r1 = ret.Error(1)
@@ -6900,20 +7230,20 @@ type MockHomepageHistory_Search_Call struct {
69007230
69017231 // Search is a helper method to define mock.On call
69027232 // - ctx context.Context
6903-// - input port.HistorySearchInput
7233+// - input dto.HistorySearchInput
69047234 func (_e *MockHomepageHistory_Expecter) Search(ctx interface{}, input interface{}) *MockHomepageHistory_Search_Call {
69057235 return &MockHomepageHistory_Search_Call{Call: _e.mock.On("Search", ctx, input)}
69067236 }
69077237
6908-func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input port.HistorySearchInput)) *MockHomepageHistory_Search_Call {
7238+func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, input dto.HistorySearchInput)) *MockHomepageHistory_Search_Call {
69097239 _c.Call.Run(func(args mock.Arguments) {
69107240 var arg0 context.Context
69117241 if args[0] != nil {
69127242 arg0 = args[0].(context.Context)
69137243 }
6914- var arg1 port.HistorySearchInput
7244+ var arg1 dto.HistorySearchInput
69157245 if args[1] != nil {
6916- arg1 = args[1].(port.HistorySearchInput)
7246+ arg1 = args[1].(dto.HistorySearchInput)
69177247 }
69187248 run(
69197249 arg0,
@@ -6923,12 +7253,12 @@ func (_c *MockHomepageHistory_Search_Call) Run(run func(ctx context.Context, inp
69237253 return _c
69247254 }
69257255
6926-func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *port.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call {
7256+func (_c *MockHomepageHistory_Search_Call) Return(historySearchOutput *dto.HistorySearchOutput, err error) *MockHomepageHistory_Search_Call {
69277257 _c.Call.Return(historySearchOutput, err)
69287258 return _c
69297259 }
69307260
6931-func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input port.HistorySearchInput) (*port.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call {
7261+func (_c *MockHomepageHistory_Search_Call) RunAndReturn(run func(ctx context.Context, input dto.HistorySearchInput) (*dto.HistorySearchOutput, error)) *MockHomepageHistory_Search_Call {
69327262 _c.Call.Return(run)
69337263 return _c
69347264 }
@@ -6960,6 +7290,74 @@ func (_m *MockHomepageFavorites) EXPECT() *MockHomepageFavorites_Expecter {
69607290 return &MockHomepageFavorites_Expecter{mock: &_m.Mock}
69617291 }
69627292
7293+// AddFavorite provides a mock function for the type MockHomepageFavorites
7294+func (_mock *MockHomepageFavorites) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
7295+ ret := _mock.Called(ctx, input)
7296+
7297+ if len(ret) == 0 {
7298+ panic("no return value specified for AddFavorite")
7299+ }
7300+
7301+ var r0 *entity.Favorite
7302+ var r1 error
7303+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteCreateInput) (*entity.Favorite, error)); ok {
7304+ return returnFunc(ctx, input)
7305+ }
7306+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteCreateInput) *entity.Favorite); ok {
7307+ r0 = returnFunc(ctx, input)
7308+ } else {
7309+ if ret.Get(0) != nil {
7310+ r0 = ret.Get(0).(*entity.Favorite)
7311+ }
7312+ }
7313+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.FavoriteCreateInput) error); ok {
7314+ r1 = returnFunc(ctx, input)
7315+ } else {
7316+ r1 = ret.Error(1)
7317+ }
7318+ return r0, r1
7319+}
7320+
7321+// MockHomepageFavorites_AddFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'AddFavorite'
7322+type MockHomepageFavorites_AddFavorite_Call struct {
7323+ *mock.Call
7324+}
7325+
7326+// AddFavorite is a helper method to define mock.On call
7327+// - ctx context.Context
7328+// - input dto.FavoriteCreateInput
7329+func (_e *MockHomepageFavorites_Expecter) AddFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_AddFavorite_Call {
7330+ return &MockHomepageFavorites_AddFavorite_Call{Call: _e.mock.On("AddFavorite", ctx, input)}
7331+}
7332+
7333+func (_c *MockHomepageFavorites_AddFavorite_Call) Run(run func(ctx context.Context, input dto.FavoriteCreateInput)) *MockHomepageFavorites_AddFavorite_Call {
7334+ _c.Call.Run(func(args mock.Arguments) {
7335+ var arg0 context.Context
7336+ if args[0] != nil {
7337+ arg0 = args[0].(context.Context)
7338+ }
7339+ var arg1 dto.FavoriteCreateInput
7340+ if args[1] != nil {
7341+ arg1 = args[1].(dto.FavoriteCreateInput)
7342+ }
7343+ run(
7344+ arg0,
7345+ arg1,
7346+ )
7347+ })
7348+ return _c
7349+}
7350+
7351+func (_c *MockHomepageFavorites_AddFavorite_Call) Return(favorite *entity.Favorite, err error) *MockHomepageFavorites_AddFavorite_Call {
7352+ _c.Call.Return(favorite, err)
7353+ return _c
7354+}
7355+
7356+func (_c *MockHomepageFavorites_AddFavorite_Call) RunAndReturn(run func(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)) *MockHomepageFavorites_AddFavorite_Call {
7357+ _c.Call.Return(run)
7358+ return _c
7359+}
7360+
69637361 // AddTag provides a mock function for the type MockHomepageFavorites
69647362 func (_mock *MockHomepageFavorites) AddTag(ctx context.Context, name string, color string) (*entity.Tag, error) {
69657363 ret := _mock.Called(ctx, name, color)
@@ -7035,8 +7433,8 @@ func (_c *MockHomepageFavorites_AddTag_Call) RunAndReturn(run func(ctx context.C
70357433 }
70367434
70377435 // CreateFolder provides a mock function for the type MockHomepageFavorites
7038-func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) {
7039- ret := _mock.Called(ctx, name, parentID)
7436+func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error) {
7437+ ret := _mock.Called(ctx, name, icon, parentID)
70407438
70417439 if len(ret) == 0 {
70427440 panic("no return value specified for CreateFolder")
@@ -7044,18 +7442,18 @@ func (_mock *MockHomepageFavorites) CreateFolder(ctx context.Context, name strin
70447442
70457443 var r0 *entity.Folder
70467444 var r1 error
7047- if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) (*entity.Folder, error)); ok {
7048- return returnFunc(ctx, name, parentID)
7445+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) (*entity.Folder, error)); ok {
7446+ return returnFunc(ctx, name, icon, parentID)
70497447 }
7050- if returnFunc, ok := ret.Get(0).(func(context.Context, string, *entity.FolderID) *entity.Folder); ok {
7051- r0 = returnFunc(ctx, name, parentID)
7448+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, *entity.FolderID) *entity.Folder); ok {
7449+ r0 = returnFunc(ctx, name, icon, parentID)
70527450 } else {
70537451 if ret.Get(0) != nil {
70547452 r0 = ret.Get(0).(*entity.Folder)
70557453 }
70567454 }
7057- if returnFunc, ok := ret.Get(1).(func(context.Context, string, *entity.FolderID) error); ok {
7058- r1 = returnFunc(ctx, name, parentID)
7455+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, *entity.FolderID) error); ok {
7456+ r1 = returnFunc(ctx, name, icon, parentID)
70597457 } else {
70607458 r1 = ret.Error(1)
70617459 }
@@ -7070,12 +7468,13 @@ type MockHomepageFavorites_CreateFolder_Call struct {
70707468 // CreateFolder is a helper method to define mock.On call
70717469 // - ctx context.Context
70727470 // - name string
7471+// - icon string
70737472 // - parentID *entity.FolderID
7074-func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call {
7075- return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, parentID)}
7473+func (_e *MockHomepageFavorites_Expecter) CreateFolder(ctx interface{}, name interface{}, icon interface{}, parentID interface{}) *MockHomepageFavorites_CreateFolder_Call {
7474+ return &MockHomepageFavorites_CreateFolder_Call{Call: _e.mock.On("CreateFolder", ctx, name, icon, parentID)}
70767475 }
70777476
7078-func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call {
7477+func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID)) *MockHomepageFavorites_CreateFolder_Call {
70797478 _c.Call.Run(func(args mock.Arguments) {
70807479 var arg0 context.Context
70817480 if args[0] != nil {
@@ -7085,14 +7484,19 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Run(run func(ctx context.Cont
70857484 if args[1] != nil {
70867485 arg1 = args[1].(string)
70877486 }
7088- var arg2 *entity.FolderID
7487+ var arg2 string
70897488 if args[2] != nil {
7090- arg2 = args[2].(*entity.FolderID)
7489+ arg2 = args[2].(string)
7490+ }
7491+ var arg3 *entity.FolderID
7492+ if args[3] != nil {
7493+ arg3 = args[3].(*entity.FolderID)
70917494 }
70927495 run(
70937496 arg0,
70947497 arg1,
70957498 arg2,
7499+ arg3,
70967500 )
70977501 })
70987502 return _c
@@ -7103,7 +7507,64 @@ func (_c *MockHomepageFavorites_CreateFolder_Call) Return(folder *entity.Folder,
71037507 return _c
71047508 }
71057509
7106-func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error)) *MockHomepageFavorites_CreateFolder_Call {
7510+func (_c *MockHomepageFavorites_CreateFolder_Call) RunAndReturn(run func(ctx context.Context, name string, icon string, parentID *entity.FolderID) (*entity.Folder, error)) *MockHomepageFavorites_CreateFolder_Call {
7511+ _c.Call.Return(run)
7512+ return _c
7513+}
7514+
7515+// DeleteFavorite provides a mock function for the type MockHomepageFavorites
7516+func (_mock *MockHomepageFavorites) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error {
7517+ ret := _mock.Called(ctx, id)
7518+
7519+ if len(ret) == 0 {
7520+ panic("no return value specified for DeleteFavorite")
7521+ }
7522+
7523+ var r0 error
7524+ if returnFunc, ok := ret.Get(0).(func(context.Context, entity.FavoriteID) error); ok {
7525+ r0 = returnFunc(ctx, id)
7526+ } else {
7527+ r0 = ret.Error(0)
7528+ }
7529+ return r0
7530+}
7531+
7532+// MockHomepageFavorites_DeleteFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteFavorite'
7533+type MockHomepageFavorites_DeleteFavorite_Call struct {
7534+ *mock.Call
7535+}
7536+
7537+// DeleteFavorite is a helper method to define mock.On call
7538+// - ctx context.Context
7539+// - id entity.FavoriteID
7540+func (_e *MockHomepageFavorites_Expecter) DeleteFavorite(ctx interface{}, id interface{}) *MockHomepageFavorites_DeleteFavorite_Call {
7541+ return &MockHomepageFavorites_DeleteFavorite_Call{Call: _e.mock.On("DeleteFavorite", ctx, id)}
7542+}
7543+
7544+func (_c *MockHomepageFavorites_DeleteFavorite_Call) Run(run func(ctx context.Context, id entity.FavoriteID)) *MockHomepageFavorites_DeleteFavorite_Call {
7545+ _c.Call.Run(func(args mock.Arguments) {
7546+ var arg0 context.Context
7547+ if args[0] != nil {
7548+ arg0 = args[0].(context.Context)
7549+ }
7550+ var arg1 entity.FavoriteID
7551+ if args[1] != nil {
7552+ arg1 = args[1].(entity.FavoriteID)
7553+ }
7554+ run(
7555+ arg0,
7556+ arg1,
7557+ )
7558+ })
7559+ return _c
7560+}
7561+
7562+func (_c *MockHomepageFavorites_DeleteFavorite_Call) Return(err error) *MockHomepageFavorites_DeleteFavorite_Call {
7563+ _c.Call.Return(err)
7564+ return _c
7565+}
7566+
7567+func (_c *MockHomepageFavorites_DeleteFavorite_Call) RunAndReturn(run func(ctx context.Context, id entity.FavoriteID) error) *MockHomepageFavorites_DeleteFavorite_Call {
71077568 _c.Call.Return(run)
71087569 return _c
71097570 }
@@ -7728,6 +8189,74 @@ func (_c *MockHomepageFavorites_UntagFavorite_Call) RunAndReturn(run func(ctx co
77288189 return _c
77298190 }
77308191
8192+// UpdateFavorite provides a mock function for the type MockHomepageFavorites
8193+func (_mock *MockHomepageFavorites) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
8194+ ret := _mock.Called(ctx, input)
8195+
8196+ if len(ret) == 0 {
8197+ panic("no return value specified for UpdateFavorite")
8198+ }
8199+
8200+ var r0 *entity.Favorite
8201+ var r1 error
8202+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteUpdateInput) (*entity.Favorite, error)); ok {
8203+ return returnFunc(ctx, input)
8204+ }
8205+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.FavoriteUpdateInput) *entity.Favorite); ok {
8206+ r0 = returnFunc(ctx, input)
8207+ } else {
8208+ if ret.Get(0) != nil {
8209+ r0 = ret.Get(0).(*entity.Favorite)
8210+ }
8211+ }
8212+ if returnFunc, ok := ret.Get(1).(func(context.Context, dto.FavoriteUpdateInput) error); ok {
8213+ r1 = returnFunc(ctx, input)
8214+ } else {
8215+ r1 = ret.Error(1)
8216+ }
8217+ return r0, r1
8218+}
8219+
8220+// MockHomepageFavorites_UpdateFavorite_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateFavorite'
8221+type MockHomepageFavorites_UpdateFavorite_Call struct {
8222+ *mock.Call
8223+}
8224+
8225+// UpdateFavorite is a helper method to define mock.On call
8226+// - ctx context.Context
8227+// - input dto.FavoriteUpdateInput
8228+func (_e *MockHomepageFavorites_Expecter) UpdateFavorite(ctx interface{}, input interface{}) *MockHomepageFavorites_UpdateFavorite_Call {
8229+ return &MockHomepageFavorites_UpdateFavorite_Call{Call: _e.mock.On("UpdateFavorite", ctx, input)}
8230+}
8231+
8232+func (_c *MockHomepageFavorites_UpdateFavorite_Call) Run(run func(ctx context.Context, input dto.FavoriteUpdateInput)) *MockHomepageFavorites_UpdateFavorite_Call {
8233+ _c.Call.Run(func(args mock.Arguments) {
8234+ var arg0 context.Context
8235+ if args[0] != nil {
8236+ arg0 = args[0].(context.Context)
8237+ }
8238+ var arg1 dto.FavoriteUpdateInput
8239+ if args[1] != nil {
8240+ arg1 = args[1].(dto.FavoriteUpdateInput)
8241+ }
8242+ run(
8243+ arg0,
8244+ arg1,
8245+ )
8246+ })
8247+ return _c
8248+}
8249+
8250+func (_c *MockHomepageFavorites_UpdateFavorite_Call) Return(favorite *entity.Favorite, err error) *MockHomepageFavorites_UpdateFavorite_Call {
8251+ _c.Call.Return(favorite, err)
8252+ return _c
8253+}
8254+
8255+func (_c *MockHomepageFavorites_UpdateFavorite_Call) RunAndReturn(run func(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)) *MockHomepageFavorites_UpdateFavorite_Call {
8256+ _c.Call.Return(run)
8257+ return _c
8258+}
8259+
77318260 // UpdateFolder provides a mock function for the type MockHomepageFavorites
77328261 func (_mock *MockHomepageFavorites) UpdateFolder(ctx context.Context, id entity.FolderID, name string, icon string) error {
77338262 ret := _mock.Called(ctx, id, name, icon)
@@ -11749,6 +12278,153 @@ func (_c *MockUpdateApplier_StageUpdate_Call) RunAndReturn(run func(ctx context.
1174912278 return _c
1175012279 }
1175112280
12281+// NewMockSystemviewConfigReader creates a new instance of MockSystemviewConfigReader. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
12282+// The first argument is typically a *testing.T value.
12283+func NewMockSystemviewConfigReader(t interface {
12284+ mock.TestingT
12285+ Cleanup(func())
12286+}) *MockSystemviewConfigReader {
12287+ mock := &MockSystemviewConfigReader{}
12288+ mock.Mock.Test(t)
12289+
12290+ t.Cleanup(func() { mock.AssertExpectations(t) })
12291+
12292+ return mock
12293+}
12294+
12295+// MockSystemviewConfigReader is an autogenerated mock type for the SystemviewConfigReader type
12296+type MockSystemviewConfigReader struct {
12297+ mock.Mock
12298+}
12299+
12300+type MockSystemviewConfigReader_Expecter struct {
12301+ mock *mock.Mock
12302+}
12303+
12304+func (_m *MockSystemviewConfigReader) EXPECT() *MockSystemviewConfigReader_Expecter {
12305+ return &MockSystemviewConfigReader_Expecter{mock: &_m.Mock}
12306+}
12307+
12308+// Current provides a mock function for the type MockSystemviewConfigReader
12309+func (_mock *MockSystemviewConfigReader) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
12310+ ret := _mock.Called(ctx)
12311+
12312+ if len(ret) == 0 {
12313+ panic("no return value specified for Current")
12314+ }
12315+
12316+ var r0 dto.SystemviewConfigPayload
12317+ var r1 error
12318+ if returnFunc, ok := ret.Get(0).(func(context.Context) (dto.SystemviewConfigPayload, error)); ok {
12319+ return returnFunc(ctx)
12320+ }
12321+ if returnFunc, ok := ret.Get(0).(func(context.Context) dto.SystemviewConfigPayload); ok {
12322+ r0 = returnFunc(ctx)
12323+ } else {
12324+ r0 = ret.Get(0).(dto.SystemviewConfigPayload)
12325+ }
12326+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
12327+ r1 = returnFunc(ctx)
12328+ } else {
12329+ r1 = ret.Error(1)
12330+ }
12331+ return r0, r1
12332+}
12333+
12334+// MockSystemviewConfigReader_Current_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Current'
12335+type MockSystemviewConfigReader_Current_Call struct {
12336+ *mock.Call
12337+}
12338+
12339+// Current is a helper method to define mock.On call
12340+// - ctx context.Context
12341+func (_e *MockSystemviewConfigReader_Expecter) Current(ctx interface{}) *MockSystemviewConfigReader_Current_Call {
12342+ return &MockSystemviewConfigReader_Current_Call{Call: _e.mock.On("Current", ctx)}
12343+}
12344+
12345+func (_c *MockSystemviewConfigReader_Current_Call) Run(run func(ctx context.Context)) *MockSystemviewConfigReader_Current_Call {
12346+ _c.Call.Run(func(args mock.Arguments) {
12347+ var arg0 context.Context
12348+ if args[0] != nil {
12349+ arg0 = args[0].(context.Context)
12350+ }
12351+ run(
12352+ arg0,
12353+ )
12354+ })
12355+ return _c
12356+}
12357+
12358+func (_c *MockSystemviewConfigReader_Current_Call) Return(systemviewConfigPayload dto.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Current_Call {
12359+ _c.Call.Return(systemviewConfigPayload, err)
12360+ return _c
12361+}
12362+
12363+func (_c *MockSystemviewConfigReader_Current_Call) RunAndReturn(run func(ctx context.Context) (dto.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Current_Call {
12364+ _c.Call.Return(run)
12365+ return _c
12366+}
12367+
12368+// Default provides a mock function for the type MockSystemviewConfigReader
12369+func (_mock *MockSystemviewConfigReader) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
12370+ ret := _mock.Called(ctx)
12371+
12372+ if len(ret) == 0 {
12373+ panic("no return value specified for Default")
12374+ }
12375+
12376+ var r0 dto.SystemviewConfigPayload
12377+ var r1 error
12378+ if returnFunc, ok := ret.Get(0).(func(context.Context) (dto.SystemviewConfigPayload, error)); ok {
12379+ return returnFunc(ctx)
12380+ }
12381+ if returnFunc, ok := ret.Get(0).(func(context.Context) dto.SystemviewConfigPayload); ok {
12382+ r0 = returnFunc(ctx)
12383+ } else {
12384+ r0 = ret.Get(0).(dto.SystemviewConfigPayload)
12385+ }
12386+ if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
12387+ r1 = returnFunc(ctx)
12388+ } else {
12389+ r1 = ret.Error(1)
12390+ }
12391+ return r0, r1
12392+}
12393+
12394+// MockSystemviewConfigReader_Default_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Default'
12395+type MockSystemviewConfigReader_Default_Call struct {
12396+ *mock.Call
12397+}
12398+
12399+// Default is a helper method to define mock.On call
12400+// - ctx context.Context
12401+func (_e *MockSystemviewConfigReader_Expecter) Default(ctx interface{}) *MockSystemviewConfigReader_Default_Call {
12402+ return &MockSystemviewConfigReader_Default_Call{Call: _e.mock.On("Default", ctx)}
12403+}
12404+
12405+func (_c *MockSystemviewConfigReader_Default_Call) Run(run func(ctx context.Context)) *MockSystemviewConfigReader_Default_Call {
12406+ _c.Call.Run(func(args mock.Arguments) {
12407+ var arg0 context.Context
12408+ if args[0] != nil {
12409+ arg0 = args[0].(context.Context)
12410+ }
12411+ run(
12412+ arg0,
12413+ )
12414+ })
12415+ return _c
12416+}
12417+
12418+func (_c *MockSystemviewConfigReader_Default_Call) Return(systemviewConfigPayload dto.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Default_Call {
12419+ _c.Call.Return(systemviewConfigPayload, err)
12420+ return _c
12421+}
12422+
12423+func (_c *MockSystemviewConfigReader_Default_Call) RunAndReturn(run func(ctx context.Context) (dto.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Default_Call {
12424+ _c.Call.Return(run)
12425+ return _c
12426+}
12427+
1175212428 // NewMockWebUIConfigSaver creates a new instance of MockWebUIConfigSaver. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
1175312429 // The first argument is typically a *testing.T value.
1175412430 func NewMockWebUIConfigSaver(t interface {
@@ -11777,7 +12453,7 @@ func (_m *MockWebUIConfigSaver) EXPECT() *MockWebUIConfigSaver_Expecter {
1177712453 }
1177812454
1177912455 // SaveWebUIConfig provides a mock function for the type MockWebUIConfigSaver
11780-func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error {
12456+func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error {
1178112457 ret := _mock.Called(ctx, cfg)
1178212458
1178312459 if len(ret) == 0 {
@@ -11785,7 +12461,7 @@ func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg port
1178512461 }
1178612462
1178712463 var r0 error
11788- if returnFunc, ok := ret.Get(0).(func(context.Context, port.WebUIConfig) error); ok {
12464+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.WebUIConfig) error); ok {
1178912465 r0 = returnFunc(ctx, cfg)
1179012466 } else {
1179112467 r0 = ret.Error(0)
@@ -11800,20 +12476,20 @@ type MockWebUIConfigSaver_SaveWebUIConfig_Call struct {
1180012476
1180112477 // SaveWebUIConfig is a helper method to define mock.On call
1180212478 // - ctx context.Context
11803-// - cfg port.WebUIConfig
12479+// - cfg dto.WebUIConfig
1180412480 func (_e *MockWebUIConfigSaver_Expecter) SaveWebUIConfig(ctx interface{}, cfg interface{}) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
1180512481 return &MockWebUIConfigSaver_SaveWebUIConfig_Call{Call: _e.mock.On("SaveWebUIConfig", ctx, cfg)}
1180612482 }
1180712483
11808-func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Run(run func(ctx context.Context, cfg port.WebUIConfig)) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
12484+func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Run(run func(ctx context.Context, cfg dto.WebUIConfig)) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
1180912485 _c.Call.Run(func(args mock.Arguments) {
1181012486 var arg0 context.Context
1181112487 if args[0] != nil {
1181212488 arg0 = args[0].(context.Context)
1181312489 }
11814- var arg1 port.WebUIConfig
12490+ var arg1 dto.WebUIConfig
1181512491 if args[1] != nil {
11816- arg1 = args[1].(port.WebUIConfig)
12492+ arg1 = args[1].(dto.WebUIConfig)
1181712493 }
1181812494 run(
1181912495 arg0,
@@ -11828,7 +12504,7 @@ func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Return(err error) *MockWebU
1182812504 return _c
1182912505 }
1183012506
11831-func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) RunAndReturn(run func(ctx context.Context, cfg port.WebUIConfig) error) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
12507+func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) RunAndReturn(run func(ctx context.Context, cfg dto.WebUIConfig) error) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
1183212508 _c.Call.Return(run)
1183312509 return _c
1183412510 }
A internal/application/port/systemviews.go +54 -0
@@ -0,0 +1,54 @@
1+package port
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/application/dto"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+// SystemviewHistoryService exposes history operations for the systemviews history route.
12+type SystemviewHistoryService interface {
13+ Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
14+ TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
15+ TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error)
16+ Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error)
17+ DeleteEntry(ctx context.Context, id int64) error
18+ DeleteRange(ctx context.Context, rangeID string) error
19+ Stats(ctx context.Context) (*entity.HistoryStats, error)
20+ Analytics(ctx context.Context) (*entity.HistoryAnalytics, error)
21+ DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error)
22+ DeleteDomain(ctx context.Context, domain string) error
23+}
24+
25+// SystemviewFavoritesService exposes favorite, folder, and tag operations.
26+type SystemviewFavoritesService interface {
27+ List(ctx context.Context) ([]*entity.Favorite, error)
28+ CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error)
29+ UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error)
30+ DeleteFavorite(ctx context.Context, id int64) error
31+ ListFolders(ctx context.Context) ([]*entity.Folder, error)
32+ ListTags(ctx context.Context) ([]*entity.Tag, error)
33+ SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error
34+ SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error
35+ CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error)
36+ UpdateFolder(ctx context.Context, id int64, name, icon string) error
37+ DeleteFolder(ctx context.Context, id int64) error
38+ CreateTag(ctx context.Context, name, color string) (*entity.Tag, error)
39+ UpdateTag(ctx context.Context, id int64, name, color string) error
40+ DeleteTag(ctx context.Context, id int64) error
41+ AssignTag(ctx context.Context, favoriteID, tagID int64) error
42+ RemoveTag(ctx context.Context, favoriteID, tagID int64) error
43+}
44+
45+// SystemviewConfigService exposes config and keybinding operations for the systemviews UI.
46+type SystemviewConfigService interface {
47+ Current(ctx context.Context) (dto.SystemviewConfigPayload, error)
48+ Default(ctx context.Context) (dto.SystemviewConfigPayload, error)
49+ Save(ctx context.Context, cfg dto.WebUIConfig) error
50+ GetKeybindings(ctx context.Context) (KeybindingsConfig, error)
51+ SetKeybinding(ctx context.Context, req SetKeybindingRequest) (SetKeybindingResponse, error)
52+ ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error
53+ ResetAllKeybindings(ctx context.Context) error
54+}
M internal/application/port/webui_config.go +9 -48
@@ -1,57 +1,18 @@
11 package port
22
3-import "context"
3+import (
4+ "context"
45
5-// WebUIConfig represents the subset of configuration editable in dumb://config.
6-type WebUIConfig struct {
7- Appearance WebUIAppearanceConfig `json:"appearance"`
8- Performance WebUIPerformanceConfig `json:"performance"`
9- DefaultUIScale float64 `json:"default_ui_scale"`
10- DefaultSearchEngine string `json:"default_search_engine"`
11- SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
12-}
13-
14-// WebUIPerformanceConfig represents performance settings editable in dumb://config.
15-type WebUIPerformanceConfig struct {
16- Profile string `json:"profile"`
17- Custom WebUICustomPerformanceConfig `json:"custom"`
18-}
19-
20-// WebUICustomPerformanceConfig holds user-editable fields for custom profile.
21-type WebUICustomPerformanceConfig struct {
22- SkiaCPUThreads int `json:"skia_cpu_threads"`
23- SkiaGPUThreads int `json:"skia_gpu_threads"`
24- WebProcessMemoryMB int `json:"web_process_memory_mb"`
25- NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
26- WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
27-}
28-
29-type WebUIAppearanceConfig struct {
30- SansFont string `json:"sans_font"`
31- SerifFont string `json:"serif_font"`
32- MonospaceFont string `json:"monospace_font"`
33- DefaultFontSize int `json:"default_font_size"`
34- ColorScheme string `json:"color_scheme"`
35- LightPalette ColorPalette `json:"light_palette"`
36- DarkPalette ColorPalette `json:"dark_palette"`
37-}
38-
39-type ColorPalette struct {
40- Background string `json:"background"`
41- Surface string `json:"surface"`
42- SurfaceVariant string `json:"surface_variant"`
43- Text string `json:"text"`
44- Muted string `json:"muted"`
45- Accent string `json:"accent"`
46- Border string `json:"border"`
47-}
6+ "github.com/bnema/dumber/internal/application/dto"
7+)
488
49-type SearchShortcut struct {
50- URL string `json:"url"`
51- Description string `json:"description"`
9+// SystemviewConfigReader reads the current and default systemview config payloads.
10+type SystemviewConfigReader interface {
11+ Current(ctx context.Context) (dto.SystemviewConfigPayload, error)
12+ Default(ctx context.Context) (dto.SystemviewConfigPayload, error)
5213 }
5314
5415 // WebUIConfigSaver persists WebUI configuration changes.
5516 type WebUIConfigSaver interface {
56- SaveWebUIConfig(ctx context.Context, cfg WebUIConfig) error
17+ SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error
5718 }
M internal/application/usecase/manage_favorites.go +161 -24
@@ -3,11 +3,13 @@ package usecase
33 import (
44 "context"
55 "fmt"
6+ "net/url"
67 "sort"
78 "strings"
89 "sync"
910 "time"
1011
12+ "github.com/bnema/dumber/internal/application/dto"
1113 "github.com/bnema/dumber/internal/application/port"
1214 "github.com/bnema/dumber/internal/domain/entity"
1315 "github.com/bnema/dumber/internal/domain/repository"
@@ -69,6 +71,11 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
6971 return existing, nil
7072 }
7173
74+ return uc.addNewFavorite(ctx, input)
75+}
76+
77+func (uc *ManageFavoritesUseCase) addNewFavorite(ctx context.Context, input AddFavoriteInput) (*entity.Favorite, error) {
78+ log := logging.FromContext(ctx)
7279 fav := entity.NewFavorite(input.URL, input.Title)
7380 fav.FaviconURL = input.FaviconURL
7481 fav.FolderID = input.FolderID
@@ -93,6 +100,124 @@ func (uc *ManageFavoritesUseCase) Add(ctx context.Context, input AddFavoriteInpu
93100 return fav, nil
94101 }
95102
103+// AddFavorite creates a favorite from the UI-facing application port.
104+func (uc *ManageFavoritesUseCase) AddFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
105+ for _, tagID := range input.Tags {
106+ if tagID <= 0 {
107+ return nil, fmt.Errorf("favorite tag id must be positive, got %d", tagID)
108+ }
109+ }
110+ favoriteURL, err := normalizeFavoriteURL(input.URL)
111+ if err != nil {
112+ return nil, err
113+ }
114+ if existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, input.URL); err != nil {
115+ return nil, err
116+ } else if existing != nil {
117+ return existing, nil
118+ }
119+ tags := append([]entity.TagID(nil), input.Tags...)
120+ title := strings.TrimSpace(input.Title)
121+ if title == "" {
122+ title = favoriteURL
123+ }
124+ return uc.addNewFavorite(ctx, AddFavoriteInput{
125+ URL: favoriteURL,
126+ Title: title,
127+ FaviconURL: strings.TrimSpace(input.FaviconURL),
128+ FolderID: input.FolderID,
129+ Tags: tags,
130+ })
131+}
132+
133+func (uc *ManageFavoritesUseCase) findFavoriteByCanonicalOrRawURL(
134+ ctx context.Context,
135+ canonicalURL string,
136+ rawURL string,
137+) (*entity.Favorite, error) {
138+ existing, err := uc.favoriteRepo.FindByURL(ctx, canonicalURL)
139+ if err != nil {
140+ return nil, fmt.Errorf("failed to check existing favorite: %w", err)
141+ }
142+ if existing != nil {
143+ return existing, nil
144+ }
145+ rawURL = strings.TrimSpace(rawURL)
146+ if rawURL == "" || rawURL == canonicalURL {
147+ return nil, nil
148+ }
149+ existing, err = uc.favoriteRepo.FindByURL(ctx, rawURL)
150+ if err != nil {
151+ return nil, fmt.Errorf("failed to check legacy favorite: %w", err)
152+ }
153+ return existing, nil
154+}
155+
156+func normalizeFavoriteURL(raw string) (string, error) {
157+ favoriteURL := domainurl.Normalize(strings.TrimSpace(raw))
158+ if favoriteURL == "" {
159+ return "", fmt.Errorf("favorite URL is required")
160+ }
161+ parsed, err := url.Parse(favoriteURL)
162+ if err != nil || parsed.Scheme == "" {
163+ return "", fmt.Errorf("favorite URL must be absolute")
164+ }
165+ switch strings.ToLower(parsed.Scheme) {
166+ case "http", "https":
167+ if parsed.Host == "" {
168+ return "", fmt.Errorf("favorite URL host is required")
169+ }
170+ case "dumb":
171+ if parsed.Host != "" {
172+ return favoriteURL, nil
173+ }
174+ if parsed.Opaque != "" {
175+ return "dumb://" + strings.TrimPrefix(parsed.Opaque, "//"), nil
176+ }
177+ return "", fmt.Errorf("favorite URL host is required")
178+ default:
179+ return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
180+ }
181+ return favoriteURL, nil
182+}
183+
184+// UpdateFavorite updates editable favorite metadata from the UI-facing application port.
185+func (uc *ManageFavoritesUseCase) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
186+ if input.ID <= 0 {
187+ return nil, fmt.Errorf("favorite ID is required")
188+ }
189+ if input.ShortcutKey != nil && (*input.ShortcutKey < 1 || *input.ShortcutKey > 9) {
190+ return nil, fmt.Errorf("shortcut key must be 1-9, got %d", *input.ShortcutKey)
191+ }
192+
193+ fav, err := uc.favoriteRepo.FindByID(ctx, input.ID)
194+ if err != nil {
195+ return nil, fmt.Errorf("failed to find favorite: %w", err)
196+ }
197+ if fav == nil {
198+ return nil, fmt.Errorf("favorite %d not found", input.ID)
199+ }
200+
201+ title := strings.TrimSpace(input.Title)
202+ if title == "" {
203+ return nil, fmt.Errorf("favorite title is required")
204+ }
205+ fav.Title = title
206+ fav.FaviconURL = strings.TrimSpace(input.FaviconURL)
207+ fav.FolderID = input.FolderID
208+ fav.ShortcutKey = input.ShortcutKey
209+
210+ if err := uc.Update(ctx, fav); err != nil {
211+ return nil, err
212+ }
213+ return fav, nil
214+}
215+
216+// DeleteFavorite deletes a favorite from the UI-facing application port.
217+func (uc *ManageFavoritesUseCase) DeleteFavorite(ctx context.Context, id entity.FavoriteID) error {
218+ return uc.Remove(ctx, id)
219+}
220+
96221 // Remove deletes a favorite by ID.
97222 func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.FavoriteID) error {
98223 log := logging.FromContext(ctx)
@@ -108,16 +233,16 @@ func (uc *ManageFavoritesUseCase) Remove(ctx context.Context, id entity.Favorite
108233 }
109234
110235 // RemoveByURL deletes a favorite by its URL.
111-func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, url string) error {
236+func (uc *ManageFavoritesUseCase) RemoveByURL(ctx context.Context, favoriteURL string) error {
112237 log := logging.FromContext(ctx)
113- log.Debug().Str("url", url).Msg("removing favorite by URL")
238+ log.Debug().Str("url", favoriteURL).Msg("removing favorite by URL")
114239
115- fav, err := uc.favoriteRepo.FindByURL(ctx, url)
240+ fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
116241 if err != nil {
117242 return fmt.Errorf("failed to find favorite: %w", err)
118243 }
119244 if fav == nil {
120- log.Debug().Str("url", url).Msg("favorite not found")
245+ log.Debug().Str("url", favoriteURL).Msg("favorite not found")
121246 return nil
122247 }
123248
@@ -187,11 +312,11 @@ func (uc *ManageFavoritesUseCase) GetByShortcut(ctx context.Context, key int) (*
187312 }
188313
189314 // GetByURL finds a favorite by its URL.
190-func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, url string) (*entity.Favorite, error) {
315+func (uc *ManageFavoritesUseCase) GetByURL(ctx context.Context, favoriteURL string) (*entity.Favorite, error) {
191316 log := logging.FromContext(ctx)
192- log.Debug().Str("url", url).Msg("getting favorite by URL")
317+ log.Debug().Str("url", favoriteURL).Msg("getting favorite by URL")
193318
194- return uc.favoriteRepo.FindByURL(ctx, url)
319+ return uc.favoriteRepo.FindByURL(ctx, favoriteURL)
195320 }
196321
197322 // GetAll retrieves all favorites.
@@ -268,8 +393,8 @@ func (uc *ManageFavoritesUseCase) FilterForOmnibox(ctx context.Context, query st
268393 }
269394
270395 // IsFavorite checks if a URL is favorited.
271-func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, url string) (bool, error) {
272- fav, err := uc.favoriteRepo.FindByURL(ctx, url)
396+func (uc *ManageFavoritesUseCase) IsFavorite(ctx context.Context, favoriteURL string) (bool, error) {
397+ fav, err := uc.favoriteRepo.FindByURL(ctx, favoriteURL)
273398 if err != nil {
274399 return false, fmt.Errorf("failed to check favorite: %w", err)
275400 }
@@ -322,18 +447,20 @@ type ToggleResult struct {
322447
323448 // Toggle adds or removes a URL from favorites based on current state.
324449 // If the URL is already a favorite, it removes it. Otherwise, it adds it.
325-func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string) (*ToggleResult, error) {
326- log := logging.FromContext(ctx)
327- log.Debug().Str("url", url).Msg("toggling favorite")
328-
329- if url == "" {
330- return nil, fmt.Errorf("URL cannot be empty")
450+func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, rawURL, title string) (*ToggleResult, error) {
451+ favoriteURL, err := normalizeFavoriteURL(rawURL)
452+ if err != nil {
453+ return nil, err
331454 }
455+ title = strings.TrimSpace(title)
332456
333- // Check if already favorited
334- existing, err := uc.favoriteRepo.FindByURL(ctx, url)
457+ log := logging.FromContext(ctx)
458+ log.Debug().Str("url", favoriteURL).Msg("toggling favorite")
459+
460+ // Check if already favorited, including legacy rows stored before URL canonicalization.
461+ existing, err := uc.findFavoriteByCanonicalOrRawURL(ctx, favoriteURL, rawURL)
335462 if err != nil {
336- return nil, fmt.Errorf("failed to check existing favorite: %w", err)
463+ return nil, err
337464 }
338465
339466 if existing != nil {
@@ -341,26 +468,26 @@ func (uc *ManageFavoritesUseCase) Toggle(ctx context.Context, url, title string)
341468 if err := uc.favoriteRepo.Delete(ctx, existing.ID); err != nil {
342469 return nil, fmt.Errorf("failed to remove favorite: %w", err)
343470 }
344- log.Info().Str("url", url).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
471+ log.Info().Str("url", favoriteURL).Int64("id", int64(existing.ID)).Msg("favorite removed via toggle")
345472 uc.invalidateCache()
346473 return &ToggleResult{
347474 Added: false,
348- URL: url,
475+ URL: favoriteURL,
349476 Title: existing.Title,
350477 Message: "Favorite removed",
351478 }, nil
352479 }
353480
354481 // Add favorite
355- fav := entity.NewFavorite(url, title)
482+ fav := entity.NewFavorite(favoriteURL, title)
356483 if err := uc.favoriteRepo.Save(ctx, fav); err != nil {
357484 return nil, fmt.Errorf("failed to add favorite: %w", err)
358485 }
359- log.Info().Str("url", url).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
486+ log.Info().Str("url", favoriteURL).Int64("id", int64(fav.ID)).Msg("favorite added via toggle")
360487 uc.invalidateCache()
361488 return &ToggleResult{
362489 Added: true,
363- URL: url,
490+ URL: favoriteURL,
364491 Title: title,
365492 Message: "Favorite added",
366493 }, nil
@@ -456,11 +583,17 @@ func (uc *ManageFavoritesUseCase) GetTree(ctx context.Context) (*entity.Favorite
456583 }
457584
458585 // CreateFolder creates a new folder.
459-func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error) {
586+func (uc *ManageFavoritesUseCase) CreateFolder(ctx context.Context, name, icon string, parentID *entity.FolderID) (*entity.Folder, error) {
587+ name = strings.TrimSpace(name)
588+ icon = strings.TrimSpace(icon)
589+ if name == "" {
590+ return nil, fmt.Errorf("folder name is required")
591+ }
460592 log := logging.FromContext(ctx)
461593 log.Debug().Str("name", name).Msg("creating folder")
462594
463595 folder := entity.NewFolder(name)
596+ folder.Icon = icon
464597 folder.ParentID = parentID
465598
466599 if err := uc.folderRepo.Save(ctx, folder); err != nil {
@@ -574,6 +707,7 @@ func (uc *ManageFavoritesUseCase) DeleteTag(ctx context.Context, id entity.TagID
574707 }
575708
576709 log.Info().Int64("id", int64(id)).Msg("tag deleted")
710+ uc.invalidateCache()
577711 return nil
578712 }
579713
@@ -596,6 +730,7 @@ func (uc *ManageFavoritesUseCase) TagFavorite(ctx context.Context, favID entity.
596730 if err := uc.tagRepo.AssignToFavorite(ctx, tagID, favID); err != nil {
597731 return fmt.Errorf("failed to tag favorite: %w", err)
598732 }
733+ uc.invalidateCache()
599734
600735 return nil
601736 }
@@ -611,6 +746,7 @@ func (uc *ManageFavoritesUseCase) UntagFavorite(ctx context.Context, favID entit
611746 if err := uc.tagRepo.RemoveFromFavorite(ctx, tagID, favID); err != nil {
612747 return fmt.Errorf("failed to untag favorite: %w", err)
613748 }
749+ uc.invalidateCache()
614750
615751 return nil
616752 }
@@ -674,5 +810,6 @@ func (uc *ManageFavoritesUseCase) UpdateTag(ctx context.Context, id entity.TagID
674810 }
675811
676812 log.Info().Int64("id", int64(id)).Msg("tag updated")
813+ uc.invalidateCache()
677814 return nil
678815 }
M internal/application/usecase/manage_favorites_test.go +105 -1
@@ -4,6 +4,7 @@ import (
44 "errors"
55 "testing"
66
7+ "github.com/bnema/dumber/internal/application/dto"
78 "github.com/bnema/dumber/internal/application/usecase"
89 "github.com/bnema/dumber/internal/domain/entity"
910 repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
@@ -142,7 +143,30 @@ func TestManageFavoritesUseCase_Toggle_EmptyURLReturnsError(t *testing.T) {
142143 result, err := uc.Toggle(ctx, "", "Title")
143144 require.Error(t, err)
144145 assert.Nil(t, result)
145- assert.Contains(t, err.Error(), "URL cannot be empty")
146+ assert.Contains(t, err.Error(), "favorite URL is required")
147+}
148+
149+func TestManageFavoritesUseCase_ToggleNormalizesURLBeforeLookup(t *testing.T) {
150+ ctx := testContext()
151+
152+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
153+ folderRepo := repomocks.NewMockFolderRepository(t)
154+ tagRepo := repomocks.NewMockTagRepository(t)
155+
156+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
157+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "example.com").Return(nil, nil).Once()
158+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
159+ return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"
160+ })).Return(nil).Once()
161+
162+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
163+
164+ result, err := uc.Toggle(ctx, " example.com ", " Example ")
165+ require.NoError(t, err)
166+ require.NotNil(t, result)
167+ assert.True(t, result.Added)
168+ assert.Equal(t, "https://example.com", result.URL)
169+ assert.Equal(t, "Example", result.Title)
146170 }
147171
148172 func TestManageFavoritesUseCase_Toggle_FindByURLErrorReturnsError(t *testing.T) {
@@ -342,3 +366,83 @@ func TestManageFavoritesUseCase_DeleteFolder_InvalidatesFavoritesCache(t *testin
342366 require.Len(t, favoritesAfter, 1)
343367 assert.Nil(t, favoritesAfter[0].FolderID)
344368 }
369+
370+func TestManageFavoritesUseCase_AddFavoriteNormalizesURL(t *testing.T) {
371+ ctx := testContext()
372+
373+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
374+ folderRepo := repomocks.NewMockFolderRepository(t)
375+ tagRepo := repomocks.NewMockTagRepository(t)
376+
377+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "https://example.com").Return(nil, nil).Once()
378+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "example.com").Return(nil, nil).Once()
379+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
380+ return fav != nil && fav.URL == "https://example.com" && fav.Title == "Example"
381+ })).Return(nil).Once()
382+
383+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
384+
385+ fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "example.com", Title: " Example "})
386+ require.NoError(t, err)
387+ require.NotNil(t, fav)
388+ assert.Equal(t, "https://example.com", fav.URL)
389+ assert.Equal(t, "Example", fav.Title)
390+}
391+
392+func TestManageFavoritesUseCase_AddFavoriteRejectsInvalidTagIDs(t *testing.T) {
393+ ctx := testContext()
394+
395+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
396+ folderRepo := repomocks.NewMockFolderRepository(t)
397+ tagRepo := repomocks.NewMockTagRepository(t)
398+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
399+
400+ _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "https://example.com", Tags: []entity.TagID{1, 0}})
401+ require.Error(t, err)
402+ assert.Contains(t, err.Error(), "favorite tag id must be positive")
403+}
404+
405+func TestManageFavoritesUseCase_AddFavoriteRejectsUnsafeOrMalformedURL(t *testing.T) {
406+ ctx := testContext()
407+
408+ tests := []string{
409+ "",
410+ "not a url",
411+ "javascript:alert(1)",
412+ "data:text/html,<script>alert(1)</script>",
413+ "https:///missing-host",
414+ "dumb:///missing-host",
415+ }
416+ for _, raw := range tests {
417+ t.Run(raw, func(t *testing.T) {
418+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
419+ folderRepo := repomocks.NewMockFolderRepository(t)
420+ tagRepo := repomocks.NewMockTagRepository(t)
421+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
422+
423+ _, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: raw})
424+ require.Error(t, err)
425+ })
426+ }
427+}
428+
429+func TestManageFavoritesUseCase_AddFavoriteNormalizesOpaqueDumbRoute(t *testing.T) {
430+ ctx := testContext()
431+
432+ favoriteRepo := repomocks.NewMockFavoriteRepository(t)
433+ folderRepo := repomocks.NewMockFolderRepository(t)
434+ tagRepo := repomocks.NewMockTagRepository(t)
435+
436+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb://history").Return(nil, nil).Once()
437+ favoriteRepo.EXPECT().FindByURL(mock.Anything, "dumb:history").Return(nil, nil).Once()
438+ favoriteRepo.EXPECT().Save(mock.Anything, mock.MatchedBy(func(fav *entity.Favorite) bool {
439+ return fav != nil && fav.URL == "dumb://history" && fav.Title == "History"
440+ })).Return(nil).Once()
441+
442+ uc := usecase.NewManageFavoritesUseCase(favoriteRepo, folderRepo, tagRepo)
443+
444+ fav, err := uc.AddFavorite(ctx, dto.FavoriteCreateInput{URL: "dumb:history", Title: "History"})
445+ require.NoError(t, err)
446+ require.NotNil(t, fav)
447+ assert.Equal(t, "dumb://history", fav.URL)
448+}
A internal/application/usecase/read_systemview_config.go +41 -0
@@ -0,0 +1,41 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+
7+ "github.com/bnema/dumber/internal/application/dto"
8+ "github.com/bnema/dumber/internal/application/port"
9+)
10+
11+// ReadSystemviewConfigUseCase exposes read-side systemview config payloads.
12+type ReadSystemviewConfigUseCase struct {
13+ reader port.SystemviewConfigReader
14+}
15+
16+// NewReadSystemviewConfigUseCase creates a new read systemview config use case.
17+func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader) *ReadSystemviewConfigUseCase {
18+ if reader == nil {
19+ panic("NewReadSystemviewConfigUseCase: reader is nil")
20+ }
21+ return &ReadSystemviewConfigUseCase{reader: reader}
22+}
23+
24+// ErrNilSystemviewConfigReader is returned when the config reader dependency is nil.
25+var ErrNilSystemviewConfigReader = errors.New("systemview config reader is nil")
26+
27+// Current returns the current systemview config payload.
28+func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
29+ if uc == nil || uc.reader == nil {
30+ return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
31+ }
32+ return uc.reader.Current(ctx)
33+}
34+
35+// Default returns the default systemview config payload.
36+func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
37+ if uc == nil || uc.reader == nil {
38+ return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
39+ }
40+ return uc.reader.Default(ctx)
41+}
A internal/application/usecase/read_systemview_config_test.go +51 -0
@@ -0,0 +1,51 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/dto"
8+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestReadSystemviewConfigUseCase_Current(t *testing.T) {
14+ reader := portmocks.NewMockSystemviewConfigReader(t)
15+ want := dto.SystemviewConfigPayload{EngineType: "webkit"}
16+ reader.EXPECT().Current(mock.Anything).Return(want, nil).Once()
17+
18+ uc := NewReadSystemviewConfigUseCase(reader)
19+
20+ got, err := uc.Current(context.Background())
21+ require.NoError(t, err)
22+ require.Equal(t, want, got)
23+}
24+
25+func TestReadSystemviewConfigUseCase_Default(t *testing.T) {
26+ reader := portmocks.NewMockSystemviewConfigReader(t)
27+ want := dto.SystemviewConfigPayload{EngineType: "webkit"}
28+ reader.EXPECT().Default(mock.Anything).Return(want, nil).Once()
29+
30+ uc := NewReadSystemviewConfigUseCase(reader)
31+
32+ got, err := uc.Default(context.Background())
33+ require.NoError(t, err)
34+ require.Equal(t, want, got)
35+}
36+
37+func TestNewReadSystemviewConfigUseCase_NilReaderPanics(t *testing.T) {
38+ require.PanicsWithValue(t, "NewReadSystemviewConfigUseCase: reader is nil", func() {
39+ NewReadSystemviewConfigUseCase(nil)
40+ })
41+}
42+
43+func TestReadSystemviewConfigUseCase_NilUseCaseReturnsSentinel(t *testing.T) {
44+ var uc *ReadSystemviewConfigUseCase
45+
46+ _, err := uc.Current(context.Background())
47+ require.ErrorIs(t, err, ErrNilSystemviewConfigReader)
48+
49+ _, err = uc.Default(context.Background())
50+ require.ErrorIs(t, err, ErrNilSystemviewConfigReader)
51+}
M internal/application/usecase/save_webui_config.go +43 -13
@@ -3,8 +3,10 @@ package usecase
33 import (
44 "context"
55 "fmt"
6+ "math"
67 "strings"
78
9+ "github.com/bnema/dumber/internal/application/dto"
810 "github.com/bnema/dumber/internal/application/port"
911 "github.com/bnema/dumber/internal/domain/validation"
1012 )
@@ -17,11 +19,15 @@ func NewSaveWebUIConfigUseCase(saver port.WebUIConfigSaver) *SaveWebUIConfigUseC
1719 return &SaveWebUIConfigUseCase{saver: saver}
1820 }
1921
20-func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUIConfig) error {
22+func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg dto.WebUIConfig) error {
2123 if uc == nil || uc.saver == nil {
2224 return fmt.Errorf("config saver is nil")
2325 }
2426
27+ if err := validateSearchShortcutKeyCollisions(cfg.SearchShortcuts); err != nil {
28+ return err
29+ }
30+
2531 normalized := normalizeWebUIConfig(cfg)
2632 if err := validateWebUIConfig(normalized); err != nil {
2733 return err
@@ -30,45 +36,59 @@ func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUICon
3036 return uc.saver.SaveWebUIConfig(ctx, normalized)
3137 }
3238
33-func normalizeWebUIConfig(cfg port.WebUIConfig) port.WebUIConfig {
39+func validateSearchShortcutKeyCollisions(shortcuts map[string]dto.SearchShortcut) error {
40+ seen := make(map[string]string, len(shortcuts))
41+ for key := range shortcuts {
42+ trimmedKey := strings.TrimSpace(key)
43+ if trimmedKey == "" {
44+ continue
45+ }
46+ if previous, ok := seen[trimmedKey]; ok && previous != key {
47+ return fmt.Errorf("search shortcut key %q duplicates %q after trimming", key, previous)
48+ }
49+ seen[trimmedKey] = key
50+ }
51+ return nil
52+}
53+
54+func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig {
3455 cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)
3556 cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)
3657 cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont)
3758 cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme)
3859 cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine)
3960 if len(cfg.SearchShortcuts) > 0 {
40- normalized := make(map[string]port.SearchShortcut, len(cfg.SearchShortcuts))
61+ normalized := make(map[string]dto.SearchShortcut, len(cfg.SearchShortcuts))
4162 for key, shortcut := range cfg.SearchShortcuts {
4263 trimmedKey := strings.TrimSpace(key)
64+ if trimmedKey == "" {
65+ continue
66+ }
4367 shortcut.URL = strings.TrimSpace(shortcut.URL)
4468 shortcut.Description = strings.TrimSpace(shortcut.Description)
45- if trimmedKey != "" {
46- normalized[trimmedKey] = shortcut
47- }
69+ normalized[trimmedKey] = shortcut
4870 }
4971 cfg.SearchShortcuts = normalized
5072 }
5173 return cfg
5274 }
5375
54-func validateWebUIConfig(cfg port.WebUIConfig) error {
76+func validateWebUIConfig(cfg dto.WebUIConfig) error {
5577 var errs []string
5678
5779 if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {
5880 errs = append(errs, "appearance.default_font_size must be between 1 and 72")
5981 }
60- if cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {
61- errs = append(errs, "default_ui_scale must be between 0.5 and 3.0")
82+ if math.IsNaN(cfg.DefaultUIScale) || math.IsInf(cfg.DefaultUIScale, 0) || cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {
83+ errs = append(errs, "default_ui_scale must be a finite value between 0.5 and 3.0")
6284 }
6385
6486 errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...)
6587 errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...)
6688 errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...)
6789
68- if cfg.DefaultSearchEngine == "" {
69- errs = append(errs, "default_search_engine cannot be empty")
70- } else if !strings.Contains(cfg.DefaultSearchEngine, "%s") {
71- errs = append(errs, "default_search_engine must contain %s placeholder for the search query")
90+ for _, err := range validation.ValidateShortcutURL(cfg.DefaultSearchEngine) {
91+ errs = append(errs, fmt.Sprintf("default_search_engine: %s", err))
7292 }
7393
7494 for key, shortcut := range cfg.SearchShortcuts {
@@ -83,6 +103,16 @@ func validateWebUIConfig(cfg port.WebUIConfig) error {
83103 }
84104 }
85105
106+ switch cfg.Performance.Profile {
107+ case "", "default", "lite", "balanced", "max", "custom":
108+ // ok
109+ default:
110+ errs = append(errs, fmt.Sprintf(
111+ "performance.profile must be one of: default, lite, balanced, max, custom (got: %s)",
112+ cfg.Performance.Profile,
113+ ))
114+ }
115+
86116 switch cfg.Appearance.ColorScheme {
87117 case "default", "prefer-dark", "prefer-light":
88118 // ok
A internal/application/usecase/save_webui_config_test.go +127 -0
@@ -0,0 +1,127 @@
1+package usecase_test
2+
3+import (
4+ "context"
5+ "math"
6+ "testing"
7+
8+ "github.com/bnema/dumber/internal/application/dto"
9+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
10+ "github.com/bnema/dumber/internal/application/usecase"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
16+ tests := []struct {
17+ name string
18+ mutate func(*dto.WebUIConfig)
19+ wantErr string
20+ }{
21+ {
22+ name: "rejects invalid default search URL",
23+ mutate: func(cfg *dto.WebUIConfig) {
24+ cfg.DefaultSearchEngine = "not-a-url-%s"
25+ },
26+ wantErr: "default_search_engine",
27+ },
28+ {
29+ name: "rejects invalid performance profile",
30+ mutate: func(cfg *dto.WebUIConfig) {
31+ cfg.Performance.Profile = "turbo"
32+ },
33+ wantErr: "performance.profile",
34+ },
35+ {
36+ name: "rejects NaN UI scale",
37+ mutate: func(cfg *dto.WebUIConfig) {
38+ cfg.DefaultUIScale = math.NaN()
39+ },
40+ wantErr: "default_ui_scale must be a finite value",
41+ },
42+ {
43+ name: "rejects infinite UI scale",
44+ mutate: func(cfg *dto.WebUIConfig) {
45+ cfg.DefaultUIScale = math.Inf(1)
46+ },
47+ wantErr: "default_ui_scale must be a finite value",
48+ },
49+ }
50+
51+ for _, tt := range tests {
52+ t.Run(tt.name, func(t *testing.T) {
53+ cfg := validWebUIConfig()
54+ tt.mutate(&cfg)
55+ saver := portmocks.NewMockWebUIConfigSaver(t)
56+ uc := usecase.NewSaveWebUIConfigUseCase(saver)
57+
58+ err := uc.Execute(context.Background(), cfg)
59+
60+ require.Error(t, err)
61+ require.Contains(t, err.Error(), tt.wantErr)
62+ })
63+ }
64+}
65+
66+func TestSaveWebUIConfigUseCase_RejectsDuplicateTrimmedSearchShortcutKeys(t *testing.T) {
67+ saver := portmocks.NewMockWebUIConfigSaver(t)
68+ uc := usecase.NewSaveWebUIConfigUseCase(saver)
69+ cfg := validWebUIConfig()
70+ cfg.SearchShortcuts["ddg"] = dto.SearchShortcut{URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"}
71+ cfg.SearchShortcuts[" ddg "] = dto.SearchShortcut{URL: "https://example.com/?q=%s", Description: "Duplicate"}
72+
73+ err := uc.Execute(context.Background(), cfg)
74+
75+ require.Error(t, err)
76+ require.Contains(t, err.Error(), "duplicates")
77+}
78+
79+func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {
80+ saver := portmocks.NewMockWebUIConfigSaver(t)
81+ var saved dto.WebUIConfig
82+ saver.EXPECT().SaveWebUIConfig(mock.Anything, mock.AnythingOfType("dto.WebUIConfig")).Run(func(_ context.Context, cfg dto.WebUIConfig) {
83+ saved = cfg
84+ }).Return(nil).Once()
85+ uc := usecase.NewSaveWebUIConfigUseCase(saver)
86+ cfg := validWebUIConfig()
87+ cfg.SearchShortcuts[" ddg "] = dto.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "}
88+ cfg.SearchShortcuts[" "] = dto.SearchShortcut{URL: "https://example.com?q=%s", Description: "Example"}
89+
90+ err := uc.Execute(context.Background(), cfg)
91+
92+ require.NoError(t, err)
93+ require.NotContains(t, saved.SearchShortcuts, " ddg ")
94+ require.NotContains(t, saved.SearchShortcuts, "")
95+ require.Contains(t, saved.SearchShortcuts, "ddg")
96+ require.Equal(t, "https://duckduckgo.com/?q=%s", saved.SearchShortcuts["ddg"].URL)
97+ require.Equal(t, "DuckDuckGo", saved.SearchShortcuts["ddg"].Description)
98+}
99+
100+func validWebUIConfig() dto.WebUIConfig {
101+ palette := dto.ColorPalette{
102+ Background: "#ffffff",
103+ Surface: "#f8f8f8",
104+ SurfaceVariant: "#eeeeee",
105+ Text: "#111111",
106+ Muted: "#666666",
107+ Accent: "#0055ff",
108+ Border: "#dddddd",
109+ }
110+ return dto.WebUIConfig{
111+ Appearance: dto.WebUIAppearanceConfig{
112+ SansFont: "Inter",
113+ SerifFont: "Georgia",
114+ MonospaceFont: "JetBrains Mono",
115+ DefaultFontSize: 16,
116+ ColorScheme: "prefer-dark",
117+ LightPalette: palette,
118+ DarkPalette: palette,
119+ },
120+ Performance: dto.WebUIPerformanceConfig{
121+ Profile: "default",
122+ },
123+ DefaultUIScale: 1,
124+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
125+ SearchShortcuts: map[string]dto.SearchShortcut{},
126+ }
127+}
M internal/application/usecase/search_history.go +116 -7
@@ -3,17 +3,23 @@ package usecase
33 import (
44 "context"
55 "fmt"
6+ "strings"
67 "time"
78
9+ "github.com/bnema/dumber/internal/application/dto"
810 "github.com/bnema/dumber/internal/application/port"
911 "github.com/bnema/dumber/internal/domain/entity"
12+ historydomain "github.com/bnema/dumber/internal/domain/history"
1013 "github.com/bnema/dumber/internal/domain/repository"
14+ domainurl "github.com/bnema/dumber/internal/domain/url"
1115 "github.com/bnema/dumber/internal/logging"
1216 )
1317
1418 // Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory.
1519 var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil)
1620
21+const historyWindowDuration = 24 * time.Hour
22+
1723 // SearchHistoryUseCase handles history search and retrieval operations.
1824 type SearchHistoryUseCase struct {
1925 historyRepo repository.HistoryRepository
@@ -26,11 +32,11 @@ func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHi
2632 }
2733 }
2834
29-// SearchInput is an alias for port.HistorySearchInput.
30-type SearchInput = port.HistorySearchInput
35+// SearchInput is an alias for dto.HistorySearchInput.
36+type SearchInput = dto.HistorySearchInput
3137
32-// SearchOutput is an alias for port.HistorySearchOutput.
33-type SearchOutput = port.HistorySearchOutput
38+// SearchOutput is an alias for dto.HistorySearchOutput.
39+type SearchOutput = dto.HistorySearchOutput
3440
3541 // Search performs a full-text search on history entries using SQLite FTS5.
3642 // Returns only entries that actually match the query terms.
@@ -60,10 +66,11 @@ func (uc *SearchHistoryUseCase) Search(ctx context.Context, input SearchInput) (
6066 return &SearchOutput{Matches: matches}, nil
6167 }
6268
63-// GetRecent retrieves recent history entries with pagination.
69+// GetRecent retrieves recent history entries. A zero limit means all entries;
70+// negative limits retain the historical default page size.
6471 func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
65- if limit <= 0 {
66- limit = 50 // Default limit
72+ if limit < 0 {
73+ limit = 50 // Default limit for invalid negative values.
6774 }
6875
6976 entries, err := uc.historyRepo.GetRecent(ctx, limit, offset)
@@ -74,6 +81,75 @@ func (uc *SearchHistoryUseCase) GetRecent(ctx context.Context, limit, offset int
7481 return entries, nil
7582 }
7683
84+// GetRecentByDomain retrieves recent history entries for a canonical domain. A
85+// zero limit means all matching entries; negative limits use the default page size.
86+func (uc *SearchHistoryUseCase) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
87+ if limit < 0 {
88+ limit = 50
89+ }
90+ domain, err := canonicalHistoryDomain(domain)
91+ if err != nil {
92+ return nil, err
93+ }
94+
95+ entries, err := uc.historyRepo.GetRecentByDomain(ctx, domain, limit, offset)
96+ if err != nil {
97+ return nil, fmt.Errorf("failed to get recent history by domain: %w", err)
98+ }
99+ return entries, nil
100+}
101+
102+// canonicalHistoryDomain normalizes a domain and rejects empty values before
103+// the domain reaches exact-match persistence queries.
104+func canonicalHistoryDomain(domain string) (string, error) {
105+ domain = domainurl.CanonicalDomain(domain)
106+ if domain == "" {
107+ return "", fmt.Errorf("domain is required")
108+ }
109+ return domain, nil
110+}
111+
112+// GetRecentWindow retrieves the 24-hour history window ending before the given cursor.
113+func (uc *SearchHistoryUseCase) GetRecentWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
114+ if before.IsZero() {
115+ before = time.Now()
116+ }
117+ before = before.UTC()
118+ after := before.Add(-historyWindowDuration)
119+
120+ var (
121+ entries []*entity.HistoryEntry
122+ hasMore bool
123+ err error
124+ )
125+ originalDomain := domain
126+ domain = domainurl.CanonicalDomain(domain)
127+ if strings.TrimSpace(originalDomain) != "" && domain == "" {
128+ return nil, fmt.Errorf("domain is required")
129+ }
130+ if domain != "" {
131+ entries, err = uc.historyRepo.GetRecentWindowByDomain(ctx, domain, before, after)
132+ if err == nil {
133+ hasMore, err = uc.historyRepo.HasEntriesByDomainBefore(ctx, domain, after)
134+ }
135+ } else {
136+ entries, err = uc.historyRepo.GetRecentWindow(ctx, before, after)
137+ if err == nil {
138+ hasMore, err = uc.historyRepo.HasEntriesBefore(ctx, after)
139+ }
140+ }
141+ if err != nil {
142+ return nil, fmt.Errorf("failed to get recent history window: %w", err)
143+ }
144+
145+ return &entity.HistoryWindow{
146+ Entries: entries,
147+ Before: before,
148+ After: after,
149+ HasMore: hasMore,
150+ }, nil
151+}
152+
77153 // GetRecentSince retrieves history entries visited within the last N days.
78154 // If days is 0, returns all history entries.
79155 // If days is negative, defaults to 30 days.
@@ -154,6 +230,25 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time.
154230 return nil
155231 }
156232
233+// ClearRange deletes history entries inside a named recent range.
234+func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error {
235+ cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
236+ if !ok {
237+ return fmt.Errorf("unknown history delete range: %q", rangeID)
238+ }
239+ if all {
240+ return uc.ClearAll(ctx)
241+ }
242+
243+ log := logging.FromContext(ctx)
244+ log.Debug().Time("since", cutoff).Str("range", rangeID).Msg("clearing recent history range")
245+ if err := uc.historyRepo.DeleteSince(ctx, cutoff); err != nil {
246+ return fmt.Errorf("failed to clear history range: %w", err)
247+ }
248+ log.Info().Time("since", cutoff).Str("range", rangeID).Msg("recent history range cleared")
249+ return nil
250+}
251+
157252 // ClearAll deletes all history entries.
158253 func (uc *SearchHistoryUseCase) ClearAll(ctx context.Context) error {
159254 log := logging.FromContext(ctx)
@@ -182,6 +277,11 @@ func (uc *SearchHistoryUseCase) Delete(ctx context.Context, id int64) error {
182277
183278 // DeleteByDomain removes all history entries for a domain.
184279 func (uc *SearchHistoryUseCase) DeleteByDomain(ctx context.Context, domain string) error {
280+ domain, err := canonicalHistoryDomain(domain)
281+ if err != nil {
282+ return err
283+ }
284+
185285 log := logging.FromContext(ctx)
186286 log.Debug().Str("domain", domain).Msg("deleting history by domain")
187287
@@ -205,6 +305,15 @@ func (uc *SearchHistoryUseCase) GetDomainStats(ctx context.Context, limit int) (
205305 return uc.historyRepo.GetDomainStats(ctx, limit)
206306 }
207307
308+// GetStats retrieves lightweight aggregate history statistics.
309+func (uc *SearchHistoryUseCase) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
310+ stats, err := uc.historyRepo.GetStats(ctx)
311+ if err != nil {
312+ return nil, fmt.Errorf("failed to get history stats: %w", err)
313+ }
314+ return stats, nil
315+}
316+
208317 // GetAnalytics retrieves aggregated history analytics for the homepage.
209318 func (uc *SearchHistoryUseCase) GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
210319 log := logging.FromContext(ctx)
M internal/application/usecase/search_history_test.go +116 -0
@@ -13,6 +13,43 @@ import (
1313 "github.com/stretchr/testify/require"
1414 )
1515
16+func TestSearchHistoryUseCase_GetRecent_ZeroLimitMeansAll(t *testing.T) {
17+ ctx := testContext()
18+ historyRepo := repomocks.NewMockHistoryRepository(t)
19+ entries := []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Example"}}
20+ historyRepo.EXPECT().GetRecent(mock.Anything, 0, 0).Return(entries, nil).Once()
21+
22+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
23+ result, err := uc.GetRecent(ctx, 0, 0)
24+
25+ require.NoError(t, err)
26+ require.Len(t, result, 1)
27+}
28+
29+func TestSearchHistoryUseCase_GetRecent_NegativeLimitDefaultsToPageSize(t *testing.T) {
30+ ctx := testContext()
31+ historyRepo := repomocks.NewMockHistoryRepository(t)
32+ historyRepo.EXPECT().GetRecent(mock.Anything, 50, 0).Return([]*entity.HistoryEntry{}, nil).Once()
33+
34+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
35+ result, err := uc.GetRecent(ctx, -1, 0)
36+
37+ require.NoError(t, err)
38+ assert.Empty(t, result)
39+}
40+
41+func TestSearchHistoryUseCase_GetRecentWindow_InvalidDomainReturnsValidationError(t *testing.T) {
42+ ctx := testContext()
43+ historyRepo := repomocks.NewMockHistoryRepository(t)
44+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
45+
46+ result, err := uc.GetRecentWindow(ctx, time.Now(), "///")
47+
48+ require.Error(t, err)
49+ assert.Nil(t, result)
50+ assert.Contains(t, err.Error(), "domain is required")
51+}
52+
1653 func TestSearchHistoryUseCase_GetRecentSince_ReturnsEntries(t *testing.T) {
1754 ctx := testContext()
1855
@@ -151,3 +188,82 @@ func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testin
151188 require.NotNil(t, result)
152189 assert.Empty(t, result)
153190 }
191+
192+func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testing.T) {
193+ ctx := testContext()
194+ historyRepo := repomocks.NewMockHistoryRepository(t)
195+ now := time.Now()
196+ expectedCutoff := now.Add(-time.Hour)
197+ historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
198+ return since.After(expectedCutoff.Add(-time.Minute)) && since.Before(expectedCutoff.Add(time.Minute))
199+ })).Return(nil).Once()
200+
201+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
202+
203+ err := uc.ClearRange(ctx, "hour")
204+ require.NoError(t, err)
205+}
206+
207+func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) {
208+ ctx := testContext()
209+ historyRepo := repomocks.NewMockHistoryRepository(t)
210+ now := time.Now()
211+ expectedCutoff := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
212+ historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
213+ return since.Equal(expectedCutoff)
214+ })).Return(nil).Once()
215+
216+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
217+
218+ err := uc.ClearRange(ctx, "day")
219+ require.NoError(t, err)
220+}
221+
222+func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) {
223+ ctx := testContext()
224+
225+ historyRepo := repomocks.NewMockHistoryRepository(t)
226+
227+ historyRepo.EXPECT().DeleteAll(mock.Anything).Return(nil)
228+
229+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
230+
231+ err := uc.ClearRange(ctx, "all")
232+ require.NoError(t, err)
233+}
234+
235+func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T) {
236+ ctx := testContext()
237+
238+ historyRepo := repomocks.NewMockHistoryRepository(t)
239+
240+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
241+
242+ err := uc.ClearRange(ctx, "bogus")
243+ require.Error(t, err)
244+ assert.Contains(t, err.Error(), "unknown history delete range")
245+}
246+
247+func TestSearchHistoryUseCase_GetRecentByDomainAllowsUnderscoreDomains(t *testing.T) {
248+ ctx := testContext()
249+ historyRepo := repomocks.NewMockHistoryRepository(t)
250+ want := []*entity.HistoryEntry{{URL: "https://example_.com"}}
251+ historyRepo.EXPECT().GetRecentByDomain(mock.Anything, "example_.com", 20, 0).Return(want, nil).Once()
252+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
253+
254+ result, err := uc.GetRecentByDomain(ctx, "example_.com", 20, 0)
255+
256+ require.NoError(t, err)
257+ assert.Equal(t, want, result)
258+}
259+
260+func TestSearchHistoryUseCase_DeleteByDomainAllowsUnderscoreDomains(t *testing.T) {
261+ ctx := testContext()
262+ historyRepo := repomocks.NewMockHistoryRepository(t)
263+ historyRepo.EXPECT().DeleteByDomain(mock.Anything, "example_.com").Return(nil).Once()
264+ uc := usecase.NewSearchHistoryUseCase(historyRepo)
265+
266+ err := uc.DeleteByDomain(ctx, "example_.com")
267+
268+ require.NoError(t, err)
269+}
M internal/bootstrap/engine.go +17 -14
@@ -5,6 +5,7 @@ import (
55 "encoding/json"
66 "fmt"
77
8+ "github.com/bnema/dumber/internal/application/dto"
89 "github.com/bnema/dumber/internal/application/port"
910 "github.com/bnema/dumber/internal/application/usecase"
1011 audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"
@@ -33,6 +34,19 @@ type EngineInput struct {
3334 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.
3435 func BuildEngine(input EngineInput) (port.Engine, error) {
3536 cfg := input.Config
37+ systemviewReader := config.NewSystemviewConfigReader(env.NewHardwareSurveyor())
38+ systemviewUC := usecase.NewReadSystemviewConfigUseCase(systemviewReader)
39+ buildConfigPayload := func(read func(context.Context) (dto.SystemviewConfigPayload, error)) func() ([]byte, error) {
40+ return func() ([]byte, error) {
41+ payload, err := read(input.Ctx)
42+ if err != nil {
43+ return nil, err
44+ }
45+ return json.Marshal(payload)
46+ }
47+ }
48+ currentConfigPayload := buildConfigPayload(systemviewUC.Current)
49+ defaultConfigPayload := buildConfigPayload(systemviewUC.Default)
3650 contextMenuBuilder := usecase.NewBuildContextMenuUseCase()
3751 contextMenuExecutorFactory := &usecase.ContextMenuActionExecutorFactory{}
3852 engineType := cfg.Engine.ResolveEngineType()
@@ -49,6 +63,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
4963
5064 return webkit.NewEngine(
5165 input.Ctx, cfg, opts, profile, wkCfg,
66+ currentConfigPayload, defaultConfigPayload,
5267 input.ThemeManager, input.ColorResolver,
5368 contextMenuBuilder, contextMenuExecutorFactory,
5469 input.Logger,
@@ -75,23 +90,11 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
7590 MaxConcurrent: cfg.Transcoding.MaxConcurrent,
7691 Quality: cfg.Transcoding.Quality,
7792 }
78- surveyor := env.NewHardwareSurveyor()
79- buildConfigPayload := func(cfgf func() *config.Config) func() ([]byte, error) {
80- return func() ([]byte, error) {
81- cfg := cfgf()
82- var hw *port.HardwareInfo
83- if surveyor != nil {
84- survey := surveyor.Survey(context.Background())
85- hw = &survey
86- }
87- return json.Marshal(config.BuildWebUIConfigPayload(cfg, hw))
88- }
89- }
9093 deps := cef.EngineDependencies{
9194 RegisterHandlers: handlers.RegisterAll,
9295 RegisterAccentHandlers: handlers.RegisterAccentHandlers,
93- CurrentConfigPayload: buildConfigPayload(config.Get),
94- DefaultConfigPayload: buildConfigPayload(config.DefaultConfig),
96+ CurrentConfigPayload: currentConfigPayload,
97+ DefaultConfigPayload: defaultConfigPayload,
9598 ContextMenuBuilder: contextMenuBuilder,
9699 ContextMenuExecutorFactory: contextMenuExecutorFactory,
97100 Clipboard: clipboardinfra.New(),
A internal/domain/entity/history_window.go +11 -0
@@ -0,0 +1,11 @@
1+package entity
2+
3+import "time"
4+
5+// HistoryWindow contains one fixed-duration slice of browsing history.
6+type HistoryWindow struct {
7+ Entries []*HistoryEntry `json:"entries"`
8+ Before time.Time `json:"before"`
9+ After time.Time `json:"after"`
10+ HasMore bool `json:"has_more"`
11+}
A internal/domain/history/delete_range.go +23 -0
@@ -0,0 +1,23 @@
1+package history
2+
3+import "time"
4+
5+// DeleteRangeCutoff resolves a delete-range identifier to a cutoff time.
6+// The booleans report whether the range means "delete all" and whether the
7+// identifier was recognized.
8+func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool, bool) {
9+ switch rangeID {
10+ case "hour":
11+ return now.Add(-time.Hour), false, true
12+ case "day":
13+ return time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()), false, true
14+ case "week":
15+ return now.AddDate(0, 0, -7), false, true
16+ case "month":
17+ return now.AddDate(0, -1, 0), false, true
18+ case "all":
19+ return time.Time{}, true, true
20+ default:
21+ return time.Time{}, false, false
22+ }
23+}
A internal/domain/history/delete_range_test.go +40 -0
@@ -0,0 +1,40 @@
1+package history
2+
3+import (
4+ "testing"
5+ "time"
6+)
7+
8+func TestDeleteRangeCutoff(t *testing.T) {
9+ now := time.Date(2026, time.April, 11, 12, 0, 0, 0, time.UTC)
10+
11+ tests := []struct {
12+ name string
13+ rangeID string
14+ want time.Time
15+ wantAll bool
16+ wantOK bool
17+ }{
18+ {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false, wantOK: true},
19+ {name: "day", rangeID: "day", want: time.Date(2026, time.April, 11, 0, 0, 0, 0, time.UTC), wantAll: false, wantOK: true},
20+ {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false, wantOK: true},
21+ {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false, wantOK: true},
22+ {name: "all", rangeID: "all", want: time.Time{}, wantAll: true, wantOK: true},
23+ {name: "unknown", rangeID: "bogus", want: time.Time{}, wantAll: false, wantOK: false},
24+ }
25+
26+ for _, tt := range tests {
27+ t.Run(tt.name, func(t *testing.T) {
28+ got, gotAll, gotOK := DeleteRangeCutoff(tt.rangeID, now)
29+ if !got.Equal(tt.want) {
30+ t.Fatalf("DeleteRangeCutoff() cutoff = %v, want %v", got, tt.want)
31+ }
32+ if gotAll != tt.wantAll {
33+ t.Fatalf("DeleteRangeCutoff() all = %v, want %v", gotAll, tt.wantAll)
34+ }
35+ if gotOK != tt.wantOK {
36+ t.Fatalf("DeleteRangeCutoff() ok = %v, want %v", gotOK, tt.wantOK)
37+ }
38+ })
39+ }
40+}
M internal/domain/repository/history.go +19 -1
@@ -18,9 +18,24 @@ type HistoryRepository interface {
1818 // Search performs a fuzzy search on history entries.
1919 Search(ctx context.Context, query string, limit int) ([]entity.HistoryMatch, error)
2020
21- // GetRecent retrieves recent history entries with pagination.
21+ // GetRecent retrieves recent history entries with pagination. A zero limit means all entries.
2222 GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
2323
24+ // GetRecentByDomain retrieves recent history entries for a domain with pagination. A zero limit means all matching entries.
25+ GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error)
26+
27+ // GetRecentWindow retrieves entries visited in [after, before), sorted by recency.
28+ GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error)
29+
30+ // GetRecentWindowByDomain retrieves domain entries visited in [after, before), sorted by recency.
31+ GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error)
32+
33+ // HasEntriesBefore reports whether any history entry was visited before the given time.
34+ HasEntriesBefore(ctx context.Context, before time.Time) (bool, error)
35+
36+ // HasEntriesByDomainBefore reports whether any domain entry was visited before the given time.
37+ HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error)
38+
2439 // GetRecentSince retrieves history entries visited within the last N days.
2540 // days must be > 0.
2641 GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error)
@@ -44,6 +59,9 @@ type HistoryRepository interface {
4459 // DeleteOlderThan removes entries older than the given time.
4560 DeleteOlderThan(ctx context.Context, before time.Time) error
4661
62+ // DeleteSince removes entries visited at or after the given time.
63+ DeleteSince(ctx context.Context, since time.Time) error
64+
4765 // DeleteAll removes all history entries.
4866 DeleteAll(ctx context.Context) error
4967
M internal/domain/repository/mocks/mocks.go +429 -0
@@ -2129,6 +2129,63 @@ func (_c *MockHistoryRepository_DeleteOlderThan_Call) RunAndReturn(run func(ctx
21292129 return _c
21302130 }
21312131
2132+// DeleteSince provides a mock function for the type MockHistoryRepository
2133+func (_mock *MockHistoryRepository) DeleteSince(ctx context.Context, since time.Time) error {
2134+ ret := _mock.Called(ctx, since)
2135+
2136+ if len(ret) == 0 {
2137+ panic("no return value specified for DeleteSince")
2138+ }
2139+
2140+ var r0 error
2141+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) error); ok {
2142+ r0 = returnFunc(ctx, since)
2143+ } else {
2144+ r0 = ret.Error(0)
2145+ }
2146+ return r0
2147+}
2148+
2149+// MockHistoryRepository_DeleteSince_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteSince'
2150+type MockHistoryRepository_DeleteSince_Call struct {
2151+ *mock.Call
2152+}
2153+
2154+// DeleteSince is a helper method to define mock.On call
2155+// - ctx context.Context
2156+// - since time.Time
2157+func (_e *MockHistoryRepository_Expecter) DeleteSince(ctx interface{}, since interface{}) *MockHistoryRepository_DeleteSince_Call {
2158+ return &MockHistoryRepository_DeleteSince_Call{Call: _e.mock.On("DeleteSince", ctx, since)}
2159+}
2160+
2161+func (_c *MockHistoryRepository_DeleteSince_Call) Run(run func(ctx context.Context, since time.Time)) *MockHistoryRepository_DeleteSince_Call {
2162+ _c.Call.Run(func(args mock.Arguments) {
2163+ var arg0 context.Context
2164+ if args[0] != nil {
2165+ arg0 = args[0].(context.Context)
2166+ }
2167+ var arg1 time.Time
2168+ if args[1] != nil {
2169+ arg1 = args[1].(time.Time)
2170+ }
2171+ run(
2172+ arg0,
2173+ arg1,
2174+ )
2175+ })
2176+ return _c
2177+}
2178+
2179+func (_c *MockHistoryRepository_DeleteSince_Call) Return(err error) *MockHistoryRepository_DeleteSince_Call {
2180+ _c.Call.Return(err)
2181+ return _c
2182+}
2183+
2184+func (_c *MockHistoryRepository_DeleteSince_Call) RunAndReturn(run func(ctx context.Context, since time.Time) error) *MockHistoryRepository_DeleteSince_Call {
2185+ _c.Call.Return(run)
2186+ return _c
2187+}
2188+
21322189 // FindByURL provides a mock function for the type MockHistoryRepository
21332190 func (_mock *MockHistoryRepository) FindByURL(ctx context.Context, url string) (*entity.HistoryEntry, error) {
21342191 ret := _mock.Called(ctx, url)
@@ -2661,6 +2718,86 @@ func (_c *MockHistoryRepository_GetRecent_Call) RunAndReturn(run func(ctx contex
26612718 return _c
26622719 }
26632720
2721+// GetRecentByDomain provides a mock function for the type MockHistoryRepository
2722+func (_mock *MockHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error) {
2723+ ret := _mock.Called(ctx, domain, limit, offset)
2724+
2725+ if len(ret) == 0 {
2726+ panic("no return value specified for GetRecentByDomain")
2727+ }
2728+
2729+ var r0 []*entity.HistoryEntry
2730+ var r1 error
2731+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) ([]*entity.HistoryEntry, error)); ok {
2732+ return returnFunc(ctx, domain, limit, offset)
2733+ }
2734+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, int, int) []*entity.HistoryEntry); ok {
2735+ r0 = returnFunc(ctx, domain, limit, offset)
2736+ } else {
2737+ if ret.Get(0) != nil {
2738+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2739+ }
2740+ }
2741+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, int, int) error); ok {
2742+ r1 = returnFunc(ctx, domain, limit, offset)
2743+ } else {
2744+ r1 = ret.Error(1)
2745+ }
2746+ return r0, r1
2747+}
2748+
2749+// MockHistoryRepository_GetRecentByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentByDomain'
2750+type MockHistoryRepository_GetRecentByDomain_Call struct {
2751+ *mock.Call
2752+}
2753+
2754+// GetRecentByDomain is a helper method to define mock.On call
2755+// - ctx context.Context
2756+// - domain string
2757+// - limit int
2758+// - offset int
2759+func (_e *MockHistoryRepository_Expecter) GetRecentByDomain(ctx interface{}, domain interface{}, limit interface{}, offset interface{}) *MockHistoryRepository_GetRecentByDomain_Call {
2760+ return &MockHistoryRepository_GetRecentByDomain_Call{Call: _e.mock.On("GetRecentByDomain", ctx, domain, limit, offset)}
2761+}
2762+
2763+func (_c *MockHistoryRepository_GetRecentByDomain_Call) Run(run func(ctx context.Context, domain string, limit int, offset int)) *MockHistoryRepository_GetRecentByDomain_Call {
2764+ _c.Call.Run(func(args mock.Arguments) {
2765+ var arg0 context.Context
2766+ if args[0] != nil {
2767+ arg0 = args[0].(context.Context)
2768+ }
2769+ var arg1 string
2770+ if args[1] != nil {
2771+ arg1 = args[1].(string)
2772+ }
2773+ var arg2 int
2774+ if args[2] != nil {
2775+ arg2 = args[2].(int)
2776+ }
2777+ var arg3 int
2778+ if args[3] != nil {
2779+ arg3 = args[3].(int)
2780+ }
2781+ run(
2782+ arg0,
2783+ arg1,
2784+ arg2,
2785+ arg3,
2786+ )
2787+ })
2788+ return _c
2789+}
2790+
2791+func (_c *MockHistoryRepository_GetRecentByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentByDomain_Call {
2792+ _c.Call.Return(historyEntrys, err)
2793+ return _c
2794+}
2795+
2796+func (_c *MockHistoryRepository_GetRecentByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, limit int, offset int) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentByDomain_Call {
2797+ _c.Call.Return(run)
2798+ return _c
2799+}
2800+
26642801 // GetRecentSince provides a mock function for the type MockHistoryRepository
26652802 func (_mock *MockHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
26662803 ret := _mock.Called(ctx, days)
@@ -2729,6 +2866,160 @@ func (_c *MockHistoryRepository_GetRecentSince_Call) RunAndReturn(run func(ctx c
27292866 return _c
27302867 }
27312868
2869+// GetRecentWindow provides a mock function for the type MockHistoryRepository
2870+func (_mock *MockHistoryRepository) GetRecentWindow(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) {
2871+ ret := _mock.Called(ctx, before, after)
2872+
2873+ if len(ret) == 0 {
2874+ panic("no return value specified for GetRecentWindow")
2875+ }
2876+
2877+ var r0 []*entity.HistoryEntry
2878+ var r1 error
2879+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok {
2880+ return returnFunc(ctx, before, after)
2881+ }
2882+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time, time.Time) []*entity.HistoryEntry); ok {
2883+ r0 = returnFunc(ctx, before, after)
2884+ } else {
2885+ if ret.Get(0) != nil {
2886+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2887+ }
2888+ }
2889+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time, time.Time) error); ok {
2890+ r1 = returnFunc(ctx, before, after)
2891+ } else {
2892+ r1 = ret.Error(1)
2893+ }
2894+ return r0, r1
2895+}
2896+
2897+// MockHistoryRepository_GetRecentWindow_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindow'
2898+type MockHistoryRepository_GetRecentWindow_Call struct {
2899+ *mock.Call
2900+}
2901+
2902+// GetRecentWindow is a helper method to define mock.On call
2903+// - ctx context.Context
2904+// - before time.Time
2905+// - after time.Time
2906+func (_e *MockHistoryRepository_Expecter) GetRecentWindow(ctx interface{}, before interface{}, after interface{}) *MockHistoryRepository_GetRecentWindow_Call {
2907+ return &MockHistoryRepository_GetRecentWindow_Call{Call: _e.mock.On("GetRecentWindow", ctx, before, after)}
2908+}
2909+
2910+func (_c *MockHistoryRepository_GetRecentWindow_Call) Run(run func(ctx context.Context, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindow_Call {
2911+ _c.Call.Run(func(args mock.Arguments) {
2912+ var arg0 context.Context
2913+ if args[0] != nil {
2914+ arg0 = args[0].(context.Context)
2915+ }
2916+ var arg1 time.Time
2917+ if args[1] != nil {
2918+ arg1 = args[1].(time.Time)
2919+ }
2920+ var arg2 time.Time
2921+ if args[2] != nil {
2922+ arg2 = args[2].(time.Time)
2923+ }
2924+ run(
2925+ arg0,
2926+ arg1,
2927+ arg2,
2928+ )
2929+ })
2930+ return _c
2931+}
2932+
2933+func (_c *MockHistoryRepository_GetRecentWindow_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentWindow_Call {
2934+ _c.Call.Return(historyEntrys, err)
2935+ return _c
2936+}
2937+
2938+func (_c *MockHistoryRepository_GetRecentWindow_Call) RunAndReturn(run func(ctx context.Context, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindow_Call {
2939+ _c.Call.Return(run)
2940+ return _c
2941+}
2942+
2943+// GetRecentWindowByDomain provides a mock function for the type MockHistoryRepository
2944+func (_mock *MockHistoryRepository) GetRecentWindowByDomain(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error) {
2945+ ret := _mock.Called(ctx, domain, before, after)
2946+
2947+ if len(ret) == 0 {
2948+ panic("no return value specified for GetRecentWindowByDomain")
2949+ }
2950+
2951+ var r0 []*entity.HistoryEntry
2952+ var r1 error
2953+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) ([]*entity.HistoryEntry, error)); ok {
2954+ return returnFunc(ctx, domain, before, after)
2955+ }
2956+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time, time.Time) []*entity.HistoryEntry); ok {
2957+ r0 = returnFunc(ctx, domain, before, after)
2958+ } else {
2959+ if ret.Get(0) != nil {
2960+ r0 = ret.Get(0).([]*entity.HistoryEntry)
2961+ }
2962+ }
2963+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time, time.Time) error); ok {
2964+ r1 = returnFunc(ctx, domain, before, after)
2965+ } else {
2966+ r1 = ret.Error(1)
2967+ }
2968+ return r0, r1
2969+}
2970+
2971+// MockHistoryRepository_GetRecentWindowByDomain_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetRecentWindowByDomain'
2972+type MockHistoryRepository_GetRecentWindowByDomain_Call struct {
2973+ *mock.Call
2974+}
2975+
2976+// GetRecentWindowByDomain is a helper method to define mock.On call
2977+// - ctx context.Context
2978+// - domain string
2979+// - before time.Time
2980+// - after time.Time
2981+func (_e *MockHistoryRepository_Expecter) GetRecentWindowByDomain(ctx interface{}, domain interface{}, before interface{}, after interface{}) *MockHistoryRepository_GetRecentWindowByDomain_Call {
2982+ return &MockHistoryRepository_GetRecentWindowByDomain_Call{Call: _e.mock.On("GetRecentWindowByDomain", ctx, domain, before, after)}
2983+}
2984+
2985+func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Run(run func(ctx context.Context, domain string, before time.Time, after time.Time)) *MockHistoryRepository_GetRecentWindowByDomain_Call {
2986+ _c.Call.Run(func(args mock.Arguments) {
2987+ var arg0 context.Context
2988+ if args[0] != nil {
2989+ arg0 = args[0].(context.Context)
2990+ }
2991+ var arg1 string
2992+ if args[1] != nil {
2993+ arg1 = args[1].(string)
2994+ }
2995+ var arg2 time.Time
2996+ if args[2] != nil {
2997+ arg2 = args[2].(time.Time)
2998+ }
2999+ var arg3 time.Time
3000+ if args[3] != nil {
3001+ arg3 = args[3].(time.Time)
3002+ }
3003+ run(
3004+ arg0,
3005+ arg1,
3006+ arg2,
3007+ arg3,
3008+ )
3009+ })
3010+ return _c
3011+}
3012+
3013+func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) Return(historyEntrys []*entity.HistoryEntry, err error) *MockHistoryRepository_GetRecentWindowByDomain_Call {
3014+ _c.Call.Return(historyEntrys, err)
3015+ return _c
3016+}
3017+
3018+func (_c *MockHistoryRepository_GetRecentWindowByDomain_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time, after time.Time) ([]*entity.HistoryEntry, error)) *MockHistoryRepository_GetRecentWindowByDomain_Call {
3019+ _c.Call.Return(run)
3020+ return _c
3021+}
3022+
27323023 // GetStats provides a mock function for the type MockHistoryRepository
27333024 func (_mock *MockHistoryRepository) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
27343025 ret := _mock.Called(ctx)
@@ -2791,6 +3082,144 @@ func (_c *MockHistoryRepository_GetStats_Call) RunAndReturn(run func(ctx context
27913082 return _c
27923083 }
27933084
3085+// HasEntriesBefore provides a mock function for the type MockHistoryRepository
3086+func (_mock *MockHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) {
3087+ ret := _mock.Called(ctx, before)
3088+
3089+ if len(ret) == 0 {
3090+ panic("no return value specified for HasEntriesBefore")
3091+ }
3092+
3093+ var r0 bool
3094+ var r1 error
3095+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) (bool, error)); ok {
3096+ return returnFunc(ctx, before)
3097+ }
3098+ if returnFunc, ok := ret.Get(0).(func(context.Context, time.Time) bool); ok {
3099+ r0 = returnFunc(ctx, before)
3100+ } else {
3101+ r0 = ret.Get(0).(bool)
3102+ }
3103+ if returnFunc, ok := ret.Get(1).(func(context.Context, time.Time) error); ok {
3104+ r1 = returnFunc(ctx, before)
3105+ } else {
3106+ r1 = ret.Error(1)
3107+ }
3108+ return r0, r1
3109+}
3110+
3111+// MockHistoryRepository_HasEntriesBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesBefore'
3112+type MockHistoryRepository_HasEntriesBefore_Call struct {
3113+ *mock.Call
3114+}
3115+
3116+// HasEntriesBefore is a helper method to define mock.On call
3117+// - ctx context.Context
3118+// - before time.Time
3119+func (_e *MockHistoryRepository_Expecter) HasEntriesBefore(ctx interface{}, before interface{}) *MockHistoryRepository_HasEntriesBefore_Call {
3120+ return &MockHistoryRepository_HasEntriesBefore_Call{Call: _e.mock.On("HasEntriesBefore", ctx, before)}
3121+}
3122+
3123+func (_c *MockHistoryRepository_HasEntriesBefore_Call) Run(run func(ctx context.Context, before time.Time)) *MockHistoryRepository_HasEntriesBefore_Call {
3124+ _c.Call.Run(func(args mock.Arguments) {
3125+ var arg0 context.Context
3126+ if args[0] != nil {
3127+ arg0 = args[0].(context.Context)
3128+ }
3129+ var arg1 time.Time
3130+ if args[1] != nil {
3131+ arg1 = args[1].(time.Time)
3132+ }
3133+ run(
3134+ arg0,
3135+ arg1,
3136+ )
3137+ })
3138+ return _c
3139+}
3140+
3141+func (_c *MockHistoryRepository_HasEntriesBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesBefore_Call {
3142+ _c.Call.Return(b, err)
3143+ return _c
3144+}
3145+
3146+func (_c *MockHistoryRepository_HasEntriesBefore_Call) RunAndReturn(run func(ctx context.Context, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesBefore_Call {
3147+ _c.Call.Return(run)
3148+ return _c
3149+}
3150+
3151+// HasEntriesByDomainBefore provides a mock function for the type MockHistoryRepository
3152+func (_mock *MockHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) {
3153+ ret := _mock.Called(ctx, domain, before)
3154+
3155+ if len(ret) == 0 {
3156+ panic("no return value specified for HasEntriesByDomainBefore")
3157+ }
3158+
3159+ var r0 bool
3160+ var r1 error
3161+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) (bool, error)); ok {
3162+ return returnFunc(ctx, domain, before)
3163+ }
3164+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, time.Time) bool); ok {
3165+ r0 = returnFunc(ctx, domain, before)
3166+ } else {
3167+ r0 = ret.Get(0).(bool)
3168+ }
3169+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, time.Time) error); ok {
3170+ r1 = returnFunc(ctx, domain, before)
3171+ } else {
3172+ r1 = ret.Error(1)
3173+ }
3174+ return r0, r1
3175+}
3176+
3177+// MockHistoryRepository_HasEntriesByDomainBefore_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HasEntriesByDomainBefore'
3178+type MockHistoryRepository_HasEntriesByDomainBefore_Call struct {
3179+ *mock.Call
3180+}
3181+
3182+// HasEntriesByDomainBefore is a helper method to define mock.On call
3183+// - ctx context.Context
3184+// - domain string
3185+// - before time.Time
3186+func (_e *MockHistoryRepository_Expecter) HasEntriesByDomainBefore(ctx interface{}, domain interface{}, before interface{}) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
3187+ return &MockHistoryRepository_HasEntriesByDomainBefore_Call{Call: _e.mock.On("HasEntriesByDomainBefore", ctx, domain, before)}
3188+}
3189+
3190+func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Run(run func(ctx context.Context, domain string, before time.Time)) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
3191+ _c.Call.Run(func(args mock.Arguments) {
3192+ var arg0 context.Context
3193+ if args[0] != nil {
3194+ arg0 = args[0].(context.Context)
3195+ }
3196+ var arg1 string
3197+ if args[1] != nil {
3198+ arg1 = args[1].(string)
3199+ }
3200+ var arg2 time.Time
3201+ if args[2] != nil {
3202+ arg2 = args[2].(time.Time)
3203+ }
3204+ run(
3205+ arg0,
3206+ arg1,
3207+ arg2,
3208+ )
3209+ })
3210+ return _c
3211+}
3212+
3213+func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) Return(b bool, err error) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
3214+ _c.Call.Return(b, err)
3215+ return _c
3216+}
3217+
3218+func (_c *MockHistoryRepository_HasEntriesByDomainBefore_Call) RunAndReturn(run func(ctx context.Context, domain string, before time.Time) (bool, error)) *MockHistoryRepository_HasEntriesByDomainBefore_Call {
3219+ _c.Call.Return(run)
3220+ return _c
3221+}
3222+
27943223 // IncrementVisitCount provides a mock function for the type MockHistoryRepository
27953224 func (_mock *MockHistoryRepository) IncrementVisitCount(ctx context.Context, url string) error {
27963225 ret := _mock.Called(ctx, url)
M internal/domain/url/normalize.go +27 -1
@@ -178,7 +178,33 @@ func ExtractDomain(rawURL string) string {
178178 if err != nil || parsed.Host == "" {
179179 return ""
180180 }
181- return strings.TrimPrefix(parsed.Host, "www.")
181+ return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
182+}
183+
184+// CanonicalDomain extracts a normalized domain from either a URL string or a
185+// host-like domain string. It keeps non-default ports so callers that need to
186+// round-trip repository domain keys do not lose identity.
187+func CanonicalDomain(raw string) string {
188+ raw = strings.TrimSpace(strings.ToLower(raw))
189+ if raw == "" {
190+ return ""
191+ }
192+ if domain := ExtractDomain(raw); domain != "" {
193+ return domain
194+ }
195+ if idx := strings.IndexAny(raw, "/?#"); idx >= 0 {
196+ raw = raw[:idx]
197+ }
198+ return strings.TrimPrefix(raw, "www.")
199+}
200+
201+// DisplayDomain returns a user-facing domain label with any port removed.
202+func DisplayDomain(raw string) string {
203+ domain := CanonicalDomain(raw)
204+ if host, _, err := net.SplitHostPort(domain); err == nil {
205+ return host
206+ }
207+ return domain
182208 }
183209
184210 // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.
M internal/domain/url/normalize_test.go +57 -4
@@ -34,8 +34,8 @@ func TestNormalize(t *testing.T) {
3434 },
3535 {
3636 name: "dumb scheme unchanged",
37- input: "dumb://home",
38- want: "dumb://home",
37+ input: "dumb://history",
38+ want: "dumb://history",
3939 },
4040 {
4141 name: "about scheme unchanged",
@@ -347,7 +347,7 @@ func TestLooksLikeURL(t *testing.T) {
347347 },
348348 {
349349 name: "dumb URL",
350- input: "dumb://home",
350+ input: "dumb://history",
351351 want: true,
352352 },
353353 {
@@ -506,7 +506,7 @@ func TestIsExternalScheme(t *testing.T) {
506506 },
507507 {
508508 name: "dumb scheme",
509- input: "dumb://home",
509+ input: "dumb://history",
510510 want: false,
511511 },
512512 {
@@ -692,3 +692,56 @@ func TestExtractOrigin_InvalidURI(t *testing.T) {
692692 })
693693 }
694694 }
695+
696+func TestCanonicalDomain(t *testing.T) {
697+ tests := []struct {
698+ name string
699+ input string
700+ want string
701+ }{
702+ {name: "empty", input: "", want: ""},
703+ {name: "whitespace only", input: " ", want: ""},
704+ {name: "url", input: "https://www.example.com/path", want: "example.com"},
705+ {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com:8443"},
706+ {name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"},
707+ {name: "url with userinfo and port", input: "https://user:pass@example.com:8443/path", want: "example.com:8443"},
708+ {name: "domain", input: "www.example.com", want: "example.com"},
709+ {name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"},
710+ }
711+
712+ for _, tt := range tests {
713+ t.Run(tt.name, func(t *testing.T) {
714+ if got := CanonicalDomain(tt.input); got != tt.want {
715+ t.Errorf("CanonicalDomain(%q) = %q, want %q", tt.input, got, tt.want)
716+ }
717+ })
718+ }
719+}
720+
721+func TestDisplayDomain(t *testing.T) {
722+ tests := []struct {
723+ name string
724+ input string
725+ want string
726+ }{
727+ {name: "empty", input: "", want: ""},
728+ {name: "whitespace only", input: " ", want: ""},
729+ {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com"},
730+ {name: "url without port", input: "https://www.example.com/path", want: "example.com"},
731+ {name: "url with userinfo", input: "https://user:pass@example.com/path", want: "example.com"},
732+ {name: "no www prefix", input: "https://docs.example.com/path", want: "docs.example.com"},
733+ {name: "host only", input: "www.example.org:9443/path?q=1", want: "example.org"},
734+ {name: "invalid url", input: "%zz", want: "%zz"},
735+ {name: "non-http scheme", input: "ftp://www.example.net:21/file", want: "example.net"},
736+ {name: "ipv6 url", input: "https://[2001:db8::1]:8443/path", want: "2001:db8::1"},
737+ {name: "uppercase host", input: "https://WWW.EXAMPLE.COM/path", want: "example.com"},
738+ }
739+
740+ for _, tt := range tests {
741+ t.Run(tt.name, func(t *testing.T) {
742+ if got := DisplayDomain(tt.input); got != tt.want {
743+ t.Errorf("DisplayDomain(%q) = %q, want %q", tt.input, got, tt.want)
744+ }
745+ })
746+ }
747+}
A internal/infrastructure/cef/favicon_systemviews.go +15 -0
@@ -0,0 +1,15 @@
1+package cef
2+
3+import "github.com/bnema/dumber/internal/application/port"
4+
5+var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil)
6+
7+// SetSystemviewFaviconService exposes the local favicon cache to internal
8+// systemview pages. The scheme handler only serves cached PNGs; it does not
9+// fetch remote favicons on behalf of History rendering.
10+func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) {
11+ if e == nil || e.schemeHandler == nil {
12+ return
13+ }
14+ e.schemeHandler.setFaviconService(service)
15+}
M internal/infrastructure/cef/internal_urls.go +4 -4
@@ -41,7 +41,7 @@ func toActualInternalURL(raw string) string {
4141 if page == "" {
4242 page = parsed.Opaque
4343 }
44- if page == "" {
44+ if page == "" || !isInternalPageHost(page) {
4545 return raw
4646 }
4747
@@ -49,8 +49,8 @@ func toActualInternalURL(raw string) string {
4949 trimmedPath := strings.Trim(parsed.Path, "/")
5050 switch {
5151 case trimmedPath == "":
52- // Keep page roots at /home instead of /home/ so relative assets resolve
53- // from the origin root.
52+ // Keep page roots at /history instead of /history/ so relative assets
53+ // resolve from the origin root.
5454 case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed):
5555 actualPath = "/" + trimmedPath
5656 case path.Ext(trimmedPath) != "":
@@ -134,7 +134,7 @@ func toConceptualInternalURL(raw string) string {
134134
135135 func isInternalPageHost(host string) bool {
136136 switch host {
137- case homePath, configPath, webrtcPath, errorPath:
137+ case historyPath, favoritesPath, configPath, errorPath:
138138 return true
139139 default:
140140 return false
M internal/infrastructure/cef/internal_urls_test.go +53 -12
@@ -15,23 +15,43 @@ func TestToActualInternalURL(t *testing.T) {
1515 }{
1616 {
1717 name: "page root",
18- in: "dumb://home",
19- want: "https://dumber.invalid/home",
18+ in: "dumb://history",
19+ want: "https://dumber.invalid/history",
20+ },
21+ {
22+ name: "error page root",
23+ in: "dumb://error",
24+ want: "https://dumber.invalid/error",
25+ },
26+ {
27+ name: "favorites page root",
28+ in: "dumb://favorites",
29+ want: "https://dumber.invalid/favorites",
30+ },
31+ {
32+ name: "config page root",
33+ in: "dumb://config",
34+ want: "https://dumber.invalid/config",
2035 },
2136 {
2237 name: "api path stays at origin root",
23- in: "dumb://home/api/message",
38+ in: "dumb://history/api/message",
2439 want: "https://dumber.invalid/api/message",
2540 },
41+ {
42+ name: "config api path stays at origin root",
43+ in: "dumb://config/api/config/default",
44+ want: "https://dumber.invalid/api/config/default",
45+ },
2646 {
2747 name: "root asset stays at origin root",
28- in: "dumb://home/favicon.ico",
48+ in: "dumb://history/favicon.ico",
2949 want: "https://dumber.invalid/favicon.ico",
3050 },
3151 {
3252 name: "page subroute stays under page namespace",
33- in: "dumb://home/crash?url=https%3A%2F%2Fexample.com",
34- want: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",
53+ in: "dumb://history/crash?url=https%3A%2F%2Fexample.com",
54+ want: "https://dumber.invalid/history/crash?url=https%3A%2F%2Fexample.com",
3555 },
3656 {
3757 name: "actual internal URL unchanged",
@@ -72,11 +92,17 @@ func TestResolveAPIPath(t *testing.T) {
7292 ok: true,
7393 },
7494 {
75- name: "conceptual page api path",
76- in: "dumb://home/api/message",
95+ name: "conceptual history api path",
96+ in: "dumb://history/api/message",
7797 want: "/api/message",
7898 ok: true,
7999 },
100+ {
101+ name: "conceptual config api path",
102+ in: "dumb://config/api/config/default",
103+ want: "/api/config/default",
104+ ok: true,
105+ },
80106 {
81107 name: "actual internal api path",
82108 in: "https://dumber.invalid/api/focus-sync",
@@ -120,13 +146,28 @@ func TestToConceptualInternalURL(t *testing.T) {
120146 }{
121147 {
122148 name: "page root",
123- in: "https://dumber.invalid/home",
124- want: "dumb://home",
149+ in: "https://dumber.invalid/history",
150+ want: "dumb://history",
151+ },
152+ {
153+ name: "error page root",
154+ in: "https://dumber.invalid/error",
155+ want: "dumb://error",
156+ },
157+ {
158+ name: "favorites page root",
159+ in: "https://dumber.invalid/favorites",
160+ want: "dumb://favorites",
161+ },
162+ {
163+ name: "config page root",
164+ in: "https://dumber.invalid/config",
165+ want: "dumb://config",
125166 },
126167 {
127168 name: "page subroute",
128- in: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com",
129- want: "dumb://home/crash?url=https%3A%2F%2Fexample.com",
169+ in: "https://dumber.invalid/history/crash?url=https%3A%2F%2Fexample.com",
170+ want: "dumb://history/crash?url=https%3A%2F%2Fexample.com",
130171 },
131172 {
132173 name: "root asset is left unchanged",
M internal/infrastructure/cef/popup_opener_bridge_test.go +1 -1
@@ -37,7 +37,7 @@ func TestOriginFromURL_PreservesNonDefaultPort(t *testing.T) {
3737 }
3838
3939 func TestOriginFromURL_NormalizesInternalConceptualURLs(t *testing.T) {
40- require.Equal(t, actualInternalOrigin, originFromURL("dumb://home"))
40+ require.Equal(t, actualInternalOrigin, originFromURL("dumb://history"))
4141 }
4242
4343 func TestTargetOriginMatchesPopupOpener_NormalizesDefaultPorts(t *testing.T) {
M internal/infrastructure/cef/scheme_handler.go +505 -56
@@ -1,24 +1,32 @@
11 package cef
22
33 import (
4+ "bytes"
45 "context"
56 "embed"
67 "encoding/base64"
78 "encoding/json"
89 "fmt"
910 "html"
11+ "io"
1012 "io/fs"
13+ "net"
1114 "net/http"
1215 "net/url"
13- "path/filepath"
16+ "os"
17+ "path"
18+ "strconv"
1419 "strings"
1520 "sync"
1621 "time"
1722 "unsafe"
1823
24+ "github.com/andybalholm/brotli"
1925 purecef "github.com/bnema/purego-cef/cef"
2026
2127 "github.com/bnema/dumber/internal/application/port"
28+ domainurl "github.com/bnema/dumber/internal/domain/url"
29+ "github.com/bnema/dumber/internal/infrastructure/netguard"
2230 "github.com/bnema/dumber/internal/infrastructure/webutil"
2331 "github.com/bnema/dumber/internal/logging"
2432 "github.com/rs/zerolog"
@@ -26,20 +34,22 @@ import (
2634
2735 // Scheme path constants matching WebKit's naming.
2836 const (
29- homePath = "home"
37+ historyPath = "history"
38+ favoritesPath = "favorites"
3039 configPath = "config"
31- webrtcPath = "webrtc"
3240 errorPath = "error"
3341 indexHTML = "index.html"
3442 maxSchemeTruncatedURLLength = 240
43+ maxSystemviewsWASMBytes = 64 * 1024 * 1024
44+ systemviewsAssetDir = "systemviews"
3545 )
3646
3747 // pageRootFiles maps internal page hosts/paths to their HTML entry points.
3848 var pageRootFiles = map[string]string{
39- homePath: indexHTML,
40- configPath: "config.html",
41- webrtcPath: "webrtc.html",
42- errorPath: "error.html",
49+ historyPath: indexHTML,
50+ favoritesPath: indexHTML,
51+ configPath: indexHTML,
52+ errorPath: indexHTML,
4353 }
4454
4555 var cefNewResourceHandler = purecef.NewResourceHandler
@@ -50,6 +60,7 @@ type dumbSchemeHandler struct {
5060 ctx context.Context
5161 messageRouter *MessageRouter
5262 transcoder port.MediaTranscoder
63+ faviconService port.FaviconService
5364 assets embed.FS
5465 assetsSet bool
5566 assetDir string
@@ -100,14 +111,14 @@ func newDumbSchemeHandler(
100111 ctx: ctx,
101112 messageRouter: router,
102113 transcoder: transcoder,
103- assetDir: "webui",
114+ assetDir: systemviewsAssetDir,
104115 logger: log.With().Str("component", "scheme-handler").Logger(),
105116 currentConfigPayload: currentConfigPayload,
106117 defaultConfigPayload: defaultConfigPayload,
107118 }, nil
108119 }
109120
110-// setAssets sets the embedded filesystem containing webui assets.
121+// setAssets sets the embedded filesystem containing systemviews assets.
111122 func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
112123 h.mu.Lock()
113124 defer h.mu.Unlock()
@@ -116,6 +127,12 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
116127 h.logger.Debug().Msg("assets filesystem configured")
117128 }
118129
130+func (h *dumbSchemeHandler) setFaviconService(service port.FaviconService) {
131+ h.mu.Lock()
132+ defer h.mu.Unlock()
133+ h.faviconService = service
134+}
135+
119136 // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
120137 // for requests to either dumb:// or the internal https origin.
121138 func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {
@@ -162,17 +179,28 @@ func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ s
162179 }
163180
164181 // handleAPI routes API requests to the message router or built-in handlers.
165-func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler {
182+func (h *dumbSchemeHandler) handleAPI(
183+ browser purecef.Browser,
184+ method string,
185+ requestPath string,
186+ request purecef.Request,
187+) purecef.ResourceHandler {
166188 if strings.EqualFold(method, http.MethodOptions) {
189+ if requestPath == "/api/config" || requestPath == "/api/config/default" {
190+ if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
191+ return denied
192+ }
193+ return h.newPrivateAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
194+ }
167195 return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
168196 }
169197 if isAPIGetMethod(method) {
170- if handler, ok := h.handleAPIGet(path, request); ok {
198+ if handler, ok := h.handleAPIGet(requestPath, request); ok {
171199 return handler
172200 }
173201 }
174202 if isAPIPostMethod(method) {
175- if handler, ok := h.handleAPIPost(browser, path, request); ok {
203+ if handler, ok := h.handleAPIPost(browser, requestPath, request); ok {
176204 return handler
177205 }
178206 }
@@ -187,21 +215,33 @@ func isAPIPostMethod(method string) bool {
187215 return strings.EqualFold(method, http.MethodPost)
188216 }
189217
190-func (h *dumbSchemeHandler) handleAPIGet(path string, request purecef.Request) (purecef.ResourceHandler, bool) {
191- switch path {
218+func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Request) (purecef.ResourceHandler, bool) {
219+ switch requestPath {
192220 case "/api/config":
221+ if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
222+ return denied, true
223+ }
193224 return h.handleConfigAPI(h.currentConfigPayload), true
194225 case "/api/config/default":
226+ if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
227+ return denied, true
228+ }
195229 return h.handleConfigAPI(h.defaultConfigPayload), true
196230 case "/api/transcode":
197231 return h.handleTranscodeAPI(request), true
232+ case "/api/favicon":
233+ return h.handleFaviconAPI(request), true
198234 default:
199235 return nil, false
200236 }
201237 }
202238
203-func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string, request purecef.Request) (purecef.ResourceHandler, bool) {
204- switch path {
239+func (h *dumbSchemeHandler) handleAPIPost(
240+ browser purecef.Browser,
241+ requestPath string,
242+ request purecef.Request,
243+) (purecef.ResourceHandler, bool) {
244+ switch requestPath {
205245 case "/api/message":
206246 return h.handleMessageAPI(request), true
207247 case "/api/clipboard-set":
@@ -224,6 +264,9 @@ func (h *dumbSchemeHandler) handleAPIPost(browser purecef.Browser, path string,
224264 }
225265
226266 func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler {
267+ if denied := h.rejectUntrustedSystemviewRequester(request); denied != nil {
268+ return denied
269+ }
227270 body := readBodyFromHeader(request)
228271 if body == nil {
229272 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
@@ -235,6 +278,98 @@ func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.Re
235278 return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)
236279 }
237280
281+func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) purecef.ResourceHandler {
282+ origin := strings.TrimSpace(request.GetHeaderByName("Origin"))
283+ if origin == "" || strings.EqualFold(origin, actualInternalOrigin) {
284+ return nil
285+ }
286+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden origin"})
287+}
288+
289+func (h *dumbSchemeHandler) rejectUntrustedConfigRequester(request purecef.Request) purecef.ResourceHandler {
290+ return h.rejectUntrustedSystemviewRequester(request)
291+}
292+
293+func (h *dumbSchemeHandler) rejectUntrustedSystemviewRequester(request purecef.Request) purecef.ResourceHandler {
294+ if request == nil {
295+ return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
296+ }
297+ origin := strings.TrimSpace(request.GetHeaderByName("Origin"))
298+ if origin != "" {
299+ if isTrustedSystemviewURL(origin) {
300+ return nil
301+ }
302+ return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
303+ }
304+ referrer := strings.TrimSpace(request.GetReferrerURL())
305+ if referrer == "" {
306+ referrer = strings.TrimSpace(request.GetHeaderByName("Referer"))
307+ }
308+ if !isTrustedSystemviewURL(referrer) {
309+ return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
310+ }
311+ return nil
312+}
313+
314+const systemviewFaviconSize = 32
315+
316+func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.ResourceHandler {
317+ if denied := h.rejectUntrustedFaviconRequester(request); denied != nil {
318+ return denied
319+ }
320+
321+ h.mu.RLock()
322+ service := h.faviconService
323+ h.mu.RUnlock()
324+ if service == nil {
325+ return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"})
326+ }
327+
328+ parsed, err := url.Parse(request.GetURL())
329+ if err != nil {
330+ return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})
331+ }
332+ domain := domainurl.CanonicalDomain(parsed.Query().Get("domain"))
333+ if domain == "" {
334+ return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing domain"})
335+ }
336+ size := systemviewFaviconSize
337+ if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" {
338+ parsedSize, parseErr := strconv.Atoi(rawSize)
339+ if parseErr != nil || parsedSize != systemviewFaviconSize {
340+ return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "unsupported favicon size"})
341+ }
342+ size = parsedSize
343+ }
344+
345+ return newFaviconResourceHandler(service, domain, size)
346+}
347+
348+func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler {
349+ return h.rejectUntrustedSystemviewRequester(request)
350+}
351+
352+func isTrustedSystemviewURL(raw string) bool {
353+ parsed, err := url.Parse(strings.TrimSpace(raw))
354+ if err != nil || parsed.Scheme == "" {
355+ return false
356+ }
357+ if strings.EqualFold(parsed.Scheme, actualInternalScheme) {
358+ return strings.EqualFold(parsed.Host, actualInternalHost)
359+ }
360+ if !strings.EqualFold(parsed.Scheme, "dumb") {
361+ return false
362+ }
363+ host := parsed.Host
364+ if host == "" {
365+ host = parsed.Opaque
366+ }
367+ if idx := strings.IndexAny(host, "/?#"); idx >= 0 {
368+ host = host[:idx]
369+ }
370+ return isInternalPageHost(host)
371+}
372+
238373 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {
239374 if h.transcoder == nil {
240375 return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})
@@ -255,7 +390,6 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
255390 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) {
256391 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
257392 }
258-
259393 headers := make(map[string]string)
260394 if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" {
261395 headers["User-Agent"] = userAgent
@@ -281,12 +415,48 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
281415 headers: headers,
282416 ctx: ctx,
283417 cancel: cancel,
418+ validateSource: func(ctx context.Context) error {
419+ return validateTranscodeSourceURL(ctx, sourceParsed)
420+ },
284421 logf: func() zerolog.Logger {
285422 return h.logger.With().Str("component", "scheme-transcoding").Logger()
286423 },
287424 })
288425 }
289426
427+func validateTranscodeSourceURL(ctx context.Context, source *url.URL) error {
428+ if source == nil || source.Host == "" {
429+ return fmt.Errorf("invalid src")
430+ }
431+ host := source.Hostname()
432+ if host == "" {
433+ return fmt.Errorf("invalid src host")
434+ }
435+ if ip := net.ParseIP(host); ip != nil {
436+ if isBlockedTranscodeIP(ip) {
437+ return fmt.Errorf("private src host not allowed")
438+ }
439+ return nil
440+ }
441+
442+ lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
443+ defer cancel()
444+ addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host)
445+ if err != nil || len(addrs) == 0 {
446+ return fmt.Errorf("src host could not be resolved")
447+ }
448+ for _, addr := range addrs {
449+ if isBlockedTranscodeIP(addr.IP) {
450+ return fmt.Errorf("private src host not allowed")
451+ }
452+ }
453+ return nil
454+}
455+
456+func isBlockedTranscodeIP(ip net.IP) bool {
457+ return netguard.IsBlockedTranscodeIP(ip)
458+}
459+
290460 func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
291461 if build == nil {
292462 return nil, fmt.Errorf("config payload builder not configured")
@@ -298,9 +468,9 @@ func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
298468 func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {
299469 data, err := resolveConfigPayload(build)
300470 if err != nil {
301- return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
471+ return h.newPrivateAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
302472 }
303- return h.newAPIRawResourceHandler(http.StatusOK, "application/json", data)
473+ return h.newPrivateAPIRawResourceHandler(http.StatusOK, "application/json", data)
304474 }
305475
306476 // handleClipboardSet receives copied text from JS copy/cut events and writes
@@ -485,20 +655,26 @@ func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location stri
485655 func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
486656 h.mu.RLock()
487657 hasAssets := h.assetsSet
488- assetDir := h.assetDir
489658 h.mu.RUnlock()
490659
491660 if !hasAssets {
492661 return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured")
493662 }
494663
495- relPath, ok := resolveAssetPath(u)
664+ assetDir, relPath, ok := resolveAssetPath(u)
496665 if !ok {
497666 return h.newErrorResourceHandler(http.StatusNotFound, "Page not found")
498667 }
668+ if assetDir == "" {
669+ assetDir = h.assetDir
670+ }
671+
672+ fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
673+ if !ok {
674+ return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
675+ }
499676
500- fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
501- data, err := fs.ReadFile(h.assets, fullPath)
677+ data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
502678 if err != nil {
503679 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
504680 return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
@@ -511,14 +687,54 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
511687 Int("size", len(data)).
512688 Msg("serving asset")
513689
514- return h.newRawResourceHandler(http.StatusOK, contentType, data)
690+ return newStaticResourceHandler(http.StatusOK, contentType, data, nil)
691+}
692+
693+func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
694+ assetDir = strings.Trim(assetDir, "/")
695+ if assetDir != systemviewsAssetDir {
696+ return "", "", false
697+ }
698+
699+ relPath = strings.TrimLeft(relPath, "/")
700+ if relPath == "" || strings.ContainsRune(relPath, '\x00') {
701+ return "", "", false
702+ }
703+
704+ cleanRelPath = path.Clean(relPath)
705+ if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) {
706+ return "", "", false
707+ }
708+
709+ fullPath = path.Join(assetDir, cleanRelPath)
710+ if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") {
711+ return "", "", false
712+ }
713+ return fullPath, cleanRelPath, true
714+}
715+
716+func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) {
717+ if strings.HasSuffix(relPath, ".wasm") {
718+ if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
719+ data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
720+ if err != nil {
721+ return nil, err
722+ }
723+ if len(data) > maxSystemviewsWASMBytes {
724+ return nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
725+ }
726+ return data, nil
727+ }
728+ }
729+ data, err := fs.ReadFile(assets, fullPath)
730+ return data, err
515731 }
516732
517733 // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
518-// to a relative asset path inside assets/webui.
519-func resolveAssetPath(u *url.URL) (string, bool) {
734+// to a relative asset path inside assets/systemviews.
735+func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
520736 if u == nil {
521- return "", false
737+ return "", "", false
522738 }
523739
524740 if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) {
@@ -528,57 +744,70 @@ func resolveAssetPath(u *url.URL) (string, bool) {
528744 return resolveConceptualAssetPath(u)
529745 }
530746
531-func resolveConceptualAssetPath(u *url.URL) (string, bool) {
747+func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
532748 if u == nil {
533- return "", false
749+ return "", "", false
534750 }
535751
536752 if root, ok := pageRootFiles[u.Host]; ok {
537- path := strings.TrimPrefix(u.Path, "/")
538- if path == "" {
539- return root, true
753+ assetPath := strings.TrimPrefix(u.Path, "/")
754+ if assetPath == "" {
755+ return assetDirForPageHost(u.Host), root, true
540756 }
541757 // Don't serve API paths as assets.
542- if strings.HasPrefix(path, "api/") {
543- return "", false
758+ if strings.HasPrefix(assetPath, "api/") {
759+ return "", "", false
544760 }
545- return path, true
761+ return assetDirForPageHost(u.Host), assetPath, true
546762 }
547763
548- // Handle opaque URLs like dumb:home.
764+ // Handle opaque URLs like dumb:history.
549765 if root, ok := pageRootFiles[u.Opaque]; ok {
550- return root, true
766+ return assetDirForPageHost(u.Opaque), root, true
551767 }
552- return "", false
768+ return "", "", false
553769 }
554770
555-func resolveActualAssetPath(u *url.URL) (string, bool) {
771+func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
556772 if u == nil {
557- return "", false
773+ return "", "", false
558774 }
559775
560- path := strings.Trim(u.Path, "/")
561- if path == "" {
562- return "", false
776+ assetPath := strings.Trim(u.Path, "/")
777+ if assetPath == "" {
778+ return "", "", false
563779 }
564780
565- if root, ok := pageRootFiles[path]; ok {
566- return root, true
781+ parts := strings.SplitN(assetPath, "/", 2)
782+ page := parts[0]
783+ if root, ok := pageRootFiles[page]; ok {
784+ if len(parts) == 1 {
785+ return assetDirForPageHost(page), root, true
786+ }
787+ if page == historyPath && parts[1] == "crash" {
788+ return "", "", false
789+ }
790+ return assetDirForPageHost(page), parts[1], true
567791 }
568792
569- if strings.HasPrefix(path, "api/") {
570- return "", false
793+ if strings.HasPrefix(assetPath, "api/") {
794+ return "", "", false
571795 }
572796
573- parts := strings.SplitN(path, "/", 2)
574- if len(parts) == 2 && isInternalPageHost(parts[0]) {
575- if parts[1] == "crash" && parts[0] == homePath {
576- return "", false
577- }
797+ if !strings.Contains(assetPath, "/") && strings.Contains(assetPath, ".") {
798+ return systemviewsAssetDir, assetPath, true
578799 }
579800
580- // Serve root assets like homepage.min.js, style.css and favicon.png.
581- return path, true
801+ return "", "", false
802+}
803+
804+func assetDirForPageHost(host string) string {
805+ switch host {
806+ case historyPath, favoritesPath, configPath, errorPath:
807+ return systemviewsAssetDir
808+ default:
809+ return ""
810+ }
582811 }
583812
584813 func isCEFCrashPageURL(u *url.URL) bool {
@@ -588,8 +817,8 @@ func isCEFCrashPageURL(u *url.URL) bool {
588817
589818 switch {
590819 case strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost):
591- return strings.Trim(u.Path, "/") == homePath+"/crash"
592- case strings.EqualFold(u.Scheme, "dumb") && u.Host == homePath:
820+ return strings.Trim(u.Path, "/") == historyPath+"/crash"
821+ case strings.EqualFold(u.Scheme, "dumb") && u.Host == historyPath:
593822 return strings.Trim(u.Path, "/") == "crash"
594823 default:
595824 return false
@@ -671,6 +900,10 @@ func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType str
671900 return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil))
672901 }
673902
903+func (h *dumbSchemeHandler) newPrivateAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
904+ return newStaticResourceHandler(status, contentType, data, map[string]string{"Cache-Control": "no-store"})
905+}
906+
674907 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
675908 escaped := html.EscapeString(msg)
676909 body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)
@@ -686,6 +919,222 @@ func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef
686919 return h.newAPIRawResourceHandler(status, "application/json", data)
687920 }
688921
922+func (h *dumbSchemeHandler) newPrivateAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
923+ data, err := json.Marshal(v)
924+ if err != nil {
925+ fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"})
926+ return h.newPrivateAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback)
927+ }
928+ return h.newPrivateAPIRawResourceHandler(status, "application/json", data)
929+}
930+
931+func newFaviconResourceHandler(service port.FaviconService, domain string, size int) purecef.ResourceHandler {
932+ return cefNewResourceHandler(&faviconResourceHandler{
933+ service: service,
934+ domain: domain,
935+ size: size,
936+ done: make(chan struct{}),
937+ headers: map[string]string{"Cache-Control": "no-store"},
938+ })
939+}
940+
941+const maxFaviconMemoryCacheEntries = 256
942+
943+var systemviewFaviconCache = newFaviconMemoryCache(maxFaviconMemoryCacheEntries)
944+
945+type faviconCacheEntry struct {
946+ statusCode int
947+ contentType string
948+ data []byte
949+}
950+
951+type faviconMemoryCache struct {
952+ mu sync.Mutex
953+ max int
954+ entries map[string]faviconCacheEntry
955+ order []string
956+}
957+
958+func newFaviconMemoryCache(maxEntries int) *faviconMemoryCache {
959+ return &faviconMemoryCache{
960+ max: maxEntries,
961+ entries: make(map[string]faviconCacheEntry),
962+ order: make([]string, 0, maxEntries),
963+ }
964+}
965+
966+func faviconCacheKey(domain string, size int) string {
967+ return strings.ToLower(strings.TrimSpace(domain)) + "\x00" + strconv.Itoa(size)
968+}
969+
970+func (c *faviconMemoryCache) get(key string) (faviconCacheEntry, bool) {
971+ if c == nil || key == "" {
972+ return faviconCacheEntry{}, false
973+ }
974+ c.mu.Lock()
975+ defer c.mu.Unlock()
976+ entry, ok := c.entries[key]
977+ if !ok {
978+ return faviconCacheEntry{}, false
979+ }
980+ c.moveToBackLocked(key)
981+ return entry, true
982+}
983+
984+func (c *faviconMemoryCache) put(key string, entry faviconCacheEntry) {
985+ if c == nil || key == "" || c.max <= 0 {
986+ return
987+ }
988+ c.mu.Lock()
989+ defer c.mu.Unlock()
990+ if _, ok := c.entries[key]; !ok {
991+ c.order = append(c.order, key)
992+ } else {
993+ c.moveToBackLocked(key)
994+ }
995+ c.entries[key] = entry
996+ for len(c.order) > c.max {
997+ oldest := c.order[0]
998+ c.order = c.order[1:]
999+ delete(c.entries, oldest)
1000+ }
1001+}
1002+
1003+func (c *faviconMemoryCache) moveToBackLocked(key string) {
1004+ for i, candidate := range c.order {
1005+ if candidate != key {
1006+ continue
1007+ }
1008+ copy(c.order[i:], c.order[i+1:])
1009+ c.order[len(c.order)-1] = key
1010+ return
1011+ }
1012+}
1013+
1014+type faviconResourceHandler struct {
1015+ service port.FaviconService
1016+ domain string
1017+ size int
1018+ headers map[string]string
1019+ once sync.Once
1020+ done chan struct{}
1021+ data []byte
1022+ contentType string
1023+ statusCode int
1024+ offset int
1025+}
1026+
1027+func (rh *faviconResourceHandler) load() {
1028+ defer close(rh.done)
1029+ rh.statusCode = http.StatusNotFound
1030+ rh.contentType = "application/json"
1031+ rh.data = []byte(`{"error":"favicon not cached"}`)
1032+ if rh.service == nil {
1033+ return
1034+ }
1035+ cacheKey := faviconCacheKey(rh.domain, rh.size)
1036+ if entry, ok := systemviewFaviconCache.get(cacheKey); ok {
1037+ rh.statusCode = entry.statusCode
1038+ rh.contentType = entry.contentType
1039+ rh.data = entry.data
1040+ return
1041+ }
1042+ if !rh.service.HasPNGSizedOnDisk(rh.domain, rh.size) {
1043+ return
1044+ }
1045+ diskPath := rh.service.DiskPathPNGSized(rh.domain, rh.size)
1046+ if diskPath == "" {
1047+ return
1048+ }
1049+ data, err := os.ReadFile(diskPath)
1050+ if err != nil || len(data) == 0 {
1051+ return
1052+ }
1053+ rh.statusCode = http.StatusOK
1054+ rh.contentType = "image/png"
1055+ rh.data = data
1056+ systemviewFaviconCache.put(cacheKey, faviconCacheEntry{
1057+ statusCode: rh.statusCode,
1058+ contentType: rh.contentType,
1059+ data: data,
1060+ })
1061+}
1062+
1063+func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
1064+ if handleRequest != nil {
1065+ *handleRequest = 0
1066+ }
1067+ rh.once.Do(func() {
1068+ go func() {
1069+ rh.load()
1070+ if callback != nil {
1071+ callback.Cont()
1072+ }
1073+ }()
1074+ })
1075+ return 1
1076+}
1077+
1078+func (rh *faviconResourceHandler) ProcessRequest(_ purecef.Request, callback purecef.Callback) int32 {
1079+ rh.once.Do(func() {
1080+ go func() {
1081+ rh.load()
1082+ if callback != nil {
1083+ callback.Cont()
1084+ }
1085+ }()
1086+ })
1087+ return 1
1088+}
1089+
1090+func (rh *faviconResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) {
1091+ <-rh.done
1092+ response.SetStatus(int32(rh.statusCode))
1093+ if text := http.StatusText(rh.statusCode); text != "" {
1094+ response.SetStatusText(text)
1095+ }
1096+ mimeType, charset := splitMimeCharset(rh.contentType)
1097+ response.SetMimeType(mimeType)
1098+ if charset != "" {
1099+ response.SetCharset(charset)
1100+ }
1101+ for name, value := range rh.headers {
1102+ response.SetHeaderByName(name, value, 1)
1103+ }
1104+ if responseLength != nil {
1105+ *responseLength = int64(len(rh.data))
1106+ }
1107+}
1108+
1109+func (rh *faviconResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 {
1110+ return 0
1111+}
1112+
1113+func (rh *faviconResourceHandler) Read(dataOut unsafe.Pointer, bytesToRead int32, bytesRead *int32, _ purecef.ResourceReadCallback) int32 {
1114+ <-rh.done
1115+ if rh.offset >= len(rh.data) {
1116+ return 0
1117+ }
1118+ remaining := len(rh.data) - rh.offset
1119+ toRead := int(bytesToRead)
1120+ if toRead > remaining {
1121+ toRead = remaining
1122+ }
1123+ dst := unsafe.Slice((*byte)(dataOut), toRead)
1124+ copy(dst, rh.data[rh.offset:rh.offset+toRead])
1125+ rh.offset += toRead
1126+ if bytesRead != nil {
1127+ *bytesRead = int32(toRead)
1128+ }
1129+ return 1
1130+}
1131+
1132+func (rh *faviconResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 {
1133+ return 0
1134+}
1135+
1136+func (rh *faviconResourceHandler) Cancel() {}
1137+
6891138 // Open handles the request immediately (synchronous).
6901139 func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 {
6911140 // Set handleRequest = true (1) to indicate we handle it immediately.
A internal/infrastructure/cef/scheme_handler_systemviews_test.go +99 -0
@@ -0,0 +1,99 @@
1+package cef
2+
3+import (
4+ "net/url"
5+ "testing"
6+
7+ "github.com/bnema/dumber/assets"
8+)
9+
10+func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) {
11+ t.Parallel()
12+
13+ tests := []struct {
14+ name string
15+ raw string
16+ wantDir string
17+ wantRel string
18+ }{
19+ {name: "history host", raw: "https://dumber.invalid/history", wantDir: "systemviews", wantRel: indexHTML},
20+ {name: "favorites host", raw: "https://dumber.invalid/favorites", wantDir: "systemviews", wantRel: indexHTML},
21+ {name: "config host", raw: "https://dumber.invalid/config", wantDir: "systemviews", wantRel: indexHTML},
22+ {name: "root wasm exec", raw: "https://dumber.invalid/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"},
23+ {name: "root wasm asset", raw: "https://dumber.invalid/systemviews.wasm", wantDir: "systemviews", wantRel: "systemviews.wasm"},
24+ {name: "root css asset", raw: "https://dumber.invalid/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"},
25+ {name: "history opaque", raw: "dumb:history", wantDir: "systemviews", wantRel: indexHTML},
26+ {name: "history sub-asset", raw: "https://dumber.invalid/history/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"},
27+ {name: "history css asset", raw: "https://dumber.invalid/history/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"},
28+ }
29+
30+ for _, tt := range tests {
31+ t.Run(tt.name, func(t *testing.T) {
32+ t.Parallel()
33+ u := mustParseURL(t, tt.raw)
34+ gotDir, gotRel, ok := resolveAssetPath(u)
35+ if !ok {
36+ t.Fatalf("resolveAssetPath(%q) = not ok", tt.raw)
37+ }
38+ if gotDir != tt.wantDir || gotRel != tt.wantRel {
39+ t.Fatalf("resolveAssetPath(%q) = (%q, %q), want (%q, %q)", tt.raw, gotDir, gotRel, tt.wantDir, tt.wantRel)
40+ }
41+ })
42+ }
43+}
44+
45+func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) {
46+ t.Parallel()
47+
48+ data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm")
49+ if err != nil {
50+ t.Fatalf("readAssetWithEncoding() error = %v", err)
51+ }
52+ if len(data) < 4 {
53+ t.Fatalf("decompressed wasm too short: got %d bytes", len(data))
54+ }
55+ if string(data[:4]) != "\x00asm" {
56+ t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4])
57+ }
58+}
59+
60+func mustParseURL(t *testing.T, raw string) *url.URL {
61+ t.Helper()
62+ u, err := url.Parse(raw)
63+ if err != nil {
64+ t.Fatalf("url.Parse(%q) error = %v", raw, err)
65+ }
66+ return u
67+}
68+
69+func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) {
70+ t.Parallel()
71+
72+ fullPath, relPath, ok := safeSystemviewsAssetPath(systemviewsAssetDir, "nested/../systemviews.css")
73+ if !ok {
74+ t.Fatal("safeSystemviewsAssetPath() rejected safe cleaned path")
75+ }
76+ if fullPath != "systemviews/systemviews.css" || relPath != "systemviews.css" {
77+ t.Fatalf("safeSystemviewsAssetPath() = (%q, %q), want (systemviews/systemviews.css, systemviews.css)", fullPath, relPath)
78+ }
79+
80+ invalid := []struct {
81+ name string
82+ assetDir string
83+ relPath string
84+ }{
85+ {name: "parent escape", assetDir: systemviewsAssetDir, relPath: "../logo.svg"},
86+ {name: "nested parent escape", assetDir: systemviewsAssetDir, relPath: "nested/../../logo.svg"},
87+ {name: "absolute parent escape", assetDir: systemviewsAssetDir, relPath: "/../logo.svg"},
88+ {name: "null byte", assetDir: systemviewsAssetDir, relPath: "systemviews.css\x00"},
89+ {name: "wrong asset dir", assetDir: "logos", relPath: "logo.svg"},
90+ }
91+ for _, tt := range invalid {
92+ t.Run(tt.name, func(t *testing.T) {
93+ t.Parallel()
94+ if fullPath, relPath, ok := safeSystemviewsAssetPath(tt.assetDir, tt.relPath); ok {
95+ t.Fatalf("safeSystemviewsAssetPath(%q, %q) = (%q, %q, true), want rejected", tt.assetDir, tt.relPath, fullPath, relPath)
96+ }
97+ })
98+ }
99+}
M internal/infrastructure/cef/scheme_handler_test.go +272 -0
@@ -4,7 +4,9 @@ import (
44 "context"
55 "encoding/base64"
66 "errors"
7+ "net"
78 "net/http"
9+ "net/url"
810 "testing"
911
1012 purecef "github.com/bnema/purego-cef/cef"
@@ -24,6 +26,31 @@ func expectAPIResponseHeaders(response *cefmocks.MockResponse) {
2426 response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once()
2527 }
2628
29+func expectPrivateAPIResponseHeaders(response *cefmocks.MockResponse) {
30+ response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once()
31+}
32+
33+type panicFaviconService struct{}
34+
35+func (panicFaviconService) GetCached(context.Context, string) ([]byte, bool) {
36+ panic("unexpected favicon lookup")
37+}
38+func (panicFaviconService) Get(context.Context, string) ([]byte, error) {
39+ panic("unexpected favicon lookup")
40+}
41+func (panicFaviconService) DiskPathPNG(string) string { panic("unexpected favicon lookup") }
42+func (panicFaviconService) HasPNGOnDisk(string) bool { panic("unexpected favicon lookup") }
43+func (panicFaviconService) HasPNGSizedOnDisk(string, int) bool { panic("unexpected favicon lookup") }
44+func (panicFaviconService) DiskPathPNGSized(string, int) string { panic("unexpected favicon lookup") }
45+func (panicFaviconService) EnsureSizedPNG(context.Context, string, int) error {
46+ panic("unexpected favicon lookup")
47+}
48+func (panicFaviconService) EnsureCacheDir() error { panic("unexpected favicon lookup") }
49+func (panicFaviconService) EnsureDiskCache(context.Context, string) {
50+ panic("unexpected favicon lookup")
51+}
52+func (panicFaviconService) Close() {}
53+
2754 func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) {
2855 data, err := resolveConfigPayload(func() ([]byte, error) {
2956 return []byte(`{"engine_type":"cef"}`), nil
@@ -60,6 +87,251 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) {
6087 require.Contains(t, err.Error(), "default config payload builder")
6188 }
6289
90+func TestRejectForbiddenAPIOrigin_AllowsInternalOrigin(t *testing.T) {
91+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
92+ require.NoError(t, err)
93+
94+ request := cefmocks.NewMockRequest(t)
95+ request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
96+
97+ require.Nil(t, h.rejectForbiddenAPIOrigin(request))
98+}
99+
100+func TestRejectForbiddenAPIOrigin_RejectsExternalOrigin(t *testing.T) {
101+ oldNewResourceHandler := cefNewResourceHandler
102+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
103+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
104+
105+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
106+ require.NoError(t, err)
107+
108+ request := cefmocks.NewMockRequest(t)
109+ request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once()
110+
111+ response := cefmocks.NewMockResponse(t)
112+ response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
113+ response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
114+ response.EXPECT().SetMimeType("application/json").Once()
115+ expectAPIResponseHeaders(response)
116+
117+ handler := h.rejectForbiddenAPIOrigin(request)
118+ require.NotNil(t, handler)
119+ var responseLength int64
120+ handler.GetResponseHeaders(response, &responseLength, 0)
121+ require.Positive(t, responseLength)
122+}
123+
124+func TestRejectUntrustedConfigRequesterRequiresTrustedOriginOrReferrer(t *testing.T) {
125+ oldNewResourceHandler := cefNewResourceHandler
126+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
127+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
128+
129+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
130+ require.NoError(t, err)
131+
132+ trustedOrigin := cefmocks.NewMockRequest(t)
133+ trustedOrigin.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
134+ require.Nil(t, h.rejectUntrustedConfigRequester(trustedOrigin))
135+
136+ trustedReferrer := cefmocks.NewMockRequest(t)
137+ trustedReferrer.EXPECT().GetHeaderByName("Origin").Return("").Once()
138+ trustedReferrer.EXPECT().GetReferrerURL().Return("dumb://history").Once()
139+ require.Nil(t, h.rejectUntrustedConfigRequester(trustedReferrer))
140+
141+ emptyContext := cefmocks.NewMockRequest(t)
142+ emptyContext.EXPECT().GetHeaderByName("Origin").Return("").Once()
143+ emptyContext.EXPECT().GetReferrerURL().Return("").Once()
144+ emptyContext.EXPECT().GetHeaderByName("Referer").Return("").Once()
145+ denied := h.rejectUntrustedConfigRequester(emptyContext)
146+ require.NotNil(t, denied)
147+
148+ response := cefmocks.NewMockResponse(t)
149+ response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
150+ response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
151+ response.EXPECT().SetMimeType("application/json").Once()
152+ expectPrivateAPIResponseHeaders(response)
153+ var responseLength int64
154+ denied.GetResponseHeaders(response, &responseLength, 0)
155+ require.Positive(t, responseLength)
156+
157+ untrustedOrigin := cefmocks.NewMockRequest(t)
158+ untrustedOrigin.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once()
159+ require.NotNil(t, h.rejectUntrustedConfigRequester(untrustedOrigin))
160+}
161+
162+func TestHandleMessageAPIRequiresTrustedOriginOrReferrer(t *testing.T) {
163+ oldNewResourceHandler := cefNewResourceHandler
164+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
165+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
166+
167+ h, err := newDumbSchemeHandler(context.Background(), NewMessageRouter(context.Background()), nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
168+ require.NoError(t, err)
169+
170+ trusted := cefmocks.NewMockRequest(t)
171+ trusted.EXPECT().GetHeaderByName("Origin").Return("").Once()
172+ trusted.EXPECT().GetReferrerURL().Return("dumb://history").Once()
173+ trusted.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(base64.StdEncoding.EncodeToString([]byte(`{"type":"missing"}`))).Once()
174+ trustedHandler := h.handleMessageAPI(trusted)
175+ require.NotNil(t, trustedHandler)
176+
177+ untrusted := cefmocks.NewMockRequest(t)
178+ untrusted.EXPECT().GetHeaderByName("Origin").Return("").Once()
179+ untrusted.EXPECT().GetReferrerURL().Return("").Once()
180+ untrusted.EXPECT().GetHeaderByName("Referer").Return("").Once()
181+ denied := h.handleMessageAPI(untrusted)
182+ require.NotNil(t, denied)
183+
184+ response := cefmocks.NewMockResponse(t)
185+ response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
186+ response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
187+ response.EXPECT().SetMimeType("application/json").Once()
188+ expectPrivateAPIResponseHeaders(response)
189+ var responseLength int64
190+ denied.GetResponseHeaders(response, &responseLength, 0)
191+ require.Positive(t, responseLength)
192+}
193+
194+func TestHandleConfigAPIUsesPrivateNoCORSHeaders(t *testing.T) {
195+ oldNewResourceHandler := cefNewResourceHandler
196+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
197+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
198+
199+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{"ok":true}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
200+ require.NoError(t, err)
201+
202+ response := cefmocks.NewMockResponse(t)
203+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
204+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
205+ response.EXPECT().SetMimeType("application/json").Once()
206+ expectPrivateAPIResponseHeaders(response)
207+
208+ handler := h.handleConfigAPI(h.currentConfigPayload)
209+ var responseLength int64
210+ handler.GetResponseHeaders(response, &responseLength, 0)
211+ require.Positive(t, responseLength)
212+}
213+
214+func TestHandleConfigAPIOptionsUsesPrivateNoCORSHeaders(t *testing.T) {
215+ oldNewResourceHandler := cefNewResourceHandler
216+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
217+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
218+
219+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
220+ require.NoError(t, err)
221+
222+ request := cefmocks.NewMockRequest(t)
223+ request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
224+
225+ response := cefmocks.NewMockResponse(t)
226+ response.EXPECT().SetStatus(int32(http.StatusNoContent)).Once()
227+ response.EXPECT().SetStatusText(http.StatusText(http.StatusNoContent)).Once()
228+ response.EXPECT().SetMimeType("text/plain").Once()
229+ response.EXPECT().SetCharset("utf-8").Once()
230+ expectPrivateAPIResponseHeaders(response)
231+
232+ handler := h.handleAPI(nil, http.MethodOptions, "/api/config", request)
233+ var responseLength int64
234+ handler.GetResponseHeaders(response, &responseLength, 0)
235+ require.Zero(t, responseLength)
236+}
237+
238+func TestIsTrustedSystemviewURL(t *testing.T) {
239+ t.Parallel()
240+
241+ for _, raw := range []string{
242+ "https://dumber.invalid/history",
243+ "dumb://history",
244+ "dumb:history",
245+ } {
246+ require.True(t, isTrustedSystemviewURL(raw), raw)
247+ }
248+ for _, raw := range []string{
249+ "",
250+ "https://evil.example/history",
251+ "dumb://api/favicon",
252+ "dumb://evil/history",
253+ } {
254+ require.False(t, isTrustedSystemviewURL(raw), raw)
255+ }
256+}
257+
258+func TestRejectUntrustedFaviconRequesterAllowsTrustedOriginWithoutReferrer(t *testing.T) {
259+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
260+ require.NoError(t, err)
261+
262+ request := cefmocks.NewMockRequest(t)
263+ request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
264+
265+ require.Nil(t, h.rejectUntrustedFaviconRequester(request))
266+}
267+
268+func TestRejectUntrustedFaviconRequesterRejectsUntrustedOriginEvenWithTrustedReferrer(t *testing.T) {
269+ oldNewResourceHandler := cefNewResourceHandler
270+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
271+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
272+
273+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
274+ require.NoError(t, err)
275+
276+ request := cefmocks.NewMockRequest(t)
277+ request.EXPECT().GetHeaderByName("Origin").Return("https://evil.example").Once()
278+
279+ require.NotNil(t, h.rejectUntrustedFaviconRequester(request))
280+}
281+
282+func TestRejectUntrustedFaviconRequesterRequiresTrustedReferrerWhenOriginAbsent(t *testing.T) {
283+ oldNewResourceHandler := cefNewResourceHandler
284+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
285+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
286+
287+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
288+ require.NoError(t, err)
289+
290+ trusted := cefmocks.NewMockRequest(t)
291+ trusted.EXPECT().GetHeaderByName("Origin").Return("").Once()
292+ trusted.EXPECT().GetReferrerURL().Return("dumb://history").Once()
293+ require.Nil(t, h.rejectUntrustedFaviconRequester(trusted))
294+
295+ untrusted := cefmocks.NewMockRequest(t)
296+ untrusted.EXPECT().GetHeaderByName("Origin").Return("").Once()
297+ untrusted.EXPECT().GetReferrerURL().Return("").Once()
298+ untrusted.EXPECT().GetHeaderByName("Referer").Return("").Once()
299+ require.NotNil(t, h.rejectUntrustedFaviconRequester(untrusted))
300+}
301+
302+func TestHandleFaviconAPIDefersFaviconDiskChecksUntilResourceOpen(t *testing.T) {
303+ oldNewResourceHandler := cefNewResourceHandler
304+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
305+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
306+
307+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
308+ require.NoError(t, err)
309+ h.setFaviconService(panicFaviconService{})
310+
311+ request := cefmocks.NewMockRequest(t)
312+ request.EXPECT().GetHeaderByName("Origin").Return(actualInternalOrigin).Once()
313+ request.EXPECT().GetURL().Return(actualInternalOrigin + "/api/favicon?domain=example.com&size=32").Once()
314+
315+ require.NotNil(t, h.handleFaviconAPI(request))
316+}
317+
318+func TestValidateTranscodeSourceURL_RejectsPrivateHosts(t *testing.T) {
319+ privateURL, err := url.Parse("http://127.0.0.1/video.mp4")
320+ require.NoError(t, err)
321+
322+ err = validateTranscodeSourceURL(context.Background(), privateURL)
323+ require.Error(t, err)
324+ require.Contains(t, err.Error(), "private src host")
325+}
326+
327+func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) {
328+ for _, rawIP := range []string{"100.64.0.1", "198.18.0.1", "192.0.2.1", "64:ff9b::a00:1", "2001::1", "2001:db8::1", "2002:0a00:0001::1"} {
329+ t.Run(rawIP, func(t *testing.T) {
330+ require.True(t, isBlockedTranscodeIP(net.ParseIP(rawIP)))
331+ })
332+ }
333+}
334+
63335 func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
64336 request := cefmocks.NewMockRequest(t)
65337 encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))
M internal/infrastructure/cef/transcoding_handler.go +89 -17
@@ -24,6 +24,8 @@ var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil)
2424 const (
2525 maxTranscodingURLLength = 240
2626 httpStatusOK = 200
27+ httpStatusForbidden = 403
28+ httpStatusBadGateway = 502
2729 )
2830
2931 // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler
@@ -34,14 +36,18 @@ const (
3436 // ---------------------------------------------------------------------------
3537
3638 type transcodingResourceHandler struct {
37- transcoder port.MediaTranscoder
38- sourceURL string
39- headers map[string]string
40- session port.TranscodeSession
41- ctx context.Context
42- cancel context.CancelFunc
43- logf func() zerolog.Logger
44- totalBytes int64
39+ transcoder port.MediaTranscoder
40+ sourceURL string
41+ headers map[string]string
42+ session port.TranscodeSession
43+ ctx context.Context
44+ cancel context.CancelFunc
45+ logf func() zerolog.Logger
46+ validateSource func(context.Context) error
47+ mu sync.Mutex
48+ startErr error
49+ startErrStatus int
50+ totalBytes int64
4551 }
4652
4753 // Open starts the transcode session asynchronously. CEF will call
@@ -62,6 +68,19 @@ func (rh *transcodingResourceHandler) Open(
6268 Msg("cef: starting transcoding resource stream")
6369
6470 go func() {
71+ if rh.validateSource != nil {
72+ if err := rh.validateSource(rh.ctx); err != nil {
73+ log.Warn().
74+ Err(err).
75+ Str("source_url", sourceURL).
76+ Msg("cef: rejected transcoding source")
77+ rh.setStartErr(err, httpStatusForbidden)
78+ rh.cancel()
79+ callback.Cont()
80+ return
81+ }
82+ }
83+
6584 session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers)
6685 if err != nil {
6786 log.Error().
@@ -69,11 +88,14 @@ func (rh *transcodingResourceHandler) Open(
6988 Str("source_url", sourceURL).
7089 Msg("cef: failed to start transcoding resource stream")
7190 // Let CEF know we failed — Cancel will be called.
91+ rh.setStartErr(err, httpStatusBadGateway)
7292 rh.cancel()
7393 callback.Cont()
7494 return
7595 }
96+ rh.mu.Lock()
7697 rh.session = session
98+ rh.mu.Unlock()
7799 log.Info().
78100 Str("source_url", sourceURL).
79101 Str("content_type", session.ContentType()).
@@ -84,10 +106,52 @@ func (rh *transcodingResourceHandler) Open(
84106 return 1
85107 }
86108
109+func (rh *transcodingResourceHandler) setStartErr(err error, status int) {
110+ rh.mu.Lock()
111+ defer rh.mu.Unlock()
112+ rh.startErr = err
113+ rh.startErrStatus = status
114+}
115+
116+func (rh *transcodingResourceHandler) getStartErr() (int, error) {
117+ rh.mu.Lock()
118+ defer rh.mu.Unlock()
119+ return rh.startErrStatus, rh.startErr
120+}
121+
122+func (rh *transcodingResourceHandler) addTotalBytes(n int) int64 {
123+ rh.mu.Lock()
124+ defer rh.mu.Unlock()
125+ rh.totalBytes += int64(n)
126+ return rh.totalBytes
127+}
128+
129+func (rh *transcodingResourceHandler) getTotalBytes() int64 {
130+ rh.mu.Lock()
131+ defer rh.mu.Unlock()
132+ return rh.totalBytes
133+}
134+
87135 // GetResponseHeaders sets the streaming response metadata.
88136 func (rh *transcodingResourceHandler) GetResponseHeaders(
89137 response purecef.Response, responseLength *int64, _ uintptr,
90138 ) {
139+ if status, startErr := rh.getStartErr(); startErr != nil {
140+ if status == 0 {
141+ status = httpStatusBadGateway
142+ }
143+ response.SetStatus(int32(status))
144+ if status == httpStatusForbidden {
145+ response.SetStatusText("Forbidden")
146+ } else {
147+ response.SetStatusText("Bad Gateway")
148+ }
149+ response.SetMimeType("text/plain")
150+ if responseLength != nil {
151+ *responseLength = 0
152+ }
153+ return
154+ }
91155 response.SetStatus(httpStatusOK)
92156 response.SetStatusText("OK")
93157 response.SetMimeType("video/webm")
@@ -108,15 +172,18 @@ func (rh *transcodingResourceHandler) Read(
108172 dataOut unsafe.Pointer, bytesToRead int32,
109173 bytesRead *int32, _ purecef.ResourceReadCallback,
110174 ) int32 {
111- if rh.session == nil {
175+ rh.mu.Lock()
176+ session := rh.session
177+ rh.mu.Unlock()
178+ if session == nil {
112179 return 0
113180 }
114181
115182 dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead))
116- n, err := rh.session.Read(dst)
183+ n, err := session.Read(dst)
117184 if n > 0 {
118- rh.totalBytes += int64(n)
119- if rh.totalBytes == int64(n) {
185+ totalBytes := rh.addTotalBytes(n)
186+ if totalBytes == int64(n) {
120187 rh.logger().Info().
121188 Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)).
122189 Int("first_chunk_bytes", n).
@@ -128,13 +195,14 @@ func (rh *transcodingResourceHandler) Read(
128195 return 1
129196 }
130197 if err != nil {
198+ totalBytes := rh.getTotalBytes()
131199 sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
132200 log := rh.logger().With().
133201 Str("source_url", sourceURL).
134- Int64("total_bytes", rh.totalBytes).
202+ Int64("total_bytes", totalBytes).
135203 Logger()
136204 switch {
137- case errors.Is(err, io.EOF) && rh.totalBytes == 0:
205+ case errors.Is(err, io.EOF) && totalBytes == 0:
138206 log.Warn().Msg("cef: transcoding resource stream ended before producing data")
139207 case errors.Is(err, io.EOF):
140208 log.Debug().Msg("cef: transcoding resource stream reached EOF")
@@ -149,12 +217,16 @@ func (rh *transcodingResourceHandler) Read(
149217 // Cancel aborts the transcode session and releases resources.
150218 func (rh *transcodingResourceHandler) Cancel() {
151219 sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
220+ totalBytes := rh.getTotalBytes()
152221 rh.logger().Debug().
153222 Str("source_url", sourceURL).
154- Int64("total_bytes", rh.totalBytes).
223+ Int64("total_bytes", totalBytes).
155224 Msg("cef: transcoding resource stream canceled")
156- if rh.session != nil {
157- if err := rh.session.Close(); err != nil {
225+ rh.mu.Lock()
226+ session := rh.session
227+ rh.mu.Unlock()
228+ if session != nil {
229+ if err := session.Close(); err != nil {
158230 rh.logger().Warn().
159231 Str("source_url", sourceURL).
160232 Err(err).
M internal/infrastructure/config/defaults.go +11 -8
@@ -240,14 +240,17 @@ func DefaultConfig() *Config {
240240 },
241241 Shortcuts: GlobalShortcutsConfig{
242242 Actions: map[string]ActionBinding{
243- "toggle_floating_pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
244- "close_pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
245- "next_tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
246- "previous_tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
247- "consume_or_expel_left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},
248- "consume_or_expel_right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},
249- "consume_or_expel_up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},
250- "consume_or_expel_down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
243+ "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"},
244+ "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"},
245+ "toggle-favorites-systemview": {Keys: []string{}, Desc: "Toggle Favorites in right split"},
246+ "toggle-config-systemview": {Keys: []string{}, Desc: "Toggle Config in right split"},
247+ "close-pane": {Keys: []string{"ctrl+w"}, Desc: "Close active pane"},
248+ "next-tab": {Keys: []string{"ctrl+tab"}, Desc: "Switch to next tab"},
249+ "previous-tab": {Keys: []string{"ctrl+shift+tab"}, Desc: "Switch to previous tab"},
250+ "consume-or-expel-left": {Keys: []string{"alt+["}, Desc: "Consume/expel pane left"},
251+ "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"},
252+ "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"},
253+ "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"},
251254 },
252255 },
253256 FloatingPane: FloatingPaneConfig{
M internal/infrastructure/config/defaults_test.go +17 -0
@@ -4,6 +4,7 @@ import (
44 "testing"
55
66 "github.com/stretchr/testify/assert"
7+ "github.com/stretchr/testify/require"
78 )
89
910 func TestDefaultConfig_CoreDefaults(t *testing.T) {
@@ -27,6 +28,22 @@ func TestDefaultConfig_CoreDefaults(t *testing.T) {
2728 assert.False(t, cfg.Engine.CEF.TraceHandlers)
2829 assert.True(t, cfg.Engine.WebKit.ITPEnabled)
2930
31+ // System view shortcuts are first-class global actions.
32+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-history-systemview", []string{"ctrl+h"})
33+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-favorites-systemview", []string{})
34+ requireActionBinding(t, cfg.Workspace.Shortcuts.Actions, "toggle-config-systemview", []string{})
35+
3036 // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.
3137 // Their values now live under cfg.Engine / cfg.Engine.WebKit (validated above).
3238 }
39+
40+func requireActionBinding(t *testing.T, actions map[string]ActionBinding, action string, keys []string) {
41+ t.Helper()
42+ binding, ok := actions[action]
43+ require.True(t, ok, "missing action binding %q", action)
44+ if len(keys) == 0 {
45+ assert.Empty(t, binding.Keys)
46+ return
47+ }
48+ assert.Equal(t, keys, binding.Keys)
49+}
M internal/infrastructure/config/floating_pane_config_test.go +2 -2
@@ -15,7 +15,7 @@ func TestFloatingPaneConfig_Defaults(t *testing.T) {
1515 assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
1616 assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
1717
18- binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]
18+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle-floating-pane"]
1919 require.True(t, ok)
2020 assert.Equal(t, []string{"alt+f"}, binding.Keys)
2121 }
@@ -31,7 +31,7 @@ func TestFloatingPaneConfig_DefaultsLoadThroughViper(t *testing.T) {
3131 assert.InDelta(t, 0.72, cfg.Workspace.FloatingPane.HeightPct, 0.0001)
3232 assert.Empty(t, cfg.Workspace.FloatingPane.Profiles)
3333
34- binding, ok := cfg.Workspace.Shortcuts.Actions["toggle_floating_pane"]
34+ binding, ok := cfg.Workspace.Shortcuts.Actions["toggle-floating-pane"]
3535 require.True(t, ok)
3636 assert.Equal(t, []string{"alt+f"}, binding.Keys)
3737 }
M internal/infrastructure/config/loader.go +2 -0
@@ -254,6 +254,8 @@ func normalizeEngineConfig(config *Config) {
254254 config.Engine.Profile = ProfileDefault
255255 case string(ProfileLite):
256256 config.Engine.Profile = ProfileLite
257+ case string(ProfileBalanced):
258+ config.Engine.Profile = ProfileBalanced
257259 case string(ProfileMax):
258260 config.Engine.Profile = ProfileMax
259261 case string(ProfileCustom):
M internal/infrastructure/config/migrate.go +8 -0
@@ -777,6 +777,10 @@ func (m *Migrator) detectMissingWorkspaceShortcutActions(userKeysWithValues map[
777777 if _, exists := userActions[actionName]; exists {
778778 continue
779779 }
780+ legacyActionName := strings.ReplaceAll(actionName, "-", "_")
781+ if _, exists := userActions[legacyActionName]; exists {
782+ continue
783+ }
780784 missing = append(missing, workspaceShortcutsActionsKey+"."+actionName)
781785 }
782786
@@ -801,6 +805,10 @@ func (m *Migrator) mergeMissingWorkspaceShortcutActions(rawConfig map[string]any
801805 if _, exists := userActions[actionName]; exists {
802806 continue
803807 }
808+ legacyActionName := strings.ReplaceAll(actionName, "-", "_")
809+ if _, exists := userActions[legacyActionName]; exists {
810+ continue
811+ }
804812 userActions[actionName] = defaultAction
805813 }
806814
M internal/infrastructure/config/migrate_test.go +11 -4
@@ -695,13 +695,13 @@ func TestMigrator_DetectMissingWorkspaceShortcutActions(t *testing.T) {
695695 for actionName, value := range defaultActions {
696696 userActions[actionName] = value
697697 }
698- delete(userActions, "toggle_floating_pane")
698+ delete(userActions, "toggle-floating-pane")
699699
700700 missing := m.detectMissingWorkspaceShortcutActions(map[string]any{
701701 "workspace.shortcuts.actions": userActions,
702702 })
703703
704- assert.Equal(t, []string{"workspace.shortcuts.actions.toggle_floating_pane"}, missing)
704+ assert.Equal(t, []string{"workspace.shortcuts.actions.toggle-floating-pane"}, missing)
705705 }
706706
707707 func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
@@ -750,7 +750,7 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
750750 actions, ok := actionsAny.(map[string]any)
751751 require.True(t, ok)
752752
753- toggleAny, hasToggle := actions["toggle_floating_pane"]
753+ toggleAny, hasToggle := actions["toggle-floating-pane"]
754754 assert.True(t, hasToggle)
755755 assert.NotNil(t, toggleAny)
756756
@@ -759,12 +759,19 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
759759 closePane, ok := closePaneAny.(map[string]any)
760760 require.True(t, ok)
761761 assert.Equal(t, "Custom close pane", closePane["desc"])
762+ assert.NotContains(t, actions, "close-pane")
763+ assert.NotContains(t, actions, "next-tab")
764+ assert.NotContains(t, actions, "previous-tab")
765+ assert.NotContains(t, actions, "consume-or-expel-left")
766+ assert.NotContains(t, actions, "consume-or-expel-right")
767+ assert.NotContains(t, actions, "consume-or-expel-up")
768+ assert.NotContains(t, actions, "consume-or-expel-down")
762769 }
763770
764771 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
765772 m := NewMigrator()
766773
767- value := m.defaultValueForKey("workspace.shortcuts.actions.toggle_floating_pane")
774+ value := m.defaultValueForKey("workspace.shortcuts.actions.toggle-floating-pane")
768775
769776 action, ok := value.(ActionBinding)
770777 require.True(t, ok)
M internal/infrastructure/config/profile.go +9 -1
@@ -85,6 +85,8 @@ func ResolvePerformanceProfile(cfg *PerformanceConfig, hw *port.HardwareInfo) Re
8585 switch cfg.Profile {
8686 case ProfileLite:
8787 return resolveLiteProfile(hw)
88+ case ProfileBalanced:
89+ return resolveBalancedProfile()
8890 case ProfileMax:
8991 return resolveMaxProfile(hw)
9092 case ProfileCustom:
@@ -116,6 +118,12 @@ func resolveDefaultProfile() ResolvedPerformanceSettings {
116118 }
117119 }
118120
121+// resolveBalancedProfile preserves the legacy balanced profile as WebKit's
122+// default tuning with the normal WebView prewarm count.
123+func resolveBalancedProfile() ResolvedPerformanceSettings {
124+ return resolveDefaultProfile()
125+}
126+
119127 // resolveLiteProfile returns settings optimized for low-RAM systems.
120128 // Uses hardware info to scale CPU threads if available.
121129 func resolveLiteProfile(hw *port.HardwareInfo) ResolvedPerformanceSettings {
@@ -299,7 +307,7 @@ func HasCustomPerformanceFields(cfg *PerformanceConfig) bool {
299307 // IsValidPerformanceProfile returns true if the profile name is recognized.
300308 func IsValidPerformanceProfile(profile PerformanceProfile) bool {
301309 switch profile {
302- case ProfileDefault, ProfileLite, ProfileMax, ProfileCustom, "":
310+ case ProfileDefault, ProfileLite, ProfileBalanced, ProfileMax, ProfileCustom, "":
303311 return true
304312 default:
305313 return false
M internal/infrastructure/config/profile_test.go +17 -0
@@ -44,6 +44,22 @@ func TestResolvePerformanceProfile_Empty(t *testing.T) {
4444 }
4545 }
4646
47+func TestResolvePerformanceProfile_Balanced(t *testing.T) {
48+ cfg := &PerformanceConfig{Profile: ProfileBalanced}
49+
50+ result := ResolvePerformanceProfile(cfg, nil)
51+
52+ if result.SkiaCPUPaintingThreads != 0 {
53+ t.Errorf("expected SkiaCPUPaintingThreads=0, got %d", result.SkiaCPUPaintingThreads)
54+ }
55+ if result.SkiaGPUPaintingThreads != -1 {
56+ t.Errorf("expected SkiaGPUPaintingThreads=-1, got %d", result.SkiaGPUPaintingThreads)
57+ }
58+ if result.WebViewPoolPrewarmCount != 4 {
59+ t.Errorf("expected WebViewPoolPrewarmCount=4, got %d", result.WebViewPoolPrewarmCount)
60+ }
61+}
62+
4763 func TestResolvePerformanceProfile_Lite_NoHardware(t *testing.T) {
4864 cfg := &PerformanceConfig{
4965 Profile: ProfileLite,
@@ -317,6 +333,7 @@ func TestIsValidPerformanceProfile(t *testing.T) {
317333 }{
318334 {ProfileDefault, true},
319335 {ProfileLite, true},
336+ {ProfileBalanced, true},
320337 {ProfileMax, true},
321338 {ProfileCustom, true},
322339 {"", true}, // Empty is valid (treated as default)
M internal/infrastructure/config/schema.go +3 -1
@@ -170,6 +170,8 @@ const (
170170 ProfileDefault PerformanceProfile = "default"
171171 // ProfileLite reduces resource usage for low-RAM systems.
172172 ProfileLite PerformanceProfile = "lite"
173+ // ProfileBalanced is a conservative compatibility preset between lite and max.
174+ ProfileBalanced PerformanceProfile = "balanced"
173175 // ProfileMax maximizes responsiveness for heavy pages (GitHub PRs, etc).
174176 ProfileMax PerformanceProfile = "max"
175177 // ProfileCustom allows full control over individual performance settings.
@@ -226,7 +228,7 @@ type HistoryConfig struct {
226228 // These are not exposed in dumb://config UI but can be set in config file.
227229 type PerformanceConfig struct {
228230 // Profile selects a preset performance configuration.
229- // Values: "default" (no tuning), "lite" (low RAM), "max" (heavy pages), "custom" (manual control)
231+ // Values: "default" (no tuning), "lite" (low RAM), "balanced" (compatibility), "max" (heavy pages), "custom" (manual control)
230232 // When profile is not "custom", individual tuning fields below are ignored and computed from the profile.
231233 // Default: "default"
232234 Profile PerformanceProfile `mapstructure:"profile" yaml:"profile" toml:"profile"`
M internal/infrastructure/config/schema_provider.go +1 -1
@@ -905,7 +905,7 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
905905 Type: "string",
906906 Default: string(defaults.Engine.Profile),
907907 Description: "Performance profile preset (custom enables manual tuning)",
908- Values: []string{"default", "lite", "max", "custom"},
908+ Values: []string{"default", "lite", "balanced", "max", "custom"},
909909 Section: SectionPerformance,
910910 },
911911 {
A internal/infrastructure/config/systemview_reader.go +41 -0
@@ -0,0 +1,41 @@
1+package config
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+type systemviewConfigReader struct {
11+ hwSurveyor port.HardwareSurveyor
12+}
13+
14+var _ port.SystemviewConfigReader = (*systemviewConfigReader)(nil)
15+
16+// NewSystemviewConfigReader creates a read-side config payload reader.
17+func NewSystemviewConfigReader(hwSurveyor port.HardwareSurveyor) port.SystemviewConfigReader {
18+ return &systemviewConfigReader{hwSurveyor: hwSurveyor}
19+}
20+
21+func (r *systemviewConfigReader) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
22+ return r.build(ctx, Get())
23+}
24+
25+func (r *systemviewConfigReader) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
26+ return r.build(ctx, DefaultConfig())
27+}
28+
29+func (r *systemviewConfigReader) build(ctx context.Context, cfg *Config) (dto.SystemviewConfigPayload, error) {
30+ if cfg == nil {
31+ return dto.SystemviewConfigPayload{}, nil
32+ }
33+
34+ var hw *port.HardwareInfo
35+ if r.hwSurveyor != nil {
36+ survey := r.hwSurveyor.Survey(ctx)
37+ hw = &survey
38+ }
39+
40+ return BuildSystemviewConfigPayload(cfg, hw), nil
41+}
M internal/infrastructure/config/validation.go +1 -1
@@ -483,7 +483,7 @@ func validatePerformanceProfile(config *Config) []string {
483483 // Validate profile name
484484 if !IsValidPerformanceProfile(config.Engine.Profile) {
485485 validationErrors = append(validationErrors, fmt.Sprintf(
486- "engine.profile must be one of: default, lite, max, custom (got: %s)",
486+ "engine.profile must be one of: default, lite, balanced, max, custom (got: %s)",
487487 config.Engine.Profile,
488488 ))
489489 }
M internal/infrastructure/config/webui_gateway.go +2 -2
@@ -4,7 +4,7 @@ import (
44 "context"
55 "fmt"
66
7- "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/application/dto"
88 )
99
1010 type WebUIConfigGateway struct {
@@ -33,7 +33,7 @@ func clampInt(v, lo, hi int) int {
3333 return v
3434 }
3535
36-func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error {
36+func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error {
3737 _ = ctx
3838 if g == nil || g.mgr == nil {
3939 return fmt.Errorf("config manager not initialized")
M internal/infrastructure/config/webui_payload.go +78 -95
@@ -1,118 +1,101 @@
11 package config
22
3-import "github.com/bnema/dumber/internal/application/port"
3+import (
4+ "github.com/bnema/dumber/internal/application/dto"
5+ "github.com/bnema/dumber/internal/application/port"
6+)
47
5-// WebUIConfigAppearancePayload is the JSON shape expected by dumb://config.
6-type WebUIConfigAppearancePayload struct {
7- SansFont string `json:"sans_font"`
8- SerifFont string `json:"serif_font"`
9- MonospaceFont string `json:"monospace_font"`
10- DefaultFontSize int `json:"default_font_size"`
11- ColorScheme string `json:"color_scheme"`
12- LightPalette ColorPalette `json:"light_palette"`
13- DarkPalette ColorPalette `json:"dark_palette"`
14-}
8+// BuildSystemviewConfigPayload projects the full config into the stable DTO used by
9+// the settings page across engines.
10+func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) dto.SystemviewConfigPayload {
11+ payload := dto.SystemviewConfigPayload{
12+ SearchShortcuts: map[string]dto.SearchShortcut{},
13+ }
14+ if cfg == nil {
15+ return payload
16+ }
1517
16-// WebUIConfigHardwarePayload exposes detected hardware to the settings UI.
17-type WebUIConfigHardwarePayload struct {
18- CPUCores int `json:"cpu_cores"`
19- CPUThreads int `json:"cpu_threads"`
20- TotalRAMMB int `json:"total_ram_mb"`
21- GPUVendor string `json:"gpu_vendor"`
22- GPUName string `json:"gpu_name"`
23- VRAMMB int `json:"vram_mb"`
24-}
18+ perfCfg := PerformanceConfigFromEngine(&cfg.Engine)
19+ resolved := ResolvePerformanceProfile(&perfCfg, hw)
2520
26-// WebUIConfigCustomPerformancePayload holds user-editable custom profile values.
27-type WebUIConfigCustomPerformancePayload struct {
28- SkiaCPUThreads int `json:"skia_cpu_threads"`
29- SkiaGPUThreads int `json:"skia_gpu_threads"`
30- WebProcessMemoryMB int `json:"web_process_memory_mb"`
31- NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
32- WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
33-}
21+ payload.Appearance = buildSystemviewAppearancePayload(cfg.Appearance)
22+ payload.DefaultUIScale = cfg.DefaultUIScale
23+ payload.DefaultSearchEngine = cfg.DefaultSearchEngine
24+ payload.SearchShortcuts = buildSystemviewSearchShortcutsPayload(cfg.SearchShortcuts)
25+ payload.EngineType = cfg.Engine.ResolveEngineType()
26+ payload.Performance = dto.SystemviewPerformancePayload{
27+ Profile: string(cfg.Engine.Profile),
28+ Custom: dto.SystemviewCustomPerformancePayload{
29+ SkiaCPUThreads: cfg.Engine.WebKit.SkiaCPUPaintingThreads,
30+ SkiaGPUThreads: cfg.Engine.WebKit.SkiaGPUPaintingThreads,
31+ WebProcessMemoryMB: cfg.Engine.WebKit.WebProcessMemoryLimitMB,
32+ NetworkProcessMemoryMB: cfg.Engine.WebKit.NetworkProcessMemoryLimitMB,
33+ WebViewPoolPrewarm: cfg.Engine.PoolPrewarmCount,
34+ },
35+ Resolved: dto.SystemviewResolvedPerformancePayload{
36+ SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,
37+ SkiaGPUThreads: resolved.SkiaGPUPaintingThreads,
38+ WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB,
39+ NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB,
40+ WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount,
41+ ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold,
42+ StrictThreshold: resolved.WebProcessMemoryStrictThreshold,
43+ },
44+ Hardware: buildSystemviewHardwarePayload(hw),
45+ }
3446
35-// WebUIConfigResolvedPerformancePayload holds the resolved effective values.
36-type WebUIConfigResolvedPerformancePayload struct {
37- SkiaCPUThreads int `json:"skia_cpu_threads"`
38- SkiaGPUThreads int `json:"skia_gpu_threads"`
39- WebProcessMemoryMB int `json:"web_process_memory_mb"`
40- NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
41- WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
42- ConservativeThreshold float64 `json:"conservative_threshold"`
43- StrictThreshold float64 `json:"strict_threshold"`
47+ return payload
4448 }
4549
46-// WebUIConfigPerformancePayload is the performance tab payload expected by the UI.
47-type WebUIConfigPerformancePayload struct {
48- Profile string `json:"profile"`
49- Custom WebUIConfigCustomPerformancePayload `json:"custom"`
50- Resolved WebUIConfigResolvedPerformancePayload `json:"resolved"`
51- Hardware WebUIConfigHardwarePayload `json:"hardware"`
50+func buildSystemviewAppearancePayload(appearance AppearanceConfig) dto.WebUIAppearanceConfig {
51+ defaults := DefaultConfig().Appearance
52+ return dto.WebUIAppearanceConfig{
53+ SansFont: appearance.SansFont,
54+ SerifFont: appearance.SerifFont,
55+ MonospaceFont: appearance.MonospaceFont,
56+ DefaultFontSize: appearance.DefaultFontSize,
57+ ColorScheme: appearance.ColorScheme,
58+ LightPalette: buildSystemviewPalettePayload(appearance.LightPalette, defaults.LightPalette),
59+ DarkPalette: buildSystemviewPalettePayload(appearance.DarkPalette, defaults.DarkPalette),
60+ }
5261 }
5362
54-// WebUIConfigPayload is the full JSON shape expected by dumb://config.
55-type WebUIConfigPayload struct {
56- Appearance WebUIConfigAppearancePayload `json:"appearance"`
57- Performance WebUIConfigPerformancePayload `json:"performance"`
58- DefaultUIScale float64 `json:"default_ui_scale"`
59- DefaultSearchEngine string `json:"default_search_engine"`
60- SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
61- EngineType string `json:"engine_type"`
63+func buildSystemviewPalettePayload(palette, fallback ColorPalette) dto.ColorPalette {
64+ return dto.ColorPalette{
65+ Background: nonEmptyColor(palette.Background, fallback.Background),
66+ Surface: nonEmptyColor(palette.Surface, fallback.Surface),
67+ SurfaceVariant: nonEmptyColor(palette.SurfaceVariant, fallback.SurfaceVariant),
68+ Text: nonEmptyColor(palette.Text, fallback.Text),
69+ Muted: nonEmptyColor(palette.Muted, fallback.Muted),
70+ Accent: nonEmptyColor(palette.Accent, fallback.Accent),
71+ Border: nonEmptyColor(palette.Border, fallback.Border),
72+ }
6273 }
6374
64-// BuildWebUIConfigPayload projects the full config into the stable DTO used by
65-// the settings page across engines.
66-func BuildWebUIConfigPayload(cfg *Config, hw *port.HardwareInfo) WebUIConfigPayload {
67- if cfg == nil {
68- return WebUIConfigPayload{}
75+func nonEmptyColor(value, fallback string) string {
76+ if value != "" {
77+ return value
6978 }
79+ return fallback
80+}
7081
71- perfCfg := PerformanceConfigFromEngine(&cfg.Engine)
72- resolved := ResolvePerformanceProfile(&perfCfg, hw)
82+func buildSystemviewSearchShortcutsPayload(shortcuts map[string]SearchShortcut) map[string]dto.SearchShortcut {
83+ if len(shortcuts) == 0 {
84+ return map[string]dto.SearchShortcut{}
85+ }
7386
74- return WebUIConfigPayload{
75- DefaultUIScale: cfg.DefaultUIScale,
76- DefaultSearchEngine: cfg.DefaultSearchEngine,
77- SearchShortcuts: cfg.SearchShortcuts,
78- EngineType: cfg.Engine.ResolveEngineType(),
79- Appearance: WebUIConfigAppearancePayload{
80- SansFont: cfg.Appearance.SansFont,
81- SerifFont: cfg.Appearance.SerifFont,
82- MonospaceFont: cfg.Appearance.MonospaceFont,
83- DefaultFontSize: cfg.Appearance.DefaultFontSize,
84- ColorScheme: cfg.Appearance.ColorScheme,
85- LightPalette: cfg.Appearance.LightPalette,
86- DarkPalette: cfg.Appearance.DarkPalette,
87- },
88- Performance: WebUIConfigPerformancePayload{
89- Profile: string(cfg.Engine.Profile),
90- Custom: WebUIConfigCustomPerformancePayload{
91- SkiaCPUThreads: cfg.Engine.WebKit.SkiaCPUPaintingThreads,
92- SkiaGPUThreads: cfg.Engine.WebKit.SkiaGPUPaintingThreads,
93- WebProcessMemoryMB: cfg.Engine.WebKit.WebProcessMemoryLimitMB,
94- NetworkProcessMemoryMB: cfg.Engine.WebKit.NetworkProcessMemoryLimitMB,
95- WebViewPoolPrewarm: cfg.Engine.PoolPrewarmCount,
96- },
97- Resolved: WebUIConfigResolvedPerformancePayload{
98- SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,
99- SkiaGPUThreads: resolved.SkiaGPUPaintingThreads,
100- WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB,
101- NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB,
102- WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount,
103- ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold,
104- StrictThreshold: resolved.WebProcessMemoryStrictThreshold,
105- },
106- Hardware: buildWebUIHardwarePayload(hw),
107- },
87+ result := make(map[string]dto.SearchShortcut, len(shortcuts))
88+ for key, shortcut := range shortcuts {
89+ result[key] = dto.SearchShortcut{URL: shortcut.URL, Description: shortcut.Description}
10890 }
91+ return result
10992 }
11093
111-func buildWebUIHardwarePayload(hw *port.HardwareInfo) WebUIConfigHardwarePayload {
94+func buildSystemviewHardwarePayload(hw *port.HardwareInfo) dto.SystemviewHardwarePayload {
11295 if hw == nil {
113- return WebUIConfigHardwarePayload{}
96+ return dto.SystemviewHardwarePayload{}
11497 }
115- return WebUIConfigHardwarePayload{
98+ return dto.SystemviewHardwarePayload{
11699 CPUCores: hw.CPUCores,
117100 CPUThreads: hw.CPUThreads,
118101 TotalRAMMB: hw.TotalRAMMB(),
A internal/infrastructure/config/webui_payload_test.go +27 -0
@@ -0,0 +1,27 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func TestBuildSystemviewConfigPayload_EmptySearchShortcutsUsesEmptyMap(t *testing.T) {
10+ cfg := DefaultConfig()
11+ cfg.SearchShortcuts = map[string]SearchShortcut{}
12+ payload := BuildSystemviewConfigPayload(cfg, nil)
13+ require.NotNil(t, payload.SearchShortcuts)
14+ require.Empty(t, payload.SearchShortcuts)
15+}
16+
17+func TestBuildSystemviewConfigPayload_FillsMissingPaletteValuesFromDefaults(t *testing.T) {
18+ cfg := DefaultConfig()
19+ cfg.Appearance.LightPalette.Background = ""
20+ cfg.Appearance.DarkPalette.Accent = ""
21+
22+ payload := BuildSystemviewConfigPayload(cfg, nil)
23+ defaults := DefaultConfig()
24+
25+ require.Equal(t, defaults.Appearance.LightPalette.Background, payload.Appearance.LightPalette.Background)
26+ require.Equal(t, defaults.Appearance.DarkPalette.Accent, payload.Appearance.DarkPalette.Accent)
27+}
M internal/infrastructure/handlers/config.go +11 -6
@@ -5,18 +5,19 @@ import (
55 "encoding/json"
66 "fmt"
77
8+ "github.com/bnema/dumber/internal/application/dto"
89 "github.com/bnema/dumber/internal/application/port"
910 "github.com/bnema/dumber/internal/logging"
1011 )
1112
1213 // ConfigHandler handles configuration-related messages.
1314 type ConfigHandler struct {
14- saveConfig func(context.Context, port.WebUIConfig) error
15+ saveConfig func(context.Context, dto.WebUIConfig) error
1516 }
1617
1718 // NewConfigHandler creates a new ConfigHandler.
1819 // saveConfig is called to persist config changes (typically usecase.SaveWebUIConfigUseCase.Execute).
19-func NewConfigHandler(saveConfig func(context.Context, port.WebUIConfig) error) *ConfigHandler {
20+func NewConfigHandler(saveConfig func(context.Context, dto.WebUIConfig) error) *ConfigHandler {
2021 if saveConfig == nil {
2122 panic("NewConfigHandler: saveConfig must not be nil")
2223 }
@@ -24,7 +25,7 @@ func NewConfigHandler(saveConfig func(context.Context, port.WebUIConfig) error)
2425 }
2526
2627 // Handle processes the save_config message.
27-func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
28+func (h *ConfigHandler) Handle(ctx context.Context, viewID port.WebViewID, payload json.RawMessage) (any, error) {
2829 if h == nil {
2930 return nil, fmt.Errorf("config handler is nil")
3031 }
@@ -33,13 +34,17 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload js
3334 }
3435 log := logging.FromContext(ctx).With().Str("handler", "config").Logger()
3536
36- var payloadCfg port.WebUIConfig
37+ var payloadCfg dto.WebUIConfig
3738 if err := json.Unmarshal(payload, &payloadCfg); err != nil {
3839 log.Error().Err(err).Msg("failed to unmarshal config payload")
3940 return nil, fmt.Errorf("invalid config format: %w", err)
4041 }
4142
42- log.Info().Msg("saving appearance configuration from webui")
43+ source := "webui"
44+ if viewID != 0 {
45+ source = fmt.Sprintf("webview:%d", viewID)
46+ }
47+ log.Info().Str("source", source).Msg("saving appearance configuration")
4348
4449 if err := h.saveConfig(ctx, payloadCfg); err != nil {
4550 log.Error().Err(err).Msg("failed to save config")
@@ -52,7 +57,7 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ port.WebViewID, payload js
5257 // RegisterConfigHandlers registers configuration handlers with the router.
5358 func RegisterConfigHandlers(
5459 ctx context.Context, router port.WebUIHandlerRouter,
55- saveConfig func(context.Context, port.WebUIConfig) error,
60+ saveConfig func(context.Context, dto.WebUIConfig) error,
5661 ) error {
5762 handler := NewConfigHandler(saveConfig)
5863
M internal/infrastructure/handlers/homepage/favorites.go +168 -4
@@ -3,12 +3,37 @@ package homepage
33 import (
44 "context"
55 "encoding/json"
6+ "fmt"
7+ "strings"
68
9+ "github.com/bnema/dumber/internal/application/dto"
710 "github.com/bnema/dumber/internal/application/port"
811 "github.com/bnema/dumber/internal/domain/entity"
912 "github.com/bnema/dumber/internal/logging"
1013 )
1114
15+func folderIDFromInt64(id *int64) (*entity.FolderID, error) {
16+ if id == nil {
17+ return nil, nil
18+ }
19+ if *id <= 0 {
20+ return nil, fmt.Errorf("folder id must be positive")
21+ }
22+ folderID := entity.FolderID(*id)
23+ return &folderID, nil
24+}
25+
26+func tagIDsFromInt64s(ids []int64) ([]entity.TagID, error) {
27+ out := make([]entity.TagID, 0, len(ids))
28+ for _, id := range ids {
29+ if id <= 0 {
30+ return nil, fmt.Errorf("tag id must be positive")
31+ }
32+ out = append(out, entity.TagID(id))
33+ }
34+ return out, nil
35+}
36+
1237 // FavoritesHandlers handles favorite-related messages from the homepage.
1338 type FavoritesHandlers struct {
1439 favoritesUC port.HomepageFavorites
@@ -39,6 +64,138 @@ func (h *FavoritesHandlers) HandleList() port.WebUIMessageHandler {
3964 })
4065 }
4166
67+type favoriteCreateRequest struct {
68+ RequestID string `json:"requestId"`
69+ URL string `json:"url"`
70+ Title string `json:"title"`
71+ FaviconURL string `json:"favicon_url"`
72+ FolderID *int64 `json:"folder_id"`
73+ Tags []int64 `json:"tags"`
74+}
75+
76+// HandleCreate handles favorite_create messages.
77+func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler {
78+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
79+ log := logging.FromContext(ctx)
80+
81+ var req favoriteCreateRequest
82+ if err := json.Unmarshal(payload, &req); err != nil {
83+ return NewErrorResponse("", err), nil
84+ }
85+
86+ log.Debug().
87+ Str("request_id", req.RequestID).
88+ Msg("handling favorite_create")
89+
90+ trimmedURL := strings.TrimSpace(req.URL)
91+ if trimmedURL == "" {
92+ return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil
93+ }
94+ folderID, err := folderIDFromInt64(req.FolderID)
95+ if err != nil {
96+ return NewErrorResponse(req.RequestID, err), nil
97+ }
98+ tags, err := tagIDsFromInt64s(req.Tags)
99+ if err != nil {
100+ return NewErrorResponse(req.RequestID, err), nil
101+ }
102+
103+ input := dto.FavoriteCreateInput{
104+ URL: trimmedURL,
105+ Title: req.Title,
106+ FaviconURL: req.FaviconURL,
107+ FolderID: folderID,
108+ Tags: tags,
109+ }
110+ favorite, err := h.favoritesUC.AddFavorite(ctx, input)
111+ if err != nil {
112+ return NewErrorResponse(req.RequestID, err), nil
113+ }
114+
115+ return NewSuccessResponse(req.RequestID, favorite), nil
116+ })
117+}
118+
119+type favoriteUpdateRequest struct {
120+ RequestID string `json:"requestId"`
121+ ID int64 `json:"id"`
122+ Title string `json:"title"`
123+ FaviconURL string `json:"favicon_url"`
124+ FolderID *int64 `json:"folder_id"`
125+ ShortcutKey *int `json:"shortcut_key"`
126+}
127+
128+// HandleUpdate handles favorite_update messages.
129+func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
130+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
131+ log := logging.FromContext(ctx)
132+
133+ var req favoriteUpdateRequest
134+ if err := json.Unmarshal(payload, &req); err != nil {
135+ return NewErrorResponse("", err), nil
136+ }
137+
138+ log.Debug().
139+ Str("request_id", req.RequestID).
140+ Int64("favorite_id", req.ID).
141+ Msg("handling favorite_update")
142+
143+ if req.ID <= 0 {
144+ return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil
145+ }
146+
147+ folderID, err := folderIDFromInt64(req.FolderID)
148+ if err != nil {
149+ return NewErrorResponse(req.RequestID, err), nil
150+ }
151+
152+ favorite, err := h.favoritesUC.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
153+ ID: entity.FavoriteID(req.ID),
154+ Title: req.Title,
155+ FaviconURL: req.FaviconURL,
156+ FolderID: folderID,
157+ ShortcutKey: req.ShortcutKey,
158+ })
159+ if err != nil {
160+ return NewErrorResponse(req.RequestID, err), nil
161+ }
162+
163+ return NewSuccessResponse(req.RequestID, favorite), nil
164+ })
165+}
166+
167+type favoriteDeleteRequest struct {
168+ RequestID string `json:"requestId"`
169+ ID int64 `json:"id"`
170+}
171+
172+// HandleDelete handles favorite_delete messages.
173+func (h *FavoritesHandlers) HandleDelete() port.WebUIMessageHandler {
174+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
175+ log := logging.FromContext(ctx)
176+
177+ var req favoriteDeleteRequest
178+ if err := json.Unmarshal(payload, &req); err != nil {
179+ return NewErrorResponse("", err), nil
180+ }
181+
182+ log.Debug().
183+ Str("request_id", req.RequestID).
184+ Int64("favorite_id", req.ID).
185+ Msg("handling favorite_delete")
186+
187+ if req.ID <= 0 {
188+ return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil
189+ }
190+
191+ if err := h.favoritesUC.DeleteFavorite(ctx, entity.FavoriteID(req.ID)); err != nil {
192+ return NewErrorResponse(req.RequestID, err), nil
193+ }
194+
195+ return NewSuccessResponse(req.RequestID, nil), nil
196+ })
197+}
198+
42199 // setShortcutRequest is the payload for favorite_set_shortcut messages.
43200 type setShortcutRequest struct {
44201 RequestID string `json:"requestId"`
@@ -61,6 +218,10 @@ func (h *FavoritesHandlers) HandleSetShortcut() port.WebUIMessageHandler {
61218 Int64("favorite_id", req.FavoriteID).
62219 Msg("handling favorite_set_shortcut")
63220
221+ if req.FavoriteID <= 0 {
222+ return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite id")), nil
223+ }
224+
64225 if err := h.favoritesUC.SetShortcut(ctx, entity.FavoriteID(req.FavoriteID), req.ShortcutKey); err != nil {
65226 return NewErrorResponse(req.RequestID, err), nil
66227 }
@@ -121,10 +282,13 @@ func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {
121282 Int64("favorite_id", req.FavoriteID).
122283 Msg("handling favorite_set_folder")
123284
124- var folderID *entity.FolderID
125- if req.FolderID != nil {
126- id := entity.FolderID(*req.FolderID)
127- folderID = &id
285+ if req.FavoriteID <= 0 {
286+ return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite_id")), nil
287+ }
288+
289+ folderID, err := folderIDFromInt64(req.FolderID)
290+ if err != nil {
291+ return NewErrorResponse(req.RequestID, err), nil
128292 }
129293
130294 if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderID); err != nil {
M internal/infrastructure/handlers/homepage/folders.go +13 -13
@@ -44,6 +44,7 @@ type createFolderRequest struct {
4444 RequestID string `json:"requestId"`
4545 Name string `json:"name"`
4646 Icon *string `json:"icon"`
47+ ParentID *int64 `json:"parent_id"`
4748 }
4849
4950 // HandleCreate handles folder_create messages.
@@ -61,17 +62,14 @@ func (h *FolderHandlers) HandleCreate() port.WebUIMessageHandler {
6162 Str("name", req.Name).
6263 Msg("handling folder_create")
6364
64- folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, nil)
65+ parentID, err := folderIDFromInt64(req.ParentID)
6566 if err != nil {
6667 return NewErrorResponse(req.RequestID, err), nil
6768 }
6869
69- // Set icon if provided and persist it
70- if req.Icon != nil && *req.Icon != "" {
71- folder.Icon = *req.Icon
72- if err := h.favoritesUC.UpdateFolder(ctx, folder.ID, folder.Name, folder.Icon); err != nil {
73- return NewErrorResponse(req.RequestID, err), nil
74- }
70+ folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, optionalStringValue(req.Icon), parentID)
71+ if err != nil {
72+ return NewErrorResponse(req.RequestID, err), nil
7573 }
7674
7775 return NewSuccessResponse(req.RequestID, folder), nil
@@ -117,6 +115,13 @@ type updateFolderRequest struct {
117115
118116 // HandleUpdate handles folder_update messages.
119117 // It delegates to the port.HomepageFavorites interface's UpdateFolder method.
118+func optionalStringValue(value *string) string {
119+ if value == nil {
120+ return ""
121+ }
122+ return *value
123+}
124+
120125 func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler {
121126 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
122127 log := logging.FromContext(ctx)
@@ -132,12 +137,7 @@ func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler {
132137 Str("name", req.Name).
133138 Msg("handling folder_update")
134139
135- icon := ""
136- if req.Icon != nil {
137- icon = *req.Icon
138- }
139-
140- if err := h.favoritesUC.UpdateFolder(ctx, entity.FolderID(req.ID), req.Name, icon); err != nil {
140+ if err := h.favoritesUC.UpdateFolder(ctx, entity.FolderID(req.ID), req.Name, optionalStringValue(req.Icon)); err != nil {
141141 return NewErrorResponse(req.RequestID, err), nil
142142 }
143143
M internal/infrastructure/handlers/homepage/history.go +120 -29
@@ -3,8 +3,11 @@ package homepage
33 import (
44 "context"
55 "encoding/json"
6+ "fmt"
7+ "strings"
68 "time"
79
10+ "github.com/bnema/dumber/internal/application/dto"
811 "github.com/bnema/dumber/internal/application/port"
912 "github.com/bnema/dumber/internal/domain/entity"
1013 "github.com/bnema/dumber/internal/logging"
@@ -27,6 +30,19 @@ type timelineRequest struct {
2730 Offset int `json:"offset"`
2831 }
2932
33+type timelineByDomainRequest struct {
34+ RequestID string `json:"requestId"`
35+ Domain string `json:"domain"`
36+ Limit int `json:"limit"`
37+ Offset int `json:"offset"`
38+}
39+
40+type timelineWindowRequest struct {
41+ RequestID string `json:"requestId"`
42+ Before string `json:"before"`
43+ Domain string `json:"domain"`
44+}
45+
3046 // HandleTimeline handles history_timeline messages.
3147 func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
3248 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
@@ -42,6 +58,12 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
4258 Int("limit", req.Limit).
4359 Int("offset", req.Offset).
4460 Msg("handling history_timeline")
61+ if req.Limit <= 0 {
62+ return NewErrorResponse(
63+ req.RequestID,
64+ fmt.Errorf("history_timeline requires a positive limit; use history_timeline_window for lazy history loading"),
65+ ), nil
66+ }
4567
4668 entries, err := h.historyUC.GetRecent(ctx, req.Limit, req.Offset)
4769 if err != nil {
@@ -52,6 +74,79 @@ func (h *HistoryHandlers) HandleTimeline() port.WebUIMessageHandler {
5274 })
5375 }
5476
77+// HandleTimelineByDomain handles history_timeline_by_domain messages.
78+func (h *HistoryHandlers) HandleTimelineByDomain() port.WebUIMessageHandler {
79+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
80+ log := logging.FromContext(ctx)
81+
82+ var req timelineByDomainRequest
83+ if err := json.Unmarshal(payload, &req); err != nil {
84+ return NewErrorResponse("", err), nil
85+ }
86+
87+ req.Domain = strings.TrimSpace(req.Domain)
88+ if req.Domain == "" {
89+ return NewErrorResponse(req.RequestID, fmt.Errorf("domain is required")), nil
90+ }
91+
92+ log.Debug().
93+ Str("request_id", req.RequestID).
94+ Str("domain", req.Domain).
95+ Int("limit", req.Limit).
96+ Int("offset", req.Offset).
97+ Msg("handling history_timeline_by_domain")
98+ if req.Limit <= 0 {
99+ return NewErrorResponse(
100+ req.RequestID,
101+ fmt.Errorf("history_timeline_by_domain requires a positive limit; use history_timeline_window for lazy history loading"),
102+ ), nil
103+ }
104+
105+ entries, err := h.historyUC.GetRecentByDomain(ctx, req.Domain, req.Limit, req.Offset)
106+ if err != nil {
107+ return NewErrorResponse(req.RequestID, err), nil
108+ }
109+
110+ return NewSuccessResponse(req.RequestID, entries), nil
111+ })
112+}
113+
114+// HandleTimelineWindow handles history_timeline_window messages.
115+func (h *HistoryHandlers) HandleTimelineWindow() port.WebUIMessageHandler {
116+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
117+ log := logging.FromContext(ctx)
118+
119+ var req timelineWindowRequest
120+ if err := json.Unmarshal(payload, &req); err != nil {
121+ return NewErrorResponse("", err), nil
122+ }
123+
124+ domain := strings.TrimSpace(req.Domain)
125+
126+ var before time.Time
127+ if strings.TrimSpace(req.Before) != "" {
128+ parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(req.Before))
129+ if err != nil {
130+ return NewErrorResponse(req.RequestID, fmt.Errorf("invalid history window cursor")), nil
131+ }
132+ before = parsed
133+ }
134+
135+ log.Debug().
136+ Str("request_id", req.RequestID).
137+ Str("domain", domain).
138+ Time("before", before).
139+ Msg("handling history_timeline_window")
140+
141+ window, err := h.historyUC.GetRecentWindow(ctx, before, domain)
142+ if err != nil {
143+ return NewErrorResponse(req.RequestID, err), nil
144+ }
145+
146+ return NewSuccessResponse(req.RequestID, window), nil
147+ })
148+}
149+
55150 // searchRequest is the payload for history_search_fts messages.
56151 type searchRequest struct {
57152 RequestID string `json:"requestId"`
@@ -74,7 +169,7 @@ func (h *HistoryHandlers) HandleSearchFTS() port.WebUIMessageHandler {
74169 Str("query", req.Query).
75170 Msg("handling history_search_fts")
76171
77- output, err := h.historyUC.Search(ctx, port.HistorySearchInput{
172+ output, err := h.historyUC.Search(ctx, dto.HistorySearchInput{
78173 Query: req.Query,
79174 Limit: req.Limit,
80175 })
@@ -137,21 +232,14 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
137232 return NewErrorResponse("", err), nil
138233 }
139234
235+ req.Range = strings.TrimSpace(req.Range)
140236 log.Debug().
141237 Str("request_id", req.RequestID).
142238 Str("range", req.Range).
143239 Msg("handling history_delete_range")
144240
145- before := rangeToTime(req.Range)
146- if before.IsZero() {
147- // "all" case - clear all
148- if err := h.historyUC.ClearAll(ctx); err != nil {
149- return NewErrorResponse(req.RequestID, err), nil
150- }
151- } else {
152- if err := h.historyUC.ClearOlderThan(ctx, before); err != nil {
153- return NewErrorResponse(req.RequestID, err), nil
154- }
241+ if err := h.historyUC.ClearRange(ctx, req.Range); err != nil {
242+ return NewErrorResponse(req.RequestID, err), nil
155243 }
156244
157245 return NewSuccessResponse(req.RequestID, nil), nil
@@ -177,24 +265,27 @@ func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler {
177265 })
178266 }
179267
180-// rangeToTime converts a range string to a time.Time cutoff.
181-// Returns zero time for "all" which means delete everything.
182-func rangeToTime(r string) time.Time {
183- now := time.Now()
184- switch r {
185- case "hour":
186- return now.Add(-time.Hour)
187- case "day":
188- return now.AddDate(0, 0, -1)
189- case "week":
190- return now.AddDate(0, 0, -7)
191- case "month":
192- return now.AddDate(0, -1, 0)
193- case "all":
194- return time.Time{} // Zero time signals "delete all"
195- default:
196- return now
197- }
268+// HandleStats handles history_stats messages.
269+func (h *HistoryHandlers) HandleStats() port.WebUIMessageHandler {
270+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
271+ log := logging.FromContext(ctx)
272+
273+ var req struct {
274+ RequestID string `json:"requestId"`
275+ }
276+ if err := json.Unmarshal(payload, &req); err != nil {
277+ return NewErrorResponse("", err), nil
278+ }
279+
280+ log.Debug().Str("request_id", req.RequestID).Msg("handling history_stats")
281+
282+ stats, err := h.historyUC.GetStats(ctx)
283+ if err != nil {
284+ return NewErrorResponse(req.RequestID, err), nil
285+ }
286+
287+ return NewSuccessResponse(req.RequestID, stats), nil
288+ })
198289 }
199290
200291 // HandleAnalytics handles history_analytics messages.
M internal/infrastructure/handlers/homepage/registry.go +6 -0
@@ -29,10 +29,13 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C
2929 // History handlers
3030 historyHandlers := NewHistoryHandlers(cfg.HistoryUC)
3131 handlers["history_timeline"] = historyHandlers.HandleTimeline()
32+ handlers["history_timeline_by_domain"] = historyHandlers.HandleTimelineByDomain()
33+ handlers["history_timeline_window"] = historyHandlers.HandleTimelineWindow()
3234 handlers["history_search_fts"] = historyHandlers.HandleSearchFTS()
3335 handlers["history_delete_entry"] = historyHandlers.HandleDeleteEntry()
3436 handlers["history_delete_range"] = historyHandlers.HandleDeleteRange()
3537 handlers["history_clear_all"] = historyHandlers.HandleClearAll()
38+ handlers["history_stats"] = historyHandlers.HandleStats()
3639 handlers["history_analytics"] = historyHandlers.HandleAnalytics()
3740 handlers["history_domain_stats"] = historyHandlers.HandleDomainStats()
3841 handlers["history_delete_domain"] = historyHandlers.HandleDeleteDomain()
@@ -40,6 +43,9 @@ func RegisterHandlers(ctx context.Context, router port.WebUIHandlerRouter, cfg C
4043 // Favorites handlers
4144 favoritesHandlers := NewFavoritesHandlers(cfg.FavoritesUC)
4245 handlers["favorite_list"] = favoritesHandlers.HandleList()
46+ handlers["favorite_create"] = favoritesHandlers.HandleCreate()
47+ handlers["favorite_update"] = favoritesHandlers.HandleUpdate()
48+ handlers["favorite_delete"] = favoritesHandlers.HandleDelete()
4349 handlers["favorite_set_shortcut"] = favoritesHandlers.HandleSetShortcut()
4450 handlers["favorite_get_by_shortcut"] = favoritesHandlers.HandleGetByShortcut()
4551 handlers["favorite_set_folder"] = favoritesHandlers.HandleSetFolder()
A internal/infrastructure/netguard/transcode.go +52 -0
@@ -0,0 +1,52 @@
1+package netguard
2+
3+import (
4+ "net"
5+ "net/netip"
6+)
7+
8+var blockedTranscodeIPPfxs = []netip.Prefix{
9+ netip.MustParsePrefix("0.0.0.0/8"),
10+ netip.MustParsePrefix("10.0.0.0/8"),
11+ netip.MustParsePrefix("100.64.0.0/10"),
12+ netip.MustParsePrefix("127.0.0.0/8"),
13+ netip.MustParsePrefix("169.254.0.0/16"),
14+ netip.MustParsePrefix("172.16.0.0/12"),
15+ netip.MustParsePrefix("192.0.0.0/24"),
16+ netip.MustParsePrefix("192.0.2.0/24"),
17+ netip.MustParsePrefix("192.168.0.0/16"),
18+ netip.MustParsePrefix("198.18.0.0/15"),
19+ netip.MustParsePrefix("198.51.100.0/24"),
20+ netip.MustParsePrefix("203.0.113.0/24"),
21+ netip.MustParsePrefix("224.0.0.0/4"),
22+ netip.MustParsePrefix("240.0.0.0/4"),
23+ netip.MustParsePrefix("::/128"),
24+ netip.MustParsePrefix("::1/128"),
25+ netip.MustParsePrefix("64:ff9b::/96"),
26+ netip.MustParsePrefix("64:ff9b:1::/48"),
27+ netip.MustParsePrefix("100::/64"),
28+ netip.MustParsePrefix("2001::/32"),
29+ netip.MustParsePrefix("2001:2::/48"),
30+ netip.MustParsePrefix("2001:db8::/32"),
31+ netip.MustParsePrefix("2002::/16"),
32+ netip.MustParsePrefix("fc00::/7"),
33+ netip.MustParsePrefix("fe80::/10"),
34+ netip.MustParsePrefix("ff00::/8"),
35+}
36+
37+// IsBlockedTranscodeIP reports whether ip belongs to a local, private,
38+// documentation, transition, or otherwise special-purpose range that a media
39+// transcoder must not fetch on behalf of web content.
40+func IsBlockedTranscodeIP(ip net.IP) bool {
41+ addr, ok := netip.AddrFromSlice(ip)
42+ if !ok {
43+ return true
44+ }
45+ addr = addr.Unmap()
46+ for _, prefix := range blockedTranscodeIPPfxs {
47+ if prefix.Contains(addr) {
48+ return true
49+ }
50+ }
51+ return false
52+}
A internal/infrastructure/netguard/transcode_test.go +50 -0
@@ -0,0 +1,50 @@
1+package netguard
2+
3+import (
4+ "net"
5+ "testing"
6+)
7+
8+func TestIsBlockedTranscodeIPRejectsSpecialPurposeRanges(t *testing.T) {
9+ tests := []string{
10+ "127.0.0.1",
11+ "10.0.0.1",
12+ "172.16.0.1",
13+ "192.168.1.1",
14+ "100.64.0.1",
15+ "198.18.0.1",
16+ "192.0.2.1",
17+ "169.254.169.254",
18+ "0.0.0.0",
19+ "64:ff9b::a00:1",
20+ "2001::1",
21+ "2001:db8::1",
22+ "2002:0a00:0001::1",
23+ "fc00::1",
24+ "fe80::1",
25+ "::ffff:127.0.0.1",
26+ }
27+ for _, rawIP := range tests {
28+ t.Run(rawIP, func(t *testing.T) {
29+ if !IsBlockedTranscodeIP(net.ParseIP(rawIP)) {
30+ t.Fatalf("IsBlockedTranscodeIP(%s) = false, want true", rawIP)
31+ }
32+ })
33+ }
34+}
35+
36+func TestIsBlockedTranscodeIPRejectsInvalidIP(t *testing.T) {
37+ if !IsBlockedTranscodeIP(net.ParseIP("")) {
38+ t.Fatal("IsBlockedTranscodeIP(invalid IP) = false, want true")
39+ }
40+}
41+
42+func TestIsBlockedTranscodeIPAllowsPublicAddress(t *testing.T) {
43+ for _, rawIP := range []string{"93.184.216.34", "2606:4700:4700::1111"} {
44+ t.Run(rawIP, func(t *testing.T) {
45+ if IsBlockedTranscodeIP(net.ParseIP(rawIP)) {
46+ t.Fatalf("IsBlockedTranscodeIP(%s) = true, want false", rawIP)
47+ }
48+ })
49+ }
50+}
M internal/infrastructure/persistence/sqlite/favorite_repo.go +62 -4
@@ -14,23 +14,28 @@ import (
1414 // ==================== Favorite Repository ====================
1515
1616 type favoriteRepo struct {
17+ db *sql.DB
1718 queries *sqlc.Queries
1819 }
1920
2021 // NewFavoriteRepository creates a new SQLite-backed favorite repository.
2122 func NewFavoriteRepository(db *sql.DB) repository.FavoriteRepository {
22- return &favoriteRepo{queries: sqlc.New(db)}
23+ return &favoriteRepo{db: db, queries: sqlc.New(db)}
2324 }
2425
2526 func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
2627 log := logging.FromContext(ctx)
27- log.Debug().Str("url", fav.URL).Msg("saving favorite")
28+ log.Debug().Str("url", fav.URL).Int64("id", int64(fav.ID)).Msg("saving favorite")
2829
2930 var folderID sql.NullInt64
3031 if fav.FolderID != nil {
3132 folderID = sql.NullInt64{Int64: int64(*fav.FolderID), Valid: true}
3233 }
3334
35+ if fav.ID > 0 {
36+ return r.updateExisting(ctx, fav, folderID)
37+ }
38+
3439 row, err := r.queries.CreateFavorite(ctx, sqlc.CreateFavoriteParams{
3540 Url: fav.URL,
3641 Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
@@ -44,6 +49,43 @@ func (r *favoriteRepo) Save(ctx context.Context, fav *entity.Favorite) error {
4449 return nil
4550 }
4651
52+func (r *favoriteRepo) updateExisting(ctx context.Context, fav *entity.Favorite, folderID sql.NullInt64) error {
53+ log := logging.FromContext(ctx)
54+ tx, err := r.db.BeginTx(ctx, nil)
55+ if err != nil {
56+ return err
57+ }
58+ defer func() {
59+ if rollbackErr := tx.Rollback(); rollbackErr != nil && !errors.Is(rollbackErr, sql.ErrTxDone) {
60+ log.Debug().Err(rollbackErr).Msg("favorite update rollback reported non-terminal error")
61+ }
62+ }()
63+
64+ shortcutKey := sql.NullInt64{}
65+ if fav.ShortcutKey != nil {
66+ shortcutKey = sql.NullInt64{Int64: int64(*fav.ShortcutKey), Valid: true}
67+ }
68+
69+ queries := r.queries.WithTx(tx)
70+ if err := queries.UpdateFavorite(ctx, sqlc.UpdateFavoriteParams{
71+ Title: sql.NullString{String: fav.Title, Valid: fav.Title != ""},
72+ FaviconUrl: sql.NullString{String: fav.FaviconURL, Valid: fav.FaviconURL != ""},
73+ ID: int64(fav.ID),
74+ }); err != nil {
75+ return err
76+ }
77+ if err := queries.SetFavoriteFolder(ctx, sqlc.SetFavoriteFolderParams{FolderID: folderID, ID: int64(fav.ID)}); err != nil {
78+ return err
79+ }
80+ if err := queries.SetFavoriteShortcut(ctx, sqlc.SetFavoriteShortcutParams{
81+ ShortcutKey: shortcutKey,
82+ ID: int64(fav.ID),
83+ }); err != nil {
84+ return err
85+ }
86+ return tx.Commit()
87+}
88+
4789 func (r *favoriteRepo) FindByID(ctx context.Context, id entity.FavoriteID) (*entity.Favorite, error) {
4890 row, err := r.queries.GetFavoriteByID(ctx, int64(id))
4991 if err != nil {
@@ -175,13 +217,21 @@ func NewFolderRepository(db *sql.DB) repository.FolderRepository {
175217
176218 func (r *folderRepo) Save(ctx context.Context, folder *entity.Folder) error {
177219 log := logging.FromContext(ctx)
178- log.Debug().Str("name", folder.Name).Msg("saving folder")
220+ log.Debug().Str("name", folder.Name).Int64("id", int64(folder.ID)).Msg("saving folder")
179221
180222 var parentID sql.NullInt64
181223 if folder.ParentID != nil {
182224 parentID = sql.NullInt64{Int64: int64(*folder.ParentID), Valid: true}
183225 }
184226
227+ if folder.ID > 0 {
228+ return r.queries.UpdateFolder(ctx, sqlc.UpdateFolderParams{
229+ Name: folder.Name,
230+ Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""},
231+ ID: int64(folder.ID),
232+ })
233+ }
234+
185235 row, err := r.queries.CreateFolder(ctx, sqlc.CreateFolderParams{
186236 Name: folder.Name,
187237 Icon: sql.NullString{String: folder.Icon, Valid: folder.Icon != ""},
@@ -275,7 +325,15 @@ func NewTagRepository(db *sql.DB) repository.TagRepository {
275325
276326 func (r *tagRepo) Save(ctx context.Context, tag *entity.Tag) error {
277327 log := logging.FromContext(ctx)
278- log.Debug().Str("name", tag.Name).Msg("saving tag")
328+ log.Debug().Str("name", tag.Name).Int64("id", int64(tag.ID)).Msg("saving tag")
329+
330+ if tag.ID > 0 {
331+ return r.queries.UpdateTag(ctx, sqlc.UpdateTagParams{
332+ Name: tag.Name,
333+ Color: tag.Color,
334+ ID: int64(tag.ID),
335+ })
336+ }
279337
280338 row, err := r.queries.CreateTag(ctx, sqlc.CreateTagParams{
281339 Name: tag.Name,
M internal/infrastructure/persistence/sqlite/history_repo.go +106 -30
@@ -61,10 +61,12 @@ func (r *historyRepo) Save(ctx context.Context, entry *entity.HistoryEntry) erro
6161 log := logging.FromContext(ctx)
6262 log.Debug().Str("url", logging.TruncateURL(entry.URL, logURLMaxLen)).Msg("saving history entry")
6363
64+ domain := domainurl.CanonicalDomain(entry.URL)
6465 err := r.queries.UpsertHistory(ctx, sqlc.UpsertHistoryParams{
6566 Url: entry.URL,
6667 Title: sql.NullString{String: entry.Title, Valid: entry.Title != ""},
6768 FaviconUrl: sql.NullString{String: entry.FaviconURL, Valid: entry.FaviconURL != ""},
69+ Domain: sql.NullString{String: domain, Valid: domain != ""},
6870 })
6971 if err != nil {
7072 return err
@@ -481,6 +483,13 @@ func isExpectedFTSError(err error) bool {
481483 }
482484
483485 func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
486+ if limit <= 0 {
487+ rows, err := r.queries.GetAllRecentHistory(ctx)
488+ if err != nil {
489+ return nil, err
490+ }
491+ return historyEntriesFromRows(rows), nil
492+ }
484493 rows, err := r.queries.GetRecentHistory(ctx, sqlc.GetRecentHistoryParams{
485494 Limit: int64(limit),
486495 Offset: int64(offset),
@@ -488,12 +497,83 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti
488497 if err != nil {
489498 return nil, err
490499 }
500+ return historyEntriesFromRows(rows), nil
501+}
491502
492- entries := make([]*entity.HistoryEntry, len(rows))
493- for i := range rows {
494- entries[i] = historyFromRow(rows[i])
503+func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
504+ rawDomain := domain
505+ domain = domainurl.CanonicalDomain(domain)
506+ if domain == "" {
507+ return nil, fmt.Errorf("history domain is required: %q", rawDomain)
508+ }
509+ if limit <= 0 {
510+ rows, err := r.queries.GetAllRecentHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true})
511+ if err != nil {
512+ return nil, err
513+ }
514+ return historyEntriesFromRows(rows), nil
515+ }
516+ rows, err := r.queries.GetRecentHistoryByDomain(ctx, sqlc.GetRecentHistoryByDomainParams{
517+ Domain: sql.NullString{String: domain, Valid: true},
518+ Limit: int64(limit),
519+ Offset: int64(offset),
520+ })
521+ if err != nil {
522+ return nil, err
523+ }
524+ return historyEntriesFromRows(rows), nil
525+}
526+
527+func (r *historyRepo) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) {
528+ rows, err := r.queries.GetRecentHistoryWindow(ctx, sqlc.GetRecentHistoryWindowParams{
529+ Before: sql.NullTime{Time: before, Valid: true},
530+ After: sql.NullTime{Time: after, Valid: true},
531+ })
532+ if err != nil {
533+ return nil, err
534+ }
535+ return historyEntriesFromRows(rows), nil
536+}
537+
538+func (r *historyRepo) GetRecentWindowByDomain(ctx context.Context, domain string, before, after time.Time) ([]*entity.HistoryEntry, error) {
539+ rawDomain := domain
540+ domain = domainurl.CanonicalDomain(domain)
541+ if domain == "" {
542+ return nil, fmt.Errorf("history domain is required: %q", rawDomain)
543+ }
544+ rows, err := r.queries.GetRecentHistoryWindowByDomain(ctx, sqlc.GetRecentHistoryWindowByDomainParams{
545+ Domain: sql.NullString{String: domain, Valid: true},
546+ Before: sql.NullTime{Time: before, Valid: true},
547+ After: sql.NullTime{Time: after, Valid: true},
548+ })
549+ if err != nil {
550+ return nil, err
551+ }
552+ return historyEntriesFromRows(rows), nil
553+}
554+
555+func (r *historyRepo) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) {
556+ value, err := r.queries.HasHistoryBefore(ctx, sql.NullTime{Time: before, Valid: true})
557+ if err != nil {
558+ return false, err
495559 }
496- return entries, nil
560+ return value != 0, nil
561+}
562+
563+func (r *historyRepo) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) {
564+ rawDomain := domain
565+ domain = domainurl.CanonicalDomain(domain)
566+ if domain == "" {
567+ return false, fmt.Errorf("history domain is required: %q", rawDomain)
568+ }
569+ value, err := r.queries.HasHistoryByDomainBefore(ctx, sqlc.HasHistoryByDomainBeforeParams{
570+ Domain: sql.NullString{String: domain, Valid: true},
571+ Before: sql.NullTime{Time: before, Valid: true},
572+ })
573+ if err != nil {
574+ return false, err
575+ }
576+ return value != 0, nil
497577 }
498578
499579 func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
@@ -507,11 +587,7 @@ func (r *historyRepo) GetRecentSince(ctx context.Context, days int) ([]*entity.H
507587 return nil, err
508588 }
509589
510- entries := make([]*entity.HistoryEntry, len(rows))
511- for i := range rows {
512- entries[i] = historyFromRow(rows[i])
513- }
514- return entries, nil
590+ return historyEntriesFromRows(rows), nil
515591 }
516592
517593 func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
@@ -525,11 +601,7 @@ func (r *historyRepo) GetMostVisited(ctx context.Context, days int) ([]*entity.H
525601 return nil, err
526602 }
527603
528- entries := make([]*entity.HistoryEntry, len(rows))
529- for i := range rows {
530- entries[i] = historyFromRow(rows[i])
531- }
532- return entries, nil
604+ return historyEntriesFromRows(rows), nil
533605 }
534606
535607 func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.HistoryEntry, error) {
@@ -538,11 +610,7 @@ func (r *historyRepo) GetAllRecentHistory(ctx context.Context) ([]*entity.Histor
538610 return nil, err
539611 }
540612
541- entries := make([]*entity.HistoryEntry, len(rows))
542- for i := range rows {
543- entries[i] = historyFromRow(rows[i])
544- }
545- return entries, nil
613+ return historyEntriesFromRows(rows), nil
546614 }
547615
548616 func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryEntry, error) {
@@ -551,11 +619,7 @@ func (r *historyRepo) GetAllMostVisited(ctx context.Context) ([]*entity.HistoryE
551619 return nil, err
552620 }
553621
554- entries := make([]*entity.HistoryEntry, len(rows))
555- for i := range rows {
556- entries[i] = historyFromRow(rows[i])
557- }
558- return entries, nil
622+ return historyEntriesFromRows(rows), nil
559623 }
560624
561625 func (r *historyRepo) IncrementVisitCount(ctx context.Context, rawURL string) error {
@@ -588,17 +652,21 @@ func (r *historyRepo) DeleteOlderThan(ctx context.Context, before time.Time) err
588652 return r.queries.DeleteHistoryOlderThan(ctx, sql.NullTime{Time: before, Valid: true})
589653 }
590654
655+func (r *historyRepo) DeleteSince(ctx context.Context, since time.Time) error {
656+ return r.queries.DeleteHistorySince(ctx, sql.NullTime{Time: since, Valid: true})
657+}
658+
591659 func (r *historyRepo) DeleteAll(ctx context.Context) error {
592660 return r.queries.DeleteAllHistory(ctx)
593661 }
594662
595663 func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {
596- return r.queries.DeleteHistoryByDomain(ctx, sqlc.DeleteHistoryByDomainParams{
597- Column1: sql.NullString{String: domain, Valid: true},
598- Column2: sql.NullString{String: domain, Valid: true},
599- Column3: sql.NullString{String: domain, Valid: true},
600- Column4: sql.NullString{String: domain, Valid: true},
601- })
664+ rawDomain := domain
665+ domain = domainurl.CanonicalDomain(domain)
666+ if domain == "" {
667+ return fmt.Errorf("history domain is required: %q", rawDomain)
668+ }
669+ return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true})
602670 }
603671
604672 func (r *historyRepo) GetStats(ctx context.Context) (*entity.HistoryStats, error) {
@@ -694,6 +762,14 @@ func (r *historyRepo) GetDailyVisitCount(ctx context.Context, daysAgo string) ([
694762 return counts, nil
695763 }
696764
765+func historyEntriesFromRows(rows []sqlc.History) []*entity.HistoryEntry {
766+ entries := make([]*entity.HistoryEntry, len(rows))
767+ for i := range rows {
768+ entries[i] = historyFromRow(rows[i])
769+ }
770+ return entries
771+}
772+
697773 func historyFromRow(row sqlc.History) *entity.HistoryEntry {
698774 return &entity.HistoryEntry{
699775 ID: row.ID,
M internal/infrastructure/persistence/sqlite/history_repo_test.go +58 -0
@@ -191,6 +191,10 @@ func TestHistoryRepository_CRUD(t *testing.T) {
191191 require.NoError(t, err)
192192 require.Len(t, recent, 1)
193193
194+ allRecent, err := repo.GetRecent(ctx, 0, 0)
195+ require.NoError(t, err)
196+ require.Len(t, allRecent, 1)
197+
194198 // Delete
195199 require.NoError(t, repo.Delete(ctx, found.ID))
196200
@@ -832,3 +836,57 @@ func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T)
832836 require.NoError(t, err)
833837 assert.Empty(t, results)
834838 }
839+
840+func TestHistoryRepository_RejectsEmptyCanonicalDomain(t *testing.T) {
841+ ctx := historyTestCtx()
842+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
843+
844+ db, err := sqlite.NewConnection(ctx, dbPath)
845+ require.NoError(t, err)
846+ t.Cleanup(func() { _ = db.Close() })
847+
848+ repo := sqlite.NewHistoryRepository(db)
849+
850+ entries, err := repo.GetRecentByDomain(ctx, " ", 10, 0)
851+ require.Error(t, err)
852+ assert.Nil(t, entries)
853+ assert.Error(t, repo.DeleteByDomain(ctx, "/path"))
854+}
855+
856+func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.T) {
857+ ctx := historyTestCtx()
858+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
859+
860+ db, err := sqlite.NewConnection(ctx, dbPath)
861+ require.NoError(t, err)
862+ t.Cleanup(func() { _ = db.Close() })
863+
864+ repo := sqlite.NewHistoryRepository(db)
865+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://www.example.com/a", Title: "Example A"}))
866+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://example.com/b", Title: "Example B"}))
867+ require.NoError(t, repo.Save(ctx, &entity.HistoryEntry{URL: "https://other.test", Title: "Other"}))
868+
869+ exampleEntries, err := repo.GetRecentByDomain(ctx, "www.example.com", 0, 0)
870+ require.NoError(t, err)
871+ require.Len(t, exampleEntries, 2)
872+ for _, entry := range exampleEntries {
873+ assert.Contains(t, entry.URL, "example.com")
874+ }
875+
876+ stats, err := repo.GetDomainStats(ctx, 10)
877+ require.NoError(t, err)
878+ require.NotEmpty(t, stats)
879+ assert.Equal(t, "example.com", stats[0].Domain)
880+ assert.Equal(t, int64(2), stats[0].PageCount)
881+
882+ require.NoError(t, repo.DeleteByDomain(ctx, "example.com"))
883+
884+ exampleEntries, err = repo.GetRecentByDomain(ctx, "example.com", 10, 0)
885+ require.NoError(t, err)
886+ assert.Empty(t, exampleEntries)
887+
888+ remaining, err := repo.GetAllRecentHistory(ctx)
889+ require.NoError(t, err)
890+ require.Len(t, remaining, 1)
891+ assert.Equal(t, "https://other.test", remaining[0].URL)
892+}
M internal/infrastructure/persistence/sqlite/lazy_repos.go +47 -0
@@ -149,6 +149,46 @@ func (r *LazyHistoryRepository) GetRecent(ctx context.Context, limit, offset int
149149 return r.repo.GetRecent(ctx, limit, offset)
150150 }
151151
152+func (r *LazyHistoryRepository) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
153+ if err := r.init(ctx); err != nil {
154+ return nil, err
155+ }
156+ return r.repo.GetRecentByDomain(ctx, domain, limit, offset)
157+}
158+
159+func (r *LazyHistoryRepository) GetRecentWindow(ctx context.Context, before, after time.Time) ([]*entity.HistoryEntry, error) {
160+ if err := r.init(ctx); err != nil {
161+ return nil, err
162+ }
163+ return r.repo.GetRecentWindow(ctx, before, after)
164+}
165+
166+func (r *LazyHistoryRepository) GetRecentWindowByDomain(
167+ ctx context.Context,
168+ domain string,
169+ before time.Time,
170+ after time.Time,
171+) ([]*entity.HistoryEntry, error) {
172+ if err := r.init(ctx); err != nil {
173+ return nil, err
174+ }
175+ return r.repo.GetRecentWindowByDomain(ctx, domain, before, after)
176+}
177+
178+func (r *LazyHistoryRepository) HasEntriesBefore(ctx context.Context, before time.Time) (bool, error) {
179+ if err := r.init(ctx); err != nil {
180+ return false, err
181+ }
182+ return r.repo.HasEntriesBefore(ctx, before)
183+}
184+
185+func (r *LazyHistoryRepository) HasEntriesByDomainBefore(ctx context.Context, domain string, before time.Time) (bool, error) {
186+ if err := r.init(ctx); err != nil {
187+ return false, err
188+ }
189+ return r.repo.HasEntriesByDomainBefore(ctx, domain, before)
190+}
191+
152192 func (r *LazyHistoryRepository) GetRecentSince(ctx context.Context, days int) ([]*entity.HistoryEntry, error) {
153193 if err := r.init(ctx); err != nil {
154194 return nil, err
@@ -223,6 +263,13 @@ func (r *LazyHistoryRepository) DeleteOlderThan(ctx context.Context, before time
223263 return r.repo.DeleteOlderThan(ctx, before)
224264 }
225265
266+func (r *LazyHistoryRepository) DeleteSince(ctx context.Context, since time.Time) error {
267+ if err := r.init(ctx); err != nil {
268+ return err
269+ }
270+ return r.repo.DeleteSince(ctx, since)
271+}
272+
226273 func (r *LazyHistoryRepository) DeleteAll(ctx context.Context) error {
227274 if err := r.init(ctx); err != nil {
228275 return err
A internal/infrastructure/persistence/sqlite/migrations/008_history_domain.sql +90 -0
@@ -0,0 +1,90 @@
1+-- +goose Up
2+-- Store normalized history domains for indexed systemview filtering.
3+
4+ALTER TABLE history ADD COLUMN domain TEXT;
5+
6+UPDATE history
7+SET domain = LOWER(
8+ CASE
9+ WHEN INSTR(REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/'), '/') > 0 THEN
10+ SUBSTR(
11+ REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/'),
12+ 1,
13+ INSTR(REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/'), '/') - 1
14+ )
15+ ELSE REPLACE(REPLACE(SUBSTR(url, INSTR(url, '://') + 3), '?', '/'), '#', '/')
16+ END
17+)
18+WHERE INSTR(url, '://') > 0;
19+
20+-- Mirror domainurl.CanonicalDomain for legacy rows as closely as SQLite can:
21+-- strip userinfo first, then strip the leading www. alias.
22+UPDATE history
23+SET domain = SUBSTR(domain, INSTR(domain, '@') + 1)
24+WHERE INSTR(domain, '@') > 0;
25+
26+UPDATE history
27+SET domain = SUBSTR(domain, 5)
28+WHERE domain LIKE 'www.%';
29+
30+CREATE INDEX IF NOT EXISTS idx_history_domain_last_visited ON history(domain, last_visited DESC);
31+
32+-- +goose Down
33+DROP INDEX IF EXISTS idx_history_domain_last_visited;
34+
35+-- Rebuild instead of ALTER TABLE DROP COLUMN so rollback works on SQLite
36+-- versions before 3.35. Preserve the base history schema and indexes/triggers.
37+DROP TRIGGER IF EXISTS history_fts_update;
38+DROP TRIGGER IF EXISTS history_fts_delete;
39+DROP TRIGGER IF EXISTS history_fts_insert;
40+DROP TABLE IF EXISTS history_fts;
41+
42+CREATE TABLE history_008_down (
43+ id INTEGER PRIMARY KEY AUTOINCREMENT,
44+ url TEXT NOT NULL UNIQUE,
45+ title TEXT,
46+ favicon_url TEXT,
47+ visit_count INTEGER DEFAULT 1,
48+ last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
49+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
50+);
51+
52+INSERT INTO history_008_down (id, url, title, favicon_url, visit_count, last_visited, created_at)
53+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at
54+FROM history;
55+
56+DROP TABLE history;
57+ALTER TABLE history_008_down RENAME TO history;
58+
59+CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
60+CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
61+CREATE INDEX IF NOT EXISTS idx_history_visit_count_last_visited ON history(visit_count DESC, last_visited DESC);
62+
63+CREATE VIRTUAL TABLE IF NOT EXISTS history_fts USING fts5(
64+ url,
65+ title,
66+ content='history',
67+ content_rowid='id'
68+);
69+
70+-- +goose StatementBegin
71+CREATE TRIGGER IF NOT EXISTS history_fts_insert AFTER INSERT ON history BEGIN
72+ INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);
73+END;
74+-- +goose StatementEnd
75+
76+-- +goose StatementBegin
77+CREATE TRIGGER IF NOT EXISTS history_fts_delete AFTER DELETE ON history BEGIN
78+ INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);
79+END;
80+-- +goose StatementEnd
81+
82+-- +goose StatementBegin
83+CREATE TRIGGER IF NOT EXISTS history_fts_update AFTER UPDATE ON history BEGIN
84+ INSERT INTO history_fts(history_fts, rowid, url, title) VALUES('delete', OLD.id, OLD.url, OLD.title);
85+ INSERT INTO history_fts(rowid, url, title) VALUES (NEW.id, NEW.url, NEW.title);
86+END;
87+-- +goose StatementEnd
88+
89+INSERT INTO history_fts(rowid, url, title)
90+SELECT id, url, title FROM history;
M internal/infrastructure/persistence/sqlite/queries/history.sql +66 -10
@@ -1,18 +1,46 @@
11 -- name: GetRecentHistory :many
22 SELECT * FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?;
33
4+-- name: GetRecentHistoryByDomain :many
5+SELECT * FROM history
6+WHERE domain = @domain
7+ORDER BY last_visited DESC
8+LIMIT @limit OFFSET @offset;
9+
10+-- name: GetAllRecentHistoryByDomain :many
11+SELECT * FROM history
12+WHERE domain = @domain
13+ORDER BY last_visited DESC;
14+
15+-- name: GetRecentHistoryWindow :many
16+SELECT * FROM history
17+WHERE last_visited < @before AND last_visited >= @after
18+ORDER BY last_visited DESC;
19+
20+-- name: GetRecentHistoryWindowByDomain :many
21+SELECT * FROM history
22+WHERE domain = @domain AND last_visited < @before AND last_visited >= @after
23+ORDER BY last_visited DESC;
24+
25+-- name: HasHistoryBefore :one
26+SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < @before LIMIT 1);
27+
28+-- name: HasHistoryByDomainBefore :one
29+SELECT EXISTS(SELECT 1 FROM history WHERE domain = @domain AND last_visited < @before LIMIT 1);
30+
431 -- name: GetHistoryByURL :one
532 SELECT * FROM history WHERE url = ? LIMIT 1;
633
734 -- name: UpsertHistory :exec
8-INSERT INTO history (url, title, favicon_url)
9-VALUES (?, ?, ?)
35+INSERT INTO history (url, title, favicon_url, domain)
36+VALUES (?, ?, ?, ?)
1037 ON CONFLICT(url)
1138 DO UPDATE SET
1239 visit_count = visit_count + 1,
1340 last_visited = CURRENT_TIMESTAMP,
1441 title = COALESCE(EXCLUDED.title, history.title),
15- favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url);
42+ favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url),
43+ domain = COALESCE(EXCLUDED.domain, history.domain);
1644
1745 -- name: IncrementVisitCount :exec
1846 UPDATE history
@@ -36,26 +64,54 @@ DELETE FROM history WHERE id = ?;
3664 -- name: DeleteHistoryOlderThan :exec
3765 DELETE FROM history WHERE last_visited < ?;
3866
67+-- name: DeleteHistorySince :exec
68+DELETE FROM history WHERE last_visited >= ?;
69+
3970 -- name: DeleteAllHistory :exec
4071 DELETE FROM history;
4172
4273 -- name: GetHistoryStats :one
43-SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history;
74+SELECT
75+ COUNT(*) as total_entries,
76+ COALESCE(SUM(visit_count), 0) as total_visits,
77+ COUNT(DISTINCT date(last_visited)) as unique_days
78+FROM history;
4479
4580 -- name: GetDomainStats :many
46-SELECT SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, CASE WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1 ELSE LENGTH(SUBSTR(url, INSTR(url, '://') + 3)) END) as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history GROUP BY domain ORDER BY total_visits DESC LIMIT ?;
81+SELECT
82+ COALESCE(domain, '') as domain,
83+ COUNT(*) as page_count,
84+ SUM(visit_count) as total_visits,
85+ MAX(last_visited) as last_visit
86+FROM history
87+WHERE domain IS NOT NULL AND domain != ''
88+GROUP BY domain
89+ORDER BY total_visits DESC
90+LIMIT ?;
4791
4892 -- name: GetHourlyDistribution :many
49-SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour;
93+SELECT
94+ CAST(strftime('%H', last_visited) AS INTEGER) as hour,
95+ COUNT(*) as visit_count
96+FROM history
97+GROUP BY hour
98+ORDER BY hour;
5099
51100 -- name: GetDailyVisitCount :many
52-SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC;
101+SELECT
102+ date(last_visited) as day,
103+ COUNT(*) as entries,
104+ SUM(visit_count) as visits
105+FROM history
106+WHERE last_visited >= date('now', ?)
107+GROUP BY day
108+ORDER BY day ASC;
53109
54110 -- name: DeleteHistoryByDomain :exec
55-DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?;
111+DELETE FROM history WHERE domain = @domain;
56112
57113 -- name: SearchHistoryFTSUrl :many
58-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
114+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
59115 FROM history_fts fts
60116 JOIN history h ON fts.rowid = h.id
61117 WHERE fts.url MATCH @query
@@ -78,7 +134,7 @@ ORDER BY domain_boost DESC, h.visit_count DESC, h.last_visited DESC
78134 LIMIT @limit;
79135
80136 -- name: SearchHistoryFTSTitle :many
81-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
137+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
82138 FROM history_fts fts
83139 JOIN history h ON fts.rowid = h.id
84140 WHERE fts.title MATCH @query
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +267 -32
@@ -35,23 +35,11 @@ func (q *Queries) DeleteAllHistory(ctx context.Context) error {
3535 }
3636
3737 const DeleteHistoryByDomain = `-- name: DeleteHistoryByDomain :exec
38-DELETE FROM history WHERE url LIKE '%://' || ? || '/%' OR url LIKE '%://' || ? || '?%' OR url LIKE '%://' || ? || '#%' OR url LIKE '%://' || ?
38+DELETE FROM history WHERE domain = ?1
3939 `
4040
41-type DeleteHistoryByDomainParams struct {
42- Column1 sql.NullString `json:"column_1"`
43- Column2 sql.NullString `json:"column_2"`
44- Column3 sql.NullString `json:"column_3"`
45- Column4 sql.NullString `json:"column_4"`
46-}
47-
48-func (q *Queries) DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error {
49- _, err := q.db.ExecContext(ctx, DeleteHistoryByDomain,
50- arg.Column1,
51- arg.Column2,
52- arg.Column3,
53- arg.Column4,
54- )
41+func (q *Queries) DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error {
42+ _, err := q.db.ExecContext(ctx, DeleteHistoryByDomain, domain)
5543 return err
5644 }
5745
@@ -73,8 +61,17 @@ func (q *Queries) DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.Nu
7361 return err
7462 }
7563
64+const DeleteHistorySince = `-- name: DeleteHistorySince :exec
65+DELETE FROM history WHERE last_visited >= ?
66+`
67+
68+func (q *Queries) DeleteHistorySince(ctx context.Context, lastVisited sql.NullTime) error {
69+ _, err := q.db.ExecContext(ctx, DeleteHistorySince, lastVisited)
70+ return err
71+}
72+
7673 const GetAllMostVisited = `-- name: GetAllMostVisited :many
77-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
74+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
7875 ORDER BY visit_count DESC, last_visited DESC
7976 `
8077
@@ -95,6 +92,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) {
9592 &i.VisitCount,
9693 &i.LastVisited,
9794 &i.CreatedAt,
95+ &i.Domain,
9896 ); err != nil {
9997 return nil, err
10098 }
@@ -110,7 +108,7 @@ func (q *Queries) GetAllMostVisited(ctx context.Context) ([]History, error) {
110108 }
111109
112110 const GetAllRecentHistory = `-- name: GetAllRecentHistory :many
113-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
111+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
114112 ORDER BY last_visited DESC
115113 `
116114
@@ -131,6 +129,45 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
131129 &i.VisitCount,
132130 &i.LastVisited,
133131 &i.CreatedAt,
132+ &i.Domain,
133+ ); err != nil {
134+ return nil, err
135+ }
136+ items = append(items, i)
137+ }
138+ if err := rows.Close(); err != nil {
139+ return nil, err
140+ }
141+ if err := rows.Err(); err != nil {
142+ return nil, err
143+ }
144+ return items, nil
145+}
146+
147+const GetAllRecentHistoryByDomain = `-- name: GetAllRecentHistoryByDomain :many
148+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
149+WHERE domain = ?1
150+ORDER BY last_visited DESC
151+`
152+
153+func (q *Queries) GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error) {
154+ rows, err := q.db.QueryContext(ctx, GetAllRecentHistoryByDomain, domain)
155+ if err != nil {
156+ return nil, err
157+ }
158+ defer rows.Close()
159+ items := []History{}
160+ for rows.Next() {
161+ var i History
162+ if err := rows.Scan(
163+ &i.ID,
164+ &i.Url,
165+ &i.Title,
166+ &i.FaviconUrl,
167+ &i.VisitCount,
168+ &i.LastVisited,
169+ &i.CreatedAt,
170+ &i.Domain,
134171 ); err != nil {
135172 return nil, err
136173 }
@@ -146,7 +183,14 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
146183 }
147184
148185 const GetDailyVisitCount = `-- name: GetDailyVisitCount :many
149-SELECT date(last_visited) as day, COUNT(*) as entries, SUM(visit_count) as visits FROM history WHERE last_visited >= date('now', ?) GROUP BY day ORDER BY day ASC
186+SELECT
187+ date(last_visited) as day,
188+ COUNT(*) as entries,
189+ SUM(visit_count) as visits
190+FROM history
191+WHERE last_visited >= date('now', ?)
192+GROUP BY day
193+ORDER BY day ASC
150194 `
151195
152196 type GetDailyVisitCountRow struct {
@@ -179,7 +223,16 @@ func (q *Queries) GetDailyVisitCount(ctx context.Context, date interface{}) ([]G
179223 }
180224
181225 const GetDomainStats = `-- name: GetDomainStats :many
182-SELECT SUBSTR(SUBSTR(url, INSTR(url, '://') + 3), 1, CASE WHEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') > 0 THEN INSTR(SUBSTR(url, INSTR(url, '://') + 3), '/') - 1 ELSE LENGTH(SUBSTR(url, INSTR(url, '://') + 3)) END) as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history GROUP BY domain ORDER BY total_visits DESC LIMIT ?
226+SELECT
227+ COALESCE(domain, '') as domain,
228+ COUNT(*) as page_count,
229+ SUM(visit_count) as total_visits,
230+ MAX(last_visited) as last_visit
231+FROM history
232+WHERE domain IS NOT NULL AND domain != ''
233+GROUP BY domain
234+ORDER BY total_visits DESC
235+LIMIT ?
183236 `
184237
185238 type GetDomainStatsRow struct {
@@ -218,7 +271,7 @@ func (q *Queries) GetDomainStats(ctx context.Context, limit int64) ([]GetDomainS
218271 }
219272
220273 const GetHistoryByURL = `-- name: GetHistoryByURL :one
221-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history WHERE url = ? LIMIT 1
274+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history WHERE url = ? LIMIT 1
222275 `
223276
224277 func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, error) {
@@ -232,12 +285,17 @@ func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, err
232285 &i.VisitCount,
233286 &i.LastVisited,
234287 &i.CreatedAt,
288+ &i.Domain,
235289 )
236290 return i, err
237291 }
238292
239293 const GetHistoryStats = `-- name: GetHistoryStats :one
240-SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history
294+SELECT
295+ COUNT(*) as total_entries,
296+ COALESCE(SUM(visit_count), 0) as total_visits,
297+ COUNT(DISTINCT date(last_visited)) as unique_days
298+FROM history
241299 `
242300
243301 type GetHistoryStatsRow struct {
@@ -254,7 +312,12 @@ func (q *Queries) GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, erro
254312 }
255313
256314 const GetHourlyDistribution = `-- name: GetHourlyDistribution :many
257-SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour
315+SELECT
316+ CAST(strftime('%H', last_visited) AS INTEGER) as hour,
317+ COUNT(*) as visit_count
318+FROM history
319+GROUP BY hour
320+ORDER BY hour
258321 `
259322
260323 type GetHourlyDistributionRow struct {
@@ -286,7 +349,7 @@ func (q *Queries) GetHourlyDistribution(ctx context.Context) ([]GetHourlyDistrib
286349 }
287350
288351 const GetMostVisited = `-- name: GetMostVisited :many
289-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
352+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
290353 WHERE last_visited >= datetime('now', ?)
291354 ORDER BY visit_count DESC, last_visited DESC
292355 `
@@ -308,6 +371,7 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H
308371 &i.VisitCount,
309372 &i.LastVisited,
310373 &i.CreatedAt,
374+ &i.Domain,
311375 ); err != nil {
312376 return nil, err
313377 }
@@ -323,7 +387,7 @@ func (q *Queries) GetMostVisited(ctx context.Context, datetime interface{}) ([]H
323387 }
324388
325389 const GetRecentHistory = `-- name: GetRecentHistory :many
326-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
390+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history ORDER BY last_visited DESC LIMIT ? OFFSET ?
327391 `
328392
329393 type GetRecentHistoryParams struct {
@@ -348,6 +412,52 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
348412 &i.VisitCount,
349413 &i.LastVisited,
350414 &i.CreatedAt,
415+ &i.Domain,
416+ ); err != nil {
417+ return nil, err
418+ }
419+ items = append(items, i)
420+ }
421+ if err := rows.Close(); err != nil {
422+ return nil, err
423+ }
424+ if err := rows.Err(); err != nil {
425+ return nil, err
426+ }
427+ return items, nil
428+}
429+
430+const GetRecentHistoryByDomain = `-- name: GetRecentHistoryByDomain :many
431+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
432+WHERE domain = ?1
433+ORDER BY last_visited DESC
434+LIMIT ?3 OFFSET ?2
435+`
436+
437+type GetRecentHistoryByDomainParams struct {
438+ Domain sql.NullString `json:"domain"`
439+ Offset int64 `json:"offset"`
440+ Limit int64 `json:"limit"`
441+}
442+
443+func (q *Queries) GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error) {
444+ rows, err := q.db.QueryContext(ctx, GetRecentHistoryByDomain, arg.Domain, arg.Offset, arg.Limit)
445+ if err != nil {
446+ return nil, err
447+ }
448+ defer rows.Close()
449+ items := []History{}
450+ for rows.Next() {
451+ var i History
452+ if err := rows.Scan(
453+ &i.ID,
454+ &i.Url,
455+ &i.Title,
456+ &i.FaviconUrl,
457+ &i.VisitCount,
458+ &i.LastVisited,
459+ &i.CreatedAt,
460+ &i.Domain,
351461 ); err != nil {
352462 return nil, err
353463 }
@@ -363,7 +473,7 @@ func (q *Queries) GetRecentHistory(ctx context.Context, arg GetRecentHistoryPara
363473 }
364474
365475 const GetRecentHistorySince = `-- name: GetRecentHistorySince :many
366-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
476+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
367477 WHERE last_visited >= datetime('now', ?)
368478 ORDER BY last_visited DESC
369479 `
@@ -385,6 +495,94 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{
385495 &i.VisitCount,
386496 &i.LastVisited,
387497 &i.CreatedAt,
498+ &i.Domain,
499+ ); err != nil {
500+ return nil, err
501+ }
502+ items = append(items, i)
503+ }
504+ if err := rows.Close(); err != nil {
505+ return nil, err
506+ }
507+ if err := rows.Err(); err != nil {
508+ return nil, err
509+ }
510+ return items, nil
511+}
512+
513+const GetRecentHistoryWindow = `-- name: GetRecentHistoryWindow :many
514+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
515+WHERE last_visited < ?1 AND last_visited >= ?2
516+ORDER BY last_visited DESC
517+`
518+
519+type GetRecentHistoryWindowParams struct {
520+ Before sql.NullTime `json:"before"`
521+ After sql.NullTime `json:"after"`
522+}
523+
524+func (q *Queries) GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error) {
525+ rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindow, arg.Before, arg.After)
526+ if err != nil {
527+ return nil, err
528+ }
529+ defer rows.Close()
530+ items := []History{}
531+ for rows.Next() {
532+ var i History
533+ if err := rows.Scan(
534+ &i.ID,
535+ &i.Url,
536+ &i.Title,
537+ &i.FaviconUrl,
538+ &i.VisitCount,
539+ &i.LastVisited,
540+ &i.CreatedAt,
541+ &i.Domain,
542+ ); err != nil {
543+ return nil, err
544+ }
545+ items = append(items, i)
546+ }
547+ if err := rows.Close(); err != nil {
548+ return nil, err
549+ }
550+ if err := rows.Err(); err != nil {
551+ return nil, err
552+ }
553+ return items, nil
554+}
555+
556+const GetRecentHistoryWindowByDomain = `-- name: GetRecentHistoryWindowByDomain :many
557+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
558+WHERE domain = ?1 AND last_visited < ?2 AND last_visited >= ?3
559+ORDER BY last_visited DESC
560+`
561+
562+type GetRecentHistoryWindowByDomainParams struct {
563+ Domain sql.NullString `json:"domain"`
564+ Before sql.NullTime `json:"before"`
565+ After sql.NullTime `json:"after"`
566+}
567+
568+func (q *Queries) GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error) {
569+ rows, err := q.db.QueryContext(ctx, GetRecentHistoryWindowByDomain, arg.Domain, arg.Before, arg.After)
570+ if err != nil {
571+ return nil, err
572+ }
573+ defer rows.Close()
574+ items := []History{}
575+ for rows.Next() {
576+ var i History
577+ if err := rows.Scan(
578+ &i.ID,
579+ &i.Url,
580+ &i.Title,
581+ &i.FaviconUrl,
582+ &i.VisitCount,
583+ &i.LastVisited,
584+ &i.CreatedAt,
585+ &i.Domain,
388586 ); err != nil {
389587 return nil, err
390588 }
@@ -399,6 +597,33 @@ func (q *Queries) GetRecentHistorySince(ctx context.Context, datetime interface{
399597 return items, nil
400598 }
401599
600+const HasHistoryBefore = `-- name: HasHistoryBefore :one
601+SELECT EXISTS(SELECT 1 FROM history WHERE last_visited < ?1 LIMIT 1)
602+`
603+
604+func (q *Queries) HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error) {
605+ row := q.db.QueryRowContext(ctx, HasHistoryBefore, before)
606+ var column_1 int64
607+ err := row.Scan(&column_1)
608+ return column_1, err
609+}
610+
611+const HasHistoryByDomainBefore = `-- name: HasHistoryByDomainBefore :one
612+SELECT EXISTS(SELECT 1 FROM history WHERE domain = ?1 AND last_visited < ?2 LIMIT 1)
613+`
614+
615+type HasHistoryByDomainBeforeParams struct {
616+ Domain sql.NullString `json:"domain"`
617+ Before sql.NullTime `json:"before"`
618+}
619+
620+func (q *Queries) HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error) {
621+ row := q.db.QueryRowContext(ctx, HasHistoryByDomainBefore, arg.Domain, arg.Before)
622+ var column_1 int64
623+ err := row.Scan(&column_1)
624+ return column_1, err
625+}
626+
402627 const IncrementVisitCount = `-- name: IncrementVisitCount :exec
403628 UPDATE history
404629 SET visit_count = visit_count + 1, last_visited = CURRENT_TIMESTAMP
@@ -427,7 +652,7 @@ func (q *Queries) IncrementVisitCountByDelta(ctx context.Context, arg IncrementV
427652 }
428653
429654 const SearchHistory = `-- name: SearchHistory :many
430-SELECT id, url, title, favicon_url, visit_count, last_visited, created_at FROM history
655+SELECT id, url, title, favicon_url, visit_count, last_visited, created_at, domain FROM history
431656 WHERE url LIKE '%' || ? || '%' OR title LIKE '%' || ? || '%'
432657 ORDER BY visit_count DESC, last_visited DESC
433658 LIMIT ?
@@ -456,6 +681,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([
456681 &i.VisitCount,
457682 &i.LastVisited,
458683 &i.CreatedAt,
684+ &i.Domain,
459685 ); err != nil {
460686 return nil, err
461687 }
@@ -471,7 +697,7 @@ func (q *Queries) SearchHistory(ctx context.Context, arg SearchHistoryParams) ([
471697 }
472698
473699 const SearchHistoryFTSTitle = `-- name: SearchHistoryFTSTitle :many
474-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
700+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
475701 FROM history_fts fts
476702 JOIN history h ON fts.rowid = h.id
477703 WHERE fts.title MATCH ?1
@@ -501,6 +727,7 @@ func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFT
501727 &i.VisitCount,
502728 &i.LastVisited,
503729 &i.CreatedAt,
730+ &i.Domain,
504731 ); err != nil {
505732 return nil, err
506733 }
@@ -516,7 +743,7 @@ func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFT
516743 }
517744
518745 const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many
519-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
746+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
520747 FROM history_fts fts
521748 JOIN history h ON fts.rowid = h.id
522749 WHERE fts.url MATCH ?1
@@ -546,6 +773,7 @@ func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSU
546773 &i.VisitCount,
547774 &i.LastVisited,
548775 &i.CreatedAt,
776+ &i.Domain,
549777 ); err != nil {
550778 return nil, err
551779 }
@@ -626,23 +854,30 @@ func (q *Queries) SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg Se
626854 }
627855
628856 const UpsertHistory = `-- name: UpsertHistory :exec
629-INSERT INTO history (url, title, favicon_url)
630-VALUES (?, ?, ?)
857+INSERT INTO history (url, title, favicon_url, domain)
858+VALUES (?, ?, ?, ?)
631859 ON CONFLICT(url)
632860 DO UPDATE SET
633861 visit_count = visit_count + 1,
634862 last_visited = CURRENT_TIMESTAMP,
635863 title = COALESCE(EXCLUDED.title, history.title),
636- favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url)
864+ favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url),
865+ domain = COALESCE(EXCLUDED.domain, history.domain)
637866 `
638867
639868 type UpsertHistoryParams struct {
640869 Url string `json:"url"`
641870 Title sql.NullString `json:"title"`
642871 FaviconUrl sql.NullString `json:"favicon_url"`
872+ Domain sql.NullString `json:"domain"`
643873 }
644874
645875 func (q *Queries) UpsertHistory(ctx context.Context, arg UpsertHistoryParams) error {
646- _, err := q.db.ExecContext(ctx, UpsertHistory, arg.Url, arg.Title, arg.FaviconUrl)
876+ _, err := q.db.ExecContext(ctx, UpsertHistory,
877+ arg.Url,
878+ arg.Title,
879+ arg.FaviconUrl,
880+ arg.Domain,
881+ )
647882 return err
648883 }
M internal/infrastructure/persistence/sqlite/sqlc/models.go +1 -0
@@ -56,6 +56,7 @@ type History struct {
5656 VisitCount sql.NullInt64 `json:"visit_count"`
5757 LastVisited sql.NullTime `json:"last_visited"`
5858 CreatedAt sql.NullTime `json:"created_at"`
59+ Domain sql.NullString `json:"domain"`
5960 }
6061
6162 type HistoryFt struct {
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +8 -1
@@ -21,9 +21,10 @@ type Querier interface {
2121 DeleteExitedSessionsBefore(ctx context.Context, endedAt sql.NullTime) (int64, error)
2222 DeleteFavorite(ctx context.Context, id int64) error
2323 DeleteFolder(ctx context.Context, id int64) error
24- DeleteHistoryByDomain(ctx context.Context, arg DeleteHistoryByDomainParams) error
24+ DeleteHistoryByDomain(ctx context.Context, domain sql.NullString) error
2525 DeleteHistoryByID(ctx context.Context, id int64) error
2626 DeleteHistoryOlderThan(ctx context.Context, lastVisited sql.NullTime) error
27+ DeleteHistorySince(ctx context.Context, lastVisited sql.NullTime) error
2728 // Deletes exited browser sessions beyond the keep limit, keeping the most recent ones.
2829 DeleteOldestExitedSessions(ctx context.Context, offset int64) (int64, error)
2930 DeletePermission(ctx context.Context, arg DeletePermissionParams) error
@@ -36,6 +37,7 @@ type Querier interface {
3637 GetAllFolders(ctx context.Context) ([]FavoriteFolder, error)
3738 GetAllMostVisited(ctx context.Context) ([]History, error)
3839 GetAllRecentHistory(ctx context.Context) ([]History, error)
40+ GetAllRecentHistoryByDomain(ctx context.Context, domain sql.NullString) ([]History, error)
3941 GetAllSessionStates(ctx context.Context) ([]SessionState, error)
4042 GetAllTags(ctx context.Context) ([]FavoriteTag, error)
4143 GetAllWhitelistedDomains(ctx context.Context) ([]string, error)
@@ -54,7 +56,10 @@ type Querier interface {
5456 GetMostVisited(ctx context.Context, datetime interface{}) ([]History, error)
5557 GetPermission(ctx context.Context, arg GetPermissionParams) (Permission, error)
5658 GetRecentHistory(ctx context.Context, arg GetRecentHistoryParams) ([]History, error)
59+ GetRecentHistoryByDomain(ctx context.Context, arg GetRecentHistoryByDomainParams) ([]History, error)
5760 GetRecentHistorySince(ctx context.Context, datetime interface{}) ([]History, error)
61+ GetRecentHistoryWindow(ctx context.Context, arg GetRecentHistoryWindowParams) ([]History, error)
62+ GetRecentHistoryWindowByDomain(ctx context.Context, arg GetRecentHistoryWindowByDomainParams) ([]History, error)
5863 GetRecentSessions(ctx context.Context, limit int64) ([]Session, error)
5964 GetRootFolders(ctx context.Context) ([]FavoriteFolder, error)
6065 GetSessionByID(ctx context.Context, id string) (Session, error)
@@ -65,6 +70,8 @@ type Querier interface {
6570 GetTagsForFavorite(ctx context.Context, favoriteID int64) ([]FavoriteTag, error)
6671 GetTotalSessionStatesSize(ctx context.Context) (interface{}, error)
6772 GetZoomLevel(ctx context.Context, domain string) (ZoomLevel, error)
73+ HasHistoryBefore(ctx context.Context, before sql.NullTime) (int64, error)
74+ HasHistoryByDomainBefore(ctx context.Context, arg HasHistoryByDomainBeforeParams) (int64, error)
6875 IncrementVisitCount(ctx context.Context, url string) error
6976 IncrementVisitCountByDelta(ctx context.Context, arg IncrementVisitCountByDeltaParams) error
7077 InsertSession(ctx context.Context, arg InsertSessionParams) error
A internal/infrastructure/systemviewsbridge/browser_transport.go +133 -0
@@ -0,0 +1,133 @@
1+package systemviewsbridge
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+)
9+
10+type browserRequest struct {
11+ messageType string
12+ requestID string
13+ body []byte
14+ directAPI bool
15+}
16+
17+func decodeBrowserRequest(body []byte) (browserRequest, error) {
18+ var envelope struct {
19+ Type string `json:"type"`
20+ Payload json.RawMessage `json:"payload"`
21+ }
22+ if err := json.Unmarshal(body, &envelope); err != nil {
23+ return browserRequest{}, fmt.Errorf("unmarshal browser request: %w", err)
24+ }
25+ if envelope.Type == "" {
26+ return browserRequest{}, fmt.Errorf("browser request missing type")
27+ }
28+
29+ var payload struct {
30+ RequestID string `json:"requestId"`
31+ }
32+ if len(envelope.Payload) != 0 {
33+ if err := json.Unmarshal(envelope.Payload, &payload); err != nil {
34+ return browserRequest{}, fmt.Errorf("unmarshal request payload: %w", err)
35+ }
36+ }
37+
38+ return browserRequest{
39+ messageType: envelope.Type,
40+ requestID: payload.RequestID,
41+ body: body,
42+ directAPI: strings.HasPrefix(envelope.Type, "/api/"),
43+ }, nil
44+}
45+
46+func wrapDirectAPIResponse(requestID string, payload []byte) ([]byte, error) {
47+ return json.Marshal(bridgeResponse{
48+ RequestID: requestID,
49+ Success: true,
50+ Data: json.RawMessage(payload),
51+ })
52+}
53+
54+// normalizeBridgeShimResponse accepts the bridge shapes emitted by native and
55+// fallback shims: a direct bridgeResponse envelope, a wrapper with data/error,
56+// or a wrapper whose data field itself contains a bridgeResponse envelope.
57+func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) {
58+ trimmed := bytes.TrimSpace(body)
59+ if len(trimmed) == 0 {
60+ return nil, fmt.Errorf("empty bridge shim response")
61+ }
62+
63+ var topLevel map[string]json.RawMessage
64+ if err := json.Unmarshal(trimmed, &topLevel); err != nil {
65+ return nil, fmt.Errorf("unmarshal top-level bridge response: %w", err)
66+ }
67+
68+ _, hasSuccess := topLevel["success"]
69+ _, hasRequestID := topLevel["requestId"]
70+ _, hasCallback := topLevel["_callback"]
71+ _, hasData := topLevel["data"]
72+
73+ // Native callbacks can already return the final bridgeResponse shape.
74+ if (hasSuccess || hasRequestID) && !hasCallback {
75+ var resp bridgeResponse
76+ if err := json.Unmarshal(trimmed, &resp); err != nil {
77+ return nil, fmt.Errorf("unmarshal direct bridge response: %w", err)
78+ }
79+ if resp.RequestID == "" {
80+ // Some direct shims omit requestId; preserve correlation for callers.
81+ resp.RequestID = requestID
82+ }
83+ return json.Marshal(resp)
84+ }
85+
86+ var wrapped struct {
87+ Data json.RawMessage `json:"data,omitempty"`
88+ Error string `json:"error,omitempty"`
89+ }
90+ if err := json.Unmarshal(trimmed, &wrapped); err != nil {
91+ return nil, fmt.Errorf("unmarshal wrapped bridge response: %w", err)
92+ }
93+ if wrapped.Error != "" {
94+ // Fetch-style wrappers report transport errors outside the data payload.
95+ return json.Marshal(bridgeResponse{RequestID: requestID, Error: wrapped.Error})
96+ }
97+ if !hasData {
98+ // Wrapped shim responses must carry either data or a top-level error.
99+ return nil, fmt.Errorf("wrapped bridge response missing data")
100+ }
101+ if len(wrapped.Data) == 0 {
102+ return json.Marshal(bridgeResponse{RequestID: requestID, Success: true})
103+ }
104+
105+ var nestedFields map[string]json.RawMessage
106+ if err := json.Unmarshal(wrapped.Data, &nestedFields); err == nil {
107+ nestedSuccess, nestedHasSuccess := nestedFields["success"]
108+ _, nestedHasRequestID := nestedFields["requestId"]
109+ _, nestedHasError := nestedFields["error"]
110+ successIsBool := false
111+ if nestedHasSuccess {
112+ var successValue bool
113+ successIsBool = json.Unmarshal(nestedSuccess, &successValue) == nil
114+ }
115+ hasNestedEnvelope := successIsBool || (nestedHasRequestID && nestedHasError)
116+ if hasNestedEnvelope {
117+ var nested bridgeResponse
118+ if err := json.Unmarshal(wrapped.Data, &nested); err == nil {
119+ if nested.RequestID == "" {
120+ // Nested envelopes from direct API wrappers also need caller correlation.
121+ nested.RequestID = requestID
122+ }
123+ return json.Marshal(nested)
124+ }
125+ }
126+ }
127+
128+ return json.Marshal(bridgeResponse{
129+ RequestID: requestID,
130+ Success: true,
131+ Data: wrapped.Data,
132+ })
133+}
A internal/infrastructure/systemviewsbridge/browser_transport_js.go +484 -0
@@ -0,0 +1,484 @@
1+//go:build js && wasm
2+
3+package systemviewsbridge
4+
5+import (
6+ "bytes"
7+ "context"
8+ "encoding/json"
9+ "errors"
10+ "fmt"
11+ "strings"
12+ "sync"
13+ "syscall/js"
14+)
15+
16+type callbackPlan struct {
17+ success string
18+ failure string
19+}
20+
21+func NewBrowserClient() *Client {
22+ return NewClient(&webkitTransport{}, &browserTransport{})
23+}
24+
25+func callbackPlanForMessage(msgType string) (callbackPlan, bool) {
26+ switch msgType {
27+ case "history_timeline", "history_timeline_by_domain", "history_timeline_window", "history_search_fts", "history_delete_entry", "history_delete_range", "history_stats", "history_analytics",
28+ "history_domain_stats", "history_delete_domain", "favorite_list", "favorite_create", "favorite_update", "favorite_delete", "folder_list", "tag_list",
29+ "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete",
30+ "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove":
31+ return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true
32+ case "save_config":
33+ return callbackPlan{success: "__dumber_config_saved", failure: "__dumber_config_error"}, true
34+ case "get_keybindings":
35+ return callbackPlan{success: "__dumber_keybindings_loaded", failure: "__dumber_keybindings_error"}, true
36+ case "set_keybinding":
37+ return callbackPlan{success: "__dumber_keybinding_set", failure: "__dumber_keybinding_set_error"}, true
38+ case "reset_keybinding":
39+ return callbackPlan{success: "__dumber_keybinding_reset", failure: "__dumber_keybinding_reset_error"}, true
40+ case "reset_all_keybindings":
41+ return callbackPlan{success: "__dumber_keybindings_reset_all", failure: "__dumber_keybindings_reset_all_error"}, true
42+ default:
43+ return callbackPlan{}, false
44+ }
45+}
46+
47+type browserTransport struct{}
48+
49+func (*browserTransport) Available() bool {
50+ window := js.Global().Get("window")
51+ if !window.Truthy() {
52+ return false
53+ }
54+ bridge := window.Get("dumber")
55+ return bridge.Truthy() && bridge.Get("postMessage").Truthy()
56+}
57+
58+func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
59+ req, err := decodeBrowserRequest(body)
60+ if err != nil {
61+ return nil, err
62+ }
63+ if req.directAPI {
64+ return fetchDirectAPI(ctx, req.requestID, req.messageType)
65+ }
66+
67+ window := js.Global().Get("window")
68+ bridge := window.Get("dumber")
69+ if !bridge.Truthy() || !bridge.Get("postMessage").Truthy() {
70+ return nil, errors.New("browser bridge shim not available")
71+ }
72+
73+ promise := bridge.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))
74+ value, err := awaitPromise(ctx, promise)
75+ if err != nil {
76+ return nil, err
77+ }
78+ raw, err := stringifyValue(value)
79+ if err != nil {
80+ return nil, err
81+ }
82+ return normalizeBridgeShimResponse(req.requestID, raw)
83+}
84+
85+type webkitTransport struct{}
86+
87+type webkitCallbackResult struct {
88+ body []byte
89+ err error
90+}
91+
92+type webkitCallbackWaiter struct {
93+ requestID string
94+ successName string
95+ failureName string
96+ resultCh chan webkitCallbackResult
97+}
98+
99+// webkitCallbacks uses one page-lifetime dispatcher per native callback name.
100+// Multiple requests may share callback names concurrently. When native payloads
101+// include requestId, dispatch to that waiter; older callbacks without requestId
102+// are resolved FIFO so they cannot target released js.Func handles.
103+var webkitCallbacks = struct {
104+ sync.Mutex
105+ installed map[string]js.Func
106+ waiters map[string][]*webkitCallbackWaiter
107+}{
108+ installed: make(map[string]js.Func),
109+ waiters: make(map[string][]*webkitCallbackWaiter),
110+}
111+
112+func (*webkitTransport) Available() bool {
113+ window := js.Global().Get("window")
114+ if !window.Truthy() {
115+ return false
116+ }
117+ webkit := window.Get("webkit")
118+ if !webkit.Truthy() {
119+ return false
120+ }
121+ handlers := webkit.Get("messageHandlers")
122+ if !handlers.Truthy() {
123+ return false
124+ }
125+ return handlers.Get("dumber").Truthy()
126+}
127+
128+func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) {
129+ req, err := decodeBrowserRequest(body)
130+ if err != nil {
131+ return nil, err
132+ }
133+ if req.directAPI {
134+ return fetchDirectAPI(ctx, req.requestID, req.messageType)
135+ }
136+
137+ plan, ok := callbackPlanForMessage(req.messageType)
138+ if !ok {
139+ return nil, fmt.Errorf("no WebKit callback plan for message type %q", req.messageType)
140+ }
141+
142+ window := js.Global().Get("window")
143+ handler := window.Get("webkit").Get("messageHandlers").Get("dumber")
144+ if !handler.Truthy() {
145+ return nil, errors.New("webkit message handler not available")
146+ }
147+
148+ ensureWebKitCallback(window, plan.success, false)
149+ ensureWebKitCallback(window, plan.failure, true)
150+
151+ resultCh := make(chan webkitCallbackResult, 1)
152+ unregister, err := registerWebKitWaiter(req.requestID, plan, resultCh)
153+ if err != nil {
154+ return nil, err
155+ }
156+
157+ handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body)))
158+
159+ select {
160+ case res := <-resultCh:
161+ return res.body, res.err
162+ case <-ctx.Done():
163+ unregister()
164+ return nil, ctx.Err()
165+ }
166+}
167+
168+func ensureWebKitCallback(window js.Value, name string, failure bool) {
169+ webkitCallbacks.Lock()
170+ if _, ok := webkitCallbacks.installed[name]; ok {
171+ webkitCallbacks.Unlock()
172+ return
173+ }
174+ callback := js.FuncOf(func(_ js.Value, args []js.Value) any {
175+ handleWebKitCallback(name, failure, args)
176+ return nil
177+ })
178+ webkitCallbacks.installed[name] = callback
179+ // Installed callbacks are page-lifetime dispatchers. Per-request waiters are
180+ // removed on completion/cancellation, so late native responses are dropped
181+ // without invoking released js.Func handles.
182+ window.Set(name, callback)
183+ webkitCallbacks.Unlock()
184+}
185+
186+func registerWebKitWaiter(requestID string, plan callbackPlan, resultCh chan webkitCallbackResult) (func(), error) {
187+ waiter := &webkitCallbackWaiter{
188+ requestID: requestID,
189+ successName: plan.success,
190+ failureName: plan.failure,
191+ resultCh: resultCh,
192+ }
193+ webkitCallbacks.Lock()
194+ defer webkitCallbacks.Unlock()
195+ webkitCallbacks.waiters[plan.success] = append(webkitCallbacks.waiters[plan.success], waiter)
196+ webkitCallbacks.waiters[plan.failure] = append(webkitCallbacks.waiters[plan.failure], waiter)
197+ return func() {
198+ webkitCallbacks.Lock()
199+ defer webkitCallbacks.Unlock()
200+ removeWebKitWaiterLocked(waiter)
201+ }, nil
202+}
203+
204+func findWebKitWaiterLocked(name, requestID string) *webkitCallbackWaiter {
205+ waiters := webkitCallbacks.waiters[name]
206+ if len(waiters) == 0 {
207+ return nil
208+ }
209+ if requestID == "" {
210+ return waiters[0]
211+ }
212+ for _, waiter := range waiters {
213+ if waiter != nil && waiter.requestID == requestID {
214+ return waiter
215+ }
216+ }
217+ return nil
218+}
219+
220+func removeWebKitWaiterLocked(waiter *webkitCallbackWaiter) {
221+ if waiter == nil {
222+ return
223+ }
224+ remove := func(name string) {
225+ waiters := webkitCallbacks.waiters[name]
226+ for i, candidate := range waiters {
227+ if candidate != waiter {
228+ continue
229+ }
230+ waiters = append(waiters[:i], waiters[i+1:]...)
231+ if len(waiters) == 0 {
232+ delete(webkitCallbacks.waiters, name)
233+ } else {
234+ webkitCallbacks.waiters[name] = waiters
235+ }
236+ return
237+ }
238+ }
239+ remove(waiter.successName)
240+ remove(waiter.failureName)
241+}
242+
243+func handleWebKitCallback(name string, failure bool, args []js.Value) {
244+ payload, payloadErr := stringifyArgs(args)
245+ payloadRequestID := requestIDFromBridgePayload(payload)
246+
247+ webkitCallbacks.Lock()
248+ waiter := findWebKitWaiterLocked(name, payloadRequestID)
249+ if waiter != nil {
250+ removeWebKitWaiterLocked(waiter)
251+ }
252+ webkitCallbacks.Unlock()
253+
254+ if waiter == nil {
255+ warnDroppedBridgeResult("webkit callback without waiter", nil)
256+ return
257+ }
258+
259+ res := webkitCallbackResult{err: payloadErr}
260+ if payloadErr == nil {
261+ if failure {
262+ res.body, res.err = normalizeNativeErrorResponse(waiter.requestID, payload)
263+ } else {
264+ res.body, res.err = normalizeNativeSuccessResponse(waiter.requestID, payload)
265+ }
266+ }
267+ select {
268+ case waiter.resultCh <- res:
269+ default:
270+ warnDroppedBridgeResult("webkit callback", res.err)
271+ }
272+}
273+
274+func requestIDFromBridgePayload(payload []byte) string {
275+ if len(bytes.TrimSpace(payload)) == 0 {
276+ return ""
277+ }
278+ var envelope struct {
279+ RequestID string `json:"requestId"`
280+ }
281+ if err := json.Unmarshal(payload, &envelope); err != nil {
282+ return ""
283+ }
284+ return envelope.RequestID
285+}
286+
287+func fetchDirectAPI(ctx context.Context, requestID, endpoint string) ([]byte, error) {
288+ fetch := js.Global().Get("fetch")
289+ if !fetch.Truthy() {
290+ return nil, errors.New("fetch API not available")
291+ }
292+ response, err := awaitPromise(ctx, fetch.Invoke(endpoint))
293+ if err != nil {
294+ return nil, err
295+ }
296+ if !response.Get("ok").Bool() {
297+ status := response.Get("status").Int()
298+ statusText := response.Get("statusText").String()
299+ if statusText == "" {
300+ statusText = "request failed"
301+ }
302+ return nil, fmt.Errorf("direct API %s failed: %d %s", endpoint, status, statusText)
303+ }
304+ data, err := awaitPromise(ctx, response.Call("json"))
305+ if err != nil {
306+ return nil, err
307+ }
308+ raw, err := stringifyValue(data)
309+ if err != nil {
310+ return nil, err
311+ }
312+ return wrapDirectAPIResponse(requestID, raw)
313+}
314+
315+func normalizeNativeSuccessResponse(requestID string, payload []byte) ([]byte, error) {
316+ trimmed := bytes.TrimSpace(payload)
317+ if len(trimmed) == 0 {
318+ return json.Marshal(bridgeResponse{RequestID: requestID, Success: true})
319+ }
320+
321+ var resp bridgeResponse
322+ if err := json.Unmarshal(trimmed, &resp); err == nil {
323+ hasEnvelope := resp.Success || resp.RequestID != "" || resp.Error != ""
324+ if hasEnvelope {
325+ if resp.RequestID == "" {
326+ resp.RequestID = requestID
327+ }
328+ return json.Marshal(resp)
329+ }
330+ }
331+
332+ return json.Marshal(bridgeResponse{RequestID: requestID, Success: true, Data: json.RawMessage(trimmed)})
333+}
334+
335+func normalizeNativeErrorResponse(requestID string, payload []byte) ([]byte, error) {
336+ trimmed := bytes.TrimSpace(payload)
337+ var message string
338+ if len(trimmed) == 0 {
339+ message = "bridge request failed"
340+ } else if err := json.Unmarshal(trimmed, &message); err != nil {
341+ message = strings.Trim(string(trimmed), `"`)
342+ }
343+ if message == "" {
344+ message = "bridge request failed"
345+ }
346+ return json.Marshal(bridgeResponse{RequestID: requestID, Error: message})
347+}
348+
349+func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) {
350+ if ctx == nil {
351+ ctx = context.Background()
352+ }
353+ type result struct {
354+ value js.Value
355+ err error
356+ }
357+ resultCh := make(chan result, 1)
358+
359+ promiseCtor := js.Global().Get("Promise")
360+ if !promiseCtor.Truthy() {
361+ return js.Undefined(), errors.New("Promise global not available")
362+ }
363+ var (
364+ cancelMu sync.Mutex
365+ reject = js.Undefined()
366+ )
367+ executor := js.FuncOf(func(_ js.Value, args []js.Value) any {
368+ if len(args) >= 2 {
369+ cancelMu.Lock()
370+ reject = args[1]
371+ cancelMu.Unlock()
372+ }
373+ return nil
374+ })
375+ cancelPromise := promiseCtor.New(executor)
376+ executor.Release()
377+ raceInput := js.Global().Get("Array").New(promise, cancelPromise)
378+ racePromise := promiseCtor.Call("race", raceInput)
379+
380+ done := make(chan struct{})
381+ go func() {
382+ select {
383+ case <-ctx.Done():
384+ cancelMu.Lock()
385+ rejectFn := reject
386+ cancelMu.Unlock()
387+ if rejectFn.Truthy() {
388+ rejectFn.Invoke(ctx.Err().Error())
389+ }
390+ case <-done:
391+ }
392+ }()
393+ defer close(done)
394+
395+ var (
396+ callbackMu sync.Mutex
397+ released bool
398+ thenCb js.Func
399+ catchCb js.Func
400+ )
401+ releaseCallbacks := func() {
402+ callbackMu.Lock()
403+ defer callbackMu.Unlock()
404+ if released {
405+ return
406+ }
407+ released = true
408+ thenCb.Release()
409+ catchCb.Release()
410+ }
411+ publish := func(res result) {
412+ select {
413+ case resultCh <- res:
414+ default:
415+ warnDroppedBridgeResult("promise", res.err)
416+ }
417+ }
418+
419+ thenCb = js.FuncOf(func(_ js.Value, args []js.Value) any {
420+ defer releaseCallbacks()
421+ if len(args) == 0 {
422+ publish(result{value: js.Undefined()})
423+ return nil
424+ }
425+ publish(result{value: args[0]})
426+ return nil
427+ })
428+ catchCb = js.FuncOf(func(_ js.Value, args []js.Value) any {
429+ defer releaseCallbacks()
430+ if len(args) == 0 {
431+ publish(result{err: errors.New("promise rejected")})
432+ return nil
433+ }
434+ message := args[0].String()
435+ if ctxErr := ctx.Err(); ctxErr != nil && message == ctxErr.Error() {
436+ publish(result{err: ctxErr})
437+ return nil
438+ }
439+ publish(result{err: errors.New(message)})
440+ return nil
441+ })
442+ attachPromiseCallbacks(racePromise, thenCb, catchCb)
443+
444+ res := <-resultCh
445+ return res.value, res.err
446+}
447+
448+func attachPromiseCallbacks(promise js.Value, thenCb, catchCb js.Func) {
449+ promise.Call("then", thenCb, catchCb)
450+}
451+
452+func warnDroppedBridgeResult(scope string, err error) {
453+ console := js.Global().Get("console")
454+ if !console.Truthy() || !console.Get("warn").Truthy() {
455+ return
456+ }
457+ if err != nil {
458+ console.Call("warn", "dumber systemviews bridge dropped "+scope+" result", err.Error())
459+ return
460+ }
461+ console.Call("warn", "dumber systemviews bridge dropped "+scope+" result")
462+}
463+
464+func stringifyArgs(args []js.Value) ([]byte, error) {
465+ if len(args) == 0 {
466+ return nil, nil
467+ }
468+ return stringifyValue(args[0])
469+}
470+
471+func stringifyValue(value js.Value) ([]byte, error) {
472+ jsonValue := js.Global().Get("JSON")
473+ if !jsonValue.Truthy() {
474+ return nil, errors.New("JSON global not available")
475+ }
476+ stringified := jsonValue.Call("stringify", value)
477+ if stringified.Type() == js.TypeString {
478+ return []byte(stringified.String()), nil
479+ }
480+ if stringified.IsUndefined() && (value.IsUndefined() || value.IsNull()) {
481+ return []byte("null"), nil
482+ }
483+ return nil, errors.New("failed to stringify JS value")
484+}
A internal/infrastructure/systemviewsbridge/browser_transport_test.go +208 -0
@@ -0,0 +1,208 @@
1+package systemviewsbridge
2+
3+import (
4+ "bytes"
5+ "encoding/json"
6+ "testing"
7+)
8+
9+func TestDecodeBrowserRequest_DirectConfigEndpoint(t *testing.T) {
10+ t.Parallel()
11+
12+ body, err := buildMessageEnvelope("/api/config", struct {
13+ RequestID string `json:"requestId"`
14+ }{RequestID: "req-1"})
15+ if err != nil {
16+ t.Fatalf("buildMessageEnvelope() error = %v", err)
17+ }
18+
19+ req, err := decodeBrowserRequest(body)
20+ if err != nil {
21+ t.Fatalf("decodeBrowserRequest() error = %v", err)
22+ }
23+ if req.messageType != "/api/config" {
24+ t.Fatalf("messageType = %q, want %q", req.messageType, "/api/config")
25+ }
26+ if req.requestID != "req-1" {
27+ t.Fatalf("requestID = %q, want %q", req.requestID, "req-1")
28+ }
29+ if !req.directAPI {
30+ t.Fatal("directAPI = false, want true")
31+ }
32+}
33+
34+func TestDecodeBrowserRequest_MessageEnvelopeUsesBridgeShim(t *testing.T) {
35+ t.Parallel()
36+
37+ body, err := buildMessageEnvelope("history_timeline", struct {
38+ RequestID string `json:"requestId"`
39+ Limit int `json:"limit"`
40+ }{RequestID: "req-2", Limit: 25})
41+ if err != nil {
42+ t.Fatalf("buildMessageEnvelope() error = %v", err)
43+ }
44+
45+ req, err := decodeBrowserRequest(body)
46+ if err != nil {
47+ t.Fatalf("decodeBrowserRequest() error = %v", err)
48+ }
49+ if req.messageType != "history_timeline" {
50+ t.Fatalf("messageType = %q, want %q", req.messageType, "history_timeline")
51+ }
52+ if req.requestID != "req-2" {
53+ t.Fatalf("requestID = %q, want %q", req.requestID, "req-2")
54+ }
55+ if req.directAPI {
56+ t.Fatal("directAPI = true, want false")
57+ }
58+ if !bytes.Equal(req.body, body) {
59+ t.Fatalf("body = %s, want %s", req.body, body)
60+ }
61+}
62+
63+func TestNormalizeBridgeShimResponse_UnwrapsNestedBridgeEnvelope(t *testing.T) {
64+ t.Parallel()
65+
66+ got, err := normalizeBridgeShimResponse("req-3", []byte(`{"data":{"requestId":"req-3","success":true,"data":[{"title":"Example"}]},"_callback":"__dumber_homepage_response"}`))
67+ if err != nil {
68+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
69+ }
70+
71+ var resp bridgeResponse
72+ if err := json.Unmarshal(got, &resp); err != nil {
73+ t.Fatalf("json.Unmarshal() error = %v", err)
74+ }
75+ if !resp.Success {
76+ t.Fatal("success = false, want true")
77+ }
78+ if resp.RequestID != "req-3" {
79+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-3")
80+ }
81+ if string(resp.Data) != `[{"title":"Example"}]` {
82+ t.Fatalf("data = %s, want %s", resp.Data, `[{"title":"Example"}]`)
83+ }
84+}
85+
86+func TestNormalizeBridgeShimResponse_DoesNotTreatRequestIDDataAsEnvelope(t *testing.T) {
87+ t.Parallel()
88+
89+ got, err := normalizeBridgeShimResponse("req-raw", []byte(`{"data":{"requestId":"external-id","title":"Example"},"_callback":"__dumber_homepage_response"}`))
90+ if err != nil {
91+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
92+ }
93+
94+ var resp bridgeResponse
95+ if err := json.Unmarshal(got, &resp); err != nil {
96+ t.Fatalf("json.Unmarshal() error = %v", err)
97+ }
98+ if string(resp.Data) != `{"requestId":"external-id","title":"Example"}` {
99+ t.Fatalf("data = %s, want raw payload", resp.Data)
100+ }
101+}
102+
103+func TestNormalizeBridgeShimResponse_DoesNotTreatNonBooleanSuccessDataAsEnvelope(t *testing.T) {
104+ t.Parallel()
105+
106+ got, err := normalizeBridgeShimResponse("req-raw-success", []byte(`{"data":{"success":"yes","title":"Example"},"_callback":"__dumber_homepage_response"}`))
107+ if err != nil {
108+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
109+ }
110+
111+ var resp bridgeResponse
112+ if err := json.Unmarshal(got, &resp); err != nil {
113+ t.Fatalf("json.Unmarshal() error = %v", err)
114+ }
115+ if string(resp.Data) != `{"success":"yes","title":"Example"}` {
116+ t.Fatalf("data = %s, want raw payload", resp.Data)
117+ }
118+}
119+
120+func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) {
121+ t.Parallel()
122+
123+ got, err := normalizeBridgeShimResponse("req-4", []byte(`{"data":{"status":"success"},"_callback":"__dumber_config_saved"}`))
124+ if err != nil {
125+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
126+ }
127+
128+ var resp bridgeResponse
129+ if err := json.Unmarshal(got, &resp); err != nil {
130+ t.Fatalf("json.Unmarshal() error = %v", err)
131+ }
132+ if !resp.Success {
133+ t.Fatal("success = false, want true")
134+ }
135+ if resp.RequestID != "req-4" {
136+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-4")
137+ }
138+ if string(resp.Data) != `{"status":"success"}` {
139+ t.Fatalf("data = %s, want %s", resp.Data, `{"status":"success"}`)
140+ }
141+}
142+
143+func TestNormalizeBridgeShimResponse_WrapsTopLevelErrorWithoutData(t *testing.T) {
144+ t.Parallel()
145+
146+ got, err := normalizeBridgeShimResponse("req-error", []byte(`{"error":"bridge failed"}`))
147+ if err != nil {
148+ t.Fatalf("normalizeBridgeShimResponse() error = %v", err)
149+ }
150+
151+ var resp bridgeResponse
152+ if err := json.Unmarshal(got, &resp); err != nil {
153+ t.Fatalf("json.Unmarshal() error = %v", err)
154+ }
155+ if resp.RequestID != "req-error" {
156+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-error")
157+ }
158+ if resp.Error != "bridge failed" {
159+ t.Fatalf("error = %q, want %q", resp.Error, "bridge failed")
160+ }
161+}
162+
163+func TestNormalizeBridgeShimResponse_RejectsMalformedJSON(t *testing.T) {
164+ t.Parallel()
165+
166+ if _, err := normalizeBridgeShimResponse("req-bad", []byte(`{"data":`)); err == nil {
167+ t.Fatal("normalizeBridgeShimResponse() error = nil, want error")
168+ }
169+}
170+
171+func TestNormalizeBridgeShimResponse_RejectsMissingData(t *testing.T) {
172+ t.Parallel()
173+
174+ if _, err := normalizeBridgeShimResponse("req-missing", []byte(`{"_callback":"__dumber_config_saved"}`)); err == nil {
175+ t.Fatal("normalizeBridgeShimResponse() error = nil, want error")
176+ }
177+}
178+
179+func TestNormalizeBridgeShimResponse_RejectsEmptyBody(t *testing.T) {
180+ t.Parallel()
181+
182+ if _, err := normalizeBridgeShimResponse("req-empty", nil); err == nil {
183+ t.Fatal("normalizeBridgeShimResponse() error = nil, want error")
184+ }
185+}
186+
187+func TestWrapDirectAPIResponse_PreservesPayload(t *testing.T) {
188+ t.Parallel()
189+
190+ got, err := wrapDirectAPIResponse("req-5", []byte(`{"engine_type":"webkit"}`))
191+ if err != nil {
192+ t.Fatalf("wrapDirectAPIResponse() error = %v", err)
193+ }
194+
195+ var resp bridgeResponse
196+ if err := json.Unmarshal(got, &resp); err != nil {
197+ t.Fatalf("json.Unmarshal() error = %v", err)
198+ }
199+ if !resp.Success {
200+ t.Fatal("success = false, want true")
201+ }
202+ if resp.RequestID != "req-5" {
203+ t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-5")
204+ }
205+ if string(resp.Data) != `{"engine_type":"webkit"}` {
206+ t.Fatalf("data = %s, want %s", resp.Data, `{"engine_type":"webkit"}`)
207+ }
208+}
A internal/infrastructure/systemviewsbridge/client.go +573 -0
@@ -0,0 +1,573 @@
1+package systemviewsbridge
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "fmt"
8+ "strings"
9+ "sync/atomic"
10+ "time"
11+
12+ "github.com/bnema/dumber/internal/application/dto"
13+ "github.com/bnema/dumber/internal/application/port"
14+ "github.com/bnema/dumber/internal/domain/entity"
15+)
16+
17+// Transport sends a WebUI message envelope and returns the raw response body.
18+type Transport interface {
19+ Available() bool
20+ Send(ctx context.Context, body []byte) ([]byte, error)
21+}
22+
23+// Client sends WebUI message envelopes through the first available transport.
24+type Client struct {
25+ native Transport
26+ fetch Transport
27+ requestTimeout time.Duration
28+}
29+
30+var _ port.SystemviewConfigService = (*Client)(nil)
31+var _ port.SystemviewHistoryService = (*Client)(nil)
32+var _ port.SystemviewFavoritesService = (*Client)(nil)
33+
34+var requestSeq atomic.Uint64
35+
36+const defaultBridgeRequestTimeout = 15 * time.Second
37+
38+// ClientOption customizes a bridge client.
39+type ClientOption func(*Client)
40+
41+// WithRequestTimeout sets the timeout used when callers provide no deadline.
42+func WithRequestTimeout(timeout time.Duration) ClientOption {
43+ return func(c *Client) {
44+ c.requestTimeout = timeout
45+ }
46+}
47+
48+// NewClient creates a bridge client with native WebKit and fetch transports.
49+func NewClient(native, fetch Transport, opts ...ClientOption) *Client {
50+ client := &Client{native: native, fetch: fetch, requestTimeout: defaultBridgeRequestTimeout}
51+ for _, opt := range opts {
52+ if opt != nil {
53+ opt(client)
54+ }
55+ }
56+ return client
57+}
58+
59+// Send builds a WebUI message envelope and sends it through the active transport.
60+func (c *Client) Send(ctx context.Context, msgType string, payload any) ([]byte, error) {
61+ if c == nil {
62+ return nil, errors.New("bridge client is nil")
63+ }
64+
65+ envelope, err := buildMessageEnvelope(msgType, payload)
66+ if err != nil {
67+ return nil, err
68+ }
69+
70+ transport := c.transport()
71+ if transport == nil {
72+ return nil, errors.New("no bridge transport available")
73+ }
74+
75+ return transport.Send(ctx, envelope)
76+}
77+
78+func (c *Client) Timeline(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
79+ if limit < 0 {
80+ return nil, fmt.Errorf("timeline limit must be non-negative, got %d", limit)
81+ }
82+ if offset < 0 {
83+ return nil, fmt.Errorf("timeline offset must be non-negative, got %d", offset)
84+ }
85+ return request[[]*entity.HistoryEntry](c, ctx, "history_timeline", struct {
86+ RequestID string `json:"requestId"`
87+ Limit int `json:"limit"`
88+ Offset int `json:"offset"`
89+ }{RequestID: nextRequestID(), Limit: limit, Offset: offset})
90+}
91+
92+func (c *Client) TimelineByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
93+ domain = strings.TrimSpace(domain)
94+ if domain == "" {
95+ return nil, fmt.Errorf("timeline-by-domain domain must be non-empty")
96+ }
97+ if limit < 0 {
98+ return nil, fmt.Errorf("timeline-by-domain limit must be non-negative, got %d", limit)
99+ }
100+ if offset < 0 {
101+ return nil, fmt.Errorf("timeline-by-domain offset must be non-negative, got %d", offset)
102+ }
103+ return request[[]*entity.HistoryEntry](c, ctx, "history_timeline_by_domain", struct {
104+ RequestID string `json:"requestId"`
105+ Domain string `json:"domain"`
106+ Limit int `json:"limit"`
107+ Offset int `json:"offset"`
108+ }{RequestID: nextRequestID(), Domain: domain, Limit: limit, Offset: offset})
109+}
110+
111+func (c *Client) TimelineWindow(ctx context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
112+ beforeCursor := ""
113+ if !before.IsZero() {
114+ beforeCursor = before.Format(time.RFC3339Nano)
115+ }
116+ return request[*entity.HistoryWindow](c, ctx, "history_timeline_window", struct {
117+ RequestID string `json:"requestId"`
118+ Before string `json:"before,omitempty"`
119+ Domain string `json:"domain,omitempty"`
120+ }{RequestID: nextRequestID(), Before: beforeCursor, Domain: strings.TrimSpace(domain)})
121+}
122+
123+func (c *Client) Search(ctx context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
124+ if limit < 0 {
125+ return nil, fmt.Errorf("search limit must be non-negative, got %d", limit)
126+ }
127+ return request[[]*entity.HistoryEntry](c, ctx, "history_search_fts", struct {
128+ RequestID string `json:"requestId"`
129+ Query string `json:"query"`
130+ Limit int `json:"limit"`
131+ }{RequestID: nextRequestID(), Query: query, Limit: limit})
132+}
133+
134+func (c *Client) DeleteEntry(ctx context.Context, id int64) error {
135+ if id <= 0 {
136+ return fmt.Errorf("delete entry id must be positive, got %d", id)
137+ }
138+ _, err := request[struct{}](c, ctx, "history_delete_entry", struct {
139+ RequestID string `json:"requestId"`
140+ ID int64 `json:"id"`
141+ }{RequestID: nextRequestID(), ID: id})
142+ return err
143+}
144+
145+func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
146+ rangeID = strings.TrimSpace(rangeID)
147+ if rangeID == "" {
148+ return errors.New("history range ID cannot be empty")
149+ }
150+ _, err := request[struct{}](c, ctx, "history_delete_range", struct {
151+ RequestID string `json:"requestId"`
152+ Range string `json:"range"`
153+ }{RequestID: nextRequestID(), Range: rangeID})
154+ return err
155+}
156+
157+// Current intentionally targets the direct HTTP config endpoint rather than a
158+// snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config.
159+func (c *Client) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
160+ return request[dto.SystemviewConfigPayload](c, ctx, "/api/config", struct {
161+ RequestID string `json:"requestId"`
162+ }{RequestID: nextRequestID()})
163+}
164+
165+// Default intentionally targets the direct HTTP config endpoint rather than a
166+// snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config/default.
167+func (c *Client) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
168+ return request[dto.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {
169+ RequestID string `json:"requestId"`
170+ }{RequestID: nextRequestID()})
171+}
172+
173+func (c *Client) Save(ctx context.Context, cfg dto.WebUIConfig) error {
174+ _, err := request[struct{}](c, ctx, "save_config", cfg)
175+ return err
176+}
177+
178+func (c *Client) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
179+ return request[port.KeybindingsConfig](c, ctx, "get_keybindings", struct {
180+ RequestID string `json:"requestId"`
181+ }{RequestID: nextRequestID()})
182+}
183+
184+func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
185+ return request[port.SetKeybindingResponse](c, ctx, "set_keybinding", req)
186+}
187+
188+func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
189+ _, err := request[struct{}](c, ctx, "reset_keybinding", req)
190+ return err
191+}
192+
193+func (c *Client) ResetAllKeybindings(ctx context.Context) error {
194+ _, err := request[struct{}](c, ctx, "reset_all_keybindings", struct {
195+ RequestID string `json:"requestId"`
196+ }{RequestID: nextRequestID()})
197+ return err
198+}
199+
200+func (c *Client) Stats(ctx context.Context) (*entity.HistoryStats, error) {
201+ return request[*entity.HistoryStats](c, ctx, "history_stats", struct {
202+ RequestID string `json:"requestId"`
203+ }{RequestID: nextRequestID()})
204+}
205+
206+func (c *Client) Analytics(ctx context.Context) (*entity.HistoryAnalytics, error) {
207+ return request[*entity.HistoryAnalytics](c, ctx, "history_analytics", struct {
208+ RequestID string `json:"requestId"`
209+ }{RequestID: nextRequestID()})
210+}
211+
212+func (c *Client) DomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error) {
213+ if limit < 0 {
214+ return nil, fmt.Errorf("domain stats limit must be non-negative, got %d", limit)
215+ }
216+ return request[[]*entity.DomainStat](c, ctx, "history_domain_stats", struct {
217+ RequestID string `json:"requestId"`
218+ Limit int `json:"limit"`
219+ }{RequestID: nextRequestID(), Limit: limit})
220+}
221+
222+func (c *Client) DeleteDomain(ctx context.Context, domain string) error {
223+ domain = strings.TrimSpace(domain)
224+ if domain == "" {
225+ return errors.New("history domain cannot be empty")
226+ }
227+ _, err := request[struct{}](c, ctx, "history_delete_domain", struct {
228+ RequestID string `json:"requestId"`
229+ Domain string `json:"domain"`
230+ }{RequestID: nextRequestID(), Domain: domain})
231+ return err
232+}
233+
234+func (c *Client) List(ctx context.Context) ([]*entity.Favorite, error) {
235+ return request[[]*entity.Favorite](c, ctx, "favorite_list", struct {
236+ RequestID string `json:"requestId"`
237+ }{RequestID: nextRequestID()})
238+}
239+
240+func (c *Client) CreateFavorite(ctx context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
241+ return request[*entity.Favorite](c, ctx, "favorite_create", favoriteCreatePayload(input))
242+}
243+
244+func (c *Client) UpdateFavorite(ctx context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
245+ if input.ID <= 0 {
246+ return nil, fmt.Errorf("update favorite id must be positive, got %d", input.ID)
247+ }
248+ return request[*entity.Favorite](c, ctx, "favorite_update", favoriteUpdatePayload(input))
249+}
250+
251+func (c *Client) DeleteFavorite(ctx context.Context, id int64) error {
252+ if id <= 0 {
253+ return fmt.Errorf("delete favorite id must be positive, got %d", id)
254+ }
255+ _, err := request[struct{}](c, ctx, "favorite_delete", struct {
256+ RequestID string `json:"requestId"`
257+ ID int64 `json:"id"`
258+ }{RequestID: nextRequestID(), ID: id})
259+ return err
260+}
261+
262+func favoriteCreatePayload(input dto.FavoriteCreateInput) any {
263+ return struct {
264+ RequestID string `json:"requestId"`
265+ URL string `json:"url"`
266+ Title string `json:"title"`
267+ FaviconURL string `json:"favicon_url"`
268+ FolderID *int64 `json:"folder_id"`
269+ Tags []int64 `json:"tags"`
270+ }{
271+ RequestID: nextRequestID(),
272+ URL: input.URL,
273+ Title: input.Title,
274+ FaviconURL: input.FaviconURL,
275+ FolderID: folderIDPayload(input.FolderID),
276+ Tags: tagIDPayloads(input.Tags),
277+ }
278+}
279+
280+func favoriteUpdatePayload(input dto.FavoriteUpdateInput) any {
281+ return struct {
282+ RequestID string `json:"requestId"`
283+ ID int64 `json:"id"`
284+ Title string `json:"title"`
285+ FaviconURL string `json:"favicon_url"`
286+ FolderID *int64 `json:"folder_id"`
287+ ShortcutKey *int `json:"shortcut_key"`
288+ }{
289+ RequestID: nextRequestID(),
290+ ID: int64(input.ID),
291+ Title: input.Title,
292+ FaviconURL: input.FaviconURL,
293+ FolderID: folderIDPayload(input.FolderID),
294+ ShortcutKey: input.ShortcutKey,
295+ }
296+}
297+
298+func folderIDPayload(id *entity.FolderID) *int64 {
299+ if id == nil {
300+ return nil
301+ }
302+ value := int64(*id)
303+ return &value
304+}
305+
306+func tagIDPayloads(ids []entity.TagID) []int64 {
307+ out := make([]int64, 0, len(ids))
308+ for _, id := range ids {
309+ out = append(out, int64(id))
310+ }
311+ return out
312+}
313+
314+func (c *Client) ListFolders(ctx context.Context) ([]*entity.Folder, error) {
315+ return request[[]*entity.Folder](c, ctx, "folder_list", struct {
316+ RequestID string `json:"requestId"`
317+ }{RequestID: nextRequestID()})
318+}
319+
320+func (c *Client) ListTags(ctx context.Context) ([]*entity.Tag, error) {
321+ return request[[]*entity.Tag](c, ctx, "tag_list", struct {
322+ RequestID string `json:"requestId"`
323+ }{RequestID: nextRequestID()})
324+}
325+
326+func (c *Client) SetShortcut(ctx context.Context, favoriteID int64, shortcutKey *int) error {
327+ if favoriteID <= 0 {
328+ return fmt.Errorf("set shortcut favorite id must be positive, got %d", favoriteID)
329+ }
330+ _, err := request[struct{}](c, ctx, "favorite_set_shortcut", struct {
331+ RequestID string `json:"requestId"`
332+ FavoriteID int64 `json:"favorite_id"`
333+ ShortcutKey *int `json:"shortcut_key"`
334+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, ShortcutKey: shortcutKey})
335+ return err
336+}
337+
338+func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int64) error {
339+ if favoriteID <= 0 {
340+ return fmt.Errorf("set folder favorite id must be positive, got %d", favoriteID)
341+ }
342+ _, err := request[struct{}](c, ctx, "favorite_set_folder", struct {
343+ RequestID string `json:"requestId"`
344+ FavoriteID int64 `json:"favorite_id"`
345+ FolderID *int64 `json:"folder_id"`
346+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, FolderID: folderID})
347+ return err
348+}
349+
350+func (c *Client) CreateFolder(ctx context.Context, name, icon string, parentID *int64) (*entity.Folder, error) {
351+ name = strings.TrimSpace(name)
352+ if name == "" {
353+ return nil, fmt.Errorf("folder name is required")
354+ }
355+ var iconPtr *string
356+ if trimmedIcon := strings.TrimSpace(icon); trimmedIcon != "" {
357+ iconPtr = &trimmedIcon
358+ }
359+ return request[*entity.Folder](c, ctx, "folder_create", struct {
360+ RequestID string `json:"requestId"`
361+ Name string `json:"name"`
362+ Icon *string `json:"icon"`
363+ ParentID *int64 `json:"parent_id,omitempty"`
364+ }{RequestID: nextRequestID(), Name: name, Icon: iconPtr, ParentID: parentID})
365+}
366+
367+func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error {
368+ if id <= 0 {
369+ return fmt.Errorf("update folder id must be positive, got %d", id)
370+ }
371+ name = strings.TrimSpace(name)
372+ if name == "" {
373+ return fmt.Errorf("folder name is required")
374+ }
375+ var iconPtr *string
376+ if trimmed := strings.TrimSpace(icon); trimmed != "" {
377+ iconPtr = &trimmed
378+ }
379+ _, err := request[struct{}](c, ctx, "folder_update", struct {
380+ RequestID string `json:"requestId"`
381+ ID int64 `json:"id"`
382+ Name string `json:"name"`
383+ Icon *string `json:"icon"`
384+ }{RequestID: nextRequestID(), ID: id, Name: name, Icon: iconPtr})
385+ return err
386+}
387+
388+func (c *Client) DeleteFolder(ctx context.Context, id int64) error {
389+ if id <= 0 {
390+ return fmt.Errorf("delete folder id must be positive, got %d", id)
391+ }
392+ _, err := request[struct{}](c, ctx, "folder_delete", struct {
393+ RequestID string `json:"requestId"`
394+ ID int64 `json:"id"`
395+ }{RequestID: nextRequestID(), ID: id})
396+ return err
397+}
398+
399+func (c *Client) CreateTag(ctx context.Context, name, color string) (*entity.Tag, error) {
400+ name = strings.TrimSpace(name)
401+ if name == "" {
402+ return nil, fmt.Errorf("tag name is required")
403+ }
404+ var colorPtr *string
405+ if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
406+ colorPtr = &trimmedColor
407+ }
408+ return request[*entity.Tag](c, ctx, "tag_create", struct {
409+ RequestID string `json:"requestId"`
410+ Name string `json:"name"`
411+ Color *string `json:"color"`
412+ }{RequestID: nextRequestID(), Name: name, Color: colorPtr})
413+}
414+
415+func (c *Client) UpdateTag(ctx context.Context, id int64, name, color string) error {
416+ if id <= 0 {
417+ return fmt.Errorf("update tag id must be positive, got %d", id)
418+ }
419+ trimmedName := strings.TrimSpace(name)
420+ if trimmedName == "" {
421+ return fmt.Errorf("tag name is required")
422+ }
423+ namePtr := &trimmedName
424+ var colorPtr *string
425+ if trimmedColor := strings.TrimSpace(color); trimmedColor != "" {
426+ colorPtr = &trimmedColor
427+ }
428+ _, err := request[struct{}](c, ctx, "tag_update", struct {
429+ RequestID string `json:"requestId"`
430+ ID int64 `json:"id"`
431+ Name *string `json:"name"`
432+ Color *string `json:"color"`
433+ }{RequestID: nextRequestID(), ID: id, Name: namePtr, Color: colorPtr})
434+ return err
435+}
436+
437+func (c *Client) DeleteTag(ctx context.Context, id int64) error {
438+ if id <= 0 {
439+ return fmt.Errorf("delete tag id must be positive, got %d", id)
440+ }
441+ _, err := request[struct{}](c, ctx, "tag_delete", struct {
442+ RequestID string `json:"requestId"`
443+ ID int64 `json:"id"`
444+ }{RequestID: nextRequestID(), ID: id})
445+ return err
446+}
447+
448+func (c *Client) AssignTag(ctx context.Context, favoriteID, tagID int64) error {
449+ if favoriteID <= 0 {
450+ return fmt.Errorf("assign tag favorite id must be positive, got %d", favoriteID)
451+ }
452+ if tagID <= 0 {
453+ return fmt.Errorf("assign tag id must be positive, got %d", tagID)
454+ }
455+ _, err := request[struct{}](c, ctx, "tag_assign", struct {
456+ RequestID string `json:"requestId"`
457+ FavoriteID int64 `json:"favorite_id"`
458+ TagID int64 `json:"tag_id"`
459+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
460+ return err
461+}
462+
463+func (c *Client) RemoveTag(ctx context.Context, favoriteID, tagID int64) error {
464+ if favoriteID <= 0 {
465+ return fmt.Errorf("remove tag favorite id must be positive, got %d", favoriteID)
466+ }
467+ if tagID <= 0 {
468+ return fmt.Errorf("remove tag id must be positive, got %d", tagID)
469+ }
470+ _, err := request[struct{}](c, ctx, "tag_remove", struct {
471+ RequestID string `json:"requestId"`
472+ FavoriteID int64 `json:"favorite_id"`
473+ TagID int64 `json:"tag_id"`
474+ }{RequestID: nextRequestID(), FavoriteID: favoriteID, TagID: tagID})
475+ return err
476+}
477+
478+func (c *Client) transport() Transport {
479+ if c == nil {
480+ return nil
481+ }
482+ if c.native != nil && c.native.Available() {
483+ return c.native
484+ }
485+ if c.fetch != nil && c.fetch.Available() {
486+ return c.fetch
487+ }
488+ return nil
489+}
490+
491+func request[T any](c *Client, ctx context.Context, msgType string, payload any) (T, error) {
492+ var zero T
493+
494+ ctx, cancel := c.withBridgeRequestTimeout(ctx)
495+ defer cancel()
496+
497+ raw, err := c.Send(ctx, msgType, payload)
498+ if err != nil {
499+ return zero, err
500+ }
501+
502+ return decodeBridgeResponse[T](raw)
503+}
504+
505+// withBridgeRequestTimeout applies the bridge request timeout; ctx must be non-nil.
506+func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
507+ timeout := defaultBridgeRequestTimeout
508+ if c != nil {
509+ timeout = c.requestTimeout
510+ }
511+ if _, ok := ctx.Deadline(); ok || timeout <= 0 {
512+ return context.WithCancel(ctx)
513+ }
514+ return context.WithTimeout(ctx, timeout)
515+}
516+
517+type bridgeResponse struct {
518+ RequestID string `json:"requestId"`
519+ Success bool `json:"success"`
520+ Data json.RawMessage `json:"data,omitempty"`
521+ Error string `json:"error,omitempty"`
522+}
523+
524+func decodeBridgeResponse[T any](body []byte) (T, error) {
525+ var result T
526+
527+ var resp bridgeResponse
528+ if err := json.Unmarshal(body, &resp); err != nil {
529+ return result, fmt.Errorf("unmarshal bridge response: %w", err)
530+ }
531+ if !resp.Success {
532+ if resp.Error == "" {
533+ return result, errors.New("bridge request failed")
534+ }
535+ return result, errors.New(resp.Error)
536+ }
537+ if len(resp.Data) == 0 {
538+ return result, nil
539+ }
540+
541+ if err := json.Unmarshal(resp.Data, &result); err != nil {
542+ return result, fmt.Errorf("unmarshal bridge data: %w", err)
543+ }
544+
545+ return result, nil
546+}
547+
548+func nextRequestID() string {
549+ return fmt.Sprintf("systemviews-%d", requestSeq.Add(1))
550+}
551+
552+func buildMessageEnvelope(msgType string, payload any) ([]byte, error) {
553+ if msgType == "" {
554+ return nil, errors.New("message type cannot be empty")
555+ }
556+
557+ body, err := json.Marshal(payload)
558+ if err != nil {
559+ return nil, fmt.Errorf("marshal payload: %w", err)
560+ }
561+
562+ envelope := port.WebUIMessage{
563+ Type: msgType,
564+ Payload: body,
565+ }
566+
567+ data, err := json.Marshal(envelope)
568+ if err != nil {
569+ return nil, fmt.Errorf("marshal envelope: %w", err)
570+ }
571+
572+ return data, nil
573+}
A internal/infrastructure/systemviewsbridge/client_test.go +702 -0
@@ -0,0 +1,702 @@
1+package systemviewsbridge
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+func TestBuildMessageEnvelope(t *testing.T) {
15+ t.Parallel()
16+
17+ payload := struct {
18+ RequestID string `json:"requestId"`
19+ Limit int `json:"limit"`
20+ }{RequestID: "req-1", Limit: 25}
21+
22+ got, err := buildMessageEnvelope("history_timeline", payload)
23+ if err != nil {
24+ t.Fatalf("buildMessageEnvelope() error = %v", err)
25+ }
26+
27+ var msg port.WebUIMessage
28+ if err := json.Unmarshal(got, &msg); err != nil {
29+ t.Fatalf("unmarshal envelope: %v", err)
30+ }
31+
32+ if msg.Type != "history_timeline" {
33+ t.Fatalf("msg.Type = %q, want %q", msg.Type, "history_timeline")
34+ }
35+
36+ if gotPayload := string(msg.Payload); gotPayload != `{"requestId":"req-1","limit":25}` {
37+ t.Fatalf("msg.Payload = %s, want %s", gotPayload, `{"requestId":"req-1","limit":25}`)
38+ }
39+}
40+
41+func TestClientSendPrefersNativeTransport(t *testing.T) {
42+ t.Parallel()
43+
44+ native := &fakeTransport{available: true, response: []byte(`{"transport":"native"}`)}
45+ fetch := &fakeTransport{available: true, response: []byte(`{"transport":"fetch"}`)}
46+
47+ client := NewClient(native, fetch)
48+ got, err := client.Send(context.Background(), "favorite_list", struct {
49+ RequestID string `json:"requestId"`
50+ }{RequestID: "req-2"})
51+ if err != nil {
52+ t.Fatalf("Send() error = %v", err)
53+ }
54+
55+ if string(got) != `{"transport":"native"}` {
56+ t.Fatalf("Send() = %s, want %s", got, `{"transport":"native"}`)
57+ }
58+
59+ if !native.called {
60+ t.Fatal("native transport was not used")
61+ }
62+ if fetch.called {
63+ t.Fatal("fetch transport was used unexpectedly")
64+ }
65+
66+ var msg port.WebUIMessage
67+ if err := json.Unmarshal(native.last, &msg); err != nil {
68+ t.Fatalf("unmarshal sent envelope: %v", err)
69+ }
70+ if msg.Type != "favorite_list" {
71+ t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list")
72+ }
73+}
74+
75+func TestClientSendFallsBackToFetchTransport(t *testing.T) {
76+ t.Parallel()
77+
78+ native := &fakeTransport{available: false, response: []byte(`{"transport":"native"}`)}
79+ fetch := &fakeTransport{available: true, response: []byte(`{"transport":"fetch"}`)}
80+
81+ client := NewClient(native, fetch)
82+ got, err := client.Send(context.Background(), "favorite_list", struct {
83+ RequestID string `json:"requestId"`
84+ }{RequestID: "req-3"})
85+ if err != nil {
86+ t.Fatalf("Send() error = %v", err)
87+ }
88+
89+ if string(got) != `{"transport":"fetch"}` {
90+ t.Fatalf("Send() = %s, want %s", got, `{"transport":"fetch"}`)
91+ }
92+
93+ if native.called {
94+ t.Fatal("native transport was used unexpectedly")
95+ }
96+ if !fetch.called {
97+ t.Fatal("fetch transport was not used")
98+ }
99+}
100+
101+func TestClientTimelineDecodesEntries(t *testing.T) {
102+ t.Parallel()
103+
104+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-9","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`)}
105+ client := NewClient(native, nil)
106+
107+ entries, err := client.Timeline(context.Background(), 25, 0)
108+ if err != nil {
109+ t.Fatalf("Timeline() error = %v", err)
110+ }
111+ if len(entries) != 1 {
112+ t.Fatalf("Timeline() len = %d, want 1", len(entries))
113+ }
114+ if entries[0].URL != "https://example.com" || entries[0].Title != "Example" {
115+ t.Fatalf("Timeline() entry = %+v", entries[0])
116+ }
117+
118+ var msg port.WebUIMessage
119+ if err := json.Unmarshal(native.last, &msg); err != nil {
120+ t.Fatalf("unmarshal sent envelope: %v", err)
121+ }
122+ if msg.Type != "history_timeline" {
123+ t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline")
124+ }
125+ if gotPayload := string(msg.Payload); gotPayload == "" {
126+ t.Fatal("sent payload was empty")
127+ }
128+}
129+
130+func TestClientTimelineWindowOmitsZeroCursor(t *testing.T) {
131+ t.Parallel()
132+
133+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-window","success":true}`)}
134+ client := NewClient(native, nil)
135+
136+ _, err := client.TimelineWindow(context.Background(), time.Time{}, "")
137+ if err != nil {
138+ t.Fatalf("TimelineWindow() error = %v", err)
139+ }
140+
141+ var msg port.WebUIMessage
142+ if err := json.Unmarshal(native.last, &msg); err != nil {
143+ t.Fatalf("unmarshal sent envelope: %v", err)
144+ }
145+ if msg.Type != "history_timeline_window" {
146+ t.Fatalf("sent type = %q, want %q", msg.Type, "history_timeline_window")
147+ }
148+ var payload map[string]any
149+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
150+ t.Fatalf("unmarshal payload: %v", err)
151+ }
152+ if _, ok := payload["before"]; ok {
153+ t.Fatalf("zero TimelineWindow cursor should be omitted, payload = %s", msg.Payload)
154+ }
155+}
156+
157+func TestClientDeleteRangeRejectsEmptyRange(t *testing.T) {
158+ t.Parallel()
159+
160+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-range-empty","success":true}`)}
161+ client := NewClient(transport, nil)
162+
163+ err := client.DeleteRange(context.Background(), " ")
164+ if err == nil {
165+ t.Fatal("DeleteRange() error = nil, want validation error")
166+ }
167+ if transport.called {
168+ t.Fatal("DeleteRange() sent a request for an invalid range")
169+ }
170+}
171+
172+func TestClientDeleteRangeSendsRange(t *testing.T) {
173+ t.Parallel()
174+
175+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-10","success":true}`)}
176+ client := NewClient(native, nil)
177+
178+ if err := client.DeleteRange(context.Background(), "week"); err != nil {
179+ t.Fatalf("DeleteRange() error = %v", err)
180+ }
181+
182+ var msg port.WebUIMessage
183+ if err := json.Unmarshal(native.last, &msg); err != nil {
184+ t.Fatalf("unmarshal sent envelope: %v", err)
185+ }
186+ if msg.Type != "history_delete_range" {
187+ t.Fatalf("sent type = %q, want %q", msg.Type, "history_delete_range")
188+ }
189+}
190+
191+func TestClientListDecodesFavorites(t *testing.T) {
192+ t.Parallel()
193+
194+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-11","success":true,"data":[{"id":1,"url":"https://example.com","title":"Example"}]}`)}
195+ client := NewClient(native, nil)
196+
197+ favorites, err := client.List(context.Background())
198+ if err != nil {
199+ t.Fatalf("List() error = %v", err)
200+ }
201+ if len(favorites) != 1 {
202+ t.Fatalf("List() len = %d, want 1", len(favorites))
203+ }
204+ if favorites[0].URL != "https://example.com" || favorites[0].Title != "Example" {
205+ t.Fatalf("List() favorite = %+v", favorites[0])
206+ }
207+
208+ var msg port.WebUIMessage
209+ if err := json.Unmarshal(native.last, &msg); err != nil {
210+ t.Fatalf("unmarshal sent envelope: %v", err)
211+ }
212+ if msg.Type != "favorite_list" {
213+ t.Fatalf("sent type = %q, want %q", msg.Type, "favorite_list")
214+ }
215+}
216+
217+func TestClientListFoldersDecodesFolders(t *testing.T) {
218+ t.Parallel()
219+
220+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-12","success":true,"data":[{"id":9,"name":"Read Later"}]}`)}
221+ client := NewClient(native, nil)
222+
223+ folders, err := client.ListFolders(context.Background())
224+ if err != nil {
225+ t.Fatalf("ListFolders() error = %v", err)
226+ }
227+ if len(folders) != 1 {
228+ t.Fatalf("ListFolders() len = %d, want 1", len(folders))
229+ }
230+ if folders[0].Name != "Read Later" {
231+ t.Fatalf("ListFolders() folder = %+v", folders[0])
232+ }
233+
234+ var msg port.WebUIMessage
235+ if err := json.Unmarshal(native.last, &msg); err != nil {
236+ t.Fatalf("unmarshal sent envelope: %v", err)
237+ }
238+ if msg.Type != "folder_list" {
239+ t.Fatalf("sent type = %q, want %q", msg.Type, "folder_list")
240+ }
241+}
242+
243+func TestClientListTagsDecodesTags(t *testing.T) {
244+ t.Parallel()
245+
246+ native := &fakeTransport{available: true, response: []byte(`{"requestId":"req-13","success":true,"data":[{"id":5,"name":"Go","color":"#00ff00"}]}`)}
247+ client := NewClient(native, nil)
248+
249+ tags, err := client.ListTags(context.Background())
250+ if err != nil {
251+ t.Fatalf("ListTags() error = %v", err)
252+ }
253+ if len(tags) != 1 {
254+ t.Fatalf("ListTags() len = %d, want 1", len(tags))
255+ }
256+ if tags[0].Name != "Go" || tags[0].Color != "#00ff00" {
257+ t.Fatalf("ListTags() tag = %+v", tags[0])
258+ }
259+
260+ var msg port.WebUIMessage
261+ if err := json.Unmarshal(native.last, &msg); err != nil {
262+ t.Fatalf("unmarshal sent envelope: %v", err)
263+ }
264+ if msg.Type != "tag_list" {
265+ t.Fatalf("sent type = %q, want %q", msg.Type, "tag_list")
266+ }
267+}
268+
269+func TestClientCurrentAndDefaultDecodeConfigPayload(t *testing.T) {
270+ t.Parallel()
271+
272+ resp := []byte(`{"requestId":"req-14","success":true,"data":{"engine_type":"webkit","default_search_engine":"DuckDuckGo"}}`)
273+
274+ currentClient := NewClient(&fakeTransport{available: true, response: resp}, nil)
275+ current, err := currentClient.Current(context.Background())
276+ if err != nil {
277+ t.Fatalf("Current() error = %v", err)
278+ }
279+ if current.EngineType != "webkit" || current.DefaultSearchEngine != "DuckDuckGo" {
280+ t.Fatalf("Current() = %+v", current)
281+ }
282+
283+ var msg port.WebUIMessage
284+ if unmarshalErr := json.Unmarshal(currentClient.native.(*fakeTransport).last, &msg); unmarshalErr != nil {
285+ t.Fatalf("unmarshal sent envelope: %v", unmarshalErr)
286+ }
287+ if msg.Type != "/api/config" {
288+ t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config")
289+ }
290+
291+ defaultClient := NewClient(&fakeTransport{available: true, response: resp}, nil)
292+ defaultCfg, err := defaultClient.Default(context.Background())
293+ if err != nil {
294+ t.Fatalf("Default() error = %v", err)
295+ }
296+ if defaultCfg.EngineType != "webkit" || defaultCfg.DefaultSearchEngine != "DuckDuckGo" {
297+ t.Fatalf("Default() = %+v", defaultCfg)
298+ }
299+
300+ if err := json.Unmarshal(defaultClient.native.(*fakeTransport).last, &msg); err != nil {
301+ t.Fatalf("unmarshal sent envelope: %v", err)
302+ }
303+ if msg.Type != "/api/config/default" {
304+ t.Fatalf("sent type = %q, want %q", msg.Type, "/api/config/default")
305+ }
306+}
307+
308+func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
309+ t.Parallel()
310+
311+ tests := []struct {
312+ name string
313+ call func(*testing.T, *Client) error
314+ want string
315+ payload any
316+ }{
317+ {
318+ name: "save config",
319+ call: func(_ *testing.T, client *Client) error {
320+ return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})
321+ },
322+ want: "save_config",
323+ payload: dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"},
324+ },
325+ {
326+ name: "get keybindings",
327+ call: func(t *testing.T, client *Client) error {
328+ got, err := client.GetKeybindings(context.Background())
329+ if err != nil {
330+ return err
331+ }
332+ if len(got.Groups) != 1 || got.Groups[0].Mode != "default" {
333+ t.Fatalf("GetKeybindings() = %#v", got)
334+ }
335+ return nil
336+ },
337+ want: "get_keybindings",
338+ },
339+ {
340+ name: "set keybinding",
341+ call: func(t *testing.T, client *Client) error {
342+ got, err := client.SetKeybinding(context.Background(), port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}})
343+ if err != nil {
344+ return err
345+ }
346+ if len(got.Conflicts) != 0 {
347+ t.Fatalf("SetKeybinding() = %#v", got)
348+ }
349+ return nil
350+ },
351+ want: "set_keybinding",
352+ payload: port.SetKeybindingRequest{RequestID: "req-1", Mode: "default", Action: "open", Keys: []string{"ctrl+o"}},
353+ },
354+ {
355+ name: "reset keybinding",
356+ call: func(_ *testing.T, client *Client) error {
357+ return client.ResetKeybinding(context.Background(), port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"})
358+ },
359+ want: "reset_keybinding",
360+ payload: port.ResetKeybindingRequest{RequestID: "req-2", Mode: "default", Action: "open"},
361+ },
362+ {
363+ name: "reset all keybindings",
364+ call: func(_ *testing.T, client *Client) error {
365+ return client.ResetAllKeybindings(context.Background())
366+ },
367+ want: "reset_all_keybindings",
368+ },
369+ }
370+
371+ for _, tt := range tests {
372+ t.Run(tt.name, func(t *testing.T) {
373+ t.Parallel()
374+
375+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-15","success":true,"data":{"status":"success","conflicts":[]}}`)}
376+ if tt.name == "save config" || tt.name == "reset keybinding" || tt.name == "reset all keybindings" {
377+ transport.response = []byte(`{"requestId":"req-15","success":true}`)
378+ }
379+ if tt.name == "get keybindings" {
380+ transport.response = []byte(`{"requestId":"req-15","success":true,"data":{"groups":[{"mode":"default","display_name":"Default","bindings":[{"action":"open","description":"Open","keys":["ctrl+o"],"default_keys":["ctrl+o"],"is_custom":false}]}]}}`)
381+ }
382+ client := NewClient(transport, nil)
383+
384+ if err := tt.call(t, client); err != nil {
385+ t.Fatalf("call() error = %v", err)
386+ }
387+
388+ var msg port.WebUIMessage
389+ if err := json.Unmarshal(transport.last, &msg); err != nil {
390+ t.Fatalf("unmarshal sent envelope: %v", err)
391+ }
392+ if msg.Type != tt.want {
393+ t.Fatalf("sent type = %q, want %q", msg.Type, tt.want)
394+ }
395+ if tt.payload != nil && len(msg.Payload) == 0 {
396+ t.Fatal("sent payload was empty")
397+ }
398+ })
399+ }
400+}
401+
402+func TestClientRequestUsesDefaultTimeoutWhenCallerHasNoDeadline(t *testing.T) {
403+ t.Parallel()
404+
405+ transport := &blockingTransport{available: true}
406+ client := NewClient(transport, nil, WithRequestTimeout(time.Millisecond))
407+
408+ _, err := client.Timeline(context.Background(), 25, 0)
409+ if !errors.Is(err, context.DeadlineExceeded) {
410+ t.Fatalf("Timeline() error = %v, want context deadline exceeded", err)
411+ }
412+ if !transport.called {
413+ t.Fatal("transport was not called")
414+ }
415+}
416+
417+func TestClientCreateFolderIncludesParentID(t *testing.T) {
418+ t.Parallel()
419+
420+ parentID := int64(42)
421+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"📁","parent_id":42}}`)}
422+ client := NewClient(transport, nil)
423+
424+ folder, err := client.CreateFolder(context.Background(), "Nested", " 📁 ", &parentID)
425+ if err != nil {
426+ t.Fatalf("CreateFolder() error = %v", err)
427+ }
428+ if folder == nil {
429+ t.Fatal("CreateFolder() returned nil folder")
430+ }
431+ if folder.Name != "Nested" {
432+ t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested")
433+ }
434+ if folder.Icon != "📁" {
435+ t.Fatalf("CreateFolder() folder icon = %q, want 📁", folder.Icon)
436+ }
437+ if folder.ParentID == nil || int64(*folder.ParentID) != parentID {
438+ t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID)
439+ }
440+
441+ var msg port.WebUIMessage
442+ if err := json.Unmarshal(transport.last, &msg); err != nil {
443+ t.Fatalf("unmarshal sent envelope: %v", err)
444+ }
445+ if msg.Type != "folder_create" {
446+ t.Fatalf("sent type = %q, want %q", msg.Type, "folder_create")
447+ }
448+
449+ var payload struct {
450+ RequestID string `json:"requestId"`
451+ Name string `json:"name"`
452+ Icon *string `json:"icon"`
453+ ParentID *int64 `json:"parent_id"`
454+ }
455+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
456+ t.Fatalf("unmarshal sent payload: %v", err)
457+ }
458+ if payload.ParentID == nil || *payload.ParentID != parentID {
459+ t.Fatalf("sent parent_id = %#v, want %d", payload.ParentID, parentID)
460+ }
461+ if payload.Name != "Nested" {
462+ t.Fatalf("sent name = %q, want %q", payload.Name, "Nested")
463+ }
464+ if payload.Icon == nil || *payload.Icon != "📁" {
465+ t.Fatalf("sent icon = %#v, want 📁", payload.Icon)
466+ }
467+ if payload.RequestID == "" {
468+ t.Fatal("sent requestId was empty")
469+ }
470+}
471+
472+func TestClientDomainStatsRejectsNegativeLimit(t *testing.T) {
473+ t.Parallel()
474+
475+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-17","success":true,"data":[]}`)}
476+ client := NewClient(transport, nil)
477+
478+ _, err := client.DomainStats(context.Background(), -1)
479+ if err == nil {
480+ t.Fatal("DomainStats() error = nil, want validation error")
481+ }
482+ if transport.called {
483+ t.Fatal("DomainStats() sent a request for a negative limit")
484+ }
485+}
486+
487+func TestClientDeleteDomainRejectsEmptyDomain(t *testing.T) {
488+ t.Parallel()
489+
490+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-domain-empty","success":true}`)}
491+ client := NewClient(transport, nil)
492+
493+ err := client.DeleteDomain(context.Background(), " ")
494+ if err == nil {
495+ t.Fatal("DeleteDomain() error = nil, want validation error")
496+ }
497+ if transport.called {
498+ t.Fatal("DeleteDomain() sent a request for an empty domain")
499+ }
500+}
501+
502+func TestClientUpdateFolderTrimsIcon(t *testing.T) {
503+ t.Parallel()
504+
505+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-18","success":true}`)}
506+ client := NewClient(transport, nil)
507+
508+ if err := client.UpdateFolder(context.Background(), 9, "Read", " 📚 "); err != nil {
509+ t.Fatalf("UpdateFolder() error = %v", err)
510+ }
511+
512+ var msg port.WebUIMessage
513+ if err := json.Unmarshal(transport.last, &msg); err != nil {
514+ t.Fatalf("unmarshal sent envelope: %v", err)
515+ }
516+ if msg.Type != "folder_update" {
517+ t.Fatalf("sent type = %q, want folder_update", msg.Type)
518+ }
519+ var payload struct {
520+ Icon *string `json:"icon"`
521+ }
522+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
523+ t.Fatalf("unmarshal sent payload: %v", err)
524+ }
525+ if payload.Icon == nil || *payload.Icon != "📚" {
526+ t.Fatalf("sent icon = %#v, want 📚", payload.Icon)
527+ }
528+}
529+
530+func TestClientUpdateFolderOmitsBlankIcon(t *testing.T) {
531+ t.Parallel()
532+
533+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-19","success":true}`)}
534+ client := NewClient(transport, nil)
535+
536+ if err := client.UpdateFolder(context.Background(), 9, "Read", " "); err != nil {
537+ t.Fatalf("UpdateFolder() error = %v", err)
538+ }
539+
540+ var msg port.WebUIMessage
541+ if err := json.Unmarshal(transport.last, &msg); err != nil {
542+ t.Fatalf("unmarshal sent envelope: %v", err)
543+ }
544+ var payload struct {
545+ Icon *string `json:"icon"`
546+ }
547+ if err := json.Unmarshal(msg.Payload, &payload); err != nil {
548+ t.Fatalf("unmarshal sent payload: %v", err)
549+ }
550+ if payload.Icon != nil {
551+ t.Fatalf("sent icon = %#v, want nil", payload.Icon)
552+ }
553+}
554+
555+func TestClientDeleteEntryRejectsInvalidID(t *testing.T) {
556+ t.Parallel()
557+
558+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-20","success":true}`)}
559+ client := NewClient(transport, nil)
560+
561+ err := client.DeleteEntry(context.Background(), 0)
562+ if err == nil {
563+ t.Fatal("DeleteEntry() error = nil, want validation error")
564+ }
565+ if transport.called {
566+ t.Fatal("DeleteEntry() sent a request for an invalid id")
567+ }
568+}
569+
570+func TestClientTagActionsTrimFields(t *testing.T) {
571+ t.Parallel()
572+
573+ createTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-21","success":true,"data":{"id":5,"name":"Go","color":"#00add8"}}`)}
574+ client := NewClient(createTransport, nil)
575+ if _, err := client.CreateTag(context.Background(), " Go ", " #00add8 "); err != nil {
576+ t.Fatalf("CreateTag() error = %v", err)
577+ }
578+ var msg port.WebUIMessage
579+ if err := json.Unmarshal(createTransport.last, &msg); err != nil {
580+ t.Fatalf("unmarshal create envelope: %v", err)
581+ }
582+ var createPayload struct {
583+ Name string `json:"name"`
584+ Color *string `json:"color"`
585+ }
586+ if err := json.Unmarshal(msg.Payload, &createPayload); err != nil {
587+ t.Fatalf("unmarshal create payload: %v", err)
588+ }
589+ if createPayload.Name != "Go" || createPayload.Color == nil || *createPayload.Color != "#00add8" {
590+ t.Fatalf("CreateTag payload = %#v", createPayload)
591+ }
592+
593+ updateTransport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-22","success":true}`)}
594+ client = NewClient(updateTransport, nil)
595+ if err := client.UpdateTag(context.Background(), 5, " Go ", " "); err != nil {
596+ t.Fatalf("UpdateTag() error = %v", err)
597+ }
598+ if err := json.Unmarshal(updateTransport.last, &msg); err != nil {
599+ t.Fatalf("unmarshal update envelope: %v", err)
600+ }
601+ var updatePayload struct {
602+ Name *string `json:"name"`
603+ Color *string `json:"color"`
604+ }
605+ if err := json.Unmarshal(msg.Payload, &updatePayload); err != nil {
606+ t.Fatalf("unmarshal update payload: %v", err)
607+ }
608+ if updatePayload.Name == nil || *updatePayload.Name != "Go" || updatePayload.Color != nil {
609+ t.Fatalf("UpdateTag payload = %#v", updatePayload)
610+ }
611+}
612+
613+type fakeTransport struct {
614+ available bool
615+ called bool
616+ last []byte
617+ response []byte
618+}
619+
620+func (t *fakeTransport) Available() bool { return t.available }
621+
622+func (t *fakeTransport) Send(_ context.Context, body []byte) ([]byte, error) {
623+ t.called = true
624+ t.last = append(t.last[:0], body...)
625+ return t.response, nil
626+}
627+
628+type blockingTransport struct {
629+ available bool
630+ called bool
631+}
632+
633+func (t *blockingTransport) Available() bool { return t.available }
634+
635+func (t *blockingTransport) Send(ctx context.Context, _ []byte) ([]byte, error) {
636+ t.called = true
637+ <-ctx.Done()
638+ return nil, ctx.Err()
639+}
640+
641+func TestClientRejectsInvalidFavoriteMutationIDs(t *testing.T) {
642+ t.Parallel()
643+
644+ transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req","success":true}`)}
645+ client := NewClient(transport, nil)
646+
647+ checks := []struct {
648+ name string
649+ call func() error
650+ }{
651+ {name: "delete favorite", call: func() error { return client.DeleteFavorite(context.Background(), 0) }},
652+ {name: "set shortcut", call: func() error { return client.SetShortcut(context.Background(), 0, nil) }},
653+ {name: "set folder", call: func() error { return client.SetFolder(context.Background(), 0, nil) }},
654+ {name: "create folder", call: func() error { _, err := client.CreateFolder(context.Background(), " ", "", nil); return err }},
655+ {name: "update folder id", call: func() error { return client.UpdateFolder(context.Background(), 0, "Folder", "") }},
656+ {name: "update folder name", call: func() error { return client.UpdateFolder(context.Background(), 1, " ", "") }},
657+ {name: "delete folder", call: func() error { return client.DeleteFolder(context.Background(), 0) }},
658+ {name: "create tag", call: func() error { _, err := client.CreateTag(context.Background(), " ", ""); return err }},
659+ {name: "update tag", call: func() error { return client.UpdateTag(context.Background(), 0, "Tag", "") }},
660+ {name: "update tag name", call: func() error { return client.UpdateTag(context.Background(), 1, " ", "") }},
661+ {name: "delete tag", call: func() error { return client.DeleteTag(context.Background(), 0) }},
662+ {name: "assign favorite", call: func() error { return client.AssignTag(context.Background(), 0, 1) }},
663+ {name: "assign tag", call: func() error { return client.AssignTag(context.Background(), 1, 0) }},
664+ {name: "remove favorite", call: func() error { return client.RemoveTag(context.Background(), 0, 1) }},
665+ {name: "remove tag", call: func() error { return client.RemoveTag(context.Background(), 1, 0) }},
666+ }
667+ for _, check := range checks {
668+ t.Run(check.name, func(t *testing.T) {
669+ if err := check.call(); err == nil {
670+ t.Fatal("error = nil, want validation error")
671+ }
672+ })
673+ }
674+ if transport.called {
675+ t.Fatal("transport called for invalid favorite mutation IDs")
676+ }
677+}
678+
679+func TestClientRejectsNegativeHistoryLimits(t *testing.T) {
680+ t.Parallel()
681+
682+ client := NewClient(&fakeTransport{available: true}, nil)
683+
684+ if _, err := client.Timeline(context.Background(), -1, 0); err == nil {
685+ t.Fatal("Timeline() error = nil, want error")
686+ }
687+ if _, err := client.Timeline(context.Background(), 1, -1); err == nil {
688+ t.Fatal("Timeline() error = nil, want error")
689+ }
690+ if _, err := client.TimelineByDomain(context.Background(), "", 1, 0); err == nil {
691+ t.Fatal("TimelineByDomain() error = nil, want error")
692+ }
693+ if _, err := client.TimelineByDomain(context.Background(), "example.com", -1, 0); err == nil {
694+ t.Fatal("TimelineByDomain() error = nil, want error")
695+ }
696+ if _, err := client.TimelineByDomain(context.Background(), "example.com", 1, -1); err == nil {
697+ t.Fatal("TimelineByDomain() error = nil, want error")
698+ }
699+ if _, err := client.Search(context.Background(), "example", -1); err == nil {
700+ t.Fatal("Search() error = nil, want error")
701+ }
702+}
M internal/infrastructure/transcoder/audio.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
13 package transcoder
24
35 import (
M internal/infrastructure/transcoder/ffmpeg_offsets.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
13 package transcoder
24
35 // Offset-based accessors for FFmpeg struct fields not yet exposed
A internal/infrastructure/transcoder/http_client_test.go +138 -0
@@ -0,0 +1,138 @@
1+//go:build !js || !wasm
2+
3+package transcoder
4+
5+import (
6+ "context"
7+ "net"
8+ "net/http"
9+ "net/url"
10+ "testing"
11+
12+ "github.com/rs/zerolog"
13+
14+ "github.com/bnema/dumber/internal/application/port"
15+)
16+
17+func TestValidateHTTPTranscodeURLRejectsPrivateTargets(t *testing.T) {
18+ source, err := url.Parse("http://127.0.0.1/video.mp4")
19+ if err != nil {
20+ t.Fatal(err)
21+ }
22+
23+ err = validateHTTPTranscodeURL(context.Background(), source)
24+ if err == nil {
25+ t.Fatal("validateHTTPTranscodeURL() expected error for loopback target")
26+ }
27+}
28+
29+func TestIsBlockedTranscodeIPRejectsReservedInternalRanges(t *testing.T) {
30+ tests := []string{
31+ "100.64.0.1",
32+ "198.18.0.1",
33+ "192.0.2.1",
34+ "64:ff9b::a00:1",
35+ "2001::1",
36+ "2001:db8::1",
37+ "2002:0a00:0001::1",
38+ }
39+ for _, rawIP := range tests {
40+ t.Run(rawIP, func(t *testing.T) {
41+ if !isBlockedTranscodeIP(net.ParseIP(rawIP)) {
42+ t.Fatalf("isBlockedTranscodeIP(%s) = false, want true", rawIP)
43+ }
44+ })
45+ }
46+}
47+
48+func TestValidateHTTPTranscodeURLRejectsUnsupportedSchemes(t *testing.T) {
49+ source, err := url.Parse("file:///etc/passwd")
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+
54+ err = validateHTTPTranscodeURL(context.Background(), source)
55+ if err == nil {
56+ t.Fatal("validateHTTPTranscodeURL() expected error for unsupported scheme")
57+ }
58+}
59+
60+func TestValidateHTTPTranscodeURLRejectsStreamingManifestURL(t *testing.T) {
61+ source, err := url.Parse("https://93.184.216.34/playlist.m3u8")
62+ if err != nil {
63+ t.Fatal(err)
64+ }
65+
66+ err = validateHTTPTranscodeURL(context.Background(), source)
67+ if err == nil {
68+ t.Fatal("validateHTTPTranscodeURL() expected error for streaming manifest")
69+ }
70+}
71+
72+func TestValidateHTTPTranscodeURLAllowsPublicIPAddress(t *testing.T) {
73+ source, err := url.Parse("https://93.184.216.34/video.mp4")
74+ if err != nil {
75+ t.Fatal(err)
76+ }
77+
78+ if err := validateHTTPTranscodeURL(context.Background(), source); err != nil {
79+ t.Fatalf("validateHTTPTranscodeURL() unexpected error: %v", err)
80+ }
81+}
82+
83+func TestOpenInputFormatContextRejectsStreamingManifests(t *testing.T) {
84+ p := newPipeline("test", port.HWCapabilities{}, "https://example.com/playlist.m3u8", nil, "", nil, zerolog.Nop())
85+
86+ _, _, err := p.openInputFormatContext(context.Background())
87+ if err == nil {
88+ t.Fatal("openInputFormatContext() expected error for streaming manifest")
89+ }
90+}
91+
92+func TestIsStreamingManifestResponseDetectsContentType(t *testing.T) {
93+ resp := &http.Response{Header: http.Header{"Content-Type": []string{"application/vnd.apple.mpegurl"}}}
94+ if !isStreamingManifestResponse(resp) {
95+ t.Fatal("isStreamingManifestResponse() = false, want true")
96+ }
97+}
98+
99+func TestValidateTranscodeRedirectRejectsPrivateTarget(t *testing.T) {
100+ requestURL, err := url.Parse("http://10.0.0.1/private.mp4")
101+ if err != nil {
102+ t.Fatal(err)
103+ }
104+
105+ err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil)
106+ if err == nil {
107+ t.Fatal("validateTranscodeRedirect() expected error for private redirect target")
108+ }
109+}
110+
111+func TestValidateTranscodeRedirectRejectsTenHopRedirect(t *testing.T) {
112+ requestURL, err := url.Parse("https://93.184.216.34/video.mp4")
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+
117+ via := make([]*http.Request, 10)
118+ for i := range via {
119+ via[i] = &http.Request{URL: requestURL}
120+ }
121+
122+ err = validateTranscodeRedirect(&http.Request{URL: requestURL}, via)
123+ if err == nil {
124+ t.Fatal("validateTranscodeRedirect() expected error for ten-hop redirect")
125+ }
126+}
127+
128+func TestValidateTranscodeRedirectRejectsStreamingManifestTarget(t *testing.T) {
129+ requestURL, err := url.Parse("https://93.184.216.34/playlist.m3u8")
130+ if err != nil {
131+ t.Fatal(err)
132+ }
133+
134+ err = validateTranscodeRedirect(&http.Request{URL: requestURL}, nil)
135+ if err == nil {
136+ t.Fatal("validateTranscodeRedirect() expected error for manifest redirect target")
137+ }
138+}
M internal/infrastructure/transcoder/hwprobe.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
13 package transcoder
24
35 import (
M internal/infrastructure/transcoder/hwprobe_test.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
13 package transcoder
24
35 import "testing"
M internal/infrastructure/transcoder/pipeline.go +112 -110
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
13 package transcoder
24
35 import (
@@ -5,13 +7,12 @@ import (
57 "errors"
68 "fmt"
79 "io"
10+ "net"
811 "net/http"
9- "sort"
12+ "net/url"
1013 "strings"
11- "unsafe"
12-
13- "net"
1414 "time"
15+ "unsafe"
1516
1617 "github.com/bnema/purego"
1718 "github.com/rs/zerolog"
@@ -19,6 +20,7 @@ import (
1920 "github.com/bnema/purego-ffmpeg/ffmpeg"
2021
2122 "github.com/bnema/dumber/internal/application/port"
23+ "github.com/bnema/dumber/internal/infrastructure/netguard"
2224 )
2325
2426 // avioBufSize is the buffer size for custom AVIO contexts (32 KiB).
@@ -53,10 +55,104 @@ type pipeline struct {
5355 // are not killed. The session context handles cancellation.
5456 var httpClient = &http.Client{
5557 Transport: &http.Transport{
56- DialContext: (&net.Dialer{Timeout: 10 * time.Second}).DialContext,
58+ DialContext: safeTranscodeDialContext,
5759 TLSHandshakeTimeout: 10 * time.Second,
5860 ResponseHeaderTimeout: 15 * time.Second,
5961 },
62+ CheckRedirect: validateTranscodeRedirect,
63+}
64+
65+func validateTranscodeRedirect(req *http.Request, via []*http.Request) error {
66+ if len(via) >= 10 {
67+ return fmt.Errorf("stopped after 10 redirects")
68+ }
69+ if err := validateHTTPTranscodeURL(req.Context(), req.URL); err != nil {
70+ return fmt.Errorf("blocked redirect target: %w", err)
71+ }
72+ return nil
73+}
74+
75+func safeTranscodeDialContext(ctx context.Context, network, address string) (net.Conn, error) {
76+ host, portValue, err := net.SplitHostPort(address)
77+ if err != nil {
78+ return nil, err
79+ }
80+
81+ ips, err := resolveAllowedTranscodeIPs(ctx, host)
82+ if err != nil {
83+ return nil, err
84+ }
85+
86+ dialer := &net.Dialer{Timeout: 10 * time.Second}
87+ var lastErr error
88+ for _, ip := range ips {
89+ conn, dialErr := dialer.DialContext(ctx, network, net.JoinHostPort(ip.String(), portValue))
90+ if dialErr == nil {
91+ return conn, nil
92+ }
93+ lastErr = dialErr
94+ }
95+ if lastErr != nil {
96+ return nil, lastErr
97+ }
98+ return nil, fmt.Errorf("no allowed transcode source addresses for %s", host)
99+}
100+
101+func validateHTTPTranscodeURL(ctx context.Context, source *url.URL) error {
102+ if source == nil || source.Host == "" {
103+ return fmt.Errorf("invalid src")
104+ }
105+ if source.Scheme != "http" && source.Scheme != "https" {
106+ return fmt.Errorf("unsupported src scheme")
107+ }
108+ if IsStreamingManifestURL(source.String()) {
109+ return fmt.Errorf("streaming manifest transcoding is disabled")
110+ }
111+ _, err := resolveAllowedTranscodeIPs(ctx, source.Hostname())
112+ return err
113+}
114+
115+func resolveAllowedTranscodeIPs(ctx context.Context, host string) ([]net.IP, error) {
116+ host = strings.TrimSpace(host)
117+ if host == "" {
118+ return nil, fmt.Errorf("invalid src host")
119+ }
120+ if ip := net.ParseIP(host); ip != nil {
121+ if isBlockedTranscodeIP(ip) {
122+ return nil, fmt.Errorf("private src host not allowed")
123+ }
124+ return []net.IP{ip}, nil
125+ }
126+
127+ lookupCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
128+ defer cancel()
129+ addrs, err := net.DefaultResolver.LookupIPAddr(lookupCtx, host)
130+ if err != nil || len(addrs) == 0 {
131+ return nil, fmt.Errorf("src host could not be resolved")
132+ }
133+ ips := make([]net.IP, 0, len(addrs))
134+ for _, addr := range addrs {
135+ if isBlockedTranscodeIP(addr.IP) {
136+ return nil, fmt.Errorf("private src host not allowed")
137+ }
138+ ips = append(ips, addr.IP)
139+ }
140+ return ips, nil
141+}
142+
143+func isBlockedTranscodeIP(ip net.IP) bool {
144+ return netguard.IsBlockedTranscodeIP(ip)
145+}
146+
147+func isStreamingManifestResponse(resp *http.Response) bool {
148+ if resp == nil {
149+ return false
150+ }
151+ if resp.Request != nil && resp.Request.URL != nil && IsStreamingManifestURL(resp.Request.URL.String()) {
152+ return true
153+ }
154+ contentType := strings.ToLower(resp.Header.Get("Content-Type"))
155+ return strings.Contains(contentType, "mpegurl") || strings.Contains(contentType, "dash+xml")
60156 }
61157
62158 func newPipeline(
@@ -394,7 +490,11 @@ func (p *pipeline) selectInputStreams(inFmtCtx unsafe.Pointer) (videoIdx, audioI
394490
395491 func (p *pipeline) openInputFormatContext(ctx context.Context) (unsafe.Pointer, func(), error) {
396492 if IsStreamingManifestURL(p.sourceURL) {
397- return p.openManifestInputContext()
493+ // FFmpeg resolves manifest redirects and segment URLs internally, bypassing
494+ // the guarded Go HTTP client below. Keep untrusted transcode requests on
495+ // custom I/O until manifests can be fetched and rewritten through the same
496+ // SSRF protections.
497+ return nil, nil, errors.New("streaming manifest transcoding is disabled")
398498 }
399499 return p.openCustomIOInputContext(ctx)
400500 }
@@ -417,6 +517,10 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer
417517 _ = resp.Body.Close()
418518 return nil, nil, fmt.Errorf("source returned HTTP %d", resp.StatusCode)
419519 }
520+ if isStreamingManifestResponse(resp) {
521+ _ = resp.Body.Close()
522+ return nil, nil, errors.New("streaming manifest transcoding is disabled")
523+ }
420524
421525 body := resp.Body
422526 readFn := func(_ unsafe.Pointer, buf unsafe.Pointer, bufSize int32) int32 {
@@ -478,110 +582,6 @@ func (p *pipeline) openCustomIOInputContext(ctx context.Context) (unsafe.Pointer
478582 return inFmtCtx, cleanup, nil
479583 }
480584
481-func (p *pipeline) openManifestInputContext() (unsafe.Pointer, func(), error) {
482- inFmtCtx := ffmpeg.FormatAllocContext()
483- if inFmtCtx == nil {
484- return nil, nil, errors.New("failed to allocate input format context")
485- }
486-
487- var opts unsafe.Pointer
488- defer freeInputOptions(&opts)
489-
490- if err := setInputOption(&opts, "protocol_whitelist", "file,http,https,tcp,tls,crypto"); err != nil {
491- ffmpeg.FormatFreeContext(inFmtCtx)
492- return nil, nil, err
493- }
494- if userAgent := p.headers["User-Agent"]; userAgent != "" {
495- if err := setInputOption(&opts, "user_agent", userAgent); err != nil {
496- ffmpeg.FormatFreeContext(inFmtCtx)
497- return nil, nil, err
498- }
499- }
500- if referer := p.headers["Referer"]; referer != "" {
501- if err := setInputOption(&opts, "referer", referer); err != nil {
502- ffmpeg.FormatFreeContext(inFmtCtx)
503- return nil, nil, err
504- }
505- }
506-
507- headerLines := formatHTTPHeaderOptions(p.headers)
508- if headerLines != "" {
509- if err := setInputOption(&opts, "headers", headerLines); err != nil {
510- ffmpeg.FormatFreeContext(inFmtCtx)
511- return nil, nil, err
512- }
513- }
514-
515- //nolint:gosec // FFmpeg requires pointer-to-pointer input opening for format contexts and option dictionaries.
516- if ret := ffmpeg.FormatOpenInput(unsafe.Pointer(&inFmtCtx), p.sourceURL, nil, unsafe.Pointer(&opts)); ret < 0 {
517- ffmpeg.FormatFreeContext(inFmtCtx)
518- return nil, nil, fmt.Errorf("open manifest input: %d", ret)
519- }
520-
521- return inFmtCtx, func() {
522- //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for format contexts.
523- ffmpeg.FormatCloseInput(unsafe.Pointer(&inFmtCtx))
524- }, nil
525-}
526-
527-func freeInputOptions(opts *unsafe.Pointer) {
528- if opts == nil || *opts == nil {
529- return
530- }
531- // av_dict_free expects an AVDictionary**. Passing the dictionary pointer
532- // itself corrupts memory and can crash inside libavutil during cleanup.
533- //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for cleanup.
534- ffmpeg.DictFree(unsafe.Pointer(opts))
535- *opts = nil
536-}
537-
538-func setInputOption(opts *unsafe.Pointer, key, value string) error {
539- if value == "" {
540- return nil
541- }
542- //nolint:gosec // FFmpeg requires passing the dictionary pointer by address for option updates.
543- if ret := ffmpeg.DictSet(unsafe.Pointer(opts), key, value, 0); ret < 0 {
544- return fmt.Errorf("set input option %q: %d", key, ret)
545- }
546- return nil
547-}
548-
549-func formatHTTPHeaderOptions(headers map[string]string) string {
550- if len(headers) == 0 {
551- return ""
552- }
553-
554- keys := make([]string, 0, len(headers))
555- for key, value := range headers {
556- if strings.TrimSpace(value) == "" {
557- continue
558- }
559- if strings.EqualFold(key, "User-Agent") || strings.EqualFold(key, "Referer") {
560- continue
561- }
562- keys = append(keys, key)
563- }
564- if len(keys) == 0 {
565- return ""
566- }
567-
568- sort.Strings(keys)
569-
570- var builder strings.Builder
571- for _, key := range keys {
572- builder.WriteString(key)
573- builder.WriteString(": ")
574- builder.WriteString(headers[key])
575- builder.WriteString("\r\n")
576- }
577- return builder.String()
578-}
579-
580-// openVideoDecoder creates and opens a decoder context for the video stream.
581-// It prefers hardware decoders (h264_vaapi, h264_cuvid) based on hwCaps,
582-// falling back to the generic software decoder.
583-//
584-//nolint:gosec // FFmpeg decoder setup requires unsafe pointer-based contexts and cleanup.
585585 func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, error) {
586586 codecID := ffmpeg.CodecParCodecID(codecPar)
587587
@@ -608,11 +608,13 @@ func (p *pipeline) openVideoDecoder(codecPar unsafe.Pointer) (unsafe.Pointer, er
608608 }
609609
610610 if ret := ffmpeg.CodecParametersToContext(decCtx, codecPar); ret < 0 {
611+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
611612 ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
612613 return nil, fmt.Errorf("copy video codec params: %d", ret)
613614 }
614615
615616 if ret := ffmpeg.CodecOpen2(decCtx, dec, nil); ret < 0 {
617+ //nolint:gosec // FFmpeg requires pointer-to-pointer cleanup for codec contexts.
616618 ffmpeg.CodecFreeContext(unsafe.Pointer(&decCtx))
617619 return nil, fmt.Errorf("open video decoder: %d", ret)
618620 }
M internal/infrastructure/transcoder/transcoder.go +2 -0
@@ -1,3 +1,5 @@
1+//go:build !js || !wasm
2+
13 package transcoder
24
35 import (
A internal/infrastructure/transcoder/transcoder_wasm.go +41 -0
@@ -0,0 +1,41 @@
1+//go:build js && wasm
2+
3+package transcoder
4+
5+import (
6+ "context"
7+ "errors"
8+
9+ "github.com/rs/zerolog"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+)
13+
14+// Compile-time check: Transcoder implements port.MediaTranscoder.
15+var _ port.MediaTranscoder = (*Transcoder)(nil)
16+
17+// Transcoder is unavailable in js/wasm builds because FFmpeg is not available.
18+type Transcoder struct{}
19+
20+// New creates an unavailable Transcoder for js/wasm builds.
21+func New(_ any, _ *zerolog.Logger) *Transcoder {
22+ return &Transcoder{}
23+}
24+
25+// Available reports whether transcoding is available.
26+func (*Transcoder) Available() bool {
27+ return false
28+}
29+
30+// Capabilities returns empty hardware capabilities for js/wasm builds.
31+func (*Transcoder) Capabilities() port.HWCapabilities {
32+ return port.HWCapabilities{}
33+}
34+
35+// Start always fails for js/wasm builds.
36+func (*Transcoder) Start(_ context.Context, _ string, _ map[string]string) (port.TranscodeSession, error) {
37+ return nil, errors.New("transcoder: FFmpeg unavailable in js/wasm")
38+}
39+
40+// Close is a no-op for js/wasm builds.
41+func (*Transcoder) Close() {}
M internal/infrastructure/webkit/engine_init.go +3 -0
@@ -25,6 +25,8 @@ func NewEngine(
2525 opts port.EngineOptions,
2626 profile runtimeprofile.Profile,
2727 wkCfg WebKitEngineConfig,
28+ currentConfigPayload func() ([]byte, error),
29+ defaultConfigPayload func() ([]byte, error),
2830 themeManager *theme.Manager,
2931 colorResolver port.ColorSchemeResolver,
3032 contextMenuBuilder port.ContextMenuBuilder,
@@ -56,6 +58,7 @@ func NewEngine(
5658
5759 // --- Scheme handler ---
5860 schemeHandler := NewDumbSchemeHandler(ctx)
61+ schemeHandler.SetConfigPayloadBuilders(currentConfigPayload, defaultConfigPayload)
5962 schemeHandler.SetAssets(assets.WebUIAssets)
6063 schemeHandler.RegisterWithContext(wkCtx)
6164
A internal/infrastructure/webkit/favicon_systemviews.go +14 -0
@@ -0,0 +1,14 @@
1+package webkit
2+
3+import "github.com/bnema/dumber/internal/application/port"
4+
5+var _ port.SystemviewFaviconServiceSetter = (*Engine)(nil)
6+
7+// SetSystemviewFaviconService exposes cached favicons to systemview pages via
8+// dumb://api/favicon without triggering remote favicon fetches during History rendering.
9+func (e *Engine) SetSystemviewFaviconService(service port.FaviconService) {
10+ if e == nil || e.schemeHandler == nil {
11+ return
12+ }
13+ e.schemeHandler.SetFaviconService(service)
14+}
M internal/infrastructure/webkit/scheme_handler.go +314 -87
@@ -1,20 +1,23 @@
11 package webkit
22
33 import (
4+ "bytes"
45 "context"
56 "embed"
6- "encoding/json"
77 "fmt"
8+ "io"
89 "io/fs"
910 "net/http"
1011 "net/url"
11- "path/filepath"
12+ "os"
13+ "path"
14+ "strconv"
1215 "strings"
1316 "sync"
1417
18+ "github.com/andybalholm/brotli"
1519 "github.com/bnema/dumber/internal/application/port"
16- "github.com/bnema/dumber/internal/infrastructure/config"
17- "github.com/bnema/dumber/internal/infrastructure/env"
20+ domainurl "github.com/bnema/dumber/internal/domain/url"
1821 "github.com/bnema/dumber/internal/infrastructure/webutil"
1922 "github.com/bnema/dumber/internal/logging"
2023 "github.com/bnema/puregotk/v4/gio"
@@ -25,29 +28,35 @@ import (
2528
2629 // Scheme path constants
2730 const (
28- HomePath = "home"
29- ConfigPath = "config"
30- WebRTCPath = "webrtc"
31- ErrorPath = "error"
32- CrashPath = "crash"
33- IndexHTML = "index.html"
34- httpGET = "GET"
31+ HistoryPath = "history"
32+ FavoritesPath = "favorites"
33+ ConfigPath = "config"
34+ ErrorPath = "error"
35+ CrashPath = "crash"
36+ IndexHTML = "index.html"
37+ httpGET = "GET"
38+ maxSystemviewsWASMBytes = 64 * 1024 * 1024
39+ systemviewsAssetDir = "systemviews"
3540 )
3641
3742 // SchemeRequest represents a request to a custom URI scheme.
3843 type SchemeRequest struct {
39- inner *webkit.URISchemeRequest
40- URI string
41- Path string
42- Method string
43- Scheme string
44+ inner *webkit.URISchemeRequest
45+ URI string
46+ Path string
47+ Method string
48+ Scheme string
49+ Origin string
50+ Referer string
4451 }
4552
4653 // SchemeResponse represents a response to a scheme request.
4754 type SchemeResponse struct {
48- Data []byte
49- ContentType string
50- StatusCode int
55+ Data []byte
56+ ContentType string
57+ StatusCode int
58+ Headers map[string]string
59+ SuppressDefaultHeaders bool
5160 }
5261
5362 // PageHandler generates content for a specific page path.
@@ -64,13 +73,14 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
6473
6574 // DumbSchemeHandler handles dumb:// URI scheme requests.
6675 type DumbSchemeHandler struct {
67- handlers map[string]PageHandler
68- assets embed.FS
69- assetDir string // subdirectory within embed.FS (e.g., "assets/webui")
70- logger zerolog.Logger
71- mu sync.RWMutex
72- hwSurveyor *env.HardwareSurveyor
73- ctx context.Context
76+ handlers map[string]PageHandler
77+ assets embed.FS
78+ faviconService port.FaviconService
79+ assetDir string // default subdirectory within embed.FS (e.g., "systemviews")
80+ logger zerolog.Logger
81+ mu sync.RWMutex
82+ currentConfigPayload func() ([]byte, error)
83+ defaultConfigPayload func() ([]byte, error)
7484 }
7585
7686 // NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
@@ -78,11 +88,9 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
7888 log := logging.FromContext(ctx)
7989
8090 h := &DumbSchemeHandler{
81- handlers: make(map[string]PageHandler),
82- assetDir: "webui",
83- logger: log.With().Str("component", "scheme-handler").Logger(),
84- hwSurveyor: env.NewHardwareSurveyor(),
85- ctx: ctx,
91+ handlers: make(map[string]PageHandler),
92+ assetDir: systemviewsAssetDir,
93+ logger: log.With().Str("component", "scheme-handler").Logger(),
8694 }
8795
8896 // Register default pages
@@ -91,7 +99,7 @@ func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
9199 return h
92100 }
93101
94-// SetAssets sets the embedded filesystem containing webui assets.
102+// SetAssets sets the embedded filesystem containing systemviews assets.
95103 func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
96104 h.mu.Lock()
97105 defer h.mu.Unlock()
@@ -99,6 +107,21 @@ func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
99107 h.logger.Debug().Msg("assets filesystem configured")
100108 }
101109
110+// SetConfigPayloadBuilders wires the config payload builders used by /api/config.
111+func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload func() ([]byte, error)) {
112+ h.mu.Lock()
113+ defer h.mu.Unlock()
114+ h.currentConfigPayload = current
115+ h.defaultConfigPayload = defaultPayload
116+ h.logger.Debug().Msg("config payload builders configured")
117+}
118+
119+func (h *DumbSchemeHandler) SetFaviconService(service port.FaviconService) {
120+ h.mu.Lock()
121+ defer h.mu.Unlock()
122+ h.faviconService = service
123+}
124+
102125 // registerDefaults sets up default page handlers.
103126 func (h *DumbSchemeHandler) registerDefaults() {
104127 // Error page (static fallback)
@@ -128,8 +151,14 @@ func (h *DumbSchemeHandler) registerDefaults() {
128151 if req.Method != "" && req.Method != httpGET {
129152 return nil
130153 }
154+ if !isTrustedSystemviewAPIRequest(req) {
155+ return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
156+ }
131157
132- return h.buildConfigResponse(config.Get())
158+ h.mu.RLock()
159+ build := h.currentConfigPayload
160+ h.mu.RUnlock()
161+ return buildConfigResponse(build)
133162 }))
134163
135164 // API: Get default config (used by Reset Defaults in dumb://config)
@@ -137,8 +166,21 @@ func (h *DumbSchemeHandler) registerDefaults() {
137166 if req.Method != "" && req.Method != httpGET {
138167 return nil
139168 }
169+ if !isTrustedSystemviewAPIRequest(req) {
170+ return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
171+ }
140172
141- return h.buildConfigResponse(config.DefaultConfig())
173+ h.mu.RLock()
174+ build := h.defaultConfigPayload
175+ h.mu.RUnlock()
176+ return buildConfigResponse(build)
177+ }))
178+
179+ h.RegisterPage("/api/favicon", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
180+ if req.Method != "" && req.Method != httpGET {
181+ return nil
182+ }
183+ return h.handleFaviconAPI(req)
142184 }))
143185 }
144186
@@ -161,40 +203,138 @@ func buildCrashPageHTML(originalURI string) string {
161203 return webutil.BuildCrashPageHTML(originalURI)
162204 }
163205
164-func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse {
165- // Get hardware info for display and profile resolution
166- // Use background context since survey results are cached and we don't want
167- // request context cancellation to affect this
168- var hw *port.HardwareInfo
169- if h.hwSurveyor != nil {
170- hwInfo := h.hwSurveyor.Survey(context.Background())
171- hw = &hwInfo
172- }
173-
174- resp := config.BuildWebUIConfigPayload(cfg, hw)
206+const systemviewFaviconSize = 32
175207
176- data, err := json.Marshal(resp)
208+func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse {
209+ if !isTrustedSystemviewFaviconRequest(req) {
210+ return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
211+ }
212+ h.mu.RLock()
213+ service := h.faviconService
214+ h.mu.RUnlock()
215+ if service == nil {
216+ return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable")
217+ }
218+ if req == nil {
219+ return privateJSONErrorResponse(http.StatusBadRequest, "invalid request")
220+ }
221+ parsed, err := url.Parse(req.URI)
177222 if err != nil {
178- return &SchemeResponse{
179- Data: []byte(fmt.Sprintf(`{"error": %q}`, err)),
180- ContentType: "application/json",
181- StatusCode: http.StatusInternalServerError,
223+ return privateJSONErrorResponse(http.StatusBadRequest, "invalid request URL")
224+ }
225+ domain := domainurl.CanonicalDomain(parsed.Query().Get("domain"))
226+ if domain == "" {
227+ return privateJSONErrorResponse(http.StatusBadRequest, "missing domain")
228+ }
229+ size := systemviewFaviconSize
230+ if rawSize := strings.TrimSpace(parsed.Query().Get("size")); rawSize != "" {
231+ parsedSize, parseErr := strconv.Atoi(rawSize)
232+ if parseErr != nil || parsedSize != systemviewFaviconSize {
233+ return privateJSONErrorResponse(http.StatusBadRequest, "unsupported favicon size")
182234 }
235+ size = parsedSize
183236 }
184237
238+ if !service.HasPNGSizedOnDisk(domain, size) {
239+ return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
240+ }
241+ diskPath := service.DiskPathPNGSized(domain, size)
242+ if diskPath == "" {
243+ return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
244+ }
245+ data, err := os.ReadFile(diskPath)
246+ if err != nil || len(data) == 0 {
247+ return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
248+ }
185249 return &SchemeResponse{
186- Data: data,
250+ Data: data,
251+ ContentType: "image/png",
252+ StatusCode: http.StatusOK,
253+ Headers: map[string]string{"Cache-Control": "no-store"},
254+ SuppressDefaultHeaders: true,
255+ }
256+}
257+
258+func isTrustedSystemviewFaviconRequest(req *SchemeRequest) bool {
259+ return isTrustedSystemviewAPIRequest(req)
260+}
261+
262+func isTrustedSystemviewAPIRequest(req *SchemeRequest) bool {
263+ if req == nil {
264+ return false
265+ }
266+ origin := strings.TrimSpace(req.Origin)
267+ if origin != "" {
268+ return isTrustedSystemviewURL(origin)
269+ }
270+ return isTrustedSystemviewURL(req.Referer)
271+}
272+
273+func isTrustedSystemviewURL(raw string) bool {
274+ parsed, err := url.Parse(strings.TrimSpace(raw))
275+ if err != nil || parsed.Scheme == "" {
276+ return false
277+ }
278+ if !strings.EqualFold(parsed.Scheme, "dumb") {
279+ return false
280+ }
281+ host := parsed.Host
282+ if host == "" {
283+ host = parsed.Opaque
284+ }
285+ if idx := strings.IndexAny(host, "/?#"); idx >= 0 {
286+ host = host[:idx]
287+ }
288+ switch host {
289+ case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
290+ return true
291+ default:
292+ return false
293+ }
294+}
295+
296+func jsonErrorResponse(status int, message string) *SchemeResponse {
297+ return &SchemeResponse{
298+ Data: []byte(fmt.Sprintf(`{"error":%q}`, message)),
187299 ContentType: "application/json",
188- StatusCode: http.StatusOK,
300+ StatusCode: status,
301+ }
302+}
303+
304+func privateJSONErrorResponse(status int, message string) *SchemeResponse {
305+ resp := jsonErrorResponse(status, message)
306+ resp.Headers = map[string]string{"Cache-Control": "no-store"}
307+ resp.SuppressDefaultHeaders = true
308+ return resp
309+}
310+
311+func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse {
312+ if build == nil {
313+ resp := privateJSONErrorResponse(http.StatusInternalServerError, "config payload builder not configured")
314+ return resp
315+ }
316+
317+ data, err := build()
318+ if err != nil {
319+ resp := privateJSONErrorResponse(http.StatusInternalServerError, err.Error())
320+ return resp
321+ }
322+
323+ return &SchemeResponse{
324+ Data: data,
325+ ContentType: "application/json",
326+ StatusCode: http.StatusOK,
327+ Headers: map[string]string{"Cache-Control": "no-store"},
328+ SuppressDefaultHeaders: true,
189329 }
190330 }
191331
192332 // RegisterPage registers a handler for a specific path.
193-func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {
333+func (h *DumbSchemeHandler) RegisterPage(pagePath string, handler PageHandler) {
194334 h.mu.Lock()
195335 defer h.mu.Unlock()
196- h.handlers[path] = handler
197- h.logger.Debug().Str("path", path).Msg("registered page handler")
336+ h.handlers[pagePath] = handler
337+ h.logger.Debug().Str("path", pagePath).Msg("registered page handler")
198338 }
199339
200340 // HandleRequest processes a scheme request and sends the response.
@@ -205,6 +345,7 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
205345 }
206346
207347 uri := req.GetUri()
348+ requestHeaders := req.GetHttpHeaders()
208349 schemeReq := &SchemeRequest{
209350 inner: req,
210351 URI: uri,
@@ -212,6 +353,10 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
212353 Method: req.GetHttpMethod(),
213354 Scheme: req.GetScheme(),
214355 }
356+ if requestHeaders != nil {
357+ schemeReq.Origin = strings.TrimSpace(requestHeaders.GetOne("Origin"))
358+ schemeReq.Referer = strings.TrimSpace(requestHeaders.GetOne("Referer"))
359+ }
215360
216361 h.logger.Debug().
217362 Str("uri", schemeReq.URI).
@@ -279,21 +424,26 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
279424 func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
280425 h.mu.RLock()
281426 hasAssets := h.assets != (embed.FS{})
282- assetDir := h.assetDir
283427 h.mu.RUnlock()
284428
285429 if !hasAssets {
286430 return nil
287431 }
288432
289- relPath, ok := resolveAssetPath(u)
433+ assetDir, relPath, ok := resolveAssetPath(u)
434+ if !ok {
435+ return nil
436+ }
437+ if assetDir == "" {
438+ assetDir = h.assetDir
439+ }
440+
441+ fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
290442 if !ok {
291443 return nil
292444 }
293445
294- // Read the asset from embedded FS
295- fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
296- data, err := fs.ReadFile(h.assets, fullPath)
446+ data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
297447 if err != nil {
298448 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
299449 return nil
@@ -310,43 +460,111 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
310460 Data: data,
311461 ContentType: contentType,
312462 StatusCode: http.StatusOK,
463+ Headers: nil,
464+ }
465+}
466+
467+func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath string, ok bool) {
468+ assetDir = strings.Trim(assetDir, "/")
469+ if assetDir != systemviewsAssetDir {
470+ return "", "", false
313471 }
472+
473+ relPath = strings.TrimLeft(relPath, "/")
474+ if relPath == "" || strings.ContainsRune(relPath, '\x00') {
475+ return "", "", false
476+ }
477+
478+ cleanRelPath = path.Clean(relPath)
479+ if cleanRelPath == "." || cleanRelPath == ".." || strings.HasPrefix(cleanRelPath, "../") || path.IsAbs(cleanRelPath) {
480+ return "", "", false
481+ }
482+
483+ fullPath = path.Join(assetDir, cleanRelPath)
484+ if fullPath != assetDir && !strings.HasPrefix(fullPath, assetDir+"/") {
485+ return "", "", false
486+ }
487+ return fullPath, cleanRelPath, true
314488 }
315489
316-func resolveAssetPath(u *url.URL) (string, bool) {
490+func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) {
491+ var compressedErr error
492+ if strings.HasSuffix(relPath, ".wasm") {
493+ if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
494+ data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
495+ if err != nil {
496+ compressedErr = err
497+ } else if len(data) > maxSystemviewsWASMBytes {
498+ compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
499+ } else {
500+ return data, nil
501+ }
502+ }
503+ }
504+ data, err := fs.ReadFile(assets, fullPath)
505+ if err == nil {
506+ return data, nil
507+ }
508+ if compressedErr != nil {
509+ return nil, compressedErr
510+ }
511+ return nil, err
512+}
513+
514+func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
317515 if u == nil {
318- return "", false
516+ return "", "", false
319517 }
320518
321- rootByHost := map[string]string{
322- HomePath: IndexHTML,
323- ConfigPath: "config.html",
324- WebRTCPath: "webrtc.html",
325- ErrorPath: "error.html",
519+ rootByHost := map[string]struct {
520+ assetDir string
521+ file string
522+ }{
523+ HistoryPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
524+ FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
525+ ConfigPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
526+ ErrorPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
527+ CrashPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
326528 }
327529
328530 if root, ok := rootByHost[u.Host]; ok {
329- path := strings.TrimPrefix(u.Path, "/")
330- if path == "" {
331- return root, true
531+ assetPath := strings.TrimPrefix(u.Path, "/")
532+ if assetPath == "" {
533+ return root.assetDir, root.file, true
332534 }
333- return path, true
535+ return root.assetDir, assetPath, true
334536 }
335537
336538 switch u.Opaque {
337- case HomePath:
338- return IndexHTML, true
339- case ConfigPath:
340- return "config.html", true
341- case ErrorPath:
342- return "error.html", true
343- case WebRTCPath:
344- return "webrtc.html", true
539+ case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
540+ return systemviewsAssetDir, IndexHTML, true
345541 default:
346- return "", false
542+ return "", "", false
347543 }
348544 }
349545
546+func shouldAddCORSHeaders(requestPath string) bool {
547+ requestPath = strings.TrimSpace(requestPath)
548+ return strings.HasSuffix(requestPath, ".wasm")
549+}
550+
551+func responseHeadersForPath(requestPath, contentType string) map[string]string {
552+ if !shouldAddCORSHeaders(requestPath) {
553+ return nil
554+ }
555+
556+ headers := map[string]string{
557+ "Access-Control-Allow-Origin": "*",
558+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
559+ "Access-Control-Allow-Headers": "Content-Type",
560+ "Access-Control-Max-Age": "86400",
561+ }
562+ if contentType != "" {
563+ headers["Content-Type"] = contentType
564+ }
565+ return headers
566+}
567+
350568 // sendResponse sends the response back to WebKit.
351569 func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
352570 if response == nil {
@@ -378,14 +596,23 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response
378596 schemeResp.SetContentType(contentType)
379597 schemeResp.SetStatus(uint(response.StatusCode), nil)
380598
381- // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().
382- // We only add CORS headers for our internal API endpoints.
383- if strings.HasPrefix(req.GetPath(), "/api/") {
599+ // WebKit can treat custom schemes as CORS-relevant for the wasm runtime asset.
600+ // Private systemview APIs opt out with SuppressDefaultHeaders and validate callers.
601+ var headers map[string]string
602+ if !response.SuppressDefaultHeaders {
603+ headers = responseHeadersForPath(req.GetPath(), contentType)
604+ }
605+ for name, value := range response.Headers {
606+ if headers == nil {
607+ headers = map[string]string{}
608+ }
609+ headers[name] = value
610+ }
611+ if len(headers) > 0 {
384612 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
385- hdrs.Append("Access-Control-Allow-Origin", "*")
386- hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
387- hdrs.Append("Access-Control-Allow-Headers", "Content-Type")
388- hdrs.Append("Access-Control-Max-Age", "86400")
613+ for name, value := range headers {
614+ hdrs.Append(name, value)
615+ }
389616 schemeResp.SetHttpHeaders(hdrs)
390617 }
391618
M internal/infrastructure/webkit/scheme_handler_test.go +216 -19
@@ -2,7 +2,9 @@ package webkit
22
33 import (
44 "context"
5+ "net/http"
56 "net/url"
7+ "strings"
68 "testing"
79
810 "github.com/bnema/dumber/assets"
@@ -11,8 +13,8 @@ import (
1113 )
1214
1315 func TestCrashOriginalURI(t *testing.T) {
14- assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
15- assert.Empty(t, crashOriginalURI("dumb://home/crash"))
16+ assert.Equal(t, "https://example.com/path?q=1", crashOriginalURI("dumb://history/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
17+ assert.Empty(t, crashOriginalURI("dumb://history/crash"))
1618 assert.Empty(t, crashOriginalURI("%%%"))
1719 }
1820
@@ -37,8 +39,8 @@ func TestBuildCrashPageHTMLRejectsUnsafeReloadScheme(t *testing.T) {
3739 }
3840
3941 func TestBuildCrashPageHTMLAllowsDumbSchemeTarget(t *testing.T) {
40- body := buildCrashPageHTML("dumb://home")
41- assert.Contains(t, body, `data-target="dumb://home"`)
42+ body := buildCrashPageHTML("dumb://history")
43+ assert.Contains(t, body, `data-target="dumb://history"`)
4244 }
4345
4446 func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) {
@@ -48,6 +50,41 @@ func TestBuildCrashPageHTMLEscapesScriptBreakoutPayload(t *testing.T) {
4850 assert.Contains(t, body, "&lt;/script&gt;&lt;script&gt;alert(1)&lt;/script&gt;")
4951 }
5052
53+func TestIsTrustedSystemviewURL(t *testing.T) {
54+ t.Parallel()
55+
56+ for _, raw := range []string{
57+ "dumb://history",
58+ "dumb://favorites",
59+ "dumb:config",
60+ } {
61+ require.True(t, isTrustedSystemviewURL(raw), raw)
62+ }
63+ for _, raw := range []string{
64+ "",
65+ "https://dumber.invalid/history",
66+ "dumb://api/favicon",
67+ "dumb://evil/history",
68+ } {
69+ require.False(t, isTrustedSystemviewURL(raw), raw)
70+ }
71+}
72+
73+func TestIsTrustedSystemviewFaviconRequestRequiresTrustedRefererWhenOriginAbsent(t *testing.T) {
74+ t.Parallel()
75+
76+ require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: "dumb://history"}))
77+ require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Referer: ""}))
78+}
79+
80+func TestIsTrustedSystemviewFaviconRequestHonorsOriginBeforeReferer(t *testing.T) {
81+ t.Parallel()
82+
83+ require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history"}))
84+ require.True(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "dumb://history", Referer: "https://evil.example"}))
85+ require.False(t, isTrustedSystemviewFaviconRequest(&SchemeRequest{Origin: "https://evil.example", Referer: "dumb://history"}))
86+}
87+
5188 func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) {
5289 handler := NewDumbSchemeHandler(context.Background())
5390 require.NotNil(t, handler)
@@ -59,7 +96,7 @@ func TestRegisterDefaultsIncludesCrashHandler(t *testing.T) {
5996 require.NotNil(t, crashHandler)
6097
6198 resp := crashHandler.Handle(&SchemeRequest{
62- URI: "dumb://home/crash?url=https%3A%2F%2Fexample.com",
99+ URI: "dumb://history/crash?url=https%3A%2F%2Fexample.com",
63100 Path: "/crash",
64101 Method: "GET",
65102 Scheme: "dumb",
@@ -81,7 +118,7 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) {
81118 require.NotNil(t, crashHandler)
82119
83120 resp := crashHandler.Handle(&SchemeRequest{
84- URI: "dumb://home/crash?url=javascript%3Aalert(1)",
121+ URI: "dumb://history/crash?url=javascript%3Aalert(1)",
85122 Path: "/crash",
86123 Method: "GET",
87124 Scheme: "dumb",
@@ -90,41 +127,201 @@ func TestCrashHandlerSanitizesUnsafeURLQuery(t *testing.T) {
90127 assert.Contains(t, string(resp.Data), `data-target=""`)
91128 }
92129
93-func TestHandleAsset_WebRTCRootServesInternalWebRTCTester(t *testing.T) {
130+func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) {
94131 h := NewDumbSchemeHandler(context.Background())
95132 h.SetAssets(assets.WebUIAssets)
96133
97- u, err := url.Parse("dumb://webrtc/")
134+ u, err := url.Parse("dumb:config")
98135 require.NoError(t, err)
99136
100137 resp := h.handleAsset(u)
101138 require.NotNil(t, resp)
102139 assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
103- assert.Contains(t, string(resp.Data), "webrtc.min.js")
140+ assert.Contains(t, string(resp.Data), "systemviews.wasm")
104141 }
105142
106-func TestHandleAsset_WebRTCOpaqueFormServesInternalWebRTCTester(t *testing.T) {
143+func TestHandleAsset_SystemviewsCSSIsServed(t *testing.T) {
107144 h := NewDumbSchemeHandler(context.Background())
108145 h.SetAssets(assets.WebUIAssets)
109146
110- u, err := url.Parse("dumb:webrtc")
147+ u, err := url.Parse("dumb://history/systemviews.css")
111148 require.NoError(t, err)
112149
113150 resp := h.handleAsset(u)
114151 require.NotNil(t, resp)
115- assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
116- assert.Contains(t, string(resp.Data), "webrtc.min.js")
152+ // Charset suffixes are acceptable; assert the MIME type contract only.
153+ assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0])
154+ assert.Contains(t, string(resp.Data), ".sv-app")
117155 }
118156
119-func TestHandleAsset_ConfigOpaqueFormServesConfigPage(t *testing.T) {
157+func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) {
120158 h := NewDumbSchemeHandler(context.Background())
121159 h.SetAssets(assets.WebUIAssets)
122160
123- u, err := url.Parse("dumb:config")
161+ tests := []string{
162+ "dumb://history",
163+ "dumb:history",
164+ "dumb://favorites",
165+ "dumb:favorites",
166+ "dumb://config",
167+ "dumb:config",
168+ "dumb://error",
169+ "dumb:error",
170+ }
171+
172+ for _, raw := range tests {
173+ t.Run(raw, func(t *testing.T) {
174+ u, err := url.Parse(raw)
175+ require.NoError(t, err)
176+
177+ resp := h.handleAsset(u)
178+ require.NotNil(t, resp)
179+ assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
180+ assert.Contains(t, string(resp.Data), "systemviews.wasm")
181+ })
182+ }
183+}
184+
185+func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) {
186+ h := NewDumbSchemeHandler(context.Background())
187+ h.SetConfigPayloadBuilders(
188+ func() ([]byte, error) { return []byte(`{"current":true}`), nil },
189+ func() ([]byte, error) { return []byte(`{"default":true}`), nil },
190+ )
191+
192+ h.mu.RLock()
193+ currentHandler := h.handlers["/api/config"]
194+ defaultHandler := h.handlers["/api/config/default"]
195+ h.mu.RUnlock()
196+ require.NotNil(t, currentHandler)
197+ require.NotNil(t, defaultHandler)
198+
199+ currentResp := currentHandler.Handle(&SchemeRequest{
200+ URI: "dumb://config/api/config",
201+ Path: "/api/config",
202+ Method: http.MethodGet,
203+ Scheme: "dumb",
204+ Referer: "dumb://config",
205+ })
206+ require.NotNil(t, currentResp)
207+ assert.Equal(t, http.StatusOK, currentResp.StatusCode)
208+ assert.Equal(t, "application/json", currentResp.ContentType)
209+ assert.Equal(t, []byte(`{"current":true}`), currentResp.Data)
210+
211+ defaultResp := defaultHandler.Handle(&SchemeRequest{
212+ URI: "dumb://config/api/config/default",
213+ Path: "/api/config/default",
214+ Method: http.MethodGet,
215+ Scheme: "dumb",
216+ Referer: "dumb://config",
217+ })
218+ require.NotNil(t, defaultResp)
219+ assert.Equal(t, http.StatusOK, defaultResp.StatusCode)
220+ assert.Equal(t, "application/json", defaultResp.ContentType)
221+ assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data)
222+}
223+
224+func TestConfigHandlersRejectUntrustedRequests(t *testing.T) {
225+ h := NewDumbSchemeHandler(context.Background())
226+ h.SetConfigPayloadBuilders(
227+ func() ([]byte, error) { return []byte(`{"current":true}`), nil },
228+ func() ([]byte, error) { return []byte(`{"default":true}`), nil },
229+ )
230+
231+ h.mu.RLock()
232+ currentHandler := h.handlers["/api/config"]
233+ defaultHandler := h.handlers["/api/config/default"]
234+ h.mu.RUnlock()
235+
236+ for _, handler := range []PageHandler{currentHandler, defaultHandler} {
237+ resp := handler.Handle(&SchemeRequest{Method: http.MethodGet, Origin: "https://evil.example"})
238+ require.NotNil(t, resp)
239+ assert.Equal(t, http.StatusForbidden, resp.StatusCode)
240+ assert.True(t, resp.SuppressDefaultHeaders)
241+ assert.NotContains(t, resp.Headers, "Access-Control-Allow-Origin")
242+ }
243+}
244+
245+func TestShouldAddCORSHeaders(t *testing.T) {
246+ t.Parallel()
247+
248+ tests := []struct {
249+ name string
250+ path string
251+ want bool
252+ }{
253+ {name: "private api route", path: "/api/config", want: false},
254+ {name: "wasm asset", path: "/systemviews.wasm", want: true},
255+ {name: "html shell", path: "/", want: false},
256+ {name: "js asset", path: "/wasm_exec.js", want: false},
257+ }
258+
259+ for _, tt := range tests {
260+ t.Run(tt.name, func(t *testing.T) {
261+ t.Parallel()
262+ assert.Equal(t, tt.want, shouldAddCORSHeaders(tt.path))
263+ })
264+ }
265+}
266+
267+func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) {
268+ t.Parallel()
269+
270+ headers := responseHeadersForPath("/systemviews.wasm", "application/wasm")
271+ assert.Equal(t, "application/wasm", headers["Content-Type"])
272+ assert.Equal(t, "*", headers["Access-Control-Allow-Origin"])
273+ assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"])
274+}
275+
276+func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) {
277+ t.Parallel()
278+
279+ data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm")
124280 require.NoError(t, err)
281+ require.GreaterOrEqual(t, len(data), 4)
282+ assert.Equal(t, "\x00asm", string(data[:4]))
283+}
125284
126- resp := h.handleAsset(u)
127- require.NotNil(t, resp)
128- assert.Equal(t, "text/html; charset=utf-8", resp.ContentType)
129- assert.Contains(t, string(resp.Data), "config")
285+func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) {
286+ h := NewDumbSchemeHandler(context.Background())
287+ h.SetAssets(assets.WebUIAssets)
288+
289+ for _, raw := range []string{
290+ "dumb://history/../logo.svg",
291+ "dumb://history/nested/../../logo.svg",
292+ } {
293+ t.Run(raw, func(t *testing.T) {
294+ u, err := url.Parse(raw)
295+ require.NoError(t, err)
296+ assert.Nil(t, h.handleAsset(u))
297+ })
298+ }
299+}
300+
301+func TestSafeSystemviewsAssetPathRejectsTraversal(t *testing.T) {
302+ t.Parallel()
303+
304+ fullPath, relPath, ok := safeSystemviewsAssetPath(systemviewsAssetDir, "nested/../systemviews.css")
305+ require.True(t, ok)
306+ assert.Equal(t, "systemviews/systemviews.css", fullPath)
307+ assert.Equal(t, "systemviews.css", relPath)
308+
309+ invalid := []struct {
310+ name string
311+ assetDir string
312+ relPath string
313+ }{
314+ {name: "parent escape", assetDir: systemviewsAssetDir, relPath: "../logo.svg"},
315+ {name: "nested parent escape", assetDir: systemviewsAssetDir, relPath: "nested/../../logo.svg"},
316+ {name: "absolute parent escape", assetDir: systemviewsAssetDir, relPath: "/../logo.svg"},
317+ {name: "null byte", assetDir: systemviewsAssetDir, relPath: "systemviews.css\x00"},
318+ {name: "wrong asset dir", assetDir: "logos", relPath: "logo.svg"},
319+ }
320+ for _, tt := range invalid {
321+ t.Run(tt.name, func(t *testing.T) {
322+ t.Parallel()
323+ _, _, ok := safeSystemviewsAssetPath(tt.assetDir, tt.relPath)
324+ assert.False(t, ok)
325+ })
326+ }
130327 }
M internal/infrastructure/webutil/mime.go +5 -1
@@ -9,7 +9,11 @@ import (
99 // GetMimeType determines the MIME type for a given file path.
1010 // It tries the standard library first, then falls back to common web asset types.
1111 func GetMimeType(filename string) string {
12- ext := strings.ToLower(filepath.Ext(filename))
12+ lowerName := strings.ToLower(filename)
13+ ext := filepath.Ext(lowerName)
14+ if ext == ".br" {
15+ return GetMimeType(strings.TrimSuffix(lowerName, ext))
16+ }
1317
1418 mt := mime.TypeByExtension(ext)
1519 if mt != "" {
A internal/infrastructure/webutil/mime_test.go +31 -0
@@ -0,0 +1,31 @@
1+package webutil
2+
3+import "testing"
4+
5+func TestGetMimeType_BrotliSuffixAndWASM(t *testing.T) {
6+ t.Parallel()
7+
8+ tests := []struct {
9+ name string
10+ filename string
11+ want string
12+ }{
13+ {name: "path input", filename: "assets/systemviews/systemviews.wasm", want: "application/wasm"},
14+ {name: "uppercase extension", filename: "systemviews.WASM", want: "application/wasm"},
15+ {name: "brotli compressed wasm", filename: "systemviews.wasm.br", want: "application/wasm"},
16+ {name: "uppercase brotli compressed wasm", filename: "SYSTEMVIEWS.WASM.BR", want: "application/wasm"},
17+ {name: "plain brotli", filename: "data.br", want: "text/plain"},
18+ {name: "js brotli", filename: "app.js.br", want: "text/javascript; charset=utf-8"},
19+ {name: "tar brotli", filename: "archive.tar.br", want: "application/x-tar"},
20+ }
21+
22+ for _, tt := range tests {
23+ t.Run(tt.name, func(t *testing.T) {
24+ t.Parallel()
25+
26+ if got := GetMimeType(tt.filename); got != tt.want {
27+ t.Fatalf("GetMimeType(%s) = %q, want %q", tt.filename, got, tt.want)
28+ }
29+ })
30+ }
31+}
M internal/ui/app.go +5 -1
@@ -204,6 +204,10 @@ func New(deps *Dependencies) (*App, error) {
204204 glib.IdleAdd(&cb, 0)
205205 })
206206 }
207+ if faviconSetter, ok := deps.Engine.(port.SystemviewFaviconServiceSetter); ok {
208+ faviconSetter.SetSystemviewFaviconService(deps.FaviconService)
209+ }
210+
207211 // Register message handlers through the engine.
208212 if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{
209213 HistoryUC: deps.HistoryUC,
@@ -795,7 +799,7 @@ func (a *App) initialWindowURL() string {
795799 if a.deps != nil && a.deps.InitialURL != "" {
796800 return a.deps.InitialURL
797801 }
798- return "dumb://home"
802+ return "dumb://history"
799803 }
800804
801805 func (a *App) initLayoutInfrastructure() {
M internal/ui/app_clipboard_test.go +3 -2
@@ -4,6 +4,7 @@ import (
44 "context"
55 "testing"
66
7+ "github.com/bnema/dumber/internal/application/dto"
78 "github.com/bnema/dumber/internal/application/port"
89 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
910 "github.com/bnema/dumber/internal/domain/entity"
@@ -28,7 +29,7 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T)
2829 Config: &config.Config{},
2930 Engine: engine,
3031 HandlerDeps: port.HandlerDeps{
31- SaveConfig: func(context.Context, port.WebUIConfig) error { return nil },
32+ SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
3233 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
3334 },
3435 Clipboard: nil,
@@ -57,7 +58,7 @@ func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) {
5758 Config: &config.Config{},
5859 Engine: engine,
5960 HandlerDeps: port.HandlerDeps{
60- SaveConfig: func(context.Context, port.WebUIConfig) error { return nil },
61+ SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
6162 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
6263 },
6364 Clipboard: clipboard,
M internal/ui/coordinator/content/appearance.go +1 -1
@@ -14,7 +14,7 @@ import (
1414
1515 const (
1616 aboutBlankURI = "about:blank"
17- crashPageURI = "dumb://home/crash"
17+ crashPageURI = "dumb://history/crash"
1818 logURLMaxLen = 80
1919 oauthParentRefreshDebounce = 200 * time.Millisecond
2020
M internal/ui/coordinator/content/callbacks.go +2 -2
@@ -12,8 +12,8 @@ import (
1212 "github.com/bnema/puregotk/v4/gdk"
1313 )
1414
15-// internalSchemePath is the path used in dumb:// URIs (replaces webkit.HomePath).
16-const internalSchemePath = "home"
15+// internalSchemePath is the host used in dumb:// crash-page URIs.
16+const internalSchemePath = "history"
1717
1818 func shouldRenderCrashPage(reason port.WebProcessTerminationReason) bool {
1919 switch reason {
M internal/ui/coordinator/content/content_termination_test.go +4 -4
@@ -15,13 +15,13 @@ func TestShouldRenderCrashPage(t *testing.T) {
1515 }
1616
1717 func TestExtractOriginalURIFromCrashPage(t *testing.T) {
18- assert.Equal(t, "https://example.com/path?q=1", extractOriginalURIFromCrashPage("dumb://home/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
19- assert.Empty(t, extractOriginalURIFromCrashPage("dumb://home/crash"))
18+ assert.Equal(t, "https://example.com/path?q=1", extractOriginalURIFromCrashPage("dumb://history/crash?url=https%3A%2F%2Fexample.com%2Fpath%3Fq%3D1"))
19+ assert.Empty(t, extractOriginalURIFromCrashPage("dumb://history/crash"))
2020 assert.Equal(t, "https://example.com", extractOriginalURIFromCrashPage("https://example.com"))
2121 assert.Equal(t, "%%%bad", extractOriginalURIFromCrashPage("%%%bad"))
2222 }
2323
2424 func TestBuildCrashPageURI(t *testing.T) {
25- assert.Equal(t, "dumb://home/crash", buildCrashPageURI(""))
26- assert.Equal(t, "dumb://home/crash?url=https%3A%2F%2Fexample.com%2Ffoo%3Fa%3D1%26b%3D2", buildCrashPageURI("https://example.com/foo?a=1&b=2"))
25+ assert.Equal(t, "dumb://history/crash", buildCrashPageURI(""))
26+ assert.Equal(t, "dumb://history/crash?url=https%3A%2F%2Fexample.com%2Ffoo%3Fa%3D1%26b%3D2", buildCrashPageURI("https://example.com/foo?a=1&b=2"))
2727 }
M internal/ui/coordinator/workspace.go +128 -1
@@ -4,6 +4,8 @@ import (
44 "context"
55 "errors"
66 "fmt"
7+ "net/url"
8+ "strings"
79
810 "github.com/bnema/dumber/internal/application/usecase"
911 "github.com/bnema/dumber/internal/domain/entity"
@@ -171,6 +173,15 @@ func setupPaneViewHover(ctx context.Context, pv *component.PaneView, wsView *com
171173
172174 // Split splits the active pane in the given direction.
173175 func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.SplitDirection) error {
176+ return c.splitWithInitialURL(ctx, direction, domainurl.Normalize(c.newPaneURL))
177+}
178+
179+// SplitWithURL splits the active pane in the given direction and loads initialURL.
180+func (c *WorkspaceCoordinator) SplitWithURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
181+ return c.splitWithInitialURL(ctx, direction, domainurl.Normalize(initialURL))
182+}
183+
184+func (c *WorkspaceCoordinator) splitWithInitialURL(ctx context.Context, direction usecase.SplitDirection, initialURL string) error {
174185 log := logging.FromContext(ctx)
175186
176187 splitCtx, ok := c.prepareSplit(ctx, direction)
@@ -182,7 +193,7 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli
182193 Workspace: splitCtx.ws,
183194 TargetPane: splitCtx.activePane,
184195 Direction: direction,
185- InitialURL: domainurl.Normalize(c.newPaneURL),
196+ InitialURL: initialURL,
186197 })
187198 if err != nil {
188199 log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
@@ -212,6 +223,122 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli
212223 return nil
213224 }
214225
226+// ToggleSystemViewRight focuses an existing system view pane, closes it if it is
227+// already active, or opens it in a right split when no matching pane exists.
228+func (c *WorkspaceCoordinator) ToggleSystemViewRight(ctx context.Context, targetURL string) error {
229+ log := logging.FromContext(ctx)
230+ targetURL = domainurl.Normalize(targetURL)
231+ if targetURL == "" {
232+ return fmt.Errorf("system view URL is required")
233+ }
234+ if c.getActiveWS == nil {
235+ log.Warn().Str("url", targetURL).Msg("active workspace provider not configured")
236+ return fmt.Errorf("system view toggle: active workspace provider not configured")
237+ }
238+
239+ ws, wsView := c.getActiveWS()
240+ if ws == nil {
241+ log.Warn().Str("url", targetURL).Msg("no active workspace for system view toggle")
242+ return fmt.Errorf("system view toggle: no active workspace")
243+ }
244+
245+ activePane := ws.ActivePane()
246+ if paneMatchesURL(activePane, targetURL) {
247+ if ws.PaneCount() <= 1 {
248+ log.Debug().Str("url", targetURL).Msg("system view is active in the last pane; leaving it open")
249+ return nil
250+ }
251+ log.Debug().Str("url", targetURL).Msg("system view already active; closing pane")
252+ return c.ClosePane(ctx)
253+ }
254+
255+ if existing := findPaneByURL(ws, targetURL); existing != nil {
256+ log.Debug().Str("url", targetURL).Str("pane_id", string(existing.ID)).Msg("focusing existing system view pane")
257+ c.focusExistingPane(ctx, ws, wsView, existing.ID)
258+ return nil
259+ }
260+
261+ log.Debug().Str("url", targetURL).Msg("opening system view in right split")
262+ return c.SplitWithURL(ctx, usecase.SplitRight, targetURL)
263+}
264+
265+func paneMatchesURL(node *entity.PaneNode, targetURL string) bool {
266+ return node != nil && node.Pane != nil && comparablePaneURL(node.Pane.URI) == comparablePaneURL(targetURL)
267+}
268+
269+func findPaneByURL(ws *entity.Workspace, targetURL string) *entity.Pane {
270+ if ws == nil {
271+ return nil
272+ }
273+ targetURL = comparablePaneURL(targetURL)
274+ for _, pane := range ws.AllPanes() {
275+ if pane != nil && comparablePaneURL(pane.URI) == targetURL {
276+ return pane
277+ }
278+ }
279+ return nil
280+}
281+
282+func comparablePaneURL(raw string) string {
283+ normalized := domainurl.Normalize(raw)
284+ parsed, err := url.Parse(normalized)
285+ if err != nil || !strings.EqualFold(parsed.Scheme, "dumb") {
286+ return normalized
287+ }
288+ page := parsed.Host
289+ if page == "" {
290+ page = parsed.Opaque
291+ }
292+ if page == "" || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
293+ return normalized
294+ }
295+ return "dumb:" + page
296+}
297+
298+func (c *WorkspaceCoordinator) focusExistingPane(
299+ ctx context.Context,
300+ ws *entity.Workspace,
301+ wsView *component.WorkspaceView,
302+ paneID entity.PaneID,
303+) {
304+ oldActivePaneID := ws.ActivePaneID
305+ ws.ActivePaneID = paneID
306+ paneNode := ws.FindPane(paneID)
307+ if paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked {
308+ setActiveStackIndexForChild(paneNode.Parent, paneNode)
309+ }
310+
311+ if wsView != nil {
312+ wsView.CancelAllPendingHovers()
313+ wsView.SuppressHover(component.KeyboardFocusSuppressDuration)
314+ if oldActivePaneID != "" && oldActivePaneID != paneID {
315+ wsView.DeactivatePane(oldActivePaneID)
316+ }
317+ if err := wsView.SetActivePaneID(paneID); err != nil {
318+ logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to focus existing pane in view")
319+ } else {
320+ wsView.FocusPane(paneID)
321+ }
322+ if paneNode != nil && paneNode.Parent != nil && paneNode.Parent.IsStacked {
323+ c.syncStackedViewActive(ctx, wsView, paneNode)
324+ }
325+ }
326+
327+ c.notifyStateChanged()
328+}
329+
330+func setActiveStackIndexForChild(parent, child *entity.PaneNode) {
331+ if parent == nil || child == nil {
332+ return
333+ }
334+ for i, candidate := range parent.Children {
335+ if candidate == child {
336+ parent.ActiveStackIndex = i
337+ return
338+ }
339+ }
340+}
341+
215342 func (c *WorkspaceCoordinator) prepareSplit(
216343 ctx context.Context,
217344 direction usecase.SplitDirection,
A internal/ui/coordinator/workspace_systemview_test.go +126 -0
@@ -0,0 +1,126 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/bnema/dumber/internal/ui/component"
10+ "github.com/stretchr/testify/assert"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestWorkspaceCoordinator_ToggleSystemViewRightSplitsWithTargetURL(t *testing.T) {
15+ ctx := context.Background()
16+ ids := []string{"pane-2", "split-1"}
17+ idx := 0
18+ uc := usecase.NewManagePanesUseCase(func() string {
19+ id := ids[idx]
20+ idx++
21+ return id
22+ })
23+
24+ initialPane := entity.NewPane("pane-1")
25+ initialPane.URI = "https://example.com"
26+ ws := entity.NewWorkspace("ws-1", initialPane)
27+
28+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
29+ PanesUC: uc,
30+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
31+ return ws, nil
32+ },
33+ })
34+
35+ err := coord.ToggleSystemViewRight(ctx, "dumb://history")
36+ require.NoError(t, err)
37+
38+ require.Equal(t, 2, ws.PaneCount())
39+ active := ws.ActivePane()
40+ require.NotNil(t, active)
41+ require.NotNil(t, active.Pane)
42+ assert.Equal(t, entity.PaneID("pane-2"), active.Pane.ID)
43+ assert.Equal(t, "dumb://history", active.Pane.URI)
44+}
45+
46+func TestWorkspaceCoordinator_ToggleSystemViewRightFocusesExistingPane(t *testing.T) {
47+ ctx := context.Background()
48+ left := testLeafNode("pane-1")
49+ left.Pane.URI = "https://example.com"
50+ right := testLeafNode("pane-2")
51+ right.Pane.URI = "dumb://history"
52+ root := testSplitNode("split-1", left, right)
53+ ws := &entity.Workspace{ID: "ws-1", Root: root, ActivePaneID: left.Pane.ID}
54+
55+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
56+ PanesUC: usecase.NewManagePanesUseCase(func() string {
57+ t.Fatal("PanesUC should not be invoked when focusing an existing systemview pane")
58+ return "unused"
59+ }),
60+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
61+ return ws, nil
62+ },
63+ })
64+
65+ err := coord.ToggleSystemViewRight(ctx, "dumb://history")
66+ require.NoError(t, err)
67+
68+ assert.Equal(t, entity.PaneID("pane-2"), ws.ActivePaneID)
69+ assert.Equal(t, 2, ws.PaneCount())
70+}
71+
72+func TestWorkspaceCoordinator_ToggleSystemViewRightClosesActivePane(t *testing.T) {
73+ ctx := context.Background()
74+ uc := usecase.NewManagePanesUseCase(func() string { return "unused" })
75+ left := testLeafNode("pane-1")
76+ left.Pane.URI = "https://example.com"
77+ right := testLeafNode("pane-2")
78+ right.Pane.URI = "dumb://history"
79+ root := testSplitNode("split-1", left, right)
80+ ws := &entity.Workspace{ID: "ws-1", Root: root, ActivePaneID: right.Pane.ID}
81+
82+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
83+ PanesUC: uc,
84+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
85+ return ws, nil
86+ },
87+ })
88+
89+ err := coord.ToggleSystemViewRight(ctx, "dumb://history")
90+ require.NoError(t, err)
91+
92+ assert.Equal(t, 1, ws.PaneCount())
93+ assert.Equal(t, entity.PaneID("pane-1"), ws.ActivePaneID)
94+ active := ws.ActivePane()
95+ require.NotNil(t, active)
96+ assert.Equal(t, "https://example.com", active.Pane.URI)
97+}
98+
99+func TestWorkspaceCoordinator_ToggleSystemViewRightUpdatesStackIndexForExistingPane(t *testing.T) {
100+ ctx := context.Background()
101+ first := testLeafNode("pane-1")
102+ first.Pane.URI = "https://example.com"
103+ second := testLeafNode("pane-2")
104+ second.Pane.URI = "dumb://history"
105+ stack := &entity.PaneNode{
106+ ID: "stack-1",
107+ IsStacked: true,
108+ ActiveStackIndex: 0,
109+ Children: []*entity.PaneNode{first, second},
110+ }
111+ first.Parent = stack
112+ second.Parent = stack
113+ ws := &entity.Workspace{ID: "ws-1", Root: stack, ActivePaneID: first.Pane.ID}
114+
115+ coord := NewWorkspaceCoordinator(ctx, WorkspaceCoordinatorConfig{
116+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
117+ return ws, nil
118+ },
119+ })
120+
121+ err := coord.ToggleSystemViewRight(ctx, "dumb://history")
122+ require.NoError(t, err)
123+
124+ assert.Equal(t, entity.PaneID("pane-2"), ws.ActivePaneID)
125+ assert.Equal(t, 1, stack.ActiveStackIndex)
126+}
M internal/ui/dispatcher/keyboard.go +15 -0
@@ -14,6 +14,12 @@ import (
1414 "github.com/bnema/puregotk/v4/glib"
1515 )
1616
17+const (
18+ historySystemViewURL = "dumb://history"
19+ favoritesSystemViewURL = "dumb://favorites"
20+ configSystemViewURL = "dumb://config"
21+)
22+
1723 // KeyboardDispatcher routes keyboard actions to appropriate coordinators.
1824 type KeyboardDispatcher struct {
1925 tabCoord *coordinator.TabCoordinator
@@ -225,6 +231,15 @@ func (d *KeyboardDispatcher) initActionHandlers() {
225231 }
226232 return d.logNoop(ctx, "toggle floating pane action (no handler)")
227233 },
234+ input.ActionToggleHistorySystemView: func(ctx context.Context) error {
235+ return d.wsCoord.ToggleSystemViewRight(ctx, historySystemViewURL)
236+ },
237+ input.ActionToggleFavoritesSystemView: func(ctx context.Context) error {
238+ return d.wsCoord.ToggleSystemViewRight(ctx, favoritesSystemViewURL)
239+ },
240+ input.ActionToggleConfigSystemView: func(ctx context.Context) error {
241+ return d.wsCoord.ToggleSystemViewRight(ctx, configSystemViewURL)
242+ },
228243 input.ActionToggleFullscreen: func(ctx context.Context) error {
229244 return d.logNoop(ctx, "toggle fullscreen action (not yet implemented)")
230245 },
M internal/ui/dispatcher/keyboard_test.go +34 -0
@@ -7,6 +7,7 @@ import (
77 "github.com/bnema/dumber/internal/application/usecase"
88 "github.com/bnema/dumber/internal/domain/entity"
99 domainurl "github.com/bnema/dumber/internal/domain/url"
10+ "github.com/bnema/dumber/internal/ui/component"
1011 "github.com/bnema/dumber/internal/ui/coordinator"
1112 "github.com/bnema/dumber/internal/ui/input"
1213 "github.com/stretchr/testify/assert"
@@ -114,6 +115,39 @@ func TestKeyboardDispatcher_AltNumberErrorsWhenMissingTabRequiresCreationWithout
114115 assert.Equal(t, tabs.TabAt(0).ID, tabs.ActiveTabID)
115116 }
116117
118+func TestKeyboardDispatcher_ToggleHistorySystemViewOpensRightSplit(t *testing.T) {
119+ ctx := context.Background()
120+ ids := []string{"pane-2", "split-1"}
121+ idx := 0
122+ panesUC := usecase.NewManagePanesUseCase(func() string {
123+ id := ids[idx]
124+ idx++
125+ return id
126+ })
127+
128+ initialPane := entity.NewPane("pane-1")
129+ initialPane.URI = "https://example.com"
130+ ws := entity.NewWorkspace("ws-1", initialPane)
131+ wsCoord := coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
132+ PanesUC: panesUC,
133+ GetActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
134+ return ws, nil
135+ },
136+ })
137+
138+ d := NewKeyboardDispatcher(ctx, &coordinator.TabCoordinator{}, wsCoord, &coordinator.NavigationCoordinator{}, nil, nil, "", func(context.Context) entity.PaneID { return "" })
139+
140+ err := d.Dispatch(ctx, input.ActionToggleHistorySystemView)
141+ require.NoError(t, err)
142+
143+ require.Equal(t, 2, ws.PaneCount())
144+ active := ws.ActivePane()
145+ require.NotNil(t, active)
146+ require.NotNil(t, active.Pane)
147+ assert.Equal(t, entity.PaneID("pane-2"), active.Pane.ID)
148+ assert.Equal(t, "dumb://history", active.Pane.URI)
149+}
150+
117151 func TestKeyboardDispatcher_PassesActivePaneIDToShellCallbacks(t *testing.T) {
118152 ctx := context.Background()
119153 activePaneID := entity.PaneID("pane-1")
M internal/ui/input/global_shortcuts.go +24 -17
@@ -104,15 +104,7 @@ func NewGlobalShortcutHandler(
104104 Msg("registered global shortcut")
105105
106106 if workspace != nil {
107- // Map action names to action constants for global shortcuts
108- actionMap := map[string]Action{
109- "toggle_floating_pane": ActionToggleFloatingPane,
110- "toggle-floating-pane": ActionToggleFloatingPane,
111- "consume_or_expel_left": ActionConsumeOrExpelLeft,
112- "consume_or_expel_right": ActionConsumeOrExpelRight,
113- "consume_or_expel_up": ActionConsumeOrExpelUp,
114- "consume_or_expel_down": ActionConsumeOrExpelDown,
115- }
107+ actionMap := globalShortcutActionMap()
116108
117109 for actionName, actionBinding := range workspace.Shortcuts.Actions {
118110 action, ok := actionMap[actionName]
@@ -247,6 +239,28 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
247239 return true
248240 }
249241
242+// globalShortcutActionMap returns a fresh map of workspace action names to global shortcut actions.
243+func globalShortcutActionMap() map[string]Action {
244+ return map[string]Action{
245+ "toggle_floating_pane": ActionToggleFloatingPane,
246+ "toggle-floating-pane": ActionToggleFloatingPane,
247+ "consume_or_expel_left": ActionConsumeOrExpelLeft,
248+ "consume-or-expel-left": ActionConsumeOrExpelLeft,
249+ "consume_or_expel_right": ActionConsumeOrExpelRight,
250+ "consume-or-expel-right": ActionConsumeOrExpelRight,
251+ "consume_or_expel_up": ActionConsumeOrExpelUp,
252+ "consume-or-expel-up": ActionConsumeOrExpelUp,
253+ "consume_or_expel_down": ActionConsumeOrExpelDown,
254+ "consume-or-expel-down": ActionConsumeOrExpelDown,
255+ "toggle_history_systemview": ActionToggleHistorySystemView,
256+ "toggle-history-systemview": ActionToggleHistorySystemView,
257+ "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
258+ "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
259+ "toggle_config_systemview": ActionToggleConfigSystemView,
260+ "toggle-config-systemview": ActionToggleConfigSystemView,
261+ }
262+}
263+
250264 // ReloadShortcuts rebuilds global shortcuts from a new config.
251265 // It replaces the GTK shortcut controller to ensure stale shortcuts
252266 // are removed.
@@ -285,14 +299,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
285299
286300 // Re-register config-driven shortcuts
287301 if workspace != nil {
288- actionMap := map[string]Action{
289- "toggle_floating_pane": ActionToggleFloatingPane,
290- "toggle-floating-pane": ActionToggleFloatingPane,
291- "consume_or_expel_left": ActionConsumeOrExpelLeft,
292- "consume_or_expel_right": ActionConsumeOrExpelRight,
293- "consume_or_expel_up": ActionConsumeOrExpelUp,
294- "consume_or_expel_down": ActionConsumeOrExpelDown,
295- }
302+ actionMap := globalShortcutActionMap()
296303
297304 for actionName, actionBinding := range workspace.Shortcuts.Actions {
298305 action, ok := actionMap[actionName]
M internal/ui/input/shortcuts.go +38 -32
@@ -179,14 +179,17 @@ const (
179179 ActionZoomReset Action = "zoom_reset"
180180
181181 // UI
182- ActionOpenOmnibox Action = "open_omnibox"
183- ActionOpenFind Action = "open_find"
184- ActionFindNext Action = "find_next"
185- ActionFindPrev Action = "find_prev"
186- ActionCloseFind Action = "close_find"
187- ActionOpenDevTools Action = "open_devtools"
188- ActionToggleFullscreen Action = "toggle_fullscreen"
189- ActionToggleFloatingPane Action = "toggle_floating_pane"
182+ ActionOpenOmnibox Action = "open_omnibox"
183+ ActionOpenFind Action = "open_find"
184+ ActionFindNext Action = "find_next"
185+ ActionFindPrev Action = "find_prev"
186+ ActionCloseFind Action = "close_find"
187+ ActionOpenDevTools Action = "open_devtools"
188+ ActionToggleFullscreen Action = "toggle_fullscreen"
189+ ActionToggleFloatingPane Action = "toggle_floating_pane"
190+ ActionToggleHistorySystemView Action = "toggle_history_systemview"
191+ ActionToggleFavoritesSystemView Action = "toggle_favorites_systemview"
192+ ActionToggleConfigSystemView Action = "toggle_config_systemview"
190193
191194 // Clipboard
192195 ActionCopyURL Action = "copy_url"
@@ -359,29 +362,9 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
359362 // r = rename tab
360363 // This follows Zellij-style modal keyboard interface.
361364 //
362- // However, these standard browser shortcuts ARE global:
363- // Map action names to action constants
364- actionMap := map[string]Action{
365- "toggle_floating_pane": ActionToggleFloatingPane,
366- "toggle-floating-pane": ActionToggleFloatingPane,
367- "close_pane": ActionClosePane,
368- "close-pane": ActionClosePane,
369- "next_tab": ActionNextTab,
370- "next-tab": ActionNextTab,
371- "previous_tab": ActionPreviousTab,
372- "previous-tab": ActionPreviousTab,
373- "consume_or_expel_left": ActionConsumeOrExpelLeft,
374- "consume-or-expel-left": ActionConsumeOrExpelLeft,
375- "consume_or_expel_right": ActionConsumeOrExpelRight,
376- "consume-or-expel-right": ActionConsumeOrExpelRight,
377- "consume_or_expel_up": ActionConsumeOrExpelUp,
378- "consume-or-expel-up": ActionConsumeOrExpelUp,
379- "consume_or_expel_down": ActionConsumeOrExpelDown,
380- "consume-or-expel-down": ActionConsumeOrExpelDown,
381- }
382-
365+ // However, these standard browser shortcuts ARE global.
383366 for actionName, actionBinding := range cfg.Shortcuts.Actions {
384- action, ok := actionMap[actionName]
367+ action, ok := configActionToAction[actionName]
385368 if !ok {
386369 continue
387370 }
@@ -580,29 +563,52 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
580563 }
581564
582565 var configActionToAction = map[string]Action{
583- "toggle_floating_pane": ActionToggleFloatingPane,
584- "toggle-floating-pane": ActionToggleFloatingPane,
566+ "toggle_floating_pane": ActionToggleFloatingPane,
567+ "toggle-floating-pane": ActionToggleFloatingPane,
568+ "toggle_history_systemview": ActionToggleHistorySystemView,
569+ "toggle-history-systemview": ActionToggleHistorySystemView,
570+ "toggle_favorites_systemview": ActionToggleFavoritesSystemView,
571+ "toggle-favorites-systemview": ActionToggleFavoritesSystemView,
572+ "toggle_config_systemview": ActionToggleConfigSystemView,
573+ "toggle-config-systemview": ActionToggleConfigSystemView,
585574
586575 // Tab actions
576+ "new_tab": ActionNewTab,
587577 "new-tab": ActionNewTab,
578+ "close_tab": ActionCloseTab,
588579 "close-tab": ActionCloseTab,
580+ "next_tab": ActionNextTab,
589581 "next-tab": ActionNextTab,
582+ "previous_tab": ActionPreviousTab,
590583 "previous-tab": ActionPreviousTab,
584+ "rename_tab": ActionRenameTab,
591585 "rename-tab": ActionRenameTab,
592586
593587 // Pane actions
588+ "split_right": ActionSplitRight,
594589 "split-right": ActionSplitRight,
590+ "split_left": ActionSplitLeft,
595591 "split-left": ActionSplitLeft,
592+ "split_up": ActionSplitUp,
596593 "split-up": ActionSplitUp,
594+ "split_down": ActionSplitDown,
597595 "split-down": ActionSplitDown,
596+ "close_pane": ActionClosePane,
598597 "close-pane": ActionClosePane,
598+ "stack_pane": ActionStackPane,
599599 "stack-pane": ActionStackPane,
600+ "move_pane_to_tab": ActionMovePaneToTab,
600601 "move-pane-to-tab": ActionMovePaneToTab,
602+ "move_pane_to_next_tab": ActionMovePaneToNextTab,
601603 "move-pane-to-next-tab": ActionMovePaneToNextTab,
602604
605+ "consume_or_expel_left": ActionConsumeOrExpelLeft,
603606 "consume-or-expel-left": ActionConsumeOrExpelLeft,
607+ "consume_or_expel_right": ActionConsumeOrExpelRight,
604608 "consume-or-expel-right": ActionConsumeOrExpelRight,
609+ "consume_or_expel_up": ActionConsumeOrExpelUp,
605610 "consume-or-expel-up": ActionConsumeOrExpelUp,
611+ "consume_or_expel_down": ActionConsumeOrExpelDown,
606612 "consume-or-expel-down": ActionConsumeOrExpelDown,
607613
608614 // Focus navigation
M internal/ui/input/shortcuts_test.go +59 -0
@@ -383,9 +383,68 @@ func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {
383383 }
384384 }
385385
386+func TestMapConfigAction_ToggleSystemViews(t *testing.T) {
387+ tests := []struct {
388+ name string
389+ want Action
390+ }{
391+ {name: "toggle_history_systemview", want: ActionToggleHistorySystemView},
392+ {name: "toggle_favorites_systemview", want: ActionToggleFavoritesSystemView},
393+ {name: "toggle_config_systemview", want: ActionToggleConfigSystemView},
394+ }
395+
396+ for _, tt := range tests {
397+ t.Run(tt.name, func(t *testing.T) {
398+ if got := mapConfigAction(tt.name); got != tt.want {
399+ t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.name, got, tt.want)
400+ }
401+ })
402+ }
403+}
404+
405+func TestMapConfigAction_ToggleSystemViewsHyphenAlias(t *testing.T) {
406+ tests := []struct {
407+ name string
408+ want Action
409+ }{
410+ {name: "toggle-history-systemview", want: ActionToggleHistorySystemView},
411+ {name: "toggle-favorites-systemview", want: ActionToggleFavoritesSystemView},
412+ {name: "toggle-config-systemview", want: ActionToggleConfigSystemView},
413+ }
414+
415+ for _, tt := range tests {
416+ t.Run(tt.name, func(t *testing.T) {
417+ if got := mapConfigAction(tt.name); got != tt.want {
418+ t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.name, got, tt.want)
419+ }
420+ })
421+ }
422+}
423+
386424 func TestMapConfigAction_ToggleFloatingPaneHyphenAlias(t *testing.T) {
387425 action := mapConfigAction("toggle-floating-pane")
388426 if action != ActionToggleFloatingPane {
389427 t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane)
390428 }
391429 }
430+
431+func TestGlobalShortcutActionMap_ConsumeOrExpelHyphenAliases(t *testing.T) {
432+ actions := globalShortcutActionMap()
433+ tests := []struct {
434+ name string
435+ want Action
436+ }{
437+ {name: "consume-or-expel-left", want: ActionConsumeOrExpelLeft},
438+ {name: "consume-or-expel-right", want: ActionConsumeOrExpelRight},
439+ {name: "consume-or-expel-up", want: ActionConsumeOrExpelUp},
440+ {name: "consume-or-expel-down", want: ActionConsumeOrExpelDown},
441+ }
442+
443+ for _, tt := range tests {
444+ t.Run(tt.name, func(t *testing.T) {
445+ if got := actions[tt.name]; got != tt.want {
446+ t.Fatalf("globalShortcutActionMap()[%s] = %s, want %s", tt.name, got, tt.want)
447+ }
448+ })
449+ }
450+}
M internal/ui/standalone_runtime_test.go +1 -1
@@ -74,7 +74,7 @@ func TestStandaloneExternalSchemeRules_CustomSchemeIsExternal(t *testing.T) {
7474 }
7575
7676 func TestStandaloneExternalSchemeRules_InternalSchemesStayInternal(t *testing.T) {
77- for _, rawURL := range []string{"dumb://home", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} {
77+ for _, rawURL := range []string{"dumb://history", "dumb://error", "file:///tmp/demo", "about:blank", "data:text/plain,hi"} {
7878 if urlutil.IsExternalScheme(rawURL) {
7979 t.Fatalf("expected %q to stay internal", rawURL)
8080 }
A internal/ui/systemviews/action_log.go +20 -0
@@ -0,0 +1,20 @@
1+//go:build !js || !wasm
2+
3+package systemviews
4+
5+import (
6+ "context"
7+
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+func logActionMountError(ctx context.Context, mountErr, actionErr error) {
12+ if mountErr == nil && actionErr == nil {
13+ return
14+ }
15+ logging.FromContext(ctx).
16+ Error().
17+ Err(mountErr).
18+ Str("action_error", errorString(actionErr)).
19+ Msg("failed to mount systemview action error")
20+}
A internal/ui/systemviews/action_log_common.go +8 -0
@@ -0,0 +1,8 @@
1+package systemviews
2+
3+func errorString(err error) string {
4+ if err == nil {
5+ return "<nil>"
6+ }
7+ return err.Error()
8+}
A internal/ui/systemviews/action_log_js.go +19 -0
@@ -0,0 +1,19 @@
1+//go:build js && wasm
2+
3+package systemviews
4+
5+import (
6+ "context"
7+ "syscall/js"
8+)
9+
10+func logActionMountError(_ context.Context, mountErr, actionErr error) {
11+ if mountErr == nil && actionErr == nil {
12+ return
13+ }
14+ console := js.Global().Get("console")
15+ if !console.Truthy() || !console.Get("error").Truthy() {
16+ return
17+ }
18+ console.Call("error", "failed to mount systemview action error", errorString(mountErr), errorString(actionErr))
19+}
A internal/ui/systemviews/app.go +832 -0
@@ -0,0 +1,832 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "strings"
8+ "sync"
9+ "time"
10+
11+ "github.com/bnema/dumber/internal/application/dto"
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/domain/entity"
14+)
15+
16+type Dependencies struct {
17+ DOM DOM
18+ Favorites port.SystemviewFavoritesService
19+ History port.SystemviewHistoryService
20+ Config port.SystemviewConfigService
21+ LocationURI string
22+}
23+
24+type App struct {
25+ // mu protects rendered route state; actionMu protects worker channels.
26+ // The two locks must not be held at the same time.
27+ mu sync.Mutex
28+ actionMu sync.Mutex
29+ deps Dependencies
30+ currentRoute Route
31+ shellTheme shellTheme
32+ historyEntries []*entity.HistoryEntry
33+ historyStats *entity.HistoryStats
34+ historyAnalytics *entity.HistoryAnalytics
35+ historyDomainStats []*entity.DomainStat
36+ historyQuery string
37+ historyDomainFilter string
38+ historyOffset int
39+ historyWindowBefore time.Time
40+ historyWindowAfter time.Time
41+ historyHasMore bool
42+ historyNotice string
43+ historyError string
44+ favorites []*entity.Favorite
45+ folders []*entity.Folder
46+ tags []*entity.Tag
47+ favoriteFolderFilter *entity.FolderID
48+ favoriteTagFilter *entity.TagID
49+ favoritesNotice string
50+ favoritesError string
51+ config *dto.SystemviewConfigPayload
52+ keybindings port.KeybindingsConfig
53+ configNotice string
54+ configError string
55+ renderedHTML string
56+ renderGeneration uint64
57+ closed bool
58+ actionQueue chan DOMAction
59+ actionErrorQueue chan error
60+ actionCtx context.Context
61+ actionCancel context.CancelFunc
62+ actionClosed bool
63+ actionWG sync.WaitGroup
64+}
65+
66+const (
67+ historyTimelineLimit = 0
68+ historySearchLimit = 100
69+)
70+
71+func (a *App) lockAction() {
72+ // Lock order: actionMu is never acquired while App.mu is held.
73+ a.actionMu.Lock()
74+}
75+
76+func (a *App) unlockAction() {
77+ a.actionMu.Unlock()
78+}
79+
80+func (a *App) lockState() {
81+ // Lock order: App.mu is never held while acquiring actionMu.
82+ a.mu.Lock()
83+}
84+
85+func (a *App) unlockState() {
86+ a.mu.Unlock()
87+}
88+
89+func NewApp(deps Dependencies) *App {
90+ return &App{deps: deps, currentRoute: RouteUnknown}
91+}
92+
93+func (a *App) Run() error {
94+ return a.RunWithContext(context.Background())
95+}
96+
97+func (a *App) RunWithContext(ctx context.Context) error {
98+ if a == nil {
99+ return errors.New("app is nil")
100+ }
101+ if ctx == nil {
102+ return errors.New("context is nil")
103+ }
104+
105+ a.currentRoute = ParseRoute(a.deps.LocationURI)
106+ if a.deps.DOM == nil {
107+ return errors.New("DOM not configured")
108+ }
109+ asyncInitial := a.shouldLoadInitialRouteAsync()
110+ if asyncInitial {
111+ a.LoadInitialShell(ctx)
112+ } else if err := a.LoadInitial(ctx); err != nil {
113+ return err
114+ }
115+
116+ if err := a.deps.DOM.Mount(a.renderedHTML); err != nil {
117+ return err
118+ }
119+ if binder, ok := a.deps.DOM.(DOMActionBinder); ok {
120+ if err := a.bindDOMActions(ctx, binder); err != nil {
121+ return err
122+ }
123+ }
124+ if asyncInitial {
125+ a.refreshCurrentRouteAsync(ctx)
126+ }
127+ return nil
128+}
129+
130+func (a *App) shouldLoadInitialRouteAsync() bool {
131+ return a != nil && a.currentRoute == RouteHistory && a.deps.History != nil
132+}
133+
134+func (a *App) refreshCurrentRouteAsync(ctx context.Context) {
135+ if a == nil {
136+ return
137+ }
138+ a.lockState()
139+ snapshot := a.currentHistoryRouteSnapshotLocked()
140+ generation := a.renderGeneration
141+ closed := a.closed
142+ a.unlockState()
143+ if closed || snapshot.route != RouteHistory {
144+ return
145+ }
146+
147+ a.lockAction()
148+ asyncCtx := a.actionCtx
149+ if asyncCtx == nil {
150+ asyncCtx = ctx
151+ }
152+ ready := !a.actionClosed && asyncCtx != nil && asyncCtx.Err() == nil
153+ if ready {
154+ a.actionWG.Add(1)
155+ }
156+ a.unlockAction()
157+ if !ready {
158+ return
159+ }
160+
161+ go func() {
162+ defer a.actionWG.Done()
163+ if asyncCtx.Err() != nil {
164+ return
165+ }
166+ result, err := a.renderHistoryRouteSnapshot(asyncCtx, snapshot)
167+
168+ a.lockState()
169+ if a.closed || asyncCtx.Err() != nil || a.currentRoute != snapshot.route || a.renderGeneration != generation {
170+ a.unlockState()
171+ return
172+ }
173+ if err != nil {
174+ a.renderRouteError(err)
175+ } else {
176+ a.commitHistoryRouteResultLocked(result)
177+ }
178+ a.renderGeneration++
179+ mountGeneration := a.renderGeneration
180+ html := a.renderedHTML
181+ a.unlockState()
182+
183+ if err := a.mountHTMLIfCurrent(asyncCtx, html, mountGeneration); err != nil {
184+ logActionMountError(asyncCtx, err, nil)
185+ }
186+ }()
187+}
188+
189+func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {
190+ var (
191+ created bool
192+ workerCtx context.Context
193+ cancel context.CancelFunc
194+ queue chan DOMAction
195+ errorQueue chan error
196+ )
197+
198+ a.lockAction()
199+ if a.actionClosed {
200+ a.unlockAction()
201+ return errors.New("systemview app is closed")
202+ }
203+ if a.actionQueue == nil {
204+ workerCtx, cancel = context.WithCancel(ctx)
205+ queue = make(chan DOMAction, 64)
206+ errorQueue = make(chan error, 1)
207+ a.actionQueue = queue
208+ a.actionErrorQueue = errorQueue
209+ a.actionCtx = workerCtx
210+ a.actionCancel = cancel
211+ created = true
212+ }
213+ a.unlockAction()
214+
215+ if err := binder.BindActions(func(action DOMAction) {
216+ if !a.enqueueDOMAction(action) && a.actionWorkerActive() {
217+ a.enqueueActionError(fmt.Errorf("systemview is busy; dropped action %q", action.Action))
218+ }
219+ }); err != nil {
220+ if created {
221+ a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue)
222+ }
223+ return err
224+ }
225+
226+ if created && !a.startActionWorkers(workerCtx, queue, errorQueue) {
227+ a.discardPendingActionWorker(workerCtx, cancel, queue, errorQueue)
228+ a.releaseDOMBindings()
229+ return errors.New("systemview action worker unavailable")
230+ }
231+ return nil
232+}
233+
234+func (a *App) startActionWorkers(ctx context.Context, queue chan DOMAction, errorQueue chan error) bool {
235+ if a == nil || ctx == nil || queue == nil || errorQueue == nil {
236+ return false
237+ }
238+ a.lockAction()
239+ ready := !a.actionClosed && a.actionCtx == ctx && a.actionQueue == queue && a.actionErrorQueue == errorQueue && ctx.Err() == nil
240+ if ready {
241+ a.actionWG.Add(2)
242+ }
243+ a.unlockAction()
244+ if !ready {
245+ return false
246+ }
247+ go func() {
248+ defer a.actionWG.Done()
249+ a.runActionWorker(ctx, queue)
250+ }()
251+ go func() {
252+ defer a.actionWG.Done()
253+ a.runActionErrorWorker(ctx, errorQueue)
254+ }()
255+ return true
256+}
257+
258+func (a *App) discardPendingActionWorker(ctx context.Context, cancel context.CancelFunc, queue chan DOMAction, errorQueue chan error) {
259+ if a == nil {
260+ return
261+ }
262+ a.lockAction()
263+ if a.actionCtx == ctx && a.actionQueue == queue {
264+ a.actionCancel = nil
265+ a.actionCtx = nil
266+ a.actionQueue = nil
267+ a.actionErrorQueue = nil
268+ if cancel != nil {
269+ cancel()
270+ }
271+ close(queue)
272+ close(errorQueue)
273+ }
274+ a.unlockAction()
275+}
276+
277+func (a *App) actionWorkerActive() bool {
278+ if a == nil {
279+ return false
280+ }
281+ a.lockAction()
282+ closed := a.actionClosed
283+ queue := a.actionQueue
284+ ctx := a.actionCtx
285+ a.unlockAction()
286+ return !closed && queue != nil && ctx != nil && ctx.Err() == nil
287+}
288+
289+func (a *App) enqueueDOMAction(action DOMAction) bool {
290+ if a == nil {
291+ return false
292+ }
293+ a.lockAction()
294+ defer a.unlockAction()
295+ queue := a.actionQueue
296+ ctx := a.actionCtx
297+ if a.actionClosed || queue == nil || ctx == nil {
298+ return false
299+ }
300+ select {
301+ case <-ctx.Done():
302+ return false
303+ case queue <- action:
304+ return true
305+ default:
306+ return false
307+ }
308+}
309+
310+func (a *App) enqueueActionError(err error) {
311+ if a == nil || err == nil {
312+ return
313+ }
314+ a.lockAction()
315+ defer a.unlockAction()
316+ queue := a.actionErrorQueue
317+ ctx := a.actionCtx
318+ if a.actionClosed || queue == nil || ctx == nil {
319+ return
320+ }
321+ select {
322+ case <-ctx.Done():
323+ return
324+ case queue <- err:
325+ default:
326+ }
327+}
328+
329+func (a *App) runActionWorker(ctx context.Context, queue <-chan DOMAction) {
330+ for {
331+ select {
332+ case <-ctx.Done():
333+ return
334+ case action, ok := <-queue:
335+ if !ok || ctx.Err() != nil {
336+ return
337+ }
338+ if err := a.HandleDOMAction(ctx, action); err != nil {
339+ a.enqueueActionError(err)
340+ }
341+ }
342+ }
343+}
344+
345+func (a *App) runActionErrorWorker(ctx context.Context, queue <-chan error) {
346+ for {
347+ select {
348+ case <-ctx.Done():
349+ return
350+ case err, ok := <-queue:
351+ if !ok || ctx.Err() != nil {
352+ return
353+ }
354+ a.surfaceActionError(ctx, err)
355+ }
356+ }
357+}
358+
359+func (a *App) surfaceActionError(ctx context.Context, err error) {
360+ if a == nil || err == nil {
361+ return
362+ }
363+ a.lockState()
364+ a.renderRouteError(err)
365+ a.renderGeneration++
366+ generation := a.renderGeneration
367+ html := a.renderedHTML
368+ a.unlockState()
369+ if mountErr := a.mountHTMLIfCurrent(ctx, html, generation); mountErr != nil {
370+ logActionMountError(ctx, mountErr, err)
371+ }
372+}
373+
374+func (a *App) Close() {
375+ if a == nil {
376+ return
377+ }
378+ a.lockState()
379+ a.closed = true
380+ a.renderGeneration++
381+ a.unlockState()
382+
383+ a.lockAction()
384+ cancel := a.actionCancel
385+ actionQueue := a.actionQueue
386+ actionErrorQueue := a.actionErrorQueue
387+ a.actionClosed = true
388+ a.actionCancel = nil
389+ a.actionCtx = nil
390+ a.actionQueue = nil
391+ a.actionErrorQueue = nil
392+ a.unlockAction()
393+
394+ if cancel != nil {
395+ cancel()
396+ }
397+ a.actionWG.Wait()
398+ if actionQueue != nil {
399+ close(actionQueue)
400+ }
401+ if actionErrorQueue != nil {
402+ close(actionErrorQueue)
403+ }
404+ a.releaseDOMBindings()
405+}
406+
407+func (a *App) releaseDOMBindings() {
408+ if a == nil {
409+ return
410+ }
411+ if releaser, ok := a.deps.DOM.(interface{ Release() }); ok {
412+ releaser.Release()
413+ }
414+}
415+
416+func (a *App) LoadInitialShell(ctx context.Context) {
417+ if a == nil {
418+ return
419+ }
420+ a.loadShellTheme(ctx)
421+ if a.currentRoute == "" || a.currentRoute == RouteUnknown {
422+ a.currentRoute = ParseRoute(a.deps.LocationURI)
423+ }
424+ switch a.currentRoute {
425+ case RouteHistory:
426+ a.loadHistoryLoadingRoute()
427+ default:
428+ a.resetRouteState()
429+ a.renderedHTML = renderAppFrame(renderedPage{
430+ route: a.currentRoute,
431+ title: routeDocumentTitle(a.currentRoute),
432+ subtitle: routeSubtitle(a.currentRoute),
433+ body: placeholderHTML(a.currentRoute),
434+ }, a.shellTheme)
435+ }
436+}
437+
438+func (a *App) LoadInitial(ctx context.Context) error {
439+ if a == nil {
440+ return errors.New("app is nil")
441+ }
442+ a.loadShellTheme(ctx)
443+ if a.currentRoute == "" || a.currentRoute == RouteUnknown {
444+ a.currentRoute = ParseRoute(a.deps.LocationURI)
445+ }
446+
447+ if err := a.loadCurrentRoute(ctx); err != nil {
448+ a.renderRouteError(err)
449+ }
450+ return nil
451+}
452+
453+func (a *App) loadCurrentRoute(ctx context.Context) error {
454+ switch a.currentRoute {
455+ case RouteHistory:
456+ return a.loadHistoryRoute(ctx)
457+ case RouteFavorites:
458+ return a.loadFavoritesRoute(ctx)
459+ case RouteConfig:
460+ return a.loadConfigRoute(ctx)
461+ default:
462+ a.resetRouteState()
463+ a.renderedHTML = renderAppFrame(renderedPage{
464+ route: a.currentRoute,
465+ title: routeDocumentTitle(a.currentRoute),
466+ subtitle: string(a.currentRoute),
467+ body: placeholderHTML(a.currentRoute),
468+ }, a.shellTheme)
469+ return nil
470+ }
471+}
472+
473+func (a *App) renderRouteError(err error) {
474+ a.resetRouteState()
475+ message := "unknown error"
476+ if err != nil {
477+ message = err.Error()
478+ }
479+ a.renderedHTML = renderAppFrame(renderedPage{
480+ route: a.currentRoute,
481+ title: routeDocumentTitle(a.currentRoute) + " — Error",
482+ subtitle: routeSubtitle(a.currentRoute),
483+ body: errorStateHTML(message),
484+ }, a.shellTheme)
485+}
486+
487+func routeDocumentTitle(route Route) string {
488+ switch route {
489+ case RouteHistory:
490+ return "History"
491+ case RouteFavorites:
492+ return "Favorites"
493+ case RouteConfig:
494+ return "Config"
495+ default:
496+ return "Dumber System View"
497+ }
498+}
499+
500+func routeSubtitle(route Route) string {
501+ switch route {
502+ case RouteHistory:
503+ return "Recent visits"
504+ case RouteFavorites:
505+ return "Saved bookmarks"
506+ case RouteConfig:
507+ return "Browser settings"
508+ default:
509+ return string(route)
510+ }
511+}
512+
513+func (a *App) loadHistoryLoadingRoute() {
514+ a.favorites = nil
515+ a.folders = nil
516+ a.tags = nil
517+ a.historyEntries = nil
518+ a.historyStats = nil
519+ a.historyAnalytics = nil
520+ a.historyDomainStats = nil
521+ a.historyNotice = ""
522+ a.historyError = ""
523+ data := historyRenderData{
524+ Query: a.historyQuery,
525+ DomainFilter: a.historyDomainFilter,
526+ Offset: a.historyOffset,
527+ Limit: historyTimelineLimit,
528+ WindowBefore: a.historyWindowBefore,
529+ WindowAfter: a.historyWindowAfter,
530+ HasMore: a.historyHasMore,
531+ Notice: a.historyNotice,
532+ Error: a.historyError,
533+ Loading: true,
534+ }
535+ title := historyDocumentTitle(data)
536+ a.renderedHTML = renderAppFrame(renderedPage{
537+ route: RouteHistory,
538+ title: title,
539+ subtitle: "History",
540+ subtitleDetail: historyTitleDetail(data),
541+ body: historyHTML(data),
542+ }, a.shellTheme)
543+}
544+
545+func (a *App) loadHistoryRoute(ctx context.Context) error {
546+ if a.deps.History == nil {
547+ a.resetRouteState()
548+ a.renderedHTML = renderAppFrame(renderedPage{
549+ route: a.currentRoute,
550+ title: routeDocumentTitle(RouteHistory),
551+ subtitle: routeDocumentTitle(RouteHistory),
552+ body: placeholderHTML(a.currentRoute),
553+ }, a.shellTheme)
554+ return nil
555+ }
556+
557+ result, err := a.renderHistoryRouteSnapshot(ctx, a.currentHistoryRouteSnapshotLocked())
558+ if err != nil {
559+ return err
560+ }
561+ a.commitHistoryRouteResultLocked(result)
562+ return nil
563+}
564+
565+type historyRouteSnapshot struct {
566+ route Route
567+ history port.SystemviewHistoryService
568+ shellTheme shellTheme
569+ query string
570+ domainFilter string
571+ offset int
572+ windowBefore time.Time
573+ windowAfter time.Time
574+ notice string
575+ error string
576+}
577+
578+type historyRouteResult struct {
579+ entries []*entity.HistoryEntry
580+ stats *entity.HistoryStats
581+ domains []*entity.DomainStat
582+ windowBefore time.Time
583+ windowAfter time.Time
584+ hasMore bool
585+ html string
586+}
587+
588+func (a *App) currentHistoryRouteSnapshotLocked() historyRouteSnapshot {
589+ return historyRouteSnapshot{
590+ route: a.currentRoute,
591+ history: a.deps.History,
592+ shellTheme: a.shellTheme,
593+ query: a.historyQuery,
594+ domainFilter: a.historyDomainFilter,
595+ offset: a.historyOffset,
596+ windowBefore: a.historyWindowBefore,
597+ windowAfter: a.historyWindowAfter,
598+ notice: a.historyNotice,
599+ error: a.historyError,
600+ }
601+}
602+
603+func (a *App) renderHistoryRouteSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (historyRouteResult, error) {
604+ if snapshot.history == nil {
605+ return historyRouteResult{
606+ html: renderAppFrame(renderedPage{
607+ route: snapshot.route,
608+ title: routeDocumentTitle(RouteHistory),
609+ subtitle: routeDocumentTitle(RouteHistory),
610+ body: placeholderHTML(snapshot.route),
611+ }, snapshot.shellTheme),
612+ }, nil
613+ }
614+
615+ window, entries, err := loadHistoryWindowSnapshot(ctx, snapshot)
616+ if err != nil {
617+ return historyRouteResult{}, err
618+ }
619+ stats, err := snapshot.history.Stats(ctx)
620+ if err != nil {
621+ return historyRouteResult{}, fmt.Errorf("failed to load history stats: %w", err)
622+ }
623+ domains, domainsErr := snapshot.history.DomainStats(ctx, 10)
624+ if domainsErr != nil {
625+ domains = nil
626+ }
627+ result := historyRouteResult{
628+ entries: entries,
629+ stats: stats,
630+ domains: domains,
631+ windowBefore: snapshot.windowBefore,
632+ windowAfter: snapshot.windowAfter,
633+ }
634+ if window != nil {
635+ result.windowBefore = window.Before
636+ result.windowAfter = window.After
637+ result.hasMore = window.HasMore
638+ }
639+ data := historyRenderData{
640+ Entries: entries,
641+ Stats: stats,
642+ Analytics: nil,
643+ Domains: domains,
644+ Query: snapshot.query,
645+ DomainFilter: snapshot.domainFilter,
646+ Offset: snapshot.offset,
647+ Limit: historyTimelineLimit,
648+ WindowBefore: result.windowBefore,
649+ WindowAfter: result.windowAfter,
650+ HasMore: result.hasMore,
651+ Notice: snapshot.notice,
652+ Error: snapshot.error,
653+ }
654+ title := historyDocumentTitle(data)
655+ result.html = renderAppFrame(renderedPage{
656+ route: RouteHistory,
657+ title: title,
658+ subtitle: "History",
659+ subtitleDetail: historyTitleDetail(data),
660+ body: historyHTML(data),
661+ }, snapshot.shellTheme)
662+ return result, nil
663+}
664+
665+func loadHistoryWindowSnapshot(ctx context.Context, snapshot historyRouteSnapshot) (*entity.HistoryWindow, []*entity.HistoryEntry, error) {
666+ query := strings.TrimSpace(snapshot.query)
667+ domain := strings.TrimSpace(snapshot.domainFilter)
668+ if query != "" {
669+ entries, err := snapshot.history.Search(ctx, query, historySearchLimit)
670+ return nil, entries, err
671+ }
672+ window, err := snapshot.history.TimelineWindow(ctx, snapshot.windowBefore, domain)
673+ if err != nil {
674+ return nil, nil, err
675+ }
676+ if window == nil {
677+ return nil, nil, nil
678+ }
679+ return window, window.Entries, nil
680+}
681+
682+func (a *App) commitHistoryRouteResultLocked(result historyRouteResult) {
683+ a.favorites = nil
684+ a.folders = nil
685+ a.tags = nil
686+ a.historyEntries = result.entries
687+ a.historyStats = result.stats
688+ a.historyAnalytics = nil
689+ a.historyDomainStats = result.domains
690+ a.historyWindowBefore = result.windowBefore
691+ a.historyWindowAfter = result.windowAfter
692+ a.historyHasMore = result.hasMore
693+ a.renderedHTML = result.html
694+}
695+
696+func (a *App) loadFavoritesRoute(ctx context.Context) error {
697+ if a.deps.Favorites == nil {
698+ a.resetRouteState()
699+ a.renderedHTML = renderAppFrame(renderedPage{
700+ route: a.currentRoute,
701+ title: routeDocumentTitle(RouteFavorites),
702+ subtitle: "Saved bookmarks",
703+ body: placeholderHTML(a.currentRoute),
704+ }, a.shellTheme)
705+ return nil
706+ }
707+
708+ favorites, err := a.deps.Favorites.List(ctx)
709+ if err != nil {
710+ return err
711+ }
712+ folders, err := a.deps.Favorites.ListFolders(ctx)
713+ if err != nil {
714+ return err
715+ }
716+ tags, err := a.deps.Favorites.ListTags(ctx)
717+ if err != nil {
718+ return err
719+ }
720+
721+ a.historyEntries = nil
722+ favorites = filterFavorites(favorites, a.favoriteFolderFilter, a.favoriteTagFilter)
723+ a.favorites = favorites
724+ a.folders = folders
725+ a.tags = tags
726+ data := favoritesRenderData{
727+ Favorites: favorites,
728+ Folders: folders,
729+ Tags: tags,
730+ FolderFilter: a.favoriteFolderFilter,
731+ TagFilter: a.favoriteTagFilter,
732+ Notice: a.favoritesNotice,
733+ Error: a.favoritesError,
734+ }
735+ a.renderedHTML = renderAppFrame(renderedPage{
736+ route: RouteFavorites,
737+ title: favoritesDocumentTitle(data),
738+ subtitle: "Saved bookmarks",
739+ body: favoritesHTML(data),
740+ }, a.shellTheme)
741+ return nil
742+}
743+
744+func (a *App) loadConfigRoute(ctx context.Context) error {
745+ if a.deps.Config == nil {
746+ a.resetRouteState()
747+ a.renderedHTML = renderAppFrame(renderedPage{
748+ route: a.currentRoute,
749+ title: routeDocumentTitle(RouteConfig),
750+ subtitle: "Browser settings",
751+ body: placeholderHTML(a.currentRoute),
752+ }, a.shellTheme)
753+ return nil
754+ }
755+
756+ config, err := a.deps.Config.Current(ctx)
757+ if err != nil {
758+ return err
759+ }
760+ keybindings, err := a.deps.Config.GetKeybindings(ctx)
761+ if err != nil {
762+ return err
763+ }
764+
765+ a.historyEntries = nil
766+ a.favorites = nil
767+ a.folders = nil
768+ a.tags = nil
769+ a.config = &config
770+ a.keybindings = keybindings
771+ a.renderedHTML = renderAppFrame(renderedPage{
772+ route: RouteConfig,
773+ title: "Config — Dumber",
774+ subtitle: "Browser settings",
775+ body: configHTML(configRenderData{
776+ Config: config,
777+ Keybindings: keybindings,
778+ Notice: a.configNotice,
779+ Error: a.configError,
780+ }),
781+ }, a.shellTheme)
782+ return nil
783+}
784+
785+func (a *App) loadShellTheme(ctx context.Context) {
786+ if a == nil {
787+ return
788+ }
789+
790+ a.shellTheme = shellTheme{}
791+ if a.deps.Config == nil {
792+ return
793+ }
794+
795+ config, err := a.deps.Config.Current(ctx)
796+ if err == nil {
797+ a.shellTheme = resolveShellTheme(config.Appearance)
798+ }
799+}
800+
801+func (a *App) resetRouteState() {
802+ a.historyEntries = nil
803+ a.historyStats = nil
804+ a.historyAnalytics = nil
805+ a.historyDomainStats = nil
806+ a.historyQuery = ""
807+ a.historyDomainFilter = ""
808+ a.historyOffset = 0
809+ a.historyWindowBefore = time.Time{}
810+ a.historyWindowAfter = time.Time{}
811+ a.historyHasMore = false
812+ a.historyNotice = ""
813+ a.historyError = ""
814+ a.favorites = nil
815+ a.folders = nil
816+ a.tags = nil
817+ a.favoriteFolderFilter = nil
818+ a.favoriteTagFilter = nil
819+ a.favoritesNotice = ""
820+ a.favoritesError = ""
821+ a.config = nil
822+ a.keybindings = port.KeybindingsConfig{}
823+ a.configNotice = ""
824+ a.configError = ""
825+}
826+
827+func (a *App) CurrentRoute() Route {
828+ if a == nil {
829+ return RouteUnknown
830+ }
831+ return a.currentRoute
832+}
A internal/ui/systemviews/app_test.go +1432 -0
@@ -0,0 +1,1432 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+func TestParseRoute(t *testing.T) {
18+ t.Parallel()
19+
20+ tests := []struct {
21+ name string
22+ uri string
23+ want Route
24+ }{
25+ {name: "empty", uri: "", want: RouteUnknown},
26+ {name: "scheme only", uri: "://", want: RouteUnknown},
27+ {name: "dumb scheme only", uri: "dumb://", want: RouteUnknown},
28+ {name: "dumb unknown host", uri: "dumb://unknown", want: RouteUnknown},
29+ {name: "dumb opaque bogus", uri: "dumb:bogus", want: RouteUnknown},
30+ {name: "history host", uri: "dumb://history", want: RouteHistory},
31+ {name: "history opaque", uri: "dumb:history", want: RouteHistory},
32+ {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites},
33+ {name: "favorites opaque", uri: "dumb:favorites", want: RouteFavorites},
34+ {name: "config host", uri: "dumb://config", want: RouteConfig},
35+ {name: "config opaque", uri: "dumb:config", want: RouteConfig},
36+ }
37+
38+ for _, tt := range tests {
39+ t.Run(tt.name, func(t *testing.T) {
40+ t.Parallel()
41+ if got := ParseRoute(tt.uri); got != tt.want {
42+ t.Fatalf("ParseRoute(%q) = %v, want %v", tt.uri, got, tt.want)
43+ }
44+ })
45+ }
46+}
47+
48+func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) {
49+ t.Parallel()
50+
51+ dom := &fakeDOM{}
52+ app := NewApp(Dependencies{
53+ DOM: dom,
54+ LocationURI: "dumb://history",
55+ })
56+
57+ require.NoError(t, app.Run())
58+ assert.Equal(t, RouteHistory, app.CurrentRoute())
59+ assert.True(t, dom.Mounted())
60+ assert.Contains(t, dom.HTML(), "history")
61+ assert.Contains(t, dom.HTML(), "systemviews")
62+}
63+
64+func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
65+ t.Parallel()
66+
67+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{
68+ URL: "https://example.com",
69+ Title: "Example",
70+ }}}
71+
72+ app := NewApp(Dependencies{
73+ History: history,
74+ LocationURI: "dumb://history",
75+ })
76+
77+ require.NoError(t, app.LoadInitial(context.Background()))
78+ assert.Equal(t, RouteHistory, app.CurrentRoute())
79+ assert.Len(t, app.historyEntries, 1)
80+ assert.Contains(t, app.renderedHTML, "Example")
81+ assert.Contains(t, app.renderedHTML, "https://example.com")
82+ assert.True(t, history.called)
83+ assert.False(t, history.analyticsCalled)
84+ assert.Equal(t, 0, history.limit)
85+ assert.Equal(t, 0, history.offset)
86+
87+ // Shell frame present, no full document wrapper.
88+ assert.NotContains(t, app.renderedHTML, "<html")
89+ assert.NotContains(t, app.renderedHTML, "<head")
90+ assert.NotContains(t, app.renderedHTML, "<body")
91+ assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
92+ assert.Contains(t, app.renderedHTML, `data-route="history"`)
93+ assert.Contains(t, app.renderedHTML, `data-page-title="History - 1 entry, 1 visit, 0 days"`)
94+ assert.Contains(t, app.renderedHTML, `sv-section`)
95+ assert.Contains(t, app.renderedHTML, `class="sv-list"`)
96+}
97+
98+func TestAppLoadInitialHistoryRouteShowsTotalStatsWithWindowedTimeline(t *testing.T) {
99+ t.Parallel()
100+
101+ history := &fakeHistoryService{
102+ entries: []*entity.HistoryEntry{
103+ {ID: 1, URL: "https://older.example", Title: "Older", VisitCount: 2},
104+ {ID: 2, URL: "https://newer.example", Title: "Newer", VisitCount: 3},
105+ },
106+ stats: &entity.HistoryStats{TotalEntries: 8904, TotalVisits: 40947, UniqueDays: 121},
107+ }
108+ app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"})
109+
110+ require.NoError(t, app.LoadInitial(context.Background()))
111+ assert.True(t, history.statsCalled)
112+ assert.Contains(t, app.renderedHTML, "Loaded 2 items")
113+ assert.Contains(t, app.renderedHTML, `data-page-title="History - 8904 entries, 40947 visits, 121 days"`)
114+ assert.Contains(t, app.renderedHTML, `<h1 class="sv-title">History <span class="sv-title-detail">- 8904 entries, 40947 visits, 121 days</span></h1>`)
115+ assert.NotContains(t, app.renderedHTML, `sv-history-summary`)
116+ assert.NotContains(t, app.renderedHTML, `sv-stat-value`)
117+}
118+
119+func TestAppRunMountsHistoryLoadingBeforeAsyncHydration(t *testing.T) {
120+ t.Parallel()
121+
122+ dom := &fakeActionDOM{fakeDOM: fakeDOM{mounts: make(chan string, 4)}}
123+ history := &fakeHistoryService{
124+ entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Loaded entry"}},
125+ timelineStarted: make(chan struct{}),
126+ releaseTimeline: make(chan struct{}),
127+ }
128+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
129+
130+ require.NoError(t, app.RunWithContext(context.Background()))
131+ loadingHTML := receiveMount(t, dom.mounts)
132+ assert.Contains(t, loadingHTML, "Loading history")
133+ assert.NotContains(t, loadingHTML, "Loaded entry")
134+
135+ select {
136+ case <-history.timelineStarted:
137+ case <-time.After(time.Second):
138+ t.Fatal("history timeline was not started asynchronously")
139+ }
140+ close(history.releaseTimeline)
141+
142+ hydratedHTML := receiveMount(t, dom.mounts)
143+ assert.Contains(t, hydratedHTML, "Loaded entry")
144+ assert.Contains(t, hydratedHTML, "Loaded 1 item")
145+}
146+
147+func TestSurfaceActionErrorPreventsStaleAsyncHydrationMount(t *testing.T) {
148+ t.Parallel()
149+
150+ dom := &fakeDOM{mounts: make(chan string, 4)}
151+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
152+ app.currentRoute = RouteHistory
153+ staleHTML := "<div>Stale hydrated entry</div>"
154+ staleGeneration := app.renderGeneration
155+ ctx := context.Background()
156+
157+ app.surfaceActionError(ctx, errors.New("action worker failed"))
158+ errorHTML := receiveMount(t, dom.mounts)
159+ assert.Contains(t, errorHTML, "action worker failed")
160+ assert.NotContains(t, errorHTML, "Stale hydrated entry")
161+
162+ require.NoError(t, app.mountHTMLIfCurrent(ctx, staleHTML, staleGeneration))
163+ assertNoMount(t, dom.mounts, 100*time.Millisecond)
164+ assert.Contains(t, dom.HTML(), "action worker failed")
165+ assert.NotContains(t, dom.HTML(), "Stale hydrated entry")
166+}
167+
168+func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testing.T) {
169+ t.Parallel()
170+
171+ dom := &fakeActionDOM{fakeDOM: fakeDOM{mounts: make(chan string, 4)}}
172+ history := &fakeHistoryService{
173+ entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com", Title: "Stale hydrated entry"}},
174+ timelineStarted: make(chan struct{}),
175+ releaseTimeline: make(chan struct{}),
176+ }
177+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
178+ ctx := context.Background()
179+
180+ require.NoError(t, app.RunWithContext(ctx))
181+ loadingHTML := receiveMount(t, dom.mounts)
182+ assert.Contains(t, loadingHTML, "Loading history")
183+
184+ select {
185+ case <-history.timelineStarted:
186+ case <-time.After(time.Second):
187+ t.Fatal("history timeline was not started asynchronously")
188+ }
189+
190+ app.surfaceActionError(ctx, errors.New("action worker failed"))
191+ errorHTML := receiveMount(t, dom.mounts)
192+ assert.Contains(t, errorHTML, "action worker failed")
193+ assert.NotContains(t, errorHTML, "Stale hydrated entry")
194+
195+ close(history.releaseTimeline)
196+ assertNoMount(t, dom.mounts, 100*time.Millisecond)
197+ assert.Contains(t, dom.HTML(), "action worker failed")
198+ assert.NotContains(t, dom.HTML(), "Stale hydrated entry")
199+}
200+
201+func TestCurrentHistoryRouteSnapshotIncludesWindowAfter(t *testing.T) {
202+ cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
203+ app := NewApp(Dependencies{History: &fakeHistoryService{}, LocationURI: "dumb://history"})
204+ app.currentRoute = RouteHistory
205+ app.historyWindowAfter = cursor
206+
207+ snapshot := app.currentHistoryRouteSnapshotLocked()
208+ assert.Equal(t, cursor, snapshot.windowAfter)
209+}
210+
211+func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
212+ t.Parallel()
213+
214+ cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
215+ history := &fakeHistoryService{window: &entity.HistoryWindow{
216+ Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
217+ Before: cursor,
218+ After: cursor.Add(-24 * time.Hour),
219+ HasMore: true,
220+ }}
221+ dom := &fakeActionDOM{}
222+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
223+ app.currentRoute = RouteHistory
224+ app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
225+ app.historyWindowAfter = cursor
226+ app.historyHasMore = true
227+
228+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
229+ Action: historyActionLoadMore,
230+ Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
231+ }))
232+ assert.Equal(t, cursor, history.windowBefore)
233+ assert.Len(t, app.historyEntries, 2)
234+ assert.Contains(t, dom.AppendedHTML(), "Older entry")
235+ assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`)
236+ assert.Empty(t, dom.HTML(), "load-more should append a fragment instead of remounting the whole page")
237+}
238+
239+func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
240+ t.Parallel()
241+
242+ cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
243+ history := &fakeHistoryService{window: &entity.HistoryWindow{
244+ Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example", Title: "Older entry", LastVisited: cursor.Add(-time.Hour)}},
245+ Before: cursor,
246+ After: cursor.Add(-24 * time.Hour),
247+ HasMore: true,
248+ }}
249+ dom := &fakeDOM{}
250+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
251+ app.currentRoute = RouteHistory
252+ app.historyEntries = []*entity.HistoryEntry{{ID: 1, URL: "https://newer.example", Title: "Newer entry", LastVisited: cursor}}
253+ app.historyWindowAfter = cursor
254+ app.historyHasMore = true
255+
256+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
257+ Action: historyActionLoadMore,
258+ Data: map[string]string{"before": cursor.Format(time.RFC3339Nano)},
259+ }))
260+ assert.Equal(t, cursor, history.windowBefore)
261+ assert.Len(t, app.historyEntries, 2)
262+ assert.Contains(t, dom.HTML(), "Older entry")
263+ assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`)
264+}
265+
266+func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) {
267+ t.Parallel()
268+
269+ cursor := time.Date(2026, 4, 25, 9, 0, 0, 0, time.UTC)
270+ history := &fakeHistoryService{window: &entity.HistoryWindow{Entries: []*entity.HistoryEntry{{ID: 2, URL: "https://older.example"}}}}
271+ dom := &fakeActionDOM{}
272+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
273+ app.currentRoute = RouteHistory
274+ app.historyWindowAfter = cursor
275+ app.historyHasMore = true
276+
277+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
278+ Action: historyActionLoadMore,
279+ Data: map[string]string{"before": cursor.Add(-24 * time.Hour).Format(time.RFC3339Nano)},
280+ }))
281+ assert.False(t, history.called)
282+ assert.Empty(t, dom.AppendedHTML())
283+}
284+
285+func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
286+ t.Parallel()
287+
288+ history := &fakeHistoryService{
289+ entries: []*entity.HistoryEntry{{
290+ ID: 42,
291+ URL: "https://example.com/page",
292+ Title: "Example",
293+ }},
294+ domainStats: []*entity.DomainStat{{Domain: "www.example.com:8080", PageCount: 1, TotalVisits: 3}},
295+ }
296+
297+ app := NewApp(Dependencies{
298+ History: history,
299+ LocationURI: "dumb://history",
300+ })
301+
302+ require.NoError(t, app.LoadInitial(context.Background()))
303+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.search"`)
304+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteEntry"`)
305+ assert.Contains(t, app.renderedHTML, `data-id="42"`)
306+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteRange"`)
307+ assert.Contains(t, app.renderedHTML, `data-range="hour"`)
308+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`)
309+ assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`)
310+ assert.Contains(t, app.renderedHTML, `data-domain="example.com:8080"`)
311+ assert.Contains(t, app.renderedHTML, `>example.com</button>`)
312+ assert.Contains(t, app.renderedHTML, `data-sv-history-search data-sv-autofocus`)
313+ assert.NotContains(t, app.renderedHTML, "Keys:")
314+}
315+
316+func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
317+ dom := &fakeDOM{}
318+ history := &fakeHistoryService{
319+ entries: []*entity.HistoryEntry{{ID: 42, URL: "https://example.com", Title: "Example"}},
320+ searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}},
321+ }
322+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
323+
324+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
325+ Action: historyActionSearch,
326+ Data: map[string]string{"query": " example "},
327+ }))
328+ assert.True(t, history.searchCalled)
329+ assert.Equal(t, "example", history.query)
330+ assert.Contains(t, dom.HTML(), "Search result")
331+ assert.Contains(t, dom.HTML(), "Query: example")
332+
333+ app.historyOffset = historyTimelineLimit
334+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
335+ Action: historyActionDeleteEntry,
336+ Data: map[string]string{"id": "42"},
337+ }))
338+ assert.Equal(t, int64(42), history.deletedEntryID)
339+ assert.Equal(t, 0, app.historyOffset)
340+ assert.Contains(t, dom.HTML(), "Deleted history entry")
341+
342+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
343+ Action: historyActionDeleteRange,
344+ Data: map[string]string{"range": "week"},
345+ }))
346+ assert.Equal(t, "week", history.deletedRangeID)
347+ assert.Contains(t, dom.HTML(), "Deleted history from this week")
348+
349+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: historyActionClear}))
350+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
351+ Action: historyActionFilterDomain,
352+ Data: map[string]string{"domain": "example.com"},
353+ }))
354+ assert.True(t, history.called)
355+ assert.Equal(t, "example.com", history.windowDomain)
356+ assert.Empty(t, app.historyQuery)
357+
358+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
359+ Action: historyActionDeleteDomain,
360+ Data: map[string]string{"domain": "example.com"},
361+ }))
362+ assert.Equal(t, "example.com", history.deletedDomain)
363+ assert.Contains(t, dom.HTML(), "Deleted history for example.com")
364+}
365+
366+func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) {
367+ dom := &fakeActionDOM{}
368+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
369+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
370+
371+ ctx, cancel := context.WithCancel(context.Background())
372+ defer cancel()
373+ require.NoError(t, app.RunWithContext(ctx))
374+ require.NotNil(t, dom.handler)
375+
376+ app.Close()
377+ assert.True(t, dom.released)
378+ assert.False(t, app.enqueueDOMAction(DOMAction{Action: historyActionClear}))
379+ require.NotPanics(t, func() {
380+ dom.handler(DOMAction{Action: historyActionClear})
381+ })
382+}
383+
384+func TestAppRunCleansActionWorkerWhenBindingFails(t *testing.T) {
385+ bindErr := errors.New("bind failed")
386+ dom := &failingActionDOM{bindErr: bindErr}
387+ app := NewApp(Dependencies{DOM: dom, LocationURI: "dumb://history"})
388+
389+ err := app.RunWithContext(context.Background())
390+ require.ErrorIs(t, err, bindErr)
391+ assert.Nil(t, app.actionQueue)
392+ assert.Nil(t, app.actionErrorQueue)
393+ assert.Nil(t, app.actionCtx)
394+ assert.False(t, app.actionWorkerActive())
395+ require.NotPanics(t, app.Close)
396+}
397+
398+func TestAppRunReleasesDOMWhenActionWorkerCannotStart(t *testing.T) {
399+ dom := &fakeActionDOM{}
400+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
401+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
402+
403+ ctx, cancel := context.WithCancel(context.Background())
404+ cancel()
405+ err := app.RunWithContext(ctx)
406+
407+ require.ErrorContains(t, err, "systemview action worker unavailable")
408+ assert.True(t, dom.released)
409+ assert.Nil(t, app.actionQueue)
410+ assert.Nil(t, app.actionErrorQueue)
411+ assert.Nil(t, app.actionCtx)
412+ assert.False(t, app.actionWorkerActive())
413+}
414+
415+func TestAppLoadInitialHistoryRouteRendersErrorState(t *testing.T) {
416+ t.Parallel()
417+
418+ history := &fakeHistoryService{err: errors.New("database unavailable")}
419+ app := NewApp(Dependencies{
420+ History: history,
421+ LocationURI: "dumb://history",
422+ })
423+
424+ require.NoError(t, app.LoadInitial(context.Background()))
425+ assert.Equal(t, RouteHistory, app.CurrentRoute())
426+ assert.True(t, history.called)
427+ assert.Contains(t, app.renderedHTML, "Could not load this system view")
428+ assert.Contains(t, app.renderedHTML, "database unavailable")
429+ assert.Contains(t, app.renderedHTML, `role="alert"`)
430+}
431+
432+func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {
433+ t.Parallel()
434+
435+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{
436+ URL: "https://example.com",
437+ Title: "Example",
438+ }}}
439+ config := &fakeConfigService{current: dto.SystemviewConfigPayload{
440+ Appearance: dto.WebUIAppearanceConfig{
441+ ColorScheme: "prefer-light",
442+ LightPalette: dto.ColorPalette{
443+ Background: "#ffffff",
444+ Surface: "#fafafa",
445+ SurfaceVariant: "#eeeeee",
446+ Text: "#111111",
447+ Muted: "#666666",
448+ Accent: "#0055ff",
449+ Border: "#dddddd",
450+ },
451+ DarkPalette: dto.ColorPalette{
452+ Background: "#111111",
453+ Surface: "#1a1a1a",
454+ SurfaceVariant: "#2a2a2a",
455+ Text: "#f5f5f5",
456+ Muted: "#a0a0a0",
457+ Accent: "#66aaff",
458+ Border: "#333333",
459+ },
460+ SansFont: "Inter",
461+ SerifFont: "Georgia",
462+ MonospaceFont: "JetBrains Mono",
463+ DefaultFontSize: 16,
464+ },
465+ }}
466+
467+ app := NewApp(Dependencies{
468+ Config: config,
469+ History: history,
470+ LocationURI: "dumb://history",
471+ })
472+
473+ require.NoError(t, app.LoadInitial(context.Background()))
474+ assert.Contains(t, app.renderedHTML, `class="sv-app sv-light"`)
475+ assert.Contains(t, app.renderedHTML, `--sv-background: #ffffff;`)
476+ assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #eeeeee;`)
477+}
478+
479+func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) {
480+ t.Parallel()
481+
482+ favorites := &fakeFavoritesService{
483+ favorites: []*entity.Favorite{{URL: "https://example.com", Title: "Example"}},
484+ folders: []*entity.Folder{{Name: "Read Later"}},
485+ tags: []*entity.Tag{{Name: "Go"}},
486+ }
487+
488+ app := NewApp(Dependencies{
489+ Favorites: favorites,
490+ LocationURI: "dumb://favorites",
491+ })
492+
493+ require.NoError(t, app.LoadInitial(context.Background()))
494+ assert.Equal(t, RouteFavorites, app.CurrentRoute())
495+ assert.Len(t, app.favorites, 1)
496+ assert.Len(t, app.folders, 1)
497+ assert.Len(t, app.tags, 1)
498+ assert.Contains(t, app.renderedHTML, "Favorites")
499+ assert.Contains(t, app.renderedHTML, "Example")
500+ assert.Contains(t, app.renderedHTML, "Read Later")
501+ assert.Contains(t, app.renderedHTML, "Go")
502+ assert.True(t, favorites.calledList)
503+ assert.True(t, favorites.calledFolders)
504+ assert.True(t, favorites.calledTags)
505+
506+ // Shell frame present, no full document wrapper.
507+ assert.NotContains(t, app.renderedHTML, "<html")
508+ assert.NotContains(t, app.renderedHTML, "<head")
509+ assert.NotContains(t, app.renderedHTML, "<body")
510+ assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
511+ assert.Contains(t, app.renderedHTML, `data-route="favorites"`)
512+ assert.Contains(t, app.renderedHTML, `data-page-title="Favorites — 1 bookmark"`)
513+ assert.Contains(t, app.renderedHTML, `sv-section`)
514+ assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
515+}
516+
517+func TestAppLoadInitialFavoritesRouteRendersCRUDControls(t *testing.T) {
518+ t.Parallel()
519+
520+ folderID := entity.FolderID(1)
521+ shortcut := 3
522+ favorites := &fakeFavoritesService{
523+ favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example", FolderID: &folderID, ShortcutKey: &shortcut}},
524+ folders: []*entity.Folder{{ID: folderID, Name: "Read Later", Icon: "📚"}},
525+ tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
526+ }
527+
528+ app := NewApp(Dependencies{Favorites: favorites, LocationURI: "dumb://favorites"})
529+
530+ require.NoError(t, app.LoadInitial(context.Background()))
531+ assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.create"`)
532+ assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.update"`)
533+ assert.Contains(t, app.renderedHTML, `data-sv-action="favorite.delete"`)
534+ assert.Contains(t, app.renderedHTML, `data-sv-action="folder.create"`)
535+ assert.Contains(t, app.renderedHTML, `data-sv-action="folder.update"`)
536+ assert.Contains(t, app.renderedHTML, `data-sv-action="folder.delete"`)
537+ assert.Contains(t, app.renderedHTML, `data-sv-action="tag.create"`)
538+ assert.Contains(t, app.renderedHTML, `data-sv-action="tag.update"`)
539+ assert.Contains(t, app.renderedHTML, `data-sv-action="tag.delete"`)
540+ assert.Contains(t, app.renderedHTML, `data-sv-action="tag.assign"`)
541+ assert.Contains(t, app.renderedHTML, `name="tags"`)
542+ assert.Contains(t, app.renderedHTML, `value="7"`)
543+ assert.Contains(t, app.renderedHTML, "Read Later")
544+ assert.Contains(t, app.renderedHTML, "Go")
545+ assert.Contains(t, app.renderedHTML, "Shortcut 3")
546+}
547+
548+func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
549+ dom := &fakeDOM{}
550+ favorites := &fakeFavoritesService{
551+ favorites: []*entity.Favorite{{ID: 42, URL: "https://example.com", Title: "Example"}},
552+ folders: []*entity.Folder{{ID: 5, Name: "Read Later"}},
553+ tags: []*entity.Tag{{ID: 7, Name: "Go", Color: "#00add8"}},
554+ }
555+ app := NewApp(Dependencies{DOM: dom, Favorites: favorites, LocationURI: "dumb://favorites"})
556+
557+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
558+ Action: favoriteActionCreate,
559+ Data: map[string]string{"url": "https://new.example", "title": "New", "folder_id": "5", "tags": "7"},
560+ }))
561+ require.NotNil(t, favorites.createdFavorite.FolderID)
562+ assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)
563+ assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID)
564+ assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags)
565+ assert.Contains(t, dom.HTML(), "Added favorite New")
566+
567+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
568+ Action: favoriteActionUpdate,
569+ Data: map[string]string{"id": "42", "title": "Updated", "favicon_url": "https://icon.example/favicon.ico", "shortcut_key": "4"},
570+ }))
571+ assert.Equal(t, entity.FavoriteID(42), favorites.updatedFavorite.ID)
572+ assert.Equal(t, "Updated", favorites.updatedFavorite.Title)
573+ require.NotNil(t, favorites.updatedFavorite.ShortcutKey)
574+ assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)
575+ assert.Contains(t, dom.HTML(), "Saved favorite Updated")
576+
577+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
578+ Action: tagActionAssign,
579+ Data: map[string]string{"favoriteId": "42", "tagId": "7"},
580+ }))
581+ assert.Equal(t, int64(42), favorites.assignedFavoriteID)
582+ assert.Equal(t, int64(7), favorites.assignedTagID)
583+
584+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
585+ Action: favoriteActionFilterFolder,
586+ Data: map[string]string{"folderId": "5"},
587+ }))
588+ require.NotNil(t, app.favoriteFolderFilter)
589+ assert.Equal(t, entity.FolderID(5), *app.favoriteFolderFilter)
590+ assert.Nil(t, app.favoriteTagFilter)
591+
592+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
593+ Action: favoriteActionFilterTag,
594+ Data: map[string]string{"tagId": "7"},
595+ }))
596+ require.NotNil(t, app.favoriteTagFilter)
597+ assert.Equal(t, entity.TagID(7), *app.favoriteTagFilter)
598+ assert.Nil(t, app.favoriteFolderFilter)
599+
600+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
601+ Action: folderActionCreate,
602+ Data: map[string]string{"name": "Reading", "icon": "📚"},
603+ }))
604+ assert.Equal(t, "Reading", favorites.createdFolder)
605+ assert.Equal(t, "📚", favorites.createdFolderIcon)
606+ assert.Contains(t, dom.HTML(), "Created folder 📚 Reading")
607+
608+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
609+ Action: favoriteActionDelete,
610+ Data: map[string]string{"id": "42"},
611+ }))
612+ assert.Equal(t, int64(42), favorites.deletedFavorite)
613+ assert.Contains(t, dom.HTML(), "Deleted favorite")
614+}
615+
616+func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
617+ t.Parallel()
618+
619+ config := dto.SystemviewConfigPayload{
620+ EngineType: "webkit",
621+ Appearance: dto.WebUIAppearanceConfig{
622+ ColorScheme: "prefer-dark",
623+ SansFont: "Inter",
624+ SerifFont: "Georgia",
625+ MonospaceFont: "JetBrains Mono",
626+ DefaultFontSize: 16,
627+ LightPalette: dto.ColorPalette{
628+ Background: "#ffffff",
629+ Surface: "#fafafa",
630+ SurfaceVariant: "#eeeeee",
631+ Text: "#111111",
632+ Muted: "#666666",
633+ Accent: "#0055ff",
634+ Border: "#dddddd",
635+ },
636+ DarkPalette: dto.ColorPalette{
637+ Background: "#111111",
638+ Surface: "#1a1a1a",
639+ SurfaceVariant: "#2a2a2a",
640+ Text: "#f5f5f5",
641+ Muted: "#a0a0a0",
642+ Accent: "#66aaff",
643+ Border: "#333333",
644+ },
645+ },
646+ Performance: dto.SystemviewPerformancePayload{
647+ Profile: "balanced",
648+ Custom: dto.SystemviewCustomPerformancePayload{
649+ SkiaCPUThreads: 4,
650+ SkiaGPUThreads: 2,
651+ WebProcessMemoryMB: 512,
652+ NetworkProcessMemoryMB: 128,
653+ WebViewPoolPrewarm: 1,
654+ },
655+ Hardware: dto.SystemviewHardwarePayload{
656+ CPUCores: 8,
657+ CPUThreads: 16,
658+ TotalRAMMB: 32768,
659+ GPUVendor: "NVIDIA",
660+ GPUName: "RTX 4060",
661+ VRAMMB: 8192,
662+ },
663+ },
664+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
665+ SearchShortcuts: map[string]dto.SearchShortcut{
666+ "ddg": {
667+ URL: "https://duckduckgo.com/?q=%s",
668+ Description: "DuckDuckGo",
669+ },
670+ },
671+ }
672+ service := &fakeConfigService{
673+ current: config,
674+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{
675+ {
676+ Mode: "default",
677+ DisplayName: "Default",
678+ Bindings: []port.KeybindingEntry{
679+ {Action: "open", Description: "Open", Keys: []string{"ctrl+o"}, DefaultKeys: []string{"ctrl+o"}},
680+ {Action: "new-tab", Description: "New tab", Keys: []string{"ctrl+t"}, DefaultKeys: []string{"ctrl+shift+t"}, IsCustom: true},
681+ },
682+ },
683+ {
684+ Mode: "search",
685+ DisplayName: "Search",
686+ Bindings: []port.KeybindingEntry{
687+ {Action: "find", Description: "Find", Keys: []string{"ctrl+f"}, DefaultKeys: []string{"ctrl+f"}},
688+ },
689+ },
690+ }},
691+ }
692+
693+ app := NewApp(Dependencies{
694+ Config: service,
695+ LocationURI: "dumb://config",
696+ })
697+
698+ require.NoError(t, app.LoadInitial(context.Background()))
699+ assert.Equal(t, RouteConfig, app.CurrentRoute())
700+ require.NotNil(t, app.config)
701+ assert.Equal(t, "webkit", app.config.EngineType)
702+ assert.Contains(t, app.renderedHTML, "webkit")
703+ assert.Contains(t, app.renderedHTML, "https://duckduckgo.com/?q=%s")
704+ assert.Contains(t, app.renderedHTML, "appearance.color_scheme")
705+ assert.Contains(t, app.renderedHTML, "prefer-dark")
706+ assert.Contains(t, app.renderedHTML, "appearance.sans_font")
707+ assert.Contains(t, app.renderedHTML, "Inter")
708+ assert.Contains(t, app.renderedHTML, "appearance.light_palette.background")
709+ assert.Contains(t, app.renderedHTML, "#ffffff")
710+ assert.Contains(t, app.renderedHTML, "performance.profile")
711+ assert.Contains(t, app.renderedHTML, "balanced")
712+ assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads")
713+ assert.Contains(t, app.renderedHTML, "4")
714+ assert.Contains(t, app.renderedHTML, "performance.hardware.gpu_vendor")
715+ assert.Contains(t, app.renderedHTML, "NVIDIA")
716+ assert.Contains(t, app.renderedHTML, "search_shortcuts.ddg.url")
717+ assert.Contains(t, app.renderedHTML, "Default")
718+ assert.Contains(t, app.renderedHTML, "Search")
719+ assert.Contains(t, app.renderedHTML, "Open")
720+ assert.Contains(t, app.renderedHTML, "ctrl+o")
721+ assert.Contains(t, app.renderedHTML, "New tab")
722+ assert.Contains(t, app.renderedHTML, "ctrl+t")
723+ assert.Contains(t, app.renderedHTML, "ctrl+shift+t")
724+ assert.Contains(t, app.renderedHTML, "Find")
725+ assert.Contains(t, app.renderedHTML, "open")
726+ assert.Contains(t, app.renderedHTML, "default")
727+ assert.Contains(t, app.renderedHTML, "custom")
728+ assert.NotContains(t, app.renderedHTML, "groups[0].bindings[0].action")
729+ assert.True(t, service.calledCurrent)
730+ assert.True(t, service.calledKeybindings)
731+
732+ // Shell frame present, no full document wrapper.
733+ assert.NotContains(t, app.renderedHTML, "<html")
734+ assert.NotContains(t, app.renderedHTML, "<head")
735+ assert.NotContains(t, app.renderedHTML, "<body")
736+ assert.Contains(t, app.renderedHTML, `class="sv-shell"`)
737+ assert.Contains(t, app.renderedHTML, `data-route="config"`)
738+ assert.Contains(t, app.renderedHTML, `data-page-title="Config — Dumber"`)
739+ assert.Contains(t, app.renderedHTML, `sv-section`)
740+ assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
741+}
742+
743+func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) {
744+ t.Parallel()
745+
746+ service := &fakeConfigService{
747+ current: testConfigPayload(),
748+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
749+ Mode: "default",
750+ DisplayName: "Default",
751+ Bindings: []port.KeybindingEntry{{
752+ Action: "toggle_history_systemview",
753+ Description: "Toggle history",
754+ Keys: []string{"ctrl+h"},
755+ DefaultKeys: []string{"ctrl+h"},
756+ }},
757+ }}},
758+ }
759+ app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
760+
761+ require.NoError(t, app.LoadInitial(context.Background()))
762+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`)
763+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`)
764+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`)
765+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`)
766+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`)
767+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.delete"`)
768+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.performance.save"`)
769+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.set"`)
770+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.reset"`)
771+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.resetAll"`)
772+ assert.Contains(t, app.renderedHTML, "may require restarting")
773+}
774+
775+func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
776+ dom := &fakeDOM{}
777+ service := &fakeConfigService{
778+ current: testConfigPayload(),
779+ defaultCfg: testDefaultConfigPayload(),
780+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
781+ Mode: "default",
782+ DisplayName: "Default",
783+ Bindings: []port.KeybindingEntry{{
784+ Action: "toggle_history_systemview",
785+ Description: "Toggle history",
786+ Keys: []string{"ctrl+h"},
787+ DefaultKeys: []string{"ctrl+h"},
788+ }},
789+ }}},
790+ setResp: port.SetKeybindingResponse{},
791+ }
792+ app := NewApp(Dependencies{DOM: dom, Config: service, LocationURI: "dumb://config"})
793+
794+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
795+ Action: configActionSaveAppearance,
796+ Data: map[string]string{
797+ "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono",
798+ "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light",
799+ "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd",
800+ "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333",
801+ },
802+ }))
803+ assert.True(t, service.calledSave)
804+ assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)
805+ assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001)
806+ assert.Contains(t, dom.HTML(), "Saved appearance settings")
807+ assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`)
808+
809+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
810+ Action: configActionCreateSearchShortcut,
811+ Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"},
812+ }))
813+ assert.Equal(t, "Google", service.savedConfig.SearchShortcuts["g"].Description)
814+ assert.Contains(t, dom.HTML(), "Created search shortcut g")
815+
816+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
817+ Action: configActionSavePerformance,
818+ Data: map[string]string{
819+ "profile": "custom", "skia_cpu_threads": "4", "skia_gpu_threads": "2",
820+ "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4",
821+ },
822+ }))
823+ assert.Equal(t, "custom", service.savedConfig.Performance.Profile)
824+ assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB)
825+ assert.Contains(t, dom.HTML(), "Saved performance settings")
826+
827+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
828+ Action: configActionSavePerformance,
829+ Data: map[string]string{"profile": "default"},
830+ }))
831+ assert.Equal(t, "default", service.savedConfig.Performance.Profile)
832+ assert.Contains(t, dom.HTML(), "Saved performance settings")
833+
834+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
835+ Action: configActionSetKeybinding,
836+ Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"},
837+ }))
838+ assert.True(t, service.calledSet)
839+ assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys)
840+ assert.Contains(t, service.setReq.RequestID, "systemviews-config-")
841+ assert.Contains(t, dom.HTML(), "Saved keybinding toggle_history_systemview")
842+
843+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
844+ Action: configActionResetKeybinding,
845+ Data: map[string]string{"mode": "default", "action": "toggle_history_systemview"},
846+ }))
847+ assert.True(t, service.calledReset)
848+ assert.Equal(t, "toggle_history_systemview", service.resetReq.Action)
849+ assert.Contains(t, service.resetReq.RequestID, "systemviews-config-")
850+ assert.NotEqual(t, service.setReq.RequestID, service.resetReq.RequestID)
851+ assert.Contains(t, dom.HTML(), "Reset keybinding toggle_history_systemview")
852+
853+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings}))
854+ assert.True(t, service.calledResetAll)
855+ assert.Contains(t, dom.HTML(), "Reset all keybindings to defaults")
856+}
857+
858+func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) {
859+ t.Parallel()
860+
861+ service := &fakeConfigService{current: testConfigPayload(), defaultCfg: testDefaultConfigPayload()}
862+ app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
863+
864+ err := app.handleConfigAction(context.Background(), DOMAction{
865+ Action: configActionSaveSearch,
866+ Data: map[string]string{"default_search_engine": "https://example.com/search"},
867+ })
868+ require.Error(t, err)
869+ assert.False(t, service.calledSave)
870+
871+ err = app.handleConfigAction(context.Background(), DOMAction{
872+ Action: configActionCreateSearchShortcut,
873+ Data: map[string]string{"key": "bad", "url": "https://example.com/search", "description": "Bad"},
874+ })
875+ require.Error(t, err)
876+ assert.False(t, service.calledSave)
877+}
878+
879+func testConfigPayload() dto.SystemviewConfigPayload {
880+ return dto.SystemviewConfigPayload{
881+ EngineType: "webkit",
882+ Appearance: dto.WebUIAppearanceConfig{
883+ ColorScheme: "prefer-dark",
884+ SansFont: "Inter",
885+ SerifFont: "Georgia",
886+ MonospaceFont: "JetBrains Mono",
887+ DefaultFontSize: 16,
888+ LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},
889+ DarkPalette: dto.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"},
890+ },
891+ DefaultUIScale: 1,
892+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
893+ SearchShortcuts: map[string]dto.SearchShortcut{
894+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
895+ },
896+ Performance: dto.SystemviewPerformancePayload{
897+ Profile: "default",
898+ Custom: dto.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2},
899+ Hardware: dto.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096},
900+ },
901+ }
902+}
903+
904+func testDefaultConfigPayload() dto.SystemviewConfigPayload {
905+ cfg := testConfigPayload()
906+ cfg.Appearance.DefaultFontSize = 14
907+ cfg.DefaultUIScale = 1
908+ return cfg
909+}
910+
911+// Handwritten fake to capture DOM mounts for stateful render assertions.
912+type fakeDOM struct {
913+ mu sync.Mutex
914+ mounted bool
915+ html string
916+ appendedHTML string
917+ mounts chan string
918+}
919+
920+func (d *fakeDOM) Mount(markup string) error {
921+ d.mu.Lock()
922+ d.mounted = true
923+ d.html = markup
924+ mounts := d.mounts
925+ d.mu.Unlock()
926+ if mounts != nil {
927+ select {
928+ case mounts <- markup:
929+ default:
930+ }
931+ }
932+ return nil
933+}
934+
935+func (d *fakeDOM) Mounted() bool {
936+ d.mu.Lock()
937+ defer d.mu.Unlock()
938+ return d.mounted
939+}
940+
941+func (d *fakeDOM) HTML() string {
942+ d.mu.Lock()
943+ defer d.mu.Unlock()
944+ return d.html
945+}
946+
947+func (d *fakeDOM) AppendedHTML() string {
948+ d.mu.Lock()
949+ defer d.mu.Unlock()
950+ return d.appendedHTML
951+}
952+
953+func receiveMount(t *testing.T, mounts <-chan string) string {
954+ t.Helper()
955+ select {
956+ case html := <-mounts:
957+ return html
958+ case <-time.After(time.Second):
959+ t.Fatal("timed out waiting for DOM mount")
960+ return ""
961+ }
962+}
963+
964+func assertNoMount(t *testing.T, mounts <-chan string, wait time.Duration) {
965+ t.Helper()
966+ select {
967+ case html := <-mounts:
968+ t.Fatalf("unexpected DOM mount: %s", html)
969+ case <-time.After(wait):
970+ }
971+}
972+
973+type fakeActionDOM struct {
974+ fakeDOM
975+ handler DOMActionHandler
976+ released bool
977+}
978+
979+func (d *fakeActionDOM) BindActions(handler DOMActionHandler) error {
980+ d.handler = handler
981+ return nil
982+}
983+
984+func (d *fakeActionDOM) AppendHistoryTimeline(markup string) error {
985+ d.mu.Lock()
986+ d.appendedHTML += markup
987+ d.mu.Unlock()
988+ return nil
989+}
990+
991+func (d *fakeActionDOM) Release() {
992+ d.released = true
993+}
994+
995+type failingActionDOM struct {
996+ fakeDOM
997+ bindErr error
998+}
999+
1000+func (d *failingActionDOM) BindActions(DOMActionHandler) error {
1001+ return d.bindErr
1002+}
1003+
1004+// Handwritten fake to capture history state for stateful render assertions.
1005+type fakeHistoryService struct {
1006+ called bool
1007+ limit int
1008+ offset int
1009+ entries []*entity.HistoryEntry
1010+ err error
1011+ domainCalled bool
1012+ domain string
1013+
1014+ searchCalled bool
1015+ query string
1016+ searchLimit int
1017+ searchEntries []*entity.HistoryEntry
1018+
1019+ deletedEntryID int64
1020+ deletedRangeID string
1021+ deletedDomain string
1022+ domainStats []*entity.DomainStat
1023+ stats *entity.HistoryStats
1024+ statsCalled bool
1025+ window *entity.HistoryWindow
1026+ windowBefore time.Time
1027+ windowDomain string
1028+ analyticsCalled bool
1029+
1030+ timelineStarted chan struct{}
1031+ releaseTimeline chan struct{}
1032+ startOnce sync.Once
1033+}
1034+
1035+func (s *fakeHistoryService) Timeline(_ context.Context, limit, offset int) ([]*entity.HistoryEntry, error) {
1036+ s.called = true
1037+ s.limit = limit
1038+ s.offset = offset
1039+ if s.timelineStarted != nil {
1040+ s.startOnce.Do(func() { close(s.timelineStarted) })
1041+ }
1042+ if s.releaseTimeline != nil {
1043+ <-s.releaseTimeline
1044+ }
1045+ return s.entries, s.err
1046+}
1047+
1048+func (s *fakeHistoryService) TimelineByDomain(_ context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
1049+ s.domainCalled = true
1050+ s.domain = domain
1051+ s.limit = limit
1052+ s.offset = offset
1053+ return s.entries, s.err
1054+}
1055+
1056+func (s *fakeHistoryService) TimelineWindow(_ context.Context, before time.Time, domain string) (*entity.HistoryWindow, error) {
1057+ s.called = true
1058+ s.windowBefore = before
1059+ s.windowDomain = domain
1060+ if s.timelineStarted != nil {
1061+ s.startOnce.Do(func() { close(s.timelineStarted) })
1062+ }
1063+ if s.releaseTimeline != nil {
1064+ <-s.releaseTimeline
1065+ }
1066+ if s.err != nil {
1067+ return nil, s.err
1068+ }
1069+ if s.window != nil {
1070+ return s.window, nil
1071+ }
1072+ return &entity.HistoryWindow{Entries: s.entries, Before: before, After: before.Add(-24 * time.Hour)}, nil
1073+}
1074+
1075+func (s *fakeHistoryService) Search(_ context.Context, query string, limit int) ([]*entity.HistoryEntry, error) {
1076+ s.searchCalled = true
1077+ s.query = query
1078+ s.searchLimit = limit
1079+ if s.searchEntries != nil {
1080+ return s.searchEntries, nil
1081+ }
1082+ return s.entries, nil
1083+}
1084+
1085+func (s *fakeHistoryService) DeleteEntry(_ context.Context, id int64) error {
1086+ s.deletedEntryID = id
1087+ return nil
1088+}
1089+
1090+func (s *fakeHistoryService) DeleteRange(_ context.Context, rangeID string) error {
1091+ s.deletedRangeID = rangeID
1092+ return nil
1093+}
1094+
1095+func (s *fakeHistoryService) Stats(context.Context) (*entity.HistoryStats, error) {
1096+ s.statsCalled = true
1097+ return s.stats, nil
1098+}
1099+
1100+func (s *fakeHistoryService) Analytics(context.Context) (*entity.HistoryAnalytics, error) {
1101+ s.analyticsCalled = true
1102+ return nil, nil
1103+}
1104+
1105+func (s *fakeHistoryService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) {
1106+ return s.domainStats, nil
1107+}
1108+
1109+func (s *fakeHistoryService) DeleteDomain(_ context.Context, domain string) error {
1110+ s.deletedDomain = domain
1111+ return nil
1112+}
1113+
1114+// Handwritten fake to capture favorites state for stateful render assertions.
1115+type fakeFavoritesService struct {
1116+ calledList bool
1117+ calledFolders bool
1118+ calledTags bool
1119+ favorites []*entity.Favorite
1120+ folders []*entity.Folder
1121+ tags []*entity.Tag
1122+
1123+ createdFavorite dto.FavoriteCreateInput
1124+ updatedFavorite dto.FavoriteUpdateInput
1125+ deletedFavorite int64
1126+ createdFolder string
1127+ createdFolderIcon string
1128+ updatedFolderID int64
1129+ deletedFolderID int64
1130+ createdTag string
1131+ updatedTagID int64
1132+ deletedTagID int64
1133+ assignedFavoriteID int64
1134+ assignedTagID int64
1135+ removedFavoriteID int64
1136+ removedTagID int64
1137+}
1138+
1139+func (s *fakeFavoritesService) List(context.Context) ([]*entity.Favorite, error) {
1140+ s.calledList = true
1141+ return s.favorites, nil
1142+}
1143+
1144+func (s *fakeFavoritesService) ListFolders(context.Context) ([]*entity.Folder, error) {
1145+ s.calledFolders = true
1146+ return s.folders, nil
1147+}
1148+
1149+func (s *fakeFavoritesService) ListTags(context.Context) ([]*entity.Tag, error) {
1150+ s.calledTags = true
1151+ return s.tags, nil
1152+}
1153+
1154+func (s *fakeFavoritesService) CreateFavorite(_ context.Context, input dto.FavoriteCreateInput) (*entity.Favorite, error) {
1155+ s.createdFavorite = input
1156+ return &entity.Favorite{ID: 99, URL: input.URL, Title: input.Title, FolderID: input.FolderID}, nil
1157+}
1158+
1159+func (s *fakeFavoritesService) UpdateFavorite(_ context.Context, input dto.FavoriteUpdateInput) (*entity.Favorite, error) {
1160+ s.updatedFavorite = input
1161+ return &entity.Favorite{ID: input.ID, URL: "https://example.com", Title: input.Title, FaviconURL: input.FaviconURL, FolderID: input.FolderID, ShortcutKey: input.ShortcutKey}, nil
1162+}
1163+
1164+func (s *fakeFavoritesService) DeleteFavorite(_ context.Context, id int64) error {
1165+ s.deletedFavorite = id
1166+ return nil
1167+}
1168+
1169+func (s *fakeFavoritesService) SetShortcut(context.Context, int64, *int) error { return nil }
1170+
1171+func (s *fakeFavoritesService) SetFolder(context.Context, int64, *int64) error { return nil }
1172+
1173+func (s *fakeFavoritesService) CreateFolder(_ context.Context, name, icon string, _ *int64) (*entity.Folder, error) {
1174+ s.createdFolder = name
1175+ s.createdFolderIcon = icon
1176+ return &entity.Folder{ID: 77, Name: name, Icon: icon}, nil
1177+}
1178+
1179+func (s *fakeFavoritesService) UpdateFolder(_ context.Context, id int64, _, _ string) error {
1180+ s.updatedFolderID = id
1181+ return nil
1182+}
1183+
1184+func (s *fakeFavoritesService) DeleteFolder(_ context.Context, id int64) error {
1185+ s.deletedFolderID = id
1186+ return nil
1187+}
1188+
1189+func (s *fakeFavoritesService) CreateTag(_ context.Context, name, color string) (*entity.Tag, error) {
1190+ s.createdTag = name
1191+ return &entity.Tag{ID: 55, Name: name, Color: color}, nil
1192+}
1193+
1194+func (s *fakeFavoritesService) UpdateTag(_ context.Context, id int64, _, _ string) error {
1195+ s.updatedTagID = id
1196+ return nil
1197+}
1198+
1199+func (s *fakeFavoritesService) DeleteTag(_ context.Context, id int64) error {
1200+ s.deletedTagID = id
1201+ return nil
1202+}
1203+
1204+func (s *fakeFavoritesService) AssignTag(_ context.Context, favoriteID, tagID int64) error {
1205+ s.assignedFavoriteID = favoriteID
1206+ s.assignedTagID = tagID
1207+ return nil
1208+}
1209+
1210+func (s *fakeFavoritesService) RemoveTag(_ context.Context, favoriteID, tagID int64) error {
1211+ s.removedFavoriteID = favoriteID
1212+ s.removedTagID = tagID
1213+ return nil
1214+}
1215+
1216+// Handwritten fake to capture config state for stateful render assertions.
1217+type fakeConfigService struct {
1218+ calledCurrent bool
1219+ calledDefault bool
1220+ calledSave bool
1221+ calledKeybindings bool
1222+ calledSet bool
1223+ calledReset bool
1224+ calledResetAll bool
1225+
1226+ current dto.SystemviewConfigPayload
1227+ defaultCfg dto.SystemviewConfigPayload
1228+ keybindings port.KeybindingsConfig
1229+ savedConfig dto.WebUIConfig
1230+ setReq port.SetKeybindingRequest
1231+ setResp port.SetKeybindingResponse
1232+ resetReq port.ResetKeybindingRequest
1233+}
1234+
1235+func (s *fakeConfigService) Current(context.Context) (dto.SystemviewConfigPayload, error) {
1236+ s.calledCurrent = true
1237+ return s.current, nil
1238+}
1239+
1240+func (s *fakeConfigService) Default(context.Context) (dto.SystemviewConfigPayload, error) {
1241+ s.calledDefault = true
1242+ return s.defaultCfg, nil
1243+}
1244+
1245+func (s *fakeConfigService) Save(_ context.Context, cfg dto.WebUIConfig) error {
1246+ s.calledSave = true
1247+ s.savedConfig = cfg
1248+ s.current.Appearance = cfg.Appearance
1249+ s.current.DefaultUIScale = cfg.DefaultUIScale
1250+ s.current.DefaultSearchEngine = cfg.DefaultSearchEngine
1251+ s.current.SearchShortcuts = cfg.SearchShortcuts
1252+ s.current.Performance.Profile = cfg.Performance.Profile
1253+ s.current.Performance.Custom = dto.SystemviewCustomPerformancePayload{
1254+ SkiaCPUThreads: cfg.Performance.Custom.SkiaCPUThreads,
1255+ SkiaGPUThreads: cfg.Performance.Custom.SkiaGPUThreads,
1256+ WebProcessMemoryMB: cfg.Performance.Custom.WebProcessMemoryMB,
1257+ NetworkProcessMemoryMB: cfg.Performance.Custom.NetworkProcessMemoryMB,
1258+ WebViewPoolPrewarm: cfg.Performance.Custom.WebViewPoolPrewarm,
1259+ }
1260+ return nil
1261+}
1262+
1263+func (s *fakeConfigService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
1264+ s.calledKeybindings = true
1265+ return s.keybindings, nil
1266+}
1267+
1268+func (s *fakeConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
1269+ s.calledSet = true
1270+ s.setReq = req
1271+ return s.setResp, nil
1272+}
1273+
1274+func (s *fakeConfigService) ResetKeybinding(_ context.Context, req port.ResetKeybindingRequest) error {
1275+ s.calledReset = true
1276+ s.resetReq = req
1277+ return nil
1278+}
1279+
1280+func (s *fakeConfigService) ResetAllKeybindings(context.Context) error {
1281+ s.calledResetAll = true
1282+ return nil
1283+}
1284+
1285+func TestHandleFavoriteActionValidatesCreateURL(t *testing.T) {
1286+ app := NewApp(Dependencies{Favorites: &fakeFavoritesService{}})
1287+
1288+ err := app.handleFavoriteAction(context.Background(), DOMAction{
1289+ Action: favoriteActionCreate,
1290+ Data: map[string]string{"url": " ", "title": "Blank"},
1291+ })
1292+ require.Error(t, err)
1293+ assert.Contains(t, err.Error(), "favorite URL is required")
1294+
1295+ err = app.handleFavoriteAction(context.Background(), DOMAction{
1296+ Action: favoriteActionCreate,
1297+ Data: map[string]string{"url": "example.com", "title": "Relative"},
1298+ })
1299+ require.Error(t, err)
1300+ assert.Contains(t, err.Error(), "favorite URL must be absolute")
1301+
1302+ err = app.handleFavoriteAction(context.Background(), DOMAction{
1303+ Action: favoriteActionCreate,
1304+ Data: map[string]string{"url": "javascript:alert(1)", "title": "Script"},
1305+ })
1306+ require.Error(t, err)
1307+ assert.Contains(t, err.Error(), "favorite URL must use http, https, or dumb scheme")
1308+}
1309+
1310+func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) {
1311+ favorites := &fakeFavoritesService{}
1312+ app := NewApp(Dependencies{Favorites: favorites})
1313+
1314+ require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1315+ Action: favoriteActionCreate,
1316+ Data: map[string]string{"url": "dumb:history", "title": "History"},
1317+ }))
1318+ assert.Equal(t, "dumb:history", favorites.createdFavorite.URL)
1319+
1320+ require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1321+ Action: favoriteActionCreate,
1322+ Data: map[string]string{"url": "dumb://config", "title": "Config"},
1323+ }))
1324+ assert.Equal(t, "dumb://config", favorites.createdFavorite.URL)
1325+}
1326+
1327+func TestHandleFavoriteCreateRejectsUnknownDumbRoutes(t *testing.T) {
1328+ favorites := &fakeFavoritesService{}
1329+ app := NewApp(Dependencies{Favorites: favorites})
1330+
1331+ err := app.handleFavoriteAction(context.Background(), DOMAction{
1332+ Action: favoriteActionCreate,
1333+ Data: map[string]string{"url": "dumb:unknown", "title": "Unknown"},
1334+ })
1335+
1336+ require.Error(t, err)
1337+ assert.Contains(t, err.Error(), "known systemview route")
1338+}
1339+
1340+func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) {
1341+ _, err := parsePositiveInt64("abc", "tag id")
1342+ require.Error(t, err)
1343+ assert.Contains(t, err.Error(), "invalid tag id")
1344+ assert.Contains(t, err.Error(), "invalid syntax")
1345+
1346+ _, err = parsePositiveInt64("0", "tag id")
1347+ require.Error(t, err)
1348+ assert.Contains(t, err.Error(), "tag id must be a positive integer")
1349+}
1350+
1351+func TestHandleFavoriteTagActionsAcceptSnakeCaseIDs(t *testing.T) {
1352+ favorites := &fakeFavoritesService{}
1353+ app := NewApp(Dependencies{Favorites: favorites})
1354+
1355+ require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1356+ Action: tagActionAssign,
1357+ Data: map[string]string{"favorite_id": "42", "tag_id": "7"},
1358+ }))
1359+ assert.Equal(t, int64(42), favorites.assignedFavoriteID)
1360+ assert.Equal(t, int64(7), favorites.assignedTagID)
1361+
1362+ require.NoError(t, app.handleFavoriteAction(context.Background(), DOMAction{
1363+ Action: tagActionRemove,
1364+ Data: map[string]string{"favorite_id": "42", "tag_id": "7"},
1365+ }))
1366+ assert.Equal(t, int64(42), favorites.removedFavoriteID)
1367+ assert.Equal(t, int64(7), favorites.removedTagID)
1368+}
1369+
1370+func TestHandleHistorySearchClearsDomainFilter(t *testing.T) {
1371+ dom := &fakeDOM{}
1372+ history := &fakeHistoryService{searchEntries: []*entity.HistoryEntry{{ID: 7, URL: "https://search.example", Title: "Search result"}}}
1373+ app := NewApp(Dependencies{DOM: dom, History: history, LocationURI: "dumb://history"})
1374+ app.historyDomainFilter = "example.com"
1375+
1376+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
1377+ Action: historyActionSearch,
1378+ Data: map[string]string{"query": "search"},
1379+ }))
1380+
1381+ assert.Equal(t, "search", app.historyQuery)
1382+ assert.Empty(t, app.historyDomainFilter)
1383+ assert.True(t, history.searchCalled)
1384+ assert.False(t, history.domainCalled)
1385+}
1386+
1387+func TestAppRunWithContextRejectsNilContext(t *testing.T) {
1388+ app := NewApp(Dependencies{DOM: &fakeDOM{}, LocationURI: "dumb://history"})
1389+
1390+ var ctx context.Context
1391+ err := app.RunWithContext(ctx)
1392+
1393+ require.ErrorContains(t, err, "context is nil")
1394+}
1395+
1396+func TestAppRunWithContextRequiresDOMBeforeLoading(t *testing.T) {
1397+ history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ID: 1, URL: "https://example.com"}}}
1398+ app := NewApp(Dependencies{History: history, LocationURI: "dumb://history"})
1399+
1400+ err := app.RunWithContext(context.Background())
1401+
1402+ require.ErrorContains(t, err, "DOM not configured")
1403+ assert.False(t, history.called)
1404+}
1405+
1406+func TestHandleActionsRejectUnknownAction(t *testing.T) {
1407+ app := NewApp(Dependencies{
1408+ History: &fakeHistoryService{},
1409+ Favorites: &fakeFavoritesService{},
1410+ Config: &fakeConfigService{current: testConfigPayload()},
1411+ })
1412+
1413+ require.ErrorContains(t, app.handleHistoryAction(context.Background(), DOMAction{Action: "history.unknown"}), "unknown history action")
1414+ require.ErrorContains(t, app.handleFavoriteAction(context.Background(), DOMAction{Action: "favorite.unknown"}), "unknown favorite action")
1415+ require.ErrorContains(t, app.handleConfigAction(context.Background(), DOMAction{Action: "config.unknown"}), "unknown config action")
1416+}
1417+
1418+func TestSavePerformanceConfigRejectsOutOfRangeValues(t *testing.T) {
1419+ service := &fakeConfigService{current: testConfigPayload()}
1420+ app := NewApp(Dependencies{Config: service})
1421+
1422+ err := app.handleConfigAction(context.Background(), DOMAction{
1423+ Action: configActionSavePerformance,
1424+ Data: map[string]string{
1425+ "profile": "custom", "skia_cpu_threads": "65", "skia_gpu_threads": "2",
1426+ "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4",
1427+ },
1428+ })
1429+
1430+ require.ErrorContains(t, err, "Skia CPU threads must be between 0 and 8")
1431+ assert.False(t, service.calledSave)
1432+}
A internal/ui/systemviews/components.templ +96 -0
@@ -0,0 +1,96 @@
1+package systemviews
2+
3+templ appFrameComponent(page renderedPage, theme shellTheme, body templ.Component) {
4+ <div class={ appRootClass(theme) } data-route={ string(page.route) } data-page-title={ pageDocumentTitle(page) }
5+ if theme.InlineVars != "" {
6+ style={ theme.InlineVars }
7+ }
8+ >
9+ <div class="sv-shell">
10+ if page.subtitle != "" {
11+ <h1 class="sv-title">
12+ { page.subtitle }
13+ if page.subtitleDetail != "" {
14+ <span class="sv-title-detail">{ page.subtitleDetail }</span>
15+ }
16+ </h1>
17+ }
18+ <div class="sv-content">
19+ @body
20+ </div>
21+ </div>
22+ </div>
23+}
24+
25+templ Section(className string, title string) {
26+ <section class={ sectionClass(className) }>
27+ <h2>{ title }</h2>
28+ { children... }
29+ </section>
30+}
31+
32+templ Meta(text string) {
33+ <p class="sv-meta">{ text }</p>
34+}
35+
36+templ EmptyState(message string) {
37+ <div class="sv-empty"><p>{ message }</p></div>
38+}
39+
40+templ LoadingPanel(message string) {
41+ <div class="sv-loading-panel" role="status" aria-live="polite">
42+ <span class="sv-spinner" aria-hidden="true"></span>
43+ <span>{ message }</span>
44+ </div>
45+}
46+
47+templ LoadingCard(label string) {
48+ <div class="sv-stat-card sv-loading-card" role="status" aria-live="polite">
49+ <span class="sv-spinner" aria-hidden="true"></span>
50+ <span class="sv-stat-label">{ label }</span>
51+ </div>
52+}
53+
54+templ Alert(kind string, message string) {
55+ if message != "" {
56+ <div class={ alertClass(kind) } role={ alertRole(kind) }>{ message }</div>
57+ }
58+}
59+
60+templ List() {
61+ <ul class="sv-list">
62+ { children... }
63+ </ul>
64+}
65+
66+templ ListRow() {
67+ <li class="sv-list-row">
68+ { children... }
69+ </li>
70+}
71+
72+templ Button(label string, action string, classes string, attrs templ.Attributes, disabled bool) {
73+ <button type="button" class={ buttonClass(classes) } data-sv-action={ action } disabled?={ disabled } { attrs... }>{ label }</button>
74+}
75+
76+templ KeyList(keys []string) {
77+ if len(keys) == 0 {
78+ <span class="sv-key sv-key-empty">Unassigned</span>
79+ } else {
80+ for _, key := range keys {
81+ <span class="sv-key">{ key }</span>
82+ }
83+ }
84+}
85+
86+templ KV(rows []kvPair) {
87+ if len(rows) == 0 {
88+ @EmptyState("No configuration details")
89+ } else {
90+ <dl class="sv-kv">
91+ for _, row := range rows {
92+ <div class="sv-kv-row"><dt>{ row.Label }</dt><dd>{ row.Value }</dd></div>
93+ }
94+ </dl>
95+ }
96+}
A internal/ui/systemviews/components_templ.go +755 -0 generated file: collapsed
A internal/ui/systemviews/config.templ +198 -0
@@ -0,0 +1,198 @@
1+package systemviews
2+
3+import (
4+ "github.com/bnema/dumber/internal/application/dto"
5+ "github.com/bnema/dumber/internal/application/port"
6+)
7+
8+templ ConfigView(data configRenderData) {
9+ @Alert("success", data.Notice)
10+ @Alert("error", data.Error)
11+ @Section("sv-config-appearance", "Appearance") {
12+ @AppearanceForm(data.Config)
13+ }
14+ @Section("sv-config-search", "Search") {
15+ @SearchSettings(data.Config)
16+ }
17+ @Section("sv-config-performance", "Performance") {
18+ @PerformanceSettings(data.Config)
19+ }
20+ @Section("sv-config-keybindings", "Keybindings") {
21+ @Meta(configKeybindingSummary(data.Keybindings))
22+ @ConfigKeybindings(data.Keybindings)
23+ }
24+ @Section("sv-config-values", "Current payload") {
25+ @KV(flattenValues(data.Config))
26+ }
27+}
28+
29+templ AppearanceForm(config dto.SystemviewConfigPayload) {
30+ <form class="sv-card-form sv-config-form" data-sv-action="config.appearance.save">
31+ <label><span>Sans font</span><input name="sans_font" type="text" value={ config.Appearance.SansFont } required/></label>
32+ <label><span>Serif font</span><input name="serif_font" type="text" value={ config.Appearance.SerifFont } required/></label>
33+ <label><span>Monospace font</span><input name="monospace_font" type="text" value={ config.Appearance.MonospaceFont } required/></label>
34+ <label><span>Default font size</span><input name="default_font_size" type="number" min="1" max="72" value={ formatConfigInt(config.Appearance.DefaultFontSize) } required/></label>
35+ <label><span>UI scale</span><input name="default_ui_scale" type="number" min="0.5" max="3" step="0.05" value={ formatConfigFloat(config.DefaultUIScale) } required/></label>
36+ <label><span>Color scheme</span><select name="color_scheme">
37+ <option value="default" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "default") }>System default</option>
38+ <option value="prefer-light" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-light") }>Prefer light</option>
39+ <option value="prefer-dark" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-dark") }>Prefer dark</option>
40+ </select></label>
41+ @PaletteFields("Light palette", "light", config.Appearance.LightPalette)
42+ @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette)
43+ <div class="sv-button-row sv-form-actions">
44+ <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save appearance")</button>
45+ <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.appearance.reset" data-sv-confirm="Reset appearance settings to defaults?">@ButtonIconLabel(iconRotateCCW, "Reset appearance")</button>
46+ </div>
47+ </form>
48+}
49+
50+templ PaletteFields(title string, prefix string, palette dto.ColorPalette) {
51+ <fieldset class="sv-fieldset">
52+ <legend>{ title }</legend>
53+ <label><span>Background</span><input name={ prefix + "_background" } type="text" value={ palette.Background } required/></label>
54+ <label><span>Surface</span><input name={ prefix + "_surface" } type="text" value={ palette.Surface } required/></label>
55+ <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="text" value={ palette.SurfaceVariant } required/></label>
56+ <label><span>Text</span><input name={ prefix + "_text" } type="text" value={ palette.Text } required/></label>
57+ <label><span>Muted</span><input name={ prefix + "_muted" } type="text" value={ palette.Muted } required/></label>
58+ <label><span>Accent</span><input name={ prefix + "_accent" } type="text" value={ palette.Accent } required/></label>
59+ <label><span>Border</span><input name={ prefix + "_border" } type="text" value={ palette.Border } required/></label>
60+ </fieldset>
61+}
62+
63+templ SearchSettings(config dto.SystemviewConfigPayload) {
64+ <form class="sv-card-form" data-sv-action="config.search.save">
65+ <label><span>Default search engine</span><input name="default_search_engine" type="text" value={ config.DefaultSearchEngine } placeholder="https://duckduckgo.com/?q=%s" required/></label>
66+ <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save search default")</button>
67+ </form>
68+ <div class="sv-group">
69+ <div class="sv-group-header"><h4>Search shortcuts</h4></div>
70+ <p class="sv-meta">Shortcut URLs must include the %s placeholder for the search query.</p>
71+ <form class="sv-inline-form" data-sv-action="config.searchShortcut.create">
72+ <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label>
73+ <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" pattern=".*%s.*" required/></label>
74+ <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label>
75+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconPlus, "Add shortcut")</button>
76+ </form>
77+ {{ shortcuts := searchShortcutRows(config.SearchShortcuts) }}
78+ if len(shortcuts) == 0 {
79+ @EmptyState("No search shortcuts configured")
80+ } else {
81+ <ul class="sv-list">
82+ for _, shortcut := range shortcuts {
83+ @SearchShortcutItem(shortcut)
84+ }
85+ </ul>
86+ }
87+ </div>
88+}
89+
90+templ SearchShortcutItem(shortcut searchShortcutRow) {
91+ <li class="sv-list-row">
92+ <form class="sv-inline-form" data-sv-action="config.searchShortcut.update">
93+ <input type="hidden" name="key" value={ shortcut.Key }/>
94+ <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label>
95+ <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } pattern=".*%s.*" required/></label>
96+ <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label>
97+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>
98+ <button class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?" aria-label={ "Delete search shortcut " + shortcut.Key }>@IconOnly(iconTrash, "Delete search shortcut " + shortcut.Key)</button>
99+ </form>
100+ </li>
101+}
102+
103+templ PerformanceSettings(config dto.SystemviewConfigPayload) {
104+ {{ customProfile := performanceProfileSelected(config.Performance.Profile, "custom") }}
105+ <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save" data-sv-performance-form>
106+ <label><span>Profile</span><select name="profile" data-sv-performance-profile>
107+ <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option>
108+ <option value="balanced" selected?={ performanceProfileSelected(config.Performance.Profile, "balanced") }>Balanced</option>
109+ <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option>
110+ <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option>
111+ <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option>
112+ </select></label>
113+ <p class="sv-meta">Choose Custom to edit individual performance values.</p>
114+ <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) } disabled?={ !customProfile } data-sv-performance-custom/></label>
115+ <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) } disabled?={ !customProfile } data-sv-performance-custom/></label>
116+ <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) } disabled?={ !customProfile } data-sv-performance-custom/></label>
117+ <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) } disabled?={ !customProfile } data-sv-performance-custom/></label>
118+ <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) } disabled?={ !customProfile } data-sv-performance-custom/></label>
119+ <button class="sv-button" type="submit">@ButtonIconLabel(iconSave, "Save performance")</button>
120+ </form>
121+ <div class="sv-stat-grid">
122+ <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.CPUThreads) }</span><span class="sv-stat-label">CPU threads</span></div>
123+ <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.TotalRAMMB) }</span><span class="sv-stat-label">RAM MB</span></div>
124+ <div class="sv-stat-card"><span class="sv-stat-value">{ config.Performance.Hardware.GPUName }</span><span class="sv-stat-label">GPU</span></div>
125+ <div class="sv-stat-card"><span class="sv-stat-value">{ config.EngineType }</span><span class="sv-stat-label">Engine</span></div>
126+ </div>
127+ if !engineIsWebKit(config.EngineType) {
128+ <p class="sv-meta">Performance tuning applies only to the WebKit engine.</p>
129+ } else {
130+ <p class="sv-meta">Performance profile changes may require restarting the browser to affect existing engine processes.</p>
131+ }
132+}
133+
134+templ ConfigKeybindings(keybindings port.KeybindingsConfig) {
135+ {{ groups := configKeybindingGroups(keybindings) }}
136+ if groups == nil {
137+ @EmptyState("Keybindings unavailable")
138+ } else if len(groups) == 0 {
139+ @EmptyState("No keybindings configured")
140+ } else {
141+ <div class="sv-button-row">
142+ <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.keybinding.resetAll" data-sv-confirm="Reset all keybindings to defaults?">@ButtonIconLabel(iconRotateCCW, "Reset all keybindings")</button>
143+ </div>
144+ for _, group := range groups {
145+ @ConfigKeybindingGroup(group)
146+ }
147+ }
148+}
149+
150+templ ConfigKeybindingGroup(group renderedKeybindingGroup) {
151+ <div class="sv-group">
152+ <div class="sv-group-header">
153+ <div>
154+ <h4>{ keybindingGroupTitle(group) }</h4>
155+ if keybindingGroupMeta(group) != "" {
156+ <p class="sv-meta">{ keybindingGroupMeta(group) }</p>
157+ }
158+ </div>
159+ </div>
160+ <div class="sv-group-body">
161+ if len(group.Bindings) == 0 {
162+ @EmptyState("No bindings in this group")
163+ } else {
164+ <ul class="sv-list">
165+ for _, binding := range group.Bindings {
166+ @ConfigKeybinding(group, binding)
167+ }
168+ </ul>
169+ }
170+ </div>
171+ </div>
172+}
173+
174+templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding) {
175+ <li class="sv-list-row">
176+ <div class="sv-keybinding-row">
177+ <div>
178+ <strong>{ keybindingDescription(binding) }</strong>
179+ <p class="sv-meta">{ keybindingAction(binding) }</p>
180+ <div class="sv-binding-keys">
181+ @KeyList(binding.Keys)
182+ <span class="sv-badge">{ keybindingStatus(binding) }</span>
183+ if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 {
184+ <span class="sv-meta">default:</span>
185+ @KeyList(binding.DefaultKeys)
186+ }
187+ </div>
188+ </div>
189+ <form class="sv-inline-form" data-sv-action="config.keybinding.set">
190+ <input type="hidden" name="mode" value={ group.Mode }/>
191+ <input type="hidden" name="action" value={ binding.Action }/>
192+ <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h, ctrl+shift+h" required/></label>
193+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>
194+ <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>@ButtonIconLabel(iconRotateCCW, "Reset")</button>
195+ </form>
196+ </div>
197+ </li>
198+}
A internal/ui/systemviews/config_actions.go +493 -0
@@ -0,0 +1,493 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "fmt"
6+ "math"
7+ "net/url"
8+ "strconv"
9+ "strings"
10+ "sync/atomic"
11+
12+ "github.com/bnema/dumber/internal/application/dto"
13+ "github.com/bnema/dumber/internal/application/port"
14+)
15+
16+const (
17+ configActionSaveAppearance = "config.appearance.save"
18+ configActionResetAppearance = "config.appearance.reset"
19+ configActionSaveSearch = "config.search.save"
20+ configActionCreateSearchShortcut = "config.searchShortcut.create"
21+ configActionUpdateSearchShortcut = "config.searchShortcut.update"
22+ configActionDeleteSearchShortcut = "config.searchShortcut.delete"
23+ configActionSavePerformance = "config.performance.save"
24+ configActionSetKeybinding = "config.keybinding.set"
25+ configActionResetKeybinding = "config.keybinding.reset"
26+ configActionResetAllKeybindings = "config.keybinding.resetAll"
27+ configRequestIDPrefix = "systemviews-config"
28+ balancedPerformanceProfile = "balanced"
29+ customPerformanceProfile = "custom"
30+ httpScheme = "http"
31+ maxCustomPerformanceThreads = 8
32+ maxCustomPerformanceWebMemoryMB = 16384
33+ maxCustomPerformanceNetworkMemoryMB = 4096
34+ maxCustomPerformancePrewarm = 20
35+)
36+
37+var configRequestCounter atomic.Uint64
38+
39+func nextConfigRequestID() string {
40+ return fmt.Sprintf("%s-%d", configRequestIDPrefix, configRequestCounter.Add(1))
41+}
42+
43+func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error {
44+ if a.deps.Config == nil {
45+ return fmt.Errorf("config service not configured")
46+ }
47+
48+ switch event.Action {
49+ case configActionSaveAppearance:
50+ return a.saveAppearanceConfig(ctx, event.Data)
51+ case configActionResetAppearance:
52+ return a.resetAppearanceConfig(ctx)
53+ case configActionSaveSearch:
54+ return a.saveSearchConfig(ctx, event.Data)
55+ case configActionCreateSearchShortcut:
56+ return a.createSearchShortcut(ctx, event.Data)
57+ case configActionUpdateSearchShortcut:
58+ return a.updateSearchShortcut(ctx, event.Data)
59+ case configActionDeleteSearchShortcut:
60+ return a.deleteSearchShortcut(ctx, event.Data)
61+ case configActionSavePerformance:
62+ return a.savePerformanceConfig(ctx, event.Data)
63+ case configActionSetKeybinding:
64+ return a.setConfigKeybinding(ctx, event.Data)
65+ case configActionResetKeybinding:
66+ return a.resetConfigKeybinding(ctx, event.Data)
67+ case configActionResetAllKeybindings:
68+ if err := a.deps.Config.ResetAllKeybindings(ctx); err != nil {
69+ return err
70+ }
71+ a.configNotice = "Reset all keybindings to defaults"
72+ return nil
73+ default:
74+ return fmt.Errorf("unknown config action: %q", event.Action)
75+ }
76+}
77+
78+func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error {
79+ cfg, err := a.editableConfig(ctx)
80+ if err != nil {
81+ return err
82+ }
83+
84+ fontSize, err := parseConfigInt(data["default_font_size"], "default font size")
85+ if err != nil {
86+ return err
87+ }
88+ uiScale, err := parseConfigFloat(data["default_ui_scale"], "UI scale")
89+ if err != nil {
90+ return err
91+ }
92+ if fontSize <= 0 {
93+ return fmt.Errorf("default font size must be positive")
94+ }
95+ if uiScale <= 0 || math.IsNaN(uiScale) || math.IsInf(uiScale, 0) {
96+ return fmt.Errorf("UI scale must be a finite positive number")
97+ }
98+
99+ cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"])
100+ cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"])
101+ cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"])
102+ cfg.Appearance.DefaultFontSize = fontSize
103+ cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])
104+ cfg.Appearance.LightPalette = paletteFromForm(data, "light")
105+ cfg.Appearance.DarkPalette = paletteFromForm(data, "dark")
106+ cfg.DefaultUIScale = uiScale
107+
108+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
109+ return err
110+ }
111+ a.configNotice = "Saved appearance settings"
112+ return nil
113+}
114+
115+func (a *App) resetAppearanceConfig(ctx context.Context) error {
116+ cfg, err := a.editableConfig(ctx)
117+ if err != nil {
118+ return err
119+ }
120+ defaults, err := a.deps.Config.Default(ctx)
121+ if err != nil {
122+ return err
123+ }
124+ cfg.Appearance = defaults.Appearance
125+ cfg.DefaultUIScale = defaults.DefaultUIScale
126+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
127+ return err
128+ }
129+ a.configNotice = "Reset appearance settings to defaults"
130+ return nil
131+}
132+
133+func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) error {
134+ cfg, err := a.editableConfig(ctx)
135+ if err != nil {
136+ return err
137+ }
138+ defaultSearchEngine, err := requireSearchURLTemplate(data["default_search_engine"], "default search engine")
139+ if err != nil {
140+ return err
141+ }
142+ cfg.DefaultSearchEngine = defaultSearchEngine
143+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
144+ return err
145+ }
146+ a.configNotice = "Saved default search engine"
147+ return nil
148+}
149+
150+func (a *App) createSearchShortcut(ctx context.Context, data map[string]string) error {
151+ cfg, err := a.editableConfig(ctx)
152+ if err != nil {
153+ return err
154+ }
155+ key, shortcut := searchShortcutFromForm(data, "key")
156+ if key == "" {
157+ return fmt.Errorf("search shortcut key is required")
158+ }
159+ if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil {
160+ return err
161+ }
162+ cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
163+ if _, exists := cfg.SearchShortcuts[key]; exists {
164+ return fmt.Errorf("search shortcut %q already exists", key)
165+ }
166+ cfg.SearchShortcuts[key] = shortcut
167+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
168+ return err
169+ }
170+ a.configNotice = "Created search shortcut " + key
171+ return nil
172+}
173+
174+func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string) error {
175+ cfg, err := a.editableConfig(ctx)
176+ if err != nil {
177+ return err
178+ }
179+ oldKey := strings.TrimSpace(data["key"])
180+ if oldKey == "" {
181+ return fmt.Errorf("search shortcut key is required")
182+ }
183+ newKey, shortcut := searchShortcutFromForm(data, "new_key")
184+ if newKey == "" {
185+ return fmt.Errorf("new search shortcut key is required")
186+ }
187+ if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil {
188+ return err
189+ }
190+ cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
191+ if _, exists := cfg.SearchShortcuts[oldKey]; !exists {
192+ return fmt.Errorf("search shortcut %q not found", oldKey)
193+ }
194+ if newKey != oldKey {
195+ if _, exists := cfg.SearchShortcuts[newKey]; exists {
196+ return fmt.Errorf("search shortcut %q already exists", newKey)
197+ }
198+ delete(cfg.SearchShortcuts, oldKey)
199+ }
200+ cfg.SearchShortcuts[newKey] = shortcut
201+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
202+ return err
203+ }
204+ a.configNotice = "Saved search shortcut " + newKey
205+ return nil
206+}
207+
208+func (a *App) deleteSearchShortcut(ctx context.Context, data map[string]string) error {
209+ cfg, err := a.editableConfig(ctx)
210+ if err != nil {
211+ return err
212+ }
213+ key := strings.TrimSpace(data["key"])
214+ if key == "" {
215+ return fmt.Errorf("search shortcut key is required")
216+ }
217+ cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
218+ if _, exists := cfg.SearchShortcuts[key]; !exists {
219+ return fmt.Errorf("search shortcut %q not found", key)
220+ }
221+ delete(cfg.SearchShortcuts, key)
222+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
223+ return err
224+ }
225+ a.configNotice = "Deleted search shortcut " + key
226+ return nil
227+}
228+
229+func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) error {
230+ cfg, err := a.editableConfig(ctx)
231+ if err != nil {
232+ return err
233+ }
234+ profile := strings.TrimSpace(data["profile"])
235+ if !isAllowedPerformanceProfile(profile) {
236+ return fmt.Errorf("unknown performance profile: %q", profile)
237+ }
238+ cfg.Performance.Profile = profile
239+ if profile != customPerformanceProfile {
240+ if saveErr := a.saveEditableConfig(ctx, cfg); saveErr != nil {
241+ return saveErr
242+ }
243+ a.configNotice = "Saved performance settings. Restart may be required."
244+ return nil
245+ }
246+
247+ skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads")
248+ if err != nil {
249+ return err
250+ }
251+ skiaGPU, err := parseConfigInt(data["skia_gpu_threads"], "Skia GPU threads")
252+ if err != nil {
253+ return err
254+ }
255+ webMemory, err := parseConfigInt(data["web_process_memory_mb"], "web process memory")
256+ if err != nil {
257+ return err
258+ }
259+ networkMemory, err := parseConfigInt(data["network_process_memory_mb"], "network process memory")
260+ if err != nil {
261+ return err
262+ }
263+ prewarm, err := parseConfigInt(data["webview_pool_prewarm"], "WebView pool prewarm")
264+ if err != nil {
265+ return err
266+ }
267+ if err := validatePerformanceInt(skiaCPU, "Skia CPU threads", 0, maxCustomPerformanceThreads); err != nil {
268+ return err
269+ }
270+ if err := validatePerformanceInt(skiaGPU, "Skia GPU threads", -1, maxCustomPerformanceThreads); err != nil {
271+ return err
272+ }
273+ if err := validatePerformanceInt(webMemory, "web process memory", 0, maxCustomPerformanceWebMemoryMB); err != nil {
274+ return err
275+ }
276+ if err := validatePerformanceInt(networkMemory, "network process memory", 0, maxCustomPerformanceNetworkMemoryMB); err != nil {
277+ return err
278+ }
279+ if err := validatePerformanceInt(prewarm, "WebView pool prewarm", 0, maxCustomPerformancePrewarm); err != nil {
280+ return err
281+ }
282+
283+ cfg.Performance.Custom = dto.WebUICustomPerformanceConfig{
284+ SkiaCPUThreads: skiaCPU,
285+ SkiaGPUThreads: skiaGPU,
286+ WebProcessMemoryMB: webMemory,
287+ NetworkProcessMemoryMB: networkMemory,
288+ WebViewPoolPrewarm: prewarm,
289+ }
290+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
291+ return err
292+ }
293+ a.configNotice = "Saved performance settings. Restart may be required."
294+ return nil
295+}
296+
297+func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) error {
298+ mode := strings.TrimSpace(data["mode"])
299+ action := strings.TrimSpace(data["action"])
300+ keys := parseKeyList(data["keys"])
301+ if mode == "" || action == "" {
302+ return fmt.Errorf("mode and action are required")
303+ }
304+ if err := a.requireKeybindingTarget(ctx, mode, action); err != nil {
305+ return err
306+ }
307+ resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{
308+ RequestID: nextConfigRequestID(),
309+ Mode: mode,
310+ Action: action,
311+ Keys: keys,
312+ })
313+ if err != nil {
314+ return err
315+ }
316+ if conflicts := len(resp.Conflicts); conflicts > 0 {
317+ a.configNotice = fmt.Sprintf("Saved keybinding with %d conflict(s)", conflicts)
318+ return nil
319+ }
320+ a.configNotice = "Saved keybinding " + action
321+ return nil
322+}
323+
324+func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) error {
325+ mode := strings.TrimSpace(data["mode"])
326+ action := strings.TrimSpace(data["action"])
327+ if mode == "" || action == "" {
328+ return fmt.Errorf("mode and action are required")
329+ }
330+ if err := a.requireKeybindingTarget(ctx, mode, action); err != nil {
331+ return err
332+ }
333+ if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{
334+ RequestID: nextConfigRequestID(),
335+ Mode: mode,
336+ Action: action,
337+ }); err != nil {
338+ return err
339+ }
340+ a.configNotice = "Reset keybinding " + action
341+ return nil
342+}
343+
344+func (a *App) editableConfig(ctx context.Context) (dto.WebUIConfig, error) {
345+ cfg, err := a.deps.Config.Current(ctx)
346+ if err != nil {
347+ return dto.WebUIConfig{}, err
348+ }
349+ a.config = &cfg
350+ return webUIConfigFromPayload(cfg), nil
351+}
352+
353+func (a *App) requireKeybindingTarget(ctx context.Context, mode, action string) error {
354+ keybindings, err := a.deps.Config.GetKeybindings(ctx)
355+ if err != nil {
356+ return err
357+ }
358+ a.keybindings = keybindings
359+ groups := configKeybindingGroups(keybindings)
360+ for _, group := range groups {
361+ if group.Mode != mode {
362+ continue
363+ }
364+ for _, binding := range group.Bindings {
365+ if binding.Action == action {
366+ return nil
367+ }
368+ }
369+ }
370+ return fmt.Errorf("keybinding %s/%s not found", mode, action)
371+}
372+
373+func (a *App) saveEditableConfig(ctx context.Context, cfg dto.WebUIConfig) error {
374+ if err := a.deps.Config.Save(ctx, cfg); err != nil {
375+ return err
376+ }
377+ a.config = nil
378+ return nil
379+}
380+
381+func webUIConfigFromPayload(payload dto.SystemviewConfigPayload) dto.WebUIConfig {
382+ return dto.WebUIConfig{
383+ Appearance: payload.Appearance,
384+ DefaultUIScale: payload.DefaultUIScale,
385+ DefaultSearchEngine: payload.DefaultSearchEngine,
386+ SearchShortcuts: cloneSearchShortcuts(payload.SearchShortcuts),
387+ Performance: dto.WebUIPerformanceConfig{
388+ Profile: payload.Performance.Profile,
389+ Custom: dto.WebUICustomPerformanceConfig{
390+ SkiaCPUThreads: payload.Performance.Custom.SkiaCPUThreads,
391+ SkiaGPUThreads: payload.Performance.Custom.SkiaGPUThreads,
392+ WebProcessMemoryMB: payload.Performance.Custom.WebProcessMemoryMB,
393+ NetworkProcessMemoryMB: payload.Performance.Custom.NetworkProcessMemoryMB,
394+ WebViewPoolPrewarm: payload.Performance.Custom.WebViewPoolPrewarm,
395+ },
396+ },
397+ }
398+}
399+
400+func cloneSearchShortcuts(shortcuts map[string]dto.SearchShortcut) map[string]dto.SearchShortcut {
401+ clone := make(map[string]dto.SearchShortcut, len(shortcuts))
402+ for key, shortcut := range shortcuts {
403+ clone[key] = shortcut
404+ }
405+ return clone
406+}
407+
408+func paletteFromForm(data map[string]string, prefix string) dto.ColorPalette {
409+ color := func(field string) string {
410+ return strings.TrimSpace(data[prefix+"_"+field])
411+ }
412+
413+ return dto.ColorPalette{
414+ Background: color("background"),
415+ Surface: color("surface"),
416+ SurfaceVariant: color("surface_variant"),
417+ Text: color("text"),
418+ Muted: color("muted"),
419+ Accent: color("accent"),
420+ Border: color("border"),
421+ }
422+}
423+
424+func searchShortcutFromForm(data map[string]string, keyField string) (string, dto.SearchShortcut) {
425+ return strings.TrimSpace(data[keyField]), dto.SearchShortcut{
426+ URL: strings.TrimSpace(data["url"]),
427+ Description: strings.TrimSpace(data["description"]),
428+ }
429+}
430+
431+func requireSearchURLTemplate(raw, label string) (string, error) {
432+ value := strings.TrimSpace(raw)
433+ if value == "" {
434+ return "", fmt.Errorf("%s is required", label)
435+ }
436+ if !strings.Contains(value, "%s") {
437+ return "", fmt.Errorf("%s must include %%s placeholder", label)
438+ }
439+ parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1))
440+ if err != nil || parsed.Host == "" || (parsed.Scheme != httpScheme && parsed.Scheme != "https") {
441+ return "", fmt.Errorf("%s must be an absolute http(s) URL", label)
442+ }
443+ return value, nil
444+}
445+
446+func parseConfigInt(raw, label string) (int, error) {
447+ value, err := strconv.Atoi(strings.TrimSpace(raw))
448+ if err != nil {
449+ return 0, fmt.Errorf("invalid %s", label)
450+ }
451+ return value, nil
452+}
453+
454+func parseConfigFloat(raw, label string) (float64, error) {
455+ value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
456+ if err != nil {
457+ return 0, fmt.Errorf("invalid %s", label)
458+ }
459+ return value, nil
460+}
461+
462+func isAllowedPerformanceProfile(profile string) bool {
463+ switch profile {
464+ case "default", balancedPerformanceProfile, "lite", "max", customPerformanceProfile:
465+ return true
466+ default:
467+ return false
468+ }
469+}
470+
471+func validatePerformanceInt(value int, label string, minValue, maxValue int) error {
472+ if value < minValue || value > maxValue {
473+ return fmt.Errorf("%s must be between %d and %d", label, minValue, maxValue)
474+ }
475+ return nil
476+}
477+
478+func parseKeyList(raw string) []string {
479+ raw = strings.TrimSpace(raw)
480+ if raw == "" {
481+ return nil
482+ }
483+ raw = strings.ReplaceAll(raw, ",", " ")
484+ parts := strings.Fields(raw)
485+ keys := make([]string, 0, len(parts))
486+ for _, part := range parts {
487+ key := strings.TrimSpace(part)
488+ if key != "" {
489+ keys = append(keys, key)
490+ }
491+ }
492+ return keys
493+}
A internal/ui/systemviews/config_templ.go +1332 -0 generated file: collapsed
A internal/ui/systemviews/config_view.go +373 -0
@@ -0,0 +1,373 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "reflect"
6+ "sort"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/application/dto"
10+ "github.com/bnema/dumber/internal/application/port"
11+)
12+
13+type configRenderData struct {
14+ Config dto.SystemviewConfigPayload
15+ Keybindings port.KeybindingsConfig
16+ Notice string
17+ Error string
18+}
19+
20+// maxFlattenDepth caps recursive config flattening to avoid runaway nesting.
21+const maxFlattenDepth = 16
22+
23+func configHTML(data configRenderData) string {
24+ return mustRenderComponent(ConfigView(data))
25+}
26+
27+func configKeybindingSummary(keybindings port.KeybindingsConfig) string {
28+ if keybindings.Groups == nil {
29+ return "Keybindings unavailable"
30+ }
31+ groups, bindings := summarizeKeybindings(keybindings)
32+ return fmt.Sprintf(
33+ "Keybindings loaded (%d %s, %d %s)",
34+ groups,
35+ plural(groups, "group"),
36+ bindings,
37+ plural(bindings, "binding"),
38+ )
39+}
40+
41+func configKeybindingGroups(keybindings port.KeybindingsConfig) []renderedKeybindingGroup {
42+ if keybindings.Groups == nil {
43+ return nil
44+ }
45+ return convertPortKeybindingGroups(keybindings.Groups)
46+}
47+
48+func keybindingGroupTitle(group renderedKeybindingGroup) string {
49+ title := strings.TrimSpace(group.DisplayName)
50+ if title == "" {
51+ title = strings.TrimSpace(group.Mode)
52+ }
53+ if title == "" {
54+ return "Keybinding group"
55+ }
56+ return title
57+}
58+
59+func keybindingGroupMeta(group renderedKeybindingGroup) string {
60+ meta := make([]string, 0, 2)
61+ if strings.TrimSpace(group.Mode) != "" {
62+ meta = append(meta, "mode: "+group.Mode)
63+ }
64+ if strings.TrimSpace(group.Activation) != "" {
65+ meta = append(meta, "activation: "+group.Activation)
66+ }
67+ return strings.Join(meta, " • ")
68+}
69+
70+func keybindingDescription(binding renderedKeybinding) string {
71+ description := strings.TrimSpace(binding.Description)
72+ if description != "" {
73+ return description
74+ }
75+ if strings.TrimSpace(binding.Action) != "" {
76+ return strings.TrimSpace(binding.Action)
77+ }
78+ return "Unnamed binding"
79+}
80+
81+func keybindingAction(binding renderedKeybinding) string {
82+ action := strings.TrimSpace(binding.Action)
83+ if action == "" {
84+ return "binding"
85+ }
86+ return action
87+}
88+
89+func keybindingIsCustom(binding renderedKeybinding) bool {
90+ return binding.IsCustom || !sameStringSlice(binding.Keys, binding.DefaultKeys)
91+}
92+
93+func keybindingStatus(binding renderedKeybinding) string {
94+ if keybindingIsCustom(binding) {
95+ return "custom"
96+ }
97+ return "default"
98+}
99+
100+func keybindingKeysValue(keys []string) string {
101+ return strings.Join(keys, ", ")
102+}
103+
104+type searchShortcutRow struct {
105+ Key string
106+ URL string
107+ Description string
108+}
109+
110+func searchShortcutRows(shortcuts map[string]dto.SearchShortcut) []searchShortcutRow {
111+ if len(shortcuts) == 0 {
112+ return nil
113+ }
114+ keys := make([]string, 0, len(shortcuts))
115+ for key := range shortcuts {
116+ keys = append(keys, key)
117+ }
118+ sort.Strings(keys)
119+ rows := make([]searchShortcutRow, 0, len(keys))
120+ for _, key := range keys {
121+ shortcut := shortcuts[key]
122+ rows = append(rows, searchShortcutRow{
123+ Key: key,
124+ URL: shortcut.URL,
125+ Description: shortcut.Description,
126+ })
127+ }
128+ return rows
129+}
130+
131+func formatConfigInt(value int) string {
132+ return fmt.Sprintf("%d", value)
133+}
134+
135+func formatConfigFloat(value float64) string {
136+ return fmt.Sprintf("%g", value)
137+}
138+
139+func colorSchemeSelected(current, option string) bool {
140+ return strings.EqualFold(strings.TrimSpace(current), option)
141+}
142+
143+func performanceProfileSelected(current, option string) bool {
144+ return strings.EqualFold(strings.TrimSpace(current), option)
145+}
146+
147+func engineIsWebKit(engine string) bool {
148+ return strings.EqualFold(strings.TrimSpace(engine), "webkit")
149+}
150+
151+func flattenValues(value any) []kvPair {
152+ rows := make([]kvPair, 0)
153+ flattenValue(&rows, "", reflect.ValueOf(value), 0)
154+ return rows
155+}
156+
157+func flattenValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
158+ if !value.IsValid() {
159+ appendFlatValue(rows, path, "null")
160+ return
161+ }
162+ if depth >= maxFlattenDepth {
163+ appendFlatValue(rows, path, "...")
164+ return
165+ }
166+
167+ for value.Kind() == reflect.Interface {
168+ if value.IsNil() {
169+ appendFlatValue(rows, path, "null")
170+ return
171+ }
172+ value = value.Elem()
173+ }
174+
175+ for value.Kind() == reflect.Pointer {
176+ if value.IsNil() {
177+ appendFlatValue(rows, path, "null")
178+ return
179+ }
180+ value = value.Elem()
181+ }
182+
183+ switch value.Kind() {
184+ case reflect.Struct:
185+ flattenStructValue(rows, path, value, depth)
186+ case reflect.Map:
187+ flattenMapValue(rows, path, value, depth)
188+ case reflect.Slice, reflect.Array:
189+ flattenListValue(rows, path, value, depth)
190+ default:
191+ appendFlatValue(rows, path, formatFlatValue(value))
192+ }
193+}
194+
195+func flattenStructValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
196+ typeOfValue := value.Type()
197+ for i := 0; i < value.NumField(); i++ {
198+ field := typeOfValue.Field(i)
199+ if !field.IsExported() {
200+ continue
201+ }
202+ name := jsonFieldName(field)
203+ if name == "-" {
204+ continue
205+ }
206+ flattenValue(rows, childFlatPath(path, name), value.Field(i), depth+1)
207+ }
208+}
209+
210+func flattenMapValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
211+ if value.Len() == 0 {
212+ appendFlatValue(rows, path, "{}")
213+ return
214+ }
215+
216+ keys := value.MapKeys()
217+ sort.Slice(keys, func(i, j int) bool {
218+ return mapKeyString(keys[i]) < mapKeyString(keys[j])
219+ })
220+
221+ for _, key := range keys {
222+ name := mapKeyString(key)
223+ flattenValue(rows, childFlatPath(path, name), value.MapIndex(key), depth+1)
224+ }
225+}
226+
227+func flattenListValue(rows *[]kvPair, path string, value reflect.Value, depth int) {
228+ if value.Len() == 0 {
229+ appendFlatValue(rows, path, "[]")
230+ return
231+ }
232+
233+ for i := 0; i < value.Len(); i++ {
234+ nextPath := fmt.Sprintf("%s[%d]", path, i)
235+ if path == "" {
236+ nextPath = fmt.Sprintf("[%d]", i)
237+ }
238+ flattenValue(rows, nextPath, value.Index(i), depth+1)
239+ }
240+}
241+
242+func childFlatPath(path, name string) string {
243+ if path == "" {
244+ return name
245+ }
246+ return path + "." + name
247+}
248+
249+func appendFlatValue(rows *[]kvPair, path, value string) {
250+ if strings.TrimSpace(path) == "" {
251+ path = "value"
252+ }
253+ *rows = append(*rows, kvPair{Label: path, Value: value})
254+}
255+
256+func formatFlatValue(value reflect.Value) string {
257+ if !value.IsValid() {
258+ return "null"
259+ }
260+
261+ switch value.Kind() {
262+ case reflect.String:
263+ return value.String()
264+ case reflect.Bool:
265+ return fmt.Sprint(value.Bool())
266+ case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
267+ return fmt.Sprint(value.Int())
268+ case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
269+ return fmt.Sprint(value.Uint())
270+ case reflect.Float32, reflect.Float64:
271+ return fmt.Sprint(value.Float())
272+ case reflect.Complex64, reflect.Complex128:
273+ return fmt.Sprint(value.Complex())
274+ default:
275+ if !value.CanInterface() {
276+ return "<unexported>"
277+ }
278+ return fmt.Sprint(value.Interface())
279+ }
280+}
281+
282+func jsonFieldName(field reflect.StructField) string {
283+ name := field.Tag.Get("json")
284+ if name == "" {
285+ return field.Name
286+ }
287+ if idx := strings.Index(name, ","); idx >= 0 {
288+ name = name[:idx]
289+ }
290+ if name == "" {
291+ return field.Name
292+ }
293+ return name
294+}
295+
296+func mapKeyString(value reflect.Value) string {
297+ if value.Kind() == reflect.String {
298+ return value.String()
299+ }
300+ if value.CanInterface() {
301+ return fmt.Sprint(value.Interface())
302+ }
303+ return formatFlatValue(value)
304+}
305+
306+func summarizeKeybindings(keybindings port.KeybindingsConfig) (groups, bindings int) {
307+ for _, group := range keybindings.Groups {
308+ groups++
309+ bindings += len(group.Bindings)
310+ }
311+ return groups, bindings
312+}
313+
314+func convertPortKeybindingGroups(groups []port.KeybindingGroup) []renderedKeybindingGroup {
315+ rendered := make([]renderedKeybindingGroup, 0, len(groups))
316+ for _, group := range groups {
317+ rendered = append(rendered, renderedKeybindingGroup{
318+ DisplayName: strings.TrimSpace(group.DisplayName),
319+ Mode: strings.TrimSpace(group.Mode),
320+ Activation: strings.TrimSpace(group.Activation),
321+ Bindings: convertPortKeybindingEntries(group.Bindings),
322+ })
323+ }
324+ return rendered
325+}
326+
327+func convertPortKeybindingEntries(bindings []port.KeybindingEntry) []renderedKeybinding {
328+ rendered := make([]renderedKeybinding, 0, len(bindings))
329+ for _, binding := range bindings {
330+ rendered = append(rendered, renderedKeybinding{
331+ Description: strings.TrimSpace(binding.Description),
332+ Action: strings.TrimSpace(binding.Action),
333+ Keys: append([]string(nil), binding.Keys...),
334+ DefaultKeys: append([]string(nil), binding.DefaultKeys...),
335+ IsCustom: binding.IsCustom,
336+ })
337+ }
338+ return rendered
339+}
340+
341+type renderedKeybindingGroup struct {
342+ DisplayName string
343+ Mode string
344+ Activation string
345+ Bindings []renderedKeybinding
346+}
347+
348+type renderedKeybinding struct {
349+ Description string
350+ Action string
351+ Keys []string
352+ DefaultKeys []string
353+ IsCustom bool
354+}
355+
356+func sameStringSlice(a, b []string) bool {
357+ if len(a) != len(b) {
358+ return false
359+ }
360+ for i := range a {
361+ if a[i] != b[i] {
362+ return false
363+ }
364+ }
365+ return true
366+}
367+
368+func plural(count int, singular string) string {
369+ if count == 1 {
370+ return singular
371+ }
372+ return singular + "s"
373+}
A internal/ui/systemviews/dom.go +40 -0
@@ -0,0 +1,40 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+)
7+
8+type DOM interface {
9+ Mount(markup string) error
10+}
11+
12+// DOMAction is a browser event delegated from the mounted systemview DOM.
13+type DOMAction struct {
14+ Action string
15+ Data map[string]string
16+}
17+
18+// DOMActionHandler handles delegated browser actions.
19+type DOMActionHandler func(DOMAction)
20+
21+// DOMActionBinder is implemented by DOM adapters that can delegate events back
22+// to the App. Tests can keep using the smaller DOM interface.
23+type DOMActionBinder interface {
24+ BindActions(handler DOMActionHandler) error
25+}
26+
27+// DOMHistoryTimelineAppender is implemented by DOM adapters that can append
28+// older history windows without reparsing and remounting the whole page.
29+type DOMHistoryTimelineAppender interface {
30+ AppendHistoryTimeline(markup string) error
31+}
32+
33+func placeholderHTML(route Route) string {
34+ name := html.EscapeString(string(route))
35+ if name == "" || route == RouteUnknown {
36+ name = "unknown"
37+ }
38+
39+ return fmt.Sprintf(`<p>systemviews %s</p>`, name)
40+}
A internal/ui/systemviews/dom_js.go +819 -0
@@ -0,0 +1,819 @@
1+//go:build js && wasm
2+
3+package systemviews
4+
5+import (
6+ "fmt"
7+ "strconv"
8+ "strings"
9+ "syscall/js"
10+)
11+
12+type browserDOM struct {
13+ document js.Value
14+ target js.Value
15+ bindings []jsEventBinding
16+ historyObserver js.Value
17+ historyObserverCallback js.Func
18+ activeConfirmCleanup func()
19+ activeConfirmID uint64
20+}
21+
22+type jsEventBinding struct {
23+ target js.Value
24+ event string
25+ fn js.Func
26+}
27+
28+func NewDOM() DOM {
29+ doc := js.Global().Get("document")
30+ if !doc.Truthy() {
31+ return &browserDOM{}
32+ }
33+
34+ target := doc.Call("getElementById", "app")
35+ return &browserDOM{document: doc, target: target}
36+}
37+
38+func (d *browserDOM) Mount(markup string) error {
39+ if d == nil || !d.target.Truthy() {
40+ return fmt.Errorf("DOM mount target #app not found")
41+ }
42+ d.target.Set("innerHTML", markup)
43+ d.updateDocumentTitle()
44+ d.scheduleAlertDismissal()
45+ d.syncPerformanceCustomInputStates()
46+ d.focusAutofocusTarget()
47+ d.setupHistoryInfiniteScroll()
48+ return nil
49+}
50+
51+func (d *browserDOM) AppendHistoryTimeline(markup string) error {
52+ if d == nil || !d.target.Truthy() {
53+ return fmt.Errorf("DOM mount target #app not found")
54+ }
55+ timeline := d.target.Call("querySelector", "[data-sv-history-timeline]")
56+ if !timeline.Truthy() {
57+ return fmt.Errorf("history timeline target not found")
58+ }
59+ if old := timeline.Call("querySelector", "[data-sv-history-load-more-container]"); old.Truthy() && old.Get("remove").Truthy() {
60+ old.Call("remove")
61+ }
62+
63+ container := d.document.Call("createElement", "div")
64+ container.Set("innerHTML", markup)
65+ d.mergeLeadingHistoryGroup(timeline, container)
66+ for container.Get("firstChild").Truthy() {
67+ timeline.Call("appendChild", container.Get("firstChild"))
68+ }
69+ d.setupHistoryInfiniteScroll()
70+ return nil
71+}
72+
73+func (d *browserDOM) mergeLeadingHistoryGroup(timeline, container js.Value) {
74+ if !timeline.Truthy() || !container.Truthy() {
75+ return
76+ }
77+ first := container.Get("firstElementChild")
78+ if !first.Truthy() || !first.Get("hasAttribute").Truthy() || !first.Call("hasAttribute", "data-sv-history-merge-date").Bool() {
79+ return
80+ }
81+ date := strings.TrimSpace(first.Call("getAttribute", "data-sv-history-merge-date").String())
82+ if date == "" {
83+ return
84+ }
85+ groups := timeline.Call("querySelectorAll", "[data-sv-history-date]")
86+ if !groups.Truthy() || groups.Get("length").Int() == 0 {
87+ return
88+ }
89+ lastGroup := groups.Index(groups.Get("length").Int() - 1)
90+ if strings.TrimSpace(lastGroup.Call("getAttribute", "data-sv-history-date").String()) != date {
91+ return
92+ }
93+ list := lastGroup.Call("querySelector", "ul")
94+ if !list.Truthy() {
95+ return
96+ }
97+ for first.Get("firstChild").Truthy() {
98+ list.Call("appendChild", first.Get("firstChild"))
99+ }
100+ if first.Get("remove").Truthy() {
101+ first.Call("remove")
102+ }
103+}
104+
105+func (d *browserDOM) updateDocumentTitle() {
106+ if d == nil || !d.document.Truthy() || !d.target.Truthy() {
107+ return
108+ }
109+ root := d.target.Call("querySelector", "[data-page-title]")
110+ if !root.Truthy() {
111+ return
112+ }
113+ title := strings.TrimSpace(root.Call("getAttribute", "data-page-title").String())
114+ if title == "" {
115+ return
116+ }
117+ d.document.Set("title", title)
118+}
119+
120+func (d *browserDOM) focusAutofocusTarget() {
121+ if d == nil || !d.target.Truthy() {
122+ return
123+ }
124+ target := d.target.Call("querySelector", "[data-sv-autofocus]")
125+ if !target.Truthy() || !target.Get("focus").Truthy() {
126+ return
127+ }
128+ target.Call("focus")
129+}
130+
131+func (d *browserDOM) scheduleAlertDismissal() {
132+ if d == nil || !d.target.Truthy() {
133+ return
134+ }
135+ alerts := d.target.Call("querySelectorAll", ".sv-alert")
136+ length := alerts.Get("length").Int()
137+ if length == 0 {
138+ return
139+ }
140+ setTimeout := js.Global().Get("setTimeout")
141+ if !setTimeout.Truthy() {
142+ return
143+ }
144+ for i := 0; i < length; i++ {
145+ alert := alerts.Index(i)
146+ if !alert.Truthy() {
147+ continue
148+ }
149+ scheduleAlertRemoval(setTimeout, alert)
150+ }
151+}
152+
153+func scheduleAlertRemoval(setTimeout, alert js.Value) {
154+ var fadeFn js.Func
155+ fadeFn = js.FuncOf(func(js.Value, []js.Value) any {
156+ defer fadeFn.Release()
157+ if !alert.Truthy() || !alert.Get("classList").Truthy() {
158+ return nil
159+ }
160+ alert.Get("classList").Call("add", "sv-alert-dismiss")
161+
162+ var removeFn js.Func
163+ removeFn = js.FuncOf(func(js.Value, []js.Value) any {
164+ defer removeFn.Release()
165+ if alert.Truthy() && alert.Get("remove").Truthy() {
166+ alert.Call("remove")
167+ }
168+ return nil
169+ })
170+ setTimeout.Invoke(removeFn, 220)
171+ return nil
172+ })
173+ setTimeout.Invoke(fadeFn, 4200)
174+}
175+
176+func (d *browserDOM) BindActions(handler DOMActionHandler) error {
177+ if d == nil || !d.target.Truthy() || handler == nil {
178+ return fmt.Errorf("DOM action binding target not available")
179+ }
180+ d.Release()
181+
182+ clickHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
183+ event := firstJSArg(args)
184+ if !event.Truthy() {
185+ return nil
186+ }
187+ element := closestActionElement(event)
188+ if !element.Truthy() {
189+ return nil
190+ }
191+ if strings.EqualFold(element.Get("tagName").String(), "form") {
192+ target := event.Get("target")
193+ if !target.Truthy() {
194+ target = event.Get("srcElement")
195+ }
196+ if !target.Truthy() || !element.Equal(target) {
197+ return nil
198+ }
199+ }
200+ action := element.Call("getAttribute", "data-sv-action").String()
201+ if action == "" {
202+ return nil
203+ }
204+ event.Call("preventDefault")
205+ data := collectActionData(element)
206+ d.confirmAction(element, func() {
207+ handler(DOMAction{Action: action, Data: data})
208+ })
209+ return nil
210+ })
211+ d.addEventBinding(d.target, "click", clickHandler)
212+
213+ submitHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
214+ event := firstJSArg(args)
215+ if !event.Truthy() {
216+ return nil
217+ }
218+ form := event.Get("target")
219+ if !form.Truthy() {
220+ return nil
221+ }
222+ action := form.Call("getAttribute", "data-sv-action").String()
223+ if action == "" {
224+ return nil
225+ }
226+ event.Call("preventDefault")
227+ updatePerformanceCustomInputs(form)
228+ data := collectActionData(form)
229+ for key, value := range collectFormData(form) {
230+ data[key] = value
231+ }
232+ d.confirmAction(form, func() {
233+ handler(DOMAction{Action: action, Data: data})
234+ })
235+ return nil
236+ })
237+ d.addEventBinding(d.target, "submit", submitHandler)
238+
239+ changeHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
240+ event := firstJSArg(args)
241+ if !event.Truthy() {
242+ return nil
243+ }
244+ target := event.Get("target")
245+ if !target.Truthy() || !target.Get("matches").Truthy() || !target.Call("matches", "[data-sv-performance-profile]").Bool() {
246+ return nil
247+ }
248+ form := target.Get("form")
249+ if form.Truthy() {
250+ updatePerformanceCustomInputs(form)
251+ }
252+ return nil
253+ })
254+ d.addEventBinding(d.target, "change", changeHandler)
255+
256+ keydownHandler := js.FuncOf(func(_ js.Value, args []js.Value) any {
257+ event := firstJSArg(args)
258+ if !d.eventTargetInsideMount(event) {
259+ return nil
260+ }
261+ d.handleKeyboard(event)
262+ return nil
263+ })
264+ d.addEventBinding(d.document, "keydown", keydownHandler)
265+
266+ return nil
267+}
268+
269+func (d *browserDOM) eventTargetInsideMount(event js.Value) bool {
270+ if d == nil || !d.target.Truthy() || !event.Truthy() {
271+ return false
272+ }
273+ target := event.Get("target")
274+ if !target.Truthy() {
275+ target = event.Get("srcElement")
276+ }
277+ if !target.Truthy() {
278+ return false
279+ }
280+ if d.document.Truthy() && target.Equal(d.document) {
281+ return true
282+ }
283+ if strings.EqualFold(target.Get("nodeName").String(), "body") {
284+ return true
285+ }
286+ return d.target.Call("contains", target).Bool()
287+}
288+
289+func (d *browserDOM) addEventBinding(target js.Value, event string, fn js.Func) {
290+ if !target.Truthy() {
291+ fn.Release()
292+ return
293+ }
294+ target.Call("addEventListener", event, fn)
295+ d.bindings = append(d.bindings, jsEventBinding{target: target, event: event, fn: fn})
296+}
297+
298+func (d *browserDOM) setupHistoryInfiniteScroll() {
299+ if d == nil || !d.target.Truthy() {
300+ return
301+ }
302+ // setupHistoryInfiniteScroll callbacks close over the current load-more
303+ // button. disconnectHistoryObserver owns the cleanup chain: it calls
304+ // d.historyObserver.disconnect(), releases d.historyObserverCallback, and
305+ // lets setupHistoryInfiniteScroll overwrite d.historyObserver with the new
306+ // observer so old button closures are not retained.
307+ d.disconnectHistoryObserver()
308+ observerCtor := js.Global().Get("IntersectionObserver")
309+ if !observerCtor.Truthy() {
310+ return
311+ }
312+ button := d.target.Call("querySelector", "[data-sv-history-load-more]")
313+ if !button.Truthy() {
314+ return
315+ }
316+ callback := js.FuncOf(func(_ js.Value, args []js.Value) any {
317+ if len(args) == 0 || !button.Truthy() {
318+ return nil
319+ }
320+ entries := args[0]
321+ for i := 0; i < entries.Get("length").Int(); i++ {
322+ entry := entries.Index(i)
323+ if !entry.Truthy() || !entry.Get("isIntersecting").Bool() {
324+ continue
325+ }
326+ if button.Get("disabled").Bool() || button.Get("dataset").Get("svLoading").String() == "true" {
327+ return nil
328+ }
329+ // A disabled button does not dispatch click events. Dispatch first, then
330+ // mark the control busy so the observer cannot enqueue duplicate loads
331+ // while the WASM action worker fetches the next history window.
332+ button.Call("click")
333+ button.Get("dataset").Set("svLoading", "true")
334+ button.Set("disabled", true)
335+ button.Set("textContent", "Loading older visits…")
336+ return nil
337+ }
338+ return nil
339+ })
340+ d.historyObserverCallback = callback
341+ options := js.Global().Get("Object").New()
342+ options.Set("rootMargin", "400px 0px")
343+ d.historyObserver = observerCtor.New(callback, options)
344+ d.historyObserver.Call("observe", button)
345+}
346+
347+func (d *browserDOM) disconnectHistoryObserver() {
348+ if d == nil {
349+ return
350+ }
351+ if d.historyObserver.Truthy() {
352+ d.historyObserver.Call("disconnect")
353+ d.historyObserver = js.Value{}
354+ }
355+ if d.historyObserverCallback.Truthy() {
356+ d.historyObserverCallback.Release()
357+ d.historyObserverCallback = js.Func{}
358+ }
359+}
360+
361+func (d *browserDOM) Release() {
362+ if d == nil {
363+ return
364+ }
365+ d.disconnectHistoryObserver()
366+ d.cleanupActiveConfirmDialog()
367+ for _, binding := range d.bindings {
368+ if binding.target.Truthy() {
369+ binding.target.Call("removeEventListener", binding.event, binding.fn)
370+ }
371+ binding.fn.Release()
372+ }
373+ d.bindings = nil
374+}
375+
376+func firstJSArg(args []js.Value) js.Value {
377+ if len(args) == 0 {
378+ return js.Value{}
379+ }
380+ return args[0]
381+}
382+
383+func closestActionElement(event js.Value) js.Value {
384+ target := event.Get("target")
385+ if !target.Truthy() || !target.Get("closest").Truthy() {
386+ return js.Value{}
387+ }
388+ return target.Call("closest", "[data-sv-action]")
389+}
390+
391+func collectActionData(element js.Value) map[string]string {
392+ data := map[string]string{}
393+ if !element.Truthy() {
394+ return data
395+ }
396+ dataset := element.Get("dataset")
397+ if !dataset.Truthy() {
398+ return data
399+ }
400+ keys := js.Global().Get("Object").Call("keys", dataset)
401+ for i := 0; i < keys.Get("length").Int(); i++ {
402+ key := keys.Index(i).String()
403+ if key == "svAction" || key == "svConfirm" {
404+ continue
405+ }
406+ data[key] = dataset.Get(key).String()
407+ }
408+ return data
409+}
410+
411+func (d *browserDOM) syncPerformanceCustomInputStates() {
412+ if d == nil || !d.target.Truthy() || !d.target.Get("querySelectorAll").Truthy() {
413+ return
414+ }
415+ forms := d.target.Call("querySelectorAll", "[data-sv-performance-form]")
416+ for i := 0; i < forms.Get("length").Int(); i++ {
417+ updatePerformanceCustomInputs(forms.Index(i))
418+ }
419+}
420+
421+func updatePerformanceCustomInputs(form js.Value) {
422+ if !form.Truthy() || !form.Get("querySelector").Truthy() {
423+ return
424+ }
425+ selectEl := form.Call("querySelector", "[data-sv-performance-profile]")
426+ if !selectEl.Truthy() {
427+ return
428+ }
429+ enabled := strings.EqualFold(selectEl.Get("value").String(), "custom")
430+ inputs := form.Call("querySelectorAll", "[data-sv-performance-custom]")
431+ for i := 0; i < inputs.Get("length").Int(); i++ {
432+ inputs.Index(i).Set("disabled", !enabled)
433+ }
434+}
435+
436+func collectFormData(form js.Value) map[string]string {
437+ data := map[string]string{}
438+ if !form.Truthy() || !js.Global().Get("FormData").Truthy() {
439+ return data
440+ }
441+ formData := js.Global().Get("FormData").New(form)
442+ entries := js.Global().Get("Array").Call("from", formData.Call("entries"))
443+ for i := 0; i < entries.Get("length").Int(); i++ {
444+ pair := entries.Index(i)
445+ if pair.Get("length").Int() < 2 {
446+ continue
447+ }
448+ key := pair.Index(0).String()
449+ value := pair.Index(1).String()
450+ if existing := data[key]; existing != "" {
451+ data[key] = existing + "," + value
452+ continue
453+ }
454+ data[key] = value
455+ }
456+ return data
457+}
458+
459+func (d *browserDOM) confirmAction(element js.Value, onConfirm func()) {
460+ if onConfirm == nil {
461+ return
462+ }
463+ confirmAttr := element.Call("getAttribute", "data-sv-confirm")
464+ if confirmAttr.Type() != js.TypeString {
465+ onConfirm()
466+ return
467+ }
468+ message := strings.TrimSpace(confirmAttr.String())
469+ if message == "" {
470+ onConfirm()
471+ return
472+ }
473+ if !d.showConfirmDialog(message, onConfirm) {
474+ onConfirm()
475+ }
476+}
477+
478+func (d *browserDOM) cleanupActiveConfirmDialog() {
479+ if d == nil || d.activeConfirmCleanup == nil {
480+ return
481+ }
482+ cleanup := d.activeConfirmCleanup
483+ d.activeConfirmCleanup = nil
484+ cleanup()
485+}
486+
487+func (d *browserDOM) showConfirmDialog(message string, onConfirm func()) bool {
488+ if d == nil || !d.document.Truthy() {
489+ return false
490+ }
491+ body := d.document.Get("body")
492+ if !body.Truthy() {
493+ return false
494+ }
495+ // Always close an existing modal through its cleanup path so key handlers and
496+ // js.Func values are released before the replacement is mounted.
497+ d.cleanupActiveConfirmDialog()
498+
499+ overlay := d.document.Call("createElement", "div")
500+ overlay.Get("classList").Call("add", "sv-confirm-backdrop")
501+ overlay.Set("role", "presentation")
502+
503+ dialog := d.document.Call("createElement", "div")
504+ dialog.Get("classList").Call("add", "sv-confirm-dialog")
505+ dialog.Set("role", "dialog")
506+ dialog.Call("setAttribute", "aria-modal", "true")
507+ dialog.Call("setAttribute", "aria-labelledby", "sv-confirm-title")
508+ dialog.Call("setAttribute", "aria-describedby", "sv-confirm-message")
509+ overlay.Call("appendChild", dialog)
510+
511+ title := d.document.Call("createElement", "h3")
512+ title.Set("id", "sv-confirm-title")
513+ title.Set("textContent", "Confirm action")
514+ dialog.Call("appendChild", title)
515+
516+ text := d.document.Call("createElement", "p")
517+ text.Set("id", "sv-confirm-message")
518+ text.Set("textContent", message)
519+ dialog.Call("appendChild", text)
520+
521+ actions := d.document.Call("createElement", "div")
522+ actions.Get("classList").Call("add", "sv-confirm-actions")
523+ dialog.Call("appendChild", actions)
524+
525+ cancelButton := d.document.Call("createElement", "button")
526+ cancelButton.Set("type", "button")
527+ cancelButton.Set("className", "sv-button sv-button-secondary")
528+ cancelButton.Set("textContent", "Cancel")
529+ actions.Call("appendChild", cancelButton)
530+
531+ confirmButton := d.document.Call("createElement", "button")
532+ confirmButton.Set("type", "button")
533+ confirmButton.Set("className", "sv-button sv-button-danger")
534+ confirmButton.Set("textContent", "Confirm")
535+ actions.Call("appendChild", confirmButton)
536+
537+ d.activeConfirmID++
538+ confirmID := d.activeConfirmID
539+
540+ var cleanup func()
541+ var cancelFn js.Func
542+ var confirmFn js.Func
543+ var keyFn js.Func
544+ var backdropFn js.Func
545+ cleaned := false
546+ confirmed := false
547+ cleanup = func() {
548+ if cleaned {
549+ return
550+ }
551+ cleaned = true
552+ if d.activeConfirmID == confirmID {
553+ d.activeConfirmCleanup = nil
554+ }
555+ if overlay.Truthy() {
556+ overlay.Call("remove")
557+ }
558+ if d.document.Truthy() && keyFn.Value.Truthy() {
559+ d.document.Call("removeEventListener", "keydown", keyFn)
560+ }
561+ cancelFn.Release()
562+ confirmFn.Release()
563+ keyFn.Release()
564+ backdropFn.Release()
565+ }
566+ runConfirm := func() {
567+ if confirmed {
568+ return
569+ }
570+ confirmed = true
571+ cleanup()
572+ onConfirm()
573+ }
574+ cancelFn = js.FuncOf(func(js.Value, []js.Value) any {
575+ cleanup()
576+ return nil
577+ })
578+ confirmFn = js.FuncOf(func(js.Value, []js.Value) any {
579+ runConfirm()
580+ return nil
581+ })
582+ keyFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
583+ event := firstJSArg(args)
584+ if !event.Truthy() {
585+ return nil
586+ }
587+ switch strings.ToLower(event.Get("key").String()) {
588+ case "escape":
589+ event.Call("preventDefault")
590+ event.Call("stopPropagation")
591+ cleanup()
592+ case "enter":
593+ if confirmDialogEnterShouldAutoConfirm(d.document, dialog, confirmButton) {
594+ event.Call("preventDefault")
595+ event.Call("stopPropagation")
596+ runConfirm()
597+ }
598+ }
599+ return nil
600+ })
601+ backdropFn = js.FuncOf(func(_ js.Value, args []js.Value) any {
602+ event := firstJSArg(args)
603+ if event.Truthy() && event.Get("target").Equal(overlay) {
604+ cleanup()
605+ }
606+ return nil
607+ })
608+
609+ cancelButton.Call("addEventListener", "click", cancelFn)
610+ confirmButton.Call("addEventListener", "click", confirmFn)
611+ document := d.document
612+ document.Call("addEventListener", "keydown", keyFn)
613+ overlay.Call("addEventListener", "click", backdropFn)
614+ d.activeConfirmCleanup = cleanup
615+ body.Call("appendChild", overlay)
616+ confirmButton.Call("focus")
617+ return true
618+}
619+
620+func confirmDialogEnterShouldAutoConfirm(document, dialog, confirmButton js.Value) bool {
621+ if !document.Truthy() || !dialog.Truthy() || !confirmButton.Truthy() {
622+ return false
623+ }
624+ active := document.Get("activeElement")
625+ if !active.Truthy() {
626+ return true
627+ }
628+ if active.Equal(confirmButton) {
629+ return true
630+ }
631+ if dialog.Call("contains", active).Bool() {
632+ return false
633+ }
634+ return true
635+}
636+
637+func (d *browserDOM) handleKeyboard(event js.Value) {
638+ if d == nil || !d.target.Truthy() || !event.Truthy() {
639+ return
640+ }
641+ if d.isHistoryRoute() {
642+ d.handleHistoryKeyboard(event)
643+ return
644+ }
645+ if d.isFavoritesRoute() {
646+ d.handleFavoritesKeyboard(event)
647+ }
648+}
649+
650+func (d *browserDOM) handleHistoryKeyboard(event js.Value) {
651+ if d == nil || !d.target.Truthy() || !event.Truthy() {
652+ return
653+ }
654+ key := strings.ToLower(event.Get("key").String())
655+ target := event.Get("target")
656+ if isEditableTarget(target) && key != "escape" {
657+ return
658+ }
659+
660+ switch key {
661+ case "/":
662+ if input := d.target.Call("querySelector", `[data-sv-history-search]`); input.Truthy() {
663+ event.Call("preventDefault")
664+ input.Call("focus")
665+ }
666+ case "j", "arrowdown":
667+ event.Call("preventDefault")
668+ d.focusHistoryRow(1)
669+ case "k", "arrowup":
670+ event.Call("preventDefault")
671+ d.focusHistoryRow(-1)
672+ case "enter":
673+ if row := d.activeHistoryRow(); row.Truthy() {
674+ event.Call("preventDefault")
675+ if link := row.Call("querySelector", ".sv-history-open"); link.Truthy() {
676+ link.Call("click")
677+ }
678+ }
679+ case "d", "delete", "backspace":
680+ // Delete the focused row through the same delegated click path as pointer users,
681+ // including the data-sv-confirm guard on the delete button.
682+ if row := d.activeHistoryRow(); row.Truthy() {
683+ event.Call("preventDefault")
684+ if button := row.Call("querySelector", `[data-sv-action="history.deleteEntry"]`); button.Truthy() {
685+ button.Call("click")
686+ }
687+ }
688+ case "escape":
689+ if target.Truthy() && isEditableTarget(target) {
690+ target.Call("blur")
691+ }
692+ }
693+}
694+
695+func (d *browserDOM) isHistoryRoute() bool {
696+ return d.target.Call("querySelector", `[data-route="history"]`).Truthy()
697+}
698+
699+func (d *browserDOM) isFavoritesRoute() bool {
700+ return d.target.Call("querySelector", `[data-route="favorites"]`).Truthy()
701+}
702+
703+func (d *browserDOM) handleFavoritesKeyboard(event js.Value) {
704+ key := strings.ToLower(event.Get("key").String())
705+ target := event.Get("target")
706+ if isEditableTarget(target) && key != "escape" {
707+ return
708+ }
709+
710+ switch key {
711+ case "j", "arrowdown":
712+ event.Call("preventDefault")
713+ d.focusFavoriteRow(1)
714+ case "k", "arrowup":
715+ event.Call("preventDefault")
716+ d.focusFavoriteRow(-1)
717+ case "enter":
718+ if row := d.activeFavoriteRow(); row.Truthy() {
719+ event.Call("preventDefault")
720+ if link := row.Call("querySelector", ".sv-favorite-open"); link.Truthy() {
721+ link.Call("click")
722+ }
723+ }
724+ case "e":
725+ if row := d.activeFavoriteRow(); row.Truthy() {
726+ event.Call("preventDefault")
727+ if input := row.Call("querySelector", `[name="title"]`); input.Truthy() {
728+ input.Call("focus")
729+ }
730+ }
731+ case "d", "delete", "backspace":
732+ if row := d.activeFavoriteRow(); row.Truthy() {
733+ event.Call("preventDefault")
734+ if button := row.Call("querySelector", `[data-sv-action="favorite.delete"]`); button.Truthy() {
735+ button.Call("click")
736+ }
737+ }
738+ case "escape":
739+ if target.Truthy() && isEditableTarget(target) {
740+ target.Call("blur")
741+ }
742+ }
743+}
744+
745+func isEditableTarget(target js.Value) bool {
746+ if !target.Truthy() {
747+ return false
748+ }
749+ tag := strings.ToLower(target.Get("tagName").String())
750+ role := strings.ToLower(target.Get("role").String())
751+ return tag == "input" || tag == "textarea" || tag == "select" || tag == "button" ||
752+ (tag == "a" && target.Get("href").String() != "") || role == "button" || role == "link" ||
753+ target.Get("isContentEditable").Bool()
754+}
755+
756+func (d *browserDOM) focusHistoryRow(delta int) {
757+ d.focusRow("[data-sv-history-row]", "svActiveHistoryIndex", delta)
758+}
759+
760+func (d *browserDOM) activeHistoryRow() js.Value {
761+ return d.activeRow("[data-sv-history-row]", "svActiveHistoryIndex")
762+}
763+
764+func (d *browserDOM) focusFavoriteRow(delta int) {
765+ d.focusRow("[data-sv-favorite-row]", "svActiveFavoriteIndex", delta)
766+}
767+
768+func (d *browserDOM) activeFavoriteRow() js.Value {
769+ return d.activeRow("[data-sv-favorite-row]", "svActiveFavoriteIndex")
770+}
771+
772+func (d *browserDOM) focusRow(selector, datasetKey string, delta int) {
773+ rows := d.target.Call("querySelectorAll", selector)
774+ length := rows.Get("length").Int()
775+ if length == 0 {
776+ return
777+ }
778+ idx := d.activeIndex(datasetKey)
779+ if idx < 0 {
780+ idx = 0
781+ } else {
782+ idx += delta
783+ }
784+ if idx < 0 {
785+ idx = 0
786+ }
787+ if idx >= length {
788+ idx = length - 1
789+ }
790+ for i := 0; i < length; i++ {
791+ rows.Index(i).Get("classList").Call("remove", "sv-focused")
792+ }
793+ row := rows.Index(idx)
794+ row.Get("classList").Call("add", "sv-focused")
795+ d.target.Get("dataset").Set(datasetKey, strconv.Itoa(idx))
796+ row.Call("scrollIntoView", map[string]any{"block": "nearest"})
797+}
798+
799+func (d *browserDOM) activeRow(selector, datasetKey string) js.Value {
800+ rows := d.target.Call("querySelectorAll", selector)
801+ length := rows.Get("length").Int()
802+ if length <= 0 {
803+ return js.Value{}
804+ }
805+ idx := d.activeIndex(datasetKey)
806+ if idx < 0 || idx >= length {
807+ return js.Value{}
808+ }
809+ return rows.Index(idx)
810+}
811+
812+func (d *browserDOM) activeIndex(datasetKey string) int {
813+ value := d.target.Get("dataset").Get(datasetKey).String()
814+ idx, err := strconv.Atoi(value)
815+ if err != nil {
816+ return -1
817+ }
818+ return idx
819+}
A internal/ui/systemviews/favorite_actions.go +311 -0
@@ -0,0 +1,311 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "fmt"
6+ "net/url"
7+ "strconv"
8+ "strings"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+)
13+
14+const (
15+ favoriteActionCreate = "favorite.create"
16+ favoriteActionUpdate = "favorite.update"
17+ favoriteActionDelete = "favorite.delete"
18+ favoriteActionFilterFolder = "favorite.filterFolder"
19+ favoriteActionFilterTag = "favorite.filterTag"
20+ favoriteActionClearFilters = "favorite.clearFilters"
21+ folderActionCreate = "folder.create"
22+ folderActionUpdate = "folder.update"
23+ folderActionDelete = "folder.delete"
24+ tagActionCreate = "tag.create"
25+ tagActionUpdate = "tag.update"
26+ tagActionDelete = "tag.delete"
27+ tagActionAssign = "tag.assign"
28+ tagActionRemove = "tag.remove"
29+)
30+
31+//nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
32+func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
33+ if a.deps.Favorites == nil {
34+ return fmt.Errorf("favorites service not configured")
35+ }
36+ data := event.Data
37+ switch event.Action {
38+ case favoriteActionCreate:
39+ folderID, err := optionalFolderID(data["folder_id"])
40+ if err != nil {
41+ return err
42+ }
43+ favoriteURL, err := validateFavoriteURL(data["url"])
44+ if err != nil {
45+ return err
46+ }
47+ tags, err := optionalTagIDs(data["tags"])
48+ if err != nil {
49+ return err
50+ }
51+ favorite, err := a.deps.Favorites.CreateFavorite(ctx, dto.FavoriteCreateInput{
52+ URL: favoriteURL,
53+ Title: strings.TrimSpace(data["title"]),
54+ FolderID: folderID,
55+ Tags: tags,
56+ })
57+ if err != nil {
58+ return err
59+ }
60+ a.favoritesNotice = "Added favorite " + favoriteItemLabel(favorite)
61+ case favoriteActionUpdate:
62+ id, err := parsePositiveInt64(data["id"], "favorite id")
63+ if err != nil {
64+ return err
65+ }
66+ folderID, err := optionalFolderID(data["folder_id"])
67+ if err != nil {
68+ return err
69+ }
70+ shortcut, err := optionalShortcut(data["shortcut_key"])
71+ if err != nil {
72+ return err
73+ }
74+ favorite, err := a.deps.Favorites.UpdateFavorite(ctx, dto.FavoriteUpdateInput{
75+ ID: entity.FavoriteID(id),
76+ Title: strings.TrimSpace(data["title"]),
77+ FaviconURL: strings.TrimSpace(data["favicon_url"]),
78+ FolderID: folderID,
79+ ShortcutKey: shortcut,
80+ })
81+ if err != nil {
82+ return err
83+ }
84+ a.favoritesNotice = "Saved favorite " + favoriteItemLabel(favorite)
85+ case favoriteActionDelete:
86+ id, err := parsePositiveInt64(data["id"], "favorite id")
87+ if err != nil {
88+ return err
89+ }
90+ if err := a.deps.Favorites.DeleteFavorite(ctx, id); err != nil {
91+ return err
92+ }
93+ a.favoritesNotice = "Deleted favorite"
94+ case favoriteActionFilterFolder:
95+ folderID, err := filterFolderID(firstActionValue(data, "folderId", "folder_id"))
96+ if err != nil {
97+ return err
98+ }
99+ a.favoriteFolderFilter = folderID
100+ a.favoriteTagFilter = nil
101+ a.favoritesNotice = ""
102+ case favoriteActionFilterTag:
103+ tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
104+ if err != nil {
105+ return err
106+ }
107+ id := entity.TagID(tagID)
108+ a.favoriteTagFilter = &id
109+ a.favoriteFolderFilter = nil
110+ a.favoritesNotice = ""
111+ case favoriteActionClearFilters:
112+ a.favoriteFolderFilter = nil
113+ a.favoriteTagFilter = nil
114+ a.favoritesNotice = ""
115+ case folderActionCreate:
116+ name := strings.TrimSpace(data["name"])
117+ if name == "" {
118+ return fmt.Errorf("folder name is required")
119+ }
120+ folder, err := a.deps.Favorites.CreateFolder(ctx, name, strings.TrimSpace(data["icon"]), nil)
121+ if err != nil {
122+ return err
123+ }
124+ a.favoritesNotice = "Created folder " + folderDisplayName(folder)
125+ case folderActionUpdate:
126+ id, err := parsePositiveInt64(data["id"], "folder id")
127+ if err != nil {
128+ return err
129+ }
130+ name := strings.TrimSpace(data["name"])
131+ if name == "" {
132+ return fmt.Errorf("folder name is required")
133+ }
134+ if err := a.deps.Favorites.UpdateFolder(ctx, id, name, strings.TrimSpace(data["icon"])); err != nil {
135+ return err
136+ }
137+ a.favoritesNotice = "Saved folder " + name
138+ case folderActionDelete:
139+ id, err := parsePositiveInt64(data["id"], "folder id")
140+ if err != nil {
141+ return err
142+ }
143+ if err := a.deps.Favorites.DeleteFolder(ctx, id); err != nil {
144+ return err
145+ }
146+ a.favoriteFolderFilter = nil
147+ a.favoritesNotice = "Deleted folder"
148+ case tagActionCreate:
149+ name := strings.TrimSpace(data["name"])
150+ if name == "" {
151+ return fmt.Errorf("tag name is required")
152+ }
153+ tag, err := a.deps.Favorites.CreateTag(ctx, name, strings.TrimSpace(data["color"]))
154+ if err != nil {
155+ return err
156+ }
157+ a.favoritesNotice = "Created tag " + tag.Name
158+ case tagActionUpdate:
159+ id, err := parsePositiveInt64(data["id"], "tag id")
160+ if err != nil {
161+ return err
162+ }
163+ name := strings.TrimSpace(data["name"])
164+ if name == "" {
165+ return fmt.Errorf("tag name is required")
166+ }
167+ if err := a.deps.Favorites.UpdateTag(ctx, id, name, strings.TrimSpace(data["color"])); err != nil {
168+ return err
169+ }
170+ a.favoritesNotice = "Saved tag " + name
171+ case tagActionDelete:
172+ id, err := parsePositiveInt64(data["id"], "tag id")
173+ if err != nil {
174+ return err
175+ }
176+ if err := a.deps.Favorites.DeleteTag(ctx, id); err != nil {
177+ return err
178+ }
179+ a.favoriteTagFilter = nil
180+ a.favoritesNotice = "Deleted tag"
181+ case tagActionAssign, tagActionRemove:
182+ favoriteID, err := parsePositiveInt64(firstActionValue(data, "favoriteId", "favorite_id"), "favorite id")
183+ if err != nil {
184+ return err
185+ }
186+ tagID, err := parsePositiveInt64(firstActionValue(data, "tagId", "tag_id"), "tag id")
187+ if err != nil {
188+ return err
189+ }
190+ if event.Action == tagActionAssign {
191+ if err := a.deps.Favorites.AssignTag(ctx, favoriteID, tagID); err != nil {
192+ return err
193+ }
194+ a.favoritesNotice = "Assigned tag"
195+ } else {
196+ if err := a.deps.Favorites.RemoveTag(ctx, favoriteID, tagID); err != nil {
197+ return err
198+ }
199+ a.favoritesNotice = "Removed tag"
200+ }
201+ default:
202+ return fmt.Errorf("unknown favorite action: %q", event.Action)
203+ }
204+ return nil
205+}
206+
207+func validateFavoriteURL(raw string) (string, error) {
208+ favoriteURL := strings.TrimSpace(raw)
209+ if favoriteURL == "" {
210+ return "", fmt.Errorf("favorite URL is required")
211+ }
212+ parsed, err := url.Parse(favoriteURL)
213+ if err != nil || parsed.Scheme == "" {
214+ return "", fmt.Errorf("favorite URL must be absolute")
215+ }
216+ switch strings.ToLower(parsed.Scheme) {
217+ case "http", "https":
218+ if parsed.Host == "" {
219+ return "", fmt.Errorf("favorite URL host is required")
220+ }
221+ case "dumb":
222+ if routeFromPageHost(parsed.Host, parsed.Opaque) != RouteUnknown {
223+ return favoriteURL, nil
224+ }
225+ return "", fmt.Errorf("favorite URL must target a known systemview route")
226+ default:
227+ return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
228+ }
229+ return favoriteURL, nil
230+}
231+
232+func firstActionValue(data map[string]string, keys ...string) string {
233+ for _, key := range keys {
234+ if value, ok := data[key]; ok {
235+ return value
236+ }
237+ }
238+ return ""
239+}
240+
241+func parsePositiveInt64(raw, label string) (int64, error) {
242+ id, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64)
243+ if err != nil {
244+ return 0, fmt.Errorf("invalid %s: %w", label, err)
245+ }
246+ if id <= 0 {
247+ return 0, fmt.Errorf("%s must be a positive integer", label)
248+ }
249+ return id, nil
250+}
251+
252+func optionalTagIDs(raw string) ([]entity.TagID, error) {
253+ raw = strings.TrimSpace(raw)
254+ if raw == "" {
255+ return nil, nil
256+ }
257+ parts := strings.Split(raw, ",")
258+ ids := make([]entity.TagID, 0, len(parts))
259+ seen := make(map[entity.TagID]struct{}, len(parts))
260+ for _, part := range parts {
261+ id, err := parsePositiveInt64(part, "tag id")
262+ if err != nil {
263+ return nil, err
264+ }
265+ tagID := entity.TagID(id)
266+ if _, ok := seen[tagID]; ok {
267+ continue
268+ }
269+ seen[tagID] = struct{}{}
270+ ids = append(ids, tagID)
271+ }
272+ return ids, nil
273+}
274+
275+// optionalFolderID returns nil for empty or "root" input, meaning no folder constraint;
276+// otherwise it parses a positive folder id into a non-nil *entity.FolderID.
277+func optionalFolderID(raw string) (*entity.FolderID, error) {
278+ raw = strings.TrimSpace(raw)
279+ if raw == "" || raw == "root" {
280+ return nil, nil
281+ }
282+ id, err := parsePositiveInt64(raw, "folder id")
283+ if err != nil {
284+ return nil, err
285+ }
286+ folderID := entity.FolderID(id)
287+ return &folderID, nil
288+}
289+
290+// filterFolderID returns *entity.FolderID(0) for "root" to explicitly filter
291+// favorites in the root folder; other inputs delegate to optionalFolderID.
292+func filterFolderID(raw string) (*entity.FolderID, error) {
293+ if strings.TrimSpace(raw) == "root" {
294+ root := entity.FolderID(0)
295+ return &root, nil
296+ }
297+ return optionalFolderID(raw)
298+}
299+
300+// optionalShortcut accepts favorite keyboard shortcuts 1–9; empty input clears the shortcut.
301+func optionalShortcut(raw string) (*int, error) {
302+ raw = strings.TrimSpace(raw)
303+ if raw == "" {
304+ return nil, nil
305+ }
306+ value, err := strconv.Atoi(raw)
307+ if err != nil || value < 1 || value > 9 {
308+ return nil, fmt.Errorf("shortcut key must be 1-9")
309+ }
310+ return &value, nil
311+}
A internal/ui/systemviews/favorites.templ +218 -0
@@ -0,0 +1,218 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "github.com/bnema/dumber/internal/domain/entity"
6+)
7+
8+templ FavoritesView(data favoritesRenderData) {
9+ @Alert("success", data.Notice)
10+ @Alert("error", data.Error)
11+ @Section("sv-favorites-controls", "Manage favorites") {
12+ @FavoritesControls(data)
13+ }
14+ @Section("sv-favorites-list", "Favorites") {
15+ @Meta(favoritesSummary(data))
16+ if len(data.Favorites) == 0 {
17+ @EmptyState("No favorites")
18+ } else {
19+ <ul class="sv-list">
20+ for _, favorite := range data.Favorites {
21+ if favorite != nil {
22+ @FavoriteItem(favorite, data.Folders, data.Tags)
23+ }
24+ }
25+ </ul>
26+ }
27+ }
28+ @Section("sv-folders-list", "Folders") {
29+ @Meta(foldersSummary(data))
30+ @FolderCreateForm()
31+ if len(data.Folders) == 0 {
32+ @EmptyState("No folders")
33+ } else {
34+ <ul class="sv-list">
35+ for _, folder := range data.Folders {
36+ if folder != nil {
37+ @FolderItem(folder)
38+ }
39+ }
40+ </ul>
41+ }
42+ }
43+ @Section("sv-tags-list", "Tags") {
44+ @Meta(tagsSummary(data))
45+ @TagCreateForm()
46+ if len(data.Tags) == 0 {
47+ @EmptyState("No tags")
48+ } else {
49+ <ul class="sv-list">
50+ for _, tag := range data.Tags {
51+ if tag != nil {
52+ @TagItem(tag)
53+ }
54+ }
55+ </ul>
56+ }
57+ }
58+}
59+
60+templ FavoritesControls(data favoritesRenderData) {
61+ <form class="sv-card-form" data-sv-action="favorite.create">
62+ <label><span>URL</span><input name="url" type="url" placeholder="https://example.com" required/></label>
63+ <label><span>Title</span><input name="title" type="text" placeholder="Optional title"/></label>
64+ @FolderSelect("folder_id", data.Folders, nil, "Folder")
65+ @FavoriteCreateTagPicker(data.Tags)
66+ <button class="sv-button" type="submit">@ButtonIconLabel(iconPlus, "Add favorite")</button>
67+ </form>
68+ <div class="sv-filter-panel">
69+ <div class="sv-button-row">
70+ <span class="sv-meta">Folders</span>
71+ <button type="button" class={ favoriteFilterClass(favoriteAllFilterActive(data)) } aria-pressed={ fmt.Sprintf("%t", favoriteAllFilterActive(data)) } data-sv-action="favorite.clearFilters">All</button>
72+ <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, entity.FolderID(0))) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, entity.FolderID(0))) } data-sv-action="favorite.filterFolder" data-folder-id="root">Unfiled</button>
73+ for _, folder := range data.Folders {
74+ if folder != nil {
75+ <button type="button" class={ favoriteFilterClass(favoriteFolderFilterActive(data, folder.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteFolderFilterActive(data, folder.ID)) } data-sv-action="favorite.filterFolder" data-folder-id={ fmt.Sprintf("%d", folder.ID) }>{ folderDisplayName(folder) }</button>
76+ }
77+ }
78+ </div>
79+ <div class="sv-button-row">
80+ <span class="sv-meta">Tags</span>
81+ for _, tag := range data.Tags {
82+ if tag != nil {
83+ <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } aria-pressed={ fmt.Sprintf("%t", favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }>
84+ <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name }
85+ </button>
86+ }
87+ }
88+ </div>
89+ </div>
90+ <p class="sv-meta sv-key-hints">Keys: <kbd>j</kbd>/<kbd>k</kbd> move, <kbd>Enter</kbd> open, <kbd>e</kbd> edit title, <kbd>d</kbd> delete focused favorite.</p>
91+}
92+
93+templ FavoriteCreateTagPicker(tags []*entity.Tag) {
94+ if len(tags) > 0 {
95+ <fieldset class="sv-tag-picker">
96+ <legend>Tags</legend>
97+ <div class="sv-tag-controls">
98+ for _, tag := range tags {
99+ if tag != nil {
100+ <label class="sv-tag-toggle">
101+ <input type="checkbox" name="tags" value={ fmt.Sprintf("%d", tag.ID) }/>
102+ <span><span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name }</span>
103+ </label>
104+ }
105+ }
106+ </div>
107+ </fieldset>
108+ }
109+}
110+
111+templ FavoriteItem(favorite *entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) {
112+ <li class="sv-list-row">
113+ <article class="sv-favorite-item" data-sv-favorite-row data-favorite-id={ fmt.Sprintf("%d", favorite.ID) }>
114+ <div class="sv-favorite-header">
115+ <div>
116+ <a class="sv-link sv-favorite-open" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>{ favoriteItemLabel(favorite) }</a>
117+ <p class="sv-history-url">{ favorite.URL }</p>
118+ if favoriteMetaText(favorite, folders) != "" {
119+ <p class="sv-meta">{ favoriteMetaText(favorite, folders) }</p>
120+ }
121+ </div>
122+ <div class="sv-history-row-actions">
123+ <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(favorite.URL)) }>@ButtonIconLabel(iconExternal, "Open")</a>
124+ <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="favorite.delete" data-id={ fmt.Sprintf("%d", favorite.ID) } data-sv-confirm="Delete this favorite?" aria-label="Delete this favorite">@IconOnly(iconTrash, "Delete this favorite")</button>
125+ </div>
126+ </div>
127+ @FavoriteEditForm(favorite, folders)
128+ @FavoriteTagControls(favorite, tags)
129+ </article>
130+ </li>
131+}
132+
133+templ FavoriteEditForm(favorite *entity.Favorite, folders []*entity.Folder) {
134+ <form class="sv-inline-form" data-sv-action="favorite.update">
135+ <input type="hidden" name="id" value={ fmt.Sprintf("%d", favorite.ID) }/>
136+ <label><span>Title</span><input name="title" type="text" value={ favorite.Title }/></label>
137+ <label><span>Favicon</span><input name="favicon_url" type="url" value={ favorite.FaviconURL }/></label>
138+ @FolderSelect("folder_id", folders, favorite.FolderID, "Folder")
139+ @ShortcutSelect(favorite.ShortcutKey)
140+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>
141+ </form>
142+}
143+
144+templ FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) {
145+ if len(tags) > 0 {
146+ <div class="sv-tag-controls">
147+ <span class="sv-meta">Tags</span>
148+ for _, tag := range tags {
149+ if tag != nil {
150+ <button type="button" class={ favoriteTagButtonClass(favorite, tag) } data-sv-action={ favoriteTagButtonAction(favorite, tag) } data-favorite-id={ fmt.Sprintf("%d", favorite.ID) } data-tag-id={ fmt.Sprintf("%d", tag.ID) }>
151+ <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ favoriteTagButtonLabel(favorite, tag) }
152+ </button>
153+ }
154+ }
155+ </div>
156+ }
157+}
158+
159+templ FolderCreateForm() {
160+ <form class="sv-inline-form" data-sv-action="folder.create">
161+ <label><span>Name</span><input name="name" type="text" required/></label>
162+ <label><span>Icon</span><input name="icon" type="text" placeholder="Optional"/></label>
163+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconFolder, "Create folder")</button>
164+ </form>
165+}
166+
167+templ FolderItem(folder *entity.Folder) {
168+ <li class="sv-list-row">
169+ <form class="sv-inline-form" data-sv-action="folder.update">
170+ <input type="hidden" name="id" value={ fmt.Sprintf("%d", folder.ID) }/>
171+ <label><span>Name</span><input name="name" type="text" value={ folder.Name } required/></label>
172+ <label><span>Icon</span><input name="icon" type="text" value={ folder.Icon }/></label>
173+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>
174+ <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="folder.delete" data-id={ fmt.Sprintf("%d", folder.ID) } data-sv-confirm="Delete this folder? Favorites will move out of it." aria-label="Delete this folder">@IconOnly(iconTrash, "Delete this folder")</button>
175+ </form>
176+ </li>
177+}
178+
179+templ TagCreateForm() {
180+ <form class="sv-inline-form" data-sv-action="tag.create">
181+ <label><span>Name</span><input name="name" type="text" required/></label>
182+ <label><span>Color</span><input name="color" type="text" placeholder="#808080"/></label>
183+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconTag, "Create tag")</button>
184+ </form>
185+}
186+
187+templ TagItem(tag *entity.Tag) {
188+ <li class="sv-list-row">
189+ <form class="sv-inline-form" data-sv-action="tag.update">
190+ <input type="hidden" name="id" value={ fmt.Sprintf("%d", tag.ID) }/>
191+ <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>
192+ <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label>
193+ <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label>
194+ <button class="sv-button sv-button-secondary" type="submit">@ButtonIconLabel(iconSave, "Save")</button>
195+ <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="tag.delete" data-id={ fmt.Sprintf("%d", tag.ID) } data-sv-confirm="Delete this tag?" aria-label="Delete this tag">@IconOnly(iconTrash, "Delete this tag")</button>
196+ </form>
197+ </li>
198+}
199+
200+templ FolderSelect(name string, folders []*entity.Folder, selected *entity.FolderID, label string) {
201+ <label><span>{ label }</span><select name={ name }>
202+ <option value="">Unfiled</option>
203+ for _, folder := range folders {
204+ if folder != nil {
205+ <option value={ fmt.Sprintf("%d", folder.ID) } selected?={ folderSelected(selected, folder.ID) }>{ folderDisplayName(folder) }</option>
206+ }
207+ }
208+ </select></label>
209+}
210+
211+templ ShortcutSelect(selected *int) {
212+ <label><span>Shortcut</span><select name="shortcut_key">
213+ <option value="">None</option>
214+ for i := 1; i <= 9; i++ {
215+ <option value={ fmt.Sprintf("%d", i) } selected?={ shortcutSelected(selected, i) }>{ fmt.Sprintf("%d", i) }</option>
216+ }
217+ </select></label>
218+}
A internal/ui/systemviews/favorites_templ.go +1416 -0 generated file: collapsed
A internal/ui/systemviews/favorites_view.go +193 -0
@@ -0,0 +1,193 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+const defaultTagColor = "#808080"
11+
12+type favoritesRenderData struct {
13+ Favorites []*entity.Favorite
14+ Folders []*entity.Folder
15+ Tags []*entity.Tag
16+ FolderFilter *entity.FolderID
17+ TagFilter *entity.TagID
18+ Notice string
19+ Error string
20+}
21+
22+func favoritesHTML(data favoritesRenderData) string {
23+ return mustRenderComponent(FavoritesView(data))
24+}
25+
26+func favoritesDocumentTitle(data favoritesRenderData) string {
27+ count := len(data.Favorites)
28+ if data.FolderFilter != nil || data.TagFilter != nil {
29+ if count == 1 {
30+ return "Favorites — 1 filtered bookmark"
31+ }
32+ return fmt.Sprintf("Favorites — %d filtered bookmarks", count)
33+ }
34+ if count == 1 {
35+ return "Favorites — 1 bookmark"
36+ }
37+ return fmt.Sprintf("Favorites — %d bookmarks", count)
38+}
39+
40+func favoritesSummary(data favoritesRenderData) string {
41+ return fmt.Sprintf("%d favorites · %d folders · %d tags", len(data.Favorites), len(data.Folders), len(data.Tags))
42+}
43+
44+func foldersSummary(data favoritesRenderData) string {
45+ return fmt.Sprintf("%d folders", len(data.Folders))
46+}
47+
48+func tagsSummary(data favoritesRenderData) string {
49+ return fmt.Sprintf("%d tags", len(data.Tags))
50+}
51+
52+func favoriteFilterClass(active bool) string {
53+ classes := "sv-button sv-button-secondary"
54+ if active {
55+ classes += " sv-button-active"
56+ }
57+ return classes
58+}
59+
60+func favoriteFolderFilterActive(data favoritesRenderData, folderID entity.FolderID) bool {
61+ return data.FolderFilter != nil && *data.FolderFilter == folderID
62+}
63+
64+func favoriteAllFilterActive(data favoritesRenderData) bool {
65+ return data.FolderFilter == nil && data.TagFilter == nil
66+}
67+
68+func favoriteTagFilterActive(data favoritesRenderData, tagID entity.TagID) bool {
69+ return data.TagFilter != nil && *data.TagFilter == tagID
70+}
71+
72+func favoriteItemLabel(favorite *entity.Favorite) string {
73+ if favorite == nil {
74+ return ""
75+ }
76+ label := strings.TrimSpace(favorite.Title)
77+ if label == "" {
78+ label = favorite.URL
79+ }
80+ return label
81+}
82+
83+func favoriteMetaText(favorite *entity.Favorite, folders []*entity.Folder) string {
84+ if favorite == nil {
85+ return ""
86+ }
87+ parts := make([]string, 0, 2)
88+ if favorite.ShortcutKey != nil {
89+ parts = append(parts, fmt.Sprintf("Shortcut %d", *favorite.ShortcutKey))
90+ }
91+ if name := folderNameForID(folders, favorite.FolderID); name != "" {
92+ parts = append(parts, "Folder "+name)
93+ }
94+ return strings.Join(parts, " · ")
95+}
96+
97+func favoriteTagButtonAction(favorite *entity.Favorite, tag *entity.Tag) string {
98+ if favorite != nil && tag != nil && favorite.HasTag(tag.ID) {
99+ return tagActionRemove
100+ }
101+ return tagActionAssign
102+}
103+
104+func favoriteTagButtonLabel(favorite *entity.Favorite, tag *entity.Tag) string {
105+ if tag == nil {
106+ return ""
107+ }
108+ if favorite != nil && favorite.HasTag(tag.ID) {
109+ return "✓ " + tag.Name
110+ }
111+ return "+ " + tag.Name
112+}
113+
114+func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string {
115+ classes := "sv-button sv-button-secondary sv-tag-button"
116+ if favorite != nil && tag != nil && favorite.HasTag(tag.ID) {
117+ classes += " sv-button-active sv-tag-button-active"
118+ }
119+ return classes
120+}
121+
122+func safeTagColor(raw string) string {
123+ value := strings.TrimSpace(raw)
124+ if len(value) != 4 && len(value) != 7 {
125+ return defaultTagColor
126+ }
127+ if value[0] != '#' {
128+ return defaultTagColor
129+ }
130+ for _, ch := range value[1:] {
131+ if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {
132+ continue
133+ }
134+ return defaultTagColor
135+ }
136+ return value
137+}
138+
139+func folderDisplayName(folder *entity.Folder) string {
140+ if folder == nil {
141+ return ""
142+ }
143+ if strings.TrimSpace(folder.Icon) != "" {
144+ return folder.Icon + " " + folder.Name
145+ }
146+ return folder.Name
147+}
148+
149+func folderNameForID(folders []*entity.Folder, id *entity.FolderID) string {
150+ if id == nil {
151+ return "Unfiled"
152+ }
153+ for _, folder := range folders {
154+ if folder != nil && folder.ID == *id {
155+ return folderDisplayName(folder)
156+ }
157+ }
158+ return "Unknown"
159+}
160+
161+func folderSelected(selected *entity.FolderID, folderID entity.FolderID) bool {
162+ return selected != nil && *selected == folderID
163+}
164+
165+func shortcutSelected(selected *int, value int) bool {
166+ return selected != nil && *selected == value
167+}
168+
169+func filterFavorites(favorites []*entity.Favorite, folderID *entity.FolderID, tagID *entity.TagID) []*entity.Favorite {
170+ if folderID == nil && tagID == nil {
171+ return favorites
172+ }
173+ out := make([]*entity.Favorite, 0, len(favorites))
174+ for _, favorite := range favorites {
175+ if favorite == nil {
176+ continue
177+ }
178+ if folderID != nil {
179+ if *folderID == 0 {
180+ if favorite.FolderID != nil {
181+ continue
182+ }
183+ } else if favorite.FolderID == nil || *favorite.FolderID != *folderID {
184+ continue
185+ }
186+ }
187+ if tagID != nil && !favorite.HasTag(*tagID) {
188+ continue
189+ }
190+ out = append(out, favorite)
191+ }
192+ return out
193+}
A internal/ui/systemviews/history.templ +172 -0
@@ -0,0 +1,172 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+templ HistoryView(data historyRenderData) {
10+ @Alert("success", data.Notice)
11+ @Alert("error", data.Error)
12+ @Section("sv-history-controls", "Manage history") {
13+ @HistoryControls(data)
14+ }
15+ @Section("sv-history-domains", "Top domains") {
16+ @HistoryDomains(data)
17+ }
18+ @Section("sv-history-timeline", "Recent visits") {
19+ @HistoryTimeline(data)
20+ }
21+}
22+
23+templ HistoryControls(data historyRenderData) {
24+ <form class="sv-toolbar sv-history-search" data-sv-action="history.search">
25+ <label class="sv-search-label">
26+ <span>Search</span>
27+ <input data-sv-history-search data-sv-autofocus name="query" type="search" value={ data.Query } placeholder="Search title or URL" autocomplete="off" autofocus/>
28+ </label>
29+ <button class="sv-button" type="submit">@ButtonIconLabel(iconSearch, "Search")</button>
30+ <button class="sv-button sv-button-secondary" type="button" data-sv-action="history.clear">@ButtonIconLabel(iconX, "Clear")</button>
31+ </form>
32+ if showHistoryFilters(data) {
33+ <div class="sv-active-filters">
34+ if data.Query != "" {
35+ <span class="sv-filter-chip">Query: { data.Query }</span>
36+ }
37+ if data.DomainFilter != "" {
38+ <span class="sv-filter-chip">Domain: { data.DomainFilter } <button type="button" data-sv-action="history.clearDomain" aria-label="Clear domain filter">@IconOnly(iconX, "Clear domain filter")</button></span>
39+ }
40+ </div>
41+ }
42+ <div class="sv-history-actions-panel">
43+ <div class="sv-button-row" aria-label="History pagination">
44+ <span class="sv-meta">{ historyShowingLabel(data) }</span>
45+ </div>
46+ <div class="sv-button-row" aria-label="History cleanup">
47+ <span class="sv-meta">Clean up</span>
48+ for _, item := range historyCleanupItems() {
49+ <button type="button" class="sv-danger-link" data-sv-action="history.deleteRange" data-range={ item.RangeID } data-sv-confirm={ item.Confirm }>{ item.Label }</button>
50+ }
51+ </div>
52+ </div>
53+}
54+
55+templ HistoryDomains(data historyRenderData) {
56+ if data.Loading {
57+ @LoadingPanel("Loading top domains…")
58+ } else if len(data.Domains) == 0 {
59+ @EmptyState("No domain statistics yet")
60+ } else {
61+ <div class="sv-domain-list">
62+ for _, domain := range data.Domains {
63+ if domain != nil && domain.Domain != "" {
64+ {{ domainKey := historyDomainActionKey(domain) }}
65+ {{ displayDomain := historyDomainDisplayLabel(domain) }}
66+ <span class="sv-domain-chip">
67+ <span class="sv-domain-chip-main">
68+ <button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain={ domainKey }>{ displayDomain }</button>
69+ <span class="sv-domain-meta">{ fmt.Sprintf("%dp · %dv", domain.PageCount, domain.TotalVisits) }</span>
70+ </span>
71+ <button type="button" class="sv-domain-delete" data-sv-action="history.deleteDomain" data-domain={ domainKey } data-sv-confirm={ "Delete all history for " + displayDomain + "?" } aria-label={ "Delete history for " + displayDomain }><span aria-hidden="true">×</span><span class="sv-sr-only">Delete history for { displayDomain }</span></button>
72+ </span>
73+ }
74+ }
75+ </div>
76+ }
77+}
78+
79+templ HistoryTimeline(data historyRenderData) {
80+ if data.Loading {
81+ @LoadingPanel("Loading recent visits…")
82+ } else {
83+ <div class="sv-history-timeline-list" data-sv-history-timeline>
84+ {{ groups := groupHistoryEntries(data.Entries) }}
85+ if len(groups) == 0 {
86+ @EmptyState(historyTimelineEmptyMessage(data))
87+ } else {
88+ @HistoryTimelineGroups(data.Entries, "")
89+ }
90+ @HistoryLoadMore(data)
91+ </div>
92+ }
93+}
94+
95+templ HistoryTimelineAppend(data historyRenderData) {
96+ @HistoryTimelineAppendGroups(data.Entries, data.AppendSkipFirstDate)
97+ @HistoryLoadMore(data)
98+}
99+
100+templ HistoryTimelineGroups(entries []*entity.HistoryEntry, skipFirstDate string) {
101+ {{ groups := groupHistoryEntries(entries) }}
102+ for _, group := range groups {
103+ @HistoryTimelineGroup(group, true)
104+ }
105+}
106+
107+templ HistoryTimelineAppendGroups(entries []*entity.HistoryEntry, mergeFirstDate string) {
108+ {{ groups := groupHistoryEntries(entries) }}
109+ for index, group := range groups {
110+ if index == 0 && group.Date == mergeFirstDate {
111+ <div data-sv-history-merge-date={ group.Date }>
112+ for _, entry := range group.Entries {
113+ if entry != nil {
114+ @HistoryItem(entry)
115+ }
116+ }
117+ </div>
118+ } else {
119+ @HistoryTimelineGroup(group, true)
120+ }
121+ }
122+}
123+
124+templ HistoryTimelineGroup(group historyTimelineGroup, showHeading bool) {
125+ <section class="sv-timeline-group" data-sv-history-date={ group.Date }>
126+ if showHeading {
127+ <h3>{ group.Label }</h3>
128+ }
129+ <ul class="sv-list">
130+ for _, entry := range group.Entries {
131+ if entry != nil {
132+ @HistoryItem(entry)
133+ }
134+ }
135+ </ul>
136+ </section>
137+}
138+
139+templ HistoryLoadMore(data historyRenderData) {
140+ <div class="sv-history-load-more" data-sv-history-load-more-container>
141+ if showHistoryLoadMore(data) {
142+ <button type="button" class="sv-button sv-button-secondary" data-sv-action="history.loadMore" data-before={ historyLoadMoreCursor(data) } data-sv-history-load-more>Load older visits</button>
143+ } else if strings.TrimSpace(data.Query) == "" && countHistoryEntries(data.Entries) > 0 {
144+ <p class="sv-meta">End of history</p>
145+ }
146+ </div>
147+}
148+
149+templ HistoryItem(entry *entity.HistoryEntry) {
150+ <li class="sv-list-row">
151+ <article class="sv-history-item" data-sv-history-row data-history-id={ fmt.Sprintf("%d", entry.ID) }>
152+ <div class="sv-history-content">
153+ {{ faviconURL := historyItemFaviconURL(entry) }}
154+ <span class={ historyFaviconClass(faviconURL) } aria-hidden="true">
155+ <span class="sv-history-favicon-fallback">{ historyFaviconFallback(entry) }</span>
156+ if faviconURL != "" {
157+ <img src={ templ.SafeURL(faviconURL) } alt="" width="24" height="24" loading="lazy" decoding="async" referrerpolicy="same-origin" onerror="this.parentElement.classList.remove('sv-history-favicon-has-image'); this.remove()"/>
158+ }
159+ </span>
160+ <div class="sv-history-main">
161+ <a class="sv-link sv-history-open" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>{ historyItemLabel(entry) }</a>
162+ <p class="sv-history-url" title={ entry.URL }>{ historyItemURL(entry) }</p>
163+ <p class="sv-history-meta">{ historyItemMeta(entry) }</p>
164+ </div>
165+ </div>
166+ <div class="sv-history-row-actions">
167+ <a class="sv-button sv-button-secondary" href={ templ.SafeURL(sanitizeHref(entry.URL)) }>@ButtonIconLabel(iconExternal, "Open")</a>
168+ <button type="button" class="sv-button sv-button-secondary sv-button-danger sv-button-icon-only" data-sv-action="history.deleteEntry" data-id={ fmt.Sprintf("%d", entry.ID) } data-sv-confirm="Delete this history entry?" aria-label="Delete this history entry" disabled?={ entry.ID <= 0 }>@IconOnly(iconTrash, "Delete this history entry")</button>
169+ </div>
170+ </article>
171+ </li>
172+}
A internal/ui/systemviews/history_actions.go +306 -0
@@ -0,0 +1,306 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+ "time"
9+)
10+
11+const (
12+ historyActionSearch = "history.search"
13+ historyActionClear = "history.clear"
14+ historyActionFilterDomain = "history.filterDomain"
15+ historyActionClearDomain = "history.clearDomain"
16+ historyActionPage = "history.page"
17+ historyActionLoadMore = "history.loadMore"
18+ historyActionDeleteEntry = "history.deleteEntry"
19+ historyActionDeleteRange = "history.deleteRange"
20+ historyActionDeleteDomain = "history.deleteDomain"
21+)
22+
23+// HandleDOMAction applies an action delegated from the browser DOM and refreshes
24+// the mounted view. It keeps UI orchestration in the systemview edge and calls
25+// only application ports for data changes.
26+func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
27+ if a == nil {
28+ return fmt.Errorf("app is nil")
29+ }
30+ a.lockState()
31+ if a.closed {
32+ a.unlockState()
33+ return nil
34+ }
35+ a.renderGeneration++
36+ if a.currentRoute == RouteUnknown || a.currentRoute == "" {
37+ a.currentRoute = ParseRoute(a.deps.LocationURI)
38+ }
39+ route := a.currentRoute
40+ if route == RouteHistory && event.Action == historyActionLoadMore {
41+ a.unlockState()
42+ return a.handleHistoryLoadMore(ctx, event)
43+ }
44+ defer a.unlockState()
45+
46+ switch route {
47+ case RouteHistory:
48+ a.historyError = ""
49+ if err := a.handleHistoryAction(ctx, event); err != nil {
50+ a.historyNotice = ""
51+ a.historyError = err.Error()
52+ }
53+ if err := a.loadHistoryRoute(ctx); err != nil {
54+ a.renderRouteError(err)
55+ _ = a.mountRenderedHTML()
56+ return err
57+ }
58+ return a.mountRenderedHTML()
59+ case RouteFavorites:
60+ a.favoritesError = ""
61+ if err := a.handleFavoriteAction(ctx, event); err != nil {
62+ a.favoritesNotice = ""
63+ a.favoritesError = err.Error()
64+ }
65+ if err := a.loadFavoritesRoute(ctx); err != nil {
66+ a.renderRouteError(err)
67+ _ = a.mountRenderedHTML()
68+ return err
69+ }
70+ return a.mountRenderedHTML()
71+ case RouteConfig:
72+ a.configError = ""
73+ if err := a.handleConfigAction(ctx, event); err != nil {
74+ a.configNotice = ""
75+ a.configError = err.Error()
76+ }
77+ a.loadShellTheme(ctx)
78+ if err := a.loadConfigRoute(ctx); err != nil {
79+ a.renderRouteError(err)
80+ _ = a.mountRenderedHTML()
81+ return err
82+ }
83+ return a.mountRenderedHTML()
84+ default:
85+ return nil
86+ }
87+}
88+
89+//nolint:gocyclo,funlen // Mechanical dispatcher keeps action routing in one place.
90+func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
91+ if a.deps.History == nil {
92+ return fmt.Errorf("history service not configured")
93+ }
94+
95+ data := event.Data
96+ switch event.Action {
97+ case historyActionSearch:
98+ a.historyQuery = strings.TrimSpace(data["query"])
99+ a.historyDomainFilter = ""
100+ a.resetHistoryWindowState()
101+ a.historyNotice = ""
102+ case historyActionClear:
103+ a.historyQuery = ""
104+ a.historyDomainFilter = ""
105+ a.resetHistoryWindowState()
106+ a.historyNotice = ""
107+ case historyActionFilterDomain:
108+ domain := strings.TrimSpace(data["domain"])
109+ if domain == "" {
110+ return fmt.Errorf("domain is required")
111+ }
112+ a.historyDomainFilter = domain
113+ a.historyQuery = ""
114+ a.resetHistoryWindowState()
115+ a.historyNotice = ""
116+ case historyActionClearDomain:
117+ a.historyDomainFilter = ""
118+ a.resetHistoryWindowState()
119+ a.historyNotice = ""
120+ case historyActionPage:
121+ offset, err := strconv.Atoi(strings.TrimSpace(data["offset"]))
122+ if err != nil || offset < 0 {
123+ return fmt.Errorf("invalid history offset")
124+ }
125+ a.resetHistoryWindowState()
126+ a.historyOffset = offset
127+ a.historyNotice = ""
128+ case historyActionDeleteEntry:
129+ id, err := strconv.ParseInt(strings.TrimSpace(data["id"]), 10, 64)
130+ if err != nil || id <= 0 {
131+ return fmt.Errorf("invalid history entry id")
132+ }
133+ if err := a.deps.History.DeleteEntry(ctx, id); err != nil {
134+ return err
135+ }
136+ a.resetHistoryWindowState()
137+ a.historyNotice = "Deleted history entry"
138+ case historyActionDeleteRange:
139+ rangeID := strings.TrimSpace(data["range"])
140+ if rangeID == "" {
141+ return fmt.Errorf("history range is required")
142+ }
143+ if !isKnownHistoryCleanupRange(rangeID) {
144+ return fmt.Errorf("invalid history range")
145+ }
146+ if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {
147+ return err
148+ }
149+ a.resetHistoryWindowState()
150+ a.historyNotice = historyRangeNotice(rangeID)
151+ case historyActionDeleteDomain:
152+ domain := strings.TrimSpace(data["domain"])
153+ if domain == "" {
154+ return fmt.Errorf("domain is required")
155+ }
156+ if err := a.deps.History.DeleteDomain(ctx, domain); err != nil {
157+ return err
158+ }
159+ if strings.EqualFold(a.historyDomainFilter, domain) {
160+ a.historyDomainFilter = ""
161+ }
162+ a.resetHistoryWindowState()
163+ a.historyNotice = "Deleted history for " + domain
164+ default:
165+ return fmt.Errorf("unknown history action: %q", event.Action)
166+ }
167+ return nil
168+}
169+
170+func (a *App) handleHistoryLoadMore(ctx context.Context, event DOMAction) error {
171+ a.lockState()
172+ if a.deps.History == nil {
173+ a.unlockState()
174+ return fmt.Errorf("history service not configured")
175+ }
176+ if strings.TrimSpace(a.historyQuery) != "" || !a.historyHasMore {
177+ a.unlockState()
178+ return nil
179+ }
180+ before := a.historyWindowAfter
181+ if requested := strings.TrimSpace(event.Data["before"]); requested != "" {
182+ parsed, err := time.Parse(time.RFC3339Nano, requested)
183+ if err != nil {
184+ a.unlockState()
185+ return fmt.Errorf("invalid history cursor")
186+ }
187+ if !before.IsZero() && !parsed.Equal(before) {
188+ a.unlockState()
189+ return nil
190+ }
191+ before = parsed
192+ }
193+ if before.IsZero() {
194+ before = time.Now()
195+ }
196+ domain := a.historyDomainFilter
197+ history := a.deps.History
198+ dom := a.deps.DOM
199+ a.unlockState()
200+
201+ window, err := history.TimelineWindow(ctx, before, domain)
202+ if err != nil {
203+ return err
204+ }
205+ if window == nil {
206+ a.lockState()
207+ a.historyHasMore = false
208+ a.unlockState()
209+ return nil
210+ }
211+
212+ a.lockState()
213+ appendSkipFirstDate := lastHistoryDateKey(a.historyEntries)
214+ a.historyEntries = append(a.historyEntries, window.Entries...)
215+ a.historyWindowBefore = window.Before
216+ a.historyWindowAfter = window.After
217+ a.historyHasMore = window.HasMore
218+ data := historyRenderData{
219+ Entries: window.Entries,
220+ Stats: a.historyStats,
221+ Domains: a.historyDomainStats,
222+ Query: a.historyQuery,
223+ DomainFilter: a.historyDomainFilter,
224+ Offset: a.historyOffset,
225+ Limit: historyTimelineLimit,
226+ WindowBefore: a.historyWindowBefore,
227+ WindowAfter: a.historyWindowAfter,
228+ HasMore: a.historyHasMore,
229+ AppendSkipFirstDate: appendSkipFirstDate,
230+ Notice: a.historyNotice,
231+ Error: a.historyError,
232+ }
233+ fragment := historyTimelineAppendHTML(data)
234+ var fallbackHTML string
235+ if _, ok := dom.(DOMHistoryTimelineAppender); !ok {
236+ fullData := data
237+ fullData.Entries = a.historyEntries
238+ title := historyDocumentTitle(fullData)
239+ a.renderedHTML = renderAppFrame(renderedPage{
240+ route: RouteHistory,
241+ title: title,
242+ subtitle: "History",
243+ subtitleDetail: historyTitleDetail(fullData),
244+ body: historyHTML(fullData),
245+ }, a.shellTheme)
246+ fallbackHTML = a.renderedHTML
247+ }
248+ a.unlockState()
249+
250+ if appender, ok := dom.(DOMHistoryTimelineAppender); ok {
251+ return appender.AppendHistoryTimeline(fragment)
252+ }
253+ return a.mountHTML(fallbackHTML)
254+}
255+
256+func (a *App) resetHistoryWindowState() {
257+ a.historyOffset = 0
258+ a.historyWindowBefore = time.Time{}
259+ a.historyWindowAfter = time.Time{}
260+ a.historyHasMore = false
261+}
262+
263+func (a *App) mountRenderedHTML() error {
264+ return a.mountHTML(a.renderedHTML)
265+}
266+
267+func (a *App) mountHTML(html string) error {
268+ if a == nil || a.deps.DOM == nil {
269+ return nil
270+ }
271+ return a.deps.DOM.Mount(html)
272+}
273+
274+func (a *App) mountHTMLIfCurrent(ctx context.Context, html string, generation uint64) error {
275+ if a == nil || a.deps.DOM == nil {
276+ return nil
277+ }
278+ if ctx != nil && ctx.Err() != nil {
279+ return nil
280+ }
281+ a.lockState()
282+ current := !a.closed && a.renderGeneration == generation
283+ a.unlockState()
284+ if !current {
285+ return nil
286+ }
287+ return a.deps.DOM.Mount(html)
288+}
289+
290+func isKnownHistoryCleanupRange(rangeID string) bool {
291+ for _, item := range historyCleanupItems() {
292+ if item.RangeID == rangeID {
293+ return true
294+ }
295+ }
296+ return false
297+}
298+
299+func historyRangeNotice(rangeID string) string {
300+ for _, item := range historyCleanupItems() {
301+ if item.RangeID == rangeID {
302+ return item.Notice
303+ }
304+ }
305+ return "Deleted history"
306+}
A internal/ui/systemviews/history_templ.go +978 -0 generated file: collapsed
A internal/ui/systemviews/history_view.go +372 -0
@@ -0,0 +1,372 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "net/url"
6+ "strings"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ browserurl "github.com/bnema/dumber/internal/domain/url"
11+)
12+
13+const historyURLDisplayMaxRunes = 180
14+
15+type historyRenderData struct {
16+ Entries []*entity.HistoryEntry
17+ Stats *entity.HistoryStats
18+ Analytics *entity.HistoryAnalytics
19+ Domains []*entity.DomainStat
20+ Query string
21+ DomainFilter string
22+ Offset int
23+ Limit int
24+ WindowBefore time.Time
25+ WindowAfter time.Time
26+ HasMore bool
27+ AppendSkipFirstDate string
28+ Notice string
29+ Error string
30+ Loading bool
31+}
32+
33+type historyTimelineGroup struct {
34+ Date string
35+ Label string
36+ Entries []*entity.HistoryEntry
37+}
38+
39+type historyCleanupItem struct {
40+ Label string
41+ RangeID string
42+ Confirm string
43+ Notice string
44+}
45+
46+func historyHTML(data historyRenderData) string {
47+ return mustRenderComponent(HistoryView(data))
48+}
49+
50+func historyTimelineAppendHTML(data historyRenderData) string {
51+ return mustRenderComponent(HistoryTimelineAppend(data))
52+}
53+
54+func historyDocumentTitle(data historyRenderData) string {
55+ return "History " + historyTitleDetail(data)
56+}
57+
58+func historyTitleDetail(data historyRenderData) string {
59+ if data.Loading {
60+ return "- Loading"
61+ }
62+ entries, visits, days := historySummaryValues(data)
63+ return fmt.Sprintf(
64+ "- %s, %s, %s",
65+ historyCountLabel(entries, "entry", "entries"),
66+ historyCountLabel(visits, "visit", "visits"),
67+ historyCountLabel(days, "day", "days"),
68+ )
69+}
70+
71+func historyCountLabel(count int64, singular, plural string) string {
72+ label := plural
73+ if count == 1 {
74+ label = singular
75+ }
76+ return fmt.Sprintf("%d %s", count, label)
77+}
78+
79+var historyCleanupRanges = []historyCleanupItem{
80+ {Label: "Last hour", RangeID: "hour", Confirm: "Delete visits from the last hour?", Notice: "Deleted history from the last hour"},
81+ {Label: "Today", RangeID: "day", Confirm: "Delete visits from today?", Notice: "Deleted history from today"},
82+ {Label: "Week", RangeID: "week", Confirm: "Delete visits from the last week?", Notice: "Deleted history from this week"},
83+ {Label: "Month", RangeID: "month", Confirm: "Delete visits from the last month?", Notice: "Deleted history from this month"},
84+ {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?", Notice: "Deleted all history"},
85+}
86+
87+func historyCleanupItems() []historyCleanupItem {
88+ return historyCleanupRanges
89+}
90+
91+func historyLimit(data historyRenderData) int {
92+ if data.Limit <= 0 {
93+ return 0
94+ }
95+ return data.Limit
96+}
97+
98+func historyIsPaginated(data historyRenderData) bool {
99+ return historyLimit(data) > 0
100+}
101+
102+func disableHistoryNext(data historyRenderData) bool {
103+ return !historyIsPaginated(data) || strings.TrimSpace(data.Query) != "" || countHistoryEntries(data.Entries) < historyLimit(data)
104+}
105+
106+func showHistoryFilters(data historyRenderData) bool {
107+ return strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != ""
108+}
109+
110+func historyShowingLabel(data historyRenderData) string {
111+ if data.Loading {
112+ return "Loading history…"
113+ }
114+ count := countHistoryEntries(data.Entries)
115+ if strings.TrimSpace(data.Query) != "" {
116+ return fmt.Sprintf("Showing %d matching item%s", count, pluralSuffix(count))
117+ }
118+ if strings.TrimSpace(data.DomainFilter) != "" {
119+ return fmt.Sprintf("Loaded %d domain item%s", count, pluralSuffix(count))
120+ }
121+ return fmt.Sprintf("Loaded %d item%s", count, pluralSuffix(count))
122+}
123+
124+func pluralSuffix(count int) string {
125+ if count == 1 {
126+ return ""
127+ }
128+ return "s"
129+}
130+
131+func historyLoadMoreCursor(data historyRenderData) string {
132+ if data.WindowAfter.IsZero() {
133+ return ""
134+ }
135+ return data.WindowAfter.Format(time.RFC3339Nano)
136+}
137+
138+func showHistoryLoadMore(data historyRenderData) bool {
139+ return !data.Loading && strings.TrimSpace(data.Query) == "" && data.HasMore
140+}
141+
142+func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) {
143+ if data.Stats != nil {
144+ return data.Stats.TotalEntries, data.Stats.TotalVisits, data.Stats.UniqueDays
145+ }
146+ if data.Analytics != nil {
147+ return data.Analytics.TotalEntries, data.Analytics.TotalVisits, data.Analytics.UniqueDays
148+ }
149+ entries = int64(countHistoryEntries(data.Entries))
150+ visits = sumHistoryVisits(data.Entries)
151+ uniqueDays = countUniqueHistoryDays(data.Entries)
152+
153+ return entries, visits, uniqueDays
154+}
155+
156+func historyTimelineEmptyMessage(data historyRenderData) string {
157+ if data.Loading {
158+ return "Loading history…"
159+ }
160+ if strings.TrimSpace(data.Query) != "" || strings.TrimSpace(data.DomainFilter) != "" {
161+ return "No history entries match the current filters"
162+ }
163+ return "No history entries"
164+}
165+
166+func lastHistoryDateKey(entries []*entity.HistoryEntry) string {
167+ for i := len(entries) - 1; i >= 0; i-- {
168+ if entries[i] == nil {
169+ continue
170+ }
171+ dateKey, _ := historyDateKeyAndLabel(entries[i].LastVisited, time.Now().Local())
172+ return dateKey
173+ }
174+ return ""
175+}
176+
177+func groupHistoryEntries(entries []*entity.HistoryEntry) []historyTimelineGroup {
178+ groups := make([]historyTimelineGroup, 0)
179+ indexByDate := map[string]int{}
180+ now := time.Now().Local()
181+ for _, entry := range entries {
182+ if entry == nil {
183+ continue
184+ }
185+ dateKey, label := historyDateKeyAndLabel(entry.LastVisited, now)
186+ idx, ok := indexByDate[dateKey]
187+ if !ok {
188+ idx = len(groups)
189+ indexByDate[dateKey] = idx
190+ groups = append(groups, historyTimelineGroup{Date: dateKey, Label: label})
191+ }
192+ groups[idx].Entries = append(groups[idx].Entries, entry)
193+ }
194+ return groups
195+}
196+
197+func historyDateKeyAndLabel(ts, now time.Time) (string, string) {
198+ if ts.IsZero() {
199+ return "unknown", "Unknown date"
200+ }
201+
202+ local := ts.Local()
203+ dateKey := local.Format("2006-01-02")
204+ today := now.Local()
205+ todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, today.Location())
206+ yesterdayStart := todayStart.AddDate(0, 0, -1)
207+ entryStart := time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, local.Location())
208+
209+ switch {
210+ case entryStart.Equal(todayStart):
211+ return dateKey, "Today"
212+ case entryStart.Equal(yesterdayStart):
213+ return dateKey, "Yesterday"
214+ default:
215+ return dateKey, local.Format("Mon Jan 2")
216+ }
217+}
218+
219+func countHistoryEntries(entries []*entity.HistoryEntry) int {
220+ count := 0
221+ for _, entry := range entries {
222+ if entry != nil {
223+ count++
224+ }
225+ }
226+ return count
227+}
228+
229+func sumHistoryVisits(entries []*entity.HistoryEntry) int64 {
230+ var visits int64
231+ for _, entry := range entries {
232+ if entry == nil {
233+ continue
234+ }
235+ // Each HistoryEntry represents at least one visit, even when VisitCount
236+ // is unset or non-positive in older persisted rows.
237+ if entry.VisitCount <= 0 {
238+ visits++
239+ continue
240+ }
241+ visits += entry.VisitCount
242+ }
243+ return visits
244+}
245+
246+func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
247+ seen := map[string]struct{}{}
248+ for _, entry := range entries {
249+ if entry == nil || entry.LastVisited.IsZero() {
250+ continue
251+ }
252+ seen[entry.LastVisited.Local().Format("2006-01-02")] = struct{}{}
253+ }
254+ return int64(len(seen))
255+}
256+
257+func displayHistoryDomain(raw string) string {
258+ return browserurl.DisplayDomain(raw)
259+}
260+
261+func historyDomainActionKey(domain *entity.DomainStat) string {
262+ if domain == nil {
263+ return ""
264+ }
265+ return browserurl.CanonicalDomain(domain.Domain)
266+}
267+
268+func historyDomainDisplayLabel(domain *entity.DomainStat) string {
269+ if domain == nil {
270+ return ""
271+ }
272+ return displayHistoryDomain(domain.Domain)
273+}
274+
275+func formatHistoryTime(ts time.Time) string {
276+ if ts.IsZero() {
277+ return ""
278+ }
279+ return ts.Local().Format("15:04")
280+}
281+
282+func historyItemLabel(entry *entity.HistoryEntry) string {
283+ if entry == nil {
284+ return ""
285+ }
286+ label := strings.TrimSpace(entry.Title)
287+ if label == "" {
288+ label = displayHistoryDomain(entry.URL)
289+ }
290+ if label == "" {
291+ label = entry.URL
292+ }
293+ return label
294+}
295+
296+func historyItemURL(entry *entity.HistoryEntry) string {
297+ if entry == nil {
298+ return ""
299+ }
300+ return truncateHistoryURL(entry.URL)
301+}
302+
303+func historyItemFaviconURL(entry *entity.HistoryEntry) string {
304+ if entry == nil {
305+ return ""
306+ }
307+ domain := browserurl.CanonicalDomain(entry.URL)
308+ if domain == "" {
309+ return ""
310+ }
311+ query := url.Values{}
312+ query.Set("domain", domain)
313+ query.Set("size", "32")
314+ return "/api/favicon?" + query.Encode()
315+}
316+
317+func historyFaviconClass(faviconURL string) string {
318+ if strings.TrimSpace(faviconURL) == "" {
319+ return "sv-history-favicon"
320+ }
321+ return "sv-history-favicon sv-history-favicon-has-image"
322+}
323+
324+func historyFaviconFallback(entry *entity.HistoryEntry) string {
325+ label := ""
326+ if entry != nil {
327+ label = displayHistoryDomain(entry.URL)
328+ }
329+ if label == "" {
330+ return "•"
331+ }
332+ for _, r := range label {
333+ return strings.ToUpper(string(r))
334+ }
335+ return "•"
336+}
337+
338+func truncateHistoryURL(raw string) string {
339+ raw = strings.TrimSpace(raw)
340+ if raw == "" {
341+ return ""
342+ }
343+ runes := []rune(raw)
344+ if len(runes) <= historyURLDisplayMaxRunes {
345+ return raw
346+ }
347+ return string(runes[:historyURLDisplayMaxRunes-1]) + "…"
348+}
349+
350+func historyItemMeta(entry *entity.HistoryEntry) string {
351+ if entry == nil {
352+ return ""
353+ }
354+ meta := []string{displayHistoryDomain(entry.URL)}
355+ if entry.VisitCount > 1 {
356+ meta = append(meta, fmt.Sprintf("%dx", entry.VisitCount))
357+ }
358+ if !entry.LastVisited.IsZero() {
359+ meta = append(meta, formatHistoryTime(entry.LastVisited))
360+ }
361+ return strings.Join(nonEmptyStrings(meta), " · ")
362+}
363+
364+func nonEmptyStrings(values []string) []string {
365+ out := make([]string, 0, len(values))
366+ for _, value := range values {
367+ if strings.TrimSpace(value) != "" {
368+ out = append(out, value)
369+ }
370+ }
371+ return out
372+}
A internal/ui/systemviews/history_view_test.go +115 -0
@@ -0,0 +1,115 @@
1+package systemviews
2+
3+import (
4+ "strings"
5+ "testing"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ "github.com/stretchr/testify/assert"
10+)
11+
12+func TestHistoryDateKeyAndLabelUsesProvidedNow(t *testing.T) {
13+ t.Parallel()
14+
15+ loc := time.FixedZone("test", 2*60*60)
16+ now := time.Date(2026, 4, 24, 12, 0, 0, 0, loc)
17+
18+ todayKey, todayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 24, 8, 30, 0, 0, loc), now)
19+ yesterdayKey, yesterdayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 23, 22, 30, 0, 0, loc), now)
20+ olderKey, olderLabel := historyDateKeyAndLabel(time.Date(2026, 4, 20, 9, 0, 0, 0, loc), now)
21+
22+ assert.Equal(t, "2026-04-24", todayKey)
23+ assert.Equal(t, "2026-04-23", yesterdayKey)
24+ assert.Equal(t, "2026-04-20", olderKey)
25+ assert.Equal(t, "Today", todayLabel)
26+ assert.Equal(t, "Yesterday", yesterdayLabel)
27+ assert.Equal(t, "Mon Apr 20", olderLabel)
28+}
29+
30+func TestHistorySummaryValuesUsesAllProvidedEntries(t *testing.T) {
31+ t.Parallel()
32+
33+ loc := time.FixedZone("test", 2*60*60)
34+ data := historyRenderData{Entries: []*entity.HistoryEntry{
35+ {VisitCount: 2, LastVisited: time.Date(2026, 4, 23, 8, 0, 0, 0, loc)},
36+ {VisitCount: 3, LastVisited: time.Date(2026, 4, 24, 8, 0, 0, 0, loc)},
37+ }}
38+
39+ entries, visits, days := historySummaryValues(data)
40+
41+ assert.Equal(t, int64(2), entries)
42+ assert.Equal(t, int64(5), visits)
43+ assert.Equal(t, int64(2), days)
44+}
45+
46+func TestHistoryShowingLabelDescribesUnpaginatedDefault(t *testing.T) {
47+ t.Parallel()
48+
49+ data := historyRenderData{Entries: []*entity.HistoryEntry{{}, {}}, Limit: 0}
50+
51+ assert.Equal(t, "Loaded 2 items", historyShowingLabel(data))
52+ assert.True(t, disableHistoryNext(data))
53+}
54+
55+func TestHistoryTimelineAppendMergesDuplicateFirstDayGroup(t *testing.T) {
56+ t.Parallel()
57+
58+ loc := time.FixedZone("test", 2*60*60)
59+ data := historyRenderData{
60+ Entries: []*entity.HistoryEntry{
61+ {URL: "https://same.example", Title: "Same day", LastVisited: time.Date(2026, 4, 23, 8, 0, 0, 0, loc)},
62+ {URL: "https://older.example", Title: "Older day", LastVisited: time.Date(2026, 4, 22, 8, 0, 0, 0, loc)},
63+ },
64+ AppendSkipFirstDate: "2026-04-23",
65+ }
66+
67+ html := historyTimelineAppendHTML(data)
68+
69+ assert.NotContains(t, html, ">Thu Apr 23</h3>")
70+ assert.NotContains(t, html, `<section class="sv-timeline-group"><ul class="sv-list">`)
71+ assert.Contains(t, html, `data-sv-history-merge-date="2026-04-23"`)
72+ assert.Contains(t, html, "Same day")
73+ assert.Contains(t, html, `data-sv-history-date="2026-04-22"`)
74+ assert.Contains(t, html, ">Wed Apr 22</h3>")
75+}
76+
77+func TestHistoryItemURLTruncatesLongURLs(t *testing.T) {
78+ t.Parallel()
79+
80+ longURL := "https://example.com/" + strings.Repeat("a", historyURLDisplayMaxRunes)
81+ entry := &entity.HistoryEntry{URL: longURL}
82+
83+ displayURL := historyItemURL(entry)
84+
85+ assert.LessOrEqual(t, len([]rune(displayURL)), historyURLDisplayMaxRunes)
86+ assert.Contains(t, displayURL, "…")
87+ assert.NotContains(t, displayURL, strings.Repeat("a", historyURLDisplayMaxRunes))
88+}
89+
90+func TestHistoryItemFaviconURLUsesLocalCachedFaviconAPI(t *testing.T) {
91+ t.Parallel()
92+
93+ entry := &entity.HistoryEntry{URL: "https://example.com/docs?token=secret#section"}
94+
95+ assert.Equal(t, "/api/favicon?domain=example.com&size=32", historyItemFaviconURL(entry))
96+}
97+
98+func TestHistoryItemFaviconURLKeepsPortsForCacheKeys(t *testing.T) {
99+ t.Parallel()
100+
101+ entry := &entity.HistoryEntry{URL: "http://localhost:1455/success?token=secret"}
102+
103+ assert.Equal(t, "/api/favicon?domain=localhost%3A1455&size=32", historyItemFaviconURL(entry))
104+}
105+
106+func TestHistoryItemFaviconURLIgnoresStoredRemoteFaviconURL(t *testing.T) {
107+ t.Parallel()
108+
109+ entry := &entity.HistoryEntry{
110+ URL: "https://example.com/docs",
111+ FaviconURL: "https://tracker.example/favicon.png",
112+ }
113+
114+ assert.Equal(t, "/api/favicon?domain=example.com&size=32", historyItemFaviconURL(entry))
115+}
A internal/ui/systemviews/href.go +44 -0
@@ -0,0 +1,44 @@
1+package systemviews
2+
3+import (
4+ "net/url"
5+ "strings"
6+)
7+
8+const httpsScheme = "https"
9+
10+func sanitizeHref(raw string) string {
11+ trimmed := strings.TrimSpace(raw)
12+ if trimmed == "" || strings.HasPrefix(trimmed, "//") {
13+ return "#"
14+ }
15+
16+ parsed, err := url.Parse(trimmed)
17+ if err != nil {
18+ return "#"
19+ }
20+
21+ if parsed.Scheme == "" {
22+ if parsed.Host != "" {
23+ return "#"
24+ }
25+ return trimmed
26+ }
27+
28+ switch strings.ToLower(parsed.Scheme) {
29+ case httpScheme, httpsScheme:
30+ if parsed.Host == "" {
31+ return "#"
32+ }
33+ parsed.Scheme = strings.ToLower(parsed.Scheme)
34+ parsed.User = nil
35+ return parsed.String()
36+ case "dumb":
37+ if parsed.Host == "" && parsed.Opaque == "" {
38+ return "#"
39+ }
40+ return trimmed
41+ default:
42+ return "#"
43+ }
44+}
A internal/ui/systemviews/href_test.go +66 -0
@@ -0,0 +1,66 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/stretchr/testify/assert"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) {
12+ t.Parallel()
13+
14+ html := historyHTML(historyRenderData{Entries: []*entity.HistoryEntry{
15+ {URL: "javascript:alert(1)", Title: "Bad"},
16+ {URL: "https://example.com", Title: "Good"},
17+ }})
18+
19+ require.NotContains(t, html, `href="javascript:alert(1)"`)
20+ assert.Contains(t, html, `href="#"`)
21+ assert.Contains(t, html, `href="https://example.com"`)
22+}
23+
24+func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) {
25+ t.Parallel()
26+
27+ html := favoritesHTML(favoritesRenderData{Favorites: []*entity.Favorite{
28+ {URL: "javascript:alert(1)", Title: "Bad"},
29+ {URL: "https://example.com", Title: "Good"},
30+ }})
31+
32+ require.NotContains(t, html, `href="javascript:alert(1)"`)
33+ assert.Contains(t, html, `href="#"`)
34+ assert.Contains(t, html, `href="https://example.com"`)
35+}
36+
37+func TestSanitizeHrefRejectsNetworkPathAndMalformedHTTP(t *testing.T) {
38+ t.Parallel()
39+
40+ assert.Equal(t, "#", sanitizeHref("///evil.com"))
41+ assert.Equal(t, "#", sanitizeHref("//evil.com"))
42+ assert.Equal(t, "#", sanitizeHref("https:///evil.com"))
43+ assert.Equal(t, "#", sanitizeHref("http:evil.com"))
44+ assert.Equal(t, "https://example.com", sanitizeHref("https://example.com"))
45+ assert.Equal(t, "https://example.com/path", sanitizeHref("https://user:pass@example.com/path"))
46+}
47+
48+func TestSanitizeHrefAllowsInternalDumbRoutes(t *testing.T) {
49+ t.Parallel()
50+
51+ assert.Equal(t, "dumb://history", sanitizeHref("dumb://history"))
52+ assert.Equal(t, "dumb:history", sanitizeHref("dumb:history"))
53+ assert.Equal(t, "#", sanitizeHref("dumb:///history"))
54+}
55+
56+func TestFavoritesHTMLSanitizesTagColors(t *testing.T) {
57+ t.Parallel()
58+
59+ html := favoritesHTML(favoritesRenderData{
60+ Favorites: []*entity.Favorite{{ID: 1, URL: "https://example.com", Title: "Example"}},
61+ Tags: []*entity.Tag{{ID: 7, Name: "Bad", Color: `red;background:url(javascript:alert(1))`}},
62+ })
63+
64+ require.NotContains(t, html, `style="background:red`)
65+ assert.Contains(t, html, "background:#808080")
66+}
A internal/ui/systemviews/icons.go +26 -0
@@ -0,0 +1,26 @@
1+package systemviews
2+
3+type systemIcon string
4+
5+const (
6+ iconTrash systemIcon = "trash-2"
7+ iconX systemIcon = "x"
8+ iconSearch systemIcon = "search"
9+ iconSave systemIcon = "save"
10+ iconPlus systemIcon = "plus"
11+ iconExternal systemIcon = "external-link"
12+ iconRotateCCW systemIcon = "rotate-ccw"
13+ iconCheck systemIcon = "check"
14+ iconHistory systemIcon = "history"
15+ iconFolder systemIcon = "folder"
16+ iconTag systemIcon = "tag"
17+ iconSettings systemIcon = "settings"
18+ iconAlert systemIcon = "alert-triangle"
19+)
20+
21+func iconClass(name systemIcon) string {
22+ if name == "" {
23+ return "sv-icon"
24+ }
25+ return "sv-icon sv-icon-" + string(name)
26+}
A internal/ui/systemviews/icons.templ +65 -0
@@ -0,0 +1,65 @@
1+package systemviews
2+
3+templ Icon(name systemIcon) {
4+ <svg class={ iconClass(name) } xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
5+ if name == iconTrash {
6+ <path d="M10 11v6"></path>
7+ <path d="M14 11v6"></path>
8+ <path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"></path>
9+ <path d="M3 6h18"></path>
10+ <path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
11+ } else if name == iconX {
12+ <path d="M18 6 6 18"></path>
13+ <path d="m6 6 12 12"></path>
14+ } else if name == iconSearch {
15+ <path d="m21 21-4.34-4.34"></path>
16+ <circle cx="11" cy="11" r="8"></circle>
17+ } else if name == iconSave {
18+ <path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"></path>
19+ <path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"></path>
20+ <path d="M7 3v4a1 1 0 0 0 1 1h7"></path>
21+ } else if name == iconPlus {
22+ <path d="M5 12h14"></path>
23+ <path d="M12 5v14"></path>
24+ } else if name == iconExternal {
25+ <path d="M15 3h6v6"></path>
26+ <path d="M10 14 21 3"></path>
27+ <path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path>
28+ } else if name == iconRotateCCW {
29+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
30+ <path d="M3 3v5h5"></path>
31+ } else if name == iconCheck {
32+ <path d="M20 6 9 17l-5-5"></path>
33+ } else if name == iconHistory {
34+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"></path>
35+ <path d="M3 3v5h5"></path>
36+ <path d="M12 7v5l4 2"></path>
37+ } else if name == iconFolder {
38+ <path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"></path>
39+ } else if name == iconTag {
40+ <path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"></path>
41+ <circle cx="7.5" cy="7.5" r=".5" fill="currentColor"></circle>
42+ } else if name == iconSettings {
43+ <path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"></path>
44+ <circle cx="12" cy="12" r="3"></circle>
45+ } else if name == iconAlert {
46+ <path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"></path>
47+ <path d="M12 9v4"></path>
48+ <path d="M12 17h.01"></path>
49+ } else {
50+ <circle cx="12" cy="12" r="10"></circle>
51+ <path d="M9.09 9a3 3 0 1 1 5.83 1c0 2-3 2-3 4"></path>
52+ <path d="M12 17h.01"></path>
53+ }
54+ </svg>
55+}
56+
57+templ ButtonIconLabel(name systemIcon, label string) {
58+ @Icon(name)
59+ <span>{ label }</span>
60+}
61+
62+templ IconOnly(name systemIcon, label string) {
63+ @Icon(name)
64+ <span class="sv-sr-only">{ label }</span>
65+}
A internal/ui/systemviews/icons_templ.go +224 -0 generated file: collapsed
A internal/ui/systemviews/prefers_dark_js.go +24 -0
@@ -0,0 +1,24 @@
1+//go:build js && wasm
2+
3+package systemviews
4+
5+import "syscall/js"
6+
7+var currentPrefersDarkImpl = func() bool {
8+ window := js.Global()
9+ if !window.Truthy() {
10+ return false
11+ }
12+
13+ matchMedia := window.Get("matchMedia")
14+ if !matchMedia.Truthy() {
15+ return false
16+ }
17+
18+ media := matchMedia.Invoke("(prefers-color-scheme: dark)")
19+ if !media.Truthy() {
20+ return false
21+ }
22+ matches := media.Get("matches")
23+ return matches.Truthy() && matches.Bool()
24+}
A internal/ui/systemviews/prefers_dark_stub.go +5 -0
@@ -0,0 +1,5 @@
1+//go:build !js || !wasm
2+
3+package systemviews
4+
5+var currentPrefersDarkImpl = func() bool { return false }
A internal/ui/systemviews/render.go +29 -0
@@ -0,0 +1,29 @@
1+package systemviews
2+
3+import (
4+ "bytes"
5+ "context"
6+
7+ "github.com/a-h/templ"
8+)
9+
10+func renderComponent(ctx context.Context, component templ.Component) (string, error) {
11+ var buf bytes.Buffer
12+ if component == nil {
13+ return "", nil
14+ }
15+ if err := component.Render(ctx, &buf); err != nil {
16+ return "", err
17+ }
18+ return buf.String(), nil
19+}
20+
21+func mustRenderComponent(component templ.Component) string {
22+ // Render helpers are pure HTML formatting paths; route/action contexts are
23+ // handled before rendering, so there is no caller context to preserve here.
24+ html, err := renderComponent(context.Background(), component)
25+ if err != nil {
26+ return errorStateHTML(err.Error())
27+ }
28+ return html
29+}
A internal/ui/systemviews/router.go +60 -0
@@ -0,0 +1,60 @@
1+package systemviews
2+
3+import (
4+ "net/url"
5+ "strings"
6+)
7+
8+type Route string
9+
10+const (
11+ RouteUnknown Route = "unknown"
12+ RouteHistory Route = "history"
13+ RouteFavorites Route = "favorites"
14+ RouteConfig Route = "config"
15+)
16+
17+func ParseRoute(uri string) Route {
18+ if uri == "" {
19+ return RouteUnknown
20+ }
21+
22+ u, err := url.Parse(uri)
23+ if err != nil {
24+ return RouteUnknown
25+ }
26+
27+ switch {
28+ case strings.EqualFold(u.Scheme, "dumb"):
29+ return routeFromPageHost(u.Host, u.Opaque)
30+ case strings.EqualFold(u.Scheme, "https") && strings.EqualFold(u.Host, "dumber.invalid"):
31+ path := strings.Trim(u.Path, "/")
32+ if path == "" {
33+ return RouteUnknown
34+ }
35+ if head, _, ok := strings.Cut(path, "/"); ok {
36+ path = head
37+ }
38+ return routeFromPageHost(path, "")
39+ default:
40+ return RouteUnknown
41+ }
42+}
43+
44+func routeFromPageHost(host, opaque string) Route {
45+ page := strings.TrimSpace(host)
46+ if page == "" {
47+ page = strings.TrimSpace(opaque)
48+ }
49+
50+ switch strings.ToLower(page) {
51+ case string(RouteHistory):
52+ return RouteHistory
53+ case string(RouteFavorites):
54+ return RouteFavorites
55+ case string(RouteConfig):
56+ return RouteConfig
57+ default:
58+ return RouteUnknown
59+ }
60+}
A internal/ui/systemviews/shell.go +138 -0
@@ -0,0 +1,138 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "html"
6+ "strings"
7+
8+ "github.com/a-h/templ"
9+)
10+
11+type kvPair struct {
12+ Label string
13+ Value string
14+}
15+
16+// renderedPage holds the data needed by renderAppFrame to produce the shell
17+// fragment. Route views populate this; the app frame wraps it.
18+type renderedPage struct {
19+ route Route
20+ title string
21+ subtitle string
22+ subtitleDetail string
23+ body string
24+}
25+
26+// renderAppFrame produces a self-contained HTML fragment (no <html>, <head>,
27+// or <body>) that wraps route content in the shared shell. page.body is
28+// inserted with templ.Raw, so callers must only pass trusted pre-rendered HTML.
29+func renderAppFrame(page renderedPage, theme shellTheme) string {
30+ return mustRenderComponent(appFrameComponent(page, theme, templ.Raw(page.body)))
31+}
32+
33+func pageDocumentTitle(page renderedPage) string {
34+ if title := strings.TrimSpace(page.title); title != "" {
35+ return title
36+ }
37+ if subtitle := strings.TrimSpace(page.subtitle); subtitle != "" {
38+ return subtitle
39+ }
40+ return routeSubtitle(page.route)
41+}
42+
43+func appRootClass(theme shellTheme) string {
44+ themeClass := theme.RootClass
45+ if themeClass == "" {
46+ themeClass = "sv-dark"
47+ }
48+ return "sv-app " + themeClass
49+}
50+
51+func sectionClass(className string) string {
52+ classes := "sv-section"
53+ if strings.TrimSpace(className) != "" {
54+ classes += " " + strings.TrimSpace(className)
55+ }
56+ return classes
57+}
58+
59+func alertClass(kind string) string {
60+ classes := "sv-alert"
61+ switch kind {
62+ case "error":
63+ return classes + " sv-alert-error"
64+ case "success":
65+ return classes + " sv-alert-success"
66+ default:
67+ return classes
68+ }
69+}
70+
71+func alertRole(kind string) string {
72+ if kind == "error" {
73+ return "alert"
74+ }
75+ return "status"
76+}
77+
78+func buttonClass(extra string) string {
79+ extra = strings.TrimSpace(extra)
80+ if extra == "" {
81+ return "sv-button"
82+ }
83+ return "sv-button " + extra
84+}
85+
86+func listHTML(rows, emptyMessage string) string {
87+ if strings.TrimSpace(rows) == "" {
88+ return emptyStateHTML(emptyMessage)
89+ }
90+
91+ return fmt.Sprintf(`<ul class="sv-list">%s</ul>`, rows)
92+}
93+
94+// listRowHTML inserts trusted, pre-sanitized raw HTML inside a list item.
95+func linkHTML(href, label string) string {
96+ return fmt.Sprintf(`<a class=%q href=%q>%s</a>`, "sv-link", html.EscapeString(sanitizeHref(href)), html.EscapeString(label))
97+}
98+
99+func kvHTML(rows []kvPair) string {
100+ var items strings.Builder
101+ for _, row := range rows {
102+ if strings.TrimSpace(row.Label) == "" && strings.TrimSpace(row.Value) == "" {
103+ continue
104+ }
105+ items.WriteString(kvRowHTML(row.Label, row.Value))
106+ }
107+ if items.Len() == 0 {
108+ return emptyStateHTML("No configuration details")
109+ }
110+
111+ return fmt.Sprintf(`<dl class="sv-kv">%s</dl>`, items.String())
112+}
113+
114+func kvRowHTML(label, value string) string {
115+ return fmt.Sprintf(`<div class=%q><dt>%s</dt><dd>%s</dd></div>`, "sv-kv-row", html.EscapeString(label), html.EscapeString(value))
116+}
117+
118+// sectionHTML wraps content in a styled section with a heading. inner is
119+// passed through as raw HTML, so callers must provide trusted markup.
120+func sectionHTML(class, title, inner string) string {
121+ return fmt.Sprintf(`<section class=%q><h2>%s</h2>%s</section>`,
122+ sectionClass(class),
123+ html.EscapeString(title),
124+ inner,
125+ )
126+}
127+
128+// emptyStateHTML returns a standard empty-state placeholder.
129+func emptyStateHTML(message string) string {
130+ return fmt.Sprintf(`<div class="sv-empty"><p>%s</p></div>`, html.EscapeString(message))
131+}
132+
133+func errorStateHTML(message string) string {
134+ return fmt.Sprintf(
135+ `<div class="sv-error" role="alert"><p>Could not load this system view.</p><p class="sv-meta">%s</p></div>`,
136+ html.EscapeString(message),
137+ )
138+}
A internal/ui/systemviews/shell_test.go +176 -0
@@ -0,0 +1,176 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/assert"
7+)
8+
9+func TestRenderAppFrame_ReturnsFragment(t *testing.T) {
10+ t.Parallel()
11+
12+ page := renderedPage{
13+ route: RouteHistory,
14+ subtitle: "History",
15+ body: `<ul><li>hello</li></ul>`,
16+ }
17+ theme := shellTheme{RootClass: "sv-dark"}
18+
19+ out := renderAppFrame(page, theme)
20+
21+ // Must be a fragment — no full document tags.
22+ assert.NotContains(t, out, "<html")
23+ assert.NotContains(t, out, "<head")
24+ assert.NotContains(t, out, "<body")
25+
26+ // Root div carries theme class.
27+ assert.Contains(t, out, `class="sv-app sv-dark"`)
28+
29+ // Route and document title are on the root element.
30+ assert.Contains(t, out, `data-route="history"`)
31+ assert.Contains(t, out, `data-page-title="History"`)
32+
33+ // Shell wrapper present.
34+ assert.Contains(t, out, `class="sv-shell"`)
35+
36+ // Body content passed through.
37+ assert.Contains(t, out, `<ul><li>hello</li></ul>`)
38+
39+ // Subtitle rendered.
40+ assert.Contains(t, out, "History")
41+}
42+
43+func TestRenderAppFrame_DefaultThemeClass(t *testing.T) {
44+ t.Parallel()
45+
46+ page := renderedPage{
47+ route: RouteFavorites,
48+ subtitle: "Favorites",
49+ body: `<p>content</p>`,
50+ }
51+ // Empty theme — should still get sv-app with sv-dark default.
52+ out := renderAppFrame(page, shellTheme{})
53+
54+ assert.Contains(t, out, `class="sv-app sv-dark"`)
55+ assert.Contains(t, out, `data-route="favorites"`)
56+ assert.Contains(t, out, `data-page-title="Favorites"`)
57+}
58+
59+func TestRenderAppFrame_DocumentTitleUsesExplicitTitle(t *testing.T) {
60+ t.Parallel()
61+
62+ page := renderedPage{
63+ route: RouteHistory,
64+ title: "History — 12 entries",
65+ subtitle: "Recent visits",
66+ body: `<p>content</p>`,
67+ }
68+
69+ out := renderAppFrame(page, shellTheme{})
70+
71+ assert.Contains(t, out, `data-page-title="History — 12 entries"`)
72+ assert.Contains(t, out, "Recent visits")
73+}
74+
75+func TestRenderAppFrame_InlineVarsApplied(t *testing.T) {
76+ t.Parallel()
77+
78+ page := renderedPage{
79+ route: RouteConfig,
80+ subtitle: "Config",
81+ body: `<p>settings</p>`,
82+ }
83+ theme := shellTheme{
84+ RootClass: "sv-light",
85+ InlineVars: "--sv-background: #fff;",
86+ }
87+
88+ out := renderAppFrame(page, theme)
89+
90+ assert.Contains(t, out, `style="--sv-background: #fff;"`)
91+ assert.Contains(t, out, `class="sv-app sv-light"`)
92+}
93+
94+func TestListHTMLWrapsRows(t *testing.T) {
95+ t.Parallel()
96+
97+ out := listHTML(`<li class="sv-list-row">entry</li>`, "Nothing here")
98+
99+ assert.Contains(t, out, `class="sv-list"`)
100+ assert.Contains(t, out, `class="sv-list-row"`)
101+ assert.Contains(t, out, "entry")
102+}
103+
104+func TestListHTMLEmptyState(t *testing.T) {
105+ t.Parallel()
106+
107+ out := listHTML("", "Nothing here")
108+
109+ assert.Contains(t, out, `class="sv-empty"`)
110+ assert.Contains(t, out, "Nothing here")
111+}
112+
113+func TestLinkHTMLSanitizesHref(t *testing.T) {
114+ t.Parallel()
115+
116+ out := linkHTML("javascript:alert(1)", "Bad")
117+
118+ assert.Contains(t, out, `class="sv-link"`)
119+ assert.Contains(t, out, `href="#"`)
120+ assert.Contains(t, out, "Bad")
121+}
122+
123+func TestKVHTMLRendersPairs(t *testing.T) {
124+ t.Parallel()
125+
126+ out := kvHTML([]kvPair{{Label: "Engine", Value: "webkit"}})
127+
128+ assert.Contains(t, out, `class="sv-kv"`)
129+ assert.Contains(t, out, "Engine")
130+ assert.Contains(t, out, "webkit")
131+}
132+
133+func TestKVHTMLEmptySlice(t *testing.T) {
134+ t.Parallel()
135+
136+ out := kvHTML(nil)
137+
138+ assert.Contains(t, out, `class="sv-empty"`)
139+ assert.Contains(t, out, "No configuration details")
140+}
141+
142+func TestKVHTMLAllEmptyRows(t *testing.T) {
143+ t.Parallel()
144+
145+ out := kvHTML([]kvPair{{}, {Label: " ", Value: ""}})
146+
147+ assert.Contains(t, out, `class="sv-empty"`)
148+ assert.Contains(t, out, "No configuration details")
149+}
150+
151+func TestErrorStateHTML(t *testing.T) {
152+ t.Parallel()
153+
154+ out := errorStateHTML("boom")
155+
156+ assert.Contains(t, out, `class="sv-error"`)
157+ assert.Contains(t, out, `role="alert"`)
158+ assert.Contains(t, out, "boom")
159+}
160+
161+func TestSectionHTML(t *testing.T) {
162+ t.Parallel()
163+
164+ out := sectionHTML("test-section", "Test Title", "<p>inner</p>")
165+ assert.Contains(t, out, `class="sv-section test-section"`)
166+ assert.Contains(t, out, "Test Title")
167+ assert.Contains(t, out, "<p>inner</p>")
168+}
169+
170+func TestEmptyStateHTML(t *testing.T) {
171+ t.Parallel()
172+
173+ out := emptyStateHTML("Nothing here")
174+ assert.Contains(t, out, `class="sv-empty"`)
175+ assert.Contains(t, out, "Nothing here")
176+}
A internal/ui/systemviews/theme.go +190 -0
@@ -0,0 +1,190 @@
1+package systemviews
2+
3+import (
4+ "fmt"
5+ "strings"
6+ "unicode"
7+
8+ "github.com/bnema/dumber/internal/application/dto"
9+)
10+
11+type shellTheme struct {
12+ RootClass string
13+ InlineVars string
14+}
15+
16+const (
17+ shellDarkClass = "sv-dark"
18+ shellLightClass = "sv-light"
19+)
20+
21+var cssNamedColors = func() map[string]struct{} {
22+ //nolint:lll // CSS named color list is clearer as a single literal.
23+ names := strings.Fields(`aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen`)
24+ colors := make(map[string]struct{}, len(names))
25+ for _, name := range names {
26+ colors[name] = struct{}{}
27+ }
28+ return colors
29+}()
30+
31+func currentPrefersDark() bool {
32+ return currentPrefersDarkImpl()
33+}
34+
35+func resolveShellTheme(appearance dto.WebUIAppearanceConfig) shellTheme {
36+ if isZeroAppearance(appearance) {
37+ return shellTheme{}
38+ }
39+
40+ var palette dto.ColorPalette
41+ var rootClass string
42+
43+ switch strings.ToLower(strings.TrimSpace(appearance.ColorScheme)) {
44+ case "prefer-dark":
45+ palette = appearance.DarkPalette
46+ rootClass = shellDarkClass
47+ case "prefer-light":
48+ palette = appearance.LightPalette
49+ rootClass = shellLightClass
50+ default:
51+ if currentPrefersDark() {
52+ palette = appearance.DarkPalette
53+ rootClass = shellDarkClass
54+ } else {
55+ palette = appearance.LightPalette
56+ rootClass = shellLightClass
57+ }
58+ }
59+
60+ return shellTheme{
61+ RootClass: rootClass,
62+ InlineVars: buildInlineVars(appearance, palette),
63+ }
64+}
65+
66+func buildInlineVars(appearance dto.WebUIAppearanceConfig, palette dto.ColorPalette) string {
67+ parts := make([]string, 0, 11)
68+ appendInlineVar := func(name, value string, sanitize func(string) (string, bool)) {
69+ value, ok := sanitize(value)
70+ if !ok {
71+ return
72+ }
73+ parts = append(parts, fmt.Sprintf("%s: %s;", name, value))
74+ }
75+
76+ appendInlineVar("--sv-background", palette.Background, sanitizeCSSColor)
77+ appendInlineVar("--sv-surface", palette.Surface, sanitizeCSSColor)
78+ appendInlineVar("--sv-surface-variant", palette.SurfaceVariant, sanitizeCSSColor)
79+ appendInlineVar("--sv-text", palette.Text, sanitizeCSSColor)
80+ appendInlineVar("--sv-muted", palette.Muted, sanitizeCSSColor)
81+ appendInlineVar("--sv-accent", palette.Accent, sanitizeCSSColor)
82+ appendInlineVar("--sv-border", palette.Border, sanitizeCSSColor)
83+ appendInlineVar("--sv-font-sans", appearance.SansFont, sanitizeCSSFontFamily)
84+ appendInlineVar("--sv-font-serif", appearance.SerifFont, sanitizeCSSFontFamily)
85+ appendInlineVar("--sv-font-mono", appearance.MonospaceFont, sanitizeCSSFontFamily)
86+ if appearance.DefaultFontSize > 0 {
87+ parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize))
88+ }
89+
90+ return strings.Join(parts, " ")
91+}
92+
93+func sanitizeCSSColor(value string) (string, bool) {
94+ value = strings.TrimSpace(value)
95+ if value == "" || hasCSSBreakout(value) {
96+ return "", false
97+ }
98+ lower := strings.ToLower(value)
99+ if isHexColor(lower) || isFunctionalColor(lower) || isNamedColor(lower) {
100+ return value, true
101+ }
102+ return "", false
103+}
104+
105+func sanitizeCSSFontFamily(value string) (string, bool) {
106+ value = strings.TrimSpace(value)
107+ if value == "" || hasCSSBreakout(value) {
108+ return "", false
109+ }
110+
111+ validated := value
112+ if quote := value[0]; quote == '\'' || quote == '"' {
113+ if len(value) < 2 || value[len(value)-1] != quote {
114+ return "", false
115+ }
116+ validated = value[1 : len(value)-1]
117+ if validated == "" || strings.ContainsAny(validated, "'\"") {
118+ return "", false
119+ }
120+ } else if strings.ContainsAny(value, "'\"") {
121+ return "", false
122+ }
123+
124+ for _, r := range validated {
125+ switch {
126+ case unicode.IsLetter(r), unicode.IsMark(r), unicode.IsDigit(r):
127+ case strings.ContainsRune(" ,._-", r):
128+ default:
129+ return "", false
130+ }
131+ }
132+ return value, true
133+}
134+
135+func hasCSSBreakout(value string) bool {
136+ return strings.ContainsAny(value, ";{}\n\r") || strings.Contains(value, "/*") || strings.Contains(value, "*/")
137+}
138+
139+func isHexColor(value string) bool {
140+ if len(value) != 4 && len(value) != 5 && len(value) != 7 && len(value) != 9 {
141+ return false
142+ }
143+ if value[0] != '#' {
144+ return false
145+ }
146+ for _, r := range value[1:] {
147+ if (r < '0' || r > '9') && (r < 'a' || r > 'f') && (r < 'A' || r > 'F') {
148+ return false
149+ }
150+ }
151+ return true
152+}
153+
154+func isFunctionalColor(value string) bool {
155+ prefixes := []string{"rgb(", "rgba(", "hsl(", "hsla(", "oklch(", "oklab(", "color(", "lab(", "lch(", "hwb("}
156+ matched := false
157+ for _, prefix := range prefixes {
158+ if strings.HasPrefix(value, prefix) {
159+ matched = true
160+ break
161+ }
162+ }
163+ if !matched || !strings.HasSuffix(value, ")") {
164+ return false
165+ }
166+ for _, r := range strings.TrimSuffix(value[strings.IndexByte(value, '(')+1:], ")") {
167+ if (r < '0' || r > '9') && (r < 'a' || r > 'z') && !strings.ContainsRune(" .,%/-", r) {
168+ return false
169+ }
170+ }
171+ return true
172+}
173+
174+func isNamedColor(value string) bool {
175+ if value == "transparent" || value == "currentcolor" {
176+ return true
177+ }
178+ _, ok := cssNamedColors[value]
179+ return ok
180+}
181+
182+func isZeroAppearance(appearance dto.WebUIAppearanceConfig) bool {
183+ return appearance.ColorScheme == "" &&
184+ appearance.SansFont == "" &&
185+ appearance.SerifFont == "" &&
186+ appearance.MonospaceFont == "" &&
187+ appearance.DefaultFontSize == 0 &&
188+ appearance.LightPalette == (dto.ColorPalette{}) &&
189+ appearance.DarkPalette == (dto.ColorPalette{})
190+}
A internal/ui/systemviews/theme_test.go +131 -0
@@ -0,0 +1,131 @@
1+package systemviews
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+ "github.com/stretchr/testify/assert"
8+)
9+
10+func TestBuildInlineVarsRejectsUnsafeCSSValues(t *testing.T) {
11+ t.Parallel()
12+
13+ vars := buildInlineVars(dto.WebUIAppearanceConfig{
14+ SansFont: "Inter; color:red",
15+ SerifFont: "Georgia",
16+ MonospaceFont: "JetBrains Mono",
17+ LightPalette: dto.ColorPalette{
18+ Background: "#ffffff; color:red",
19+ Surface: "rgb(1 2 3 / 50%)",
20+ Text: "transparent",
21+ Accent: "definitelynotacolor",
22+ Border: "#ABCDEF80",
23+ },
24+ }, dto.ColorPalette{
25+ Background: "#ffffff; color:red",
26+ Surface: "rgb(1 2 3 / 50%)",
27+ Text: "transparent",
28+ Accent: "definitelynotacolor",
29+ Border: "#ABCDEF80",
30+ })
31+
32+ assert.NotContains(t, vars, "color:red")
33+ assert.NotContains(t, vars, "--sv-background")
34+ assert.NotContains(t, vars, "--sv-font-sans")
35+ assert.Contains(t, vars, "--sv-surface: rgb(1 2 3 / 50%);")
36+ assert.Contains(t, vars, "--sv-text: transparent;")
37+ assert.NotContains(t, vars, "definitelynotacolor")
38+ assert.Contains(t, vars, "--sv-border: #ABCDEF80;")
39+ assert.Contains(t, vars, "--sv-font-serif: Georgia;")
40+ assert.Contains(t, vars, "--sv-font-mono: JetBrains Mono;")
41+}
42+
43+func TestSanitizeCSSFontFamilyQuotes(t *testing.T) {
44+ t.Parallel()
45+
46+ tests := []struct {
47+ name string
48+ value string
49+ want bool
50+ }{
51+ {name: "unquoted spaces", value: "JetBrains Mono", want: true},
52+ {name: "single quoted token", value: "'JetBrains Mono'", want: true},
53+ {name: "double quoted token", value: "\"JetBrains Mono\"", want: true},
54+ {name: "unquoted single quote", value: "JetBrains' Mono", want: false},
55+ {name: "unquoted double quote", value: "JetBrains\" Mono", want: false},
56+ {name: "internal quote", value: "'JetBrains' Mono'", want: false},
57+ {name: "breakout", value: "'JetBrains Mono'; color:red", want: false},
58+ }
59+
60+ for _, tt := range tests {
61+ t.Run(tt.name, func(t *testing.T) {
62+ t.Parallel()
63+
64+ _, got := sanitizeCSSFontFamily(tt.value)
65+ assert.Equal(t, tt.want, got)
66+ })
67+ }
68+}
69+
70+func TestResolveShellTheme(t *testing.T) {
71+ orig := currentPrefersDarkImpl
72+ t.Cleanup(func() {
73+ currentPrefersDarkImpl = orig
74+ })
75+
76+ appearance := dto.WebUIAppearanceConfig{
77+ SansFont: "Inter",
78+ SerifFont: "Georgia",
79+ MonospaceFont: "JetBrains Mono",
80+ DefaultFontSize: 16,
81+ ColorScheme: "default",
82+ LightPalette: dto.ColorPalette{
83+ Background: "#ffffff",
84+ Surface: "#f8f8f8",
85+ SurfaceVariant: "#eeeeee",
86+ Text: "#111111",
87+ Muted: "#666666",
88+ Accent: "#0055ff",
89+ Border: "#dddddd",
90+ },
91+ DarkPalette: dto.ColorPalette{
92+ Background: "#111111",
93+ Surface: "#1a1a1a",
94+ SurfaceVariant: "#2a2a2a",
95+ Text: "#f5f5f5",
96+ Muted: "#a0a0a0",
97+ Accent: "#66aaff",
98+ Border: "#333333",
99+ },
100+ }
101+
102+ tests := []struct {
103+ name string
104+ colorScheme string
105+ prefersDark bool
106+ wantClass string
107+ wantBackground string
108+ wantSurfaceVariant string
109+ }{
110+ {name: "prefer-dark", colorScheme: "prefer-dark", prefersDark: false, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"},
111+ {name: "prefer-light", colorScheme: "prefer-light", prefersDark: true, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"},
112+ {name: "default prefers dark", colorScheme: "default", prefersDark: true, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"},
113+ {name: "default prefers light", colorScheme: "default", prefersDark: false, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"},
114+ }
115+
116+ for _, tt := range tests {
117+ t.Run(tt.name, func(t *testing.T) {
118+ currentPrefersDarkImpl = func() bool { return tt.prefersDark }
119+ ttAppearance := appearance
120+ ttAppearance.ColorScheme = tt.colorScheme
121+
122+ got := resolveShellTheme(ttAppearance)
123+
124+ assert.Equal(t, tt.wantClass, got.RootClass)
125+ assert.Contains(t, got.InlineVars, "--sv-background")
126+ assert.Contains(t, got.InlineVars, "--sv-font-sans")
127+ assert.Contains(t, got.InlineVars, tt.wantBackground)
128+ assert.Contains(t, got.InlineVars, tt.wantSurfaceVariant)
129+ })
130+ }
131+}
D webui/components.json +0 -16
@@ -1,16 +0,0 @@
1-{
2- "$schema": "https://shadcn-svelte.com/schema.json",
3- "tailwind": {
4- "css": "src/styles/app.css",
5- "baseColor": "neutral"
6- },
7- "aliases": {
8- "components": "$lib/components",
9- "utils": "$lib/utils",
10- "ui": "$lib/components/ui",
11- "hooks": "$lib/hooks",
12- "lib": "$lib"
13- },
14- "typescript": true,
15- "registry": "https://shadcn-svelte.com/registry"
16-}
D webui/eslint.config.js +0 -59
@@ -1,59 +0,0 @@
1-import js from "@eslint/js";
2-import svelte from "eslint-plugin-svelte";
3-import globals from "globals";
4-import tseslint from "typescript-eslint";
5-import svelteConfig from "./svelte.config.js";
6-
7-export default tseslint.config(
8- js.configs.recommended,
9- ...tseslint.configs.recommended,
10- ...svelte.configs.recommended,
11- {
12- languageOptions: {
13- globals: {
14- ...globals.browser,
15- },
16- },
17- },
18- {
19- files: ["**/*.svelte", "**/*.svelte.ts", "**/*.svelte.js"],
20- languageOptions: {
21- parserOptions: {
22- extraFileExtensions: [".svelte"],
23- parser: tseslint.parser,
24- svelteConfig,
25- },
26- },
27- },
28- {
29- files: ["**/*.{js,mjs,cjs,ts}"],
30- languageOptions: {
31- ecmaVersion: 2024,
32- sourceType: "module",
33- },
34- rules: {
35- // Modern TypeScript best practices
36- "@typescript-eslint/no-unused-vars": [
37- "error",
38- {
39- argsIgnorePattern: "^_",
40- varsIgnorePattern: "^_",
41- },
42- ],
43- "@typescript-eslint/no-explicit-any": "warn",
44- "@typescript-eslint/no-empty-function": "off",
45- "@typescript-eslint/consistent-type-imports": "error",
46- "@typescript-eslint/no-import-type-side-effects": "error",
47-
48- // Modern JavaScript/TypeScript patterns
49- "prefer-const": "error",
50- "no-var": "error",
51- "object-shorthand": "error",
52- "prefer-template": "error",
53- },
54- },
55- {
56- files: ["**/*.js", "**/*.mjs"],
57- ...tseslint.configs.disableTypeChecked,
58- },
59-);
D webui/package-lock.json +0 -3903 generated file: collapsed
D webui/package.json +0 -52
@@ -1,52 +0,0 @@
1-{
2- "name": "@dumber/gui",
3- "version": "0.1.0",
4- "private": true,
5- "type": "module",
6- "scripts": {
7- "build": "VITE_PAGE=homepage vite build && VITE_PAGE=error vite build && VITE_PAGE=config vite build && VITE_PAGE=webrtc vite build",
8- "build:homepage": "VITE_PAGE=homepage vite build",
9- "build:error": "VITE_PAGE=error vite build",
10- "build:config": "VITE_PAGE=config vite build",
11- "build:webrtc": "VITE_PAGE=webrtc vite build",
12- "dev": "VITE_PAGE=homepage vite build --watch",
13- "dev:homepage": "VITE_PAGE=homepage vite build --watch",
14- "dev:error": "VITE_PAGE=error vite build --watch",
15- "dev:config": "VITE_PAGE=config vite build --watch",
16- "dev:webrtc": "VITE_PAGE=webrtc vite build --watch",
17- "check": "svelte-check",
18- "lint": "eslint src --fix",
19- "fmt": "prettier --write ."
20- },
21- "devDependencies": {
22- "@internationalized/date": "^3.12.0",
23- "@lucide/svelte": "^0.577.0",
24- "@sveltejs/vite-plugin-svelte": "^7.0.0",
25- "@tailwindcss/cli": "^4.2.2",
26- "@tailwindcss/postcss": "^4.2.2",
27- "@types/node": "^25.5.0",
28- "@typescript-eslint/eslint-plugin": "^8.57.1",
29- "@typescript-eslint/parser": "^8.57.1",
30- "autoprefixer": "^10.4.27",
31- "eslint": "^10.0.3",
32- "eslint-plugin-svelte": "^3.15.2",
33- "globals": "^17.4.0",
34- "postcss": "^8.5.8",
35- "svelte": "^5.54.0",
36- "svelte-awesome-color-picker": "^4.1.0",
37- "svelte-check": "^4.4.5",
38- "tailwind-variants": "^3.2.2",
39- "tailwindcss": "^4.0.0",
40- "tw-animate-css": "^1.4.0",
41- "typescript": "^5.3.0",
42- "typescript-eslint": "^8.57.1",
43- "vite": "^8.0.0"
44- },
45- "dependencies": {
46- "bits-ui": "^2.16.3",
47- "clsx": "^2.1.1",
48- "frappe-charts": "^1.6.2",
49- "mode-watcher": "^1.1.0",
50- "tailwind-merge": "^3.5.0"
51- }
52-}
D webui/postcss.config.js +0 -6
@@ -1,6 +0,0 @@
1-export default {
2- plugins: {
3- "@tailwindcss/postcss": {},
4- autoprefixer: {},
5- },
6-};
D webui/public/favicon.png +0 -0 binary file: no text diff

binary file: no text diff

D webui/src/lib/bridge.ts +0 -165
@@ -1,165 +0,0 @@
1-/**
2- * WebKit bridge utilities for communicating with the Go backend.
3- *
4- * IMPORTANT: Svelte 5 uses Proxy objects for reactive state ($state, $derived).
5- * WebKit's messageHandlers.postMessage() cannot serialize Proxy objects - it
6- * fails silently, dropping the message entirely. This module provides safe
7- * serialization that converts Proxies to plain objects before sending.
8- */
9-
10-export interface BridgeMessage {
11- type: string;
12- webviewId: number;
13- payload: Record<string, unknown>;
14-}
15-
16-interface WebKitBridge {
17- postMessage: (msg: unknown) => void;
18-}
19-
20-/**
21- * Get the native bridge, if available.
22- * WebKit exposes `window.webkit.messageHandlers.dumber` while CEF injects
23- * `window.dumber.postMessage`.
24- */
25-export function getWebKitBridge(): WebKitBridge | null {
26- // eslint-disable-next-line @typescript-eslint/no-explicit-any
27- const bridge = (window as any).webkit?.messageHandlers?.dumber ?? (window as any).dumber;
28- return bridge && typeof bridge.postMessage === "function" ? bridge : null;
29-}
30-
31-/**
32- * Get the current webview ID assigned by the Go backend.
33- */
34-export function getWebViewId(): number {
35- // eslint-disable-next-line @typescript-eslint/no-explicit-any
36- return (window as any).__dumber_webview_id || 0;
37-}
38-
39-/**
40- * Deep-clone an object, converting all Svelte 5 Proxy objects to plain objects.
41- * Uses JSON serialization which strips Proxy wrappers.
42- *
43- * @throws Error if the object contains non-serializable values (functions, circular refs, etc.)
44- */
45-export function toPlainObject<T>(obj: T): T {
46- try {
47- return JSON.parse(JSON.stringify(obj));
48- } catch (err) {
49- const message = err instanceof Error ? err.message : String(err);
50- throw new Error(`Failed to serialize object for bridge: ${message}`);
51- }
52-}
53-
54-/**
55- * Post a message to the Go backend via the native bridge.
56- *
57- * Automatically:
58- * - Converts Svelte 5 Proxy objects to plain objects
59- * - Adds webviewId if not provided
60- * - Logs errors if serialization fails
61- *
62- * @throws Error if bridge is not available or serialization fails
63- */
64-export function postMessage(msg: BridgeMessage): void {
65- const bridge = getWebKitBridge();
66- if (!bridge) {
67- throw new Error("Dumber bridge not available");
68- }
69-
70- // Ensure webviewId is set
71- if (!msg.webviewId) {
72- msg.webviewId = getWebViewId();
73- }
74-
75- // Convert to plain object to strip Svelte 5 Proxies
76- // This is critical - WebKit silently drops messages with Proxy objects
77- const plainMsg = toPlainObject(msg);
78- bridge.postMessage(plainMsg);
79-}
80-
81-/**
82- * Internal callback registry using Map for tracking pending requests.
83- * Callbacks must also be exposed on window for Go backend to call,
84- * but we track them here for proper cleanup and to prevent leaks.
85- */
86-const pendingCallbacks = new Map<string, {
87- cleanup: () => void;
88- timeoutId: ReturnType<typeof setTimeout>;
89-}>();
90-
91-let callbackCounter = 0;
92-const CALLBACK_TIMEOUT_MS = 30000;
93-
94-/**
95- * Post a message and set up success/error callbacks with automatic cleanup.
96- * Uses unique IDs to prevent naming conflicts between concurrent requests.
97- *
98- * @param type - Message type (e.g., "get_keybindings", "set_keybinding")
99- * @param payload - Message payload (will be serialized to strip Proxies)
100- * @param successCallback - Base name for success callback (will be made unique)
101- * @param errorCallback - Base name for error callback (will be made unique)
102- * @param onSuccess - Handler for success response
103- * @param onError - Handler for error response
104- */
105-export function postMessageWithCallbacks<TResponse, TPayload extends Record<string, unknown>>(
106- type: string,
107- payload: TPayload,
108- successCallback: string,
109- errorCallback: string,
110- onSuccess: (response: TResponse) => void,
111- onError: (error: string) => void,
112-): void {
113- const id = ++callbackCounter;
114- const successKey = `${successCallback}_${id}`;
115- const errorKey = `${errorCallback}_${id}`;
116-
117- // Cleanup removes from both window and our tracking Map
118- const cleanup = () => {
119- const entry = pendingCallbacks.get(successKey);
120- if (entry) {
121- clearTimeout(entry.timeoutId);
122- pendingCallbacks.delete(successKey);
123- }
124- // eslint-disable-next-line @typescript-eslint/no-explicit-any
125- delete (window as any)[successKey];
126- // eslint-disable-next-line @typescript-eslint/no-explicit-any
127- delete (window as any)[errorKey];
128- };
129-
130- // Timeout prevents memory leaks if backend never responds
131- const timeoutId = setTimeout(() => {
132- cleanup();
133- onError("Request timed out");
134- }, CALLBACK_TIMEOUT_MS);
135-
136- // Track in our Map for proper lifecycle management
137- pendingCallbacks.set(successKey, { cleanup, timeoutId });
138-
139- // Expose on window for Go backend (it calls window[callbackName](response))
140- // eslint-disable-next-line @typescript-eslint/no-explicit-any
141- (window as any)[successKey] = (response: TResponse) => {
142- cleanup();
143- onSuccess(response);
144- };
145- // eslint-disable-next-line @typescript-eslint/no-explicit-any
146- (window as any)[errorKey] = (msg: unknown) => {
147- cleanup();
148- onError(typeof msg === "string" ? msg : "Unknown error");
149- };
150-
151- try {
152- postMessage({
153- type,
154- webviewId: getWebViewId(),
155- payload: {
156- ...payload,
157- successCallback: successKey,
158- errorCallback: errorKey,
159- },
160- });
161- } catch (err) {
162- cleanup();
163- onError(err instanceof Error ? err.message : String(err));
164- }
165-}
D webui/src/lib/components/ui/alert-dialog/alert-dialog-action.svelte +0 -27
@@ -1,27 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { buttonVariants } from "$lib/components/ui/button/index.js";
4- import { cn } from "$lib/utils.js";
5- import type { Snippet } from "svelte";
6- import type { MouseEventHandler } from "svelte/elements";
7-
8- let {
9- ref = $bindable(null),
10- class: className,
11- children,
12- onclick,
13- ...restProps
14- }: AlertDialogPrimitive.ActionProps & {
15- children?: Snippet;
16- onclick?: MouseEventHandler<HTMLButtonElement>;
17- } = $props();
18-</script>
19-
20-<AlertDialogPrimitive.Action
21- bind:ref
22- class={cn(buttonVariants(), className)}
23- {onclick}
24- {...restProps}
25->
26- {@render children?.()}
27-</AlertDialogPrimitive.Action>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { buttonVariants } from "$lib/components/ui/button/index.js";
4- import { cn } from "$lib/utils.js";
5- import type { Snippet } from "svelte";
6-
7- let {
8- ref = $bindable(null),
9- class: className,
10- children,
11- ...restProps
12- }: AlertDialogPrimitive.CancelProps & {
13- children?: Snippet;
14- } = $props();
15-</script>
16-
17-<AlertDialogPrimitive.Cancel
18- bind:ref
19- class={cn(buttonVariants({ variant: "outline" }), className)}
20- {...restProps}
21->
22- {@render children?.()}
23-</AlertDialogPrimitive.Cancel>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-content.svelte +0 -24
@@ -1,24 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4- import AlertDialogOverlay from "./alert-dialog-overlay.svelte";
5- import AlertDialogPortal from "./alert-dialog-portal.svelte";
6-
7- let {
8- ref = $bindable(null),
9- class: className,
10- ...restProps
11- }: AlertDialogPrimitive.ContentProps = $props();
12-</script>
13-
14-<AlertDialogPortal>
15- <AlertDialogOverlay />
16- <AlertDialogPrimitive.Content
17- bind:ref
18- class={cn(
19- "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed left-1/2 top-1/2 z-50 grid w-full max-w-lg -translate-x-1/2 -translate-y-1/2 gap-4 border border-border p-6 shadow-lg duration-200 sm:rounded-lg",
20- className
21- )}
22- {...restProps}
23- />
24-</AlertDialogPortal>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-description.svelte +0 -16
@@ -1,16 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: AlertDialogPrimitive.DescriptionProps = $props();
10-</script>
11-
12-<AlertDialogPrimitive.Description
13- bind:ref
14- class={cn("text-sm text-muted-foreground", className)}
15- {...restProps}
16-/>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte +0 -19
@@ -1,19 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- class={cn("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", className)}
16- {...restProps}
17->
18- {@render children?.()}
19-</div>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-header.svelte +0 -19
@@ -1,19 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- class={cn("flex flex-col gap-2 text-center sm:text-left", className)}
16- {...restProps}
17->
18- {@render children?.()}
19-</div>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte +0 -19
@@ -1,19 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: AlertDialogPrimitive.OverlayProps = $props();
10-</script>
11-
12-<AlertDialogPrimitive.Overlay
13- bind:ref
14- class={cn(
15- "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80",
16- className
17- )}
18- {...restProps}
19-/>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte +0 -7
@@ -1,7 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3-
4- let { ...restProps }: AlertDialogPrimitive.PortalProps = $props();
5-</script>
6-
7-<AlertDialogPrimitive.Portal {...restProps} />
D webui/src/lib/components/ui/alert-dialog/alert-dialog-title.svelte +0 -16
@@ -1,16 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: AlertDialogPrimitive.TitleProps = $props();
10-</script>
11-
12-<AlertDialogPrimitive.Title
13- bind:ref
14- class={cn("text-lg font-semibold", className)}
15- {...restProps}
16-/>
D webui/src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte +0 -16
@@ -1,16 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: AlertDialogPrimitive.TriggerProps = $props();
10-</script>
11-
12-<AlertDialogPrimitive.Trigger
13- bind:ref
14- class={cn(className)}
15- {...restProps}
16-/>
D webui/src/lib/components/ui/alert-dialog/alert-dialog.svelte +0 -10
@@ -1,10 +0,0 @@
1-<script lang="ts">
2- import { AlertDialog as AlertDialogPrimitive } from "bits-ui";
3-
4- let {
5- open = $bindable(false),
6- ...restProps
7- }: AlertDialogPrimitive.RootProps = $props();
8-</script>
9-
10-<AlertDialogPrimitive.Root bind:open {...restProps} />
D webui/src/lib/components/ui/alert-dialog/index.ts +0 -37
@@ -1,37 +0,0 @@
1-import Root from "./alert-dialog.svelte";
2-import Trigger from "./alert-dialog-trigger.svelte";
3-import Portal from "./alert-dialog-portal.svelte";
4-import Overlay from "./alert-dialog-overlay.svelte";
5-import Content from "./alert-dialog-content.svelte";
6-import Header from "./alert-dialog-header.svelte";
7-import Footer from "./alert-dialog-footer.svelte";
8-import Title from "./alert-dialog-title.svelte";
9-import Description from "./alert-dialog-description.svelte";
10-import Action from "./alert-dialog-action.svelte";
11-import Cancel from "./alert-dialog-cancel.svelte";
12-
13-export {
14- Root,
15- Trigger,
16- Portal,
17- Overlay,
18- Content,
19- Header,
20- Footer,
21- Title,
22- Description,
23- Action,
24- Cancel,
25- //
26- Root as AlertDialog,
27- Trigger as AlertDialogTrigger,
28- Portal as AlertDialogPortal,
29- Overlay as AlertDialogOverlay,
30- Content as AlertDialogContent,
31- Header as AlertDialogHeader,
32- Footer as AlertDialogFooter,
33- Title as AlertDialogTitle,
34- Description as AlertDialogDescription,
35- Action as AlertDialogAction,
36- Cancel as AlertDialogCancel,
37-};
D webui/src/lib/components/ui/button/button.svelte +0 -82
@@ -1,82 +0,0 @@
1-<script lang="ts" module>
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAnchorAttributes, HTMLButtonAttributes } from "svelte/elements";
4- import { type VariantProps, tv } from "tailwind-variants";
5-
6- export const buttonVariants = tv({
7- base: "focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive inline-flex shrink-0 items-center justify-center gap-2 text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8- variants: {
9- variant: {
10- default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-xs",
11- destructive:
12- "bg-destructive hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60 text-white shadow-xs",
13- outline:
14- "bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50 border border-border shadow-xs",
15- secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80 shadow-xs",
16- ghost: "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
17- link: "text-primary underline-offset-4 hover:underline",
18- },
19- size: {
20- default: "h-9 px-4 py-2 has-[>svg]:px-3",
21- sm: "h-8 gap-1.5 px-3 has-[>svg]:px-2.5",
22- lg: "h-10 px-6 has-[>svg]:px-4",
23- icon: "size-9",
24- "icon-sm": "size-8",
25- "icon-lg": "size-10",
26- },
27- },
28- defaultVariants: {
29- variant: "default",
30- size: "default",
31- },
32- });
33-
34- export type ButtonVariant = VariantProps<typeof buttonVariants>["variant"];
35- export type ButtonSize = VariantProps<typeof buttonVariants>["size"];
36-
37- export type ButtonProps = WithElementRef<HTMLButtonAttributes> &
38- WithElementRef<HTMLAnchorAttributes> & {
39- variant?: ButtonVariant;
40- size?: ButtonSize;
41- };
42-</script>
43-
44-<script lang="ts">
45- let {
46- class: className,
47- variant = "default",
48- size = "default",
49- ref = $bindable(null),
50- href = undefined,
51- type = "button",
52- disabled,
53- children,
54- ...restProps
55- }: ButtonProps = $props();
56-</script>
57-
58-{#if href}
59- <a
60- bind:this={ref}
61- data-slot="button"
62- class={cn(buttonVariants({ variant, size }), className)}
63- href={disabled ? undefined : href}
64- aria-disabled={disabled}
65- role={disabled ? "link" : undefined}
66- tabindex={disabled ? -1 : undefined}
67- {...restProps}
68- >
69- {@render children?.()}
70- </a>
71-{:else}
72- <button
73- bind:this={ref}
74- data-slot="button"
75- class={cn(buttonVariants({ variant, size }), className)}
76- {type}
77- {disabled}
78- {...restProps}
79- >
80- {@render children?.()}
81- </button>
82-{/if}
D webui/src/lib/components/ui/button/index.ts +0 -17
@@ -1,17 +0,0 @@
1-import Root, {
2- type ButtonProps,
3- type ButtonSize,
4- type ButtonVariant,
5- buttonVariants,
6-} from "./button.svelte";
7-
8-export {
9- Root,
10- type ButtonProps as Props,
11- //
12- Root as Button,
13- buttonVariants,
14- type ButtonProps,
15- type ButtonSize,
16- type ButtonVariant,
17-};
D webui/src/lib/components/ui/card/card-action.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card-action"
16- class={cn("col-start-2 row-span-2 row-start-1 self-start justify-self-end", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</div>
D webui/src/lib/components/ui/card/card-content.svelte +0 -15
@@ -1,15 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div bind:this={ref} data-slot="card-content" class={cn("px-6", className)} {...restProps}>
14- {@render children?.()}
15-</div>
D webui/src/lib/components/ui/card/card-description.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLParagraphElement>> = $props();
11-</script>
12-
13-<p
14- bind:this={ref}
15- data-slot="card-description"
16- class={cn("text-muted-foreground text-sm", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</p>
D webui/src/lib/components/ui/card/card-footer.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card-footer"
16- class={cn("flex items-center px-6 [.border-t]:pt-6", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</div>
D webui/src/lib/components/ui/card/card-header.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card-header"
16- class={cn(
17- "@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</div>
D webui/src/lib/components/ui/card/card-title.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card-title"
16- class={cn("leading-none font-semibold", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</div>
D webui/src/lib/components/ui/card/card.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import type { HTMLAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLDivElement>> = $props();
11-</script>
12-
13-<div
14- bind:this={ref}
15- data-slot="card"
16- class={cn(
17- "bg-card text-card-foreground flex flex-col gap-6 border border-border py-6 shadow-sm",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</div>
D webui/src/lib/components/ui/card/index.ts +0 -25
@@ -1,25 +0,0 @@
1-import Root from "./card.svelte";
2-import Content from "./card-content.svelte";
3-import Description from "./card-description.svelte";
4-import Footer from "./card-footer.svelte";
5-import Header from "./card-header.svelte";
6-import Title from "./card-title.svelte";
7-import Action from "./card-action.svelte";
8-
9-export {
10- Root,
11- Content,
12- Description,
13- Footer,
14- Header,
15- Title,
16- Action,
17- //
18- Root as Card,
19- Content as CardContent,
20- Description as CardDescription,
21- Footer as CardFooter,
22- Header as CardHeader,
23- Title as CardTitle,
24- Action as CardAction,
25-};
D webui/src/lib/components/ui/color-picker/color-picker.svelte +0 -80
@@ -1,80 +0,0 @@
1-<script lang="ts" module>
2- export type ColorPickerProps = {
3- value: string;
4- onValueChange?: (value: string) => void;
5- id?: string;
6- class?: string;
7- };
8-</script>
9-
10-<script lang="ts">
11- import ColorPicker from "svelte-awesome-color-picker";
12- import { Popover } from "bits-ui";
13- import { cn } from "$lib/utils.js";
14-
15- let {
16- class: className,
17- value = $bindable("#000000"),
18- onValueChange,
19- id,
20- }: ColorPickerProps = $props();
21-
22- let open = $state(false);
23-
24- function handleInput(event: { hex: string | null }) {
25- if (event.hex) {
26- value = event.hex;
27- onValueChange?.(event.hex);
28- }
29- }
30-</script>
31-
32-<Popover.Root bind:open>
33- <Popover.Trigger
34- {id}
35- class={cn(
36- "inline-flex h-10 w-12 cursor-pointer items-center justify-center border border-border bg-background transition-colors hover:border-foreground/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
37- className
38- )}
39- >
40- <span
41- class="block h-6 w-8 border border-border/50"
42- style="background-color: {value};"
43- aria-hidden="true"
44- ></span>
45- <span class="sr-only">Choose color: {value}</span>
46- </Popover.Trigger>
47-
48- <Popover.Portal>
49- <Popover.Content
50- class="color-picker-popover z-50 border border-border bg-background p-3 shadow-lg"
51- sideOffset={4}
52- >
53- <ColorPicker
54- bind:hex={value}
55- onInput={handleInput}
56- isDialog={false}
57- />
58- <!-- Hex value display -->
59- <div class="mt-2 flex items-center gap-2">
60- <span
61- class="block h-6 w-6 shrink-0 border border-border"
62- style="background-color: {value};"
63- aria-hidden="true"
64- ></span>
65- <span class="font-mono text-sm text-foreground">{value}</span>
66- </div>
67- </Popover.Content>
68- </Popover.Portal>
69-</Popover.Root>
70-
71-<style>
72- /* Override svelte-awesome-color-picker styles to match our theme */
73- :global(.color-picker-popover) {
74- --cp-bg-color: var(--background);
75- --cp-border-color: var(--border);
76- --cp-text-color: var(--foreground);
77- --cp-input-color: var(--background);
78- --cp-button-hover-color: var(--accent);
79- }
80-</style>
D webui/src/lib/components/ui/color-picker/index.ts +0 -8
@@ -1,8 +0,0 @@
1-import Root, { type ColorPickerProps } from "./color-picker.svelte";
2-
3-export {
4- Root,
5- Root as ColorPicker,
6- type ColorPickerProps,
7- type ColorPickerProps as Props,
8-};
D webui/src/lib/components/ui/input/index.ts +0 -7
@@ -1,7 +0,0 @@
1-import Root from "./input.svelte";
2-
3-export {
4- Root,
5- //
6- Root as Input,
7-};
D webui/src/lib/components/ui/input/input.svelte +0 -52
@@ -1,52 +0,0 @@
1-<script lang="ts">
2- import type { HTMLInputAttributes, HTMLInputTypeAttribute } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- type InputType = Exclude<HTMLInputTypeAttribute, "file">;
6-
7- type Props = WithElementRef<
8- Omit<HTMLInputAttributes, "type"> &
9- ({ type: "file"; files?: FileList } | { type?: InputType; files?: undefined })
10- >;
11-
12- let {
13- ref = $bindable(null),
14- value = $bindable(),
15- type,
16- files = $bindable(),
17- class: className,
18- "data-slot": dataSlot = "input",
19- ...restProps
20- }: Props = $props();
21-</script>
22-
23-{#if type === "file"}
24- <input
25- bind:this={ref}
26- data-slot={dataSlot}
27- class={cn(
28- "selection:bg-primary dark:bg-input/30 selection:text-primary-foreground border-border ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 border bg-transparent px-3 pt-1.5 text-sm font-medium shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50",
29- "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
30- "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
31- className
32- )}
33- type="file"
34- bind:files
35- bind:value
36- {...restProps}
37- />
38-{:else}
39- <input
40- bind:this={ref}
41- data-slot={dataSlot}
42- class={cn(
43- "border-border bg-background selection:bg-primary dark:bg-input/30 selection:text-primary-foreground ring-offset-background placeholder:text-muted-foreground flex h-9 w-full min-w-0 border px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
44- "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
45- "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
46- className
47- )}
48- {type}
49- bind:value
50- {...restProps}
51- />
52-{/if}
D webui/src/lib/components/ui/label/index.ts +0 -7
@@ -1,7 +0,0 @@
1-import Root from "./label.svelte";
2-
3-export {
4- Root,
5- //
6- Root as Label,
7-};
D webui/src/lib/components/ui/label/label.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { Label as LabelPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: LabelPrimitive.RootProps = $props();
10-</script>
11-
12-<LabelPrimitive.Root
13- bind:ref
14- data-slot="label"
15- class={cn(
16- "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
17- className
18- )}
19- {...restProps}
20-/>
D webui/src/lib/components/ui/skeleton/index.ts +0 -7
@@ -1,7 +0,0 @@
1-import Root from "./skeleton.svelte";
2-
3-export {
4- Root,
5- //
6- Root as Skeleton,
7-};
D webui/src/lib/components/ui/skeleton/skeleton.svelte +0 -17
@@ -1,17 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: WithoutChildren<WithElementRef<HTMLAttributes<HTMLDivElement>>> = $props();
10-</script>
11-
12-<div
13- bind:this={ref}
14- data-slot="skeleton"
15- class={cn("bg-accent animate-pulse", className)}
16- {...restProps}
17-></div>
D webui/src/lib/components/ui/spinner/index.ts +0 -1
@@ -1,1 +0,0 @@
1-export { default as Spinner } from "./spinner.svelte";
D webui/src/lib/components/ui/spinner/spinner.svelte +0 -14
@@ -1,14 +0,0 @@
1-<script lang="ts">
2- import { cn } from "$lib/utils.js";
3- import Loader2Icon from "@lucide/svelte/icons/loader-2";
4- import type { ComponentProps } from "svelte";
5-
6- let { class: className, ...restProps }: ComponentProps<typeof Loader2Icon> = $props();
7-</script>
8-
9-<Loader2Icon
10- role="status"
11- aria-label="Loading"
12- class={cn("size-4 animate-spin", className)}
13- {...restProps}
14-/>
D webui/src/lib/components/ui/table/index.ts +0 -28
@@ -1,28 +0,0 @@
1-import Root from "./table.svelte";
2-import Body from "./table-body.svelte";
3-import Caption from "./table-caption.svelte";
4-import Cell from "./table-cell.svelte";
5-import Footer from "./table-footer.svelte";
6-import Head from "./table-head.svelte";
7-import Header from "./table-header.svelte";
8-import Row from "./table-row.svelte";
9-
10-export {
11- Root,
12- Body,
13- Caption,
14- Cell,
15- Footer,
16- Head,
17- Header,
18- Row,
19- //
20- Root as Table,
21- Body as TableBody,
22- Caption as TableCaption,
23- Cell as TableCell,
24- Footer as TableFooter,
25- Head as TableHead,
26- Header as TableHeader,
27- Row as TableRow,
28-};
D webui/src/lib/components/ui/table/table-body.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
11-</script>
12-
13-<tbody
14- bind:this={ref}
15- data-slot="table-body"
16- class={cn("[&_tr:last-child]:border-0", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</tbody>
D webui/src/lib/components/ui/table/table-caption.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLElement>> = $props();
11-</script>
12-
13-<caption
14- bind:this={ref}
15- data-slot="table-caption"
16- class={cn("text-muted-foreground mt-4 text-sm", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</caption>
D webui/src/lib/components/ui/table/table-cell.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLTdAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLTdAttributes> = $props();
11-</script>
12-
13-<td
14- bind:this={ref}
15- data-slot="table-cell"
16- class={cn(
17- "bg-clip-padding p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</td>
D webui/src/lib/components/ui/table/table-footer.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
11-</script>
12-
13-<tfoot
14- bind:this={ref}
15- data-slot="table-footer"
16- class={cn("bg-muted/50 border-t font-medium [&>tr]:last:border-b-0", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</tfoot>
D webui/src/lib/components/ui/table/table-head.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLThAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLThAttributes> = $props();
11-</script>
12-
13-<th
14- bind:this={ref}
15- data-slot="table-head"
16- class={cn(
17- "text-foreground h-10 bg-clip-padding px-2 text-start align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pe-0",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</th>
D webui/src/lib/components/ui/table/table-header.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLTableSectionElement>> = $props();
11-</script>
12-
13-<thead
14- bind:this={ref}
15- data-slot="table-header"
16- class={cn("[&_tr]:border-b", className)}
17- {...restProps}
18->
19- {@render children?.()}
20-</thead>
D webui/src/lib/components/ui/table/table-row.svelte +0 -23
@@ -1,23 +0,0 @@
1-<script lang="ts">
2- import { cn, type WithElementRef } from "$lib/utils.js";
3- import type { HTMLAttributes } from "svelte/elements";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLAttributes<HTMLTableRowElement>> = $props();
11-</script>
12-
13-<tr
14- bind:this={ref}
15- data-slot="table-row"
16- class={cn(
17- "hover:[&,&>svelte-css-wrapper]:[&>th,td]:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
18- className
19- )}
20- {...restProps}
21->
22- {@render children?.()}
23-</tr>
D webui/src/lib/components/ui/table/table.svelte +0 -22
@@ -1,22 +0,0 @@
1-<script lang="ts">
2- import type { HTMLTableAttributes } from "svelte/elements";
3- import { cn, type WithElementRef } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- children,
9- ...restProps
10- }: WithElementRef<HTMLTableAttributes> = $props();
11-</script>
12-
13-<div data-slot="table-container" class="relative w-full overflow-x-auto">
14- <table
15- bind:this={ref}
16- data-slot="table"
17- class={cn("w-full caption-bottom text-sm", className)}
18- {...restProps}
19- >
20- {@render children?.()}
21- </table>
22-</div>
D webui/src/lib/components/ui/tabs/index.ts +0 -16
@@ -1,16 +0,0 @@
1-import Root from "./tabs.svelte";
2-import Content from "./tabs-content.svelte";
3-import List from "./tabs-list.svelte";
4-import Trigger from "./tabs-trigger.svelte";
5-
6-export {
7- Root,
8- Content,
9- List,
10- Trigger,
11- //
12- Root as Tabs,
13- Content as TabsContent,
14- List as TabsList,
15- Trigger as TabsTrigger,
16-};
D webui/src/lib/components/ui/tabs/tabs-content.svelte +0 -17
@@ -1,17 +0,0 @@
1-<script lang="ts">
2- import { Tabs as TabsPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: TabsPrimitive.ContentProps = $props();
10-</script>
11-
12-<TabsPrimitive.Content
13- bind:ref
14- data-slot="tabs-content"
15- class={cn("flex-1 outline-none", className)}
16- {...restProps}
17-/>
D webui/src/lib/components/ui/tabs/tabs-list.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { Tabs as TabsPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: TabsPrimitive.ListProps = $props();
10-</script>
11-
12-<TabsPrimitive.List
13- bind:ref
14- data-slot="tabs-list"
15- class={cn(
16- "bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center border border-border p-[3px]",
17- className
18- )}
19- {...restProps}
20-/>
D webui/src/lib/components/ui/tabs/tabs-trigger.svelte +0 -20
@@ -1,20 +0,0 @@
1-<script lang="ts">
2- import { Tabs as TabsPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- class: className,
8- ...restProps
9- }: TabsPrimitive.TriggerProps = $props();
10-</script>
11-
12-<TabsPrimitive.Trigger
13- bind:ref
14- data-slot="tabs-trigger"
15- class={cn(
16- "data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
17- className
18- )}
19- {...restProps}
20-/>
D webui/src/lib/components/ui/tabs/tabs.svelte +0 -19
@@ -1,19 +0,0 @@
1-<script lang="ts">
2- import { Tabs as TabsPrimitive } from "bits-ui";
3- import { cn } from "$lib/utils.js";
4-
5- let {
6- ref = $bindable(null),
7- value = $bindable(""),
8- class: className,
9- ...restProps
10- }: TabsPrimitive.RootProps = $props();
11-</script>
12-
13-<TabsPrimitive.Root
14- bind:ref
15- bind:value
16- data-slot="tabs"
17- class={cn("flex flex-col gap-2", className)}
18- {...restProps}
19-/>
D webui/src/lib/types.ts +0 -68
@@ -1,68 +0,0 @@
1-import type { HistoryEntry, SearchShortcut } from "../types/generated.js";
2-
3-type ThemeManagerAPI = {
4- setUserPreference: (theme: "light" | "dark") => void;
5- getCurrentTheme: () => "light" | "dark";
6-};
7-
8-// Global window API types for browser integration
9-declare global {
10- interface Window {
11- // Toast system API
12- __dumber_toast_loaded?: boolean;
13- __dumber_showToast?: (
14- message: string,
15- duration?: number,
16- type?: "info" | "success" | "error",
17- ) => number | void;
18- __dumber_dismissToast?: (id: number) => void;
19- __dumber_clearToasts?: () => void;
20- __dumber_showZoomToast?: (zoomLevel: number) => void;
21-
22- // Theme integration
23- __dumber_initial_theme?: string;
24- __dumber_setTheme?: (theme: "light" | "dark") => void;
25- __dumber_color_scheme_manager?: ThemeManagerAPI;
26-
27- // History callbacks
28- __dumber_history_recent?: (data: HistoryEntry[]) => void;
29- __dumber_history_error?: (error: string) => void;
30-
31- // Shortcuts callbacks
32- __dumber_shortcuts_loaded?: (data: Record<string, SearchShortcut>) => void;
33- __dumber_shortcuts_error?: (error: string) => void;
34-
35- // WebKit message handler
36- webkit?: {
37- messageHandlers?: {
38- dumber?: {
39- postMessage: (message: unknown) => void;
40- };
41- };
42- };
43- }
44-}
45-
46-export interface ToastAPI {
47- show: (
48- message: string,
49- duration?: number,
50- type?: "info" | "success" | "error",
51- ) => number;
52- dismiss: (id: number) => void;
53- clear: () => void;
54- showZoom: (zoomLevel: number) => void;
55-}
56-
57-export interface ThemeAPI {
58- setTheme: (theme: "light" | "dark") => void;
59- getCurrentTheme: () => "light" | "dark";
60-}
61-
62-// Message types for WebKit communication
63-export interface BrowserMessage {
64- action: string;
65- data?: unknown;
66-}
67-
68-export {}; // Make this file a module
D webui/src/lib/utils.ts +0 -13
@@ -1,13 +0,0 @@
1-import { clsx, type ClassValue } from "clsx";
2-import { twMerge } from "tailwind-merge";
3-
4-export function cn(...inputs: ClassValue[]) {
5- return twMerge(clsx(inputs));
6-}
7-
8-// eslint-disable-next-line @typescript-eslint/no-explicit-any
9-export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
10-// eslint-disable-next-line @typescript-eslint/no-explicit-any
11-export type WithoutChildren<T> = T extends { children?: any } ? Omit<T, "children"> : T;
12-export type WithoutChildrenOrChild<T> = WithoutChildren<WithoutChild<T>>;
13-export type WithElementRef<T, U extends HTMLElement = HTMLElement> = T & { ref?: U | null };
D webui/src/lib/utils/keys.ts +0 -51
@@ -1,51 +0,0 @@
1-/**
2- * Format a keybinding string for display.
3- * Converts internal key names to user-friendly display format.
4- *
5- * Examples:
6- * "ctrl+shift+a" -> "Ctrl + Shift + A"
7- * "arrowleft" -> "Left"
8- * "alt+[" -> "Alt + ["
9- */
10-export function formatKey(key: string): string {
11- // Split by '+' separator first, then format each part
12- // This avoids replacing '+' characters that are actual key names
13- const parts = key.split("+");
14-
15- const formattedParts = parts.map((part) => {
16- const lower = part.toLowerCase();
17- switch (lower) {
18- case "arrowleft":
19- return "Left";
20- case "arrowright":
21- return "Right";
22- case "arrowup":
23- return "Up";
24- case "arrowdown":
25- return "Down";
26- case "ctrl":
27- return "Ctrl";
28- case "alt":
29- return "Alt";
30- case "shift":
31- return "Shift";
32- case "space":
33- return "Space";
34- case "enter":
35- return "Enter";
36- case "tab":
37- return "Tab";
38- case "backspace":
39- return "Backspace";
40- case "escape":
41- return "Esc";
42- default:
43- // Capitalize single letters, leave symbols as-is
44- return part.length === 1 && /[a-z]/i.test(part)
45- ? part.toUpperCase()
46- : part;
47- }
48- });
49-
50- return formattedParts.join(" + ");
51-}
D webui/src/pages/ConfigPage.svelte +0 -761
@@ -1,761 +0,0 @@
1-<script lang="ts">
2- import { onMount } from "svelte";
3- import { ModeWatcher, resetMode, setMode } from "mode-watcher";
4- import ConfigShell from "./config/ConfigShell.svelte";
5- import ShortcutsTable from "./config/ShortcutsTable.svelte";
6- import KeybindingsTab from "./config/KeybindingsTab.svelte";
7- import * as AlertDialog from "$lib/components/ui/alert-dialog";
8- import { Button } from "$lib/components/ui/button";
9- import { ColorPicker } from "$lib/components/ui/color-picker";
10- import { Input } from "$lib/components/ui/input";
11- import { Label } from "$lib/components/ui/label";
12- import { Spinner } from "$lib/components/ui/spinner";
13- import * as Card from "$lib/components/ui/card";
14- import * as Tabs from "$lib/components/ui/tabs";
15- import { FlaskConical, RefreshCw } from "@lucide/svelte";
16-
17- function getErrorMessage(e: unknown): string {
18- if (e instanceof Error) return e.message;
19- if (typeof e === "string") return e;
20- return "Unknown error";
21- }
22-
23- type ColorPalette = {
24- background: string;
25- surface: string;
26- surface_variant: string;
27- text: string;
28- muted: string;
29- accent: string;
30- border: string;
31- };
32-
33- type SearchShortcut = {
34- url: string;
35- description: string;
36- };
37-
38- type ResolvedPerformance = {
39- skia_cpu_threads: number;
40- skia_gpu_threads: number;
41- web_process_memory_mb: number;
42- network_process_memory_mb: number;
43- webview_pool_prewarm: number;
44- conservative_threshold: number;
45- strict_threshold: number;
46- };
47-
48- type HardwareInfo = {
49- cpu_cores: number;
50- cpu_threads: number;
51- total_ram_mb: number;
52- gpu_vendor: string;
53- gpu_name: string;
54- vram_mb: number;
55- };
56-
57- type CustomPerformance = {
58- skia_cpu_threads: number;
59- skia_gpu_threads: number;
60- web_process_memory_mb: number;
61- network_process_memory_mb: number;
62- webview_pool_prewarm: number;
63- };
64-
65- type PerformanceConfig = {
66- profile: string;
67- custom: CustomPerformance;
68- resolved: ResolvedPerformance;
69- hardware: HardwareInfo;
70- };
71-
72- type ConfigDTO = {
73- appearance: {
74- sans_font: string;
75- serif_font: string;
76- monospace_font: string;
77- default_font_size: number;
78- color_scheme: string;
79- light_palette: ColorPalette;
80- dark_palette: ColorPalette;
81- };
82- performance: PerformanceConfig;
83- default_ui_scale: number;
84- default_search_engine: string;
85- search_shortcuts: Record<string, SearchShortcut>;
86- engine_type: string;
87- };
88-
89- const PERFORMANCE_PROFILES = [
90- { value: "default", label: "Default", description: "No tuning, uses WebKit defaults. Recommended for most users." },
91- { value: "lite", label: "Lite", description: "Reduced resource usage for low-RAM systems (< 4GB) or battery saving." },
92- { value: "max", label: "Max", description: "Maximum responsiveness, scales based on detected hardware." },
93- { value: "custom", label: "Custom", description: "Manual control via config file. Edit config.toml to set individual values." },
94- ];
95-
96- const PALETTE_FIELDS: Array<{ key: keyof ColorPalette; label: string }> = [
97- { key: "background", label: "Background" },
98- { key: "surface", label: "Surface" },
99- { key: "surface_variant", label: "Surface Variant" },
100- { key: "text", label: "Text" },
101- { key: "muted", label: "Muted" },
102- { key: "accent", label: "Accent" },
103- { key: "border", label: "Border" },
104- ];
105-
106- function isHexColor(value: string): boolean {
107- return /^#[0-9a-fA-F]{6}$/.test(value);
108- }
109-
110- // UI state
111- let loading = $state(true);
112- let loadError = $state<string | null>(null);
113- let config = $state<ConfigDTO | null>(null);
114- let saving = $state(false);
115- let saveSuccess = $state(false);
116- let saveError = $state<string | null>(null);
117- let showRestartWarning = $state(false);
118- let themeEvents = $state(0);
119- let activeTab = $state("appearance");
120-
121- function getResolvedDarkPreference(fallback?: boolean): boolean {
122- if (typeof fallback === "boolean") {
123- return fallback;
124- }
125- if (typeof window.__dumber_cef_prefers_dark === "boolean") {
126- return window.__dumber_cef_prefers_dark;
127- }
128- if (typeof window.__dumber_gtk_prefers_dark === "boolean") {
129- return window.__dumber_gtk_prefers_dark;
130- }
131- return document.documentElement.classList.contains("dark");
132- }
133-
134- function applyResolvedTheme(prefersDark: boolean): void {
135- const root = document.documentElement;
136- root.classList.toggle("dark", prefersDark);
137- root.classList.toggle("light", !prefersDark);
138- root.style.colorScheme = prefersDark ? "dark" : "light";
139- window.__dumber_cef_prefers_dark = prefersDark;
140- window.__dumber_gtk_prefers_dark = prefersDark;
141- }
142-
143- function syncModeWatcherPreference(colorScheme: string | undefined, prefersDark?: boolean): void {
144- switch (colorScheme) {
145- case "prefer-dark":
146- setMode("dark");
147- applyResolvedTheme(true);
148- break;
149- case "prefer-light":
150- setMode("light");
151- applyResolvedTheme(false);
152- break;
153- default:
154- resetMode();
155- applyResolvedTheme(getResolvedDarkPreference(prefersDark));
156- break;
157- }
158- }
159-
160- function isPerformanceSupported(cfg: ConfigDTO | null): boolean {
161- return cfg != null && cfg.engine_type !== "cef";
162- }
163-
164- // Load config from API
165- async function loadConfig() {
166- loading = true;
167- loadError = null;
168- try {
169- const response = await fetch("/api/config");
170- if (!response.ok) throw new Error("Failed to fetch config");
171- config = (await response.json()) as ConfigDTO;
172- syncModeWatcherPreference(config.appearance.color_scheme);
173- if (!isPerformanceSupported(config) && activeTab === "performance") {
174- activeTab = "appearance";
175- }
176- } catch (e: unknown) {
177- loadError = getErrorMessage(e);
178- console.error("[config] load failed", e);
179- } finally {
180- loading = false;
181- }
182- }
183-
184- // Reload config (used after save to get normalized values)
185- function reloadConfig() {
186- loadConfig();
187- }
188-
189- let resetDialogOpen = $state(false);
190-
191- async function doResetToDefaults() {
192- saveSuccess = false;
193- try {
194- const response = await fetch("/api/config/default");
195- if (!response.ok) throw new Error("Failed to fetch default config");
196- config = (await response.json()) as ConfigDTO;
197- syncModeWatcherPreference(config.appearance.color_scheme);
198- if (!isPerformanceSupported(config) && activeTab === "performance") {
199- activeTab = "appearance";
200- }
201- } catch (e: unknown) {
202- console.error("[config] reset defaults failed", e);
203- }
204- resetDialogOpen = false;
205- }
206-
207- function getWebKitBridge(): { postMessage: (msg: unknown) => void } | null {
208- const bridge = window.webkit?.messageHandlers?.dumber ?? window.dumber;
209- if (bridge && typeof bridge.postMessage === "function") {
210- return bridge;
211- }
212- return null;
213- }
214-
215- function getWebViewId(): number {
216- return window.__dumber_webview_id || 0;
217- }
218-
219- async function saveConfig() {
220- saving = true;
221- saveSuccess = false;
222- saveError = null;
223-
224- try {
225- const bridge = getWebKitBridge();
226- const webviewId = getWebViewId();
227-
228- console.debug("[config] save click", {
229- hasBridge: Boolean(bridge),
230- webviewId,
231- hasConfig: Boolean(config),
232- });
233-
234- if (!bridge) {
235- throw new Error("Dumber bridge not available (not running inside Dumber)");
236- }
237- if (!config) {
238- throw new Error("Config not loaded");
239- }
240-
241- let timeout: number | null = window.setTimeout(() => {
242- if (saving) {
243- console.warn("[config] save timed out (no callback)");
244- saveError = "Save timed out (no response from native handler)";
245- saving = false;
246- }
247- }, 8000);
248-
249- const clearSaveTimeout = () => {
250- if (timeout != null) {
251- window.clearTimeout(timeout);
252- timeout = null;
253- }
254- };
255-
256- window.__dumber_config_saved = (resp?: unknown) => {
257- clearSaveTimeout();
258- console.debug("[config] save success", resp);
259- saveSuccess = true;
260- saving = false;
261-
262- // Show restart warning if performance tab was active
263- if (activeTab === "performance" && isPerformanceSupported(config)) {
264- showRestartWarning = true;
265- }
266-
267- syncModeWatcherPreference(config?.appearance.color_scheme);
268-
269- // Refresh config from backend (in case watcher normalized values)
270- reloadConfig();
271-
272- setTimeout(() => {
273- saveSuccess = false;
274- }, 3000);
275- };
276- window.__dumber_config_error = (msg: unknown) => {
277- clearSaveTimeout();
278- console.error("[config] save error", msg);
279- saveError = typeof msg === "string" ? msg : "Failed to save config";
280- saving = false;
281- };
282-
283- const payload = $state.snapshot(config);
284-
285- try {
286- bridge.postMessage({
287- type: "save_config",
288- webview_id: webviewId,
289- payload,
290- });
291- } catch (postErr) {
292- console.error("[config] postMessage threw", postErr);
293- throw postErr;
294- }
295- } catch (e: unknown) {
296- console.error("[config] save exception", e);
297- saveError = getErrorMessage(e);
298- saving = false;
299- }
300- }
301-
302- function updateShortcuts(nextShortcuts: Record<string, SearchShortcut>) {
303- if (!config) return;
304- config.search_shortcuts = nextShortcuts;
305- }
306-
307- onMount(() => {
308- console.debug("[config] mount", {
309- webviewId: window.__dumber_webview_id,
310- hasBridge: Boolean(getWebKitBridge()),
311- });
312-
313- const onThemeChanged = (e: Event) => {
314- themeEvents += 1;
315- const detail = (e as CustomEvent<{ prefersDark?: boolean }>).detail;
316- syncModeWatcherPreference(config?.appearance.color_scheme, detail?.prefersDark);
317- console.debug("[config] theme changed", detail);
318- };
319- window.addEventListener("dumber:theme-changed", onThemeChanged);
320-
321- // Load config on mount
322- loadConfig();
323-
324- return () => {
325- window.removeEventListener("dumber:theme-changed", onThemeChanged);
326- };
327- });
328-</script>
329-
330-<ModeWatcher />
331-<ConfigShell>
332- <div class="flex w-full flex-col gap-6 px-6 py-8">
333- {#if loading}
334- <!-- Loading state with spinner -->
335- <div class="flex flex-col items-center justify-center gap-4 py-16">
336- <Spinner class="size-8" />
337- <div class="text-xs font-semibold uppercase tracking-[0.4em] text-muted-foreground">
338- Loading configuration…
339- </div>
340- </div>
341- {:else if loadError}
342- <!-- Error state -->
343- <div class="flex flex-col items-center justify-center gap-4 py-16">
344- <Card.Root class="max-w-md">
345- <Card.Header>
346- <Card.Title class="text-destructive">Failed to load config</Card.Title>
347- <Card.Description>{loadError}</Card.Description>
348- </Card.Header>
349- <Card.Content>
350- <Button variant="outline" onclick={reloadConfig}>Try again</Button>
351- </Card.Content>
352- </Card.Root>
353- </div>
354- {:else if config}
355- <Tabs.Root bind:value={activeTab} class="w-full">
356- <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
357- <div class="text-xs font-semibold uppercase tracking-[0.3em] text-muted-foreground">
358- Config Workspace
359- </div>
360- <Tabs.List class="bg-muted/60">
361- <Tabs.Trigger value="appearance">Appearance</Tabs.Trigger>
362- <Tabs.Trigger value="search">Search</Tabs.Trigger>
363- <Tabs.Trigger value="keybindings">Keybindings</Tabs.Trigger>
364- <Tabs.Trigger
365- value="performance"
366- disabled={!isPerformanceSupported(config)}
367- title={!isPerformanceSupported(config) ? "Performance tuning is only available with the WebKit engine" : undefined}
368- >
369- Performance
370- </Tabs.Trigger>
371- </Tabs.List>
372- </div>
373-
374- {#if !isPerformanceSupported(config)}
375- <div class="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
376- Performance settings are disabled while using the CEF engine because these options are specific to WebKitGTK.
377- </div>
378- {/if}
379-
380- <Tabs.Content value="appearance">
381- <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
382- <Card.Header>
383- <Card.Title>Appearance</Card.Title>
384- <Card.Description>Fonts, palette, and theme preferences.</Card.Description>
385- </Card.Header>
386- <Card.Content class="space-y-8">
387- <div class="grid gap-6 md:grid-cols-2">
388- <div class="space-y-2">
389- <Label for="sans_font">Sans-Serif Font</Label>
390- <Input id="sans_font" bind:value={config.appearance.sans_font} />
391- </div>
392- <div class="space-y-2">
393- <Label for="serif_font">Serif Font</Label>
394- <Input id="serif_font" bind:value={config.appearance.serif_font} />
395- </div>
396- <div class="space-y-2">
397- <Label for="monospace_font">Monospace Font</Label>
398- <Input id="monospace_font" bind:value={config.appearance.monospace_font} />
399- </div>
400- <div class="space-y-2">
401- <Label for="font_size">Default Font Size</Label>
402- <Input id="font_size" type="number" bind:value={config.appearance.default_font_size} />
403- </div>
404- <div class="space-y-2">
405- <Label for="color_scheme">Color Scheme</Label>
406- <select
407- id="color_scheme"
408- bind:value={config.appearance.color_scheme}
409- class="flex h-10 w-full border border-border bg-background px-3 py-2 text-sm text-foreground ring-offset-background transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
410- >
411- <option value="default">Follow System</option>
412- <option value="prefer-dark">Always Dark</option>
413- <option value="prefer-light">Always Light</option>
414- </select>
415- </div>
416- <div class="space-y-2">
417- <Label for="ui_scale">UI Scale</Label>
418- <Input
419- id="ui_scale"
420- type="number"
421- step="0.1"
422- bind:value={config.default_ui_scale}
423- />
424- </div>
425- </div>
426-
427- <div class="space-y-6">
428- <div class="space-y-3">
429- <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
430- Theme: Light
431- </div>
432- <div class="grid gap-4 md:grid-cols-2">
433- {#each PALETTE_FIELDS as field (field.key)}
434- <div class="space-y-2">
435- <Label for={`light_${field.key}`}>{field.label}</Label>
436- <div class="flex items-center gap-3">
437- <ColorPicker
438- id={`light_${field.key}`}
439- value={config.appearance.light_palette[field.key]}
440- onValueChange={(v) => {
441- config!.appearance.light_palette[field.key] = v;
442- }}
443- />
444- <Input bind:value={config.appearance.light_palette[field.key]} />
445- </div>
446- {#if !isHexColor(config.appearance.light_palette[field.key])}
447- <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
448- {/if}
449- </div>
450- {/each}
451- </div>
452- </div>
453-
454- <div class="space-y-3">
455- <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
456- Theme: Dark
457- </div>
458- <div class="grid gap-4 md:grid-cols-2">
459- {#each PALETTE_FIELDS as field (field.key)}
460- <div class="space-y-2">
461- <Label for={`dark_${field.key}`}>{field.label}</Label>
462- <div class="flex items-center gap-3">
463- <ColorPicker
464- id={`dark_${field.key}`}
465- value={config.appearance.dark_palette[field.key]}
466- onValueChange={(v) => {
467- config!.appearance.dark_palette[field.key] = v;
468- }}
469- />
470- <Input bind:value={config.appearance.dark_palette[field.key]} />
471- </div>
472- {#if !isHexColor(config.appearance.dark_palette[field.key])}
473- <p class="text-xs text-muted-foreground">Expected hex color like #RRGGBB</p>
474- {/if}
475- </div>
476- {/each}
477- </div>
478- </div>
479- </div>
480- </Card.Content>
481- </Card.Root>
482- </Tabs.Content>
483-
484- <Tabs.Content value="search">
485- <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
486- <Card.Header>
487- <Card.Title>Search</Card.Title>
488- <Card.Description>Default engine and shortcuts.</Card.Description>
489- </Card.Header>
490- <Card.Content class="space-y-8">
491- <div class="space-y-2">
492- <Label for="search_engine">Default Search Engine (use %s for query)</Label>
493- <Input id="search_engine" bind:value={config.default_search_engine} />
494- </div>
495-
496- <div class="space-y-3">
497- <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
498- Search Shortcuts
499- </div>
500- <p class="text-sm text-muted-foreground">Map prefixes like g to URL templates.</p>
501- <ShortcutsTable shortcuts={config.search_shortcuts} onUpdate={updateShortcuts} />
502- </div>
503- </Card.Content>
504- </Card.Root>
505- </Tabs.Content>
506-
507- <Tabs.Content value="keybindings">
508- <KeybindingsTab />
509- </Tabs.Content>
510-
511- <Tabs.Content value="performance">
512- <Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
513- <Card.Header>
514- <Card.Title>Performance Profile</Card.Title>
515- <Card.Description>
516- WebKitGTK rendering and memory tuning presets.
517- </Card.Description>
518- </Card.Header>
519- <Card.Content class="space-y-6">
520- {#if !isPerformanceSupported(config)}
521- <div class="rounded-md border border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
522- This section is unavailable under the CEF engine.
523- </div>
524- {:else}
525- <!-- Detected Hardware Card -->
526- {#if config.performance?.hardware}
527- {@const hw = config.performance.hardware}
528- <div class="rounded-md border border-border bg-muted/30 px-4 py-3">
529- <div class="mb-2 text-xs font-semibold uppercase tracking-[0.15em] text-muted-foreground">
530- Detected Hardware
531- </div>
532- <div class="flex flex-wrap gap-x-6 gap-y-1 text-sm">
533- <div><span class="text-muted-foreground">CPU:</span> <span class="font-mono">{hw.cpu_cores}c/{hw.cpu_threads}t</span></div>
534- <div><span class="text-muted-foreground">RAM:</span> <span class="font-mono">{hw.total_ram_mb > 0 ? (hw.total_ram_mb / 1024).toFixed(0) + ' GB' : '?'}</span></div>
535- <div><span class="text-muted-foreground">GPU:</span> <span class="font-mono capitalize">{hw.gpu_vendor || 'Unknown'}</span></div>
536- <div><span class="text-muted-foreground">VRAM:</span> <span class="font-mono">{hw.vram_mb > 0 ? (hw.vram_mb / 1024).toFixed(0) + ' GB' : (hw.gpu_vendor === 'intel' ? 'Shared' : '?')}</span></div>
537- </div>
538- </div>
539- {/if}
540-
541- <!-- Experimental feature warning -->
542- <div class="flex items-start gap-3 rounded-md border border-info/30 bg-info/10 px-4 py-3">
543- <FlaskConical class="mt-0.5 size-4 shrink-0 text-info" />
544- <div class="space-y-1">
545- <p class="text-sm font-medium text-info">Experimental Feature</p>
546- <p class="text-xs text-muted-foreground">
547- Performance profiles are experimental. Results may vary depending on your hardware and WebKitGTK version.
548- </p>
549- </div>
550- </div>
551-
552- <!-- Profile selector -->
553- {#if config.performance}
554- <div class="space-y-4">
555- {#each PERFORMANCE_PROFILES as profile (profile.value)}
556- <label
557- class="flex cursor-pointer items-start gap-4 rounded-md border p-4 transition-colors hover:bg-muted/50 {config.performance.profile === profile.value ? 'border-primary bg-primary/5' : ''}"
558- >
559- <input
560- type="radio"
561- name="performance_profile"
562- value={profile.value}
563- checked={config.performance.profile === profile.value}
564- onchange={() => config!.performance.profile = profile.value}
565- class="mt-1 accent-primary"
566- />
567- <div class="space-y-1">
568- <div class="font-medium">{profile.label}</div>
569- <div class="text-sm text-muted-foreground">{profile.description}</div>
570- </div>
571- </label>
572- {/each}
573- </div>
574- {:else}
575- <div class="text-sm text-muted-foreground">
576- Performance settings not available. This may be due to a configuration error.
577- </div>
578- {/if}
579-
580- <!-- Custom profile fields -->
581- {#if config.performance?.profile === "custom"}
582- <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4">
583- <div class="text-sm font-semibold uppercase tracking-[0.15em] text-muted-foreground">
584- Custom Settings
585- </div>
586- <div class="grid gap-4 md:grid-cols-2">
587- <div class="space-y-2">
588- <Label for="skia_cpu_threads">Skia CPU Threads</Label>
589- <Input
590- id="skia_cpu_threads"
591- type="number"
592- min="0"
593- max="8"
594- bind:value={config.performance.custom.skia_cpu_threads}
595- />
596- <p class="text-xs text-muted-foreground">0 = unset (WebKit default). Max 8.</p>
597- </div>
598- <div class="space-y-2">
599- <Label for="skia_gpu_threads">Skia GPU Threads</Label>
600- <Input
601- id="skia_gpu_threads"
602- type="number"
603- min="-1"
604- bind:value={config.performance.custom.skia_gpu_threads}
605- />
606- <p class="text-xs text-muted-foreground">-1 = unset, 0 = disable GPU tiles.</p>
607- </div>
608- <div class="space-y-2">
609- <Label for="web_process_memory">Web Process Memory (MB)</Label>
610- <Input
611- id="web_process_memory"
612- type="number"
613- min="0"
614- step="256"
615- bind:value={config.performance.custom.web_process_memory_mb}
616- />
617- <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p>
618- </div>
619- <div class="space-y-2">
620- <Label for="network_process_memory">Network Process Memory (MB)</Label>
621- <Input
622- id="network_process_memory"
623- type="number"
624- min="0"
625- step="128"
626- bind:value={config.performance.custom.network_process_memory_mb}
627- />
628- <p class="text-xs text-muted-foreground">0 = unset (WebKit default).</p>
629- </div>
630- <div class="space-y-2">
631- <Label for="webview_pool_prewarm">WebView Pool Prewarm</Label>
632- <Input
633- id="webview_pool_prewarm"
634- type="number"
635- min="0"
636- max="20"
637- bind:value={config.performance.custom.webview_pool_prewarm}
638- />
639- <p class="text-xs text-muted-foreground">Pre-created WebViews at startup.</p>
640- </div>
641- </div>
642- </div>
643- {/if}
644-
645- <!-- Resolved values display -->
646- {#if config.performance?.resolved}
647- <div class="space-y-3">
648- <div class="text-sm font-semibold uppercase tracking-[0.2em] text-muted-foreground">
649- Resolved Settings (applied on restart)
650- </div>
651- <div class="space-y-4 rounded-md border border-border bg-muted/20 p-4 text-sm">
652- <div class="space-y-1">
653- <div class="flex justify-between">
654- <span class="font-medium">Skia CPU Threads</span>
655- <span class="font-mono">{config.performance.resolved.skia_cpu_threads === 0 ? 'unset' : config.performance.resolved.skia_cpu_threads}</span>
656- </div>
657- <p class="text-xs text-muted-foreground">Parallel threads for CPU-based 2D rendering. More threads = faster paint operations.</p>
658- </div>
659- <div class="space-y-1">
660- <div class="flex justify-between">
661- <span class="font-medium">Skia GPU Threads</span>
662- <span class="font-mono">{config.performance.resolved.skia_gpu_threads === -1 ? 'unset' : config.performance.resolved.skia_gpu_threads}</span>
663- </div>
664- <p class="text-xs text-muted-foreground">Threads for GPU texture uploads and shader compilation.</p>
665- </div>
666- <div class="space-y-1">
667- <div class="flex justify-between">
668- <span class="font-medium">Web Process Memory</span>
669- <span class="font-mono">{config.performance.resolved.web_process_memory_mb === 0 ? 'unset' : config.performance.resolved.web_process_memory_mb + ' MB'}</span>
670- </div>
671- <p class="text-xs text-muted-foreground">Memory limit for web content processes (JavaScript, DOM, rendering).</p>
672- </div>
673- <div class="space-y-1">
674- <div class="flex justify-between">
675- <span class="font-medium">Network Process Memory</span>
676- <span class="font-mono">{config.performance.resolved.network_process_memory_mb === 0 ? 'unset' : config.performance.resolved.network_process_memory_mb + ' MB'}</span>
677- </div>
678- <p class="text-xs text-muted-foreground">Memory limit for network operations (HTTP cache, connections).</p>
679- </div>
680- <div class="space-y-1">
681- <div class="flex justify-between">
682- <span class="font-medium">WebView Pool Prewarm</span>
683- <span class="font-mono">{config.performance.resolved.webview_pool_prewarm}</span>
684- </div>
685- <p class="text-xs text-muted-foreground">Pre-created WebViews at startup for faster new tab/pane creation.</p>
686- </div>
687- </div>
688- </div>
689- {/if}
690- {/if}
691- </Card.Content>
692- </Card.Root>
693- </Tabs.Content>
694- </Tabs.Root>
695-
696- <div class="flex flex-wrap items-center justify-between gap-4 border-t border-border px-6 py-4">
697- <div class="flex items-center gap-3 text-xs uppercase tracking-[0.3em] text-muted-foreground">
698- {#if saveSuccess}
699- <span class="text-primary">Saved</span>
700- {:else}
701- <span>Ready</span>
702- {/if}
703- {#if themeEvents > 0}
704- <span>Theme x{themeEvents}</span>
705- {/if}
706- </div>
707- {#if activeTab !== "keybindings"}
708- <div class="flex items-center gap-3">
709- <AlertDialog.Root bind:open={resetDialogOpen}>
710- <AlertDialog.Trigger disabled={saving}>
711- {#snippet child({ props })}
712- <Button variant="outline" {...props} type="button">
713- Reset Defaults
714- </Button>
715- {/snippet}
716- </AlertDialog.Trigger>
717- <AlertDialog.Content>
718- <AlertDialog.Header>
719- <AlertDialog.Title>Reset to defaults?</AlertDialog.Title>
720- <AlertDialog.Description>
721- This will reset all settings on this page to their default values.
722- You will still need to click Save to persist the changes.
723- </AlertDialog.Description>
724- </AlertDialog.Header>
725- <AlertDialog.Footer>
726- <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
727- <AlertDialog.Action onclick={doResetToDefaults}>
728- Reset Defaults
729- </AlertDialog.Action>
730- </AlertDialog.Footer>
731- </AlertDialog.Content>
732- </AlertDialog.Root>
733- <Button onclick={saveConfig} disabled={saving} type="button">
734- {saving ? "Saving…" : "Save"}
735- </Button>
736- </div>
737- {/if}
738- </div>
739-
740- <!-- Save error display -->
741- {#if saveError}
742- <div class="border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
743- {saveError}
744- </div>
745- {/if}
746-
747- <!-- Restart warning after saving performance settings -->
748- {#if showRestartWarning}
749- <div class="flex items-start gap-3 border border-warning/30 bg-warning/10 px-4 py-3">
750- <RefreshCw class="mt-0.5 size-4 shrink-0 text-warning" />
751- <div class="space-y-1">
752- <p class="text-sm font-medium text-warning">Restart Required</p>
753- <p class="text-xs text-muted-foreground">
754- Performance settings are applied at browser startup. Restart Dumber for changes to take effect.
755- </p>
756- </div>
757- </div>
758- {/if}
759- {/if}
760- </div>
761-</ConfigShell>
D webui/src/pages/ErrorPage.svelte +0 -213
@@ -1,213 +0,0 @@
1-<script lang="ts">
2- import { onMount } from 'svelte';
3- import { ModeWatcher } from 'mode-watcher';
4- import { Button } from '$lib/components/ui/button';
5- import * as Card from '$lib/components/ui/card';
6-
7- type ErrorConfig = {
8- code: number;
9- title: string;
10- message: string;
11- icon: string;
12- actions: string[];
13- };
14-
15- const defaultConfig: ErrorConfig = {
16- code: 404,
17- title: 'Page Not Found',
18- message: 'The page you are looking for does not exist.',
19- icon: 'warning',
20- actions: ['back'],
21- };
22-
23- // Error configurations
24- const errorConfigs: Record<string, ErrorConfig> = {
25- blocked: { code: 0, title: 'Page Blocked', message: 'Content blocked by filter', icon: 'shield', actions: ['whitelist', 'bypass', 'back'] },
26- '404': defaultConfig,
27- '500': { code: 500, title: 'Server Error', message: 'Something went wrong on the server.', icon: 'error', actions: ['retry', 'back'] },
28- '502': { code: 502, title: 'Bad Gateway', message: 'The server received an invalid response.', icon: 'error', actions: ['retry', 'back'] },
29- '503': { code: 503, title: 'Service Unavailable', message: 'The server is temporarily unavailable.', icon: 'error', actions: ['retry', 'back'] },
30- offline: { code: 0, title: 'You Are Offline', message: 'Check your internet connection.', icon: 'offline', actions: ['retry', 'back'] },
31- ssl: { code: 0, title: 'Connection Not Secure', message: 'The security certificate is invalid.', icon: 'lock', actions: ['bypass', 'back'] },
32- timeout: { code: 0, title: 'Connection Timed Out', message: 'The server took too long to respond.', icon: 'warning', actions: ['retry', 'back'] },
33- };
34-
35- let config = $state<ErrorConfig>(defaultConfig);
36- let targetUrl = $state('');
37- let targetDomain = $state('');
38- let customMessage = $state('');
39- let isLoading = $state(false);
40- let actionError = $state('');
41-
42- onMount(() => {
43- const params = new URLSearchParams(window.location.search);
44- const typeParam = params.get('type') || params.get('code') || '404';
45-
46- const selectedConfig = errorConfigs[typeParam];
47- config = selectedConfig ?? {
48- code: parseInt(typeParam) || 0,
49- title: `Error ${typeParam}`,
50- message: 'An unexpected error occurred.',
51- icon: 'error',
52- actions: ['retry', 'back'],
53- };
54-
55- targetUrl = params.get('url') || '';
56- customMessage = params.get('reason') || params.get('message') || '';
57-
58- if (targetUrl) {
59- try {
60- targetDomain = new URL(targetUrl).hostname;
61- } catch {
62- targetDomain = targetUrl;
63- }
64- }
65- });
66-
67- function sendMessage(type: string, payload: Record<string, unknown>): Promise<unknown> {
68- return new Promise((resolve, reject) => {
69- const requestId = `error_${Date.now()}_${Math.random().toString(36).slice(2)}`;
70- const handler = (event: MessageEvent) => {
71- if (event.data?.requestId === requestId) {
72- window.removeEventListener('message', handler);
73- if (event.data.success) {
74- resolve(event.data.data);
75- } else {
76- reject(new Error(event.data.error || 'Unknown error'));
77- }
78- }
79- };
80- window.addEventListener('message', handler);
81- if (window.webkit?.messageHandlers?.dumber) {
82- window.webkit.messageHandlers.dumber.postMessage(JSON.stringify({ type, requestId, payload }));
83- } else {
84- reject(new Error('WebKit message handler not available'));
85- }
86- setTimeout(() => { window.removeEventListener('message', handler); reject(new Error('Timeout')); }, 10000);
87- });
88- }
89-
90- async function addToWhitelist() {
91- if (!targetDomain) return;
92- isLoading = true;
93- actionError = '';
94- try {
95- await sendMessage('addToWhitelist', { domain: targetDomain });
96- window.location.href = targetUrl;
97- } catch (e) {
98- actionError = e instanceof Error ? e.message : 'Failed';
99- isLoading = false;
100- }
101- }
102-
103- async function bypassOnce() {
104- if (!targetUrl) return;
105- isLoading = true;
106- actionError = '';
107- try {
108- await sendMessage('bypassOnce', { url: targetUrl });
109- window.location.href = targetUrl;
110- } catch (e) {
111- actionError = e instanceof Error ? e.message : 'Failed';
112- isLoading = false;
113- }
114- }
115-
116- function retry() {
117- if (targetUrl) {
118- window.location.href = targetUrl;
119- } else {
120- window.location.reload();
121- }
122- }
123-
124- function goBack() {
125- window.history.back();
126- }
127-
128- const icons: Record<string, string> = {
129- shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/>',
130- warning: '<path d="M12 9v4"/><path d="M12 17h.01"/><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>',
131- error: '<circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6"/><path d="M9 9l6 6"/>',
132- offline: '<line x1="1" y1="1" x2="23" y2="23"/><path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/><path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/><path d="M10.71 5.05A16 16 0 0 1 22.58 9"/><path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/><path d="M8.53 16.11a6 6 0 0 1 6.95 0"/><line x1="12" y1="20" x2="12.01" y2="20"/>',
133- lock: '<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/>',
134- };
135-
136- const iconColors: Record<string, string> = {
137- shield: 'text-primary',
138- warning: 'text-yellow-500',
139- error: 'text-destructive',
140- offline: 'text-muted-foreground',
141- lock: 'text-yellow-500',
142- };
143-</script>
144-
145-<svelte:head>
146- <title>{config.title}</title>
147-</svelte:head>
148-
149-<ModeWatcher />
150-<div class="min-h-screen flex items-center justify-center p-8 bg-background">
151- <Card.Root class="w-full max-w-md">
152- <Card.Header class="text-center">
153- <div class="flex justify-center mb-4">
154- <svg class="w-16 h-16 {iconColors[config.icon] || 'text-muted-foreground'}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
155- <!-- eslint-disable-next-line svelte/no-at-html-tags -- trusted static SVG paths -->
156- {@html icons[config.icon] || icons.error}
157- </svg>
158- </div>
159- {#if config.code > 0}
160- <div class="text-6xl font-bold text-muted-foreground/30 mb-2">{config.code}</div>
161- {/if}
162- <Card.Title class="text-xl">{config.title}</Card.Title>
163- <Card.Description>{customMessage || config.message}</Card.Description>
164- </Card.Header>
165-
166- <Card.Content class="space-y-4">
167- {#if targetUrl}
168- <div class="bg-muted p-3 rounded-md">
169- <p class="text-xs text-muted-foreground uppercase tracking-wide mb-1">URL</p>
170- <code class="text-sm break-all">{targetUrl}</code>
171- </div>
172- {/if}
173-
174- {#if actionError}
175- <div class="bg-destructive/10 border border-destructive/30 text-destructive p-3 rounded-md text-sm">
176- {actionError}
177- </div>
178- {/if}
179- </Card.Content>
180-
181- <Card.Footer class="flex flex-col gap-3">
182- {#if config.actions.includes('whitelist') && targetDomain}
183- <Button class="w-full" onclick={addToWhitelist} disabled={isLoading}>
184- {isLoading ? 'Adding...' : 'Add to Whitelist'}
185- </Button>
186- {/if}
187-
188- {#if config.actions.includes('bypass') && targetUrl}
189- <Button variant="outline" class="w-full" onclick={bypassOnce} disabled={isLoading}>
190- Proceed Anyway
191- </Button>
192- {/if}
193-
194- {#if config.actions.includes('retry')}
195- <Button class="w-full" onclick={retry} disabled={isLoading}>
196- Try Again
197- </Button>
198- {/if}
199-
200- {#if config.actions.includes('back')}
201- <Button variant="ghost" class="w-full" onclick={goBack} disabled={isLoading}>
202- Go Back
203- </Button>
204- {/if}
205-
206- {#if config.actions.includes('whitelist') && targetDomain}
207- <p class="text-xs text-muted-foreground text-center">
208- Adding <strong class="text-foreground">{targetDomain}</strong> to whitelist will disable content filtering.
209- </p>
210- {/if}
211- </Card.Footer>
212- </Card.Root>
213-</div>
D webui/src/pages/Homepage.svelte +0 -17
@@ -1,17 +0,0 @@
1-<script lang="ts">
2- import { ModeWatcher } from 'mode-watcher';
3- import { HomepageShell } from './homepage/shell';
4- import { HistoryPanel, FavoritesPanel, AnalyticsPanel } from './homepage/panels';
5- import { homepageState } from './homepage/state.svelte';
6-</script>
7-
8-<ModeWatcher />
9-<HomepageShell>
10- {#if homepageState.activePanel === 'history'}
11- <HistoryPanel />
12- {:else if homepageState.activePanel === 'favorites'}
13- <FavoritesPanel />
14- {:else if homepageState.activePanel === 'analytics'}
15- <AnalyticsPanel />
16- {/if}
17-</HomepageShell>
D webui/src/pages/config.ts +0 -10
@@ -1,10 +0,0 @@
1-import { mount } from "svelte";
2-import ConfigPage from "./ConfigPage.svelte";
3-import "../styles/app.css";
4-
5-// Mount the config page
6-const app = mount(ConfigPage, {
7- target: document.body,
8-});
9-
10-export default app;
D webui/src/pages/config/ConfigShell.svelte +0 -227 collapsed to keep the page light
D webui/src/pages/config/KeyCaptureModal.svelte +0 -184 collapsed to keep the page light
D webui/src/pages/config/KeybindingGroup.svelte +0 -95 collapsed to keep the page light
D webui/src/pages/config/KeybindingsTab.svelte +0 -340
@@ -1,340 +0,0 @@
1-<script lang="ts">
2- import { onMount } from "svelte";
3- import { Button } from "$lib/components/ui/button";
4- import { Spinner } from "$lib/components/ui/spinner";
5- import * as Card from "$lib/components/ui/card";
6- import * as AlertDialog from "$lib/components/ui/alert-dialog";
7- import { RefreshCw, RotateCcw, AlertTriangle, CheckCircle } from "@lucide/svelte";
8- import { postMessage, getWebKitBridge, getWebViewId } from "$lib/bridge";
9- import KeybindingGroup from "./KeybindingGroup.svelte";
10- import KeyCaptureModal from "./KeyCaptureModal.svelte";
11-
12- type KeybindingEntry = {
13- action: string;
14- description: string;
15- keys: string[];
16- default_keys: string[];
17- is_custom: boolean;
18- };
19-
20- type KeybindingGroupData = {
21- mode: string;
22- display_name: string;
23- bindings: KeybindingEntry[];
24- activation?: string;
25- };
26-
27- type KeybindingsConfig = {
28- groups: KeybindingGroupData[];
29- };
30-
31- type KeybindingConflict = {
32- conflicting_action: string;
33- conflicting_mode: string;
34- key: string;
35- };
36-
37- let loading = $state(true);
38- let error = $state<string | null>(null);
39- let keybindings = $state<KeybindingsConfig | null>(null);
40- let editingBinding = $state<{ mode: string; action: string; entry: KeybindingEntry } | null>(null);
41- let resetAllDialogOpen = $state(false);
42- let successMessage = $state<string | null>(null);
43- let conflicts = $state<KeybindingConflict[]>([]);
44- let hasPendingReset = $state(false);
45- let savingReset = $state(false);
46-
47- // Type-safe callback setup helper
48- function setupCallback<T>(name: string, handler: (data: T) => void): void {
49- (window as unknown as Record<string, (data: T) => void>)[name] = handler;
50- }
51-
52- async function loadKeybindings() {
53- loading = true;
54- error = null;
55-
56- if (!getWebKitBridge()) {
57- error = "Dumber bridge not available";
58- loading = false;
59- return;
60- }
61-
62- setupCallback<KeybindingsConfig>("__dumber_keybindings_loaded", (data) => {
63- keybindings = data;
64- loading = false;
65- });
66- setupCallback<string>("__dumber_keybindings_error", (msg) => {
67- error = typeof msg === "string" ? msg : "Failed to load keybindings";
68- loading = false;
69- });
70-
71- try {
72- postMessage({
73- type: "get_keybindings",
74- webviewId: getWebViewId(),
75- payload: {},
76- });
77- } catch (err) {
78- error = err instanceof Error ? err.message : "Failed to load keybindings";
79- loading = false;
80- }
81- }
82-
83- function startEdit(mode: string, action: string, entry: KeybindingEntry) {
84- editingBinding = { mode, action, entry };
85- }
86-
87- function showSuccess(message: string) {
88- successMessage = message;
89- setTimeout(() => { successMessage = null; }, 3000);
90- }
91-
92- async function saveBinding(keys: string[]) {
93- if (!editingBinding) return;
94-
95- if (!getWebKitBridge()) {
96- error = "Dumber bridge not available";
97- return;
98- }
99-
100- // Timeout to detect if backend never responds (message silently dropped)
101- const timeoutId = setTimeout(() => {
102- error = "Save timed out - backend did not respond";
103- }, 5000);
104-
105- setupCallback<{ status: string; conflicts?: KeybindingConflict[] }>(
106- "__dumber_keybinding_set",
107- (response) => {
108- clearTimeout(timeoutId);
109- if (!response || response.status !== "success") {
110- error = "Failed to save keybinding: invalid response";
111- return;
112- }
113- conflicts = response.conflicts?.length ? response.conflicts : [];
114- loadKeybindings();
115- editingBinding = null;
116- showSuccess("Keybinding saved successfully");
117- }
118- );
119- setupCallback<string>("__dumber_keybinding_set_error", (msg) => {
120- clearTimeout(timeoutId);
121- error = typeof msg === "string" ? msg : "Failed to save keybinding";
122- });
123-
124- // Create plain payload - extract primitive values explicitly to avoid any proxy issues
125- const mode = String(editingBinding.mode);
126- const action = String(editingBinding.action);
127- const payload = { mode, action, keys: [...keys] };
128-
129- try {
130- postMessage({
131- type: "set_keybinding",
132- webviewId: getWebViewId(),
133- payload,
134- });
135- } catch (err) {
136- clearTimeout(timeoutId);
137- error = err instanceof Error ? err.message : "Failed to save keybinding";
138- }
139- }
140-
141- async function resetBinding(mode: string, action: string) {
142- if (!getWebKitBridge()) return;
143-
144- setupCallback<void>("__dumber_keybinding_reset", () => {
145- conflicts = [];
146- loadKeybindings();
147- showSuccess("Keybinding reset to default");
148- });
149- setupCallback<string>("__dumber_keybinding_reset_error", (msg) => {
150- error = typeof msg === "string" ? msg : "Failed to reset keybinding";
151- });
152-
153- try {
154- postMessage({
155- type: "reset_keybinding",
156- webviewId: getWebViewId(),
157- payload: { mode, action },
158- });
159- } catch (err) {
160- error = err instanceof Error ? err.message : "Failed to reset keybinding";
161- }
162- }
163-
164- // Reset All: update local state to defaults WITHOUT saving
165- function resetAllBindings() {
166- if (!keybindings) return;
167-
168- // Reset each binding's keys to default_keys in local state
169- const resetGroups = keybindings.groups.map(group => ({
170- ...group,
171- bindings: group.bindings.map(binding => ({
172- ...binding,
173- keys: [...binding.default_keys],
174- is_custom: false,
175- })),
176- }));
177-
178- keybindings = { groups: resetGroups };
179- hasPendingReset = true;
180- conflicts = [];
181- resetAllDialogOpen = false;
182- showSuccess("Preview: all keybindings reset to defaults. Click Save to apply.");
183- }
184-
185- // Actually save the reset to backend
186- async function saveResetAll() {
187- if (!getWebKitBridge()) return;
188-
189- savingReset = true;
190-
191- setupCallback<void>("__dumber_keybindings_reset_all", () => {
192- hasPendingReset = false;
193- savingReset = false;
194- loadKeybindings();
195- showSuccess("All keybindings saved successfully");
196- });
197- setupCallback<string>("__dumber_keybindings_reset_all_error", (msg) => {
198- error = typeof msg === "string" ? msg : "Failed to save keybindings";
199- savingReset = false;
200- });
201-
202- try {
203- postMessage({
204- type: "reset_all_keybindings",
205- webviewId: getWebViewId(),
206- payload: {},
207- });
208- } catch (err) {
209- error = err instanceof Error ? err.message : "Failed to save keybindings";
210- savingReset = false;
211- }
212- }
213-
214- // Cancel pending reset and reload from backend
215- function cancelReset() {
216- hasPendingReset = false;
217- loadKeybindings();
218- }
219-
220- onMount(() => {
221- loadKeybindings();
222- });
223-</script>
224-
225-<Card.Root class="rounded-none border-0 bg-transparent py-0 shadow-none">
226- <Card.Header>
227- <div class="flex items-center justify-between">
228- <div>
229- <Card.Title>Keybindings</Card.Title>
230- <Card.Description>
231- Customize keyboard shortcuts for all modes.
232- </Card.Description>
233- </div>
234- <div class="flex gap-2">
235- {#if hasPendingReset}
236- <Button variant="outline" size="sm" onclick={cancelReset} disabled={savingReset}>
237- Cancel
238- </Button>
239- <Button size="sm" onclick={saveResetAll} disabled={savingReset}>
240- {savingReset ? "Saving…" : "Save"}
241- </Button>
242- {:else}
243- <Button variant="outline" size="sm" onclick={loadKeybindings}>
244- <RefreshCw class="mr-1 size-4" />
245- Refresh
246- </Button>
247- <AlertDialog.Root bind:open={resetAllDialogOpen}>
248- <AlertDialog.Trigger>
249- {#snippet child({ props })}
250- <Button variant="outline" size="sm" {...props}>
251- <RotateCcw class="mr-1 size-4" />
252- Reset All
253- </Button>
254- {/snippet}
255- </AlertDialog.Trigger>
256- <AlertDialog.Content>
257- <AlertDialog.Header>
258- <AlertDialog.Title>Reset all keybindings?</AlertDialog.Title>
259- <AlertDialog.Description>
260- This will preview all keybindings with their default values. You'll need to click Save to apply.
261- </AlertDialog.Description>
262- </AlertDialog.Header>
263- <AlertDialog.Footer>
264- <AlertDialog.Cancel>Cancel</AlertDialog.Cancel>
265- <AlertDialog.Action onclick={resetAllBindings}>Reset All</AlertDialog.Action>
266- </AlertDialog.Footer>
267- </AlertDialog.Content>
268- </AlertDialog.Root>
269- {/if}
270- </div>
271- </div>
272- </Card.Header>
273- <Card.Content class="space-y-6">
274- {#if hasPendingReset}
275- <div class="flex items-center gap-2 rounded-md border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-sm text-yellow-600 dark:text-yellow-400">
276- <AlertTriangle class="size-4" />
277- <span>Unsaved changes: all keybindings previewed with defaults. Click <strong>Save</strong> to apply or <strong>Cancel</strong> to discard.</span>
278- </div>
279- {/if}
280- {#if loading}
281- <div class="flex items-center justify-center py-8">
282- <Spinner class="size-6" />
283- </div>
284- {:else if error}
285- <div class="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 px-4 py-3 text-sm text-destructive">
286- <AlertTriangle class="size-4" />
287- {error}
288- </div>
289- {/if}
290- {#if successMessage}
291- <div class="flex items-center gap-2 rounded-md border border-green-500/50 bg-green-500/10 px-4 py-3 text-sm text-green-600 dark:text-green-400">
292- <CheckCircle class="size-4" />
293- {successMessage}
294- </div>
295- {/if}
296- {#if conflicts.length > 0}
297- <div class="rounded-md border border-yellow-500/50 bg-yellow-500/10 px-4 py-3 text-sm">
298- <div class="flex items-center gap-2 text-yellow-600 dark:text-yellow-400">
299- <AlertTriangle class="size-4" />
300- <span class="font-medium">Keybinding conflicts detected</span>
301- </div>
302- <ul class="mt-2 list-disc pl-6 text-muted-foreground">
303- {#each conflicts as conflict (conflict.key + conflict.conflicting_action)}
304- <li>
305- <kbd class="font-mono text-xs">{conflict.key}</kbd> is also bound to
306- <strong>{conflict.conflicting_action}</strong> in <em>{conflict.conflicting_mode}</em> mode
307- </li>
308- {/each}
309- </ul>
310- <button
311- type="button"
312- class="mt-2 text-xs text-muted-foreground hover:text-foreground"
313- onclick={() => conflicts = []}
314- >
315- Dismiss
316- </button>
317- </div>
318- {/if}
319- {#if !loading && !error && keybindings}
320- {#each keybindings.groups as group (group.mode)}
321- <KeybindingGroup
322- {group}
323- onEdit={(action, entry) => startEdit(group.mode, action, entry)}
324- onReset={(action) => resetBinding(group.mode, action)}
325- />
326- {/each}
327- {/if}
328- {#if !loading && !keybindings && !error}
329- <div class="text-center text-muted-foreground py-8">No keybindings available</div>
330- {/if}
331- </Card.Content>
332-</Card.Root>
333-
334-{#if editingBinding}
335- <KeyCaptureModal
336- binding={editingBinding.entry}
337- onSave={saveBinding}
338- onCancel={() => (editingBinding = null)}
339- />
340-{/if}
D webui/src/pages/config/ShortcutsTable.svelte +0 -170 collapsed to keep the page light
D webui/src/pages/error.ts +0 -11
@@ -1,11 +0,0 @@
1-import { mount } from "svelte";
2-import "../styles/app.css";
3-import ErrorPage from "./ErrorPage.svelte";
4-
5-console.log("[dumber] Mounting ErrorPage component");
6-try {
7- const app = mount(ErrorPage, { target: document.body });
8- console.log("[dumber] ErrorPage mounted successfully", app);
9-} catch (error) {
10- console.error("[dumber] Failed to mount ErrorPage:", error);
11-}
D webui/src/pages/homepage.ts +0 -37 collapsed to keep the page light
D webui/src/pages/homepage/analytics/DailyVisitsChart.svelte +0 -124 collapsed to keep the page light
D webui/src/pages/homepage/analytics/HourlyActivityChart.svelte +0 -130 collapsed to keep the page light
D webui/src/pages/homepage/analytics/index.ts +0 -2
@@ -1,2 +0,0 @@
1-export { default as DailyVisitsChart } from './DailyVisitsChart.svelte';
2-export { default as HourlyActivityChart } from './HourlyActivityChart.svelte';
D webui/src/pages/homepage/favorites/FavoriteCard.svelte +0 -280 collapsed to keep the page light
D webui/src/pages/homepage/favorites/FavoriteEditor.svelte +0 -495 collapsed to keep the page light
D webui/src/pages/homepage/favorites/FavoriteGrid.svelte +0 -108 collapsed to keep the page light
D webui/src/pages/homepage/favorites/FolderList.svelte +0 -312 collapsed to keep the page light
D webui/src/pages/homepage/favorites/TagCloud.svelte +0 -292 collapsed to keep the page light
D webui/src/pages/homepage/favorites/index.ts +0 -6
@@ -1,6 +0,0 @@
1-// Favorites Components - Folders, Tags, Grid, Cards, Editor
2-export { default as FavoriteCard } from './FavoriteCard.svelte';
3-export { default as FavoriteGrid } from './FavoriteGrid.svelte';
4-export { default as FolderList } from './FolderList.svelte';
5-export { default as TagCloud } from './TagCloud.svelte';
6-export { default as FavoriteEditor } from './FavoriteEditor.svelte';
D webui/src/pages/homepage/history/HistoryCleanup.svelte +0 -396 collapsed to keep the page light
D webui/src/pages/homepage/history/HistoryFilters.svelte +0 -155 collapsed to keep the page light
D webui/src/pages/homepage/history/HistoryItem.svelte +0 -280 collapsed to keep the page light
D webui/src/pages/homepage/history/HistorySearch.svelte +0 -179 collapsed to keep the page light
D webui/src/pages/homepage/history/HistoryTimeline.svelte +0 -223 collapsed to keep the page light
D webui/src/pages/homepage/history/index.ts +0 -6 collapsed to keep the page light
D webui/src/pages/homepage/keyboard.ts +0 -744 collapsed to keep the page light
D webui/src/pages/homepage/messaging.ts +0 -546 collapsed to keep the page light
D webui/src/pages/homepage/panels/AnalyticsPanel.svelte +0 -246 collapsed to keep the page light
D webui/src/pages/homepage/panels/FavoritesPanel.svelte +0 -287 collapsed to keep the page light
D webui/src/pages/homepage/panels/HistoryPanel.svelte +0 -210 collapsed to keep the page light
D webui/src/pages/homepage/panels/index.ts +0 -4 collapsed to keep the page light
D webui/src/pages/homepage/shell/CommandPalette.svelte +0 -613 collapsed to keep the page light
D webui/src/pages/homepage/shell/HomepageShell.svelte +0 -634 collapsed to keep the page light
D webui/src/pages/homepage/shell/KeyboardHints.svelte +0 -447 collapsed to keep the page light
D webui/src/pages/homepage/shell/StatusBar.svelte +0 -416 collapsed to keep the page light
D webui/src/pages/homepage/shell/index.ts +0 -5 collapsed to keep the page light
D webui/src/pages/homepage/state.svelte.ts +0 -504 collapsed to keep the page light
D webui/src/pages/homepage/types.ts +0 -232 collapsed to keep the page light
D webui/src/pages/webrtc.ts +0 -165 collapsed to keep the page light
D webui/src/styles/app.css +0 -332 collapsed to keep the page light
D webui/src/types/dumber.d.ts +0 -156 collapsed to keep the page light
D webui/src/types/frappe-charts.d.ts +0 -47 collapsed to keep the page light
D webui/src/types/generated.ts +0 -21 collapsed to keep the page light
D webui/src/utils/dom.ts +0 -203 collapsed to keep the page light
D webui/svelte.config.js +0 -10 collapsed to keep the page light
D webui/tailwind.config.js +0 -66 collapsed to keep the page light
D webui/tsconfig.json +0 -27 collapsed to keep the page light
D webui/vite-plugin-pages.ts +0 -153 collapsed to keep the page light
D webui/vite.config.ts +0 -123 collapsed to keep the page light

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