refactor(systemviews): simplify wasm UI plumbing
- commit
- 9d4fa9304b40bff8ef7d70e9dd820be6448273ee
- author
- brice · · 2026-04-24 04:04 UTC
- parent
- a9a951b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |||
| 40 | 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 | 44 | if p.route != systemviews.RouteConfig { | |
| 45 | - | return nil, nil | |
| 45 | + | return port.KeybindingsConfig{}, nil | |
| 46 | 46 | } | |
| 47 | 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 | 51 | return p.bridge.SetKeybinding(ctx, req) | |
| 52 | 52 | } | |
| 53 | 53 | ||
| @@ -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, nil | 45 | + | 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 { | |||
| 107 | 107 | folders []*entity.Folder | |
| 108 | 108 | tags []*entity.Tag | |
| 109 | 109 | currentConfig port.SystemviewConfigPayload | |
| 110 | - | keybindings any | |
| 110 | + | keybindings port.KeybindingsConfig | |
| 111 | 111 | } | |
| 112 | 112 | ||
| 113 | 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 | 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 | 195 | f.calledKeybindings = true | |
| 196 | 196 | return f.keybindings, nil | |
| 197 | 197 | } | |
| 198 | 198 | ||
| 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 | |
| 201 | 201 | } | |
| 202 | 202 | ||
| 203 | 203 | func (*fakeBridgeService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error { | |
| @@ -107,7 +107,7 @@ type fakeBridgeService struct { | |||||
| 107 | folders []*entity.Folder | 107 | folders []*entity.Folder | ||
| 108 | tags []*entity.Tag | 108 | tags []*entity.Tag | ||
| 109 | currentConfig port.SystemviewConfigPayload | 109 | currentConfig port.SystemviewConfigPayload | ||
| 110 | - | keybindings any | 110 | + | 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 = true | 195 | f.calledKeybindings = true | ||
| 196 | return f.keybindings, nil | 196 | 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, nil | 200 | + | 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 { | |||
| 42 | 42 | Current(ctx context.Context) (SystemviewConfigPayload, error) | |
| 43 | 43 | Default(ctx context.Context) (SystemviewConfigPayload, error) | |
| 44 | 44 | 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) | |
| 47 | 47 | ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error | |
| 48 | 48 | ResetAllKeybindings(ctx context.Context) error | |
| 49 | 49 | } | |
| @@ -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) error | 44 | 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) error | 47 | ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error | ||
| 48 | ResetAllKeybindings(ctx context.Context) error | 48 | 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 { | |||
| 105 | 105 | return err | |
| 106 | 106 | } | |
| 107 | 107 | ||
| 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 { | |
| 110 | 110 | RequestID string `json:"requestId"` | |
| 111 | 111 | }{RequestID: nextRequestID()}) | |
| 112 | 112 | } | |
| 113 | 113 | ||
| 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) | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | 118 | 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 err | 105 | 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) { | |||
| 286 | 286 | if err != nil { | |
| 287 | 287 | return err | |
| 288 | 288 | } | |
| 289 | - | m, ok := got.(map[string]any) | |
| 290 | - | if !ok || len(m) == 0 { | |
| 289 | + | if len(got.Groups) != 1 || got.Groups[0].Mode != "default" { | |
| 291 | 290 | t.Fatalf("GetKeybindings() = %#v", got) | |
| 292 | 291 | } | |
| 293 | 292 | return nil | |
| @@ -301,8 +300,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||
| 301 | 300 | if err != nil { | |
| 302 | 301 | return err | |
| 303 | 302 | } | |
| 304 | - | m, ok := got.(map[string]any) | |
| 305 | - | if !ok || m["status"] != "success" { | |
| 303 | + | if len(got.Conflicts) != 0 { | |
| 306 | 304 | t.Fatalf("SetKeybinding() = %#v", got) | |
| 307 | 305 | } | |
| 308 | 306 | return nil | |
| @@ -286,8 +286,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||||
| 286 | if err != nil { | 286 | if err != nil { | ||
| 287 | return err | 287 | 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 nil | 292 | return nil | ||
| @@ -301,8 +300,7 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||||
| 301 | if err != nil { | 300 | if err != nil { | ||
| 302 | return err | 301 | 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 nil | 306 | return nil | ||
M internal/ui/systemviews/app.go +28 -7
| @@ -39,10 +39,11 @@ type App struct { | |||
| 39 | 39 | favoritesNotice string | |
| 40 | 40 | favoritesError string | |
| 41 | 41 | config *port.SystemviewConfigPayload | |
| 42 | - | keybindings any | |
| 42 | + | keybindings port.KeybindingsConfig | |
| 43 | 43 | configNotice string | |
| 44 | 44 | configError string | |
| 45 | 45 | renderedHTML string | |
| 46 | + | actionQueue chan DOMAction | |
| 46 | 47 | } | |
| 47 | 48 | ||
| 48 | 49 | const historyTimelineLimit = 25 | |
| @@ -68,15 +69,35 @@ func (a *App) Run() error { | |||
| 68 | 69 | return err | |
| 69 | 70 | } | |
| 70 | 71 | 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) | |
| 76 | 73 | } | |
| 77 | 74 | 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 | 101 | func (a *App) LoadInitial(ctx context.Context) error { | |
| 81 | 102 | if a == nil { | |
| 82 | 103 | return errors.New("app is nil") | |
| @@ -308,7 +329,7 @@ func (a *App) resetRouteState() { | |||
| 308 | 329 | a.favoritesNotice = "" | |
| 309 | 330 | a.favoritesError = "" | |
| 310 | 331 | a.config = nil | |
| 311 | - | a.keybindings = nil | |
| 332 | + | a.keybindings = port.KeybindingsConfig{} | |
| 312 | 333 | a.configNotice = "" | |
| 313 | 334 | a.configError = "" | |
| 314 | 335 | } | |
| @@ -39,10 +39,11 @@ type App struct { | |||||
| 39 | favoritesNotice string | 39 | favoritesNotice string | ||
| 40 | favoritesError string | 40 | favoritesError string | ||
| 41 | config *port.SystemviewConfigPayload | 41 | config *port.SystemviewConfigPayload | ||
| 42 | - | keybindings any | 42 | + | keybindings port.KeybindingsConfig |
| 43 | configNotice string | 43 | configNotice string | ||
| 44 | configError string | 44 | configError string | ||
| 45 | renderedHTML string | 45 | renderedHTML string | ||
| 46 | + | actionQueue chan DOMAction | |||
| 46 | } | 47 | } | ||
| 47 | 48 | ||||
| 48 | const historyTimelineLimit = 25 | 49 | const historyTimelineLimit = 25 | ||
| @@ -68,15 +69,35 @@ func (a *App) Run() error { | |||||
| 68 | return err | 69 | 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 nil | 74 | 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 = nil | 331 | a.config = nil | ||
| 311 | - | a.keybindings = nil | 332 | + | 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) { | |||
| 94 | 94 | URL: "https://example.com/page", | |
| 95 | 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 | 100 | app := NewApp(Dependencies{ | |
| @@ -110,6 +110,8 @@ func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) { | |||
| 110 | 110 | assert.Contains(t, app.renderedHTML, `data-range="hour"`) | |
| 111 | 111 | assert.Contains(t, app.renderedHTML, `data-sv-action="history.filterDomain"`) | |
| 112 | 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 | 115 | assert.Contains(t, app.renderedHTML, "Keys:") | |
| 114 | 116 | assert.Contains(t, app.renderedHTML, "Enter") | |
| 115 | 117 | } | |
| @@ -452,15 +454,6 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |||
| 452 | 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 | 457 | func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) { | |
| 465 | 458 | t.Parallel() | |
| 466 | 459 | ||
| @@ -777,10 +770,10 @@ type fakeConfigService struct { | |||
| 777 | 770 | ||
| 778 | 771 | current port.SystemviewConfigPayload | |
| 779 | 772 | defaultCfg port.SystemviewConfigPayload | |
| 780 | - | keybindings any | |
| 773 | + | keybindings port.KeybindingsConfig | |
| 781 | 774 | savedConfig port.WebUIConfig | |
| 782 | 775 | setReq port.SetKeybindingRequest | |
| 783 | - | setResp any | |
| 776 | + | setResp port.SetKeybindingResponse | |
| 784 | 777 | resetReq port.ResetKeybindingRequest | |
| 785 | 778 | } | |
| 786 | 779 | ||
| @@ -812,12 +805,12 @@ func (s *fakeConfigService) Save(_ context.Context, cfg port.WebUIConfig) error | |||
| 812 | 805 | 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 | 809 | s.calledKeybindings = true | |
| 817 | 810 | 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 | 814 | s.calledSet = true | |
| 822 | 815 | s.setReq = req | |
| 823 | 816 | 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.SystemviewConfigPayload | 771 | current port.SystemviewConfigPayload | ||
| 779 | defaultCfg port.SystemviewConfigPayload | 772 | defaultCfg port.SystemviewConfigPayload | ||
| 780 | - | keybindings any | 773 | + | keybindings port.KeybindingsConfig |
| 781 | savedConfig port.WebUIConfig | 774 | savedConfig port.WebUIConfig | ||
| 782 | setReq port.SetKeybindingRequest | 775 | setReq port.SetKeybindingRequest | ||
| 783 | - | setResp any | 776 | + | setResp port.SetKeybindingResponse |
| 784 | resetReq port.ResetKeybindingRequest | 777 | resetReq port.ResetKeybindingRequest | ||
| 785 | } | 778 | } | ||
| 786 | 779 | ||||
| @@ -812,12 +805,12 @@ func (s *fakeConfigService) Save(_ context.Context, cfg port.WebUIConfig) error | |||||
| 812 | return nil | 805 | 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 = true | 809 | s.calledKeybindings = true | ||
| 817 | return s.keybindings, nil | 810 | 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 = true | 814 | s.calledSet = true | ||
| 822 | s.setReq = req | 815 | s.setReq = req | ||
| 823 | return s.setResp, nil | 816 | return s.setResp, nil | ||
M internal/ui/systemviews/config.templ +3 -3
| @@ -68,7 +68,7 @@ templ SearchSettings(config port.SystemviewConfigPayload) { | |||
| 68 | 68 | <form class="sv-inline-form" data-sv-action="config.searchShortcut.create"> | |
| 69 | 69 | <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label> | |
| 70 | 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 | 72 | <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button> | |
| 73 | 73 | </form> | |
| 74 | 74 | if len(searchShortcutRows(config.SearchShortcuts)) == 0 { | |
| @@ -89,7 +89,7 @@ templ SearchShortcutItem(shortcut searchShortcutRow) { | |||
| 89 | 89 | <input type="hidden" name="key" value={ shortcut.Key }/> | |
| 90 | 90 | <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label> | |
| 91 | 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 | 93 | <button class="sv-button sv-button-secondary" type="submit">Save</button> | |
| 94 | 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 | 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 | 128 | {{ groups := configKeybindingGroups(keybindings) }} | |
| 129 | 129 | <div class="sv-button-row"> | |
| 130 | 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> | |
| @@ -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 ( | |||
| 7 | 7 | "strings" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/domain/validation" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | const ( | |
| @@ -258,7 +259,7 @@ func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) e | |||
| 258 | 259 | if err != nil { | |
| 259 | 260 | return err | |
| 260 | 261 | } | |
| 261 | - | if conflicts := keybindingConflictCount(resp); conflicts > 0 { | |
| 262 | + | if conflicts := len(resp.Conflicts); conflicts > 0 { | |
| 262 | 263 | a.configNotice = fmt.Sprintf("Saved keybinding with %d conflict(s)", conflicts) | |
| 263 | 264 | return nil | |
| 264 | 265 | } | |
| @@ -366,24 +367,32 @@ func searchShortcutFromForm(data map[string]string, keyField string) (string, po | |||
| 366 | 367 | key := strings.TrimSpace(data[keyField]) | |
| 367 | 368 | url := strings.TrimSpace(data["url"]) | |
| 368 | 369 | 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 { | |
| 376 | 371 | return "", port.SearchShortcut{}, err | |
| 377 | 372 | } | |
| 378 | 373 | return key, port.SearchShortcut{URL: url, Description: description}, nil | |
| 379 | 374 | } | |
| 380 | 375 | ||
| 381 | 376 | 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) | |
| 384 | 387 | } | |
| 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, "; ")) | |
| 387 | 396 | } | |
| 388 | 397 | return nil | |
| 389 | 398 | } | |
| @@ -431,22 +440,3 @@ func parseKeyList(raw string) []string { | |||
| 431 | 440 | } | |
| 432 | 441 | 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 | - | } | |
| @@ -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 err | 260 | 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 nil | 264 | 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{}, err | 371 | return "", port.SearchShortcut{}, err | ||
| 377 | } | 372 | } | ||
| 378 | return key, port.SearchShortcut{URL: url, Description: description}, nil | 373 | 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 nil | 397 | return nil | ||
| 389 | } | 398 | } | ||
| @@ -431,22 +440,3 @@ func parseKeyList(raw string) []string { | |||||
| 431 | } | 440 | } | ||
| 432 | return keys | 441 | 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 ( | |||
| 11 | 11 | ||
| 12 | 12 | type configRenderData struct { | |
| 13 | 13 | Config port.SystemviewConfigPayload | |
| 14 | - | Keybindings any | |
| 14 | + | Keybindings port.KeybindingsConfig | |
| 15 | 15 | Notice string | |
| 16 | 16 | Error string | |
| 17 | 17 | } | |
| @@ -20,15 +20,10 @@ func configHTML(data configRenderData) string { | |||
| 20 | 20 | return mustRenderComponent(ConfigView(data)) | |
| 21 | 21 | } | |
| 22 | 22 | ||
| 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) | |
| 29 | 25 | return fmt.Sprintf( | |
| 30 | - | "Keybindings %s (%d %s, %d %s)", | |
| 31 | - | status, | |
| 26 | + | "Keybindings loaded (%d %s, %d %s)", | |
| 32 | 27 | groups, | |
| 33 | 28 | plural(groups, "group"), | |
| 34 | 29 | bindings, | |
| @@ -36,12 +31,8 @@ func configKeybindingSummary(keybindings any) string { | |||
| 36 | 31 | ) | |
| 37 | 32 | } | |
| 38 | 33 | ||
| 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) | |
| 45 | 36 | } | |
| 46 | 37 | ||
| 47 | 38 | func keybindingGroupTitle(group renderedKeybindingGroup) string { | |
| @@ -281,36 +272,12 @@ func mapKeyString(value reflect.Value) string { | |||
| 281 | 272 | return fmt.Sprint(value.Interface()) | |
| 282 | 273 | } | |
| 283 | 274 | ||
| 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 { | |
| 291 | 277 | groups++ | |
| 292 | 278 | bindings += len(group.Bindings) | |
| 293 | 279 | } | |
| 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 | |
| 314 | 281 | } | |
| 315 | 282 | ||
| 316 | 283 | func convertPortKeybindingGroups(groups []port.KeybindingGroup) []renderedKeybindingGroup { | |
| @@ -340,119 +307,6 @@ func convertPortKeybindingEntries(bindings []port.KeybindingEntry) []renderedKey | |||
| 340 | 307 | 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 | 310 | type renderedKeybindingGroup struct { | |
| 457 | 311 | DisplayName string | |
| 458 | 312 | Mode string | |
| @@ -11,7 +11,7 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | type configRenderData struct { | 12 | type configRenderData struct { | ||
| 13 | Config port.SystemviewConfigPayload | 13 | Config port.SystemviewConfigPayload | ||
| 14 | - | Keybindings any | 14 | + | Keybindings port.KeybindingsConfig |
| 15 | Notice string | 15 | Notice string | ||
| 16 | Error string | 16 | 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 rendered | 307 | 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 string | 311 | DisplayName string | ||
| 458 | Mode string | 312 | 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 { | |||
| 46 | 46 | if err != nil { | |
| 47 | 47 | return err | |
| 48 | 48 | } | |
| 49 | - | a.favoritesNotice = "Added favorite " + favoriteDisplayTitle(favorite) | |
| 49 | + | a.favoritesNotice = "Added favorite " + favoriteItemLabel(favorite) | |
| 50 | 50 | case favoriteActionUpdate: | |
| 51 | 51 | id, err := parsePositiveInt64(data["id"], "favorite id") | |
| 52 | 52 | if err != nil { | |
| @@ -70,7 +70,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||
| 70 | 70 | if err != nil { | |
| 71 | 71 | return err | |
| 72 | 72 | } | |
| 73 | - | a.favoritesNotice = "Saved favorite " + favoriteDisplayTitle(favorite) | |
| 73 | + | a.favoritesNotice = "Saved favorite " + favoriteItemLabel(favorite) | |
| 74 | 74 | case favoriteActionDelete: | |
| 75 | 75 | id, err := parsePositiveInt64(data["id"], "favorite id") | |
| 76 | 76 | if err != nil { | |
| @@ -231,13 +231,3 @@ func optionalShortcut(raw string) (*int, error) { | |||
| 231 | 231 | } | |
| 232 | 232 | 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 | - | } | |
| @@ -46,7 +46,7 @@ func (a *App) handleFavoriteAction(ctx context.Context, event DOMAction) error { | |||||
| 46 | if err != nil { | 46 | if err != nil { | ||
| 47 | return err | 47 | 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 err | 71 | 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, nil | 232 | 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) { | |||
| 77 | 77 | <div class="sv-domain-list"> | |
| 78 | 78 | for _, domain := range data.Domains { | |
| 79 | 79 | if domain != nil && domain.Domain != "" { | |
| 80 | - | {{ displayDomain := displayHistoryDomain(domain.Domain) }} | |
| 80 | + | {{ domainKey := historyDomainActionKey(domain) }} | |
| 81 | + | {{ displayDomain := historyDomainDisplayLabel(domain) }} | |
| 81 | 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 | 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 | 86 | </span> | |
| 86 | 87 | } | |
| 87 | 88 | } | |
| @@ -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 ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "strconv" | |
| 7 | 7 | "strings" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | historydomain "github.com/bnema/dumber/internal/domain/history" | |
| 8 | 11 | ) | |
| 9 | 12 | ||
| 10 | 13 | const ( | |
| @@ -147,27 +150,15 @@ func (a *App) mountRenderedHTML() error { | |||
| 147 | 150 | } | |
| 148 | 151 | ||
| 149 | 152 | 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 | |
| 156 | 155 | } | |
| 157 | 156 | ||
| 158 | 157 | 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 | + | } | |
| 172 | 162 | } | |
| 163 | + | return "Deleted history" | |
| 173 | 164 | } | |
| @@ -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 | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | + | "net" | |
| 5 | 6 | "net/url" | |
| 6 | 7 | "strings" | |
| 7 | 8 | "time" | |
| @@ -31,20 +32,23 @@ type historyCleanupItem struct { | |||
| 31 | 32 | Label string | |
| 32 | 33 | RangeID string | |
| 33 | 34 | Confirm string | |
| 35 | + | Notice string | |
| 34 | 36 | } | |
| 35 | 37 | ||
| 36 | 38 | func historyHTML(data historyRenderData) string { | |
| 37 | 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 | 50 | 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 | |
| 48 | 52 | } | |
| 49 | 53 | ||
| 50 | 54 | func historyLimit(data historyRenderData) int { | |
| @@ -193,7 +197,7 @@ func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 { | |||
| 193 | 197 | } | |
| 194 | 198 | ||
| 195 | 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 | 201 | if domain == "" { | |
| 198 | 202 | return entries | |
| 199 | 203 | } | |
| @@ -202,8 +206,7 @@ func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) | |||
| 202 | 206 | if entry == nil { | |
| 203 | 207 | continue | |
| 204 | 208 | } | |
| 205 | - | entryDomain := strings.TrimPrefix(strings.ToLower(displayHistoryDomain(entry.URL)), "www.") | |
| 206 | - | if entryDomain == domain { | |
| 209 | + | if canonicalHistoryDomain(entry.URL) == domain { | |
| 207 | 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 | 216 | 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 | + | } | |
| 217 | 255 | } | |
| 218 | - | return strings.TrimPrefix(parsed.Hostname(), "www.") | |
| 256 | + | return strings.TrimPrefix(raw, "www.") | |
| 219 | 257 | } | |
| 220 | 258 | ||
| 221 | 259 | 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 string | 32 | Label string | ||
| 32 | RangeID string | 33 | RangeID string | ||
| 33 | Confirm string | 34 | 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 entries | 202 | return entries | ||
| 199 | } | 203 | } | ||
| @@ -202,8 +206,7 @@ func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) | |||||
| 202 | if entry == nil { | 206 | if entry == nil { | ||
| 203 | continue | 207 | 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 { | ||