detached a4e770b

fix(systemviews): address PR review feedback

commit
a4e770b16a30227ac27509968b02f52df07ad183
author
brice · · 2026-04-25 08:34 UTC
parent
06c3b66
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
51 files · +720 -452
M CONTRIBUTING.md +1 -0
@@ -36,6 +36,7 @@ git push origin my-fix
3636
3737 **Systemviews assets:**
3838 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
3940
4041 Keep commits focused and use single-line commit messages.
4142
@@ -36,6 +36,7 @@ git push origin my-fix
36 36
37 **Systemviews assets:**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 assets38 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
39 40
40 Keep commits focused and use single-line commit messages.41 Keep commits focused and use single-line commit messages.
41 42
M Makefile +2 -2
@@ -56,8 +56,8 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
5656 @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; }
5757 @mkdir -p assets/systemviews
5858 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
59- GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
60- brotli -f assets/systemviews/systemviews.wasm -o assets/systemviews/systemviews.wasm.br
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
6161 @echo "Systemviews build complete"
6262
6363 build-quick: ## Build quickly for backend development
@@ -56,8 +56,8 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
56 @command -v brotli >/dev/null 2>&1 || { echo "Error: brotli is required to build compressed systemviews assets. Install brotli and retry."; exit 1; }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/systemviews57 @mkdir -p assets/systemviews
58 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js58 @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js
59- GOOS=js GOARCH=wasm go build -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews59+ GOOS=js GOARCH=wasm go build -buildvcs=false -ldflags="-s -w" -o assets/systemviews/systemviews.wasm ./cmd/systemviews
60- brotli -f assets/systemviews/systemviews.wasm -o assets/systemviews/systemviews.wasm.br60+ brotli -f -o assets/systemviews/systemviews.wasm.br assets/systemviews/systemviews.wasm
61 @echo "Systemviews build complete"61 @echo "Systemviews build complete"
62 62
63 build-quick: ## Build quickly for backend development63 build-quick: ## Build quickly for backend development
M assets/webui_embed.go +1 -0
@@ -5,6 +5,7 @@ import (
55 )
66
77 // WebUIAssets contains the embedded systemviews build output.
8+// The legacy WebUIAssets name is kept for compatibility with existing callers.
89 // Used by the scheme handlers to serve dumb://error, dumb://config,
910 // dumb://history, and dumb://favorites.
1011 //
@@ -5,6 +5,7 @@ import (
5 )5 )
6 6
7 // WebUIAssets contains the embedded systemviews build output.7 // WebUIAssets contains the embedded systemviews build output.
8+// The legacy WebUIAssets name is kept for compatibility with existing callers.
8 // Used by the scheme handlers to serve dumb://error, dumb://config,9 // Used by the scheme handlers to serve dumb://error, dumb://config,
9 // dumb://history, and dumb://favorites.10 // dumb://history, and dumb://favorites.
10 //11 //
M cmd/systemviews/bootstrap.go +4 -3
@@ -3,6 +3,7 @@ package main
33 import (
44 "context"
55
6+ "github.com/bnema/dumber/internal/application/dto"
67 "github.com/bnema/dumber/internal/application/port"
78 "github.com/bnema/dumber/internal/ui/systemviews"
89 )
@@ -29,15 +30,15 @@ type bridgeConfigProxy struct {
2930 route systemviews.Route
3031 }
3132
32-func (p bridgeConfigProxy) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
33+func (p bridgeConfigProxy) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
3334 return p.bridge.Current(ctx)
3435 }
3536
36-func (p bridgeConfigProxy) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
37+func (p bridgeConfigProxy) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
3738 return p.bridge.Default(ctx)
3839 }
3940
40-func (p bridgeConfigProxy) Save(ctx context.Context, cfg port.WebUIConfig) error {
41+func (p bridgeConfigProxy) Save(ctx context.Context, cfg dto.WebUIConfig) error {
4142 return p.bridge.Save(ctx, cfg)
4243 }
4344
@@ -3,6 +3,7 @@ package main
3 import (3 import (
4 "context"4 "context"
5 5
6+ "github.com/bnema/dumber/internal/application/dto"
6 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/ui/systemviews"8 "github.com/bnema/dumber/internal/ui/systemviews"
8 )9 )
@@ -29,15 +30,15 @@ type bridgeConfigProxy struct {
29 route systemviews.Route30 route systemviews.Route
30 }31 }
31 32
32-func (p bridgeConfigProxy) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {33+func (p bridgeConfigProxy) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
33 return p.bridge.Current(ctx)34 return p.bridge.Current(ctx)
34 }35 }
35 36
36-func (p bridgeConfigProxy) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {37+func (p bridgeConfigProxy) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
37 return p.bridge.Default(ctx)38 return p.bridge.Default(ctx)
38 }39 }
39 40
40-func (p bridgeConfigProxy) Save(ctx context.Context, cfg port.WebUIConfig) error {41+func (p bridgeConfigProxy) Save(ctx context.Context, cfg dto.WebUIConfig) error {
41 return p.bridge.Save(ctx, cfg)42 return p.bridge.Save(ctx, cfg)
42 }43 }
43 44
M cmd/systemviews/bootstrap_test.go +10 -9
@@ -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 "github.com/bnema/dumber/internal/domain/entity"
910 "github.com/stretchr/testify/assert"
@@ -28,10 +29,10 @@ func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {
2829
2930 bridge := &fakeBridgeService{
3031 historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}},
31- currentConfig: port.SystemviewConfigPayload{
32- Appearance: port.WebUIAppearanceConfig{
32+ currentConfig: dto.SystemviewConfigPayload{
33+ Appearance: dto.WebUIAppearanceConfig{
3334 ColorScheme: "prefer-dark",
34- DarkPalette: port.ColorPalette{
35+ DarkPalette: dto.ColorPalette{
3536 Background: "#111111",
3637 Surface: "#1a1a1a",
3738 SurfaceVariant: "#2a2a2a",
@@ -75,7 +76,7 @@ func TestNewBridgeApp_WiresConfigService(t *testing.T) {
7576 t.Parallel()
7677
7778 bridge := &fakeBridgeService{
78- currentConfig: port.SystemviewConfigPayload{EngineType: "webkit"},
79+ currentConfig: dto.SystemviewConfigPayload{EngineType: "webkit"},
7980 keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{DisplayName: "Default"}}},
8081 }
8182 app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge)
@@ -108,7 +109,7 @@ type fakeBridgeService struct {
108109 favorites []*entity.Favorite
109110 folders []*entity.Folder
110111 tags []*entity.Tag
111- currentConfig port.SystemviewConfigPayload
112+ currentConfig dto.SystemviewConfigPayload
112113 keybindings port.KeybindingsConfig
113114 }
114115
@@ -187,16 +188,16 @@ func (*fakeBridgeService) AssignTag(context.Context, int64, int64) error { retur
187188
188189 func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil }
189190
190-func (f *fakeBridgeService) Current(context.Context) (port.SystemviewConfigPayload, error) {
191+func (f *fakeBridgeService) Current(context.Context) (dto.SystemviewConfigPayload, error) {
191192 f.calledConfig = true
192193 return f.currentConfig, nil
193194 }
194195
195-func (*fakeBridgeService) Default(context.Context) (port.SystemviewConfigPayload, error) {
196- return port.SystemviewConfigPayload{}, nil
196+func (*fakeBridgeService) Default(context.Context) (dto.SystemviewConfigPayload, error) {
197+ return dto.SystemviewConfigPayload{}, nil
197198 }
198199
199-func (*fakeBridgeService) Save(context.Context, port.WebUIConfig) error { return nil }
200+func (*fakeBridgeService) Save(context.Context, dto.WebUIConfig) error { return nil }
200201
201202 func (f *fakeBridgeService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
202203 f.calledKeybindings = true
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/stretchr/testify/assert"10 "github.com/stretchr/testify/assert"
@@ -28,10 +29,10 @@ func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {
28 29
29 bridge := &fakeBridgeService{30 bridge := &fakeBridgeService{
30 historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}},31 historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}},
31- currentConfig: port.SystemviewConfigPayload{32+ currentConfig: dto.SystemviewConfigPayload{
32- Appearance: port.WebUIAppearanceConfig{33+ Appearance: dto.WebUIAppearanceConfig{
33 ColorScheme: "prefer-dark",34 ColorScheme: "prefer-dark",
34- DarkPalette: port.ColorPalette{35+ DarkPalette: dto.ColorPalette{
35 Background: "#111111",36 Background: "#111111",
36 Surface: "#1a1a1a",37 Surface: "#1a1a1a",
37 SurfaceVariant: "#2a2a2a",38 SurfaceVariant: "#2a2a2a",
@@ -75,7 +76,7 @@ func TestNewBridgeApp_WiresConfigService(t *testing.T) {
75 t.Parallel()76 t.Parallel()
76 77
77 bridge := &fakeBridgeService{78 bridge := &fakeBridgeService{
78- currentConfig: port.SystemviewConfigPayload{EngineType: "webkit"},79+ currentConfig: dto.SystemviewConfigPayload{EngineType: "webkit"},
79 keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{DisplayName: "Default"}}},80 keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{DisplayName: "Default"}}},
80 }81 }
81 app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge)82 app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge)
@@ -108,7 +109,7 @@ type fakeBridgeService struct {
108 favorites []*entity.Favorite109 favorites []*entity.Favorite
109 folders []*entity.Folder110 folders []*entity.Folder
110 tags []*entity.Tag111 tags []*entity.Tag
111- currentConfig port.SystemviewConfigPayload112+ currentConfig dto.SystemviewConfigPayload
112 keybindings port.KeybindingsConfig113 keybindings port.KeybindingsConfig
113 }114 }
114 115
@@ -187,16 +188,16 @@ func (*fakeBridgeService) AssignTag(context.Context, int64, int64) error { retur
187 188
188 func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil }189 func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil }
189 190
190-func (f *fakeBridgeService) Current(context.Context) (port.SystemviewConfigPayload, error) {191+func (f *fakeBridgeService) Current(context.Context) (dto.SystemviewConfigPayload, error) {
191 f.calledConfig = true192 f.calledConfig = true
192 return f.currentConfig, nil193 return f.currentConfig, nil
193 }194 }
194 195
195-func (*fakeBridgeService) Default(context.Context) (port.SystemviewConfigPayload, error) {196+func (*fakeBridgeService) Default(context.Context) (dto.SystemviewConfigPayload, error) {
196- return port.SystemviewConfigPayload{}, nil197+ return dto.SystemviewConfigPayload{}, nil
197 }198 }
198 199
199-func (*fakeBridgeService) Save(context.Context, port.WebUIConfig) error { return nil }200+func (*fakeBridgeService) Save(context.Context, dto.WebUIConfig) error { return nil }
200 201
201 func (f *fakeBridgeService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {202 func (f *fakeBridgeService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
202 f.calledKeybindings = true203 f.calledKeybindings = true
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+}
@@ -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/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
@@ -3,6 +3,7 @@ package port
3 import (3 import (
4 "context"4 "context"
5 5
6+ "github.com/bnema/dumber/internal/application/dto"
6 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
7 )8 )
8 9
@@ -43,7 +44,7 @@ type AutoCopyConfig interface {
43 // A struct is used here to avoid reverse dependencies between the bootstrap and44 // A struct is used here to avoid reverse dependencies between the bootstrap and
44 // UI layers, which both need to reference this shared dependency container.45 // UI layers, which both need to reference this shared dependency container.
45 type HandlerDeps struct {46 type HandlerDeps struct {
46- SaveConfig func(context.Context, WebUIConfig) error47+ SaveConfig func(context.Context, dto.WebUIConfig) error
47 SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error48 SaveOmniboxInitialBehavior func(context.Context, entity.OmniboxInitialBehavior) error
48 KeybindingsGetter KeybindingsGetter49 KeybindingsGetter KeybindingsGetter
49 KeybindingSetter KeybindingSetter50 KeybindingSetter KeybindingSetter
M internal/application/port/mocks/mocks.go +21 -21
@@ -12113,22 +12113,22 @@ func (_m *MockSystemviewConfigReader) EXPECT() *MockSystemviewConfigReader_Expec
1211312113 }
1211412114
1211512115 // Current provides a mock function for the type MockSystemviewConfigReader
12116-func (_mock *MockSystemviewConfigReader) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
12116+func (_mock *MockSystemviewConfigReader) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
1211712117 ret := _mock.Called(ctx)
1211812118
1211912119 if len(ret) == 0 {
1212012120 panic("no return value specified for Current")
1212112121 }
1212212122
12123- var r0 port.SystemviewConfigPayload
12123+ var r0 dto.SystemviewConfigPayload
1212412124 var r1 error
12125- if returnFunc, ok := ret.Get(0).(func(context.Context) (port.SystemviewConfigPayload, error)); ok {
12125+ if returnFunc, ok := ret.Get(0).(func(context.Context) (dto.SystemviewConfigPayload, error)); ok {
1212612126 return returnFunc(ctx)
1212712127 }
12128- if returnFunc, ok := ret.Get(0).(func(context.Context) port.SystemviewConfigPayload); ok {
12128+ if returnFunc, ok := ret.Get(0).(func(context.Context) dto.SystemviewConfigPayload); ok {
1212912129 r0 = returnFunc(ctx)
1213012130 } else {
12131- r0 = ret.Get(0).(port.SystemviewConfigPayload)
12131+ r0 = ret.Get(0).(dto.SystemviewConfigPayload)
1213212132 }
1213312133 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1213412134 r1 = returnFunc(ctx)
@@ -12162,33 +12162,33 @@ func (_c *MockSystemviewConfigReader_Current_Call) Run(run func(ctx context.Cont
1216212162 return _c
1216312163 }
1216412164
12165-func (_c *MockSystemviewConfigReader_Current_Call) Return(systemviewConfigPayload port.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Current_Call {
12165+func (_c *MockSystemviewConfigReader_Current_Call) Return(systemviewConfigPayload dto.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Current_Call {
1216612166 _c.Call.Return(systemviewConfigPayload, err)
1216712167 return _c
1216812168 }
1216912169
12170-func (_c *MockSystemviewConfigReader_Current_Call) RunAndReturn(run func(ctx context.Context) (port.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Current_Call {
12170+func (_c *MockSystemviewConfigReader_Current_Call) RunAndReturn(run func(ctx context.Context) (dto.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Current_Call {
1217112171 _c.Call.Return(run)
1217212172 return _c
1217312173 }
1217412174
1217512175 // Default provides a mock function for the type MockSystemviewConfigReader
12176-func (_mock *MockSystemviewConfigReader) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
12176+func (_mock *MockSystemviewConfigReader) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
1217712177 ret := _mock.Called(ctx)
1217812178
1217912179 if len(ret) == 0 {
1218012180 panic("no return value specified for Default")
1218112181 }
1218212182
12183- var r0 port.SystemviewConfigPayload
12183+ var r0 dto.SystemviewConfigPayload
1218412184 var r1 error
12185- if returnFunc, ok := ret.Get(0).(func(context.Context) (port.SystemviewConfigPayload, error)); ok {
12185+ if returnFunc, ok := ret.Get(0).(func(context.Context) (dto.SystemviewConfigPayload, error)); ok {
1218612186 return returnFunc(ctx)
1218712187 }
12188- if returnFunc, ok := ret.Get(0).(func(context.Context) port.SystemviewConfigPayload); ok {
12188+ if returnFunc, ok := ret.Get(0).(func(context.Context) dto.SystemviewConfigPayload); ok {
1218912189 r0 = returnFunc(ctx)
1219012190 } else {
12191- r0 = ret.Get(0).(port.SystemviewConfigPayload)
12191+ r0 = ret.Get(0).(dto.SystemviewConfigPayload)
1219212192 }
1219312193 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
1219412194 r1 = returnFunc(ctx)
@@ -12222,12 +12222,12 @@ func (_c *MockSystemviewConfigReader_Default_Call) Run(run func(ctx context.Cont
1222212222 return _c
1222312223 }
1222412224
12225-func (_c *MockSystemviewConfigReader_Default_Call) Return(systemviewConfigPayload port.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Default_Call {
12225+func (_c *MockSystemviewConfigReader_Default_Call) Return(systemviewConfigPayload dto.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Default_Call {
1222612226 _c.Call.Return(systemviewConfigPayload, err)
1222712227 return _c
1222812228 }
1222912229
12230-func (_c *MockSystemviewConfigReader_Default_Call) RunAndReturn(run func(ctx context.Context) (port.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Default_Call {
12230+func (_c *MockSystemviewConfigReader_Default_Call) RunAndReturn(run func(ctx context.Context) (dto.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Default_Call {
1223112231 _c.Call.Return(run)
1223212232 return _c
1223312233 }
@@ -12260,7 +12260,7 @@ func (_m *MockWebUIConfigSaver) EXPECT() *MockWebUIConfigSaver_Expecter {
1226012260 }
1226112261
1226212262 // SaveWebUIConfig provides a mock function for the type MockWebUIConfigSaver
12263-func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error {
12263+func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error {
1226412264 ret := _mock.Called(ctx, cfg)
1226512265
1226612266 if len(ret) == 0 {
@@ -12268,7 +12268,7 @@ func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg port
1226812268 }
1226912269
1227012270 var r0 error
12271- if returnFunc, ok := ret.Get(0).(func(context.Context, port.WebUIConfig) error); ok {
12271+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.WebUIConfig) error); ok {
1227212272 r0 = returnFunc(ctx, cfg)
1227312273 } else {
1227412274 r0 = ret.Error(0)
@@ -12283,20 +12283,20 @@ type MockWebUIConfigSaver_SaveWebUIConfig_Call struct {
1228312283
1228412284 // SaveWebUIConfig is a helper method to define mock.On call
1228512285 // - ctx context.Context
12286-// - cfg port.WebUIConfig
12286+// - cfg dto.WebUIConfig
1228712287 func (_e *MockWebUIConfigSaver_Expecter) SaveWebUIConfig(ctx interface{}, cfg interface{}) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
1228812288 return &MockWebUIConfigSaver_SaveWebUIConfig_Call{Call: _e.mock.On("SaveWebUIConfig", ctx, cfg)}
1228912289 }
1229012290
12291-func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Run(run func(ctx context.Context, cfg port.WebUIConfig)) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
12291+func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Run(run func(ctx context.Context, cfg dto.WebUIConfig)) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
1229212292 _c.Call.Run(func(args mock.Arguments) {
1229312293 var arg0 context.Context
1229412294 if args[0] != nil {
1229512295 arg0 = args[0].(context.Context)
1229612296 }
12297- var arg1 port.WebUIConfig
12297+ var arg1 dto.WebUIConfig
1229812298 if args[1] != nil {
12299- arg1 = args[1].(port.WebUIConfig)
12299+ arg1 = args[1].(dto.WebUIConfig)
1230012300 }
1230112301 run(
1230212302 arg0,
@@ -12311,7 +12311,7 @@ func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Return(err error) *MockWebU
1231112311 return _c
1231212312 }
1231312313
12314-func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) RunAndReturn(run func(ctx context.Context, cfg port.WebUIConfig) error) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
12314+func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) RunAndReturn(run func(ctx context.Context, cfg dto.WebUIConfig) error) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
1231512315 _c.Call.Return(run)
1231612316 return _c
1231712317 }
@@ -12113,22 +12113,22 @@ func (_m *MockSystemviewConfigReader) EXPECT() *MockSystemviewConfigReader_Expec
12113 }12113 }
12114 12114
12115 // Current provides a mock function for the type MockSystemviewConfigReader12115 // Current provides a mock function for the type MockSystemviewConfigReader
12116-func (_mock *MockSystemviewConfigReader) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {12116+func (_mock *MockSystemviewConfigReader) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
12117 ret := _mock.Called(ctx)12117 ret := _mock.Called(ctx)
12118 12118
12119 if len(ret) == 0 {12119 if len(ret) == 0 {
12120 panic("no return value specified for Current")12120 panic("no return value specified for Current")
12121 }12121 }
12122 12122
12123- var r0 port.SystemviewConfigPayload12123+ var r0 dto.SystemviewConfigPayload
12124 var r1 error12124 var r1 error
12125- if returnFunc, ok := ret.Get(0).(func(context.Context) (port.SystemviewConfigPayload, error)); ok {12125+ if returnFunc, ok := ret.Get(0).(func(context.Context) (dto.SystemviewConfigPayload, error)); ok {
12126 return returnFunc(ctx)12126 return returnFunc(ctx)
12127 }12127 }
12128- if returnFunc, ok := ret.Get(0).(func(context.Context) port.SystemviewConfigPayload); ok {12128+ if returnFunc, ok := ret.Get(0).(func(context.Context) dto.SystemviewConfigPayload); ok {
12129 r0 = returnFunc(ctx)12129 r0 = returnFunc(ctx)
12130 } else {12130 } else {
12131- r0 = ret.Get(0).(port.SystemviewConfigPayload)12131+ r0 = ret.Get(0).(dto.SystemviewConfigPayload)
12132 }12132 }
12133 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {12133 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
12134 r1 = returnFunc(ctx)12134 r1 = returnFunc(ctx)
@@ -12162,33 +12162,33 @@ func (_c *MockSystemviewConfigReader_Current_Call) Run(run func(ctx context.Cont
12162 return _c12162 return _c
12163 }12163 }
12164 12164
12165-func (_c *MockSystemviewConfigReader_Current_Call) Return(systemviewConfigPayload port.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Current_Call {12165+func (_c *MockSystemviewConfigReader_Current_Call) Return(systemviewConfigPayload dto.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Current_Call {
12166 _c.Call.Return(systemviewConfigPayload, err)12166 _c.Call.Return(systemviewConfigPayload, err)
12167 return _c12167 return _c
12168 }12168 }
12169 12169
12170-func (_c *MockSystemviewConfigReader_Current_Call) RunAndReturn(run func(ctx context.Context) (port.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Current_Call {12170+func (_c *MockSystemviewConfigReader_Current_Call) RunAndReturn(run func(ctx context.Context) (dto.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Current_Call {
12171 _c.Call.Return(run)12171 _c.Call.Return(run)
12172 return _c12172 return _c
12173 }12173 }
12174 12174
12175 // Default provides a mock function for the type MockSystemviewConfigReader12175 // Default provides a mock function for the type MockSystemviewConfigReader
12176-func (_mock *MockSystemviewConfigReader) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {12176+func (_mock *MockSystemviewConfigReader) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
12177 ret := _mock.Called(ctx)12177 ret := _mock.Called(ctx)
12178 12178
12179 if len(ret) == 0 {12179 if len(ret) == 0 {
12180 panic("no return value specified for Default")12180 panic("no return value specified for Default")
12181 }12181 }
12182 12182
12183- var r0 port.SystemviewConfigPayload12183+ var r0 dto.SystemviewConfigPayload
12184 var r1 error12184 var r1 error
12185- if returnFunc, ok := ret.Get(0).(func(context.Context) (port.SystemviewConfigPayload, error)); ok {12185+ if returnFunc, ok := ret.Get(0).(func(context.Context) (dto.SystemviewConfigPayload, error)); ok {
12186 return returnFunc(ctx)12186 return returnFunc(ctx)
12187 }12187 }
12188- if returnFunc, ok := ret.Get(0).(func(context.Context) port.SystemviewConfigPayload); ok {12188+ if returnFunc, ok := ret.Get(0).(func(context.Context) dto.SystemviewConfigPayload); ok {
12189 r0 = returnFunc(ctx)12189 r0 = returnFunc(ctx)
12190 } else {12190 } else {
12191- r0 = ret.Get(0).(port.SystemviewConfigPayload)12191+ r0 = ret.Get(0).(dto.SystemviewConfigPayload)
12192 }12192 }
12193 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {12193 if returnFunc, ok := ret.Get(1).(func(context.Context) error); ok {
12194 r1 = returnFunc(ctx)12194 r1 = returnFunc(ctx)
@@ -12222,12 +12222,12 @@ func (_c *MockSystemviewConfigReader_Default_Call) Run(run func(ctx context.Cont
12222 return _c12222 return _c
12223 }12223 }
12224 12224
12225-func (_c *MockSystemviewConfigReader_Default_Call) Return(systemviewConfigPayload port.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Default_Call {12225+func (_c *MockSystemviewConfigReader_Default_Call) Return(systemviewConfigPayload dto.SystemviewConfigPayload, err error) *MockSystemviewConfigReader_Default_Call {
12226 _c.Call.Return(systemviewConfigPayload, err)12226 _c.Call.Return(systemviewConfigPayload, err)
12227 return _c12227 return _c
12228 }12228 }
12229 12229
12230-func (_c *MockSystemviewConfigReader_Default_Call) RunAndReturn(run func(ctx context.Context) (port.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Default_Call {12230+func (_c *MockSystemviewConfigReader_Default_Call) RunAndReturn(run func(ctx context.Context) (dto.SystemviewConfigPayload, error)) *MockSystemviewConfigReader_Default_Call {
12231 _c.Call.Return(run)12231 _c.Call.Return(run)
12232 return _c12232 return _c
12233 }12233 }
@@ -12260,7 +12260,7 @@ func (_m *MockWebUIConfigSaver) EXPECT() *MockWebUIConfigSaver_Expecter {
12260 }12260 }
12261 12261
12262 // SaveWebUIConfig provides a mock function for the type MockWebUIConfigSaver12262 // SaveWebUIConfig provides a mock function for the type MockWebUIConfigSaver
12263-func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error {12263+func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error {
12264 ret := _mock.Called(ctx, cfg)12264 ret := _mock.Called(ctx, cfg)
12265 12265
12266 if len(ret) == 0 {12266 if len(ret) == 0 {
@@ -12268,7 +12268,7 @@ func (_mock *MockWebUIConfigSaver) SaveWebUIConfig(ctx context.Context, cfg port
12268 }12268 }
12269 12269
12270 var r0 error12270 var r0 error
12271- if returnFunc, ok := ret.Get(0).(func(context.Context, port.WebUIConfig) error); ok {12271+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.WebUIConfig) error); ok {
12272 r0 = returnFunc(ctx, cfg)12272 r0 = returnFunc(ctx, cfg)
12273 } else {12273 } else {
12274 r0 = ret.Error(0)12274 r0 = ret.Error(0)
@@ -12283,20 +12283,20 @@ type MockWebUIConfigSaver_SaveWebUIConfig_Call struct {
12283 12283
12284 // SaveWebUIConfig is a helper method to define mock.On call12284 // SaveWebUIConfig is a helper method to define mock.On call
12285 // - ctx context.Context12285 // - ctx context.Context
12286-// - cfg port.WebUIConfig12286+// - cfg dto.WebUIConfig
12287 func (_e *MockWebUIConfigSaver_Expecter) SaveWebUIConfig(ctx interface{}, cfg interface{}) *MockWebUIConfigSaver_SaveWebUIConfig_Call {12287 func (_e *MockWebUIConfigSaver_Expecter) SaveWebUIConfig(ctx interface{}, cfg interface{}) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
12288 return &MockWebUIConfigSaver_SaveWebUIConfig_Call{Call: _e.mock.On("SaveWebUIConfig", ctx, cfg)}12288 return &MockWebUIConfigSaver_SaveWebUIConfig_Call{Call: _e.mock.On("SaveWebUIConfig", ctx, cfg)}
12289 }12289 }
12290 12290
12291-func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Run(run func(ctx context.Context, cfg port.WebUIConfig)) *MockWebUIConfigSaver_SaveWebUIConfig_Call {12291+func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Run(run func(ctx context.Context, cfg dto.WebUIConfig)) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
12292 _c.Call.Run(func(args mock.Arguments) {12292 _c.Call.Run(func(args mock.Arguments) {
12293 var arg0 context.Context12293 var arg0 context.Context
12294 if args[0] != nil {12294 if args[0] != nil {
12295 arg0 = args[0].(context.Context)12295 arg0 = args[0].(context.Context)
12296 }12296 }
12297- var arg1 port.WebUIConfig12297+ var arg1 dto.WebUIConfig
12298 if args[1] != nil {12298 if args[1] != nil {
12299- arg1 = args[1].(port.WebUIConfig)12299+ arg1 = args[1].(dto.WebUIConfig)
12300 }12300 }
12301 run(12301 run(
12302 arg0,12302 arg0,
@@ -12311,7 +12311,7 @@ func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) Return(err error) *MockWebU
12311 return _c12311 return _c
12312 }12312 }
12313 12313
12314-func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) RunAndReturn(run func(ctx context.Context, cfg port.WebUIConfig) error) *MockWebUIConfigSaver_SaveWebUIConfig_Call {12314+func (_c *MockWebUIConfigSaver_SaveWebUIConfig_Call) RunAndReturn(run func(ctx context.Context, cfg dto.WebUIConfig) error) *MockWebUIConfigSaver_SaveWebUIConfig_Call {
12315 _c.Call.Return(run)12315 _c.Call.Return(run)
12316 return _c12316 return _c
12317 }12317 }
M internal/application/port/systemviews.go +4 -3
@@ -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
@@ -40,9 +41,9 @@ type SystemviewFavoritesService interface {
4041
4142 // SystemviewConfigService exposes config and keybinding operations for the systemviews UI.
4243 type SystemviewConfigService interface {
43- Current(ctx context.Context) (SystemviewConfigPayload, error)
44- Default(ctx context.Context) (SystemviewConfigPayload, error)
45- Save(ctx context.Context, cfg WebUIConfig) error
44+ Current(ctx context.Context) (dto.SystemviewConfigPayload, error)
45+ Default(ctx context.Context) (dto.SystemviewConfigPayload, error)
46+ Save(ctx context.Context, cfg dto.WebUIConfig) error
4647 GetKeybindings(ctx context.Context) (KeybindingsConfig, error)
4748 SetKeybinding(ctx context.Context, req SetKeybindingRequest) (SetKeybindingResponse, error)
4849 ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error
@@ -3,6 +3,7 @@ package port
3 import (3 import (
4 "context"4 "context"
5 5
6+ "github.com/bnema/dumber/internal/application/dto"
6 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
7 )8 )
8 9
@@ -40,9 +41,9 @@ type SystemviewFavoritesService interface {
40 41
41 // SystemviewConfigService exposes config and keybinding operations for the systemviews UI.42 // SystemviewConfigService exposes config and keybinding operations for the systemviews UI.
42 type SystemviewConfigService interface {43 type SystemviewConfigService interface {
43- Current(ctx context.Context) (SystemviewConfigPayload, error)44+ Current(ctx context.Context) (dto.SystemviewConfigPayload, error)
44- Default(ctx context.Context) (SystemviewConfigPayload, error)45+ Default(ctx context.Context) (dto.SystemviewConfigPayload, error)
45- Save(ctx context.Context, cfg WebUIConfig) error46+ Save(ctx context.Context, cfg dto.WebUIConfig) error
46 GetKeybindings(ctx context.Context) (KeybindingsConfig, error)47 GetKeybindings(ctx context.Context) (KeybindingsConfig, error)
47 SetKeybinding(ctx context.Context, req SetKeybindingRequest) (SetKeybindingResponse, error)48 SetKeybinding(ctx context.Context, req SetKeybindingRequest) (SetKeybindingResponse, error)
48 ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error49 ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error
M internal/application/port/webui_config.go +7 -94
@@ -1,105 +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-// SystemviewCustomPerformancePayload is the editable custom performance profile shape.
15-type SystemviewCustomPerformancePayload = WebUICustomPerformanceConfig
16-
17-// SystemviewResolvedPerformancePayload is the resolved effective performance shape.
18-type SystemviewResolvedPerformancePayload struct {
19- SkiaCPUThreads int `json:"skia_cpu_threads"`
20- SkiaGPUThreads int `json:"skia_gpu_threads"`
21- WebProcessMemoryMB int `json:"web_process_memory_mb"`
22- NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
23- WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
24- ConservativeThreshold float64 `json:"conservative_threshold"`
25- StrictThreshold float64 `json:"strict_threshold"`
26-}
27-
28-// SystemviewHardwarePayload exposes detected hardware to the settings UI.
29-type SystemviewHardwarePayload struct {
30- CPUCores int `json:"cpu_cores"`
31- CPUThreads int `json:"cpu_threads"`
32- TotalRAMMB int `json:"total_ram_mb"`
33- GPUVendor string `json:"gpu_vendor"`
34- GPUName string `json:"gpu_name"`
35- VRAMMB int `json:"vram_mb"`
36-}
37-
38-// SystemviewPerformancePayload is the performance tab payload expected by the UI.
39-type SystemviewPerformancePayload struct {
40- Profile string `json:"profile"`
41- Custom SystemviewCustomPerformancePayload `json:"custom"`
42- Resolved SystemviewResolvedPerformancePayload `json:"resolved"`
43- Hardware SystemviewHardwarePayload `json:"hardware"`
44-}
45-
46-// SystemviewConfigPayload is the full JSON shape expected by dumb://config.
47-type SystemviewConfigPayload struct {
48- Appearance WebUIAppearanceConfig `json:"appearance"`
49- Performance SystemviewPerformancePayload `json:"performance"`
50- DefaultUIScale float64 `json:"default_ui_scale"`
51- DefaultSearchEngine string `json:"default_search_engine"`
52- SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
53- EngineType string `json:"engine_type"`
54-}
55-
56-// WebUIPerformanceConfig represents performance settings editable in dumb://config.
57-type WebUIPerformanceConfig struct {
58- Profile string `json:"profile"`
59- Custom WebUICustomPerformanceConfig `json:"custom"`
60-}
61-
62-// WebUICustomPerformanceConfig holds user-editable fields for custom profile.
63-type WebUICustomPerformanceConfig struct {
64- SkiaCPUThreads int `json:"skia_cpu_threads"`
65- SkiaGPUThreads int `json:"skia_gpu_threads"`
66- WebProcessMemoryMB int `json:"web_process_memory_mb"`
67- NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
68- WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
69-}
70-
71-type WebUIAppearanceConfig struct {
72- SansFont string `json:"sans_font"`
73- SerifFont string `json:"serif_font"`
74- MonospaceFont string `json:"monospace_font"`
75- DefaultFontSize int `json:"default_font_size"`
76- ColorScheme string `json:"color_scheme"`
77- LightPalette ColorPalette `json:"light_palette"`
78- DarkPalette ColorPalette `json:"dark_palette"`
79-}
80-
81-type ColorPalette struct {
82- Background string `json:"background"`
83- Surface string `json:"surface"`
84- SurfaceVariant string `json:"surface_variant"`
85- Text string `json:"text"`
86- Muted string `json:"muted"`
87- Accent string `json:"accent"`
88- Border string `json:"border"`
89-}
90-
91-type SearchShortcut struct {
92- URL string `json:"url"`
93- Description string `json:"description"`
94-}
6+ "github.com/bnema/dumber/internal/application/dto"
7+)
958
969 // SystemviewConfigReader reads the current and default systemview config payloads.
9710 type SystemviewConfigReader interface {
98- Current(ctx context.Context) (SystemviewConfigPayload, error)
99- Default(ctx context.Context) (SystemviewConfigPayload, error)
11+ Current(ctx context.Context) (dto.SystemviewConfigPayload, error)
12+ Default(ctx context.Context) (dto.SystemviewConfigPayload, error)
10013 }
10114
10215 // WebUIConfigSaver persists WebUI configuration changes.
10316 type WebUIConfigSaver interface {
104- SaveWebUIConfig(ctx context.Context, cfg WebUIConfig) error
17+ SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error
10518 }
@@ -1,105 +1,18 @@
1 package port1 package port
2 2
3-import "context"3+import (
4+ "context"
4 5
5-// WebUIConfig represents the subset of configuration editable in dumb://config.6+ "github.com/bnema/dumber/internal/application/dto"
6-type WebUIConfig struct {7+)
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-// SystemviewCustomPerformancePayload is the editable custom performance profile shape.
15-type SystemviewCustomPerformancePayload = WebUICustomPerformanceConfig
16-
17-// SystemviewResolvedPerformancePayload is the resolved effective performance shape.
18-type SystemviewResolvedPerformancePayload struct {
19- SkiaCPUThreads int `json:"skia_cpu_threads"`
20- SkiaGPUThreads int `json:"skia_gpu_threads"`
21- WebProcessMemoryMB int `json:"web_process_memory_mb"`
22- NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
23- WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
24- ConservativeThreshold float64 `json:"conservative_threshold"`
25- StrictThreshold float64 `json:"strict_threshold"`
26-}
27-
28-// SystemviewHardwarePayload exposes detected hardware to the settings UI.
29-type SystemviewHardwarePayload struct {
30- CPUCores int `json:"cpu_cores"`
31- CPUThreads int `json:"cpu_threads"`
32- TotalRAMMB int `json:"total_ram_mb"`
33- GPUVendor string `json:"gpu_vendor"`
34- GPUName string `json:"gpu_name"`
35- VRAMMB int `json:"vram_mb"`
36-}
37-
38-// SystemviewPerformancePayload is the performance tab payload expected by the UI.
39-type SystemviewPerformancePayload struct {
40- Profile string `json:"profile"`
41- Custom SystemviewCustomPerformancePayload `json:"custom"`
42- Resolved SystemviewResolvedPerformancePayload `json:"resolved"`
43- Hardware SystemviewHardwarePayload `json:"hardware"`
44-}
45-
46-// SystemviewConfigPayload is the full JSON shape expected by dumb://config.
47-type SystemviewConfigPayload struct {
48- Appearance WebUIAppearanceConfig `json:"appearance"`
49- Performance SystemviewPerformancePayload `json:"performance"`
50- DefaultUIScale float64 `json:"default_ui_scale"`
51- DefaultSearchEngine string `json:"default_search_engine"`
52- SearchShortcuts map[string]SearchShortcut `json:"search_shortcuts"`
53- EngineType string `json:"engine_type"`
54-}
55-
56-// WebUIPerformanceConfig represents performance settings editable in dumb://config.
57-type WebUIPerformanceConfig struct {
58- Profile string `json:"profile"`
59- Custom WebUICustomPerformanceConfig `json:"custom"`
60-}
61-
62-// WebUICustomPerformanceConfig holds user-editable fields for custom profile.
63-type WebUICustomPerformanceConfig struct {
64- SkiaCPUThreads int `json:"skia_cpu_threads"`
65- SkiaGPUThreads int `json:"skia_gpu_threads"`
66- WebProcessMemoryMB int `json:"web_process_memory_mb"`
67- NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
68- WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
69-}
70-
71-type WebUIAppearanceConfig struct {
72- SansFont string `json:"sans_font"`
73- SerifFont string `json:"serif_font"`
74- MonospaceFont string `json:"monospace_font"`
75- DefaultFontSize int `json:"default_font_size"`
76- ColorScheme string `json:"color_scheme"`
77- LightPalette ColorPalette `json:"light_palette"`
78- DarkPalette ColorPalette `json:"dark_palette"`
79-}
80-
81-type ColorPalette struct {
82- Background string `json:"background"`
83- Surface string `json:"surface"`
84- SurfaceVariant string `json:"surface_variant"`
85- Text string `json:"text"`
86- Muted string `json:"muted"`
87- Accent string `json:"accent"`
88- Border string `json:"border"`
89-}
90-
91-type SearchShortcut struct {
92- URL string `json:"url"`
93- Description string `json:"description"`
94-}
95 8
96 // SystemviewConfigReader reads the current and default systemview config payloads.9 // SystemviewConfigReader reads the current and default systemview config payloads.
97 type SystemviewConfigReader interface {10 type SystemviewConfigReader interface {
98- Current(ctx context.Context) (SystemviewConfigPayload, error)11+ Current(ctx context.Context) (dto.SystemviewConfigPayload, error)
99- Default(ctx context.Context) (SystemviewConfigPayload, error)12+ Default(ctx context.Context) (dto.SystemviewConfigPayload, error)
100 }13 }
101 14
102 // WebUIConfigSaver persists WebUI configuration changes.15 // WebUIConfigSaver persists WebUI configuration changes.
103 type WebUIConfigSaver interface {16 type WebUIConfigSaver interface {
104- SaveWebUIConfig(ctx context.Context, cfg WebUIConfig) error17+ SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error
105 }18 }
M internal/application/usecase/read_systemview_config.go +5 -4
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66
7+ "github.com/bnema/dumber/internal/application/dto"
78 "github.com/bnema/dumber/internal/application/port"
89 )
910
@@ -24,17 +25,17 @@ func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader) *ReadSys
2425 var ErrNilSystemviewConfigReader = errors.New("systemview config reader is nil")
2526
2627 // Current returns the current systemview config payload.
27-func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
28+func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
2829 if uc == nil || uc.reader == nil {
29- return port.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
30+ return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
3031 }
3132 return uc.reader.Current(ctx)
3233 }
3334
3435 // Default returns the default systemview config payload.
35-func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
36+func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
3637 if uc == nil || uc.reader == nil {
37- return port.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
38+ return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
3839 }
3940 return uc.reader.Default(ctx)
4041 }
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 6
7+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
8 )9 )
9 10
@@ -24,17 +25,17 @@ func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader) *ReadSys
24 var ErrNilSystemviewConfigReader = errors.New("systemview config reader is nil")25 var ErrNilSystemviewConfigReader = errors.New("systemview config reader is nil")
25 26
26 // Current returns the current systemview config payload.27 // Current returns the current systemview config payload.
27-func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {28+func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
28 if uc == nil || uc.reader == nil {29 if uc == nil || uc.reader == nil {
29- return port.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader30+ return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
30 }31 }
31 return uc.reader.Current(ctx)32 return uc.reader.Current(ctx)
32 }33 }
33 34
34 // Default returns the default systemview config payload.35 // Default returns the default systemview config payload.
35-func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {36+func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
36 if uc == nil || uc.reader == nil {37 if uc == nil || uc.reader == nil {
37- return port.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader38+ return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
38 }39 }
39 return uc.reader.Default(ctx)40 return uc.reader.Default(ctx)
40 }41 }
M internal/application/usecase/read_systemview_config_test.go +3 -3
@@ -4,7 +4,7 @@ import (
44 "context"
55 "testing"
66
7- "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/application/dto"
88 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
99 "github.com/stretchr/testify/mock"
1010 "github.com/stretchr/testify/require"
@@ -12,7 +12,7 @@ import (
1212
1313 func TestReadSystemviewConfigUseCase_Current(t *testing.T) {
1414 reader := portmocks.NewMockSystemviewConfigReader(t)
15- want := port.SystemviewConfigPayload{EngineType: "webkit"}
15+ want := dto.SystemviewConfigPayload{EngineType: "webkit"}
1616 reader.EXPECT().Current(mock.Anything).Return(want, nil).Once()
1717
1818 uc := NewReadSystemviewConfigUseCase(reader)
@@ -24,7 +24,7 @@ func TestReadSystemviewConfigUseCase_Current(t *testing.T) {
2424
2525 func TestReadSystemviewConfigUseCase_Default(t *testing.T) {
2626 reader := portmocks.NewMockSystemviewConfigReader(t)
27- want := port.SystemviewConfigPayload{EngineType: "webkit"}
27+ want := dto.SystemviewConfigPayload{EngineType: "webkit"}
2828 reader.EXPECT().Default(mock.Anything).Return(want, nil).Once()
2929
3030 uc := NewReadSystemviewConfigUseCase(reader)
@@ -4,7 +4,7 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7- "github.com/bnema/dumber/internal/application/port"7+ "github.com/bnema/dumber/internal/application/dto"
8 portmocks "github.com/bnema/dumber/internal/application/port/mocks"8 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
9 "github.com/stretchr/testify/mock"9 "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
@@ -12,7 +12,7 @@ import (
12 12
13 func TestReadSystemviewConfigUseCase_Current(t *testing.T) {13 func TestReadSystemviewConfigUseCase_Current(t *testing.T) {
14 reader := portmocks.NewMockSystemviewConfigReader(t)14 reader := portmocks.NewMockSystemviewConfigReader(t)
15- want := port.SystemviewConfigPayload{EngineType: "webkit"}15+ want := dto.SystemviewConfigPayload{EngineType: "webkit"}
16 reader.EXPECT().Current(mock.Anything).Return(want, nil).Once()16 reader.EXPECT().Current(mock.Anything).Return(want, nil).Once()
17 17
18 uc := NewReadSystemviewConfigUseCase(reader)18 uc := NewReadSystemviewConfigUseCase(reader)
@@ -24,7 +24,7 @@ func TestReadSystemviewConfigUseCase_Current(t *testing.T) {
24 24
25 func TestReadSystemviewConfigUseCase_Default(t *testing.T) {25 func TestReadSystemviewConfigUseCase_Default(t *testing.T) {
26 reader := portmocks.NewMockSystemviewConfigReader(t)26 reader := portmocks.NewMockSystemviewConfigReader(t)
27- want := port.SystemviewConfigPayload{EngineType: "webkit"}27+ want := dto.SystemviewConfigPayload{EngineType: "webkit"}
28 reader.EXPECT().Default(mock.Anything).Return(want, nil).Once()28 reader.EXPECT().Default(mock.Anything).Return(want, nil).Once()
29 29
30 uc := NewReadSystemviewConfigUseCase(reader)30 uc := NewReadSystemviewConfigUseCase(reader)
M internal/application/usecase/save_webui_config.go +8 -4
@@ -6,6 +6,7 @@ import (
66 "math"
77 "strings"
88
9+ "github.com/bnema/dumber/internal/application/dto"
910 "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/domain/validation"
1112 )
@@ -18,7 +19,7 @@ func NewSaveWebUIConfigUseCase(saver port.WebUIConfigSaver) *SaveWebUIConfigUseC
1819 return &SaveWebUIConfigUseCase{saver: saver}
1920 }
2021
21-func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUIConfig) error {
22+func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg dto.WebUIConfig) error {
2223 if uc == nil || uc.saver == nil {
2324 return fmt.Errorf("config saver is nil")
2425 }
@@ -31,16 +32,19 @@ func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUICon
3132 return uc.saver.SaveWebUIConfig(ctx, normalized)
3233 }
3334
34-func normalizeWebUIConfig(cfg port.WebUIConfig) port.WebUIConfig {
35+func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig {
3536 cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)
3637 cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)
3738 cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont)
3839 cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme)
3940 cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine)
4041 if len(cfg.SearchShortcuts) > 0 {
41- normalized := make(map[string]port.SearchShortcut, len(cfg.SearchShortcuts))
42+ normalized := make(map[string]dto.SearchShortcut, len(cfg.SearchShortcuts))
4243 for key, shortcut := range cfg.SearchShortcuts {
4344 trimmedKey := strings.TrimSpace(key)
45+ if trimmedKey == "" {
46+ continue
47+ }
4448 shortcut.URL = strings.TrimSpace(shortcut.URL)
4549 shortcut.Description = strings.TrimSpace(shortcut.Description)
4650 normalized[trimmedKey] = shortcut
@@ -50,7 +54,7 @@ func normalizeWebUIConfig(cfg port.WebUIConfig) port.WebUIConfig {
5054 return cfg
5155 }
5256
53-func validateWebUIConfig(cfg port.WebUIConfig) error {
57+func validateWebUIConfig(cfg dto.WebUIConfig) error {
5458 var errs []string
5559
5660 if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {
@@ -6,6 +6,7 @@ import (
6 "math"6 "math"
7 "strings"7 "strings"
8 8
9+ "github.com/bnema/dumber/internal/application/dto"
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/domain/validation"11 "github.com/bnema/dumber/internal/domain/validation"
11 )12 )
@@ -18,7 +19,7 @@ func NewSaveWebUIConfigUseCase(saver port.WebUIConfigSaver) *SaveWebUIConfigUseC
18 return &SaveWebUIConfigUseCase{saver: saver}19 return &SaveWebUIConfigUseCase{saver: saver}
19 }20 }
20 21
21-func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUIConfig) error {22+func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg dto.WebUIConfig) error {
22 if uc == nil || uc.saver == nil {23 if uc == nil || uc.saver == nil {
23 return fmt.Errorf("config saver is nil")24 return fmt.Errorf("config saver is nil")
24 }25 }
@@ -31,16 +32,19 @@ func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUICon
31 return uc.saver.SaveWebUIConfig(ctx, normalized)32 return uc.saver.SaveWebUIConfig(ctx, normalized)
32 }33 }
33 34
34-func normalizeWebUIConfig(cfg port.WebUIConfig) port.WebUIConfig {35+func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig {
35 cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)36 cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)
36 cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)37 cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)
37 cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont)38 cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont)
38 cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme)39 cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme)
39 cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine)40 cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine)
40 if len(cfg.SearchShortcuts) > 0 {41 if len(cfg.SearchShortcuts) > 0 {
41- normalized := make(map[string]port.SearchShortcut, len(cfg.SearchShortcuts))42+ normalized := make(map[string]dto.SearchShortcut, len(cfg.SearchShortcuts))
42 for key, shortcut := range cfg.SearchShortcuts {43 for key, shortcut := range cfg.SearchShortcuts {
43 trimmedKey := strings.TrimSpace(key)44 trimmedKey := strings.TrimSpace(key)
45+ if trimmedKey == "" {
46+ continue
47+ }
44 shortcut.URL = strings.TrimSpace(shortcut.URL)48 shortcut.URL = strings.TrimSpace(shortcut.URL)
45 shortcut.Description = strings.TrimSpace(shortcut.Description)49 shortcut.Description = strings.TrimSpace(shortcut.Description)
46 normalized[trimmedKey] = shortcut50 normalized[trimmedKey] = shortcut
@@ -50,7 +54,7 @@ func normalizeWebUIConfig(cfg port.WebUIConfig) port.WebUIConfig {
50 return cfg54 return cfg
51 }55 }
52 56
53-func validateWebUIConfig(cfg port.WebUIConfig) error {57+func validateWebUIConfig(cfg dto.WebUIConfig) error {
54 var errs []string58 var errs []string
55 59
56 if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {60 if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {
M internal/application/usecase/save_webui_config_test.go +17 -22
@@ -5,7 +5,7 @@ import (
55 "math"
66 "testing"
77
8- "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/dto"
99 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1010 "github.com/bnema/dumber/internal/application/usecase"
1111 "github.com/stretchr/testify/mock"
@@ -15,40 +15,33 @@ import (
1515 func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
1616 tests := []struct {
1717 name string
18- mutate func(*port.WebUIConfig)
18+ mutate func(*dto.WebUIConfig)
1919 wantErr string
2020 }{
2121 {
2222 name: "rejects invalid default search URL",
23- mutate: func(cfg *port.WebUIConfig) {
23+ mutate: func(cfg *dto.WebUIConfig) {
2424 cfg.DefaultSearchEngine = "not-a-url-%s"
2525 },
2626 wantErr: "default_search_engine",
2727 },
28- {
29- name: "rejects blank search shortcut key",
30- mutate: func(cfg *port.WebUIConfig) {
31- cfg.SearchShortcuts[" "] = port.SearchShortcut{URL: "https://example.com?q=%s", Description: "Example"}
32- },
33- wantErr: "shortcut key cannot be empty",
34- },
3528 {
3629 name: "rejects invalid performance profile",
37- mutate: func(cfg *port.WebUIConfig) {
30+ mutate: func(cfg *dto.WebUIConfig) {
3831 cfg.Performance.Profile = "turbo"
3932 },
4033 wantErr: "performance.profile",
4134 },
4235 {
4336 name: "rejects NaN UI scale",
44- mutate: func(cfg *port.WebUIConfig) {
37+ mutate: func(cfg *dto.WebUIConfig) {
4538 cfg.DefaultUIScale = math.NaN()
4639 },
4740 wantErr: "default_ui_scale must be a finite value",
4841 },
4942 {
5043 name: "rejects infinite UI scale",
51- mutate: func(cfg *port.WebUIConfig) {
44+ mutate: func(cfg *dto.WebUIConfig) {
5245 cfg.DefaultUIScale = math.Inf(1)
5346 },
5447 wantErr: "default_ui_scale must be a finite value",
@@ -72,25 +65,27 @@ func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
7265
7366 func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {
7467 saver := portmocks.NewMockWebUIConfigSaver(t)
75- var saved port.WebUIConfig
76- saver.EXPECT().SaveWebUIConfig(mock.Anything, mock.AnythingOfType("port.WebUIConfig")).Run(func(_ context.Context, cfg port.WebUIConfig) {
68+ var saved dto.WebUIConfig
69+ saver.EXPECT().SaveWebUIConfig(mock.Anything, mock.AnythingOfType("dto.WebUIConfig")).Run(func(_ context.Context, cfg dto.WebUIConfig) {
7770 saved = cfg
7871 }).Return(nil).Once()
7972 uc := usecase.NewSaveWebUIConfigUseCase(saver)
8073 cfg := validWebUIConfig()
81- cfg.SearchShortcuts[" ddg "] = port.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "}
74+ cfg.SearchShortcuts[" ddg "] = dto.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "}
75+ cfg.SearchShortcuts[" "] = dto.SearchShortcut{URL: "https://example.com?q=%s", Description: "Example"}
8276
8377 err := uc.Execute(context.Background(), cfg)
8478
8579 require.NoError(t, err)
8680 require.NotContains(t, saved.SearchShortcuts, " ddg ")
81+ require.NotContains(t, saved.SearchShortcuts, "")
8782 require.Contains(t, saved.SearchShortcuts, "ddg")
8883 require.Equal(t, "https://duckduckgo.com/?q=%s", saved.SearchShortcuts["ddg"].URL)
8984 require.Equal(t, "DuckDuckGo", saved.SearchShortcuts["ddg"].Description)
9085 }
9186
92-func validWebUIConfig() port.WebUIConfig {
93- palette := port.ColorPalette{
87+func validWebUIConfig() dto.WebUIConfig {
88+ palette := dto.ColorPalette{
9489 Background: "#ffffff",
9590 Surface: "#f8f8f8",
9691 SurfaceVariant: "#eeeeee",
@@ -99,8 +94,8 @@ func validWebUIConfig() port.WebUIConfig {
9994 Accent: "#0055ff",
10095 Border: "#dddddd",
10196 }
102- return port.WebUIConfig{
103- Appearance: port.WebUIAppearanceConfig{
97+ return dto.WebUIConfig{
98+ Appearance: dto.WebUIAppearanceConfig{
10499 SansFont: "Inter",
105100 SerifFont: "Georgia",
106101 MonospaceFont: "JetBrains Mono",
@@ -109,11 +104,11 @@ func validWebUIConfig() port.WebUIConfig {
109104 LightPalette: palette,
110105 DarkPalette: palette,
111106 },
112- Performance: port.WebUIPerformanceConfig{
107+ Performance: dto.WebUIPerformanceConfig{
113108 Profile: "default",
114109 },
115110 DefaultUIScale: 1,
116111 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
117- SearchShortcuts: map[string]port.SearchShortcut{},
112+ SearchShortcuts: map[string]dto.SearchShortcut{},
118113 }
119114 }
@@ -5,7 +5,7 @@ import (
5 "math"5 "math"
6 "testing"6 "testing"
7 7
8- "github.com/bnema/dumber/internal/application/port"8+ "github.com/bnema/dumber/internal/application/dto"
9 portmocks "github.com/bnema/dumber/internal/application/port/mocks"9 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
10 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
11 "github.com/stretchr/testify/mock"11 "github.com/stretchr/testify/mock"
@@ -15,40 +15,33 @@ import (
15 func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {15 func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
16 tests := []struct {16 tests := []struct {
17 name string17 name string
18- mutate func(*port.WebUIConfig)18+ mutate func(*dto.WebUIConfig)
19 wantErr string19 wantErr string
20 }{20 }{
21 {21 {
22 name: "rejects invalid default search URL",22 name: "rejects invalid default search URL",
23- mutate: func(cfg *port.WebUIConfig) {23+ mutate: func(cfg *dto.WebUIConfig) {
24 cfg.DefaultSearchEngine = "not-a-url-%s"24 cfg.DefaultSearchEngine = "not-a-url-%s"
25 },25 },
26 wantErr: "default_search_engine",26 wantErr: "default_search_engine",
27 },27 },
28- {
29- name: "rejects blank search shortcut key",
30- mutate: func(cfg *port.WebUIConfig) {
31- cfg.SearchShortcuts[" "] = port.SearchShortcut{URL: "https://example.com?q=%s", Description: "Example"}
32- },
33- wantErr: "shortcut key cannot be empty",
34- },
35 {28 {
36 name: "rejects invalid performance profile",29 name: "rejects invalid performance profile",
37- mutate: func(cfg *port.WebUIConfig) {30+ mutate: func(cfg *dto.WebUIConfig) {
38 cfg.Performance.Profile = "turbo"31 cfg.Performance.Profile = "turbo"
39 },32 },
40 wantErr: "performance.profile",33 wantErr: "performance.profile",
41 },34 },
42 {35 {
43 name: "rejects NaN UI scale",36 name: "rejects NaN UI scale",
44- mutate: func(cfg *port.WebUIConfig) {37+ mutate: func(cfg *dto.WebUIConfig) {
45 cfg.DefaultUIScale = math.NaN()38 cfg.DefaultUIScale = math.NaN()
46 },39 },
47 wantErr: "default_ui_scale must be a finite value",40 wantErr: "default_ui_scale must be a finite value",
48 },41 },
49 {42 {
50 name: "rejects infinite UI scale",43 name: "rejects infinite UI scale",
51- mutate: func(cfg *port.WebUIConfig) {44+ mutate: func(cfg *dto.WebUIConfig) {
52 cfg.DefaultUIScale = math.Inf(1)45 cfg.DefaultUIScale = math.Inf(1)
53 },46 },
54 wantErr: "default_ui_scale must be a finite value",47 wantErr: "default_ui_scale must be a finite value",
@@ -72,25 +65,27 @@ func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
72 65
73 func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {66 func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {
74 saver := portmocks.NewMockWebUIConfigSaver(t)67 saver := portmocks.NewMockWebUIConfigSaver(t)
75- var saved port.WebUIConfig68+ var saved dto.WebUIConfig
76- saver.EXPECT().SaveWebUIConfig(mock.Anything, mock.AnythingOfType("port.WebUIConfig")).Run(func(_ context.Context, cfg port.WebUIConfig) {69+ saver.EXPECT().SaveWebUIConfig(mock.Anything, mock.AnythingOfType("dto.WebUIConfig")).Run(func(_ context.Context, cfg dto.WebUIConfig) {
77 saved = cfg70 saved = cfg
78 }).Return(nil).Once()71 }).Return(nil).Once()
79 uc := usecase.NewSaveWebUIConfigUseCase(saver)72 uc := usecase.NewSaveWebUIConfigUseCase(saver)
80 cfg := validWebUIConfig()73 cfg := validWebUIConfig()
81- cfg.SearchShortcuts[" ddg "] = port.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "}74+ cfg.SearchShortcuts[" ddg "] = dto.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "}
75+ cfg.SearchShortcuts[" "] = dto.SearchShortcut{URL: "https://example.com?q=%s", Description: "Example"}
82 76
83 err := uc.Execute(context.Background(), cfg)77 err := uc.Execute(context.Background(), cfg)
84 78
85 require.NoError(t, err)79 require.NoError(t, err)
86 require.NotContains(t, saved.SearchShortcuts, " ddg ")80 require.NotContains(t, saved.SearchShortcuts, " ddg ")
81+ require.NotContains(t, saved.SearchShortcuts, "")
87 require.Contains(t, saved.SearchShortcuts, "ddg")82 require.Contains(t, saved.SearchShortcuts, "ddg")
88 require.Equal(t, "https://duckduckgo.com/?q=%s", saved.SearchShortcuts["ddg"].URL)83 require.Equal(t, "https://duckduckgo.com/?q=%s", saved.SearchShortcuts["ddg"].URL)
89 require.Equal(t, "DuckDuckGo", saved.SearchShortcuts["ddg"].Description)84 require.Equal(t, "DuckDuckGo", saved.SearchShortcuts["ddg"].Description)
90 }85 }
91 86
92-func validWebUIConfig() port.WebUIConfig {87+func validWebUIConfig() dto.WebUIConfig {
93- palette := port.ColorPalette{88+ palette := dto.ColorPalette{
94 Background: "#ffffff",89 Background: "#ffffff",
95 Surface: "#f8f8f8",90 Surface: "#f8f8f8",
96 SurfaceVariant: "#eeeeee",91 SurfaceVariant: "#eeeeee",
@@ -99,8 +94,8 @@ func validWebUIConfig() port.WebUIConfig {
99 Accent: "#0055ff",94 Accent: "#0055ff",
100 Border: "#dddddd",95 Border: "#dddddd",
101 }96 }
102- return port.WebUIConfig{97+ return dto.WebUIConfig{
103- Appearance: port.WebUIAppearanceConfig{98+ Appearance: dto.WebUIAppearanceConfig{
104 SansFont: "Inter",99 SansFont: "Inter",
105 SerifFont: "Georgia",100 SerifFont: "Georgia",
106 MonospaceFont: "JetBrains Mono",101 MonospaceFont: "JetBrains Mono",
@@ -109,11 +104,11 @@ func validWebUIConfig() port.WebUIConfig {
109 LightPalette: palette,104 LightPalette: palette,
110 DarkPalette: palette,105 DarkPalette: palette,
111 },106 },
112- Performance: port.WebUIPerformanceConfig{107+ Performance: dto.WebUIPerformanceConfig{
113 Profile: "default",108 Profile: "default",
114 },109 },
115 DefaultUIScale: 1,110 DefaultUIScale: 1,
116 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",111 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
117- SearchShortcuts: map[string]port.SearchShortcut{},112+ SearchShortcuts: map[string]dto.SearchShortcut{},
118 }113 }
119 }114 }
M internal/application/usecase/search_history_test.go +6 -9
@@ -155,8 +155,10 @@ func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testin
155155 func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testing.T) {
156156 ctx := testContext()
157157 historyRepo := repomocks.NewMockHistoryRepository(t)
158+ now := time.Now()
159+ expectedCutoff := now.Add(-time.Hour)
158160 historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
159- return !since.IsZero() && time.Since(since) <= time.Hour+time.Minute
161+ return since.After(expectedCutoff.Add(-time.Minute)) && since.Before(expectedCutoff.Add(time.Minute))
160162 })).Return(nil).Once()
161163
162164 uc := usecase.NewSearchHistoryUseCase(historyRepo)
@@ -168,15 +170,10 @@ func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testin
168170 func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) {
169171 ctx := testContext()
170172 historyRepo := repomocks.NewMockHistoryRepository(t)
171- today := time.Now()
173+ now := time.Now()
174+ expectedCutoff := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
172175 historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
173- return since.Year() == today.Year() &&
174- since.Month() == today.Month() &&
175- since.Day() == today.Day() &&
176- since.Hour() == 0 &&
177- since.Minute() == 0 &&
178- since.Second() == 0 &&
179- since.Nanosecond() == 0
176+ return since.Equal(expectedCutoff)
180177 })).Return(nil).Once()
181178
182179 uc := usecase.NewSearchHistoryUseCase(historyRepo)
@@ -155,8 +155,10 @@ func TestSearchHistoryUseCase_GetMostVisited_ReturnsEmptyWhenNoHistory(t *testin
155 func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testing.T) {155 func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testing.T) {
156 ctx := testContext()156 ctx := testContext()
157 historyRepo := repomocks.NewMockHistoryRepository(t)157 historyRepo := repomocks.NewMockHistoryRepository(t)
158+ now := time.Now()
159+ expectedCutoff := now.Add(-time.Hour)
158 historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {160 historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
159- return !since.IsZero() && time.Since(since) <= time.Hour+time.Minute161+ return since.After(expectedCutoff.Add(-time.Minute)) && since.Before(expectedCutoff.Add(time.Minute))
160 })).Return(nil).Once()162 })).Return(nil).Once()
161 163
162 uc := usecase.NewSearchHistoryUseCase(historyRepo)164 uc := usecase.NewSearchHistoryUseCase(historyRepo)
@@ -168,15 +170,10 @@ func TestSearchHistoryUseCase_ClearRange_RecentRangeDeletesSinceCutoff(t *testin
168 func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) {170 func TestSearchHistoryUseCase_ClearRange_TodayDeletesSinceLocalMidnight(t *testing.T) {
169 ctx := testContext()171 ctx := testContext()
170 historyRepo := repomocks.NewMockHistoryRepository(t)172 historyRepo := repomocks.NewMockHistoryRepository(t)
171- today := time.Now()173+ now := time.Now()
174+ expectedCutoff := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
172 historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {175 historyRepo.EXPECT().DeleteSince(mock.Anything, mock.MatchedBy(func(since time.Time) bool {
173- return since.Year() == today.Year() &&176+ return since.Equal(expectedCutoff)
174- since.Month() == today.Month() &&
175- since.Day() == today.Day() &&
176- since.Hour() == 0 &&
177- since.Minute() == 0 &&
178- since.Second() == 0 &&
179- since.Nanosecond() == 0
180 })).Return(nil).Once()177 })).Return(nil).Once()
181 178
182 uc := usecase.NewSearchHistoryUseCase(historyRepo)179 uc := usecase.NewSearchHistoryUseCase(historyRepo)
M internal/bootstrap/engine.go +2 -1
@@ -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"
@@ -35,7 +36,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
3536 cfg := input.Config
3637 systemviewReader := config.NewSystemviewConfigReader(env.NewHardwareSurveyor())
3738 systemviewUC := usecase.NewReadSystemviewConfigUseCase(systemviewReader)
38- buildConfigPayload := func(read func(context.Context) (port.SystemviewConfigPayload, error)) func() ([]byte, error) {
39+ buildConfigPayload := func(read func(context.Context) (dto.SystemviewConfigPayload, error)) func() ([]byte, error) {
3940 return func() ([]byte, error) {
4041 payload, err := read(input.Ctx)
4142 if err != nil {
@@ -5,6 +5,7 @@ import (
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7 7
8+ "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
10 audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"11 audiofactory "github.com/bnema/dumber/internal/infrastructure/audio/factory"
@@ -35,7 +36,7 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
35 cfg := input.Config36 cfg := input.Config
36 systemviewReader := config.NewSystemviewConfigReader(env.NewHardwareSurveyor())37 systemviewReader := config.NewSystemviewConfigReader(env.NewHardwareSurveyor())
37 systemviewUC := usecase.NewReadSystemviewConfigUseCase(systemviewReader)38 systemviewUC := usecase.NewReadSystemviewConfigUseCase(systemviewReader)
38- buildConfigPayload := func(read func(context.Context) (port.SystemviewConfigPayload, error)) func() ([]byte, error) {39+ buildConfigPayload := func(read func(context.Context) (dto.SystemviewConfigPayload, error)) func() ([]byte, error) {
39 return func() ([]byte, error) {40 return func() ([]byte, error) {
40 payload, err := read(input.Ctx)41 payload, err := read(input.Ctx)
41 if err != nil {42 if err != nil {
M internal/infrastructure/cef/scheme_handler.go +3 -4
@@ -279,10 +279,6 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
279279 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) {
280280 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
281281 }
282- if err := validateTranscodeSourceURL(h.ctx, sourceParsed); err != nil {
283- return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": err.Error()})
284- }
285-
286282 headers := make(map[string]string)
287283 if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" {
288284 headers["User-Agent"] = userAgent
@@ -308,6 +304,9 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
308304 headers: headers,
309305 ctx: ctx,
310306 cancel: cancel,
307+ validateSource: func(ctx context.Context) error {
308+ return validateTranscodeSourceURL(ctx, sourceParsed)
309+ },
311310 logf: func() zerolog.Logger {
312311 return h.logger.With().Str("component", "scheme-transcoding").Logger()
313312 },
@@ -279,10 +279,6 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
279 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) {279 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != actualInternalScheme) {
280 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})280 return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
281 }281 }
282- if err := validateTranscodeSourceURL(h.ctx, sourceParsed); err != nil {
283- return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": err.Error()})
284- }
285-
286 headers := make(map[string]string)282 headers := make(map[string]string)
287 if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" {283 if userAgent := request.GetHeaderByName("User-Agent"); userAgent != "" {
288 headers["User-Agent"] = userAgent284 headers["User-Agent"] = userAgent
@@ -308,6 +304,9 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
308 headers: headers,304 headers: headers,
309 ctx: ctx,305 ctx: ctx,
310 cancel: cancel,306 cancel: cancel,
307+ validateSource: func(ctx context.Context) error {
308+ return validateTranscodeSourceURL(ctx, sourceParsed)
309+ },
311 logf: func() zerolog.Logger {310 logf: func() zerolog.Logger {
312 return h.logger.With().Str("component", "scheme-transcoding").Logger()311 return h.logger.With().Str("component", "scheme-transcoding").Logger()
313 },312 },
M internal/infrastructure/cef/transcoding_handler.go +68 -12
@@ -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,18 @@ 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+ callback.Cont()
79+ return
80+ }
81+ }
82+
6583 session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers)
6684 if err != nil {
6785 log.Error().
@@ -69,11 +87,14 @@ func (rh *transcodingResourceHandler) Open(
6987 Str("source_url", sourceURL).
7088 Msg("cef: failed to start transcoding resource stream")
7189 // Let CEF know we failed — Cancel will be called.
90+ rh.setStartErr(err, httpStatusBadGateway)
7291 rh.cancel()
7392 callback.Cont()
7493 return
7594 }
95+ rh.mu.Lock()
7696 rh.session = session
97+ rh.mu.Unlock()
7798 log.Info().
7899 Str("source_url", sourceURL).
79100 Str("content_type", session.ContentType()).
@@ -84,10 +105,39 @@ func (rh *transcodingResourceHandler) Open(
84105 return 1
85106 }
86107
108+func (rh *transcodingResourceHandler) setStartErr(err error, status int) {
109+ rh.mu.Lock()
110+ defer rh.mu.Unlock()
111+ rh.startErr = err
112+ rh.startErrStatus = status
113+}
114+
115+func (rh *transcodingResourceHandler) getStartErr() (error, int) {
116+ rh.mu.Lock()
117+ defer rh.mu.Unlock()
118+ return rh.startErr, rh.startErrStatus
119+}
120+
87121 // GetResponseHeaders sets the streaming response metadata.
88122 func (rh *transcodingResourceHandler) GetResponseHeaders(
89123 response purecef.Response, responseLength *int64, _ uintptr,
90124 ) {
125+ if startErr, status := rh.getStartErr(); startErr != nil {
126+ if status == 0 {
127+ status = httpStatusBadGateway
128+ }
129+ response.SetStatus(int32(status))
130+ if status == httpStatusForbidden {
131+ response.SetStatusText("Forbidden")
132+ } else {
133+ response.SetStatusText("Bad Gateway")
134+ }
135+ response.SetMimeType("text/plain")
136+ if responseLength != nil {
137+ *responseLength = 0
138+ }
139+ return
140+ }
91141 response.SetStatus(httpStatusOK)
92142 response.SetStatusText("OK")
93143 response.SetMimeType("video/webm")
@@ -108,12 +158,15 @@ func (rh *transcodingResourceHandler) Read(
108158 dataOut unsafe.Pointer, bytesToRead int32,
109159 bytesRead *int32, _ purecef.ResourceReadCallback,
110160 ) int32 {
111- if rh.session == nil {
161+ rh.mu.Lock()
162+ session := rh.session
163+ rh.mu.Unlock()
164+ if session == nil {
112165 return 0
113166 }
114167
115168 dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead))
116- n, err := rh.session.Read(dst)
169+ n, err := session.Read(dst)
117170 if n > 0 {
118171 rh.totalBytes += int64(n)
119172 if rh.totalBytes == int64(n) {
@@ -153,8 +206,11 @@ func (rh *transcodingResourceHandler) Cancel() {
153206 Str("source_url", sourceURL).
154207 Int64("total_bytes", rh.totalBytes).
155208 Msg("cef: transcoding resource stream canceled")
156- if rh.session != nil {
157- if err := rh.session.Close(); err != nil {
209+ rh.mu.Lock()
210+ session := rh.session
211+ rh.mu.Unlock()
212+ if session != nil {
213+ if err := session.Close(); err != nil {
158214 rh.logger().Warn().
159215 Str("source_url", sourceURL).
160216 Err(err).
@@ -24,6 +24,8 @@ var _ purecef.ResourceRequestHandler = (*transcodingRequestHandler)(nil)
24 const (24 const (
25 maxTranscodingURLLength = 24025 maxTranscodingURLLength = 240
26 httpStatusOK = 20026 httpStatusOK = 200
27+ httpStatusForbidden = 403
28+ httpStatusBadGateway = 502
27 )29 )
28 30
29 // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler31 // transcodingResourceHandler is checked indirectly: purecef.NewResourceHandler
@@ -34,14 +36,18 @@ const (
34 // ---------------------------------------------------------------------------36 // ---------------------------------------------------------------------------
35 37
36 type transcodingResourceHandler struct {38 type transcodingResourceHandler struct {
37- transcoder port.MediaTranscoder39+ transcoder port.MediaTranscoder
38- sourceURL string40+ sourceURL string
39- headers map[string]string41+ headers map[string]string
40- session port.TranscodeSession42+ session port.TranscodeSession
41- ctx context.Context43+ ctx context.Context
42- cancel context.CancelFunc44+ cancel context.CancelFunc
43- logf func() zerolog.Logger45+ logf func() zerolog.Logger
44- totalBytes int6446+ validateSource func(context.Context) error
47+ mu sync.Mutex
48+ startErr error
49+ startErrStatus int
50+ totalBytes int64
45 }51 }
46 52
47 // Open starts the transcode session asynchronously. CEF will call53 // Open starts the transcode session asynchronously. CEF will call
@@ -62,6 +68,18 @@ func (rh *transcodingResourceHandler) Open(
62 Msg("cef: starting transcoding resource stream")68 Msg("cef: starting transcoding resource stream")
63 69
64 go func() {70 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+ callback.Cont()
79+ return
80+ }
81+ }
82+
65 session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers)83 session, err := rh.transcoder.Start(rh.ctx, rh.sourceURL, rh.headers)
66 if err != nil {84 if err != nil {
67 log.Error().85 log.Error().
@@ -69,11 +87,14 @@ func (rh *transcodingResourceHandler) Open(
69 Str("source_url", sourceURL).87 Str("source_url", sourceURL).
70 Msg("cef: failed to start transcoding resource stream")88 Msg("cef: failed to start transcoding resource stream")
71 // Let CEF know we failed — Cancel will be called.89 // Let CEF know we failed — Cancel will be called.
90+ rh.setStartErr(err, httpStatusBadGateway)
72 rh.cancel()91 rh.cancel()
73 callback.Cont()92 callback.Cont()
74 return93 return
75 }94 }
95+ rh.mu.Lock()
76 rh.session = session96 rh.session = session
97+ rh.mu.Unlock()
77 log.Info().98 log.Info().
78 Str("source_url", sourceURL).99 Str("source_url", sourceURL).
79 Str("content_type", session.ContentType()).100 Str("content_type", session.ContentType()).
@@ -84,10 +105,39 @@ func (rh *transcodingResourceHandler) Open(
84 return 1105 return 1
85 }106 }
86 107
108+func (rh *transcodingResourceHandler) setStartErr(err error, status int) {
109+ rh.mu.Lock()
110+ defer rh.mu.Unlock()
111+ rh.startErr = err
112+ rh.startErrStatus = status
113+}
114+
115+func (rh *transcodingResourceHandler) getStartErr() (error, int) {
116+ rh.mu.Lock()
117+ defer rh.mu.Unlock()
118+ return rh.startErr, rh.startErrStatus
119+}
120+
87 // GetResponseHeaders sets the streaming response metadata.121 // GetResponseHeaders sets the streaming response metadata.
88 func (rh *transcodingResourceHandler) GetResponseHeaders(122 func (rh *transcodingResourceHandler) GetResponseHeaders(
89 response purecef.Response, responseLength *int64, _ uintptr,123 response purecef.Response, responseLength *int64, _ uintptr,
90 ) {124 ) {
125+ if startErr, status := rh.getStartErr(); startErr != nil {
126+ if status == 0 {
127+ status = httpStatusBadGateway
128+ }
129+ response.SetStatus(int32(status))
130+ if status == httpStatusForbidden {
131+ response.SetStatusText("Forbidden")
132+ } else {
133+ response.SetStatusText("Bad Gateway")
134+ }
135+ response.SetMimeType("text/plain")
136+ if responseLength != nil {
137+ *responseLength = 0
138+ }
139+ return
140+ }
91 response.SetStatus(httpStatusOK)141 response.SetStatus(httpStatusOK)
92 response.SetStatusText("OK")142 response.SetStatusText("OK")
93 response.SetMimeType("video/webm")143 response.SetMimeType("video/webm")
@@ -108,12 +158,15 @@ func (rh *transcodingResourceHandler) Read(
108 dataOut unsafe.Pointer, bytesToRead int32,158 dataOut unsafe.Pointer, bytesToRead int32,
109 bytesRead *int32, _ purecef.ResourceReadCallback,159 bytesRead *int32, _ purecef.ResourceReadCallback,
110 ) int32 {160 ) int32 {
111- if rh.session == nil {161+ rh.mu.Lock()
162+ session := rh.session
163+ rh.mu.Unlock()
164+ if session == nil {
112 return 0165 return 0
113 }166 }
114 167
115 dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead))168 dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead))
116- n, err := rh.session.Read(dst)169+ n, err := session.Read(dst)
117 if n > 0 {170 if n > 0 {
118 rh.totalBytes += int64(n)171 rh.totalBytes += int64(n)
119 if rh.totalBytes == int64(n) {172 if rh.totalBytes == int64(n) {
@@ -153,8 +206,11 @@ func (rh *transcodingResourceHandler) Cancel() {
153 Str("source_url", sourceURL).206 Str("source_url", sourceURL).
154 Int64("total_bytes", rh.totalBytes).207 Int64("total_bytes", rh.totalBytes).
155 Msg("cef: transcoding resource stream canceled")208 Msg("cef: transcoding resource stream canceled")
156- if rh.session != nil {209+ rh.mu.Lock()
157- if err := rh.session.Close(); err != nil {210+ session := rh.session
211+ rh.mu.Unlock()
212+ if session != nil {
213+ if err := session.Close(); err != nil {
158 rh.logger().Warn().214 rh.logger().Warn().
159 Str("source_url", sourceURL).215 Str("source_url", sourceURL).
160 Err(err).216 Err(err).
M internal/infrastructure/config/defaults_test.go +4 -0
@@ -41,5 +41,9 @@ func requireActionBinding(t *testing.T, actions map[string]ActionBinding, action
4141 t.Helper()
4242 binding, ok := actions[action]
4343 require.True(t, ok, "missing action binding %q", action)
44+ if len(keys) == 0 {
45+ assert.Empty(t, binding.Keys)
46+ return
47+ }
4448 assert.Equal(t, keys, binding.Keys)
4549 }
@@ -41,5 +41,9 @@ func requireActionBinding(t *testing.T, actions map[string]ActionBinding, action
41 t.Helper()41 t.Helper()
42 binding, ok := actions[action]42 binding, ok := actions[action]
43 require.True(t, ok, "missing action binding %q", 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+ }
44 assert.Equal(t, keys, binding.Keys)48 assert.Equal(t, keys, binding.Keys)
45 }49 }
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
@@ -777,6 +777,10 @@ func (m *Migrator) detectMissingWorkspaceShortcutActions(userKeysWithValues map[
777 if _, exists := userActions[actionName]; exists {777 if _, exists := userActions[actionName]; exists {
778 continue778 continue
779 }779 }
780+ legacyActionName := strings.ReplaceAll(actionName, "-", "_")
781+ if _, exists := userActions[legacyActionName]; exists {
782+ continue
783+ }
780 missing = append(missing, workspaceShortcutsActionsKey+"."+actionName)784 missing = append(missing, workspaceShortcutsActionsKey+"."+actionName)
781 }785 }
782 786
@@ -801,6 +805,10 @@ func (m *Migrator) mergeMissingWorkspaceShortcutActions(rawConfig map[string]any
801 if _, exists := userActions[actionName]; exists {805 if _, exists := userActions[actionName]; exists {
802 continue806 continue
803 }807 }
808+ legacyActionName := strings.ReplaceAll(actionName, "-", "_")
809+ if _, exists := userActions[legacyActionName]; exists {
810+ continue
811+ }
804 userActions[actionName] = defaultAction812 userActions[actionName] = defaultAction
805 }813 }
806 814
M internal/infrastructure/config/migrate_test.go +7 -0
@@ -759,6 +759,13 @@ 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) {
@@ -759,6 +759,13 @@ func TestMigrator_MergeMissingWorkspaceShortcutActions(t *testing.T) {
759 closePane, ok := closePaneAny.(map[string]any)759 closePane, ok := closePaneAny.(map[string]any)
760 require.True(t, ok)760 require.True(t, ok)
761 assert.Equal(t, "Custom close pane", closePane["desc"])761 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")
762 }769 }
763 770
764 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {771 func TestMigrator_DefaultValueForKey_WorkspaceShortcutAction(t *testing.T) {
M internal/infrastructure/config/systemview_reader.go +5 -4
@@ -3,6 +3,7 @@ package config
33 import (
44 "context"
55
6+ "github.com/bnema/dumber/internal/application/dto"
67 "github.com/bnema/dumber/internal/application/port"
78 )
89
@@ -17,17 +18,17 @@ func NewSystemviewConfigReader(hwSurveyor port.HardwareSurveyor) port.Systemview
1718 return &systemviewConfigReader{hwSurveyor: hwSurveyor}
1819 }
1920
20-func (r *systemviewConfigReader) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
21+func (r *systemviewConfigReader) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
2122 return r.build(ctx, Get())
2223 }
2324
24-func (r *systemviewConfigReader) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
25+func (r *systemviewConfigReader) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
2526 return r.build(ctx, DefaultConfig())
2627 }
2728
28-func (r *systemviewConfigReader) build(ctx context.Context, cfg *Config) (port.SystemviewConfigPayload, error) {
29+func (r *systemviewConfigReader) build(ctx context.Context, cfg *Config) (dto.SystemviewConfigPayload, error) {
2930 if cfg == nil {
30- return port.SystemviewConfigPayload{}, nil
31+ return dto.SystemviewConfigPayload{}, nil
3132 }
3233
3334 var hw *port.HardwareInfo
@@ -3,6 +3,7 @@ package config
3 import (3 import (
4 "context"4 "context"
5 5
6+ "github.com/bnema/dumber/internal/application/dto"
6 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
7 )8 )
8 9
@@ -17,17 +18,17 @@ func NewSystemviewConfigReader(hwSurveyor port.HardwareSurveyor) port.Systemview
17 return &systemviewConfigReader{hwSurveyor: hwSurveyor}18 return &systemviewConfigReader{hwSurveyor: hwSurveyor}
18 }19 }
19 20
20-func (r *systemviewConfigReader) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {21+func (r *systemviewConfigReader) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
21 return r.build(ctx, Get())22 return r.build(ctx, Get())
22 }23 }
23 24
24-func (r *systemviewConfigReader) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {25+func (r *systemviewConfigReader) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
25 return r.build(ctx, DefaultConfig())26 return r.build(ctx, DefaultConfig())
26 }27 }
27 28
28-func (r *systemviewConfigReader) build(ctx context.Context, cfg *Config) (port.SystemviewConfigPayload, error) {29+func (r *systemviewConfigReader) build(ctx context.Context, cfg *Config) (dto.SystemviewConfigPayload, error) {
29 if cfg == nil {30 if cfg == nil {
30- return port.SystemviewConfigPayload{}, nil31+ return dto.SystemviewConfigPayload{}, nil
31 }32 }
32 33
33 var hw *port.HardwareInfo34 var hw *port.HardwareInfo
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")
@@ -4,7 +4,7 @@ import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6 6
7- "github.com/bnema/dumber/internal/application/port"7+ "github.com/bnema/dumber/internal/application/dto"
8 )8 )
9 9
10 type WebUIConfigGateway struct {10 type WebUIConfigGateway struct {
@@ -33,7 +33,7 @@ func clampInt(v, lo, hi int) int {
33 return v33 return v
34 }34 }
35 35
36-func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error {36+func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIConfig) error {
37 _ = ctx37 _ = ctx
38 if g == nil || g.mgr == nil {38 if g == nil || g.mgr == nil {
39 return fmt.Errorf("config manager not initialized")39 return fmt.Errorf("config manager not initialized")
M internal/infrastructure/config/webui_payload.go +21 -18
@@ -1,12 +1,15 @@
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
58 // BuildSystemviewConfigPayload projects the full config into the stable DTO used by
69 // the settings page across engines.
7-func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) port.SystemviewConfigPayload {
8- payload := port.SystemviewConfigPayload{
9- SearchShortcuts: map[string]port.SearchShortcut{},
10+func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) dto.SystemviewConfigPayload {
11+ payload := dto.SystemviewConfigPayload{
12+ SearchShortcuts: map[string]dto.SearchShortcut{},
1013 }
1114 if cfg == nil {
1215 return payload
@@ -20,16 +23,16 @@ func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) port.Syste
2023 payload.DefaultSearchEngine = cfg.DefaultSearchEngine
2124 payload.SearchShortcuts = buildSystemviewSearchShortcutsPayload(cfg.SearchShortcuts)
2225 payload.EngineType = cfg.Engine.ResolveEngineType()
23- payload.Performance = port.SystemviewPerformancePayload{
26+ payload.Performance = dto.SystemviewPerformancePayload{
2427 Profile: string(cfg.Engine.Profile),
25- Custom: port.SystemviewCustomPerformancePayload{
28+ Custom: dto.SystemviewCustomPerformancePayload{
2629 SkiaCPUThreads: cfg.Engine.WebKit.SkiaCPUPaintingThreads,
2730 SkiaGPUThreads: cfg.Engine.WebKit.SkiaGPUPaintingThreads,
2831 WebProcessMemoryMB: cfg.Engine.WebKit.WebProcessMemoryLimitMB,
2932 NetworkProcessMemoryMB: cfg.Engine.WebKit.NetworkProcessMemoryLimitMB,
3033 WebViewPoolPrewarm: cfg.Engine.PoolPrewarmCount,
3134 },
32- Resolved: port.SystemviewResolvedPerformancePayload{
35+ Resolved: dto.SystemviewResolvedPerformancePayload{
3336 SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,
3437 SkiaGPUThreads: resolved.SkiaGPUPaintingThreads,
3538 WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB,
@@ -44,9 +47,9 @@ func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) port.Syste
4447 return payload
4548 }
4649
47-func buildSystemviewAppearancePayload(appearance AppearanceConfig) port.WebUIAppearanceConfig {
50+func buildSystemviewAppearancePayload(appearance AppearanceConfig) dto.WebUIAppearanceConfig {
4851 defaults := DefaultConfig().Appearance
49- return port.WebUIAppearanceConfig{
52+ return dto.WebUIAppearanceConfig{
5053 SansFont: appearance.SansFont,
5154 SerifFont: appearance.SerifFont,
5255 MonospaceFont: appearance.MonospaceFont,
@@ -57,8 +60,8 @@ func buildSystemviewAppearancePayload(appearance AppearanceConfig) port.WebUIApp
5760 }
5861 }
5962
60-func buildSystemviewPalettePayload(palette, fallback ColorPalette) port.ColorPalette {
61- return port.ColorPalette{
63+func buildSystemviewPalettePayload(palette, fallback ColorPalette) dto.ColorPalette {
64+ return dto.ColorPalette{
6265 Background: nonEmptyColor(palette.Background, fallback.Background),
6366 Surface: nonEmptyColor(palette.Surface, fallback.Surface),
6467 SurfaceVariant: nonEmptyColor(palette.SurfaceVariant, fallback.SurfaceVariant),
@@ -76,23 +79,23 @@ func nonEmptyColor(value, fallback string) string {
7679 return fallback
7780 }
7881
79-func buildSystemviewSearchShortcutsPayload(shortcuts map[string]SearchShortcut) map[string]port.SearchShortcut {
82+func buildSystemviewSearchShortcutsPayload(shortcuts map[string]SearchShortcut) map[string]dto.SearchShortcut {
8083 if len(shortcuts) == 0 {
81- return map[string]port.SearchShortcut{}
84+ return map[string]dto.SearchShortcut{}
8285 }
8386
84- result := make(map[string]port.SearchShortcut, len(shortcuts))
87+ result := make(map[string]dto.SearchShortcut, len(shortcuts))
8588 for key, shortcut := range shortcuts {
86- result[key] = port.SearchShortcut{URL: shortcut.URL, Description: shortcut.Description}
89+ result[key] = dto.SearchShortcut{URL: shortcut.URL, Description: shortcut.Description}
8790 }
8891 return result
8992 }
9093
91-func buildSystemviewHardwarePayload(hw *port.HardwareInfo) port.SystemviewHardwarePayload {
94+func buildSystemviewHardwarePayload(hw *port.HardwareInfo) dto.SystemviewHardwarePayload {
9295 if hw == nil {
93- return port.SystemviewHardwarePayload{}
96+ return dto.SystemviewHardwarePayload{}
9497 }
95- return port.SystemviewHardwarePayload{
98+ return dto.SystemviewHardwarePayload{
9699 CPUCores: hw.CPUCores,
97100 CPUThreads: hw.CPUThreads,
98101 TotalRAMMB: hw.TotalRAMMB(),
@@ -1,12 +1,15 @@
1 package config1 package config
2 2
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+)
4 7
5 // BuildSystemviewConfigPayload projects the full config into the stable DTO used by8 // BuildSystemviewConfigPayload projects the full config into the stable DTO used by
6 // the settings page across engines.9 // the settings page across engines.
7-func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) port.SystemviewConfigPayload {10+func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) dto.SystemviewConfigPayload {
8- payload := port.SystemviewConfigPayload{11+ payload := dto.SystemviewConfigPayload{
9- SearchShortcuts: map[string]port.SearchShortcut{},12+ SearchShortcuts: map[string]dto.SearchShortcut{},
10 }13 }
11 if cfg == nil {14 if cfg == nil {
12 return payload15 return payload
@@ -20,16 +23,16 @@ func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) port.Syste
20 payload.DefaultSearchEngine = cfg.DefaultSearchEngine23 payload.DefaultSearchEngine = cfg.DefaultSearchEngine
21 payload.SearchShortcuts = buildSystemviewSearchShortcutsPayload(cfg.SearchShortcuts)24 payload.SearchShortcuts = buildSystemviewSearchShortcutsPayload(cfg.SearchShortcuts)
22 payload.EngineType = cfg.Engine.ResolveEngineType()25 payload.EngineType = cfg.Engine.ResolveEngineType()
23- payload.Performance = port.SystemviewPerformancePayload{26+ payload.Performance = dto.SystemviewPerformancePayload{
24 Profile: string(cfg.Engine.Profile),27 Profile: string(cfg.Engine.Profile),
25- Custom: port.SystemviewCustomPerformancePayload{28+ Custom: dto.SystemviewCustomPerformancePayload{
26 SkiaCPUThreads: cfg.Engine.WebKit.SkiaCPUPaintingThreads,29 SkiaCPUThreads: cfg.Engine.WebKit.SkiaCPUPaintingThreads,
27 SkiaGPUThreads: cfg.Engine.WebKit.SkiaGPUPaintingThreads,30 SkiaGPUThreads: cfg.Engine.WebKit.SkiaGPUPaintingThreads,
28 WebProcessMemoryMB: cfg.Engine.WebKit.WebProcessMemoryLimitMB,31 WebProcessMemoryMB: cfg.Engine.WebKit.WebProcessMemoryLimitMB,
29 NetworkProcessMemoryMB: cfg.Engine.WebKit.NetworkProcessMemoryLimitMB,32 NetworkProcessMemoryMB: cfg.Engine.WebKit.NetworkProcessMemoryLimitMB,
30 WebViewPoolPrewarm: cfg.Engine.PoolPrewarmCount,33 WebViewPoolPrewarm: cfg.Engine.PoolPrewarmCount,
31 },34 },
32- Resolved: port.SystemviewResolvedPerformancePayload{35+ Resolved: dto.SystemviewResolvedPerformancePayload{
33 SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,36 SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,
34 SkiaGPUThreads: resolved.SkiaGPUPaintingThreads,37 SkiaGPUThreads: resolved.SkiaGPUPaintingThreads,
35 WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB,38 WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB,
@@ -44,9 +47,9 @@ func BuildSystemviewConfigPayload(cfg *Config, hw *port.HardwareInfo) port.Syste
44 return payload47 return payload
45 }48 }
46 49
47-func buildSystemviewAppearancePayload(appearance AppearanceConfig) port.WebUIAppearanceConfig {50+func buildSystemviewAppearancePayload(appearance AppearanceConfig) dto.WebUIAppearanceConfig {
48 defaults := DefaultConfig().Appearance51 defaults := DefaultConfig().Appearance
49- return port.WebUIAppearanceConfig{52+ return dto.WebUIAppearanceConfig{
50 SansFont: appearance.SansFont,53 SansFont: appearance.SansFont,
51 SerifFont: appearance.SerifFont,54 SerifFont: appearance.SerifFont,
52 MonospaceFont: appearance.MonospaceFont,55 MonospaceFont: appearance.MonospaceFont,
@@ -57,8 +60,8 @@ func buildSystemviewAppearancePayload(appearance AppearanceConfig) port.WebUIApp
57 }60 }
58 }61 }
59 62
60-func buildSystemviewPalettePayload(palette, fallback ColorPalette) port.ColorPalette {63+func buildSystemviewPalettePayload(palette, fallback ColorPalette) dto.ColorPalette {
61- return port.ColorPalette{64+ return dto.ColorPalette{
62 Background: nonEmptyColor(palette.Background, fallback.Background),65 Background: nonEmptyColor(palette.Background, fallback.Background),
63 Surface: nonEmptyColor(palette.Surface, fallback.Surface),66 Surface: nonEmptyColor(palette.Surface, fallback.Surface),
64 SurfaceVariant: nonEmptyColor(palette.SurfaceVariant, fallback.SurfaceVariant),67 SurfaceVariant: nonEmptyColor(palette.SurfaceVariant, fallback.SurfaceVariant),
@@ -76,23 +79,23 @@ func nonEmptyColor(value, fallback string) string {
76 return fallback79 return fallback
77 }80 }
78 81
79-func buildSystemviewSearchShortcutsPayload(shortcuts map[string]SearchShortcut) map[string]port.SearchShortcut {82+func buildSystemviewSearchShortcutsPayload(shortcuts map[string]SearchShortcut) map[string]dto.SearchShortcut {
80 if len(shortcuts) == 0 {83 if len(shortcuts) == 0 {
81- return map[string]port.SearchShortcut{}84+ return map[string]dto.SearchShortcut{}
82 }85 }
83 86
84- result := make(map[string]port.SearchShortcut, len(shortcuts))87+ result := make(map[string]dto.SearchShortcut, len(shortcuts))
85 for key, shortcut := range shortcuts {88 for key, shortcut := range shortcuts {
86- result[key] = port.SearchShortcut{URL: shortcut.URL, Description: shortcut.Description}89+ result[key] = dto.SearchShortcut{URL: shortcut.URL, Description: shortcut.Description}
87 }90 }
88 return result91 return result
89 }92 }
90 93
91-func buildSystemviewHardwarePayload(hw *port.HardwareInfo) port.SystemviewHardwarePayload {94+func buildSystemviewHardwarePayload(hw *port.HardwareInfo) dto.SystemviewHardwarePayload {
92 if hw == nil {95 if hw == nil {
93- return port.SystemviewHardwarePayload{}96+ return dto.SystemviewHardwarePayload{}
94 }97 }
95- return port.SystemviewHardwarePayload{98+ return dto.SystemviewHardwarePayload{
96 CPUCores: hw.CPUCores,99 CPUCores: hw.CPUCores,
97 CPUThreads: hw.CPUThreads,100 CPUThreads: hw.CPUThreads,
98 TotalRAMMB: hw.TotalRAMMB(),101 TotalRAMMB: hw.TotalRAMMB(),
M internal/infrastructure/handlers/config.go +5 -4
@@ -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 }
@@ -33,7 +34,7 @@ func (h *ConfigHandler) Handle(ctx context.Context, viewID port.WebViewID, paylo
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)
@@ -56,7 +57,7 @@ func (h *ConfigHandler) Handle(ctx context.Context, viewID port.WebViewID, paylo
5657 // RegisterConfigHandlers registers configuration handlers with the router.
5758 func RegisterConfigHandlers(
5859 ctx context.Context, router port.WebUIHandlerRouter,
59- saveConfig func(context.Context, port.WebUIConfig) error,
60+ saveConfig func(context.Context, dto.WebUIConfig) error,
6061 ) error {
6162 handler := NewConfigHandler(saveConfig)
6263
@@ -5,18 +5,19 @@ import (
5 "encoding/json"5 "encoding/json"
6 "fmt"6 "fmt"
7 7
8+ "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
10 )11 )
11 12
12 // ConfigHandler handles configuration-related messages.13 // ConfigHandler handles configuration-related messages.
13 type ConfigHandler struct {14 type ConfigHandler struct {
14- saveConfig func(context.Context, port.WebUIConfig) error15+ saveConfig func(context.Context, dto.WebUIConfig) error
15 }16 }
16 17
17 // NewConfigHandler creates a new ConfigHandler.18 // NewConfigHandler creates a new ConfigHandler.
18 // saveConfig is called to persist config changes (typically usecase.SaveWebUIConfigUseCase.Execute).19 // 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 {
20 if saveConfig == nil {21 if saveConfig == nil {
21 panic("NewConfigHandler: saveConfig must not be nil")22 panic("NewConfigHandler: saveConfig must not be nil")
22 }23 }
@@ -33,7 +34,7 @@ func (h *ConfigHandler) Handle(ctx context.Context, viewID port.WebViewID, paylo
33 }34 }
34 log := logging.FromContext(ctx).With().Str("handler", "config").Logger()35 log := logging.FromContext(ctx).With().Str("handler", "config").Logger()
35 36
36- var payloadCfg port.WebUIConfig37+ var payloadCfg dto.WebUIConfig
37 if err := json.Unmarshal(payload, &payloadCfg); err != nil {38 if err := json.Unmarshal(payload, &payloadCfg); err != nil {
38 log.Error().Err(err).Msg("failed to unmarshal config payload")39 log.Error().Err(err).Msg("failed to unmarshal config payload")
39 return nil, fmt.Errorf("invalid config format: %w", err)40 return nil, fmt.Errorf("invalid config format: %w", err)
@@ -56,7 +57,7 @@ func (h *ConfigHandler) Handle(ctx context.Context, viewID port.WebViewID, paylo
56 // RegisterConfigHandlers registers configuration handlers with the router.57 // RegisterConfigHandlers registers configuration handlers with the router.
57 func RegisterConfigHandlers(58 func RegisterConfigHandlers(
58 ctx context.Context, router port.WebUIHandlerRouter,59 ctx context.Context, router port.WebUIHandlerRouter,
59- saveConfig func(context.Context, port.WebUIConfig) error,60+ saveConfig func(context.Context, dto.WebUIConfig) error,
60 ) error {61 ) error {
61 handler := NewConfigHandler(saveConfig)62 handler := NewConfigHandler(saveConfig)
62 63
M internal/infrastructure/handlers/homepage/favorites.go +36 -14
@@ -11,22 +11,26 @@ import (
1111 "github.com/bnema/dumber/internal/logging"
1212 )
1313
14-func folderIDFromInt64(id *int64) *entity.FolderID {
14+func folderIDFromInt64(id *int64) (*entity.FolderID, error) {
1515 if id == nil {
16- return nil
16+ return nil, nil
17+ }
18+ if *id <= 0 {
19+ return nil, fmt.Errorf("folder id must be positive")
1720 }
1821 folderID := entity.FolderID(*id)
19- return &folderID
22+ return &folderID, nil
2023 }
2124
22-func tagIDsFromInt64s(ids []int64) []entity.TagID {
25+func tagIDsFromInt64s(ids []int64) ([]entity.TagID, error) {
2326 out := make([]entity.TagID, 0, len(ids))
2427 for _, id := range ids {
25- if id > 0 {
26- out = append(out, entity.TagID(id))
28+ if id <= 0 {
29+ return nil, fmt.Errorf("tag id must be positive")
2730 }
31+ out = append(out, entity.TagID(id))
2832 }
29- return out
33+ return out, nil
3034 }
3135
3236 // FavoritesHandlers handles favorite-related messages from the homepage.
@@ -80,19 +84,27 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler {
8084
8185 log.Debug().
8286 Str("request_id", req.RequestID).
83- Str("url", req.URL).
8487 Msg("handling favorite_create")
8588
86- if strings.TrimSpace(req.URL) == "" {
89+ trimmedURL := strings.TrimSpace(req.URL)
90+ if trimmedURL == "" {
8791 return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil
8892 }
93+ folderID, err := folderIDFromInt64(req.FolderID)
94+ if err != nil {
95+ return NewErrorResponse(req.RequestID, err), nil
96+ }
97+ tags, err := tagIDsFromInt64s(req.Tags)
98+ if err != nil {
99+ return NewErrorResponse(req.RequestID, err), nil
100+ }
89101
90102 input := port.FavoriteCreateInput{
91- URL: req.URL,
103+ URL: trimmedURL,
92104 Title: req.Title,
93105 FaviconURL: req.FaviconURL,
94- FolderID: folderIDFromInt64(req.FolderID),
95- Tags: tagIDsFromInt64s(req.Tags),
106+ FolderID: folderID,
107+ Tags: tags,
96108 }
97109 favorite, err := h.favoritesUC.AddFavorite(ctx, input)
98110 if err != nil {
@@ -131,11 +143,16 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
131143 return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil
132144 }
133145
146+ folderID, err := folderIDFromInt64(req.FolderID)
147+ if err != nil {
148+ return NewErrorResponse(req.RequestID, err), nil
149+ }
150+
134151 favorite, err := h.favoritesUC.UpdateFavorite(ctx, port.FavoriteUpdateInput{
135152 ID: entity.FavoriteID(req.ID),
136153 Title: req.Title,
137154 FaviconURL: req.FaviconURL,
138- FolderID: folderIDFromInt64(req.FolderID),
155+ FolderID: folderID,
139156 ShortcutKey: req.ShortcutKey,
140157 })
141158 if err != nil {
@@ -260,7 +277,12 @@ func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {
260277 Int64("favorite_id", req.FavoriteID).
261278 Msg("handling favorite_set_folder")
262279
263- if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderIDFromInt64(req.FolderID)); err != nil {
280+ folderID, err := folderIDFromInt64(req.FolderID)
281+ if err != nil {
282+ return NewErrorResponse(req.RequestID, err), nil
283+ }
284+
285+ if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderID); err != nil {
264286 return NewErrorResponse(req.RequestID, err), nil
265287 }
266288
@@ -11,22 +11,26 @@ import (
11 "github.com/bnema/dumber/internal/logging"11 "github.com/bnema/dumber/internal/logging"
12 )12 )
13 13
14-func folderIDFromInt64(id *int64) *entity.FolderID {14+func folderIDFromInt64(id *int64) (*entity.FolderID, error) {
15 if id == nil {15 if id == nil {
16- return nil16+ return nil, nil
17+ }
18+ if *id <= 0 {
19+ return nil, fmt.Errorf("folder id must be positive")
17 }20 }
18 folderID := entity.FolderID(*id)21 folderID := entity.FolderID(*id)
19- return &folderID22+ return &folderID, nil
20 }23 }
21 24
22-func tagIDsFromInt64s(ids []int64) []entity.TagID {25+func tagIDsFromInt64s(ids []int64) ([]entity.TagID, error) {
23 out := make([]entity.TagID, 0, len(ids))26 out := make([]entity.TagID, 0, len(ids))
24 for _, id := range ids {27 for _, id := range ids {
25- if id > 0 {28+ if id <= 0 {
26- out = append(out, entity.TagID(id))29+ return nil, fmt.Errorf("tag id must be positive")
27 }30 }
31+ out = append(out, entity.TagID(id))
28 }32 }
29- return out33+ return out, nil
30 }34 }
31 35
32 // FavoritesHandlers handles favorite-related messages from the homepage.36 // FavoritesHandlers handles favorite-related messages from the homepage.
@@ -80,19 +84,27 @@ func (h *FavoritesHandlers) HandleCreate() port.WebUIMessageHandler {
80 84
81 log.Debug().85 log.Debug().
82 Str("request_id", req.RequestID).86 Str("request_id", req.RequestID).
83- Str("url", req.URL).
84 Msg("handling favorite_create")87 Msg("handling favorite_create")
85 88
86- if strings.TrimSpace(req.URL) == "" {89+ trimmedURL := strings.TrimSpace(req.URL)
90+ if trimmedURL == "" {
87 return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil91 return NewErrorResponse(req.RequestID, fmt.Errorf("URL is required")), nil
88 }92 }
93+ folderID, err := folderIDFromInt64(req.FolderID)
94+ if err != nil {
95+ return NewErrorResponse(req.RequestID, err), nil
96+ }
97+ tags, err := tagIDsFromInt64s(req.Tags)
98+ if err != nil {
99+ return NewErrorResponse(req.RequestID, err), nil
100+ }
89 101
90 input := port.FavoriteCreateInput{102 input := port.FavoriteCreateInput{
91- URL: req.URL,103+ URL: trimmedURL,
92 Title: req.Title,104 Title: req.Title,
93 FaviconURL: req.FaviconURL,105 FaviconURL: req.FaviconURL,
94- FolderID: folderIDFromInt64(req.FolderID),106+ FolderID: folderID,
95- Tags: tagIDsFromInt64s(req.Tags),107+ Tags: tags,
96 }108 }
97 favorite, err := h.favoritesUC.AddFavorite(ctx, input)109 favorite, err := h.favoritesUC.AddFavorite(ctx, input)
98 if err != nil {110 if err != nil {
@@ -131,11 +143,16 @@ func (h *FavoritesHandlers) HandleUpdate() port.WebUIMessageHandler {
131 return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil143 return NewErrorResponse(req.RequestID, fmt.Errorf("favorite id must be positive")), nil
132 }144 }
133 145
146+ folderID, err := folderIDFromInt64(req.FolderID)
147+ if err != nil {
148+ return NewErrorResponse(req.RequestID, err), nil
149+ }
150+
134 favorite, err := h.favoritesUC.UpdateFavorite(ctx, port.FavoriteUpdateInput{151 favorite, err := h.favoritesUC.UpdateFavorite(ctx, port.FavoriteUpdateInput{
135 ID: entity.FavoriteID(req.ID),152 ID: entity.FavoriteID(req.ID),
136 Title: req.Title,153 Title: req.Title,
137 FaviconURL: req.FaviconURL,154 FaviconURL: req.FaviconURL,
138- FolderID: folderIDFromInt64(req.FolderID),155+ FolderID: folderID,
139 ShortcutKey: req.ShortcutKey,156 ShortcutKey: req.ShortcutKey,
140 })157 })
141 if err != nil {158 if err != nil {
@@ -260,7 +277,12 @@ func (h *FavoritesHandlers) HandleSetFolder() port.WebUIMessageHandler {
260 Int64("favorite_id", req.FavoriteID).277 Int64("favorite_id", req.FavoriteID).
261 Msg("handling favorite_set_folder")278 Msg("handling favorite_set_folder")
262 279
263- if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderIDFromInt64(req.FolderID)); err != nil {280+ folderID, err := folderIDFromInt64(req.FolderID)
281+ if err != nil {
282+ return NewErrorResponse(req.RequestID, err), nil
283+ }
284+
285+ if err := h.favoritesUC.Move(ctx, entity.FavoriteID(req.FavoriteID), folderID); err != nil {
264 return NewErrorResponse(req.RequestID, err), nil286 return NewErrorResponse(req.RequestID, err), nil
265 }287 }
266 288
M internal/infrastructure/handlers/homepage/folders.go +13 -10
@@ -62,11 +62,12 @@ func (h *FolderHandlers) HandleCreate() port.WebUIMessageHandler {
6262 Str("name", req.Name).
6363 Msg("handling folder_create")
6464
65- icon := ""
66- if req.Icon != nil {
67- icon = *req.Icon
65+ parentID, err := folderIDFromInt64(req.ParentID)
66+ if err != nil {
67+ return NewErrorResponse(req.RequestID, err), nil
6868 }
69- folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, icon, folderIDFromInt64(req.ParentID))
69+
70+ folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, optionalStringValue(req.Icon), parentID)
7071 if err != nil {
7172 return NewErrorResponse(req.RequestID, err), nil
7273 }
@@ -114,6 +115,13 @@ type updateFolderRequest struct {
114115
115116 // HandleUpdate handles folder_update messages.
116117 // 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+
117125 func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler {
118126 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
119127 log := logging.FromContext(ctx)
@@ -129,12 +137,7 @@ func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler {
129137 Str("name", req.Name).
130138 Msg("handling folder_update")
131139
132- icon := ""
133- if req.Icon != nil {
134- icon = *req.Icon
135- }
136-
137- 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 {
138141 return NewErrorResponse(req.RequestID, err), nil
139142 }
140143
@@ -62,11 +62,12 @@ func (h *FolderHandlers) HandleCreate() port.WebUIMessageHandler {
62 Str("name", req.Name).62 Str("name", req.Name).
63 Msg("handling folder_create")63 Msg("handling folder_create")
64 64
65- icon := ""65+ parentID, err := folderIDFromInt64(req.ParentID)
66- if req.Icon != nil {66+ if err != nil {
67- icon = *req.Icon67+ return NewErrorResponse(req.RequestID, err), nil
68 }68 }
69- folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, icon, folderIDFromInt64(req.ParentID))69+
70+ folder, err := h.favoritesUC.CreateFolder(ctx, req.Name, optionalStringValue(req.Icon), parentID)
70 if err != nil {71 if err != nil {
71 return NewErrorResponse(req.RequestID, err), nil72 return NewErrorResponse(req.RequestID, err), nil
72 }73 }
@@ -114,6 +115,13 @@ type updateFolderRequest struct {
114 115
115 // HandleUpdate handles folder_update messages.116 // HandleUpdate handles folder_update messages.
116 // It delegates to the port.HomepageFavorites interface's UpdateFolder method.117 // 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+
117 func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler {125 func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler {
118 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {126 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
119 log := logging.FromContext(ctx)127 log := logging.FromContext(ctx)
@@ -129,12 +137,7 @@ func (h *FolderHandlers) HandleUpdate() port.WebUIMessageHandler {
129 Str("name", req.Name).137 Str("name", req.Name).
130 Msg("handling folder_update")138 Msg("handling folder_update")
131 139
132- icon := ""140+ if err := h.favoritesUC.UpdateFolder(ctx, entity.FolderID(req.ID), req.Name, optionalStringValue(req.Icon)); err != nil {
133- if req.Icon != nil {
134- icon = *req.Icon
135- }
136-
137- if err := h.favoritesUC.UpdateFolder(ctx, entity.FolderID(req.ID), req.Name, icon); err != nil {
138 return NewErrorResponse(req.RequestID, err), nil141 return NewErrorResponse(req.RequestID, err), nil
139 }142 }
140 143
M internal/infrastructure/handlers/homepage/history.go +0 -12
@@ -182,9 +182,6 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
182182 Str("range", req.Range).
183183 Msg("handling history_delete_range")
184184
185- if !isAllowedHistoryDeleteRange(req.Range) {
186- return NewErrorResponse(req.RequestID, fmt.Errorf("invalid range: %s", req.Range)), nil
187- }
188185 if err := h.historyUC.ClearRange(ctx, req.Range); err != nil {
189186 return NewErrorResponse(req.RequestID, err), nil
190187 }
@@ -193,15 +190,6 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
193190 })
194191 }
195192
196-func isAllowedHistoryDeleteRange(rangeID string) bool {
197- switch rangeID {
198- case "hour", "day", "week", "month", "all":
199- return true
200- default:
201- return false
202- }
203-}
204-
205193 // HandleClearAll handles history_clear_all messages.
206194 func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler {
207195 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
@@ -182,9 +182,6 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
182 Str("range", req.Range).182 Str("range", req.Range).
183 Msg("handling history_delete_range")183 Msg("handling history_delete_range")
184 184
185- if !isAllowedHistoryDeleteRange(req.Range) {
186- return NewErrorResponse(req.RequestID, fmt.Errorf("invalid range: %s", req.Range)), nil
187- }
188 if err := h.historyUC.ClearRange(ctx, req.Range); err != nil {185 if err := h.historyUC.ClearRange(ctx, req.Range); err != nil {
189 return NewErrorResponse(req.RequestID, err), nil186 return NewErrorResponse(req.RequestID, err), nil
190 }187 }
@@ -193,15 +190,6 @@ func (h *HistoryHandlers) HandleDeleteRange() port.WebUIMessageHandler {
193 })190 })
194 }191 }
195 192
196-func isAllowedHistoryDeleteRange(rangeID string) bool {
197- switch rangeID {
198- case "hour", "day", "week", "month", "all":
199- return true
200- default:
201- return false
202- }
203-}
204-
205 // HandleClearAll handles history_clear_all messages.193 // HandleClearAll handles history_clear_all messages.
206 func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler {194 func (h *HistoryHandlers) HandleClearAll() port.WebUIMessageHandler {
207 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {195 return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {
M internal/infrastructure/persistence/sqlite/history_repo.go +3 -3
@@ -494,9 +494,10 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti
494494 }
495495
496496 func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
497+ rawDomain := domain
497498 domain = domainurl.CanonicalDomain(domain)
498499 if domain == "" {
499- return []*entity.HistoryEntry{}, nil
500+ return nil, fmt.Errorf("history domain is required: %q", rawDomain)
500501 }
501502 rows, err := r.queries.GetRecentHistoryByDomain(ctx, sqlc.GetRecentHistoryByDomainParams{
502503 Domain: sql.NullString{String: domain, Valid: true},
@@ -597,8 +598,7 @@ func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {
597598 rawDomain := domain
598599 domain = domainurl.CanonicalDomain(domain)
599600 if domain == "" {
600- logging.FromContext(ctx).Debug().Str("domain", rawDomain).Msg("skipping delete by domain: empty canonical domain")
601- return nil
601+ return fmt.Errorf("history domain is required: %q", rawDomain)
602602 }
603603 return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true})
604604 }
@@ -494,9 +494,10 @@ func (r *historyRepo) GetRecent(ctx context.Context, limit, offset int) ([]*enti
494 }494 }
495 495
496 func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {496 func (r *historyRepo) GetRecentByDomain(ctx context.Context, domain string, limit, offset int) ([]*entity.HistoryEntry, error) {
497+ rawDomain := domain
497 domain = domainurl.CanonicalDomain(domain)498 domain = domainurl.CanonicalDomain(domain)
498 if domain == "" {499 if domain == "" {
499- return []*entity.HistoryEntry{}, nil500+ return nil, fmt.Errorf("history domain is required: %q", rawDomain)
500 }501 }
501 rows, err := r.queries.GetRecentHistoryByDomain(ctx, sqlc.GetRecentHistoryByDomainParams{502 rows, err := r.queries.GetRecentHistoryByDomain(ctx, sqlc.GetRecentHistoryByDomainParams{
502 Domain: sql.NullString{String: domain, Valid: true},503 Domain: sql.NullString{String: domain, Valid: true},
@@ -597,8 +598,7 @@ func (r *historyRepo) DeleteByDomain(ctx context.Context, domain string) error {
597 rawDomain := domain598 rawDomain := domain
598 domain = domainurl.CanonicalDomain(domain)599 domain = domainurl.CanonicalDomain(domain)
599 if domain == "" {600 if domain == "" {
600- logging.FromContext(ctx).Debug().Str("domain", rawDomain).Msg("skipping delete by domain: empty canonical domain")601+ return fmt.Errorf("history domain is required: %q", rawDomain)
601- return nil
602 }602 }
603 return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true})603 return r.queries.DeleteHistoryByDomain(ctx, sql.NullString{String: domain, Valid: true})
604 }604 }
M internal/infrastructure/persistence/sqlite/history_repo_test.go +16 -0
@@ -833,6 +833,22 @@ func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T)
833833 assert.Empty(t, results)
834834 }
835835
836+func TestHistoryRepository_RejectsEmptyCanonicalDomain(t *testing.T) {
837+ ctx := historyTestCtx()
838+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
839+
840+ db, err := sqlite.NewConnection(ctx, dbPath)
841+ require.NoError(t, err)
842+ t.Cleanup(func() { _ = db.Close() })
843+
844+ repo := sqlite.NewHistoryRepository(db)
845+
846+ entries, err := repo.GetRecentByDomain(ctx, " ", 10, 0)
847+ require.Error(t, err)
848+ assert.Nil(t, entries)
849+ assert.Error(t, repo.DeleteByDomain(ctx, "/path"))
850+}
851+
836852 func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.T) {
837853 ctx := historyTestCtx()
838854 dbPath := filepath.Join(t.TempDir(), "dumber.db")
@@ -833,6 +833,22 @@ func TestHistoryRepository_Search_ReturnsEmptyForNonPositiveLimit(t *testing.T)
833 assert.Empty(t, results)833 assert.Empty(t, results)
834 }834 }
835 835
836+func TestHistoryRepository_RejectsEmptyCanonicalDomain(t *testing.T) {
837+ ctx := historyTestCtx()
838+ dbPath := filepath.Join(t.TempDir(), "dumber.db")
839+
840+ db, err := sqlite.NewConnection(ctx, dbPath)
841+ require.NoError(t, err)
842+ t.Cleanup(func() { _ = db.Close() })
843+
844+ repo := sqlite.NewHistoryRepository(db)
845+
846+ entries, err := repo.GetRecentByDomain(ctx, " ", 10, 0)
847+ require.Error(t, err)
848+ assert.Nil(t, entries)
849+ assert.Error(t, repo.DeleteByDomain(ctx, "/path"))
850+}
851+
836 func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.T) {852 func TestHistoryRepository_DomainColumnPowersFilteringStatsAndDelete(t *testing.T) {
837 ctx := historyTestCtx()853 ctx := historyTestCtx()
838 dbPath := filepath.Join(t.TempDir(), "dumber.db")854 dbPath := filepath.Join(t.TempDir(), "dumber.db")
M internal/infrastructure/persistence/sqlite/migrations/008_history_domain.sql +57 -1
@@ -31,4 +31,60 @@ CREATE INDEX IF NOT EXISTS idx_history_domain_last_visited ON history(domain, la
3131
3232 -- +goose Down
3333 DROP INDEX IF EXISTS idx_history_domain_last_visited;
34-ALTER TABLE history DROP COLUMN domain;
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;
@@ -31,4 +31,60 @@ CREATE INDEX IF NOT EXISTS idx_history_domain_last_visited ON history(domain, la
31 31
32 -- +goose Down32 -- +goose Down
33 DROP INDEX IF EXISTS idx_history_domain_last_visited;33 DROP INDEX IF EXISTS idx_history_domain_last_visited;
34-ALTER TABLE history DROP COLUMN domain;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 +31 -6
@@ -19,7 +19,7 @@ DO UPDATE SET
1919 last_visited = CURRENT_TIMESTAMP,
2020 title = COALESCE(EXCLUDED.title, history.title),
2121 favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url),
22- domain = EXCLUDED.domain;
22+ domain = COALESCE(EXCLUDED.domain, history.domain);
2323
2424 -- name: IncrementVisitCount :exec
2525 UPDATE history
@@ -50,22 +50,47 @@ DELETE FROM history WHERE last_visited >= ?;
5050 DELETE FROM history;
5151
5252 -- name: GetHistoryStats :one
53-SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history;
53+SELECT
54+ COUNT(*) as total_entries,
55+ COALESCE(SUM(visit_count), 0) as total_visits,
56+ COUNT(DISTINCT date(last_visited)) as unique_days
57+FROM history;
5458
5559 -- name: GetDomainStats :many
56-SELECT COALESCE(domain, '') as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history WHERE domain IS NOT NULL AND domain != '' GROUP BY domain ORDER BY total_visits DESC LIMIT ?;
60+SELECT
61+ COALESCE(domain, '') as domain,
62+ COUNT(*) as page_count,
63+ SUM(visit_count) as total_visits,
64+ MAX(last_visited) as last_visit
65+FROM history
66+WHERE domain IS NOT NULL AND domain != ''
67+GROUP BY domain
68+ORDER BY total_visits DESC
69+LIMIT ?;
5770
5871 -- name: GetHourlyDistribution :many
59-SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour;
72+SELECT
73+ CAST(strftime('%H', last_visited) AS INTEGER) as hour,
74+ COUNT(*) as visit_count
75+FROM history
76+GROUP BY hour
77+ORDER BY hour;
6078
6179 -- name: GetDailyVisitCount :many
62-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;
80+SELECT
81+ date(last_visited) as day,
82+ COUNT(*) as entries,
83+ SUM(visit_count) as visits
84+FROM history
85+WHERE last_visited >= date('now', ?)
86+GROUP BY day
87+ORDER BY day ASC;
6388
6489 -- name: DeleteHistoryByDomain :exec
6590 DELETE FROM history WHERE domain = @domain;
6691
6792 -- name: SearchHistoryFTSUrl :many
68-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
93+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
6994 FROM history_fts fts
7095 JOIN history h ON fts.rowid = h.id
7196 WHERE fts.url MATCH @query
@@ -19,7 +19,7 @@ DO UPDATE SET
19 last_visited = CURRENT_TIMESTAMP,19 last_visited = CURRENT_TIMESTAMP,
20 title = COALESCE(EXCLUDED.title, history.title),20 title = COALESCE(EXCLUDED.title, history.title),
21 favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url),21 favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url),
22- domain = EXCLUDED.domain;22+ domain = COALESCE(EXCLUDED.domain, history.domain);
23 23
24 -- name: IncrementVisitCount :exec24 -- name: IncrementVisitCount :exec
25 UPDATE history25 UPDATE history
@@ -50,22 +50,47 @@ DELETE FROM history WHERE last_visited >= ?;
50 DELETE FROM history;50 DELETE FROM history;
51 51
52 -- name: GetHistoryStats :one52 -- name: GetHistoryStats :one
53-SELECT COUNT(*) as total_entries, COALESCE(SUM(visit_count), 0) as total_visits, COUNT(DISTINCT date(last_visited)) as unique_days FROM history;53+SELECT
54+ COUNT(*) as total_entries,
55+ COALESCE(SUM(visit_count), 0) as total_visits,
56+ COUNT(DISTINCT date(last_visited)) as unique_days
57+FROM history;
54 58
55 -- name: GetDomainStats :many59 -- name: GetDomainStats :many
56-SELECT COALESCE(domain, '') as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history WHERE domain IS NOT NULL AND domain != '' GROUP BY domain ORDER BY total_visits DESC LIMIT ?;60+SELECT
61+ COALESCE(domain, '') as domain,
62+ COUNT(*) as page_count,
63+ SUM(visit_count) as total_visits,
64+ MAX(last_visited) as last_visit
65+FROM history
66+WHERE domain IS NOT NULL AND domain != ''
67+GROUP BY domain
68+ORDER BY total_visits DESC
69+LIMIT ?;
57 70
58 -- name: GetHourlyDistribution :many71 -- name: GetHourlyDistribution :many
59-SELECT CAST(strftime('%H', last_visited) AS INTEGER) as hour, COUNT(*) as visit_count FROM history GROUP BY hour ORDER BY hour;72+SELECT
73+ CAST(strftime('%H', last_visited) AS INTEGER) as hour,
74+ COUNT(*) as visit_count
75+FROM history
76+GROUP BY hour
77+ORDER BY hour;
60 78
61 -- name: GetDailyVisitCount :many79 -- name: GetDailyVisitCount :many
62-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;80+SELECT
81+ date(last_visited) as day,
82+ COUNT(*) as entries,
83+ SUM(visit_count) as visits
84+FROM history
85+WHERE last_visited >= date('now', ?)
86+GROUP BY day
87+ORDER BY day ASC;
63 88
64 -- name: DeleteHistoryByDomain :exec89 -- name: DeleteHistoryByDomain :exec
65 DELETE FROM history WHERE domain = @domain;90 DELETE FROM history WHERE domain = @domain;
66 91
67 -- name: SearchHistoryFTSUrl :many92 -- name: SearchHistoryFTSUrl :many
68-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at93+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
69 FROM history_fts fts94 FROM history_fts fts
70 JOIN history h ON fts.rowid = h.id95 JOIN history h ON fts.rowid = h.id
71 WHERE fts.url MATCH @query96 WHERE fts.url MATCH @query
M internal/infrastructure/persistence/sqlite/sqlc/history.sql.go +35 -19
@@ -145,7 +145,14 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
145145 }
146146
147147 const GetDailyVisitCount = `-- name: GetDailyVisitCount :many
148-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
148+SELECT
149+ date(last_visited) as day,
150+ COUNT(*) as entries,
151+ SUM(visit_count) as visits
152+FROM history
153+WHERE last_visited >= date('now', ?)
154+GROUP BY day
155+ORDER BY day ASC
149156 `
150157
151158 type GetDailyVisitCountRow struct {
@@ -178,7 +185,16 @@ func (q *Queries) GetDailyVisitCount(ctx context.Context, date interface{}) ([]G
178185 }
179186
180187 const GetDomainStats = `-- name: GetDomainStats :many
181-SELECT COALESCE(domain, '') as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history WHERE domain IS NOT NULL AND domain != '' GROUP BY domain ORDER BY total_visits DESC LIMIT ?
188+SELECT
189+ COALESCE(domain, '') as domain,
190+ COUNT(*) as page_count,
191+ SUM(visit_count) as total_visits,
192+ MAX(last_visited) as last_visit
193+FROM history
194+WHERE domain IS NOT NULL AND domain != ''
195+GROUP BY domain
196+ORDER BY total_visits DESC
197+LIMIT ?
182198 `
183199
184200 type GetDomainStatsRow struct {
@@ -237,7 +253,11 @@ func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, err
237253 }
238254
239255 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
256+SELECT
257+ COUNT(*) as total_entries,
258+ COALESCE(SUM(visit_count), 0) as total_visits,
259+ COUNT(DISTINCT date(last_visited)) as unique_days
260+FROM history
241261 `
242262
243263 type GetHistoryStatsRow struct {
@@ -254,7 +274,12 @@ func (q *Queries) GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, erro
254274 }
255275
256276 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
277+SELECT
278+ CAST(strftime('%H', last_visited) AS INTEGER) as hour,
279+ COUNT(*) as visit_count
280+FROM history
281+GROUP BY hour
282+ORDER BY hour
258283 `
259284
260285 type GetHourlyDistributionRow struct {
@@ -566,7 +591,7 @@ func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFT
566591 }
567592
568593 const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many
569-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at
594+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
570595 FROM history_fts fts
571596 JOIN history h ON fts.rowid = h.id
572597 WHERE fts.url MATCH ?1
@@ -579,25 +604,15 @@ type SearchHistoryFTSUrlParams struct {
579604 Limit int64 `json:"limit"`
580605 }
581606
582-type SearchHistoryFTSUrlRow struct {
583- ID int64 `json:"id"`
584- Url string `json:"url"`
585- Title sql.NullString `json:"title"`
586- FaviconUrl sql.NullString `json:"favicon_url"`
587- VisitCount sql.NullInt64 `json:"visit_count"`
588- LastVisited sql.NullTime `json:"last_visited"`
589- CreatedAt sql.NullTime `json:"created_at"`
590-}
591-
592-func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]SearchHistoryFTSUrlRow, error) {
607+func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) {
593608 rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit)
594609 if err != nil {
595610 return nil, err
596611 }
597612 defer rows.Close()
598- items := []SearchHistoryFTSUrlRow{}
613+ items := []History{}
599614 for rows.Next() {
600- var i SearchHistoryFTSUrlRow
615+ var i History
601616 if err := rows.Scan(
602617 &i.ID,
603618 &i.Url,
@@ -606,6 +621,7 @@ func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSU
606621 &i.VisitCount,
607622 &i.LastVisited,
608623 &i.CreatedAt,
624+ &i.Domain,
609625 ); err != nil {
610626 return nil, err
611627 }
@@ -694,7 +710,7 @@ DO UPDATE SET
694710 last_visited = CURRENT_TIMESTAMP,
695711 title = COALESCE(EXCLUDED.title, history.title),
696712 favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url),
697- domain = EXCLUDED.domain
713+ domain = COALESCE(EXCLUDED.domain, history.domain)
698714 `
699715
700716 type UpsertHistoryParams struct {
@@ -145,7 +145,14 @@ func (q *Queries) GetAllRecentHistory(ctx context.Context) ([]History, error) {
145 }145 }
146 146
147 const GetDailyVisitCount = `-- name: GetDailyVisitCount :many147 const GetDailyVisitCount = `-- name: GetDailyVisitCount :many
148-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 ASC148+SELECT
149+ date(last_visited) as day,
150+ COUNT(*) as entries,
151+ SUM(visit_count) as visits
152+FROM history
153+WHERE last_visited >= date('now', ?)
154+GROUP BY day
155+ORDER BY day ASC
149 `156 `
150 157
151 type GetDailyVisitCountRow struct {158 type GetDailyVisitCountRow struct {
@@ -178,7 +185,16 @@ func (q *Queries) GetDailyVisitCount(ctx context.Context, date interface{}) ([]G
178 }185 }
179 186
180 const GetDomainStats = `-- name: GetDomainStats :many187 const GetDomainStats = `-- name: GetDomainStats :many
181-SELECT COALESCE(domain, '') as domain, COUNT(*) as page_count, SUM(visit_count) as total_visits, MAX(last_visited) as last_visit FROM history WHERE domain IS NOT NULL AND domain != '' GROUP BY domain ORDER BY total_visits DESC LIMIT ?188+SELECT
189+ COALESCE(domain, '') as domain,
190+ COUNT(*) as page_count,
191+ SUM(visit_count) as total_visits,
192+ MAX(last_visited) as last_visit
193+FROM history
194+WHERE domain IS NOT NULL AND domain != ''
195+GROUP BY domain
196+ORDER BY total_visits DESC
197+LIMIT ?
182 `198 `
183 199
184 type GetDomainStatsRow struct {200 type GetDomainStatsRow struct {
@@ -237,7 +253,11 @@ func (q *Queries) GetHistoryByURL(ctx context.Context, url string) (History, err
237 }253 }
238 254
239 const GetHistoryStats = `-- name: GetHistoryStats :one255 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 history256+SELECT
257+ COUNT(*) as total_entries,
258+ COALESCE(SUM(visit_count), 0) as total_visits,
259+ COUNT(DISTINCT date(last_visited)) as unique_days
260+FROM history
241 `261 `
242 262
243 type GetHistoryStatsRow struct {263 type GetHistoryStatsRow struct {
@@ -254,7 +274,12 @@ func (q *Queries) GetHistoryStats(ctx context.Context) (GetHistoryStatsRow, erro
254 }274 }
255 275
256 const GetHourlyDistribution = `-- name: GetHourlyDistribution :many276 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 hour277+SELECT
278+ CAST(strftime('%H', last_visited) AS INTEGER) as hour,
279+ COUNT(*) as visit_count
280+FROM history
281+GROUP BY hour
282+ORDER BY hour
258 `283 `
259 284
260 type GetHourlyDistributionRow struct {285 type GetHourlyDistributionRow struct {
@@ -566,7 +591,7 @@ func (q *Queries) SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFT
566 }591 }
567 592
568 const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many593 const SearchHistoryFTSUrl = `-- name: SearchHistoryFTSUrl :many
569-SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at594+SELECT h.id, h.url, h.title, h.favicon_url, h.visit_count, h.last_visited, h.created_at, h.domain
570 FROM history_fts fts595 FROM history_fts fts
571 JOIN history h ON fts.rowid = h.id596 JOIN history h ON fts.rowid = h.id
572 WHERE fts.url MATCH ?1597 WHERE fts.url MATCH ?1
@@ -579,25 +604,15 @@ type SearchHistoryFTSUrlParams struct {
579 Limit int64 `json:"limit"`604 Limit int64 `json:"limit"`
580 }605 }
581 606
582-type SearchHistoryFTSUrlRow struct {607+func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error) {
583- ID int64 `json:"id"`
584- Url string `json:"url"`
585- Title sql.NullString `json:"title"`
586- FaviconUrl sql.NullString `json:"favicon_url"`
587- VisitCount sql.NullInt64 `json:"visit_count"`
588- LastVisited sql.NullTime `json:"last_visited"`
589- CreatedAt sql.NullTime `json:"created_at"`
590-}
591-
592-func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]SearchHistoryFTSUrlRow, error) {
593 rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit)608 rows, err := q.db.QueryContext(ctx, SearchHistoryFTSUrl, arg.Query, arg.Limit)
594 if err != nil {609 if err != nil {
595 return nil, err610 return nil, err
596 }611 }
597 defer rows.Close()612 defer rows.Close()
598- items := []SearchHistoryFTSUrlRow{}613+ items := []History{}
599 for rows.Next() {614 for rows.Next() {
600- var i SearchHistoryFTSUrlRow615+ var i History
601 if err := rows.Scan(616 if err := rows.Scan(
602 &i.ID,617 &i.ID,
603 &i.Url,618 &i.Url,
@@ -606,6 +621,7 @@ func (q *Queries) SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSU
606 &i.VisitCount,621 &i.VisitCount,
607 &i.LastVisited,622 &i.LastVisited,
608 &i.CreatedAt,623 &i.CreatedAt,
624+ &i.Domain,
609 ); err != nil {625 ); err != nil {
610 return nil, err626 return nil, err
611 }627 }
@@ -694,7 +710,7 @@ DO UPDATE SET
694 last_visited = CURRENT_TIMESTAMP,710 last_visited = CURRENT_TIMESTAMP,
695 title = COALESCE(EXCLUDED.title, history.title),711 title = COALESCE(EXCLUDED.title, history.title),
696 favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url),712 favicon_url = COALESCE(EXCLUDED.favicon_url, history.favicon_url),
697- domain = EXCLUDED.domain713+ domain = COALESCE(EXCLUDED.domain, history.domain)
698 `714 `
699 715
700 type UpsertHistoryParams struct {716 type UpsertHistoryParams struct {
M internal/infrastructure/persistence/sqlite/sqlc/querier.go +1 -1
@@ -79,7 +79,7 @@ type Querier interface {
7979 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
8080 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
8181 SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)
82- SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]SearchHistoryFTSUrlRow, error)
82+ SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error)
8383 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)
8484 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
8585 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
@@ -79,7 +79,7 @@ type Querier interface {
79 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error79 RemoveTagFromFavorite(ctx context.Context, arg RemoveTagFromFavoriteParams) error
80 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)80 SearchHistory(ctx context.Context, arg SearchHistoryParams) ([]History, error)
81 SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)81 SearchHistoryFTSTitle(ctx context.Context, arg SearchHistoryFTSTitleParams) ([]History, error)
82- SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]SearchHistoryFTSUrlRow, error)82+ SearchHistoryFTSUrl(ctx context.Context, arg SearchHistoryFTSUrlParams) ([]History, error)
83 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)83 SearchHistoryFTSUrlWithDomainBoost(ctx context.Context, arg SearchHistoryFTSUrlWithDomainBoostParams) ([]SearchHistoryFTSUrlWithDomainBoostRow, error)
84 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error84 SetFavoriteFolder(ctx context.Context, arg SetFavoriteFolderParams) error
85 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error85 SetFavoriteShortcut(ctx context.Context, arg SetFavoriteShortcutParams) error
M internal/infrastructure/systemviewsbridge/client.go +7 -8
@@ -9,6 +9,7 @@ import (
99 "sync/atomic"
1010 "time"
1111
12+ "github.com/bnema/dumber/internal/application/dto"
1213 "github.com/bnema/dumber/internal/application/port"
1314 "github.com/bnema/dumber/internal/domain/entity"
1415 )
@@ -143,21 +144,21 @@ func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
143144
144145 // Current intentionally targets the direct HTTP config endpoint rather than a
145146 // snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config.
146-func (c *Client) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {
147- return request[port.SystemviewConfigPayload](c, ctx, "/api/config", struct {
147+func (c *Client) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
148+ return request[dto.SystemviewConfigPayload](c, ctx, "/api/config", struct {
148149 RequestID string `json:"requestId"`
149150 }{RequestID: nextRequestID()})
150151 }
151152
152153 // Default intentionally targets the direct HTTP config endpoint rather than a
153154 // snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config/default.
154-func (c *Client) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {
155- return request[port.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {
155+func (c *Client) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
156+ return request[dto.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {
156157 RequestID string `json:"requestId"`
157158 }{RequestID: nextRequestID()})
158159 }
159160
160-func (c *Client) Save(ctx context.Context, cfg port.WebUIConfig) error {
161+func (c *Client) Save(ctx context.Context, cfg dto.WebUIConfig) error {
161162 _, err := request[struct{}](c, ctx, "save_config", cfg)
162163 return err
163164 }
@@ -482,10 +483,8 @@ func request[T any](c *Client, ctx context.Context, msgType string, payload any)
482483 return decodeBridgeResponse[T](raw)
483484 }
484485
486+// withBridgeRequestTimeout applies the bridge request timeout; ctx must be non-nil.
485487 func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
486- if ctx == nil {
487- ctx = context.Background()
488- }
489488 timeout := defaultBridgeRequestTimeout
490489 if c != nil {
491490 timeout = c.requestTimeout
@@ -9,6 +9,7 @@ import (
9 "sync/atomic"9 "sync/atomic"
10 "time"10 "time"
11 11
12+ "github.com/bnema/dumber/internal/application/dto"
12 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/domain/entity"14 "github.com/bnema/dumber/internal/domain/entity"
14 )15 )
@@ -143,21 +144,21 @@ func (c *Client) DeleteRange(ctx context.Context, rangeID string) error {
143 144
144 // Current intentionally targets the direct HTTP config endpoint rather than a145 // Current intentionally targets the direct HTTP config endpoint rather than a
145 // snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config.146 // snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config.
146-func (c *Client) Current(ctx context.Context) (port.SystemviewConfigPayload, error) {147+func (c *Client) Current(ctx context.Context) (dto.SystemviewConfigPayload, error) {
147- return request[port.SystemviewConfigPayload](c, ctx, "/api/config", struct {148+ return request[dto.SystemviewConfigPayload](c, ctx, "/api/config", struct {
148 RequestID string `json:"requestId"`149 RequestID string `json:"requestId"`
149 }{RequestID: nextRequestID()})150 }{RequestID: nextRequestID()})
150 }151 }
151 152
152 // Default intentionally targets the direct HTTP config endpoint rather than a153 // Default intentionally targets the direct HTTP config endpoint rather than a
153 // snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config/default.154 // snake_case bridge message so CEF/WebKit fetch shims can reuse /api/config/default.
154-func (c *Client) Default(ctx context.Context) (port.SystemviewConfigPayload, error) {155+func (c *Client) Default(ctx context.Context) (dto.SystemviewConfigPayload, error) {
155- return request[port.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {156+ return request[dto.SystemviewConfigPayload](c, ctx, "/api/config/default", struct {
156 RequestID string `json:"requestId"`157 RequestID string `json:"requestId"`
157 }{RequestID: nextRequestID()})158 }{RequestID: nextRequestID()})
158 }159 }
159 160
160-func (c *Client) Save(ctx context.Context, cfg port.WebUIConfig) error {161+func (c *Client) Save(ctx context.Context, cfg dto.WebUIConfig) error {
161 _, err := request[struct{}](c, ctx, "save_config", cfg)162 _, err := request[struct{}](c, ctx, "save_config", cfg)
162 return err163 return err
163 }164 }
@@ -482,10 +483,8 @@ func request[T any](c *Client, ctx context.Context, msgType string, payload any)
482 return decodeBridgeResponse[T](raw)483 return decodeBridgeResponse[T](raw)
483 }484 }
484 485
486+// withBridgeRequestTimeout applies the bridge request timeout; ctx must be non-nil.
485 func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {487 func (c *Client) withBridgeRequestTimeout(ctx context.Context) (context.Context, context.CancelFunc) {
486- if ctx == nil {
487- ctx = context.Background()
488- }
489 timeout := defaultBridgeRequestTimeout488 timeout := defaultBridgeRequestTimeout
490 if c != nil {489 if c != nil {
491 timeout = c.requestTimeout490 timeout = c.requestTimeout
M internal/infrastructure/systemviewsbridge/client_test.go +3 -2
@@ -7,6 +7,7 @@ import (
77 "testing"
88 "time"
99
10+ "github.com/bnema/dumber/internal/application/dto"
1011 "github.com/bnema/dumber/internal/application/port"
1112 )
1213
@@ -289,10 +290,10 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
289290 {
290291 name: "save config",
291292 call: func(client *Client) error {
292- return client.Save(context.Background(), port.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})
293+ return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})
293294 },
294295 want: "save_config",
295- payload: port.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"},
296+ payload: dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"},
296297 },
297298 {
298299 name: "get keybindings",
@@ -7,6 +7,7 @@ import (
7 "testing"7 "testing"
8 "time"8 "time"
9 9
10+ "github.com/bnema/dumber/internal/application/dto"
10 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
11 )12 )
12 13
@@ -289,10 +290,10 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
289 {290 {
290 name: "save config",291 name: "save config",
291 call: func(client *Client) error {292 call: func(client *Client) error {
292- return client.Save(context.Background(), port.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})293+ return client.Save(context.Background(), dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"})
293 },294 },
294 want: "save_config",295 want: "save_config",
295- payload: port.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"},296+ payload: dto.WebUIConfig{DefaultSearchEngine: "DuckDuckGo"},
296 },297 },
297 {298 {
298 name: "get keybindings",299 name: "get keybindings",
M internal/infrastructure/webkit/scheme_handler.go +15 -7
@@ -355,20 +355,27 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath
355355 }
356356
357357 func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {
358+ var compressedErr error
358359 if strings.HasSuffix(relPath, ".wasm") {
359360 if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
360361 data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
361362 if err != nil {
362- return nil, nil, err
363+ compressedErr = err
364+ } else if len(data) > maxSystemviewsWASMBytes {
365+ compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
366+ } else {
367+ return data, nil, nil
363368 }
364- if len(data) > maxSystemviewsWASMBytes {
365- return nil, nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
366- }
367- return data, nil, nil
368369 }
369370 }
370371 data, err := fs.ReadFile(assets, fullPath)
371- return data, nil, err
372+ if err == nil {
373+ return data, nil, nil
374+ }
375+ if compressedErr != nil {
376+ return nil, nil, compressedErr
377+ }
378+ return nil, nil, err
372379 }
373380
374381 func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
@@ -384,6 +391,7 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
384391 FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
385392 ConfigPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
386393 ErrorPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
394+ CrashPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
387395 }
388396
389397 if root, ok := rootByHost[u.Host]; ok {
@@ -395,7 +403,7 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
395403 }
396404
397405 switch u.Opaque {
398- case HistoryPath, FavoritesPath, ConfigPath, ErrorPath:
406+ case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
399407 return systemviewsAssetDir, IndexHTML, true
400408 default:
401409 return "", "", false
@@ -355,20 +355,27 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath
355 }355 }
356 356
357 func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {357 func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, map[string]string, error) {
358+ var compressedErr error
358 if strings.HasSuffix(relPath, ".wasm") {359 if strings.HasSuffix(relPath, ".wasm") {
359 if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {360 if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil {
360 data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))361 data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1))
361 if err != nil {362 if err != nil {
362- return nil, nil, err363+ compressedErr = err
364+ } else if len(data) > maxSystemviewsWASMBytes {
365+ compressedErr = fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
366+ } else {
367+ return data, nil, nil
363 }368 }
364- if len(data) > maxSystemviewsWASMBytes {
365- return nil, nil, fmt.Errorf("decompressed asset %s exceeds %d bytes", fullPath, maxSystemviewsWASMBytes)
366- }
367- return data, nil, nil
368 }369 }
369 }370 }
370 data, err := fs.ReadFile(assets, fullPath)371 data, err := fs.ReadFile(assets, fullPath)
371- return data, nil, err372+ if err == nil {
373+ return data, nil, nil
374+ }
375+ if compressedErr != nil {
376+ return nil, nil, compressedErr
377+ }
378+ return nil, nil, err
372 }379 }
373 380
374 func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {381 func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
@@ -384,6 +391,7 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
384 FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML},391 FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
385 ConfigPath: {assetDir: systemviewsAssetDir, file: IndexHTML},392 ConfigPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
386 ErrorPath: {assetDir: systemviewsAssetDir, file: IndexHTML},393 ErrorPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
394+ CrashPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
387 }395 }
388 396
389 if root, ok := rootByHost[u.Host]; ok {397 if root, ok := rootByHost[u.Host]; ok {
@@ -395,7 +403,7 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
395 }403 }
396 404
397 switch u.Opaque {405 switch u.Opaque {
398- case HistoryPath, FavoritesPath, ConfigPath, ErrorPath:406+ case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
399 return systemviewsAssetDir, IndexHTML, true407 return systemviewsAssetDir, IndexHTML, true
400 default:408 default:
401 return "", "", false409 return "", "", false
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,
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
8 portmocks "github.com/bnema/dumber/internal/application/port/mocks"9 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
9 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
@@ -28,7 +29,7 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T)
28 Config: &config.Config{},29 Config: &config.Config{},
29 Engine: engine,30 Engine: engine,
30 HandlerDeps: port.HandlerDeps{31 HandlerDeps: port.HandlerDeps{
31- SaveConfig: func(context.Context, port.WebUIConfig) error { return nil },32+ SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
32 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },33 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
33 },34 },
34 Clipboard: nil,35 Clipboard: nil,
@@ -57,7 +58,7 @@ func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) {
57 Config: &config.Config{},58 Config: &config.Config{},
58 Engine: engine,59 Engine: engine,
59 HandlerDeps: port.HandlerDeps{60 HandlerDeps: port.HandlerDeps{
60- SaveConfig: func(context.Context, port.WebUIConfig) error { return nil },61+ SaveConfig: func(context.Context, dto.WebUIConfig) error { return nil },
61 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },62 SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
62 },63 },
63 Clipboard: clipboard,64 Clipboard: clipboard,
M internal/ui/coordinator/workspace.go +21 -2
@@ -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"
@@ -261,21 +263,38 @@ func (c *WorkspaceCoordinator) ToggleSystemViewRight(ctx context.Context, target
261263 }
262264
263265 func paneMatchesURL(node *entity.PaneNode, targetURL string) bool {
264- return node != nil && node.Pane != nil && domainurl.Normalize(node.Pane.URI) == targetURL
266+ return node != nil && node.Pane != nil && comparablePaneURL(node.Pane.URI) == comparablePaneURL(targetURL)
265267 }
266268
267269 func findPaneByURL(ws *entity.Workspace, targetURL string) *entity.Pane {
268270 if ws == nil {
269271 return nil
270272 }
273+ targetURL = comparablePaneURL(targetURL)
271274 for _, pane := range ws.AllPanes() {
272- if pane != nil && domainurl.Normalize(pane.URI) == targetURL {
275+ if pane != nil && comparablePaneURL(pane.URI) == targetURL {
273276 return pane
274277 }
275278 }
276279 return nil
277280 }
278281
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+
279298 func (c *WorkspaceCoordinator) focusExistingPane(
280299 ctx context.Context,
281300 ws *entity.Workspace,
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "fmt"6 "fmt"
7+ "net/url"
8+ "strings"
7 9
8 "github.com/bnema/dumber/internal/application/usecase"10 "github.com/bnema/dumber/internal/application/usecase"
9 "github.com/bnema/dumber/internal/domain/entity"11 "github.com/bnema/dumber/internal/domain/entity"
@@ -261,21 +263,38 @@ func (c *WorkspaceCoordinator) ToggleSystemViewRight(ctx context.Context, target
261 }263 }
262 264
263 func paneMatchesURL(node *entity.PaneNode, targetURL string) bool {265 func paneMatchesURL(node *entity.PaneNode, targetURL string) bool {
264- return node != nil && node.Pane != nil && domainurl.Normalize(node.Pane.URI) == targetURL266+ return node != nil && node.Pane != nil && comparablePaneURL(node.Pane.URI) == comparablePaneURL(targetURL)
265 }267 }
266 268
267 func findPaneByURL(ws *entity.Workspace, targetURL string) *entity.Pane {269 func findPaneByURL(ws *entity.Workspace, targetURL string) *entity.Pane {
268 if ws == nil {270 if ws == nil {
269 return nil271 return nil
270 }272 }
273+ targetURL = comparablePaneURL(targetURL)
271 for _, pane := range ws.AllPanes() {274 for _, pane := range ws.AllPanes() {
272- if pane != nil && domainurl.Normalize(pane.URI) == targetURL {275+ if pane != nil && comparablePaneURL(pane.URI) == targetURL {
273 return pane276 return pane
274 }277 }
275 }278 }
276 return nil279 return nil
277 }280 }
278 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+
279 func (c *WorkspaceCoordinator) focusExistingPane(298 func (c *WorkspaceCoordinator) focusExistingPane(
280 ctx context.Context,299 ctx context.Context,
281 ws *entity.Workspace,300 ws *entity.Workspace,
M internal/ui/systemviews/app.go +2 -1
@@ -7,6 +7,7 @@ import (
77 "strings"
88 "sync"
99
10+ "github.com/bnema/dumber/internal/application/dto"
1011 "github.com/bnema/dumber/internal/application/port"
1112 "github.com/bnema/dumber/internal/domain/entity"
1213 )
@@ -42,7 +43,7 @@ type App struct {
4243 favoriteTagFilter *entity.TagID
4344 favoritesNotice string
4445 favoritesError string
45- config *port.SystemviewConfigPayload
46+ config *dto.SystemviewConfigPayload
4647 keybindings port.KeybindingsConfig
4748 configNotice string
4849 configError string
@@ -7,6 +7,7 @@ import (
7 "strings"7 "strings"
8 "sync"8 "sync"
9 9
10+ "github.com/bnema/dumber/internal/application/dto"
10 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
11 "github.com/bnema/dumber/internal/domain/entity"12 "github.com/bnema/dumber/internal/domain/entity"
12 )13 )
@@ -42,7 +43,7 @@ type App struct {
42 favoriteTagFilter *entity.TagID43 favoriteTagFilter *entity.TagID
43 favoritesNotice string44 favoritesNotice string
44 favoritesError string45 favoritesError string
45- config *port.SystemviewConfigPayload46+ config *dto.SystemviewConfigPayload
46 keybindings port.KeybindingsConfig47 keybindings port.KeybindingsConfig
47 configNotice string48 configNotice string
48 configError string49 configError string
M internal/ui/systemviews/app_test.go +30 -29
@@ -5,6 +5,7 @@ import (
55 "errors"
66 "testing"
77
8+ "github.com/bnema/dumber/internal/application/dto"
89 "github.com/bnema/dumber/internal/application/port"
910 "github.com/bnema/dumber/internal/domain/entity"
1011 "github.com/stretchr/testify/assert"
@@ -245,10 +246,10 @@ func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {
245246 URL: "https://example.com",
246247 Title: "Example",
247248 }}}
248- config := &fakeConfigService{current: port.SystemviewConfigPayload{
249- Appearance: port.WebUIAppearanceConfig{
249+ config := &fakeConfigService{current: dto.SystemviewConfigPayload{
250+ Appearance: dto.WebUIAppearanceConfig{
250251 ColorScheme: "prefer-light",
251- LightPalette: port.ColorPalette{
252+ LightPalette: dto.ColorPalette{
252253 Background: "#ffffff",
253254 Surface: "#fafafa",
254255 SurfaceVariant: "#eeeeee",
@@ -257,7 +258,7 @@ func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {
257258 Accent: "#0055ff",
258259 Border: "#dddddd",
259260 },
260- DarkPalette: port.ColorPalette{
261+ DarkPalette: dto.ColorPalette{
261262 Background: "#111111",
262263 Surface: "#1a1a1a",
263264 SurfaceVariant: "#2a2a2a",
@@ -425,15 +426,15 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
425426 func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
426427 t.Parallel()
427428
428- config := port.SystemviewConfigPayload{
429+ config := dto.SystemviewConfigPayload{
429430 EngineType: "webkit",
430- Appearance: port.WebUIAppearanceConfig{
431+ Appearance: dto.WebUIAppearanceConfig{
431432 ColorScheme: "prefer-dark",
432433 SansFont: "Inter",
433434 SerifFont: "Georgia",
434435 MonospaceFont: "JetBrains Mono",
435436 DefaultFontSize: 16,
436- LightPalette: port.ColorPalette{
437+ LightPalette: dto.ColorPalette{
437438 Background: "#ffffff",
438439 Surface: "#fafafa",
439440 SurfaceVariant: "#eeeeee",
@@ -442,7 +443,7 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
442443 Accent: "#0055ff",
443444 Border: "#dddddd",
444445 },
445- DarkPalette: port.ColorPalette{
446+ DarkPalette: dto.ColorPalette{
446447 Background: "#111111",
447448 Surface: "#1a1a1a",
448449 SurfaceVariant: "#2a2a2a",
@@ -452,16 +453,16 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
452453 Border: "#333333",
453454 },
454455 },
455- Performance: port.SystemviewPerformancePayload{
456+ Performance: dto.SystemviewPerformancePayload{
456457 Profile: "balanced",
457- Custom: port.SystemviewCustomPerformancePayload{
458+ Custom: dto.SystemviewCustomPerformancePayload{
458459 SkiaCPUThreads: 4,
459460 SkiaGPUThreads: 2,
460461 WebProcessMemoryMB: 512,
461462 NetworkProcessMemoryMB: 128,
462463 WebViewPoolPrewarm: 1,
463464 },
464- Hardware: port.SystemviewHardwarePayload{
465+ Hardware: dto.SystemviewHardwarePayload{
465466 CPUCores: 8,
466467 CPUThreads: 16,
467468 TotalRAMMB: 32768,
@@ -471,7 +472,7 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
471472 },
472473 },
473474 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
474- SearchShortcuts: map[string]port.SearchShortcut{
475+ SearchShortcuts: map[string]dto.SearchShortcut{
475476 "ddg": {
476477 URL: "https://duckduckgo.com/?q=%s",
477478 Description: "DuckDuckGo",
@@ -685,32 +686,32 @@ func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) {
685686 assert.False(t, service.calledSave)
686687 }
687688
688-func testConfigPayload() port.SystemviewConfigPayload {
689- return port.SystemviewConfigPayload{
689+func testConfigPayload() dto.SystemviewConfigPayload {
690+ return dto.SystemviewConfigPayload{
690691 EngineType: "webkit",
691- Appearance: port.WebUIAppearanceConfig{
692+ Appearance: dto.WebUIAppearanceConfig{
692693 ColorScheme: "prefer-dark",
693694 SansFont: "Inter",
694695 SerifFont: "Georgia",
695696 MonospaceFont: "JetBrains Mono",
696697 DefaultFontSize: 16,
697- LightPalette: port.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},
698- DarkPalette: port.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"},
698+ LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},
699+ DarkPalette: dto.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"},
699700 },
700701 DefaultUIScale: 1,
701702 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
702- SearchShortcuts: map[string]port.SearchShortcut{
703+ SearchShortcuts: map[string]dto.SearchShortcut{
703704 "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
704705 },
705- Performance: port.SystemviewPerformancePayload{
706+ Performance: dto.SystemviewPerformancePayload{
706707 Profile: "default",
707- Custom: port.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2},
708- Hardware: port.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096},
708+ Custom: dto.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2},
709+ Hardware: dto.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096},
709710 },
710711 }
711712 }
712713
713-func testDefaultConfigPayload() port.SystemviewConfigPayload {
714+func testDefaultConfigPayload() dto.SystemviewConfigPayload {
714715 cfg := testConfigPayload()
715716 cfg.Appearance.DefaultFontSize = 14
716717 cfg.DefaultUIScale = 1
@@ -934,26 +935,26 @@ type fakeConfigService struct {
934935 calledReset bool
935936 calledResetAll bool
936937
937- current port.SystemviewConfigPayload
938- defaultCfg port.SystemviewConfigPayload
938+ current dto.SystemviewConfigPayload
939+ defaultCfg dto.SystemviewConfigPayload
939940 keybindings port.KeybindingsConfig
940- savedConfig port.WebUIConfig
941+ savedConfig dto.WebUIConfig
941942 setReq port.SetKeybindingRequest
942943 setResp port.SetKeybindingResponse
943944 resetReq port.ResetKeybindingRequest
944945 }
945946
946-func (s *fakeConfigService) Current(context.Context) (port.SystemviewConfigPayload, error) {
947+func (s *fakeConfigService) Current(context.Context) (dto.SystemviewConfigPayload, error) {
947948 s.calledCurrent = true
948949 return s.current, nil
949950 }
950951
951-func (s *fakeConfigService) Default(context.Context) (port.SystemviewConfigPayload, error) {
952+func (s *fakeConfigService) Default(context.Context) (dto.SystemviewConfigPayload, error) {
952953 s.calledDefault = true
953954 return s.defaultCfg, nil
954955 }
955956
956-func (s *fakeConfigService) Save(_ context.Context, cfg port.WebUIConfig) error {
957+func (s *fakeConfigService) Save(_ context.Context, cfg dto.WebUIConfig) error {
957958 s.calledSave = true
958959 s.savedConfig = cfg
959960 s.current.Appearance = cfg.Appearance
@@ -961,7 +962,7 @@ func (s *fakeConfigService) Save(_ context.Context, cfg port.WebUIConfig) error
961962 s.current.DefaultSearchEngine = cfg.DefaultSearchEngine
962963 s.current.SearchShortcuts = cfg.SearchShortcuts
963964 s.current.Performance.Profile = cfg.Performance.Profile
964- s.current.Performance.Custom = port.SystemviewCustomPerformancePayload{
965+ s.current.Performance.Custom = dto.SystemviewCustomPerformancePayload{
965966 SkiaCPUThreads: cfg.Performance.Custom.SkiaCPUThreads,
966967 SkiaGPUThreads: cfg.Performance.Custom.SkiaGPUThreads,
967968 WebProcessMemoryMB: cfg.Performance.Custom.WebProcessMemoryMB,
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "testing"6 "testing"
7 7
8+ "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
@@ -245,10 +246,10 @@ func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {
245 URL: "https://example.com",246 URL: "https://example.com",
246 Title: "Example",247 Title: "Example",
247 }}}248 }}}
248- config := &fakeConfigService{current: port.SystemviewConfigPayload{249+ config := &fakeConfigService{current: dto.SystemviewConfigPayload{
249- Appearance: port.WebUIAppearanceConfig{250+ Appearance: dto.WebUIAppearanceConfig{
250 ColorScheme: "prefer-light",251 ColorScheme: "prefer-light",
251- LightPalette: port.ColorPalette{252+ LightPalette: dto.ColorPalette{
252 Background: "#ffffff",253 Background: "#ffffff",
253 Surface: "#fafafa",254 Surface: "#fafafa",
254 SurfaceVariant: "#eeeeee",255 SurfaceVariant: "#eeeeee",
@@ -257,7 +258,7 @@ func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) {
257 Accent: "#0055ff",258 Accent: "#0055ff",
258 Border: "#dddddd",259 Border: "#dddddd",
259 },260 },
260- DarkPalette: port.ColorPalette{261+ DarkPalette: dto.ColorPalette{
261 Background: "#111111",262 Background: "#111111",
262 Surface: "#1a1a1a",263 Surface: "#1a1a1a",
263 SurfaceVariant: "#2a2a2a",264 SurfaceVariant: "#2a2a2a",
@@ -425,15 +426,15 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
425 func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {426 func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
426 t.Parallel()427 t.Parallel()
427 428
428- config := port.SystemviewConfigPayload{429+ config := dto.SystemviewConfigPayload{
429 EngineType: "webkit",430 EngineType: "webkit",
430- Appearance: port.WebUIAppearanceConfig{431+ Appearance: dto.WebUIAppearanceConfig{
431 ColorScheme: "prefer-dark",432 ColorScheme: "prefer-dark",
432 SansFont: "Inter",433 SansFont: "Inter",
433 SerifFont: "Georgia",434 SerifFont: "Georgia",
434 MonospaceFont: "JetBrains Mono",435 MonospaceFont: "JetBrains Mono",
435 DefaultFontSize: 16,436 DefaultFontSize: 16,
436- LightPalette: port.ColorPalette{437+ LightPalette: dto.ColorPalette{
437 Background: "#ffffff",438 Background: "#ffffff",
438 Surface: "#fafafa",439 Surface: "#fafafa",
439 SurfaceVariant: "#eeeeee",440 SurfaceVariant: "#eeeeee",
@@ -442,7 +443,7 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
442 Accent: "#0055ff",443 Accent: "#0055ff",
443 Border: "#dddddd",444 Border: "#dddddd",
444 },445 },
445- DarkPalette: port.ColorPalette{446+ DarkPalette: dto.ColorPalette{
446 Background: "#111111",447 Background: "#111111",
447 Surface: "#1a1a1a",448 Surface: "#1a1a1a",
448 SurfaceVariant: "#2a2a2a",449 SurfaceVariant: "#2a2a2a",
@@ -452,16 +453,16 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
452 Border: "#333333",453 Border: "#333333",
453 },454 },
454 },455 },
455- Performance: port.SystemviewPerformancePayload{456+ Performance: dto.SystemviewPerformancePayload{
456 Profile: "balanced",457 Profile: "balanced",
457- Custom: port.SystemviewCustomPerformancePayload{458+ Custom: dto.SystemviewCustomPerformancePayload{
458 SkiaCPUThreads: 4,459 SkiaCPUThreads: 4,
459 SkiaGPUThreads: 2,460 SkiaGPUThreads: 2,
460 WebProcessMemoryMB: 512,461 WebProcessMemoryMB: 512,
461 NetworkProcessMemoryMB: 128,462 NetworkProcessMemoryMB: 128,
462 WebViewPoolPrewarm: 1,463 WebViewPoolPrewarm: 1,
463 },464 },
464- Hardware: port.SystemviewHardwarePayload{465+ Hardware: dto.SystemviewHardwarePayload{
465 CPUCores: 8,466 CPUCores: 8,
466 CPUThreads: 16,467 CPUThreads: 16,
467 TotalRAMMB: 32768,468 TotalRAMMB: 32768,
@@ -471,7 +472,7 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
471 },472 },
472 },473 },
473 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",474 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
474- SearchShortcuts: map[string]port.SearchShortcut{475+ SearchShortcuts: map[string]dto.SearchShortcut{
475 "ddg": {476 "ddg": {
476 URL: "https://duckduckgo.com/?q=%s",477 URL: "https://duckduckgo.com/?q=%s",
477 Description: "DuckDuckGo",478 Description: "DuckDuckGo",
@@ -685,32 +686,32 @@ func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) {
685 assert.False(t, service.calledSave)686 assert.False(t, service.calledSave)
686 }687 }
687 688
688-func testConfigPayload() port.SystemviewConfigPayload {689+func testConfigPayload() dto.SystemviewConfigPayload {
689- return port.SystemviewConfigPayload{690+ return dto.SystemviewConfigPayload{
690 EngineType: "webkit",691 EngineType: "webkit",
691- Appearance: port.WebUIAppearanceConfig{692+ Appearance: dto.WebUIAppearanceConfig{
692 ColorScheme: "prefer-dark",693 ColorScheme: "prefer-dark",
693 SansFont: "Inter",694 SansFont: "Inter",
694 SerifFont: "Georgia",695 SerifFont: "Georgia",
695 MonospaceFont: "JetBrains Mono",696 MonospaceFont: "JetBrains Mono",
696 DefaultFontSize: 16,697 DefaultFontSize: 16,
697- LightPalette: port.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},698+ LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},
698- DarkPalette: port.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"},699+ DarkPalette: dto.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"},
699 },700 },
700 DefaultUIScale: 1,701 DefaultUIScale: 1,
701 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",702 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
702- SearchShortcuts: map[string]port.SearchShortcut{703+ SearchShortcuts: map[string]dto.SearchShortcut{
703 "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},704 "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
704 },705 },
705- Performance: port.SystemviewPerformancePayload{706+ Performance: dto.SystemviewPerformancePayload{
706 Profile: "default",707 Profile: "default",
707- Custom: port.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2},708+ Custom: dto.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2},
708- Hardware: port.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096},709+ Hardware: dto.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096},
709 },710 },
710 }711 }
711 }712 }
712 713
713-func testDefaultConfigPayload() port.SystemviewConfigPayload {714+func testDefaultConfigPayload() dto.SystemviewConfigPayload {
714 cfg := testConfigPayload()715 cfg := testConfigPayload()
715 cfg.Appearance.DefaultFontSize = 14716 cfg.Appearance.DefaultFontSize = 14
716 cfg.DefaultUIScale = 1717 cfg.DefaultUIScale = 1
@@ -934,26 +935,26 @@ type fakeConfigService struct {
934 calledReset bool935 calledReset bool
935 calledResetAll bool936 calledResetAll bool
936 937
937- current port.SystemviewConfigPayload938+ current dto.SystemviewConfigPayload
938- defaultCfg port.SystemviewConfigPayload939+ defaultCfg dto.SystemviewConfigPayload
939 keybindings port.KeybindingsConfig940 keybindings port.KeybindingsConfig
940- savedConfig port.WebUIConfig941+ savedConfig dto.WebUIConfig
941 setReq port.SetKeybindingRequest942 setReq port.SetKeybindingRequest
942 setResp port.SetKeybindingResponse943 setResp port.SetKeybindingResponse
943 resetReq port.ResetKeybindingRequest944 resetReq port.ResetKeybindingRequest
944 }945 }
945 946
946-func (s *fakeConfigService) Current(context.Context) (port.SystemviewConfigPayload, error) {947+func (s *fakeConfigService) Current(context.Context) (dto.SystemviewConfigPayload, error) {
947 s.calledCurrent = true948 s.calledCurrent = true
948 return s.current, nil949 return s.current, nil
949 }950 }
950 951
951-func (s *fakeConfigService) Default(context.Context) (port.SystemviewConfigPayload, error) {952+func (s *fakeConfigService) Default(context.Context) (dto.SystemviewConfigPayload, error) {
952 s.calledDefault = true953 s.calledDefault = true
953 return s.defaultCfg, nil954 return s.defaultCfg, nil
954 }955 }
955 956
956-func (s *fakeConfigService) Save(_ context.Context, cfg port.WebUIConfig) error {957+func (s *fakeConfigService) Save(_ context.Context, cfg dto.WebUIConfig) error {
957 s.calledSave = true958 s.calledSave = true
958 s.savedConfig = cfg959 s.savedConfig = cfg
959 s.current.Appearance = cfg.Appearance960 s.current.Appearance = cfg.Appearance
@@ -961,7 +962,7 @@ func (s *fakeConfigService) Save(_ context.Context, cfg port.WebUIConfig) error
961 s.current.DefaultSearchEngine = cfg.DefaultSearchEngine962 s.current.DefaultSearchEngine = cfg.DefaultSearchEngine
962 s.current.SearchShortcuts = cfg.SearchShortcuts963 s.current.SearchShortcuts = cfg.SearchShortcuts
963 s.current.Performance.Profile = cfg.Performance.Profile964 s.current.Performance.Profile = cfg.Performance.Profile
964- s.current.Performance.Custom = port.SystemviewCustomPerformancePayload{965+ s.current.Performance.Custom = dto.SystemviewCustomPerformancePayload{
965 SkiaCPUThreads: cfg.Performance.Custom.SkiaCPUThreads,966 SkiaCPUThreads: cfg.Performance.Custom.SkiaCPUThreads,
966 SkiaGPUThreads: cfg.Performance.Custom.SkiaGPUThreads,967 SkiaGPUThreads: cfg.Performance.Custom.SkiaGPUThreads,
967 WebProcessMemoryMB: cfg.Performance.Custom.WebProcessMemoryMB,968 WebProcessMemoryMB: cfg.Performance.Custom.WebProcessMemoryMB,
M internal/ui/systemviews/config.templ +15 -12
@@ -1,6 +1,9 @@
11 package systemviews
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
58 templ ConfigView(data configRenderData) {
69 @Alert("success", data.Notice)
@@ -23,7 +26,7 @@ templ ConfigView(data configRenderData) {
2326 }
2427 }
2528
26-templ AppearanceForm(config port.SystemviewConfigPayload) {
29+templ AppearanceForm(config dto.SystemviewConfigPayload) {
2730 <form class="sv-card-form sv-config-form" data-sv-action="config.appearance.save">
2831 <label><span>Sans font</span><input name="sans_font" type="text" value={ config.Appearance.SansFont } required/></label>
2932 <label><span>Serif font</span><input name="serif_font" type="text" value={ config.Appearance.SerifFont } required/></label>
@@ -44,20 +47,20 @@ templ AppearanceForm(config port.SystemviewConfigPayload) {
4447 </form>
4548 }
4649
47-templ PaletteFields(title string, prefix string, palette port.ColorPalette) {
50+templ PaletteFields(title string, prefix string, palette dto.ColorPalette) {
4851 <fieldset class="sv-fieldset">
4952 <legend>{ title }</legend>
50- <label><span>Background</span><input name={ prefix + "_background" } type="color" value={ palette.Background } required/></label>
51- <label><span>Surface</span><input name={ prefix + "_surface" } type="color" value={ palette.Surface } required/></label>
52- <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="color" value={ palette.SurfaceVariant } required/></label>
53- <label><span>Text</span><input name={ prefix + "_text" } type="color" value={ palette.Text } required/></label>
54- <label><span>Muted</span><input name={ prefix + "_muted" } type="color" value={ palette.Muted } required/></label>
55- <label><span>Accent</span><input name={ prefix + "_accent" } type="color" value={ palette.Accent } required/></label>
56- <label><span>Border</span><input name={ prefix + "_border" } type="color" value={ palette.Border } required/></label>
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>
5760 </fieldset>
5861 }
5962
60-templ SearchSettings(config port.SystemviewConfigPayload) {
63+templ SearchSettings(config dto.SystemviewConfigPayload) {
6164 <form class="sv-card-form" data-sv-action="config.search.save">
6265 <label><span>Default search engine</span><input name="default_search_engine" type="text" value={ config.DefaultSearchEngine } placeholder="https://duckduckgo.com/?q=%s" required/></label>
6366 <button class="sv-button" type="submit">Save search default</button>
@@ -97,7 +100,7 @@ templ SearchShortcutItem(shortcut searchShortcutRow) {
97100 </li>
98101 }
99102
100-templ PerformanceSettings(config port.SystemviewConfigPayload) {
103+templ PerformanceSettings(config dto.SystemviewConfigPayload) {
101104 {{ customProfile := performanceProfileSelected(config.Performance.Profile, "custom") }}
102105 <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save">
103106 <label><span>Profile</span><select name="profile">
@@ -1,6 +1,9 @@
1 package systemviews1 package systemviews
2 2
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+)
4 7
5 templ ConfigView(data configRenderData) {8 templ ConfigView(data configRenderData) {
6 @Alert("success", data.Notice)9 @Alert("success", data.Notice)
@@ -23,7 +26,7 @@ templ ConfigView(data configRenderData) {
23 }26 }
24 }27 }
25 28
26-templ AppearanceForm(config port.SystemviewConfigPayload) {29+templ AppearanceForm(config dto.SystemviewConfigPayload) {
27 <form class="sv-card-form sv-config-form" data-sv-action="config.appearance.save">30 <form class="sv-card-form sv-config-form" data-sv-action="config.appearance.save">
28 <label><span>Sans font</span><input name="sans_font" type="text" value={ config.Appearance.SansFont } required/></label>31 <label><span>Sans font</span><input name="sans_font" type="text" value={ config.Appearance.SansFont } required/></label>
29 <label><span>Serif font</span><input name="serif_font" type="text" value={ config.Appearance.SerifFont } required/></label>32 <label><span>Serif font</span><input name="serif_font" type="text" value={ config.Appearance.SerifFont } required/></label>
@@ -44,20 +47,20 @@ templ AppearanceForm(config port.SystemviewConfigPayload) {
44 </form>47 </form>
45 }48 }
46 49
47-templ PaletteFields(title string, prefix string, palette port.ColorPalette) {50+templ PaletteFields(title string, prefix string, palette dto.ColorPalette) {
48 <fieldset class="sv-fieldset">51 <fieldset class="sv-fieldset">
49 <legend>{ title }</legend>52 <legend>{ title }</legend>
50- <label><span>Background</span><input name={ prefix + "_background" } type="color" value={ palette.Background } required/></label>53+ <label><span>Background</span><input name={ prefix + "_background" } type="text" value={ palette.Background } required/></label>
51- <label><span>Surface</span><input name={ prefix + "_surface" } type="color" value={ palette.Surface } required/></label>54+ <label><span>Surface</span><input name={ prefix + "_surface" } type="text" value={ palette.Surface } required/></label>
52- <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="color" value={ palette.SurfaceVariant } required/></label>55+ <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="text" value={ palette.SurfaceVariant } required/></label>
53- <label><span>Text</span><input name={ prefix + "_text" } type="color" value={ palette.Text } required/></label>56+ <label><span>Text</span><input name={ prefix + "_text" } type="text" value={ palette.Text } required/></label>
54- <label><span>Muted</span><input name={ prefix + "_muted" } type="color" value={ palette.Muted } required/></label>57+ <label><span>Muted</span><input name={ prefix + "_muted" } type="text" value={ palette.Muted } required/></label>
55- <label><span>Accent</span><input name={ prefix + "_accent" } type="color" value={ palette.Accent } required/></label>58+ <label><span>Accent</span><input name={ prefix + "_accent" } type="text" value={ palette.Accent } required/></label>
56- <label><span>Border</span><input name={ prefix + "_border" } type="color" value={ palette.Border } required/></label>59+ <label><span>Border</span><input name={ prefix + "_border" } type="text" value={ palette.Border } required/></label>
57 </fieldset>60 </fieldset>
58 }61 }
59 62
60-templ SearchSettings(config port.SystemviewConfigPayload) {63+templ SearchSettings(config dto.SystemviewConfigPayload) {
61 <form class="sv-card-form" data-sv-action="config.search.save">64 <form class="sv-card-form" data-sv-action="config.search.save">
62 <label><span>Default search engine</span><input name="default_search_engine" type="text" value={ config.DefaultSearchEngine } placeholder="https://duckduckgo.com/?q=%s" required/></label>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>
63 <button class="sv-button" type="submit">Save search default</button>66 <button class="sv-button" type="submit">Save search default</button>
@@ -97,7 +100,7 @@ templ SearchShortcutItem(shortcut searchShortcutRow) {
97 </li>100 </li>
98 }101 }
99 102
100-templ PerformanceSettings(config port.SystemviewConfigPayload) {103+templ PerformanceSettings(config dto.SystemviewConfigPayload) {
101 {{ customProfile := performanceProfileSelected(config.Performance.Profile, "custom") }}104 {{ customProfile := performanceProfileSelected(config.Performance.Profile, "custom") }}
102 <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save">105 <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save">
103 <label><span>Profile</span><select name="profile">106 <label><span>Profile</span><select name="profile">
M internal/ui/systemviews/config_actions.go +34 -21
@@ -9,6 +9,7 @@ import (
99 "strings"
1010 "sync/atomic"
1111
12+ "github.com/bnema/dumber/internal/application/dto"
1213 "github.com/bnema/dumber/internal/application/port"
1314 )
1415
@@ -236,6 +237,9 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string)
236237 return err
237238 }
238239 profile := strings.TrimSpace(data["profile"])
240+ if !isAllowedPerformanceProfile(profile) {
241+ return fmt.Errorf("unknown performance profile: %q", profile)
242+ }
239243 cfg.Performance.Profile = profile
240244 if profile != "custom" {
241245 if err := a.saveEditableConfig(ctx, cfg); err != nil {
@@ -281,7 +285,7 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string)
281285 return err
282286 }
283287
284- cfg.Performance.Custom = port.WebUICustomPerformanceConfig{
288+ cfg.Performance.Custom = dto.WebUICustomPerformanceConfig{
285289 SkiaCPUThreads: skiaCPU,
286290 SkiaGPUThreads: skiaGPU,
287291 WebProcessMemoryMB: webMemory,
@@ -342,10 +346,10 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string)
342346 return nil
343347 }
344348
345-func (a *App) editableConfig(ctx context.Context) (port.WebUIConfig, error) {
349+func (a *App) editableConfig(ctx context.Context) (dto.WebUIConfig, error) {
346350 cfg, err := a.deps.Config.Current(ctx)
347351 if err != nil {
348- return port.WebUIConfig{}, err
352+ return dto.WebUIConfig{}, err
349353 }
350354 a.config = &cfg
351355 return webUIConfigFromPayload(cfg), nil
@@ -371,7 +375,7 @@ func (a *App) requireKeybindingTarget(ctx context.Context, mode, action string)
371375 return fmt.Errorf("keybinding %s/%s not found", mode, action)
372376 }
373377
374-func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) error {
378+func (a *App) saveEditableConfig(ctx context.Context, cfg dto.WebUIConfig) error {
375379 if err := a.deps.Config.Save(ctx, cfg); err != nil {
376380 return err
377381 }
@@ -379,15 +383,15 @@ func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) erro
379383 return nil
380384 }
381385
382-func webUIConfigFromPayload(payload port.SystemviewConfigPayload) port.WebUIConfig {
383- return port.WebUIConfig{
386+func webUIConfigFromPayload(payload dto.SystemviewConfigPayload) dto.WebUIConfig {
387+ return dto.WebUIConfig{
384388 Appearance: payload.Appearance,
385389 DefaultUIScale: payload.DefaultUIScale,
386390 DefaultSearchEngine: payload.DefaultSearchEngine,
387391 SearchShortcuts: cloneSearchShortcuts(payload.SearchShortcuts),
388- Performance: port.WebUIPerformanceConfig{
392+ Performance: dto.WebUIPerformanceConfig{
389393 Profile: payload.Performance.Profile,
390- Custom: port.WebUICustomPerformanceConfig{
394+ Custom: dto.WebUICustomPerformanceConfig{
391395 SkiaCPUThreads: payload.Performance.Custom.SkiaCPUThreads,
392396 SkiaGPUThreads: payload.Performance.Custom.SkiaGPUThreads,
393397 WebProcessMemoryMB: payload.Performance.Custom.WebProcessMemoryMB,
@@ -398,15 +402,15 @@ func webUIConfigFromPayload(payload port.SystemviewConfigPayload) port.WebUIConf
398402 }
399403 }
400404
401-func cloneSearchShortcuts(shortcuts map[string]port.SearchShortcut) map[string]port.SearchShortcut {
402- clone := make(map[string]port.SearchShortcut, len(shortcuts))
405+func cloneSearchShortcuts(shortcuts map[string]dto.SearchShortcut) map[string]dto.SearchShortcut {
406+ clone := make(map[string]dto.SearchShortcut, len(shortcuts))
403407 for key, shortcut := range shortcuts {
404408 clone[key] = shortcut
405409 }
406410 return clone
407411 }
408412
409-func paletteFromForm(data map[string]string, prefix string) (port.ColorPalette, error) {
413+func paletteFromForm(data map[string]string, prefix string) (dto.ColorPalette, error) {
410414 color := func(field string) (string, error) {
411415 value := strings.TrimSpace(data[prefix+"_"+field])
412416 if value == "" || isValidHexColor(value) {
@@ -417,34 +421,34 @@ func paletteFromForm(data map[string]string, prefix string) (port.ColorPalette,
417421
418422 background, err := color("background")
419423 if err != nil {
420- return port.ColorPalette{}, err
424+ return dto.ColorPalette{}, err
421425 }
422426 surface, err := color("surface")
423427 if err != nil {
424- return port.ColorPalette{}, err
428+ return dto.ColorPalette{}, err
425429 }
426430 surfaceVariant, err := color("surface_variant")
427431 if err != nil {
428- return port.ColorPalette{}, err
432+ return dto.ColorPalette{}, err
429433 }
430434 text, err := color("text")
431435 if err != nil {
432- return port.ColorPalette{}, err
436+ return dto.ColorPalette{}, err
433437 }
434438 muted, err := color("muted")
435439 if err != nil {
436- return port.ColorPalette{}, err
440+ return dto.ColorPalette{}, err
437441 }
438442 accent, err := color("accent")
439443 if err != nil {
440- return port.ColorPalette{}, err
444+ return dto.ColorPalette{}, err
441445 }
442446 border, err := color("border")
443447 if err != nil {
444- return port.ColorPalette{}, err
448+ return dto.ColorPalette{}, err
445449 }
446450
447- return port.ColorPalette{
451+ return dto.ColorPalette{
448452 Background: background,
449453 Surface: surface,
450454 SurfaceVariant: surfaceVariant,
@@ -459,8 +463,8 @@ func isValidHexColor(value string) bool {
459463 return isHexColor(strings.TrimSpace(value))
460464 }
461465
462-func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut) {
463- return strings.TrimSpace(data[keyField]), port.SearchShortcut{
466+func searchShortcutFromForm(data map[string]string, keyField string) (string, dto.SearchShortcut) {
467+ return strings.TrimSpace(data[keyField]), dto.SearchShortcut{
464468 URL: strings.TrimSpace(data["url"]),
465469 Description: strings.TrimSpace(data["description"]),
466470 }
@@ -497,6 +501,15 @@ func parseConfigFloat(raw, label string) (float64, error) {
497501 return value, nil
498502 }
499503
504+func isAllowedPerformanceProfile(profile string) bool {
505+ switch profile {
506+ case "default", "lite", "max", "custom":
507+ return true
508+ default:
509+ return false
510+ }
511+}
512+
500513 func validatePerformanceInt(value int, label string, minValue, maxValue int) error {
501514 if value < minValue || value > maxValue {
502515 return fmt.Errorf("%s must be between %d and %d", label, minValue, maxValue)
@@ -9,6 +9,7 @@ import (
9 "strings"9 "strings"
10 "sync/atomic"10 "sync/atomic"
11 11
12+ "github.com/bnema/dumber/internal/application/dto"
12 "github.com/bnema/dumber/internal/application/port"13 "github.com/bnema/dumber/internal/application/port"
13 )14 )
14 15
@@ -236,6 +237,9 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string)
236 return err237 return err
237 }238 }
238 profile := strings.TrimSpace(data["profile"])239 profile := strings.TrimSpace(data["profile"])
240+ if !isAllowedPerformanceProfile(profile) {
241+ return fmt.Errorf("unknown performance profile: %q", profile)
242+ }
239 cfg.Performance.Profile = profile243 cfg.Performance.Profile = profile
240 if profile != "custom" {244 if profile != "custom" {
241 if err := a.saveEditableConfig(ctx, cfg); err != nil {245 if err := a.saveEditableConfig(ctx, cfg); err != nil {
@@ -281,7 +285,7 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string)
281 return err285 return err
282 }286 }
283 287
284- cfg.Performance.Custom = port.WebUICustomPerformanceConfig{288+ cfg.Performance.Custom = dto.WebUICustomPerformanceConfig{
285 SkiaCPUThreads: skiaCPU,289 SkiaCPUThreads: skiaCPU,
286 SkiaGPUThreads: skiaGPU,290 SkiaGPUThreads: skiaGPU,
287 WebProcessMemoryMB: webMemory,291 WebProcessMemoryMB: webMemory,
@@ -342,10 +346,10 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string)
342 return nil346 return nil
343 }347 }
344 348
345-func (a *App) editableConfig(ctx context.Context) (port.WebUIConfig, error) {349+func (a *App) editableConfig(ctx context.Context) (dto.WebUIConfig, error) {
346 cfg, err := a.deps.Config.Current(ctx)350 cfg, err := a.deps.Config.Current(ctx)
347 if err != nil {351 if err != nil {
348- return port.WebUIConfig{}, err352+ return dto.WebUIConfig{}, err
349 }353 }
350 a.config = &cfg354 a.config = &cfg
351 return webUIConfigFromPayload(cfg), nil355 return webUIConfigFromPayload(cfg), nil
@@ -371,7 +375,7 @@ func (a *App) requireKeybindingTarget(ctx context.Context, mode, action string)
371 return fmt.Errorf("keybinding %s/%s not found", mode, action)375 return fmt.Errorf("keybinding %s/%s not found", mode, action)
372 }376 }
373 377
374-func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) error {378+func (a *App) saveEditableConfig(ctx context.Context, cfg dto.WebUIConfig) error {
375 if err := a.deps.Config.Save(ctx, cfg); err != nil {379 if err := a.deps.Config.Save(ctx, cfg); err != nil {
376 return err380 return err
377 }381 }
@@ -379,15 +383,15 @@ func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) erro
379 return nil383 return nil
380 }384 }
381 385
382-func webUIConfigFromPayload(payload port.SystemviewConfigPayload) port.WebUIConfig {386+func webUIConfigFromPayload(payload dto.SystemviewConfigPayload) dto.WebUIConfig {
383- return port.WebUIConfig{387+ return dto.WebUIConfig{
384 Appearance: payload.Appearance,388 Appearance: payload.Appearance,
385 DefaultUIScale: payload.DefaultUIScale,389 DefaultUIScale: payload.DefaultUIScale,
386 DefaultSearchEngine: payload.DefaultSearchEngine,390 DefaultSearchEngine: payload.DefaultSearchEngine,
387 SearchShortcuts: cloneSearchShortcuts(payload.SearchShortcuts),391 SearchShortcuts: cloneSearchShortcuts(payload.SearchShortcuts),
388- Performance: port.WebUIPerformanceConfig{392+ Performance: dto.WebUIPerformanceConfig{
389 Profile: payload.Performance.Profile,393 Profile: payload.Performance.Profile,
390- Custom: port.WebUICustomPerformanceConfig{394+ Custom: dto.WebUICustomPerformanceConfig{
391 SkiaCPUThreads: payload.Performance.Custom.SkiaCPUThreads,395 SkiaCPUThreads: payload.Performance.Custom.SkiaCPUThreads,
392 SkiaGPUThreads: payload.Performance.Custom.SkiaGPUThreads,396 SkiaGPUThreads: payload.Performance.Custom.SkiaGPUThreads,
393 WebProcessMemoryMB: payload.Performance.Custom.WebProcessMemoryMB,397 WebProcessMemoryMB: payload.Performance.Custom.WebProcessMemoryMB,
@@ -398,15 +402,15 @@ func webUIConfigFromPayload(payload port.SystemviewConfigPayload) port.WebUIConf
398 }402 }
399 }403 }
400 404
401-func cloneSearchShortcuts(shortcuts map[string]port.SearchShortcut) map[string]port.SearchShortcut {405+func cloneSearchShortcuts(shortcuts map[string]dto.SearchShortcut) map[string]dto.SearchShortcut {
402- clone := make(map[string]port.SearchShortcut, len(shortcuts))406+ clone := make(map[string]dto.SearchShortcut, len(shortcuts))
403 for key, shortcut := range shortcuts {407 for key, shortcut := range shortcuts {
404 clone[key] = shortcut408 clone[key] = shortcut
405 }409 }
406 return clone410 return clone
407 }411 }
408 412
409-func paletteFromForm(data map[string]string, prefix string) (port.ColorPalette, error) {413+func paletteFromForm(data map[string]string, prefix string) (dto.ColorPalette, error) {
410 color := func(field string) (string, error) {414 color := func(field string) (string, error) {
411 value := strings.TrimSpace(data[prefix+"_"+field])415 value := strings.TrimSpace(data[prefix+"_"+field])
412 if value == "" || isValidHexColor(value) {416 if value == "" || isValidHexColor(value) {
@@ -417,34 +421,34 @@ func paletteFromForm(data map[string]string, prefix string) (port.ColorPalette,
417 421
418 background, err := color("background")422 background, err := color("background")
419 if err != nil {423 if err != nil {
420- return port.ColorPalette{}, err424+ return dto.ColorPalette{}, err
421 }425 }
422 surface, err := color("surface")426 surface, err := color("surface")
423 if err != nil {427 if err != nil {
424- return port.ColorPalette{}, err428+ return dto.ColorPalette{}, err
425 }429 }
426 surfaceVariant, err := color("surface_variant")430 surfaceVariant, err := color("surface_variant")
427 if err != nil {431 if err != nil {
428- return port.ColorPalette{}, err432+ return dto.ColorPalette{}, err
429 }433 }
430 text, err := color("text")434 text, err := color("text")
431 if err != nil {435 if err != nil {
432- return port.ColorPalette{}, err436+ return dto.ColorPalette{}, err
433 }437 }
434 muted, err := color("muted")438 muted, err := color("muted")
435 if err != nil {439 if err != nil {
436- return port.ColorPalette{}, err440+ return dto.ColorPalette{}, err
437 }441 }
438 accent, err := color("accent")442 accent, err := color("accent")
439 if err != nil {443 if err != nil {
440- return port.ColorPalette{}, err444+ return dto.ColorPalette{}, err
441 }445 }
442 border, err := color("border")446 border, err := color("border")
443 if err != nil {447 if err != nil {
444- return port.ColorPalette{}, err448+ return dto.ColorPalette{}, err
445 }449 }
446 450
447- return port.ColorPalette{451+ return dto.ColorPalette{
448 Background: background,452 Background: background,
449 Surface: surface,453 Surface: surface,
450 SurfaceVariant: surfaceVariant,454 SurfaceVariant: surfaceVariant,
@@ -459,8 +463,8 @@ func isValidHexColor(value string) bool {
459 return isHexColor(strings.TrimSpace(value))463 return isHexColor(strings.TrimSpace(value))
460 }464 }
461 465
462-func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut) {466+func searchShortcutFromForm(data map[string]string, keyField string) (string, dto.SearchShortcut) {
463- return strings.TrimSpace(data[keyField]), port.SearchShortcut{467+ return strings.TrimSpace(data[keyField]), dto.SearchShortcut{
464 URL: strings.TrimSpace(data["url"]),468 URL: strings.TrimSpace(data["url"]),
465 Description: strings.TrimSpace(data["description"]),469 Description: strings.TrimSpace(data["description"]),
466 }470 }
@@ -497,6 +501,15 @@ func parseConfigFloat(raw, label string) (float64, error) {
497 return value, nil501 return value, nil
498 }502 }
499 503
504+func isAllowedPerformanceProfile(profile string) bool {
505+ switch profile {
506+ case "default", "lite", "max", "custom":
507+ return true
508+ default:
509+ return false
510+ }
511+}
512+
500 func validatePerformanceInt(value int, label string, minValue, maxValue int) error {513 func validatePerformanceInt(value int, label string, minValue, maxValue int) error {
501 if value < minValue || value > maxValue {514 if value < minValue || value > maxValue {
502 return fmt.Errorf("%s must be between %d and %d", label, minValue, maxValue)515 return fmt.Errorf("%s must be between %d and %d", label, minValue, maxValue)
M internal/ui/systemviews/config_templ.go +60 -57 generated file: collapsed
M internal/ui/systemviews/config_view.go +3 -2
@@ -6,11 +6,12 @@ import (
66 "sort"
77 "strings"
88
9+ "github.com/bnema/dumber/internal/application/dto"
910 "github.com/bnema/dumber/internal/application/port"
1011 )
1112
1213 type configRenderData struct {
13- Config port.SystemviewConfigPayload
14+ Config dto.SystemviewConfigPayload
1415 Keybindings port.KeybindingsConfig
1516 Notice string
1617 Error string
@@ -100,7 +101,7 @@ type searchShortcutRow struct {
100101 Description string
101102 }
102103
103-func searchShortcutRows(shortcuts map[string]port.SearchShortcut) []searchShortcutRow {
104+func searchShortcutRows(shortcuts map[string]dto.SearchShortcut) []searchShortcutRow {
104105 if len(shortcuts) == 0 {
105106 return nil
106107 }
@@ -6,11 +6,12 @@ import (
6 "sort"6 "sort"
7 "strings"7 "strings"
8 8
9+ "github.com/bnema/dumber/internal/application/dto"
9 "github.com/bnema/dumber/internal/application/port"10 "github.com/bnema/dumber/internal/application/port"
10 )11 )
11 12
12 type configRenderData struct {13 type configRenderData struct {
13- Config port.SystemviewConfigPayload14+ Config dto.SystemviewConfigPayload
14 Keybindings port.KeybindingsConfig15 Keybindings port.KeybindingsConfig
15 Notice string16 Notice string
16 Error string17 Error string
@@ -100,7 +101,7 @@ type searchShortcutRow struct {
100 Description string101 Description string
101 }102 }
102 103
103-func searchShortcutRows(shortcuts map[string]port.SearchShortcut) []searchShortcutRow {104+func searchShortcutRows(shortcuts map[string]dto.SearchShortcut) []searchShortcutRow {
104 if len(shortcuts) == 0 {105 if len(shortcuts) == 0 {
105 return nil106 return nil
106 }107 }
M internal/ui/systemviews/dom_js.go +5 -6
@@ -402,7 +402,10 @@ func isEditableTarget(target js.Value) bool {
402402 return false
403403 }
404404 tag := strings.ToLower(target.Get("tagName").String())
405- return tag == "input" || tag == "textarea" || tag == "select" || target.Get("isContentEditable").Bool()
405+ role := strings.ToLower(target.Get("role").String())
406+ return tag == "input" || tag == "textarea" || tag == "select" || tag == "button" ||
407+ (tag == "a" && target.Get("href").String() != "") || role == "button" || role == "link" ||
408+ target.Get("isContentEditable").Bool()
406409 }
407410
408411 func (d *browserDOM) focusHistoryRow(delta int) {
@@ -456,11 +459,7 @@ func (d *browserDOM) activeRow(selector, datasetKey string) js.Value {
456459 }
457460 idx := d.activeIndex(datasetKey)
458461 if idx < 0 || idx >= length {
459- idx = 0
460- row := rows.Index(idx)
461- d.target.Get("dataset").Set(datasetKey, "0")
462- row.Get("classList").Call("add", "sv-focused")
463- return row
462+ return js.Value{}
464463 }
465464 return rows.Index(idx)
466465 }
@@ -402,7 +402,10 @@ func isEditableTarget(target js.Value) bool {
402 return false402 return false
403 }403 }
404 tag := strings.ToLower(target.Get("tagName").String())404 tag := strings.ToLower(target.Get("tagName").String())
405- return tag == "input" || tag == "textarea" || tag == "select" || target.Get("isContentEditable").Bool()405+ role := strings.ToLower(target.Get("role").String())
406+ return tag == "input" || tag == "textarea" || tag == "select" || tag == "button" ||
407+ (tag == "a" && target.Get("href").String() != "") || role == "button" || role == "link" ||
408+ target.Get("isContentEditable").Bool()
406 }409 }
407 410
408 func (d *browserDOM) focusHistoryRow(delta int) {411 func (d *browserDOM) focusHistoryRow(delta int) {
@@ -456,11 +459,7 @@ func (d *browserDOM) activeRow(selector, datasetKey string) js.Value {
456 }459 }
457 idx := d.activeIndex(datasetKey)460 idx := d.activeIndex(datasetKey)
458 if idx < 0 || idx >= length {461 if idx < 0 || idx >= length {
459- idx = 0462+ return js.Value{}
460- row := rows.Index(idx)
461- d.target.Get("dataset").Set(datasetKey, "0")
462- row.Get("classList").Call("add", "sv-focused")
463- return row
464 }463 }
465 return rows.Index(idx)464 return rows.Index(idx)
466 }465 }
M internal/ui/systemviews/history_view_test.go +6 -3
@@ -13,10 +13,13 @@ func TestHistoryDateKeyAndLabelUsesProvidedNow(t *testing.T) {
1313 loc := time.FixedZone("test", 2*60*60)
1414 now := time.Date(2026, 4, 24, 12, 0, 0, 0, loc)
1515
16- _, todayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 24, 8, 30, 0, 0, loc), now)
17- _, yesterdayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 23, 22, 30, 0, 0, loc), now)
18- _, olderLabel := historyDateKeyAndLabel(time.Date(2026, 4, 20, 9, 0, 0, 0, loc), now)
16+ todayKey, todayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 24, 8, 30, 0, 0, loc), now)
17+ yesterdayKey, yesterdayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 23, 22, 30, 0, 0, loc), now)
18+ olderKey, olderLabel := historyDateKeyAndLabel(time.Date(2026, 4, 20, 9, 0, 0, 0, loc), now)
1919
20+ assert.Equal(t, "2026-04-24", todayKey)
21+ assert.Equal(t, "2026-04-23", yesterdayKey)
22+ assert.Equal(t, "2026-04-20", olderKey)
2023 assert.Equal(t, "Today", todayLabel)
2124 assert.Equal(t, "Yesterday", yesterdayLabel)
2225 assert.Equal(t, "Mon Apr 20", olderLabel)
@@ -13,10 +13,13 @@ func TestHistoryDateKeyAndLabelUsesProvidedNow(t *testing.T) {
13 loc := time.FixedZone("test", 2*60*60)13 loc := time.FixedZone("test", 2*60*60)
14 now := time.Date(2026, 4, 24, 12, 0, 0, 0, loc)14 now := time.Date(2026, 4, 24, 12, 0, 0, 0, loc)
15 15
16- _, todayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 24, 8, 30, 0, 0, loc), now)16+ todayKey, todayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 24, 8, 30, 0, 0, loc), now)
17- _, yesterdayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 23, 22, 30, 0, 0, loc), now)17+ yesterdayKey, yesterdayLabel := historyDateKeyAndLabel(time.Date(2026, 4, 23, 22, 30, 0, 0, loc), now)
18- _, olderLabel := historyDateKeyAndLabel(time.Date(2026, 4, 20, 9, 0, 0, 0, loc), now)18+ olderKey, olderLabel := historyDateKeyAndLabel(time.Date(2026, 4, 20, 9, 0, 0, 0, loc), now)
19 19
20+ assert.Equal(t, "2026-04-24", todayKey)
21+ assert.Equal(t, "2026-04-23", yesterdayKey)
22+ assert.Equal(t, "2026-04-20", olderKey)
20 assert.Equal(t, "Today", todayLabel)23 assert.Equal(t, "Today", todayLabel)
21 assert.Equal(t, "Yesterday", yesterdayLabel)24 assert.Equal(t, "Yesterday", yesterdayLabel)
22 assert.Equal(t, "Mon Apr 20", olderLabel)25 assert.Equal(t, "Mon Apr 20", olderLabel)
M internal/ui/systemviews/href.go +1 -0
@@ -29,6 +29,7 @@ func sanitizeHref(raw string) string {
2929 return "#"
3030 }
3131 parsed.Scheme = strings.ToLower(parsed.Scheme)
32+ parsed.User = nil
3233 return parsed.String()
3334 case "dumb":
3435 if parsed.Host == "" && parsed.Opaque == "" {
@@ -29,6 +29,7 @@ func sanitizeHref(raw string) string {
29 return "#"29 return "#"
30 }30 }
31 parsed.Scheme = strings.ToLower(parsed.Scheme)31 parsed.Scheme = strings.ToLower(parsed.Scheme)
32+ parsed.User = nil
32 return parsed.String()33 return parsed.String()
33 case "dumb":34 case "dumb":
34 if parsed.Host == "" && parsed.Opaque == "" {35 if parsed.Host == "" && parsed.Opaque == "" {
M internal/ui/systemviews/href_test.go +1 -0
@@ -42,6 +42,7 @@ func TestSanitizeHrefRejectsNetworkPathAndMalformedHTTP(t *testing.T) {
4242 assert.Equal(t, "#", sanitizeHref("https:///evil.com"))
4343 assert.Equal(t, "#", sanitizeHref("http:evil.com"))
4444 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"))
4546 }
4647
4748 func TestSanitizeHrefAllowsInternalDumbRoutes(t *testing.T) {
@@ -42,6 +42,7 @@ func TestSanitizeHrefRejectsNetworkPathAndMalformedHTTP(t *testing.T) {
42 assert.Equal(t, "#", sanitizeHref("https:///evil.com"))42 assert.Equal(t, "#", sanitizeHref("https:///evil.com"))
43 assert.Equal(t, "#", sanitizeHref("http:evil.com"))43 assert.Equal(t, "#", sanitizeHref("http:evil.com"))
44 assert.Equal(t, "https://example.com", sanitizeHref("https://example.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"))
45 }46 }
46 47
47 func TestSanitizeHrefAllowsInternalDumbRoutes(t *testing.T) {48 func TestSanitizeHrefAllowsInternalDumbRoutes(t *testing.T) {
M internal/ui/systemviews/theme.go +7 -7
@@ -5,7 +5,7 @@ import (
55 "strings"
66 "unicode"
77
8- "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/dto"
99 )
1010
1111 type shellTheme struct {
@@ -31,12 +31,12 @@ func currentPrefersDark() bool {
3131 return currentPrefersDarkImpl()
3232 }
3333
34-func resolveShellTheme(appearance port.WebUIAppearanceConfig) shellTheme {
34+func resolveShellTheme(appearance dto.WebUIAppearanceConfig) shellTheme {
3535 if isZeroAppearance(appearance) {
3636 return shellTheme{}
3737 }
3838
39- var palette port.ColorPalette
39+ var palette dto.ColorPalette
4040 var rootClass string
4141
4242 switch strings.ToLower(strings.TrimSpace(appearance.ColorScheme)) {
@@ -62,7 +62,7 @@ func resolveShellTheme(appearance port.WebUIAppearanceConfig) shellTheme {
6262 }
6363 }
6464
65-func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPalette) string {
65+func buildInlineVars(appearance dto.WebUIAppearanceConfig, palette dto.ColorPalette) string {
6666 parts := make([]string, 0, 11)
6767 appendInlineVar := func(name, value string, sanitize func(string) (string, bool)) {
6868 value, ok := sanitize(value)
@@ -178,12 +178,12 @@ func isNamedColor(value string) bool {
178178 return ok
179179 }
180180
181-func isZeroAppearance(appearance port.WebUIAppearanceConfig) bool {
181+func isZeroAppearance(appearance dto.WebUIAppearanceConfig) bool {
182182 return appearance.ColorScheme == "" &&
183183 appearance.SansFont == "" &&
184184 appearance.SerifFont == "" &&
185185 appearance.MonospaceFont == "" &&
186186 appearance.DefaultFontSize == 0 &&
187- appearance.LightPalette == (port.ColorPalette{}) &&
188- appearance.DarkPalette == (port.ColorPalette{})
187+ appearance.LightPalette == (dto.ColorPalette{}) &&
188+ appearance.DarkPalette == (dto.ColorPalette{})
189189 }
@@ -5,7 +5,7 @@ import (
5 "strings"5 "strings"
6 "unicode"6 "unicode"
7 7
8- "github.com/bnema/dumber/internal/application/port"8+ "github.com/bnema/dumber/internal/application/dto"
9 )9 )
10 10
11 type shellTheme struct {11 type shellTheme struct {
@@ -31,12 +31,12 @@ func currentPrefersDark() bool {
31 return currentPrefersDarkImpl()31 return currentPrefersDarkImpl()
32 }32 }
33 33
34-func resolveShellTheme(appearance port.WebUIAppearanceConfig) shellTheme {34+func resolveShellTheme(appearance dto.WebUIAppearanceConfig) shellTheme {
35 if isZeroAppearance(appearance) {35 if isZeroAppearance(appearance) {
36 return shellTheme{}36 return shellTheme{}
37 }37 }
38 38
39- var palette port.ColorPalette39+ var palette dto.ColorPalette
40 var rootClass string40 var rootClass string
41 41
42 switch strings.ToLower(strings.TrimSpace(appearance.ColorScheme)) {42 switch strings.ToLower(strings.TrimSpace(appearance.ColorScheme)) {
@@ -62,7 +62,7 @@ func resolveShellTheme(appearance port.WebUIAppearanceConfig) shellTheme {
62 }62 }
63 }63 }
64 64
65-func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPalette) string {65+func buildInlineVars(appearance dto.WebUIAppearanceConfig, palette dto.ColorPalette) string {
66 parts := make([]string, 0, 11)66 parts := make([]string, 0, 11)
67 appendInlineVar := func(name, value string, sanitize func(string) (string, bool)) {67 appendInlineVar := func(name, value string, sanitize func(string) (string, bool)) {
68 value, ok := sanitize(value)68 value, ok := sanitize(value)
@@ -178,12 +178,12 @@ func isNamedColor(value string) bool {
178 return ok178 return ok
179 }179 }
180 180
181-func isZeroAppearance(appearance port.WebUIAppearanceConfig) bool {181+func isZeroAppearance(appearance dto.WebUIAppearanceConfig) bool {
182 return appearance.ColorScheme == "" &&182 return appearance.ColorScheme == "" &&
183 appearance.SansFont == "" &&183 appearance.SansFont == "" &&
184 appearance.SerifFont == "" &&184 appearance.SerifFont == "" &&
185 appearance.MonospaceFont == "" &&185 appearance.MonospaceFont == "" &&
186 appearance.DefaultFontSize == 0 &&186 appearance.DefaultFontSize == 0 &&
187- appearance.LightPalette == (port.ColorPalette{}) &&187+ appearance.LightPalette == (dto.ColorPalette{}) &&
188- appearance.DarkPalette == (port.ColorPalette{})188+ appearance.DarkPalette == (dto.ColorPalette{})
189 }189 }
M internal/ui/systemviews/theme_test.go +7 -7
@@ -3,25 +3,25 @@ package systemviews
33 import (
44 "testing"
55
6- "github.com/bnema/dumber/internal/application/port"
6+ "github.com/bnema/dumber/internal/application/dto"
77 "github.com/stretchr/testify/assert"
88 )
99
1010 func TestBuildInlineVarsRejectsUnsafeCSSValues(t *testing.T) {
1111 t.Parallel()
1212
13- vars := buildInlineVars(port.WebUIAppearanceConfig{
13+ vars := buildInlineVars(dto.WebUIAppearanceConfig{
1414 SansFont: "Inter; color:red",
1515 SerifFont: "Georgia",
1616 MonospaceFont: "JetBrains Mono",
17- LightPalette: port.ColorPalette{
17+ LightPalette: dto.ColorPalette{
1818 Background: "#ffffff; color:red",
1919 Surface: "rgb(1 2 3 / 50%)",
2020 Text: "transparent",
2121 Accent: "definitelynotacolor",
2222 Border: "#ABCDEF80",
2323 },
24- }, port.ColorPalette{
24+ }, dto.ColorPalette{
2525 Background: "#ffffff; color:red",
2626 Surface: "rgb(1 2 3 / 50%)",
2727 Text: "transparent",
@@ -73,13 +73,13 @@ func TestResolveShellTheme(t *testing.T) {
7373 currentPrefersDarkImpl = orig
7474 })
7575
76- appearance := port.WebUIAppearanceConfig{
76+ appearance := dto.WebUIAppearanceConfig{
7777 SansFont: "Inter",
7878 SerifFont: "Georgia",
7979 MonospaceFont: "JetBrains Mono",
8080 DefaultFontSize: 16,
8181 ColorScheme: "default",
82- LightPalette: port.ColorPalette{
82+ LightPalette: dto.ColorPalette{
8383 Background: "#ffffff",
8484 Surface: "#f8f8f8",
8585 SurfaceVariant: "#eeeeee",
@@ -88,7 +88,7 @@ func TestResolveShellTheme(t *testing.T) {
8888 Accent: "#0055ff",
8989 Border: "#dddddd",
9090 },
91- DarkPalette: port.ColorPalette{
91+ DarkPalette: dto.ColorPalette{
9292 Background: "#111111",
9393 Surface: "#1a1a1a",
9494 SurfaceVariant: "#2a2a2a",
@@ -3,25 +3,25 @@ package systemviews
3 import (3 import (
4 "testing"4 "testing"
5 5
6- "github.com/bnema/dumber/internal/application/port"6+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/stretchr/testify/assert"7 "github.com/stretchr/testify/assert"
8 )8 )
9 9
10 func TestBuildInlineVarsRejectsUnsafeCSSValues(t *testing.T) {10 func TestBuildInlineVarsRejectsUnsafeCSSValues(t *testing.T) {
11 t.Parallel()11 t.Parallel()
12 12
13- vars := buildInlineVars(port.WebUIAppearanceConfig{13+ vars := buildInlineVars(dto.WebUIAppearanceConfig{
14 SansFont: "Inter; color:red",14 SansFont: "Inter; color:red",
15 SerifFont: "Georgia",15 SerifFont: "Georgia",
16 MonospaceFont: "JetBrains Mono",16 MonospaceFont: "JetBrains Mono",
17- LightPalette: port.ColorPalette{17+ LightPalette: dto.ColorPalette{
18 Background: "#ffffff; color:red",18 Background: "#ffffff; color:red",
19 Surface: "rgb(1 2 3 / 50%)",19 Surface: "rgb(1 2 3 / 50%)",
20 Text: "transparent",20 Text: "transparent",
21 Accent: "definitelynotacolor",21 Accent: "definitelynotacolor",
22 Border: "#ABCDEF80",22 Border: "#ABCDEF80",
23 },23 },
24- }, port.ColorPalette{24+ }, dto.ColorPalette{
25 Background: "#ffffff; color:red",25 Background: "#ffffff; color:red",
26 Surface: "rgb(1 2 3 / 50%)",26 Surface: "rgb(1 2 3 / 50%)",
27 Text: "transparent",27 Text: "transparent",
@@ -73,13 +73,13 @@ func TestResolveShellTheme(t *testing.T) {
73 currentPrefersDarkImpl = orig73 currentPrefersDarkImpl = orig
74 })74 })
75 75
76- appearance := port.WebUIAppearanceConfig{76+ appearance := dto.WebUIAppearanceConfig{
77 SansFont: "Inter",77 SansFont: "Inter",
78 SerifFont: "Georgia",78 SerifFont: "Georgia",
79 MonospaceFont: "JetBrains Mono",79 MonospaceFont: "JetBrains Mono",
80 DefaultFontSize: 16,80 DefaultFontSize: 16,
81 ColorScheme: "default",81 ColorScheme: "default",
82- LightPalette: port.ColorPalette{82+ LightPalette: dto.ColorPalette{
83 Background: "#ffffff",83 Background: "#ffffff",
84 Surface: "#f8f8f8",84 Surface: "#f8f8f8",
85 SurfaceVariant: "#eeeeee",85 SurfaceVariant: "#eeeeee",
@@ -88,7 +88,7 @@ func TestResolveShellTheme(t *testing.T) {
88 Accent: "#0055ff",88 Accent: "#0055ff",
89 Border: "#dddddd",89 Border: "#dddddd",
90 },90 },
91- DarkPalette: port.ColorPalette{91+ DarkPalette: dto.ColorPalette{
92 Background: "#111111",92 Background: "#111111",
93 Surface: "#1a1a1a",93 Surface: "#1a1a1a",
94 SurfaceVariant: "#2a2a2a",94 SurfaceVariant: "#2a2a2a",

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