detached 9d4fa93

refactor(systemviews): simplify wasm UI plumbing

commit
9d4fa9304b40bff8ef7d70e9dd820be6448273ee
author
brice · · 2026-04-24 04:04 UTC
parent
a9a951b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
16 files · +172 -295
M cmd/systemviews/bootstrap.go +3 -3
@@ -40,14 +40,14 @@ func (p bridgeConfigProxy) Save(ctx context.Context, cfg port.WebUIConfig) error
4040 return p.bridge.Save(ctx, cfg)
4141 }
4242
43-func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (any, error) {
43+func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
4444 if p.route != systemviews.RouteConfig {
45- return nil, nil
45+ return port.KeybindingsConfig{}, nil
4646 }
4747 return p.bridge.GetKeybindings(ctx)
4848 }
4949
50-func (p bridgeConfigProxy) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (any, error) {
50+func (p bridgeConfigProxy) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
5151 return p.bridge.SetKeybinding(ctx, req)
5252 }
5353
@@ -40,14 +40,14 @@ func (p bridgeConfigProxy) Save(ctx context.Context, cfg port.WebUIConfig) error
40 return p.bridge.Save(ctx, cfg)40 return p.bridge.Save(ctx, cfg)
41 }41 }
42 42
43-func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (any, error) {43+func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
44 if p.route != systemviews.RouteConfig {44 if p.route != systemviews.RouteConfig {
45- return nil, nil45+ return port.KeybindingsConfig{}, nil
46 }46 }
47 return p.bridge.GetKeybindings(ctx)47 return p.bridge.GetKeybindings(ctx)
48 }48 }
49 49
50-func (p bridgeConfigProxy) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (any, error) {50+func (p bridgeConfigProxy) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
51 return p.bridge.SetKeybinding(ctx, req)51 return p.bridge.SetKeybinding(ctx, req)
52 }52 }
53 53
M cmd/systemviews/bootstrap_test.go +4 -4
@@ -107,7 +107,7 @@ type fakeBridgeService struct {
107107 folders []*entity.Folder
108108 tags []*entity.Tag
109109 currentConfig port.SystemviewConfigPayload
110- keybindings any
110+ keybindings port.KeybindingsConfig
111111 }
112112
113113 func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) {
@@ -191,13 +191,13 @@ func (*fakeBridgeService) Default(context.Context) (port.SystemviewConfigPayload
191191
192192 func (*fakeBridgeService) Save(context.Context, port.WebUIConfig) error { return nil }
193193
194-func (f *fakeBridgeService) GetKeybindings(context.Context) (any, error) {
194+func (f *fakeBridgeService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
195195 f.calledKeybindings = true
196196 return f.keybindings, nil
197197 }
198198
199-func (*fakeBridgeService) SetKeybinding(context.Context, port.SetKeybindingRequest) (any, error) {
200- return nil, nil
199+func (*fakeBridgeService) SetKeybinding(context.Context, port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
200+ return port.SetKeybindingResponse{}, nil
201201 }
202202
203203 func (*fakeBridgeService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error {
@@ -107,7 +107,7 @@ type fakeBridgeService struct {
107 folders []*entity.Folder107 folders []*entity.Folder
108 tags []*entity.Tag108 tags []*entity.Tag
109 currentConfig port.SystemviewConfigPayload109 currentConfig port.SystemviewConfigPayload
110- keybindings any110+ keybindings port.KeybindingsConfig
111 }111 }
112 112
113 func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) {113 func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) {
@@ -191,13 +191,13 @@ func (*fakeBridgeService) Default(context.Context) (port.SystemviewConfigPayload
191 191
192 func (*fakeBridgeService) Save(context.Context, port.WebUIConfig) error { return nil }192 func (*fakeBridgeService) Save(context.Context, port.WebUIConfig) error { return nil }
193 193
194-func (f *fakeBridgeService) GetKeybindings(context.Context) (any, error) {194+func (f *fakeBridgeService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
195 f.calledKeybindings = true195 f.calledKeybindings = true
196 return f.keybindings, nil196 return f.keybindings, nil
197 }197 }
198 198
199-func (*fakeBridgeService) SetKeybinding(context.Context, port.SetKeybindingRequest) (any, error) {199+func (*fakeBridgeService) SetKeybinding(context.Context, port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
200- return nil, nil200+ return port.SetKeybindingResponse{}, nil
201 }201 }
202 202
203 func (*fakeBridgeService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error {203 func (*fakeBridgeService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error {
M internal/application/port/systemviews.go +2 -2
@@ -42,8 +42,8 @@ type SystemviewConfigService interface {
4242 Current(ctx context.Context) (SystemviewConfigPayload, error)
4343 Default(ctx context.Context) (SystemviewConfigPayload, error)
4444 Save(ctx context.Context, cfg WebUIConfig) error
45- GetKeybindings(ctx context.Context) (any, error)
46- SetKeybinding(ctx context.Context, req SetKeybindingRequest) (any, error)
45+ GetKeybindings(ctx context.Context) (KeybindingsConfig, error)
46+ SetKeybinding(ctx context.Context, req SetKeybindingRequest) (SetKeybindingResponse, error)
4747 ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error
4848 ResetAllKeybindings(ctx context.Context) error
4949 }
@@ -42,8 +42,8 @@ type SystemviewConfigService interface {
42 Current(ctx context.Context) (SystemviewConfigPayload, error)42 Current(ctx context.Context) (SystemviewConfigPayload, error)
43 Default(ctx context.Context) (SystemviewConfigPayload, error)43 Default(ctx context.Context) (SystemviewConfigPayload, error)
44 Save(ctx context.Context, cfg WebUIConfig) error44 Save(ctx context.Context, cfg WebUIConfig) error
45- GetKeybindings(ctx context.Context) (any, error)45+ GetKeybindings(ctx context.Context) (KeybindingsConfig, error)
46- SetKeybinding(ctx context.Context, req SetKeybindingRequest) (any, error)46+ SetKeybinding(ctx context.Context, req SetKeybindingRequest) (SetKeybindingResponse, error)
47 ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error47 ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error
48 ResetAllKeybindings(ctx context.Context) error48 ResetAllKeybindings(ctx context.Context) error
49 }49 }
M internal/infrastructure/systemviewsbridge/client.go +4 -4
@@ -105,14 +105,14 @@ func (c *Client) Save(ctx context.Context, cfg port.WebUIConfig) error {
105105 return err
106106 }
107107
108-func (c *Client) GetKeybindings(ctx context.Context) (any, error) {
109- return request[any](c, ctx, "get_keybindings", struct {
108+func (c *Client) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
109+ return request[port.KeybindingsConfig](c, ctx, "get_keybindings", struct {
110110 RequestID string `json:"requestId"`
111111 }{RequestID: nextRequestID()})
112112 }
113113
114-func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (any, error) {
115- return request[any](c, ctx, "set_keybinding", req)
114+func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
115+ return request[port.SetKeybindingResponse](c, ctx, "set_keybinding", req)
116116 }
117117
118118 func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
@@ -105,14 +105,14 @@ func (c *Client) Save(ctx context.Context, cfg port.WebUIConfig) error {
105 return err105 return err
106 }106 }
107 107
108-func (c *Client) GetKeybindings(ctx context.Context) (any, error) {108+func (c *Client) GetKeybindings(ctx context.Context) (port.KeybindingsConfig, error) {
109- return request[any](c, ctx, "get_keybindings", struct {109+ return request[port.KeybindingsConfig](c, ctx, "get_keybindings", struct {
110 RequestID string `json:"requestId"`110 RequestID string `json:"requestId"`
111 }{RequestID: nextRequestID()})111 }{RequestID: nextRequestID()})
112 }112 }
113 113
114-func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (any, error) {114+func (c *Client) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
115- return request[any](c, ctx, "set_keybinding", req)115+ return request[port.SetKeybindingResponse](c, ctx, "set_keybinding", req)
116 }116 }
117 117
118 func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {118 func (c *Client) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error {
M internal/infrastructure/systemviewsbridge/client_test.go +2 -4
@@ -286,8 +286,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
286286 if err != nil {
287287 return err
288288 }
289- m, ok := got.(map[string]any)
290- if !ok || len(m) == 0 {
289+ if len(got.Groups) != 1 || got.Groups[0].Mode != "default" {
291290 t.Fatalf("GetKeybindings() = %#v", got)
292291 }
293292 return nil
@@ -301,8 +300,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
301300 if err != nil {
302301 return err
303302 }
304- m, ok := got.(map[string]any)
305- if !ok || m["status"] != "success" {
303+ if len(got.Conflicts) != 0 {
306304 t.Fatalf("SetKeybinding() = %#v", got)
307305 }
308306 return nil
@@ -286,8 +286,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
286 if err != nil {286 if err != nil {
287 return err287 return err
288 }288 }
289- m, ok := got.(map[string]any)289+ if len(got.Groups) != 1 || got.Groups[0].Mode != "default" {
290- if !ok || len(m) == 0 {
291 t.Fatalf("GetKeybindings() = %#v", got)290 t.Fatalf("GetKeybindings() = %#v", got)
292 }291 }
293 return nil292 return nil
@@ -301,8 +300,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) {
301 if err != nil {300 if err != nil {
302 return err301 return err
303 }302 }
304- m, ok := got.(map[string]any)303+ if len(got.Conflicts) != 0 {
305- if !ok || m["status"] != "success" {
306 t.Fatalf("SetKeybinding() = %#v", got)304 t.Fatalf("SetKeybinding() = %#v", got)
307 }305 }
308 return nil306 return nil
M internal/ui/systemviews/app.go +28 -7
@@ -39,10 +39,11 @@ type App struct {
3939 favoritesNotice string
4040 favoritesError string
4141 config *port.SystemviewConfigPayload
42- keybindings any
42+ keybindings port.KeybindingsConfig
4343 configNotice string
4444 configError string
4545 renderedHTML string
46+ actionQueue chan DOMAction
4647 }
4748
4849 const historyTimelineLimit = 25
@@ -68,15 +69,35 @@ func (a *App) Run() error {
6869 return err
6970 }
7071 if binder, ok := a.deps.DOM.(DOMActionBinder); ok {
71- return binder.BindActions(func(action DOMAction) {
72- go func() {
73- _ = a.HandleDOMAction(context.Background(), action)
74- }()
75- })
72+ return a.bindDOMActions(context.Background(), binder)
7673 }
7774 return nil
7875 }
7976
77+func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {
78+ if a.actionQueue == nil {
79+ a.actionQueue = make(chan DOMAction, 64)
80+ go a.runActionWorker(ctx)
81+ }
82+ return binder.BindActions(func(action DOMAction) {
83+ a.actionQueue <- action
84+ })
85+}
86+
87+func (a *App) runActionWorker(ctx context.Context) {
88+ for {
89+ select {
90+ case <-ctx.Done():
91+ return
92+ case action, ok := <-a.actionQueue:
93+ if !ok {
94+ return
95+ }
96+ _ = a.HandleDOMAction(ctx, action)
97+ }
98+ }
99+}
100+
80101 func (a *App) LoadInitial(ctx context.Context) error {
81102 if a == nil {
82103 return errors.New("app is nil")
@@ -308,7 +329,7 @@ func (a *App) resetRouteState() {
308329 a.favoritesNotice = ""
309330 a.favoritesError = ""
310331 a.config = nil
311- a.keybindings = nil
332+ a.keybindings = port.KeybindingsConfig{}
312333 a.configNotice = ""
313334 a.configError = ""
314335 }
@@ -39,10 +39,11 @@ type App struct {
39 favoritesNotice string39 favoritesNotice string
40 favoritesError string40 favoritesError string
41 config *port.SystemviewConfigPayload41 config *port.SystemviewConfigPayload
42- keybindings any42+ keybindings port.KeybindingsConfig
43 configNotice string43 configNotice string
44 configError string44 configError string
45 renderedHTML string45 renderedHTML string
46+ actionQueue chan DOMAction
46 }47 }
47 48
48 const historyTimelineLimit = 2549 const historyTimelineLimit = 25
@@ -68,15 +69,35 @@ func (a *App) Run() error {
68 return err69 return err
69 }70 }
70 if binder, ok := a.deps.DOM.(DOMActionBinder); ok {71 if binder, ok := a.deps.DOM.(DOMActionBinder); ok {
71- return binder.BindActions(func(action DOMAction) {72+ return a.bindDOMActions(context.Background(), binder)
72- go func() {
73- _ = a.HandleDOMAction(context.Background(), action)
74- }()
75- })
76 }73 }
77 return nil74 return nil
78 }75 }
79 76
77+func (a *App) bindDOMActions(ctx context.Context, binder DOMActionBinder) error {
78+ if a.actionQueue == nil {
79+ a.actionQueue = make(chan DOMAction, 64)
80+ go a.runActionWorker(ctx)
81+ }
82+ return binder.BindActions(func(action DOMAction) {
83+ a.actionQueue <- action
84+ })
85+}
86+
87+func (a *App) runActionWorker(ctx context.Context) {
88+ for {
89+ select {
90+ case <-ctx.Done():
91+ return
92+ case action, ok := <-a.actionQueue:
93+ if !ok {
94+ return
95+ }
96+ _ = a.HandleDOMAction(ctx, action)
97+ }
98+ }
99+}
100+
80 func (a *App) LoadInitial(ctx context.Context) error {101 func (a *App) LoadInitial(ctx context.Context) error {
81 if a == nil {102 if a == nil {
82 return errors.New("app is nil")103 return errors.New("app is nil")
@@ -308,7 +329,7 @@ func (a *App) resetRouteState() {
308 a.favoritesNotice = ""329 a.favoritesNotice = ""
309 a.favoritesError = ""330 a.favoritesError = ""
310 a.config = nil331 a.config = nil
311- a.keybindings = nil332+ a.keybindings = port.KeybindingsConfig{}
312 a.configNotice = ""333 a.configNotice = ""
313 a.configError = ""334 a.configError = ""
314 }335 }
M internal/ui/systemviews/app_test.go +7 -14
@@ -94,7 +94,7 @@ func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
9494 URL: "https://example.com/page",
9595 Title: "Example",
9696 }},
97- domainStats: []*entity.DomainStat{{Domain: "example.com", PageCount: 1, TotalVisits: 3}},
97+ domainStats: []*entity.DomainStat{{Domain: "www.example.com:8080", PageCount: 1, TotalVisits: 3}},
9898 }
9999
100100 app := NewApp(Dependencies{
@@ -110,6 +110,8 @@ func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
110110 assert.Contains(t, app.renderedHTML, `data-range="hour"`)
111111 assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`)
112112 assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`)
113+ assert.Contains(t, app.renderedHTML, `data-domain="www.example.com:8080"`)
114+ assert.Contains(t, app.renderedHTML, `>example.com</button>`)
113115 assert.Contains(t, app.renderedHTML, "Keys:")
114116 assert.Contains(t, app.renderedHTML, "Enter")
115117 }
@@ -452,15 +454,6 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
452454 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
453455 }
454456
455-func TestConfigHTMLFallsBackForMalformedKeybindings(t *testing.T) {
456- t.Parallel()
457-
458- html := configHTML(configRenderData{Config: port.SystemviewConfigPayload{}, Keybindings: map[string]any{"groups": "oops"}})
459-
460- assert.Contains(t, html, "Keybindings unavailable")
461- assert.NotContains(t, html, "groups[0].bindings[0].action")
462-}
463-
464457 func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) {
465458 t.Parallel()
466459
@@ -777,10 +770,10 @@ type fakeConfigService struct {
777770
778771 current port.SystemviewConfigPayload
779772 defaultCfg port.SystemviewConfigPayload
780- keybindings any
773+ keybindings port.KeybindingsConfig
781774 savedConfig port.WebUIConfig
782775 setReq port.SetKeybindingRequest
783- setResp any
776+ setResp port.SetKeybindingResponse
784777 resetReq port.ResetKeybindingRequest
785778 }
786779
@@ -812,12 +805,12 @@ func (s *fakeConfigService) Save(_ context.Context, cfg port.WebUIConfig) error
812805 return nil
813806 }
814807
815-func (s *fakeConfigService) GetKeybindings(context.Context) (any, error) {
808+func (s *fakeConfigService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
816809 s.calledKeybindings = true
817810 return s.keybindings, nil
818811 }
819812
820-func (s *fakeConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (any, error) {
813+func (s *fakeConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
821814 s.calledSet = true
822815 s.setReq = req
823816 return s.setResp, nil
@@ -94,7 +94,7 @@ func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
94 URL: "https://example.com/page",94 URL: "https://example.com/page",
95 Title: "Example",95 Title: "Example",
96 }},96 }},
97- domainStats: []*entity.DomainStat{{Domain: "example.com", PageCount: 1, TotalVisits: 3}},97+ domainStats: []*entity.DomainStat{{Domain: "www.example.com:8080", PageCount: 1, TotalVisits: 3}},
98 }98 }
99 99
100 app := NewApp(Dependencies{100 app := NewApp(Dependencies{
@@ -110,6 +110,8 @@ func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
110 assert.Contains(t, app.renderedHTML, `data-range="hour"`)110 assert.Contains(t, app.renderedHTML, `data-range="hour"`)
111 assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`)111 assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`)
112 assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`)112 assert.Contains(t, app.renderedHTML, `data-sv-action="history.deleteDomain"`)
113+ assert.Contains(t, app.renderedHTML, `data-domain="www.example.com:8080"`)
114+ assert.Contains(t, app.renderedHTML, `>example.com</button>`)
113 assert.Contains(t, app.renderedHTML, "Keys:")115 assert.Contains(t, app.renderedHTML, "Keys:")
114 assert.Contains(t, app.renderedHTML, "Enter")116 assert.Contains(t, app.renderedHTML, "Enter")
115 }117 }
@@ -452,15 +454,6 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
452 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)454 assert.Contains(t, app.renderedHTML, `class="sv-meta"`)
453 }455 }
454 456
455-func TestConfigHTMLFallsBackForMalformedKeybindings(t *testing.T) {
456- t.Parallel()
457-
458- html := configHTML(configRenderData{Config: port.SystemviewConfigPayload{}, Keybindings: map[string]any{"groups": "oops"}})
459-
460- assert.Contains(t, html, "Keybindings unavailable")
461- assert.NotContains(t, html, "groups[0].bindings[0].action")
462-}
463-
464 func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) {457 func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) {
465 t.Parallel()458 t.Parallel()
466 459
@@ -777,10 +770,10 @@ type fakeConfigService struct {
777 770
778 current port.SystemviewConfigPayload771 current port.SystemviewConfigPayload
779 defaultCfg port.SystemviewConfigPayload772 defaultCfg port.SystemviewConfigPayload
780- keybindings any773+ keybindings port.KeybindingsConfig
781 savedConfig port.WebUIConfig774 savedConfig port.WebUIConfig
782 setReq port.SetKeybindingRequest775 setReq port.SetKeybindingRequest
783- setResp any776+ setResp port.SetKeybindingResponse
784 resetReq port.ResetKeybindingRequest777 resetReq port.ResetKeybindingRequest
785 }778 }
786 779
@@ -812,12 +805,12 @@ func (s *fakeConfigService) Save(_ context.Context, cfg port.WebUIConfig) error
812 return nil805 return nil
813 }806 }
814 807
815-func (s *fakeConfigService) GetKeybindings(context.Context) (any, error) {808+func (s *fakeConfigService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
816 s.calledKeybindings = true809 s.calledKeybindings = true
817 return s.keybindings, nil810 return s.keybindings, nil
818 }811 }
819 812
820-func (s *fakeConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (any, error) {813+func (s *fakeConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (port.SetKeybindingResponse, error) {
821 s.calledSet = true814 s.calledSet = true
822 s.setReq = req815 s.setReq = req
823 return s.setResp, nil816 return s.setResp, nil
M internal/ui/systemviews/config.templ +3 -3
@@ -68,7 +68,7 @@ templ SearchSettings(config port.SystemviewConfigPayload) {
6868 <form class="sv-inline-form" data-sv-action="config.searchShortcut.create">
6969 <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label>
7070 <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" required/></label>
71- <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo" required/></label>
71+ <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label>
7272 <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button>
7373 </form>
7474 if len(searchShortcutRows(config.SearchShortcuts)) == 0 {
@@ -89,7 +89,7 @@ templ SearchShortcutItem(shortcut searchShortcutRow) {
8989 <input type="hidden" name="key" value={ shortcut.Key }/>
9090 <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label>
9191 <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } required/></label>
92- <label><span>Description</span><input name="description" type="text" value={ shortcut.Description } required/></label>
92+ <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label>
9393 <button class="sv-button sv-button-secondary" type="submit">Save</button>
9494 <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button>
9595 </form>
@@ -124,7 +124,7 @@ templ PerformanceSettings(config port.SystemviewConfigPayload) {
124124 }
125125 }
126126
127-templ ConfigKeybindings(keybindings any) {
127+templ ConfigKeybindings(keybindings port.KeybindingsConfig) {
128128 {{ groups := configKeybindingGroups(keybindings) }}
129129 <div class="sv-button-row">
130130 <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.keybinding.resetAll" data-sv-confirm="Reset all keybindings to defaults?">Reset all keybindings</button>
@@ -68,7 +68,7 @@ templ SearchSettings(config port.SystemviewConfigPayload) {
68 <form class="sv-inline-form" data-sv-action="config.searchShortcut.create">68 <form class="sv-inline-form" data-sv-action="config.searchShortcut.create">
69 <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label>69 <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label>
70 <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" required/></label>70 <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" required/></label>
71- <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo" required/></label>71+ <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo"/></label>
72 <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button>72 <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button>
73 </form>73 </form>
74 if len(searchShortcutRows(config.SearchShortcuts)) == 0 {74 if len(searchShortcutRows(config.SearchShortcuts)) == 0 {
@@ -89,7 +89,7 @@ templ SearchShortcutItem(shortcut searchShortcutRow) {
89 <input type="hidden" name="key" value={ shortcut.Key }/>89 <input type="hidden" name="key" value={ shortcut.Key }/>
90 <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label>90 <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label>
91 <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } required/></label>91 <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } required/></label>
92- <label><span>Description</span><input name="description" type="text" value={ shortcut.Description } required/></label>92+ <label><span>Description</span><input name="description" type="text" value={ shortcut.Description }/></label>
93 <button class="sv-button sv-button-secondary" type="submit">Save</button>93 <button class="sv-button sv-button-secondary" type="submit">Save</button>
94 <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button>94 <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button>
95 </form>95 </form>
@@ -124,7 +124,7 @@ templ PerformanceSettings(config port.SystemviewConfigPayload) {
124 }124 }
125 }125 }
126 126
127-templ ConfigKeybindings(keybindings any) {127+templ ConfigKeybindings(keybindings port.KeybindingsConfig) {
128 {{ groups := configKeybindingGroups(keybindings) }}128 {{ groups := configKeybindingGroups(keybindings) }}
129 <div class="sv-button-row">129 <div class="sv-button-row">
130 <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.keybinding.resetAll" data-sv-confirm="Reset all keybindings to defaults?">Reset all keybindings</button>130 <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.keybinding.resetAll" data-sv-confirm="Reset all keybindings to defaults?">Reset all keybindings</button>
M internal/ui/systemviews/config_actions.go +21 -31
@@ -7,6 +7,7 @@ import (
77 "strings"
88
99 "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/validation"
1011 )
1112
1213 const (
@@ -258,7 +259,7 @@ func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) e
258259 if err != nil {
259260 return err
260261 }
261- if conflicts := keybindingConflictCount(resp); conflicts > 0 {
262+ if conflicts := len(resp.Conflicts); conflicts > 0 {
262263 a.configNotice = fmt.Sprintf("Saved keybinding with %d conflict(s)", conflicts)
263264 return nil
264265 }
@@ -366,24 +367,32 @@ func searchShortcutFromForm(data map[string]string, keyField string) (string, po
366367 key := strings.TrimSpace(data[keyField])
367368 url := strings.TrimSpace(data["url"])
368369 description := strings.TrimSpace(data["description"])
369- if key == "" {
370- return "", port.SearchShortcut{}, fmt.Errorf("search shortcut key is required")
371- }
372- if description == "" {
373- return "", port.SearchShortcut{}, fmt.Errorf("search shortcut description is required")
374- }
375- if err := validateSearchURL(url, "search shortcut URL"); err != nil {
370+ if err := validateSearchShortcut(key, url, description); err != nil {
376371 return "", port.SearchShortcut{}, err
377372 }
378373 return key, port.SearchShortcut{URL: url, Description: description}, nil
379374 }
380375
381376 func validateSearchURL(raw, label string) error {
382- if strings.TrimSpace(raw) == "" {
383- return fmt.Errorf("%s is required", label)
377+ if errs := validation.ValidateShortcutURL(raw); len(errs) > 0 {
378+ return fmt.Errorf("%s: %s", label, strings.Join(errs, "; "))
379+ }
380+ return nil
381+}
382+
383+func validateSearchShortcut(key, url, description string) error {
384+ var errs []string
385+ for _, err := range validation.ValidateShortcutKey(key) {
386+ errs = append(errs, "key: "+err)
384387 }
385- if !strings.Contains(raw, "%s") {
386- return fmt.Errorf("%s must contain %%s placeholder", label)
388+ for _, err := range validation.ValidateShortcutURL(url) {
389+ errs = append(errs, "url: "+err)
390+ }
391+ for _, err := range validation.ValidateShortcutDescription(description) {
392+ errs = append(errs, "description: "+err)
393+ }
394+ if len(errs) > 0 {
395+ return fmt.Errorf("invalid search shortcut: %s", strings.Join(errs, "; "))
387396 }
388397 return nil
389398 }
@@ -431,22 +440,3 @@ func parseKeyList(raw string) []string {
431440 }
432441 return keys
433442 }
434-
435-func keybindingConflictCount(resp any) int {
436- switch v := resp.(type) {
437- case nil:
438- return 0
439- case port.SetKeybindingResponse:
440- return len(v.Conflicts)
441- case *port.SetKeybindingResponse:
442- if v == nil {
443- return 0
444- }
445- return len(v.Conflicts)
446- case map[string]any:
447- if conflicts, ok := anySlice(v["conflicts"]); ok {
448- return len(conflicts)
449- }
450- }
451- return 0
452-}
@@ -7,6 +7,7 @@ import (
7 "strings"7 "strings"
8 8
9 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/validation"
10 )11 )
11 12
12 const (13 const (
@@ -258,7 +259,7 @@ func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) e
258 if err != nil {259 if err != nil {
259 return err260 return err
260 }261 }
261- if conflicts := keybindingConflictCount(resp); conflicts > 0 {262+ if conflicts := len(resp.Conflicts); conflicts > 0 {
262 a.configNotice = fmt.Sprintf("Saved keybinding with %d conflict(s)", conflicts)263 a.configNotice = fmt.Sprintf("Saved keybinding with %d conflict(s)", conflicts)
263 return nil264 return nil
264 }265 }
@@ -366,24 +367,32 @@ func searchShortcutFromForm(data map[string]string, keyField string) (string, po
366 key := strings.TrimSpace(data[keyField])367 key := strings.TrimSpace(data[keyField])
367 url := strings.TrimSpace(data["url"])368 url := strings.TrimSpace(data["url"])
368 description := strings.TrimSpace(data["description"])369 description := strings.TrimSpace(data["description"])
369- if key == "" {370+ if err := validateSearchShortcut(key, url, description); err != nil {
370- return "", port.SearchShortcut{}, fmt.Errorf("search shortcut key is required")
371- }
372- if description == "" {
373- return "", port.SearchShortcut{}, fmt.Errorf("search shortcut description is required")
374- }
375- if err := validateSearchURL(url, "search shortcut URL"); err != nil {
376 return "", port.SearchShortcut{}, err371 return "", port.SearchShortcut{}, err
377 }372 }
378 return key, port.SearchShortcut{URL: url, Description: description}, nil373 return key, port.SearchShortcut{URL: url, Description: description}, nil
379 }374 }
380 375
381 func validateSearchURL(raw, label string) error {376 func validateSearchURL(raw, label string) error {
382- if strings.TrimSpace(raw) == "" {377+ if errs := validation.ValidateShortcutURL(raw); len(errs) > 0 {
383- return fmt.Errorf("%s is required", label)378+ return fmt.Errorf("%s: %s", label, strings.Join(errs, "; "))
379+ }
380+ return nil
381+}
382+
383+func validateSearchShortcut(key, url, description string) error {
384+ var errs []string
385+ for _, err := range validation.ValidateShortcutKey(key) {
386+ errs = append(errs, "key: "+err)
384 }387 }
385- if !strings.Contains(raw, "%s") {388+ for _, err := range validation.ValidateShortcutURL(url) {
386- return fmt.Errorf("%s must contain %%s placeholder", label)389+ errs = append(errs, "url: "+err)
390+ }
391+ for _, err := range validation.ValidateShortcutDescription(description) {
392+ errs = append(errs, "description: "+err)
393+ }
394+ if len(errs) > 0 {
395+ return fmt.Errorf("invalid search shortcut: %s", strings.Join(errs, "; "))
387 }396 }
388 return nil397 return nil
389 }398 }
@@ -431,22 +440,3 @@ func parseKeyList(raw string) []string {
431 }440 }
432 return keys441 return keys
433 }442 }
434-
435-func keybindingConflictCount(resp any) int {
436- switch v := resp.(type) {
437- case nil:
438- return 0
439- case port.SetKeybindingResponse:
440- return len(v.Conflicts)
441- case *port.SetKeybindingResponse:
442- if v == nil {
443- return 0
444- }
445- return len(v.Conflicts)
446- case map[string]any:
447- if conflicts, ok := anySlice(v["conflicts"]); ok {
448- return len(conflicts)
449- }
450- }
451- return 0
452-}
M internal/ui/systemviews/config_templ.go +3 -3 generated file: collapsed
M internal/ui/systemviews/config_view.go +9 -155
@@ -11,7 +11,7 @@ import (
1111
1212 type configRenderData struct {
1313 Config port.SystemviewConfigPayload
14- Keybindings any
14+ Keybindings port.KeybindingsConfig
1515 Notice string
1616 Error string
1717 }
@@ -20,15 +20,10 @@ func configHTML(data configRenderData) string {
2020 return mustRenderComponent(ConfigView(data))
2121 }
2222
23-func configKeybindingSummary(keybindings any) string {
24- groups, bindings, loaded := summarizeKeybindings(keybindings)
25- status := "not loaded"
26- if loaded {
27- status = "loaded"
28- }
23+func configKeybindingSummary(keybindings port.KeybindingsConfig) string {
24+ groups, bindings := summarizeKeybindings(keybindings)
2925 return fmt.Sprintf(
30- "Keybindings %s (%d %s, %d %s)",
31- status,
26+ "Keybindings loaded (%d %s, %d %s)",
3227 groups,
3328 plural(groups, "group"),
3429 bindings,
@@ -36,12 +31,8 @@ func configKeybindingSummary(keybindings any) string {
3631 )
3732 }
3833
39-func configKeybindingGroups(keybindings any) []renderedKeybindingGroup {
40- groups, ok := collectKeybindingGroups(keybindings)
41- if !ok {
42- return nil
43- }
44- return groups
34+func configKeybindingGroups(keybindings port.KeybindingsConfig) []renderedKeybindingGroup {
35+ return convertPortKeybindingGroups(keybindings.Groups)
4536 }
4637
4738 func keybindingGroupTitle(group renderedKeybindingGroup) string {
@@ -281,36 +272,12 @@ func mapKeyString(value reflect.Value) string {
281272 return fmt.Sprint(value.Interface())
282273 }
283274
284-func summarizeKeybindings(keybindings any) (groups, bindings int, loaded bool) {
285- renderedGroups, ok := collectKeybindingGroups(keybindings)
286- if !ok {
287- return 0, 0, false
288- }
289-
290- for _, group := range renderedGroups {
275+func summarizeKeybindings(keybindings port.KeybindingsConfig) (groups, bindings int) {
276+ for _, group := range keybindings.Groups {
291277 groups++
292278 bindings += len(group.Bindings)
293279 }
294-
295- return groups, bindings, true
296-}
297-
298-func collectKeybindingGroups(keybindings any) ([]renderedKeybindingGroup, bool) {
299- switch v := keybindings.(type) {
300- case nil:
301- return nil, false
302- case port.KeybindingsConfig:
303- return convertPortKeybindingGroups(v.Groups), true
304- case *port.KeybindingsConfig:
305- if v == nil {
306- return nil, false
307- }
308- return convertPortKeybindingGroups(v.Groups), true
309- case map[string]any:
310- return convertBridgeKeybindingGroups(v)
311- default:
312- return nil, false
313- }
280+ return groups, bindings
314281 }
315282
316283 func convertPortKeybindingGroups(groups []port.KeybindingGroup) []renderedKeybindingGroup {
@@ -340,119 +307,6 @@ func convertPortKeybindingEntries(bindings []port.KeybindingEntry) []renderedKey
340307 return rendered
341308 }
342309
343-func convertBridgeKeybindingGroups(data map[string]any) ([]renderedKeybindingGroup, bool) {
344- rawGroups, ok := data["groups"]
345- if !ok || rawGroups == nil {
346- return nil, false
347- }
348-
349- groupValues, ok := anySlice(rawGroups)
350- if !ok {
351- return nil, false
352- }
353-
354- rendered := make([]renderedKeybindingGroup, 0, len(groupValues))
355- for _, rawGroup := range groupValues {
356- groupMap, ok := rawGroup.(map[string]any)
357- if !ok {
358- return nil, false
359- }
360- group, ok := convertBridgeKeybindingGroup(groupMap)
361- if !ok {
362- return nil, false
363- }
364- rendered = append(rendered, group)
365- }
366-
367- return rendered, true
368-}
369-
370-func convertBridgeKeybindingGroup(group map[string]any) (renderedKeybindingGroup, bool) {
371- bindings := []renderedKeybinding{}
372- if rawBindings, ok := group["bindings"]; ok && rawBindings != nil {
373- converted, ok := convertBridgeKeybindingEntries(rawBindings)
374- if !ok {
375- return renderedKeybindingGroup{}, false
376- }
377- bindings = converted
378- }
379-
380- return renderedKeybindingGroup{
381- DisplayName: strings.TrimSpace(stringValue(group["display_name"])),
382- Mode: strings.TrimSpace(stringValue(group["mode"])),
383- Activation: strings.TrimSpace(stringValue(group["activation"])),
384- Bindings: bindings,
385- }, true
386-}
387-
388-func convertBridgeKeybindingEntries(rawBindings any) ([]renderedKeybinding, bool) {
389- bindingValues, ok := anySlice(rawBindings)
390- if !ok {
391- return nil, false
392- }
393-
394- rendered := make([]renderedKeybinding, 0, len(bindingValues))
395- for _, rawBinding := range bindingValues {
396- bindingMap, ok := rawBinding.(map[string]any)
397- if !ok {
398- return nil, false
399- }
400- rendered = append(rendered, renderedKeybinding{
401- Description: strings.TrimSpace(stringValue(bindingMap["description"])),
402- Action: strings.TrimSpace(stringValue(bindingMap["action"])),
403- Keys: stringSlice(bindingMap["keys"]),
404- DefaultKeys: stringSlice(bindingMap["default_keys"]),
405- IsCustom: boolValue(bindingMap["is_custom"]),
406- })
407- }
408-
409- return rendered, true
410-}
411-
412-func anySlice(value any) ([]any, bool) {
413- switch v := value.(type) {
414- case []any:
415- return v, true
416- case []map[string]any:
417- items := make([]any, len(v))
418- for i, item := range v {
419- items[i] = item
420- }
421- return items, true
422- default:
423- return nil, false
424- }
425-}
426-
427-func stringValue(value any) string {
428- s, _ := value.(string)
429- return s
430-}
431-
432-func stringSlice(value any) []string {
433- switch v := value.(type) {
434- case []string:
435- return append([]string(nil), v...)
436- case []any:
437- out := make([]string, 0, len(v))
438- for _, item := range v {
439- text, ok := item.(string)
440- if !ok {
441- return nil
442- }
443- out = append(out, text)
444- }
445- return out
446- default:
447- return nil
448- }
449-}
450-
451-func boolValue(value any) bool {
452- b, _ := value.(bool)
453- return b
454-}
455-
456310 type renderedKeybindingGroup struct {
457311 DisplayName string
458312 Mode string
@@ -11,7 +11,7 @@ import (
11 11
12 type configRenderData struct {12 type configRenderData struct {
13 Config port.SystemviewConfigPayload13 Config port.SystemviewConfigPayload
14- Keybindings any14+ Keybindings port.KeybindingsConfig
15 Notice string15 Notice string
16 Error string16 Error string
17 }17 }
@@ -20,15 +20,10 @@ func configHTML(data configRenderData) string {
20 return mustRenderComponent(ConfigView(data))20 return mustRenderComponent(ConfigView(data))
21 }21 }
22 22
23-func configKeybindingSummary(keybindings any) string {23+func configKeybindingSummary(keybindings port.KeybindingsConfig) string {
24- groups, bindings, loaded := summarizeKeybindings(keybindings)24+ groups, bindings := summarizeKeybindings(keybindings)
25- status := "not loaded"
26- if loaded {
27- status = "loaded"
28- }
29 return fmt.Sprintf(25 return fmt.Sprintf(
30- "Keybindings %s (%d %s, %d %s)",26+ "Keybindings loaded (%d %s, %d %s)",
31- status,
32 groups,27 groups,
33 plural(groups, "group"),28 plural(groups, "group"),
34 bindings,29 bindings,
@@ -36,12 +31,8 @@ func configKeybindingSummary(keybindings any) string {
36 )31 )
37 }32 }
38 33
39-func configKeybindingGroups(keybindings any) []renderedKeybindingGroup {34+func configKeybindingGroups(keybindings port.KeybindingsConfig) []renderedKeybindingGroup {
40- groups, ok := collectKeybindingGroups(keybindings)35+ return convertPortKeybindingGroups(keybindings.Groups)
41- if !ok {
42- return nil
43- }
44- return groups
45 }36 }
46 37
47 func keybindingGroupTitle(group renderedKeybindingGroup) string {38 func keybindingGroupTitle(group renderedKeybindingGroup) string {
@@ -281,36 +272,12 @@ func mapKeyString(value reflect.Value) string {
281 return fmt.Sprint(value.Interface())272 return fmt.Sprint(value.Interface())
282 }273 }
283 274
284-func summarizeKeybindings(keybindings any) (groups, bindings int, loaded bool) {275+func summarizeKeybindings(keybindings port.KeybindingsConfig) (groups, bindings int) {
285- renderedGroups, ok := collectKeybindingGroups(keybindings)276+ for _, group := range keybindings.Groups {
286- if !ok {
287- return 0, 0, false
288- }
289-
290- for _, group := range renderedGroups {
291 groups++277 groups++
292 bindings += len(group.Bindings)278 bindings += len(group.Bindings)
293 }279 }
294-280+ return groups, bindings
295- return groups, bindings, true
296-}
297-
298-func collectKeybindingGroups(keybindings any) ([]renderedKeybindingGroup, bool) {
299- switch v := keybindings.(type) {
300- case nil:
301- return nil, false
302- case port.KeybindingsConfig:
303- return convertPortKeybindingGroups(v.Groups), true
304- case *port.KeybindingsConfig:
305- if v == nil {
306- return nil, false
307- }
308- return convertPortKeybindingGroups(v.Groups), true
309- case map[string]any:
310- return convertBridgeKeybindingGroups(v)
311- default:
312- return nil, false
313- }
314 }281 }
315 282
316 func convertPortKeybindingGroups(groups []port.KeybindingGroup) []renderedKeybindingGroup {283 func convertPortKeybindingGroups(groups []port.KeybindingGroup) []renderedKeybindingGroup {
@@ -340,119 +307,6 @@ func convertPortKeybindingEntries(bindings []port.KeybindingEntry) []renderedKey
340 return rendered307 return rendered
341 }308 }
342 309
343-func convertBridgeKeybindingGroups(data map[string]any) ([]renderedKeybindingGroup, bool) {
344- rawGroups, ok := data["groups"]
345- if !ok || rawGroups == nil {
346- return nil, false
347- }
348-
349- groupValues, ok := anySlice(rawGroups)
350- if !ok {
351- return nil, false
352- }
353-
354- rendered := make([]renderedKeybindingGroup, 0, len(groupValues))
355- for _, rawGroup := range groupValues {
356- groupMap, ok := rawGroup.(map[string]any)
357- if !ok {
358- return nil, false
359- }
360- group, ok := convertBridgeKeybindingGroup(groupMap)
361- if !ok {
362- return nil, false
363- }
364- rendered = append(rendered, group)
365- }
366-
367- return rendered, true
368-}
369-
370-func convertBridgeKeybindingGroup(group map[string]any) (renderedKeybindingGroup, bool) {
371- bindings := []renderedKeybinding{}
372- if rawBindings, ok := group["bindings"]; ok && rawBindings != nil {
373- converted, ok := convertBridgeKeybindingEntries(rawBindings)
374- if !ok {
375- return renderedKeybindingGroup{}, false
376- }
377- bindings = converted
378- }
379-
380- return renderedKeybindingGroup{
381- DisplayName: strings.TrimSpace(stringValue(group["display_name"])),
382- Mode: strings.TrimSpace(stringValue(group["mode"])),
383- Activation: strings.TrimSpace(stringValue(group["activation"])),
384- Bindings: bindings,
385- }, true
386-}
387-
388-func convertBridgeKeybindingEntries(rawBindings any) ([]renderedKeybinding, bool) {
389- bindingValues, ok := anySlice(rawBindings)
390- if !ok {
391- return nil, false
392- }
393-
394- rendered := make([]renderedKeybinding, 0, len(bindingValues))
395- for _, rawBinding := range bindingValues {
396- bindingMap, ok := rawBinding.(map[string]any)
397- if !ok {
398- return nil, false
399- }
400- rendered = append(rendered, renderedKeybinding{
401- Description: strings.TrimSpace(stringValue(bindingMap["description"])),
402- Action: strings.TrimSpace(stringValue(bindingMap["action"])),
403- Keys: stringSlice(bindingMap["keys"]),
404- DefaultKeys: stringSlice(bindingMap["default_keys"]),
405- IsCustom: boolValue(bindingMap["is_custom"]),
406- })
407- }
408-
409- return rendered, true
410-}
411-
412-func anySlice(value any) ([]any, bool) {
413- switch v := value.(type) {
414- case []any:
415- return v, true
416- case []map[string]any:
417- items := make([]any, len(v))
418- for i, item := range v {
419- items[i] = item
420- }
421- return items, true
422- default:
423- return nil, false
424- }
425-}
426-
427-func stringValue(value any) string {
428- s, _ := value.(string)
429- return s
430-}
431-
432-func stringSlice(value any) []string {
433- switch v := value.(type) {
434- case []string:
435- return append([]string(nil), v...)
436- case []any:
437- out := make([]string, 0, len(v))
438- for _, item := range v {
439- text, ok := item.(string)
440- if !ok {
441- return nil
442- }
443- out = append(out, text)
444- }
445- return out
446- default:
447- return nil
448- }
449-}
450-
451-func boolValue(value any) bool {
452- b, _ := value.(bool)
453- return b
454-}
455-
456 type renderedKeybindingGroup struct {310 type renderedKeybindingGroup struct {
457 DisplayName string311 DisplayName string
458 Mode string312 Mode string
M internal/ui/systemviews/favorite_actions.go +2 -12
@@ -46,7 +46,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
4646 if err != nil {
4747 return err
4848 }
49- a.favoritesNotice = "Added favorite " + favoriteDisplayTitle(favorite)
49+ a.favoritesNotice = "Added favorite " + favoriteItemLabel(favorite)
5050 case favoriteActionUpdate:
5151 id, err := parsePositiveInt64(data["id"], "favorite id")
5252 if err != nil {
@@ -70,7 +70,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
7070 if err != nil {
7171 return err
7272 }
73- a.favoritesNotice = "Saved favorite " + favoriteDisplayTitle(favorite)
73+ a.favoritesNotice = "Saved favorite " + favoriteItemLabel(favorite)
7474 case favoriteActionDelete:
7575 id, err := parsePositiveInt64(data["id"], "favorite id")
7676 if err != nil {
@@ -231,13 +231,3 @@ func optionalShortcut(raw string) (*int, error) {
231231 }
232232 return &value, nil
233233 }
234-
235-func favoriteDisplayTitle(favorite *entity.Favorite) string {
236- if favorite == nil {
237- return ""
238- }
239- if strings.TrimSpace(favorite.Title) != "" {
240- return favorite.Title
241- }
242- return favorite.URL
243-}
@@ -46,7 +46,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
46 if err != nil {46 if err != nil {
47 return err47 return err
48 }48 }
49- a.favoritesNotice = "Added favorite " + favoriteDisplayTitle(favorite)49+ a.favoritesNotice = "Added favorite " + favoriteItemLabel(favorite)
50 case favoriteActionUpdate:50 case favoriteActionUpdate:
51 id, err := parsePositiveInt64(data["id"], "favorite id")51 id, err := parsePositiveInt64(data["id"], "favorite id")
52 if err != nil {52 if err != nil {
@@ -70,7 +70,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error {
70 if err != nil {70 if err != nil {
71 return err71 return err
72 }72 }
73- a.favoritesNotice = "Saved favorite " + favoriteDisplayTitle(favorite)73+ a.favoritesNotice = "Saved favorite " + favoriteItemLabel(favorite)
74 case favoriteActionDelete:74 case favoriteActionDelete:
75 id, err := parsePositiveInt64(data["id"], "favorite id")75 id, err := parsePositiveInt64(data["id"], "favorite id")
76 if err != nil {76 if err != nil {
@@ -231,13 +231,3 @@ func optionalShortcut(raw string) (*int, error) {
231 }231 }
232 return &value, nil232 return &value, nil
233 }233 }
234-
235-func favoriteDisplayTitle(favorite *entity.Favorite) string {
236- if favorite == nil {
237- return ""
238- }
239- if strings.TrimSpace(favorite.Title) != "" {
240- return favorite.Title
241- }
242- return favorite.URL
243-}
M internal/ui/systemviews/history.templ +4 -3
@@ -77,11 +77,12 @@ templ HistoryDomains(data historyRenderData) {
7777 <div class="sv-domain-list">
7878 for _, domain := range data.Domains {
7979 if domain != nil && domain.Domain != "" {
80- {{ displayDomain := displayHistoryDomain(domain.Domain) }}
80+ {{ domainKey := historyDomainActionKey(domain) }}
81+ {{ displayDomain := historyDomainDisplayLabel(domain) }}
8182 <span class="sv-domain-chip">
82- <button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain={ displayDomain }>{ displayDomain }</button>
83+ <button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain={ domainKey }>{ displayDomain }</button>
8384 <span class="sv-domain-meta">{ fmt.Sprintf("%d pages · %d visits", domain.PageCount, domain.TotalVisits) }</span>
84- <button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain={ displayDomain } data-sv-confirm={ "Delete all history for " + displayDomain + "?" } aria-label={ "Delete history for " + displayDomain }>×</button>
85+ <button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain={ domainKey } data-sv-confirm={ "Delete all history for " + displayDomain + "?" } aria-label={ "Delete history for " + displayDomain }>×</button>
8586 </span>
8687 }
8788 }
@@ -77,11 +77,12 @@ templ HistoryDomains(data historyRenderData) {
77 <div class="sv-domain-list">77 <div class="sv-domain-list">
78 for _, domain := range data.Domains {78 for _, domain := range data.Domains {
79 if domain != nil && domain.Domain != "" {79 if domain != nil && domain.Domain != "" {
80- {{ displayDomain := displayHistoryDomain(domain.Domain) }}80+ {{ domainKey := historyDomainActionKey(domain) }}
81+ {{ displayDomain := historyDomainDisplayLabel(domain) }}
81 <span class="sv-domain-chip">82 <span class="sv-domain-chip">
82- <button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain={ displayDomain }>{ displayDomain }</button>83+ <button type="button" class="sv-domain-filter" data-sv-action="history.filterDomain" data-domain={ domainKey }>{ displayDomain }</button>
83 <span class="sv-domain-meta">{ fmt.Sprintf("%d pages · %d visits", domain.PageCount, domain.TotalVisits) }</span>84 <span class="sv-domain-meta">{ fmt.Sprintf("%d pages · %d visits", domain.PageCount, domain.TotalVisits) }</span>
84- <button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain={ displayDomain } data-sv-confirm={ "Delete all history for " + displayDomain + "?" } aria-label={ "Delete history for " + displayDomain }>×</button>85+ <button type="button" class="sv-icon-button sv-danger" data-sv-action="history.deleteDomain" data-domain={ domainKey } data-sv-confirm={ "Delete all history for " + displayDomain + "?" } aria-label={ "Delete history for " + displayDomain }>×</button>
85 </span>86 </span>
86 }87 }
87 }88 }
M internal/ui/systemviews/history_actions.go +10 -19
@@ -5,6 +5,9 @@ import (
55 "fmt"
66 "strconv"
77 "strings"
8+ "time"
9+
10+ historydomain "github.com/bnema/dumber/internal/domain/history"
811 )
912
1013 const (
@@ -147,27 +150,15 @@ func (a *App) mountRenderedHTML() error {
147150 }
148151
149152 func validHistoryRange(rangeID string) bool {
150- switch rangeID {
151- case "hour", "day", "week", "month", "all":
152- return true
153- default:
154- return false
155- }
153+ _, _, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
154+ return ok
156155 }
157156
158157 func historyRangeNotice(rangeID string) string {
159- switch rangeID {
160- case "hour":
161- return "Deleted history from the last hour"
162- case "day":
163- return "Deleted history from today"
164- case "week":
165- return "Deleted history from this week"
166- case "month":
167- return "Deleted history from this month"
168- case "all":
169- return "Deleted all history"
170- default:
171- return "Deleted history"
158+ for _, item := range historyCleanupItems() {
159+ if item.RangeID == rangeID {
160+ return item.Notice
161+ }
172162 }
163+ return "Deleted history"
173164 }
@@ -5,6 +5,9 @@ import (
5 "fmt"5 "fmt"
6 "strconv"6 "strconv"
7 "strings"7 "strings"
8+ "time"
9+
10+ historydomain "github.com/bnema/dumber/internal/domain/history"
8 )11 )
9 12
10 const (13 const (
@@ -147,27 +150,15 @@ func (a *App) mountRenderedHTML() error {
147 }150 }
148 151
149 func validHistoryRange(rangeID string) bool {152 func validHistoryRange(rangeID string) bool {
150- switch rangeID {153+ _, _, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
151- case "hour", "day", "week", "month", "all":154+ return ok
152- return true
153- default:
154- return false
155- }
156 }155 }
157 156
158 func historyRangeNotice(rangeID string) string {157 func historyRangeNotice(rangeID string) string {
159- switch rangeID {158+ for _, item := range historyCleanupItems() {
160- case "hour":159+ if item.RangeID == rangeID {
161- return "Deleted history from the last hour"160+ return item.Notice
162- case "day":161+ }
163- return "Deleted history from today"
164- case "week":
165- return "Deleted history from this week"
166- case "month":
167- return "Deleted history from this month"
168- case "all":
169- return "Deleted all history"
170- default:
171- return "Deleted history"
172 }162 }
163+ return "Deleted history"
173 }164 }
M internal/ui/systemviews/history_templ.go +18 -17 generated file: collapsed
M internal/ui/systemviews/history_view.go +52 -14
@@ -2,6 +2,7 @@ package systemviews
22
33 import (
44 "fmt"
5+ "net"
56 "net/url"
67 "strings"
78 "time"
@@ -31,20 +32,23 @@ type historyCleanupItem struct {
3132 Label string
3233 RangeID string
3334 Confirm string
35+ Notice string
3436 }
3537
3638 func historyHTML(data historyRenderData) string {
3739 return mustRenderComponent(HistoryView(data))
3840 }
3941
42+var historyCleanupRanges = []historyCleanupItem{
43+ {Label: "Last hour", RangeID: "hour", Confirm: "Delete history for last hour?", Notice: "Deleted history from the last hour"},
44+ {Label: "Today", RangeID: "day", Confirm: "Delete history for today?", Notice: "Deleted history from today"},
45+ {Label: "Week", RangeID: "week", Confirm: "Delete history for week?", Notice: "Deleted history from this week"},
46+ {Label: "Month", RangeID: "month", Confirm: "Delete history for month?", Notice: "Deleted history from this month"},
47+ {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?", Notice: "Deleted all history"},
48+}
49+
4050 func historyCleanupItems() []historyCleanupItem {
41- return []historyCleanupItem{
42- {Label: "Last hour", RangeID: "hour", Confirm: "Delete history for last hour?"},
43- {Label: "Today", RangeID: "day", Confirm: "Delete history for today?"},
44- {Label: "Week", RangeID: "week", Confirm: "Delete history for week?"},
45- {Label: "Month", RangeID: "month", Confirm: "Delete history for month?"},
46- {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?"},
47- }
51+ return historyCleanupRanges
4852 }
4953
5054 func historyLimit(data historyRenderData) int {
@@ -193,7 +197,7 @@ func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
193197 }
194198
195199 func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) []*entity.HistoryEntry {
196- domain = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(domain)), "www.")
200+ domain = canonicalHistoryDomain(domain)
197201 if domain == "" {
198202 return entries
199203 }
@@ -202,8 +206,7 @@ func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string)
202206 if entry == nil {
203207 continue
204208 }
205- entryDomain := strings.TrimPrefix(strings.ToLower(displayHistoryDomain(entry.URL)), "www.")
206- if entryDomain == domain {
209+ if canonicalHistoryDomain(entry.URL) == domain {
207210 filtered = append(filtered, entry)
208211 }
209212 }
@@ -211,11 +214,46 @@ func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string)
211214 }
212215
213216 func displayHistoryDomain(raw string) string {
214- parsed, err := url.Parse(raw)
215- if err != nil || parsed.Hostname() == "" {
216- return strings.TrimSpace(raw)
217+ domain := canonicalHistoryDomain(raw)
218+ if host, _, err := net.SplitHostPort(domain); err == nil {
219+ domain = host
220+ }
221+ return domain
222+}
223+
224+func historyDomainActionKey(domain *entity.DomainStat) string {
225+ if domain == nil {
226+ return ""
227+ }
228+ return strings.TrimSpace(domain.Domain)
229+}
230+
231+func historyDomainDisplayLabel(domain *entity.DomainStat) string {
232+ if domain == nil {
233+ return ""
234+ }
235+ return displayHistoryDomain(domain.Domain)
236+}
237+
238+func canonicalHistoryDomain(raw string) string {
239+ raw = strings.TrimSpace(strings.ToLower(raw))
240+ if raw == "" {
241+ return ""
242+ }
243+ if parsed, err := url.Parse(raw); err == nil && parsed.Host != "" {
244+ return strings.TrimPrefix(parsed.Host, "www.")
245+ }
246+ if schemeIdx := strings.Index(raw, "://"); schemeIdx >= 0 {
247+ if parsed, err := url.Parse(raw[schemeIdx+3:]); err == nil && parsed.Host != "" {
248+ return strings.TrimPrefix(parsed.Host, "www.")
249+ }
250+ }
251+ for _, sep := range []string{"/", "?", "#"} {
252+ if before, _, ok := strings.Cut(raw, sep); ok {
253+ raw = before
254+ }
217255 }
218- return strings.TrimPrefix(parsed.Hostname(), "www.")
256+ return strings.TrimPrefix(raw, "www.")
219257 }
220258
221259 func formatHistoryTime(ts time.Time) string {
@@ -2,6 +2,7 @@ package systemviews
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5+ "net"
5 "net/url"6 "net/url"
6 "strings"7 "strings"
7 "time"8 "time"
@@ -31,20 +32,23 @@ type historyCleanupItem struct {
31 Label string32 Label string
32 RangeID string33 RangeID string
33 Confirm string34 Confirm string
35+ Notice string
34 }36 }
35 37
36 func historyHTML(data historyRenderData) string {38 func historyHTML(data historyRenderData) string {
37 return mustRenderComponent(HistoryView(data))39 return mustRenderComponent(HistoryView(data))
38 }40 }
39 41
42+var historyCleanupRanges = []historyCleanupItem{
43+ {Label: "Last hour", RangeID: "hour", Confirm: "Delete history for last hour?", Notice: "Deleted history from the last hour"},
44+ {Label: "Today", RangeID: "day", Confirm: "Delete history for today?", Notice: "Deleted history from today"},
45+ {Label: "Week", RangeID: "week", Confirm: "Delete history for week?", Notice: "Deleted history from this week"},
46+ {Label: "Month", RangeID: "month", Confirm: "Delete history for month?", Notice: "Deleted history from this month"},
47+ {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?", Notice: "Deleted all history"},
48+}
49+
40 func historyCleanupItems() []historyCleanupItem {50 func historyCleanupItems() []historyCleanupItem {
41- return []historyCleanupItem{51+ return historyCleanupRanges
42- {Label: "Last hour", RangeID: "hour", Confirm: "Delete history for last hour?"},
43- {Label: "Today", RangeID: "day", Confirm: "Delete history for today?"},
44- {Label: "Week", RangeID: "week", Confirm: "Delete history for week?"},
45- {Label: "Month", RangeID: "month", Confirm: "Delete history for month?"},
46- {Label: "All", RangeID: "all", Confirm: "Delete all browsing history?"},
47- }
48 }52 }
49 53
50 func historyLimit(data historyRenderData) int {54 func historyLimit(data historyRenderData) int {
@@ -193,7 +197,7 @@ func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
193 }197 }
194 198
195 func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) []*entity.HistoryEntry {199 func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) []*entity.HistoryEntry {
196- domain = strings.TrimPrefix(strings.ToLower(strings.TrimSpace(domain)), "www.")200+ domain = canonicalHistoryDomain(domain)
197 if domain == "" {201 if domain == "" {
198 return entries202 return entries
199 }203 }
@@ -202,8 +206,7 @@ func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string)
202 if entry == nil {206 if entry == nil {
203 continue207 continue
204 }208 }
205- entryDomain := strings.TrimPrefix(strings.ToLower(displayHistoryDomain(entry.URL)), "www.")209+ if canonicalHistoryDomain(entry.URL) == domain {
206- if entryDomain == domain {
207 filtered = append(filtered, entry)210 filtered = append(filtered, entry)
208 }211 }
209 }212 }
@@ -211,11 +214,46 @@ func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string)
211 }214 }
212 215
213 func displayHistoryDomain(raw string) string {216 func displayHistoryDomain(raw string) string {
214- parsed, err := url.Parse(raw)217+ domain := canonicalHistoryDomain(raw)
215- if err != nil || parsed.Hostname() == "" {218+ if host, _, err := net.SplitHostPort(domain); err == nil {
216- return strings.TrimSpace(raw)219+ domain = host
220+ }
221+ return domain
222+}
223+
224+func historyDomainActionKey(domain *entity.DomainStat) string {
225+ if domain == nil {
226+ return ""
227+ }
228+ return strings.TrimSpace(domain.Domain)
229+}
230+
231+func historyDomainDisplayLabel(domain *entity.DomainStat) string {
232+ if domain == nil {
233+ return ""
234+ }
235+ return displayHistoryDomain(domain.Domain)
236+}
237+
238+func canonicalHistoryDomain(raw string) string {
239+ raw = strings.TrimSpace(strings.ToLower(raw))
240+ if raw == "" {
241+ return ""
242+ }
243+ if parsed, err := url.Parse(raw); err == nil && parsed.Host != "" {
244+ return strings.TrimPrefix(parsed.Host, "www.")
245+ }
246+ if schemeIdx := strings.Index(raw, "://"); schemeIdx >= 0 {
247+ if parsed, err := url.Parse(raw[schemeIdx+3:]); err == nil && parsed.Host != "" {
248+ return strings.TrimPrefix(parsed.Host, "www.")
249+ }
250+ }
251+ for _, sep := range []string{"/", "?", "#"} {
252+ if before, _, ok := strings.Cut(raw, sep); ok {
253+ raw = before
254+ }
217 }255 }
218- return strings.TrimPrefix(parsed.Hostname(), "www.")256+ return strings.TrimPrefix(raw, "www.")
219 }257 }
220 258
221 func formatHistoryTime(ts time.Time) string {259 func formatHistoryTime(ts time.Time) string {

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