feat(systemviews): add themed shell and full config views
- commit
- cf2b13e20a51f37f64cf17f97a6a3988fec5f453
- author
- brice · · 2026-04-12 09:00 UTC
- parent
- a8b7d7d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
33 files · +2304 -181
M assets/systemviews/index.html +2 -0
| @@ -3,7 +3,9 @@ | |||
| 3 | 3 | <head> | |
| 4 | 4 | <meta charset="utf-8"> | |
| 5 | 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <meta name="color-scheme" content="dark light"> | |
| 6 | 7 | <title>systemviews</title> | |
| 8 | + | <link rel="stylesheet" href="./systemviews.css"> | |
| 7 | 9 | <script src="./wasm_exec.js"></script> | |
| 8 | 10 | <script> | |
| 9 | 11 | const go = new Go(); | |
| @@ -3,7 +3,9 @@ | |||||
| 3 | <head> | 3 | <head> | ||
| 4 | <meta charset="utf-8"> | 4 | <meta charset="utf-8"> | ||
| 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | 5 | <meta name="viewport" content="width=device-width, initial-scale=1"> | ||
| 6 | + | <meta name="color-scheme" content="dark light"> | |||
| 6 | <title>systemviews</title> | 7 | <title>systemviews</title> | ||
| 8 | + | <link rel="stylesheet" href="./systemviews.css"> | |||
| 7 | <script src="./wasm_exec.js"></script> | 9 | <script src="./wasm_exec.js"></script> | ||
| 8 | <script> | 10 | <script> | ||
| 9 | const go = new Go(); | 11 | const go = new Go(); | ||
A assets/systemviews/systemviews.css +151 -0
| @@ -0,0 +1,151 @@ | |||
| 1 | + | :root { | |
| 2 | + | color-scheme: dark light; | |
| 3 | + | --sv-bg: #0f1115; | |
| 4 | + | --sv-fg: #e8ebf0; | |
| 5 | + | --sv-muted: #9aa3b2; | |
| 6 | + | --sv-border: #2a313d; | |
| 7 | + | } | |
| 8 | + | ||
| 9 | + | body { | |
| 10 | + | margin: 0; | |
| 11 | + | font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| 12 | + | font-size: 14px; | |
| 13 | + | background: var(--sv-bg, #0f1115); | |
| 14 | + | color: var(--sv-fg, #e8ebf0); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | .sv-app { | |
| 18 | + | min-height: 100vh; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | .sv-shell { | |
| 22 | + | display: grid; | |
| 23 | + | gap: 1rem; | |
| 24 | + | padding: 1rem; | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | .sv-header { | |
| 28 | + | background: var(--sv-surface-variant, #1d2129); | |
| 29 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 30 | + | border-radius: 0.75rem; | |
| 31 | + | padding: 1rem; | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | .sv-section { | |
| 35 | + | background: var(--sv-surface, #171a21); | |
| 36 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 37 | + | border-radius: 0.75rem; | |
| 38 | + | padding: 1rem; | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | .sv-meta { | |
| 42 | + | color: var(--sv-muted, #9aa3b2); | |
| 43 | + | font-size: 12px; | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | .sv-group { | |
| 47 | + | margin-top: 1rem; | |
| 48 | + | padding: 1rem; | |
| 49 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 50 | + | border-radius: 0.75rem; | |
| 51 | + | background: var(--sv-surface, #171a21); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | .sv-group-header { | |
| 55 | + | display: flex; | |
| 56 | + | justify-content: space-between; | |
| 57 | + | gap: 1rem; | |
| 58 | + | align-items: baseline; | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | .sv-group-header h4 { | |
| 62 | + | margin: 0; | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | .sv-group-body { | |
| 66 | + | display: grid; | |
| 67 | + | gap: 0.75rem; | |
| 68 | + | margin-top: 0.75rem; | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | .sv-binding { | |
| 72 | + | padding-top: 0.75rem; | |
| 73 | + | border-top: 1px solid var(--sv-border, #2a313d); | |
| 74 | + | display: grid; | |
| 75 | + | gap: 0.5rem; | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | .sv-group-body > .sv-binding:first-child { | |
| 79 | + | padding-top: 0; | |
| 80 | + | border-top: 0; | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | .sv-binding-header { | |
| 84 | + | display: flex; | |
| 85 | + | justify-content: space-between; | |
| 86 | + | gap: 1rem; | |
| 87 | + | align-items: flex-start; | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | .sv-binding-description { | |
| 91 | + | font-weight: 600; | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | .sv-binding-keys { | |
| 95 | + | display: flex; | |
| 96 | + | flex-wrap: wrap; | |
| 97 | + | gap: 0.35rem; | |
| 98 | + | justify-content: flex-end; | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | .sv-key { | |
| 102 | + | display: inline-flex; | |
| 103 | + | align-items: center; | |
| 104 | + | padding: 0.2rem 0.45rem; | |
| 105 | + | border-radius: 0.45rem; | |
| 106 | + | border: 1px solid var(--sv-border, #2a313d); | |
| 107 | + | background: var(--sv-surface-variant, #1d2129); | |
| 108 | + | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | |
| 109 | + | font-size: 12px; | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | .sv-binding-meta { | |
| 113 | + | display: flex; | |
| 114 | + | flex-wrap: wrap; | |
| 115 | + | gap: 0.35rem 1rem; | |
| 116 | + | color: var(--sv-muted, #9aa3b2); | |
| 117 | + | font-size: 12px; | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | .sv-empty { | |
| 121 | + | color: var(--sv-muted, #9aa3b2); | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | .sv-kv { | |
| 125 | + | margin: 0.75rem 0 0; | |
| 126 | + | display: grid; | |
| 127 | + | gap: 0.25rem; | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | .sv-kv-row { | |
| 131 | + | display: grid; | |
| 132 | + | grid-template-columns: minmax(12rem, 35%) minmax(0, 1fr); | |
| 133 | + | gap: 0.25rem 1rem; | |
| 134 | + | padding: 0.5rem 0; | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | .sv-kv-row + .sv-kv-row { | |
| 138 | + | border-top: 1px solid var(--sv-border, #2a313d); | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | .sv-kv-row dt, | |
| 142 | + | .sv-kv-row dd { | |
| 143 | + | margin: 0; | |
| 144 | + | min-width: 0; | |
| 145 | + | overflow-wrap: anywhere; | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | .sv-kv-row dt { | |
| 149 | + | color: var(--sv-muted, #9aa3b2); | |
| 150 | + | font-weight: 600; | |
| 151 | + | } | |
| @@ -0,0 +1,151 @@ | |||||
| 1 | + | :root { | |||
| 2 | + | color-scheme: dark light; | |||
| 3 | + | --sv-bg: #0f1115; | |||
| 4 | + | --sv-fg: #e8ebf0; | |||
| 5 | + | --sv-muted: #9aa3b2; | |||
| 6 | + | --sv-border: #2a313d; | |||
| 7 | + | } | |||
| 8 | + | ||||
| 9 | + | body { | |||
| 10 | + | margin: 0; | |||
| 11 | + | font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |||
| 12 | + | font-size: 14px; | |||
| 13 | + | background: var(--sv-bg, #0f1115); | |||
| 14 | + | color: var(--sv-fg, #e8ebf0); | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | .sv-app { | |||
| 18 | + | min-height: 100vh; | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | .sv-shell { | |||
| 22 | + | display: grid; | |||
| 23 | + | gap: 1rem; | |||
| 24 | + | padding: 1rem; | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | .sv-header { | |||
| 28 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 29 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 30 | + | border-radius: 0.75rem; | |||
| 31 | + | padding: 1rem; | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | .sv-section { | |||
| 35 | + | background: var(--sv-surface, #171a21); | |||
| 36 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 37 | + | border-radius: 0.75rem; | |||
| 38 | + | padding: 1rem; | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | .sv-meta { | |||
| 42 | + | color: var(--sv-muted, #9aa3b2); | |||
| 43 | + | font-size: 12px; | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | .sv-group { | |||
| 47 | + | margin-top: 1rem; | |||
| 48 | + | padding: 1rem; | |||
| 49 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 50 | + | border-radius: 0.75rem; | |||
| 51 | + | background: var(--sv-surface, #171a21); | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | .sv-group-header { | |||
| 55 | + | display: flex; | |||
| 56 | + | justify-content: space-between; | |||
| 57 | + | gap: 1rem; | |||
| 58 | + | align-items: baseline; | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | .sv-group-header h4 { | |||
| 62 | + | margin: 0; | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | .sv-group-body { | |||
| 66 | + | display: grid; | |||
| 67 | + | gap: 0.75rem; | |||
| 68 | + | margin-top: 0.75rem; | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | .sv-binding { | |||
| 72 | + | padding-top: 0.75rem; | |||
| 73 | + | border-top: 1px solid var(--sv-border, #2a313d); | |||
| 74 | + | display: grid; | |||
| 75 | + | gap: 0.5rem; | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | .sv-group-body > .sv-binding:first-child { | |||
| 79 | + | padding-top: 0; | |||
| 80 | + | border-top: 0; | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | .sv-binding-header { | |||
| 84 | + | display: flex; | |||
| 85 | + | justify-content: space-between; | |||
| 86 | + | gap: 1rem; | |||
| 87 | + | align-items: flex-start; | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | .sv-binding-description { | |||
| 91 | + | font-weight: 600; | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | .sv-binding-keys { | |||
| 95 | + | display: flex; | |||
| 96 | + | flex-wrap: wrap; | |||
| 97 | + | gap: 0.35rem; | |||
| 98 | + | justify-content: flex-end; | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | .sv-key { | |||
| 102 | + | display: inline-flex; | |||
| 103 | + | align-items: center; | |||
| 104 | + | padding: 0.2rem 0.45rem; | |||
| 105 | + | border-radius: 0.45rem; | |||
| 106 | + | border: 1px solid var(--sv-border, #2a313d); | |||
| 107 | + | background: var(--sv-surface-variant, #1d2129); | |||
| 108 | + | font-family: ui-monospace, SFMono-Regular, Menlo, monospace; | |||
| 109 | + | font-size: 12px; | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | .sv-binding-meta { | |||
| 113 | + | display: flex; | |||
| 114 | + | flex-wrap: wrap; | |||
| 115 | + | gap: 0.35rem 1rem; | |||
| 116 | + | color: var(--sv-muted, #9aa3b2); | |||
| 117 | + | font-size: 12px; | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | .sv-empty { | |||
| 121 | + | color: var(--sv-muted, #9aa3b2); | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | .sv-kv { | |||
| 125 | + | margin: 0.75rem 0 0; | |||
| 126 | + | display: grid; | |||
| 127 | + | gap: 0.25rem; | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | .sv-kv-row { | |||
| 131 | + | display: grid; | |||
| 132 | + | grid-template-columns: minmax(12rem, 35%) minmax(0, 1fr); | |||
| 133 | + | gap: 0.25rem 1rem; | |||
| 134 | + | padding: 0.5rem 0; | |||
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | .sv-kv-row + .sv-kv-row { | |||
| 138 | + | border-top: 1px solid var(--sv-border, #2a313d); | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | .sv-kv-row dt, | |||
| 142 | + | .sv-kv-row dd { | |||
| 143 | + | margin: 0; | |||
| 144 | + | min-width: 0; | |||
| 145 | + | overflow-wrap: anywhere; | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | .sv-kv-row dt { | |||
| 149 | + | color: var(--sv-muted, #9aa3b2); | |||
| 150 | + | font-weight: 600; | |||
| 151 | + | } | |||
A cmd/systemviews/bootstrap.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/bnema/dumber/internal/ui/systemviews" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type bridgeServices interface { | |
| 11 | + | port.SystemviewHistoryService | |
| 12 | + | port.SystemviewFavoritesService | |
| 13 | + | port.SystemviewConfigService | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func newBridgeApp(dom systemviews.DOM, locationURI string, bridge bridgeServices) *systemviews.App { | |
| 17 | + | return systemviews.NewApp(systemviews.Dependencies{ | |
| 18 | + | DOM: dom, | |
| 19 | + | History: bridge, | |
| 20 | + | Favorites: bridge, | |
| 21 | + | Config: bridgeConfigProxy{bridge: bridge, route: systemviews.ParseRoute(locationURI)}, | |
| 22 | + | LocationURI: locationURI, | |
| 23 | + | }) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | type bridgeConfigProxy struct { | |
| 27 | + | bridge bridgeServices | |
| 28 | + | route systemviews.Route | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func (p bridgeConfigProxy) Current(ctx context.Context) (port.SystemviewConfigPayload, error) { | |
| 32 | + | return p.bridge.Current(ctx) | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func (p bridgeConfigProxy) Default(ctx context.Context) (port.SystemviewConfigPayload, error) { | |
| 36 | + | return p.bridge.Default(ctx) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func (p bridgeConfigProxy) Save(ctx context.Context, cfg port.WebUIConfig) error { | |
| 40 | + | return p.bridge.Save(ctx, cfg) | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (any, error) { | |
| 44 | + | if p.route != systemviews.RouteConfig { | |
| 45 | + | return nil, nil | |
| 46 | + | } | |
| 47 | + | return p.bridge.GetKeybindings(ctx) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func (p bridgeConfigProxy) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (any, error) { | |
| 51 | + | return p.bridge.SetKeybinding(ctx, req) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func (p bridgeConfigProxy) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error { | |
| 55 | + | return p.bridge.ResetKeybinding(ctx, req) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func (p bridgeConfigProxy) ResetAllKeybindings(ctx context.Context) error { | |
| 59 | + | return p.bridge.ResetAllKeybindings(ctx) | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | "github.com/bnema/dumber/internal/ui/systemviews" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type bridgeServices interface { | |||
| 11 | + | port.SystemviewHistoryService | |||
| 12 | + | port.SystemviewFavoritesService | |||
| 13 | + | port.SystemviewConfigService | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func newBridgeApp(dom systemviews.DOM, locationURI string, bridge bridgeServices) *systemviews.App { | |||
| 17 | + | return systemviews.NewApp(systemviews.Dependencies{ | |||
| 18 | + | DOM: dom, | |||
| 19 | + | History: bridge, | |||
| 20 | + | Favorites: bridge, | |||
| 21 | + | Config: bridgeConfigProxy{bridge: bridge, route: systemviews.ParseRoute(locationURI)}, | |||
| 22 | + | LocationURI: locationURI, | |||
| 23 | + | }) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | type bridgeConfigProxy struct { | |||
| 27 | + | bridge bridgeServices | |||
| 28 | + | route systemviews.Route | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (p bridgeConfigProxy) Current(ctx context.Context) (port.SystemviewConfigPayload, error) { | |||
| 32 | + | return p.bridge.Current(ctx) | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func (p bridgeConfigProxy) Default(ctx context.Context) (port.SystemviewConfigPayload, error) { | |||
| 36 | + | return p.bridge.Default(ctx) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func (p bridgeConfigProxy) Save(ctx context.Context, cfg port.WebUIConfig) error { | |||
| 40 | + | return p.bridge.Save(ctx, cfg) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func (p bridgeConfigProxy) GetKeybindings(ctx context.Context) (any, error) { | |||
| 44 | + | if p.route != systemviews.RouteConfig { | |||
| 45 | + | return nil, nil | |||
| 46 | + | } | |||
| 47 | + | return p.bridge.GetKeybindings(ctx) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | func (p bridgeConfigProxy) SetKeybinding(ctx context.Context, req port.SetKeybindingRequest) (any, error) { | |||
| 51 | + | return p.bridge.SetKeybinding(ctx, req) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func (p bridgeConfigProxy) ResetKeybinding(ctx context.Context, req port.ResetKeybindingRequest) error { | |||
| 55 | + | return p.bridge.ResetKeybinding(ctx, req) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func (p bridgeConfigProxy) ResetAllKeybindings(ctx context.Context) error { | |||
| 59 | + | return p.bridge.ResetAllKeybindings(ctx) | |||
| 60 | + | } | |||
A cmd/systemviews/bootstrap_test.go +197 -0
| @@ -0,0 +1,197 @@ | |||
| 1 | + | package main | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestNewBridgeApp_WiresHistoryService(t *testing.T) { | |
| 14 | + | t.Parallel() | |
| 15 | + | ||
| 16 | + | bridge := &fakeBridgeService{historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}}} | |
| 17 | + | app := newBridgeApp(&fakeDOM{}, "dumb://history", bridge) | |
| 18 | + | ||
| 19 | + | require.NoError(t, app.LoadInitial(context.Background())) | |
| 20 | + | assert.True(t, bridge.calledHistory) | |
| 21 | + | assert.False(t, bridge.calledFavorites) | |
| 22 | + | assert.True(t, bridge.calledConfig) | |
| 23 | + | assert.False(t, bridge.calledKeybindings) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) { | |
| 27 | + | t.Parallel() | |
| 28 | + | ||
| 29 | + | bridge := &fakeBridgeService{ | |
| 30 | + | historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}}, | |
| 31 | + | currentConfig: port.SystemviewConfigPayload{ | |
| 32 | + | Appearance: port.WebUIAppearanceConfig{ | |
| 33 | + | ColorScheme: "prefer-dark", | |
| 34 | + | DarkPalette: port.ColorPalette{ | |
| 35 | + | Background: "#111111", | |
| 36 | + | Surface: "#1a1a1a", | |
| 37 | + | SurfaceVariant: "#2a2a2a", | |
| 38 | + | Text: "#f5f5f5", | |
| 39 | + | Muted: "#a0a0a0", | |
| 40 | + | Accent: "#66aaff", | |
| 41 | + | Border: "#333333", | |
| 42 | + | }, | |
| 43 | + | }, | |
| 44 | + | }, | |
| 45 | + | } | |
| 46 | + | dom := &fakeDOM{} | |
| 47 | + | app := newBridgeApp(dom, "dumb://history", bridge) | |
| 48 | + | ||
| 49 | + | require.NoError(t, app.Run()) | |
| 50 | + | assert.True(t, bridge.calledHistory) | |
| 51 | + | assert.True(t, bridge.calledConfig) | |
| 52 | + | assert.False(t, bridge.calledKeybindings) | |
| 53 | + | assert.Contains(t, dom.html, `class="sv-app sv-dark"`) | |
| 54 | + | assert.Contains(t, dom.html, `--sv-background: #111111;`) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestNewBridgeApp_WiresFavoritesService(t *testing.T) { | |
| 58 | + | t.Parallel() | |
| 59 | + | ||
| 60 | + | bridge := &fakeBridgeService{ | |
| 61 | + | favorites: []*entity.Favorite{{Title: "Example", URL: "https://example.com"}}, | |
| 62 | + | folders: []*entity.Folder{{Name: "Read Later"}}, | |
| 63 | + | tags: []*entity.Tag{{Name: "Go"}}, | |
| 64 | + | } | |
| 65 | + | app := newBridgeApp(&fakeDOM{}, "dumb://favorites", bridge) | |
| 66 | + | ||
| 67 | + | require.NoError(t, app.LoadInitial(context.Background())) | |
| 68 | + | assert.False(t, bridge.calledHistory) | |
| 69 | + | assert.True(t, bridge.calledFavorites) | |
| 70 | + | assert.True(t, bridge.calledConfig) | |
| 71 | + | assert.False(t, bridge.calledKeybindings) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func TestNewBridgeApp_WiresConfigService(t *testing.T) { | |
| 75 | + | t.Parallel() | |
| 76 | + | ||
| 77 | + | bridge := &fakeBridgeService{ | |
| 78 | + | currentConfig: port.SystemviewConfigPayload{EngineType: "webkit"}, | |
| 79 | + | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{DisplayName: "Default"}}}, | |
| 80 | + | } | |
| 81 | + | app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge) | |
| 82 | + | ||
| 83 | + | require.NoError(t, app.LoadInitial(context.Background())) | |
| 84 | + | assert.False(t, bridge.calledHistory) | |
| 85 | + | assert.False(t, bridge.calledFavorites) | |
| 86 | + | assert.True(t, bridge.calledConfig) | |
| 87 | + | assert.True(t, bridge.calledKeybindings) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | type fakeDOM struct { | |
| 91 | + | html string | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (f *fakeDOM) Mount(html string) error { | |
| 95 | + | f.html = html | |
| 96 | + | return nil | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | type fakeBridgeService struct { | |
| 100 | + | calledHistory bool | |
| 101 | + | calledFavorites bool | |
| 102 | + | calledConfig bool | |
| 103 | + | calledKeybindings bool | |
| 104 | + | ||
| 105 | + | historyEntries []*entity.HistoryEntry | |
| 106 | + | favorites []*entity.Favorite | |
| 107 | + | folders []*entity.Folder | |
| 108 | + | tags []*entity.Tag | |
| 109 | + | currentConfig port.SystemviewConfigPayload | |
| 110 | + | keybindings any | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) { | |
| 114 | + | f.calledHistory = true | |
| 115 | + | return f.historyEntries, nil | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) { | |
| 119 | + | return nil, nil | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | func (*fakeBridgeService) DeleteEntry(context.Context, int64) error { return nil } | |
| 123 | + | ||
| 124 | + | func (*fakeBridgeService) DeleteRange(context.Context, string) error { return nil } | |
| 125 | + | ||
| 126 | + | func (*fakeBridgeService) Analytics(context.Context) (*entity.HistoryAnalytics, error) { | |
| 127 | + | return nil, nil | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func (*fakeBridgeService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) { | |
| 131 | + | return nil, nil | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | func (*fakeBridgeService) DeleteDomain(context.Context, string) error { return nil } | |
| 135 | + | ||
| 136 | + | func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) { | |
| 137 | + | f.calledFavorites = true | |
| 138 | + | return f.favorites, nil | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (f *fakeBridgeService) ListFolders(context.Context) ([]*entity.Folder, error) { | |
| 142 | + | return f.folders, nil | |
| 143 | + | } | |
| 144 | + | ||
| 145 | + | func (f *fakeBridgeService) ListTags(context.Context) ([]*entity.Tag, error) { | |
| 146 | + | return f.tags, nil | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func (*fakeBridgeService) SetShortcut(context.Context, int64, *int) error { return nil } | |
| 150 | + | ||
| 151 | + | func (*fakeBridgeService) SetFolder(context.Context, int64, *int64) error { return nil } | |
| 152 | + | ||
| 153 | + | func (*fakeBridgeService) CreateFolder(context.Context, string, *int64) (*entity.Folder, error) { | |
| 154 | + | return nil, nil | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | func (*fakeBridgeService) UpdateFolder(context.Context, int64, string, string) error { return nil } | |
| 158 | + | ||
| 159 | + | func (*fakeBridgeService) DeleteFolder(context.Context, int64) error { return nil } | |
| 160 | + | ||
| 161 | + | func (*fakeBridgeService) CreateTag(context.Context, string, string) (*entity.Tag, error) { | |
| 162 | + | return nil, nil | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (*fakeBridgeService) UpdateTag(context.Context, int64, string, string) error { return nil } | |
| 166 | + | ||
| 167 | + | func (*fakeBridgeService) DeleteTag(context.Context, int64) error { return nil } | |
| 168 | + | ||
| 169 | + | func (*fakeBridgeService) AssignTag(context.Context, int64, int64) error { return nil } | |
| 170 | + | ||
| 171 | + | func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil } | |
| 172 | + | ||
| 173 | + | func (f *fakeBridgeService) Current(context.Context) (port.SystemviewConfigPayload, error) { | |
| 174 | + | f.calledConfig = true | |
| 175 | + | return f.currentConfig, nil | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | func (*fakeBridgeService) Default(context.Context) (port.SystemviewConfigPayload, error) { | |
| 179 | + | return port.SystemviewConfigPayload{}, nil | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | func (*fakeBridgeService) Save(context.Context, port.WebUIConfig) error { return nil } | |
| 183 | + | ||
| 184 | + | func (f *fakeBridgeService) GetKeybindings(context.Context) (any, error) { | |
| 185 | + | f.calledKeybindings = true | |
| 186 | + | return f.keybindings, nil | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | func (*fakeBridgeService) SetKeybinding(context.Context, port.SetKeybindingRequest) (any, error) { | |
| 190 | + | return nil, nil | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | func (*fakeBridgeService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error { | |
| 194 | + | return nil | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | func (*fakeBridgeService) ResetAllKeybindings(context.Context) error { return nil } | |
| @@ -0,0 +1,197 @@ | |||||
| 1 | + | package main | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestNewBridgeApp_WiresHistoryService(t *testing.T) { | |||
| 14 | + | t.Parallel() | |||
| 15 | + | ||||
| 16 | + | bridge := &fakeBridgeService{historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}}} | |||
| 17 | + | app := newBridgeApp(&fakeDOM{}, "dumb://history", bridge) | |||
| 18 | + | ||||
| 19 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 20 | + | assert.True(t, bridge.calledHistory) | |||
| 21 | + | assert.False(t, bridge.calledFavorites) | |||
| 22 | + | assert.True(t, bridge.calledConfig) | |||
| 23 | + | assert.False(t, bridge.calledKeybindings) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) { | |||
| 27 | + | t.Parallel() | |||
| 28 | + | ||||
| 29 | + | bridge := &fakeBridgeService{ | |||
| 30 | + | historyEntries: []*entity.HistoryEntry{{Title: "Example", URL: "https://example.com"}}, | |||
| 31 | + | currentConfig: port.SystemviewConfigPayload{ | |||
| 32 | + | Appearance: port.WebUIAppearanceConfig{ | |||
| 33 | + | ColorScheme: "prefer-dark", | |||
| 34 | + | DarkPalette: port.ColorPalette{ | |||
| 35 | + | Background: "#111111", | |||
| 36 | + | Surface: "#1a1a1a", | |||
| 37 | + | SurfaceVariant: "#2a2a2a", | |||
| 38 | + | Text: "#f5f5f5", | |||
| 39 | + | Muted: "#a0a0a0", | |||
| 40 | + | Accent: "#66aaff", | |||
| 41 | + | Border: "#333333", | |||
| 42 | + | }, | |||
| 43 | + | }, | |||
| 44 | + | }, | |||
| 45 | + | } | |||
| 46 | + | dom := &fakeDOM{} | |||
| 47 | + | app := newBridgeApp(dom, "dumb://history", bridge) | |||
| 48 | + | ||||
| 49 | + | require.NoError(t, app.Run()) | |||
| 50 | + | assert.True(t, bridge.calledHistory) | |||
| 51 | + | assert.True(t, bridge.calledConfig) | |||
| 52 | + | assert.False(t, bridge.calledKeybindings) | |||
| 53 | + | assert.Contains(t, dom.html, `class="sv-app sv-dark"`) | |||
| 54 | + | assert.Contains(t, dom.html, `--sv-background: #111111;`) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestNewBridgeApp_WiresFavoritesService(t *testing.T) { | |||
| 58 | + | t.Parallel() | |||
| 59 | + | ||||
| 60 | + | bridge := &fakeBridgeService{ | |||
| 61 | + | favorites: []*entity.Favorite{{Title: "Example", URL: "https://example.com"}}, | |||
| 62 | + | folders: []*entity.Folder{{Name: "Read Later"}}, | |||
| 63 | + | tags: []*entity.Tag{{Name: "Go"}}, | |||
| 64 | + | } | |||
| 65 | + | app := newBridgeApp(&fakeDOM{}, "dumb://favorites", bridge) | |||
| 66 | + | ||||
| 67 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 68 | + | assert.False(t, bridge.calledHistory) | |||
| 69 | + | assert.True(t, bridge.calledFavorites) | |||
| 70 | + | assert.True(t, bridge.calledConfig) | |||
| 71 | + | assert.False(t, bridge.calledKeybindings) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestNewBridgeApp_WiresConfigService(t *testing.T) { | |||
| 75 | + | t.Parallel() | |||
| 76 | + | ||||
| 77 | + | bridge := &fakeBridgeService{ | |||
| 78 | + | currentConfig: port.SystemviewConfigPayload{EngineType: "webkit"}, | |||
| 79 | + | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{DisplayName: "Default"}}}, | |||
| 80 | + | } | |||
| 81 | + | app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge) | |||
| 82 | + | ||||
| 83 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 84 | + | assert.False(t, bridge.calledHistory) | |||
| 85 | + | assert.False(t, bridge.calledFavorites) | |||
| 86 | + | assert.True(t, bridge.calledConfig) | |||
| 87 | + | assert.True(t, bridge.calledKeybindings) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | type fakeDOM struct { | |||
| 91 | + | html string | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func (f *fakeDOM) Mount(html string) error { | |||
| 95 | + | f.html = html | |||
| 96 | + | return nil | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | type fakeBridgeService struct { | |||
| 100 | + | calledHistory bool | |||
| 101 | + | calledFavorites bool | |||
| 102 | + | calledConfig bool | |||
| 103 | + | calledKeybindings bool | |||
| 104 | + | ||||
| 105 | + | historyEntries []*entity.HistoryEntry | |||
| 106 | + | favorites []*entity.Favorite | |||
| 107 | + | folders []*entity.Folder | |||
| 108 | + | tags []*entity.Tag | |||
| 109 | + | currentConfig port.SystemviewConfigPayload | |||
| 110 | + | keybindings any | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) { | |||
| 114 | + | f.calledHistory = true | |||
| 115 | + | return f.historyEntries, nil | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func (*fakeBridgeService) Search(context.Context, string, int) ([]*entity.HistoryEntry, error) { | |||
| 119 | + | return nil, nil | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | func (*fakeBridgeService) DeleteEntry(context.Context, int64) error { return nil } | |||
| 123 | + | ||||
| 124 | + | func (*fakeBridgeService) DeleteRange(context.Context, string) error { return nil } | |||
| 125 | + | ||||
| 126 | + | func (*fakeBridgeService) Analytics(context.Context) (*entity.HistoryAnalytics, error) { | |||
| 127 | + | return nil, nil | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func (*fakeBridgeService) DomainStats(context.Context, int) ([]*entity.DomainStat, error) { | |||
| 131 | + | return nil, nil | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | func (*fakeBridgeService) DeleteDomain(context.Context, string) error { return nil } | |||
| 135 | + | ||||
| 136 | + | func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) { | |||
| 137 | + | f.calledFavorites = true | |||
| 138 | + | return f.favorites, nil | |||
| 139 | + | } | |||
| 140 | + | ||||
| 141 | + | func (f *fakeBridgeService) ListFolders(context.Context) ([]*entity.Folder, error) { | |||
| 142 | + | return f.folders, nil | |||
| 143 | + | } | |||
| 144 | + | ||||
| 145 | + | func (f *fakeBridgeService) ListTags(context.Context) ([]*entity.Tag, error) { | |||
| 146 | + | return f.tags, nil | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func (*fakeBridgeService) SetShortcut(context.Context, int64, *int) error { return nil } | |||
| 150 | + | ||||
| 151 | + | func (*fakeBridgeService) SetFolder(context.Context, int64, *int64) error { return nil } | |||
| 152 | + | ||||
| 153 | + | func (*fakeBridgeService) CreateFolder(context.Context, string, *int64) (*entity.Folder, error) { | |||
| 154 | + | return nil, nil | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | func (*fakeBridgeService) UpdateFolder(context.Context, int64, string, string) error { return nil } | |||
| 158 | + | ||||
| 159 | + | func (*fakeBridgeService) DeleteFolder(context.Context, int64) error { return nil } | |||
| 160 | + | ||||
| 161 | + | func (*fakeBridgeService) CreateTag(context.Context, string, string) (*entity.Tag, error) { | |||
| 162 | + | return nil, nil | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (*fakeBridgeService) UpdateTag(context.Context, int64, string, string) error { return nil } | |||
| 166 | + | ||||
| 167 | + | func (*fakeBridgeService) DeleteTag(context.Context, int64) error { return nil } | |||
| 168 | + | ||||
| 169 | + | func (*fakeBridgeService) AssignTag(context.Context, int64, int64) error { return nil } | |||
| 170 | + | ||||
| 171 | + | func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil } | |||
| 172 | + | ||||
| 173 | + | func (f *fakeBridgeService) Current(context.Context) (port.SystemviewConfigPayload, error) { | |||
| 174 | + | f.calledConfig = true | |||
| 175 | + | return f.currentConfig, nil | |||
| 176 | + | } | |||
| 177 | + | ||||
| 178 | + | func (*fakeBridgeService) Default(context.Context) (port.SystemviewConfigPayload, error) { | |||
| 179 | + | return port.SystemviewConfigPayload{}, nil | |||
| 180 | + | } | |||
| 181 | + | ||||
| 182 | + | func (*fakeBridgeService) Save(context.Context, port.WebUIConfig) error { return nil } | |||
| 183 | + | ||||
| 184 | + | func (f *fakeBridgeService) GetKeybindings(context.Context) (any, error) { | |||
| 185 | + | f.calledKeybindings = true | |||
| 186 | + | return f.keybindings, nil | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | func (*fakeBridgeService) SetKeybinding(context.Context, port.SetKeybindingRequest) (any, error) { | |||
| 190 | + | return nil, nil | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | func (*fakeBridgeService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error { | |||
| 194 | + | return nil | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | func (*fakeBridgeService) ResetAllKeybindings(context.Context) error { return nil } | |||
M cmd/systemviews/main.go +3 -4
| @@ -5,14 +5,13 @@ package main | |||
| 5 | 5 | import ( | |
| 6 | 6 | "syscall/js" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/systemviewsbridge" | |
| 8 | 9 | "github.com/bnema/dumber/internal/ui/systemviews" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | func main() { | |
| 12 | - | app := systemviews.NewApp(systemviews.Dependencies{ | |
| 13 | - | DOM: systemviews.NewDOM(), | |
| 14 | - | LocationURI: js.Global().Get("location").Get("href").String(), | |
| 15 | - | }) | |
| 13 | + | bridge := systemviewsbridge.NewBrowserClient() | |
| 14 | + | app := newBridgeApp(systemviews.NewDOM(), js.Global().Get("location").Get("href").String(), bridge) | |
| 16 | 15 | ||
| 17 | 16 | if err := app.Run(); err != nil { | |
| 18 | 17 | js.Global().Get("console").Call("error", err.Error()) | |
| @@ -5,14 +5,13 @@ package main | |||||
| 5 | import ( | 5 | import ( | ||
| 6 | "syscall/js" | 6 | "syscall/js" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/systemviewsbridge" | |||
| 8 | "github.com/bnema/dumber/internal/ui/systemviews" | 9 | "github.com/bnema/dumber/internal/ui/systemviews" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | func main() { | 12 | func main() { | ||
| 12 | - | app := systemviews.NewApp(systemviews.Dependencies{ | 13 | + | bridge := systemviewsbridge.NewBrowserClient() |
| 13 | - | DOM: systemviews.NewDOM(), | 14 | + | app := newBridgeApp(systemviews.NewDOM(), js.Global().Get("location").Get("href").String(), bridge) |
| 14 | - | LocationURI: js.Global().Get("location").Get("href").String(), | |||
| 15 | - | }) | |||
| 16 | 15 | ||||
| 17 | if err := app.Run(); err != nil { | 16 | if err := app.Run(); err != nil { | ||
| 18 | js.Global().Get("console").Call("error", err.Error()) | 17 | js.Global().Get("console").Call("error", err.Error()) | ||
M internal/application/usecase/search_history.go +4 -1
| @@ -157,7 +157,10 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time. | |||
| 157 | 157 | ||
| 158 | 158 | // ClearRange deletes history entries for a named range. | |
| 159 | 159 | func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error { | |
| 160 | - | cutoff, all := historydomain.DeleteRangeCutoff(rangeID, time.Now()) | |
| 160 | + | cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now()) | |
| 161 | + | if !ok { | |
| 162 | + | return fmt.Errorf("unknown history delete range: %q", rangeID) | |
| 163 | + | } | |
| 161 | 164 | if all { | |
| 162 | 165 | return uc.ClearAll(ctx) | |
| 163 | 166 | } | |
| @@ -157,7 +157,10 @@ func (uc *SearchHistoryUseCase) ClearOlderThan(ctx context.Context, before time. | |||||
| 157 | 157 | ||||
| 158 | // ClearRange deletes history entries for a named range. | 158 | // ClearRange deletes history entries for a named range. | ||
| 159 | func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error { | 159 | func (uc *SearchHistoryUseCase) ClearRange(ctx context.Context, rangeID string) error { | ||
| 160 | - | cutoff, all := historydomain.DeleteRangeCutoff(rangeID, time.Now()) | 160 | + | cutoff, all, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now()) |
| 161 | + | if !ok { | |||
| 162 | + | return fmt.Errorf("unknown history delete range: %q", rangeID) | |||
| 163 | + | } | |||
| 161 | if all { | 164 | if all { | ||
| 162 | return uc.ClearAll(ctx) | 165 | return uc.ClearAll(ctx) | ||
| 163 | } | 166 | } | ||
M internal/application/usecase/search_history_test.go +12 -0
| @@ -164,3 +164,15 @@ func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) { | |||
| 164 | 164 | err := uc.ClearRange(ctx, "all") | |
| 165 | 165 | require.NoError(t, err) | |
| 166 | 166 | } | |
| 167 | + | ||
| 168 | + | func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T) { | |
| 169 | + | ctx := testContext() | |
| 170 | + | ||
| 171 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |
| 172 | + | ||
| 173 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |
| 174 | + | ||
| 175 | + | err := uc.ClearRange(ctx, "bogus") | |
| 176 | + | require.Error(t, err) | |
| 177 | + | assert.Contains(t, err.Error(), "unknown history delete range") | |
| 178 | + | } | |
| @@ -164,3 +164,15 @@ func TestSearchHistoryUseCase_ClearRange_AllUsesClearAll(t *testing.T) { | |||||
| 164 | err := uc.ClearRange(ctx, "all") | 164 | err := uc.ClearRange(ctx, "all") | ||
| 165 | require.NoError(t, err) | 165 | require.NoError(t, err) | ||
| 166 | } | 166 | } | ||
| 167 | + | ||||
| 168 | + | func TestSearchHistoryUseCase_ClearRange_UnknownRangeDoesNotDelete(t *testing.T) { | |||
| 169 | + | ctx := testContext() | |||
| 170 | + | ||||
| 171 | + | historyRepo := repomocks.NewMockHistoryRepository(t) | |||
| 172 | + | ||||
| 173 | + | uc := usecase.NewSearchHistoryUseCase(historyRepo) | |||
| 174 | + | ||||
| 175 | + | err := uc.ClearRange(ctx, "bogus") | |||
| 176 | + | require.Error(t, err) | |||
| 177 | + | assert.Contains(t, err.Error(), "unknown history delete range") | |||
| 178 | + | } | |||
M internal/domain/history/delete_range.go +9 -8
| @@ -3,20 +3,21 @@ package history | |||
| 3 | 3 | import "time" | |
| 4 | 4 | ||
| 5 | 5 | // DeleteRangeCutoff resolves a delete-range identifier to a cutoff time. | |
| 6 | - | // The boolean reports whether the range means "delete all". | |
| 7 | - | func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool) { | |
| 6 | + | // The booleans report whether the range means "delete all" and whether the | |
| 7 | + | // identifier was recognized. | |
| 8 | + | func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool, bool) { | |
| 8 | 9 | switch rangeID { | |
| 9 | 10 | case "hour": | |
| 10 | - | return now.Add(-time.Hour), false | |
| 11 | + | return now.Add(-time.Hour), false, true | |
| 11 | 12 | case "day": | |
| 12 | - | return now.AddDate(0, 0, -1), false | |
| 13 | + | return now.AddDate(0, 0, -1), false, true | |
| 13 | 14 | case "week": | |
| 14 | - | return now.AddDate(0, 0, -7), false | |
| 15 | + | return now.AddDate(0, 0, -7), false, true | |
| 15 | 16 | case "month": | |
| 16 | - | return now.AddDate(0, -1, 0), false | |
| 17 | + | return now.AddDate(0, -1, 0), false, true | |
| 17 | 18 | case "all": | |
| 18 | - | return time.Time{}, true | |
| 19 | + | return time.Time{}, true, true | |
| 19 | 20 | default: | |
| 20 | - | return now, false | |
| 21 | + | return time.Time{}, false, false | |
| 21 | 22 | } | |
| 22 | 23 | } | |
| @@ -3,20 +3,21 @@ package history | |||||
| 3 | import "time" | 3 | import "time" | ||
| 4 | 4 | ||||
| 5 | // DeleteRangeCutoff resolves a delete-range identifier to a cutoff time. | 5 | // DeleteRangeCutoff resolves a delete-range identifier to a cutoff time. | ||
| 6 | - | // The boolean reports whether the range means "delete all". | 6 | + | // The booleans report whether the range means "delete all" and whether the |
| 7 | - | func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool) { | 7 | + | // identifier was recognized. |
| 8 | + | func DeleteRangeCutoff(rangeID string, now time.Time) (time.Time, bool, bool) { | |||
| 8 | switch rangeID { | 9 | switch rangeID { | ||
| 9 | case "hour": | 10 | case "hour": | ||
| 10 | - | return now.Add(-time.Hour), false | 11 | + | return now.Add(-time.Hour), false, true |
| 11 | case "day": | 12 | case "day": | ||
| 12 | - | return now.AddDate(0, 0, -1), false | 13 | + | return now.AddDate(0, 0, -1), false, true |
| 13 | case "week": | 14 | case "week": | ||
| 14 | - | return now.AddDate(0, 0, -7), false | 15 | + | return now.AddDate(0, 0, -7), false, true |
| 15 | case "month": | 16 | case "month": | ||
| 16 | - | return now.AddDate(0, -1, 0), false | 17 | + | return now.AddDate(0, -1, 0), false, true |
| 17 | case "all": | 18 | case "all": | ||
| 18 | - | return time.Time{}, true | 19 | + | return time.Time{}, true, true |
| 19 | default: | 20 | default: | ||
| 20 | - | return now, false | 21 | + | return time.Time{}, false, false |
| 21 | } | 22 | } | ||
| 22 | } | 23 | } | ||
M internal/domain/history/delete_range_test.go +11 -6
| @@ -13,23 +13,28 @@ func TestDeleteRangeCutoff(t *testing.T) { | |||
| 13 | 13 | rangeID string | |
| 14 | 14 | want time.Time | |
| 15 | 15 | wantAll bool | |
| 16 | + | wantOK bool | |
| 16 | 17 | }{ | |
| 17 | - | {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false}, | |
| 18 | - | {name: "day", rangeID: "day", want: now.AddDate(0, 0, -1), wantAll: false}, | |
| 19 | - | {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false}, | |
| 20 | - | {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false}, | |
| 21 | - | {name: "all", rangeID: "all", want: time.Time{}, wantAll: true}, | |
| 18 | + | {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false, wantOK: true}, | |
| 19 | + | {name: "day", rangeID: "day", want: now.AddDate(0, 0, -1), wantAll: false, wantOK: true}, | |
| 20 | + | {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false, wantOK: true}, | |
| 21 | + | {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false, wantOK: true}, | |
| 22 | + | {name: "all", rangeID: "all", want: time.Time{}, wantAll: true, wantOK: true}, | |
| 23 | + | {name: "unknown", rangeID: "bogus", want: time.Time{}, wantAll: false, wantOK: false}, | |
| 22 | 24 | } | |
| 23 | 25 | ||
| 24 | 26 | for _, tt := range tests { | |
| 25 | 27 | t.Run(tt.name, func(t *testing.T) { | |
| 26 | - | got, gotAll := DeleteRangeCutoff(tt.rangeID, now) | |
| 28 | + | got, gotAll, gotOK := DeleteRangeCutoff(tt.rangeID, now) | |
| 27 | 29 | if !got.Equal(tt.want) { | |
| 28 | 30 | t.Fatalf("DeleteRangeCutoff() cutoff = %v, want %v", got, tt.want) | |
| 29 | 31 | } | |
| 30 | 32 | if gotAll != tt.wantAll { | |
| 31 | 33 | t.Fatalf("DeleteRangeCutoff() all = %v, want %v", gotAll, tt.wantAll) | |
| 32 | 34 | } | |
| 35 | + | if gotOK != tt.wantOK { | |
| 36 | + | t.Fatalf("DeleteRangeCutoff() ok = %v, want %v", gotOK, tt.wantOK) | |
| 37 | + | } | |
| 33 | 38 | }) | |
| 34 | 39 | } | |
| 35 | 40 | } | |
| @@ -13,23 +13,28 @@ func TestDeleteRangeCutoff(t *testing.T) { | |||||
| 13 | rangeID string | 13 | rangeID string | ||
| 14 | want time.Time | 14 | want time.Time | ||
| 15 | wantAll bool | 15 | wantAll bool | ||
| 16 | + | wantOK bool | |||
| 16 | }{ | 17 | }{ | ||
| 17 | - | {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false}, | 18 | + | {name: "hour", rangeID: "hour", want: now.Add(-time.Hour), wantAll: false, wantOK: true}, |
| 18 | - | {name: "day", rangeID: "day", want: now.AddDate(0, 0, -1), wantAll: false}, | 19 | + | {name: "day", rangeID: "day", want: now.AddDate(0, 0, -1), wantAll: false, wantOK: true}, |
| 19 | - | {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false}, | 20 | + | {name: "week", rangeID: "week", want: now.AddDate(0, 0, -7), wantAll: false, wantOK: true}, |
| 20 | - | {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false}, | 21 | + | {name: "month", rangeID: "month", want: now.AddDate(0, -1, 0), wantAll: false, wantOK: true}, |
| 21 | - | {name: "all", rangeID: "all", want: time.Time{}, wantAll: true}, | 22 | + | {name: "all", rangeID: "all", want: time.Time{}, wantAll: true, wantOK: true}, |
| 23 | + | {name: "unknown", rangeID: "bogus", want: time.Time{}, wantAll: false, wantOK: false}, | |||
| 22 | } | 24 | } | ||
| 23 | 25 | ||||
| 24 | for _, tt := range tests { | 26 | for _, tt := range tests { | ||
| 25 | t.Run(tt.name, func(t *testing.T) { | 27 | t.Run(tt.name, func(t *testing.T) { | ||
| 26 | - | got, gotAll := DeleteRangeCutoff(tt.rangeID, now) | 28 | + | got, gotAll, gotOK := DeleteRangeCutoff(tt.rangeID, now) |
| 27 | if !got.Equal(tt.want) { | 29 | if !got.Equal(tt.want) { | ||
| 28 | t.Fatalf("DeleteRangeCutoff() cutoff = %v, want %v", got, tt.want) | 30 | t.Fatalf("DeleteRangeCutoff() cutoff = %v, want %v", got, tt.want) | ||
| 29 | } | 31 | } | ||
| 30 | if gotAll != tt.wantAll { | 32 | if gotAll != tt.wantAll { | ||
| 31 | t.Fatalf("DeleteRangeCutoff() all = %v, want %v", gotAll, tt.wantAll) | 33 | t.Fatalf("DeleteRangeCutoff() all = %v, want %v", gotAll, tt.wantAll) | ||
| 32 | } | 34 | } | ||
| 35 | + | if gotOK != tt.wantOK { | |||
| 36 | + | t.Fatalf("DeleteRangeCutoff() ok = %v, want %v", gotOK, tt.wantOK) | |||
| 37 | + | } | |||
| 33 | }) | 38 | }) | ||
| 34 | } | 39 | } | ||
| 35 | } | 40 | } | ||
M internal/infrastructure/cef/scheme_handler.go +5 -1
| @@ -351,7 +351,7 @@ func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) | |||
| 351 | 351 | return assetDirForPageHost(u.Host), path, true | |
| 352 | 352 | } | |
| 353 | 353 | ||
| 354 | - | // Handle opaque URLs like dumb:home. | |
| 354 | + | // Handle opaque URLs like dumb:history. | |
| 355 | 355 | if root, ok := pageRootFiles[u.Opaque]; ok { | |
| 356 | 356 | return assetDirForPageHost(u.Opaque), root, true | |
| 357 | 357 | } | |
| @@ -384,6 +384,10 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 384 | 384 | return "", "", false | |
| 385 | 385 | } | |
| 386 | 386 | ||
| 387 | + | if !strings.Contains(path, "/") && strings.Contains(path, ".") { | |
| 388 | + | return assetDirForPageHost(historyPath), path, true | |
| 389 | + | } | |
| 390 | + | ||
| 387 | 391 | return "", "", false | |
| 388 | 392 | } | |
| 389 | 393 | ||
| @@ -351,7 +351,7 @@ func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) | |||||
| 351 | return assetDirForPageHost(u.Host), path, true | 351 | return assetDirForPageHost(u.Host), path, true | ||
| 352 | } | 352 | } | ||
| 353 | 353 | ||||
| 354 | - | // Handle opaque URLs like dumb:home. | 354 | + | // Handle opaque URLs like dumb:history. |
| 355 | if root, ok := pageRootFiles[u.Opaque]; ok { | 355 | if root, ok := pageRootFiles[u.Opaque]; ok { | ||
| 356 | return assetDirForPageHost(u.Opaque), root, true | 356 | return assetDirForPageHost(u.Opaque), root, true | ||
| 357 | } | 357 | } | ||
| @@ -384,6 +384,10 @@ func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||||
| 384 | return "", "", false | 384 | return "", "", false | ||
| 385 | } | 385 | } | ||
| 386 | 386 | ||||
| 387 | + | if !strings.Contains(path, "/") && strings.Contains(path, ".") { | |||
| 388 | + | return assetDirForPageHost(historyPath), path, true | |||
| 389 | + | } | |||
| 390 | + | ||||
| 387 | return "", "", false | 391 | return "", "", false | ||
| 388 | } | 392 | } | ||
| 389 | 393 | ||||
M internal/infrastructure/cef/scheme_handler_systemviews_test.go +4 -0
| @@ -17,8 +17,12 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | |||
| 17 | 17 | {name: "history host", raw: "https://dumber.invalid/history", wantDir: "systemviews", wantRel: indexHTML}, | |
| 18 | 18 | {name: "favorites host", raw: "https://dumber.invalid/favorites", wantDir: "systemviews", wantRel: indexHTML}, | |
| 19 | 19 | {name: "config host", raw: "https://dumber.invalid/config", wantDir: "systemviews", wantRel: indexHTML}, | |
| 20 | + | {name: "root wasm exec", raw: "https://dumber.invalid/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"}, | |
| 21 | + | {name: "root wasm asset", raw: "https://dumber.invalid/systemviews.wasm", wantDir: "systemviews", wantRel: "systemviews.wasm"}, | |
| 22 | + | {name: "root css asset", raw: "https://dumber.invalid/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"}, | |
| 20 | 23 | {name: "history opaque", raw: "dumb:history", wantDir: "systemviews", wantRel: indexHTML}, | |
| 21 | 24 | {name: "history sub-asset", raw: "https://dumber.invalid/history/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"}, | |
| 25 | + | {name: "history css asset", raw: "https://dumber.invalid/history/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"}, | |
| 22 | 26 | } | |
| 23 | 27 | ||
| 24 | 28 | for _, tt := range tests { | |
| @@ -17,8 +17,12 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | |||||
| 17 | {name: "history host", raw: "https://dumber.invalid/history", wantDir: "systemviews", wantRel: indexHTML}, | 17 | {name: "history host", raw: "https://dumber.invalid/history", wantDir: "systemviews", wantRel: indexHTML}, | ||
| 18 | {name: "favorites host", raw: "https://dumber.invalid/favorites", wantDir: "systemviews", wantRel: indexHTML}, | 18 | {name: "favorites host", raw: "https://dumber.invalid/favorites", wantDir: "systemviews", wantRel: indexHTML}, | ||
| 19 | {name: "config host", raw: "https://dumber.invalid/config", wantDir: "systemviews", wantRel: indexHTML}, | 19 | {name: "config host", raw: "https://dumber.invalid/config", wantDir: "systemviews", wantRel: indexHTML}, | ||
| 20 | + | {name: "root wasm exec", raw: "https://dumber.invalid/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"}, | |||
| 21 | + | {name: "root wasm asset", raw: "https://dumber.invalid/systemviews.wasm", wantDir: "systemviews", wantRel: "systemviews.wasm"}, | |||
| 22 | + | {name: "root css asset", raw: "https://dumber.invalid/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"}, | |||
| 20 | {name: "history opaque", raw: "dumb:history", wantDir: "systemviews", wantRel: indexHTML}, | 23 | {name: "history opaque", raw: "dumb:history", wantDir: "systemviews", wantRel: indexHTML}, | ||
| 21 | {name: "history sub-asset", raw: "https://dumber.invalid/history/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"}, | 24 | {name: "history sub-asset", raw: "https://dumber.invalid/history/wasm_exec.js", wantDir: "systemviews", wantRel: "wasm_exec.js"}, | ||
| 25 | + | {name: "history css asset", raw: "https://dumber.invalid/history/systemviews.css", wantDir: "systemviews", wantRel: "systemviews.css"}, | |||
| 22 | } | 26 | } | ||
| 23 | 27 | ||||
| 24 | for _, tt := range tests { | 28 | for _, tt := range tests { | ||
A internal/infrastructure/systemviewsbridge/browser_transport.go +115 -0
| @@ -0,0 +1,115 @@ | |||
| 1 | + | package systemviewsbridge | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "fmt" | |
| 7 | + | "strings" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type browserRequest struct { | |
| 11 | + | messageType string | |
| 12 | + | requestID string | |
| 13 | + | body []byte | |
| 14 | + | directAPI bool | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func decodeBrowserRequest(body []byte) (browserRequest, error) { | |
| 18 | + | var envelope struct { | |
| 19 | + | Type string `json:"type"` | |
| 20 | + | Payload json.RawMessage `json:"payload"` | |
| 21 | + | } | |
| 22 | + | if err := json.Unmarshal(body, &envelope); err != nil { | |
| 23 | + | return browserRequest{}, fmt.Errorf("unmarshal browser request: %w", err) | |
| 24 | + | } | |
| 25 | + | if envelope.Type == "" { | |
| 26 | + | return browserRequest{}, fmt.Errorf("browser request missing type") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | var payload struct { | |
| 30 | + | RequestID string `json:"requestId"` | |
| 31 | + | } | |
| 32 | + | if len(envelope.Payload) != 0 { | |
| 33 | + | if err := json.Unmarshal(envelope.Payload, &payload); err != nil { | |
| 34 | + | return browserRequest{}, fmt.Errorf("unmarshal request payload: %w", err) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | return browserRequest{ | |
| 39 | + | messageType: envelope.Type, | |
| 40 | + | requestID: payload.RequestID, | |
| 41 | + | body: body, | |
| 42 | + | directAPI: strings.HasPrefix(envelope.Type, "/api/"), | |
| 43 | + | }, nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func wrapDirectAPIResponse(requestID string, payload []byte) ([]byte, error) { | |
| 47 | + | return json.Marshal(bridgeResponse{ | |
| 48 | + | RequestID: requestID, | |
| 49 | + | Success: true, | |
| 50 | + | Data: json.RawMessage(payload), | |
| 51 | + | }) | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) { | |
| 55 | + | trimmed := bytes.TrimSpace(body) | |
| 56 | + | if len(trimmed) == 0 { | |
| 57 | + | return nil, fmt.Errorf("empty bridge shim response") | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | var topLevel map[string]json.RawMessage | |
| 61 | + | if err := json.Unmarshal(trimmed, &topLevel); err != nil { | |
| 62 | + | return nil, fmt.Errorf("unmarshal top-level bridge response: %w", err) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | _, hasSuccess := topLevel["success"] | |
| 66 | + | _, hasRequestID := topLevel["requestId"] | |
| 67 | + | _, hasCallback := topLevel["_callback"] | |
| 68 | + | _, hasData := topLevel["data"] | |
| 69 | + | ||
| 70 | + | if (hasSuccess || hasRequestID) && !hasCallback { | |
| 71 | + | var resp bridgeResponse | |
| 72 | + | if err := json.Unmarshal(trimmed, &resp); err != nil { | |
| 73 | + | return nil, fmt.Errorf("unmarshal direct bridge response: %w", err) | |
| 74 | + | } | |
| 75 | + | if resp.RequestID == "" { | |
| 76 | + | resp.RequestID = requestID | |
| 77 | + | } | |
| 78 | + | return json.Marshal(resp) | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | if !hasData { | |
| 82 | + | return nil, fmt.Errorf("wrapped bridge response missing data") | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | var wrapped struct { | |
| 86 | + | Data json.RawMessage `json:"data,omitempty"` | |
| 87 | + | Error string `json:"error,omitempty"` | |
| 88 | + | } | |
| 89 | + | if err := json.Unmarshal(trimmed, &wrapped); err != nil { | |
| 90 | + | return nil, fmt.Errorf("unmarshal wrapped bridge response: %w", err) | |
| 91 | + | } | |
| 92 | + | if wrapped.Error != "" { | |
| 93 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Error: wrapped.Error}) | |
| 94 | + | } | |
| 95 | + | if len(wrapped.Data) == 0 { | |
| 96 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Success: true}) | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | var nested bridgeResponse | |
| 100 | + | if err := json.Unmarshal(wrapped.Data, &nested); err == nil { | |
| 101 | + | hasNestedEnvelope := nested.Success || nested.RequestID != "" || nested.Error != "" | |
| 102 | + | if hasNestedEnvelope { | |
| 103 | + | if nested.RequestID == "" { | |
| 104 | + | nested.RequestID = requestID | |
| 105 | + | } | |
| 106 | + | return json.Marshal(nested) | |
| 107 | + | } | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | return json.Marshal(bridgeResponse{ | |
| 111 | + | RequestID: requestID, | |
| 112 | + | Success: true, | |
| 113 | + | Data: wrapped.Data, | |
| 114 | + | }) | |
| 115 | + | } | |
| @@ -0,0 +1,115 @@ | |||||
| 1 | + | package systemviewsbridge | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "fmt" | |||
| 7 | + | "strings" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type browserRequest struct { | |||
| 11 | + | messageType string | |||
| 12 | + | requestID string | |||
| 13 | + | body []byte | |||
| 14 | + | directAPI bool | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func decodeBrowserRequest(body []byte) (browserRequest, error) { | |||
| 18 | + | var envelope struct { | |||
| 19 | + | Type string `json:"type"` | |||
| 20 | + | Payload json.RawMessage `json:"payload"` | |||
| 21 | + | } | |||
| 22 | + | if err := json.Unmarshal(body, &envelope); err != nil { | |||
| 23 | + | return browserRequest{}, fmt.Errorf("unmarshal browser request: %w", err) | |||
| 24 | + | } | |||
| 25 | + | if envelope.Type == "" { | |||
| 26 | + | return browserRequest{}, fmt.Errorf("browser request missing type") | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | var payload struct { | |||
| 30 | + | RequestID string `json:"requestId"` | |||
| 31 | + | } | |||
| 32 | + | if len(envelope.Payload) != 0 { | |||
| 33 | + | if err := json.Unmarshal(envelope.Payload, &payload); err != nil { | |||
| 34 | + | return browserRequest{}, fmt.Errorf("unmarshal request payload: %w", err) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | return browserRequest{ | |||
| 39 | + | messageType: envelope.Type, | |||
| 40 | + | requestID: payload.RequestID, | |||
| 41 | + | body: body, | |||
| 42 | + | directAPI: strings.HasPrefix(envelope.Type, "/api/"), | |||
| 43 | + | }, nil | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func wrapDirectAPIResponse(requestID string, payload []byte) ([]byte, error) { | |||
| 47 | + | return json.Marshal(bridgeResponse{ | |||
| 48 | + | RequestID: requestID, | |||
| 49 | + | Success: true, | |||
| 50 | + | Data: json.RawMessage(payload), | |||
| 51 | + | }) | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | func normalizeBridgeShimResponse(requestID string, body []byte) ([]byte, error) { | |||
| 55 | + | trimmed := bytes.TrimSpace(body) | |||
| 56 | + | if len(trimmed) == 0 { | |||
| 57 | + | return nil, fmt.Errorf("empty bridge shim response") | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | var topLevel map[string]json.RawMessage | |||
| 61 | + | if err := json.Unmarshal(trimmed, &topLevel); err != nil { | |||
| 62 | + | return nil, fmt.Errorf("unmarshal top-level bridge response: %w", err) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | _, hasSuccess := topLevel["success"] | |||
| 66 | + | _, hasRequestID := topLevel["requestId"] | |||
| 67 | + | _, hasCallback := topLevel["_callback"] | |||
| 68 | + | _, hasData := topLevel["data"] | |||
| 69 | + | ||||
| 70 | + | if (hasSuccess || hasRequestID) && !hasCallback { | |||
| 71 | + | var resp bridgeResponse | |||
| 72 | + | if err := json.Unmarshal(trimmed, &resp); err != nil { | |||
| 73 | + | return nil, fmt.Errorf("unmarshal direct bridge response: %w", err) | |||
| 74 | + | } | |||
| 75 | + | if resp.RequestID == "" { | |||
| 76 | + | resp.RequestID = requestID | |||
| 77 | + | } | |||
| 78 | + | return json.Marshal(resp) | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | if !hasData { | |||
| 82 | + | return nil, fmt.Errorf("wrapped bridge response missing data") | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | var wrapped struct { | |||
| 86 | + | Data json.RawMessage `json:"data,omitempty"` | |||
| 87 | + | Error string `json:"error,omitempty"` | |||
| 88 | + | } | |||
| 89 | + | if err := json.Unmarshal(trimmed, &wrapped); err != nil { | |||
| 90 | + | return nil, fmt.Errorf("unmarshal wrapped bridge response: %w", err) | |||
| 91 | + | } | |||
| 92 | + | if wrapped.Error != "" { | |||
| 93 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Error: wrapped.Error}) | |||
| 94 | + | } | |||
| 95 | + | if len(wrapped.Data) == 0 { | |||
| 96 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Success: true}) | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | var nested bridgeResponse | |||
| 100 | + | if err := json.Unmarshal(wrapped.Data, &nested); err == nil { | |||
| 101 | + | hasNestedEnvelope := nested.Success || nested.RequestID != "" || nested.Error != "" | |||
| 102 | + | if hasNestedEnvelope { | |||
| 103 | + | if nested.RequestID == "" { | |||
| 104 | + | nested.RequestID = requestID | |||
| 105 | + | } | |||
| 106 | + | return json.Marshal(nested) | |||
| 107 | + | } | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | return json.Marshal(bridgeResponse{ | |||
| 111 | + | RequestID: requestID, | |||
| 112 | + | Success: true, | |||
| 113 | + | Data: wrapped.Data, | |||
| 114 | + | }) | |||
| 115 | + | } | |||
A internal/infrastructure/systemviewsbridge/browser_transport_js.go +278 -0
| @@ -0,0 +1,278 @@ | |||
| 1 | + | //go:build js && wasm | |
| 2 | + | ||
| 3 | + | package systemviewsbridge | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "bytes" | |
| 7 | + | "context" | |
| 8 | + | "encoding/json" | |
| 9 | + | "errors" | |
| 10 | + | "fmt" | |
| 11 | + | "strings" | |
| 12 | + | "syscall/js" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type callbackPlan struct { | |
| 16 | + | success string | |
| 17 | + | failure string | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func NewBrowserClient() *Client { | |
| 21 | + | return NewClient(&webkitTransport{}, &browserTransport{}) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | |
| 25 | + | switch msgType { | |
| 26 | + | case "history_timeline", "history_search_fts", "history_delete_entry", "history_delete_range", "history_analytics", | |
| 27 | + | "history_domain_stats", "history_delete_domain", "favorite_list", "folder_list", "tag_list", | |
| 28 | + | "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete", | |
| 29 | + | "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove": | |
| 30 | + | return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true | |
| 31 | + | case "save_config": | |
| 32 | + | return callbackPlan{success: "__dumber_config_saved", failure: "__dumber_config_error"}, true | |
| 33 | + | case "get_keybindings": | |
| 34 | + | return callbackPlan{success: "__dumber_keybindings_loaded", failure: "__dumber_keybindings_error"}, true | |
| 35 | + | case "set_keybinding": | |
| 36 | + | return callbackPlan{success: "__dumber_keybinding_set", failure: "__dumber_keybinding_set_error"}, true | |
| 37 | + | case "reset_keybinding": | |
| 38 | + | return callbackPlan{success: "__dumber_keybinding_reset", failure: "__dumber_keybinding_reset_error"}, true | |
| 39 | + | case "reset_all_keybindings": | |
| 40 | + | return callbackPlan{success: "__dumber_keybindings_reset_all", failure: "__dumber_keybindings_reset_all_error"}, true | |
| 41 | + | default: | |
| 42 | + | return callbackPlan{}, false | |
| 43 | + | } | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | type browserTransport struct{} | |
| 47 | + | ||
| 48 | + | func (*browserTransport) Available() bool { | |
| 49 | + | window := js.Global().Get("window") | |
| 50 | + | if !window.Truthy() { | |
| 51 | + | return false | |
| 52 | + | } | |
| 53 | + | return window.Get("fetch").Truthy() || window.Get("dumber").Truthy() | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) { | |
| 57 | + | req, err := decodeBrowserRequest(body) | |
| 58 | + | if err != nil { | |
| 59 | + | return nil, err | |
| 60 | + | } | |
| 61 | + | if req.directAPI { | |
| 62 | + | return fetchDirectAPI(ctx, req.requestID, req.messageType) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | window := js.Global().Get("window") | |
| 66 | + | bridge := window.Get("dumber") | |
| 67 | + | if !bridge.Truthy() || !bridge.Get("postMessage").Truthy() { | |
| 68 | + | return nil, errors.New("browser bridge shim not available") | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | promise := bridge.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body))) | |
| 72 | + | value, err := awaitPromise(ctx, promise) | |
| 73 | + | if err != nil { | |
| 74 | + | return nil, err | |
| 75 | + | } | |
| 76 | + | raw, err := stringifyValue(value) | |
| 77 | + | if err != nil { | |
| 78 | + | return nil, err | |
| 79 | + | } | |
| 80 | + | return normalizeBridgeShimResponse(req.requestID, raw) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | type webkitTransport struct{} | |
| 84 | + | ||
| 85 | + | func (*webkitTransport) Available() bool { | |
| 86 | + | window := js.Global().Get("window") | |
| 87 | + | if !window.Truthy() { | |
| 88 | + | return false | |
| 89 | + | } | |
| 90 | + | webkit := window.Get("webkit") | |
| 91 | + | if !webkit.Truthy() { | |
| 92 | + | return false | |
| 93 | + | } | |
| 94 | + | handlers := webkit.Get("messageHandlers") | |
| 95 | + | if !handlers.Truthy() { | |
| 96 | + | return false | |
| 97 | + | } | |
| 98 | + | return handlers.Get("dumber").Truthy() | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) { | |
| 102 | + | req, err := decodeBrowserRequest(body) | |
| 103 | + | if err != nil { | |
| 104 | + | return nil, err | |
| 105 | + | } | |
| 106 | + | if req.directAPI { | |
| 107 | + | return fetchDirectAPI(ctx, req.requestID, req.messageType) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | plan, ok := callbackPlanForMessage(req.messageType) | |
| 111 | + | if !ok { | |
| 112 | + | return nil, fmt.Errorf("no WebKit callback plan for message type %q", req.messageType) | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | window := js.Global().Get("window") | |
| 116 | + | handler := window.Get("webkit").Get("messageHandlers").Get("dumber") | |
| 117 | + | if !handler.Truthy() { | |
| 118 | + | return nil, errors.New("webkit message handler not available") | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | type result struct { | |
| 122 | + | body []byte | |
| 123 | + | err error | |
| 124 | + | } | |
| 125 | + | resultCh := make(chan result, 1) | |
| 126 | + | ||
| 127 | + | successFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 128 | + | payload, err := stringifyArgs(args) | |
| 129 | + | if err != nil { | |
| 130 | + | resultCh <- result{err: err} | |
| 131 | + | return nil | |
| 132 | + | } | |
| 133 | + | normalized, err := normalizeNativeSuccessResponse(req.requestID, payload) | |
| 134 | + | resultCh <- result{body: normalized, err: err} | |
| 135 | + | return nil | |
| 136 | + | }) | |
| 137 | + | errorFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 138 | + | payload, err := stringifyArgs(args) | |
| 139 | + | if err != nil { | |
| 140 | + | resultCh <- result{err: err} | |
| 141 | + | return nil | |
| 142 | + | } | |
| 143 | + | normalized, err := normalizeNativeErrorResponse(req.requestID, payload) | |
| 144 | + | resultCh <- result{body: normalized, err: err} | |
| 145 | + | return nil | |
| 146 | + | }) | |
| 147 | + | defer successFn.Release() | |
| 148 | + | defer errorFn.Release() | |
| 149 | + | ||
| 150 | + | previousSuccess := window.Get(plan.success) | |
| 151 | + | previousError := window.Get(plan.failure) | |
| 152 | + | window.Set(plan.success, successFn) | |
| 153 | + | window.Set(plan.failure, errorFn) | |
| 154 | + | defer window.Set(plan.success, previousSuccess) | |
| 155 | + | defer window.Set(plan.failure, previousError) | |
| 156 | + | ||
| 157 | + | handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body))) | |
| 158 | + | ||
| 159 | + | select { | |
| 160 | + | case res := <-resultCh: | |
| 161 | + | return res.body, res.err | |
| 162 | + | case <-ctx.Done(): | |
| 163 | + | return nil, ctx.Err() | |
| 164 | + | } | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | func fetchDirectAPI(ctx context.Context, requestID, endpoint string) ([]byte, error) { | |
| 168 | + | response, err := awaitPromise(ctx, js.Global().Call("fetch", endpoint)) | |
| 169 | + | if err != nil { | |
| 170 | + | return nil, err | |
| 171 | + | } | |
| 172 | + | if !response.Get("ok").Bool() { | |
| 173 | + | status := response.Get("status").Int() | |
| 174 | + | statusText := response.Get("statusText").String() | |
| 175 | + | if statusText == "" { | |
| 176 | + | statusText = "request failed" | |
| 177 | + | } | |
| 178 | + | return nil, fmt.Errorf("direct API %s failed: %d %s", endpoint, status, statusText) | |
| 179 | + | } | |
| 180 | + | data, err := awaitPromise(ctx, response.Call("json")) | |
| 181 | + | if err != nil { | |
| 182 | + | return nil, err | |
| 183 | + | } | |
| 184 | + | raw, err := stringifyValue(data) | |
| 185 | + | if err != nil { | |
| 186 | + | return nil, err | |
| 187 | + | } | |
| 188 | + | return wrapDirectAPIResponse(requestID, raw) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func normalizeNativeSuccessResponse(requestID string, payload []byte) ([]byte, error) { | |
| 192 | + | trimmed := bytes.TrimSpace(payload) | |
| 193 | + | if len(trimmed) == 0 { | |
| 194 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Success: true}) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | var resp bridgeResponse | |
| 198 | + | if err := json.Unmarshal(trimmed, &resp); err == nil { | |
| 199 | + | hasEnvelope := resp.Success || resp.RequestID != "" || resp.Error != "" | |
| 200 | + | if hasEnvelope { | |
| 201 | + | if resp.RequestID == "" { | |
| 202 | + | resp.RequestID = requestID | |
| 203 | + | } | |
| 204 | + | return json.Marshal(resp) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Success: true, Data: json.RawMessage(trimmed)}) | |
| 209 | + | } | |
| 210 | + | ||
| 211 | + | func normalizeNativeErrorResponse(requestID string, payload []byte) ([]byte, error) { | |
| 212 | + | trimmed := bytes.TrimSpace(payload) | |
| 213 | + | var message string | |
| 214 | + | if len(trimmed) == 0 { | |
| 215 | + | message = "bridge request failed" | |
| 216 | + | } else if err := json.Unmarshal(trimmed, &message); err != nil { | |
| 217 | + | message = strings.Trim(string(trimmed), `"`) | |
| 218 | + | } | |
| 219 | + | if message == "" { | |
| 220 | + | message = "bridge request failed" | |
| 221 | + | } | |
| 222 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Error: message}) | |
| 223 | + | } | |
| 224 | + | ||
| 225 | + | func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) { | |
| 226 | + | type result struct { | |
| 227 | + | value js.Value | |
| 228 | + | err error | |
| 229 | + | } | |
| 230 | + | resultCh := make(chan result, 1) | |
| 231 | + | ||
| 232 | + | thenFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 233 | + | if len(args) == 0 { | |
| 234 | + | resultCh <- result{value: js.Undefined()} | |
| 235 | + | return nil | |
| 236 | + | } | |
| 237 | + | resultCh <- result{value: args[0]} | |
| 238 | + | return nil | |
| 239 | + | }) | |
| 240 | + | catchFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | |
| 241 | + | if len(args) == 0 { | |
| 242 | + | resultCh <- result{err: errors.New("promise rejected")} | |
| 243 | + | return nil | |
| 244 | + | } | |
| 245 | + | resultCh <- result{err: errors.New(args[0].String())} | |
| 246 | + | return nil | |
| 247 | + | }) | |
| 248 | + | defer thenFn.Release() | |
| 249 | + | defer catchFn.Release() | |
| 250 | + | ||
| 251 | + | promise.Call("then", thenFn).Call("catch", catchFn) | |
| 252 | + | ||
| 253 | + | select { | |
| 254 | + | case res := <-resultCh: | |
| 255 | + | return res.value, res.err | |
| 256 | + | case <-ctx.Done(): | |
| 257 | + | return js.Undefined(), ctx.Err() | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | func stringifyArgs(args []js.Value) ([]byte, error) { | |
| 262 | + | if len(args) == 0 { | |
| 263 | + | return nil, nil | |
| 264 | + | } | |
| 265 | + | return stringifyValue(args[0]) | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func stringifyValue(value js.Value) ([]byte, error) { | |
| 269 | + | jsonValue := js.Global().Get("JSON") | |
| 270 | + | if !jsonValue.Truthy() { | |
| 271 | + | return nil, errors.New("JSON global not available") | |
| 272 | + | } | |
| 273 | + | stringified := jsonValue.Call("stringify", value) | |
| 274 | + | if !stringified.Truthy() { | |
| 275 | + | return nil, errors.New("failed to stringify JS value") | |
| 276 | + | } | |
| 277 | + | return []byte(stringified.String()), nil | |
| 278 | + | } | |
| @@ -0,0 +1,278 @@ | |||||
| 1 | + | //go:build js && wasm | |||
| 2 | + | ||||
| 3 | + | package systemviewsbridge | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "bytes" | |||
| 7 | + | "context" | |||
| 8 | + | "encoding/json" | |||
| 9 | + | "errors" | |||
| 10 | + | "fmt" | |||
| 11 | + | "strings" | |||
| 12 | + | "syscall/js" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type callbackPlan struct { | |||
| 16 | + | success string | |||
| 17 | + | failure string | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | func NewBrowserClient() *Client { | |||
| 21 | + | return NewClient(&webkitTransport{}, &browserTransport{}) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func callbackPlanForMessage(msgType string) (callbackPlan, bool) { | |||
| 25 | + | switch msgType { | |||
| 26 | + | case "history_timeline", "history_search_fts", "history_delete_entry", "history_delete_range", "history_analytics", | |||
| 27 | + | "history_domain_stats", "history_delete_domain", "favorite_list", "folder_list", "tag_list", | |||
| 28 | + | "favorite_set_shortcut", "favorite_set_folder", "folder_create", "folder_update", "folder_delete", | |||
| 29 | + | "tag_create", "tag_update", "tag_delete", "tag_assign", "tag_remove": | |||
| 30 | + | return callbackPlan{success: "__dumber_homepage_response", failure: "__dumber_error"}, true | |||
| 31 | + | case "save_config": | |||
| 32 | + | return callbackPlan{success: "__dumber_config_saved", failure: "__dumber_config_error"}, true | |||
| 33 | + | case "get_keybindings": | |||
| 34 | + | return callbackPlan{success: "__dumber_keybindings_loaded", failure: "__dumber_keybindings_error"}, true | |||
| 35 | + | case "set_keybinding": | |||
| 36 | + | return callbackPlan{success: "__dumber_keybinding_set", failure: "__dumber_keybinding_set_error"}, true | |||
| 37 | + | case "reset_keybinding": | |||
| 38 | + | return callbackPlan{success: "__dumber_keybinding_reset", failure: "__dumber_keybinding_reset_error"}, true | |||
| 39 | + | case "reset_all_keybindings": | |||
| 40 | + | return callbackPlan{success: "__dumber_keybindings_reset_all", failure: "__dumber_keybindings_reset_all_error"}, true | |||
| 41 | + | default: | |||
| 42 | + | return callbackPlan{}, false | |||
| 43 | + | } | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | type browserTransport struct{} | |||
| 47 | + | ||||
| 48 | + | func (*browserTransport) Available() bool { | |||
| 49 | + | window := js.Global().Get("window") | |||
| 50 | + | if !window.Truthy() { | |||
| 51 | + | return false | |||
| 52 | + | } | |||
| 53 | + | return window.Get("fetch").Truthy() || window.Get("dumber").Truthy() | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func (*browserTransport) Send(ctx context.Context, body []byte) ([]byte, error) { | |||
| 57 | + | req, err := decodeBrowserRequest(body) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return nil, err | |||
| 60 | + | } | |||
| 61 | + | if req.directAPI { | |||
| 62 | + | return fetchDirectAPI(ctx, req.requestID, req.messageType) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | window := js.Global().Get("window") | |||
| 66 | + | bridge := window.Get("dumber") | |||
| 67 | + | if !bridge.Truthy() || !bridge.Get("postMessage").Truthy() { | |||
| 68 | + | return nil, errors.New("browser bridge shim not available") | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | promise := bridge.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body))) | |||
| 72 | + | value, err := awaitPromise(ctx, promise) | |||
| 73 | + | if err != nil { | |||
| 74 | + | return nil, err | |||
| 75 | + | } | |||
| 76 | + | raw, err := stringifyValue(value) | |||
| 77 | + | if err != nil { | |||
| 78 | + | return nil, err | |||
| 79 | + | } | |||
| 80 | + | return normalizeBridgeShimResponse(req.requestID, raw) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | type webkitTransport struct{} | |||
| 84 | + | ||||
| 85 | + | func (*webkitTransport) Available() bool { | |||
| 86 | + | window := js.Global().Get("window") | |||
| 87 | + | if !window.Truthy() { | |||
| 88 | + | return false | |||
| 89 | + | } | |||
| 90 | + | webkit := window.Get("webkit") | |||
| 91 | + | if !webkit.Truthy() { | |||
| 92 | + | return false | |||
| 93 | + | } | |||
| 94 | + | handlers := webkit.Get("messageHandlers") | |||
| 95 | + | if !handlers.Truthy() { | |||
| 96 | + | return false | |||
| 97 | + | } | |||
| 98 | + | return handlers.Get("dumber").Truthy() | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func (*webkitTransport) Send(ctx context.Context, body []byte) ([]byte, error) { | |||
| 102 | + | req, err := decodeBrowserRequest(body) | |||
| 103 | + | if err != nil { | |||
| 104 | + | return nil, err | |||
| 105 | + | } | |||
| 106 | + | if req.directAPI { | |||
| 107 | + | return fetchDirectAPI(ctx, req.requestID, req.messageType) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | plan, ok := callbackPlanForMessage(req.messageType) | |||
| 111 | + | if !ok { | |||
| 112 | + | return nil, fmt.Errorf("no WebKit callback plan for message type %q", req.messageType) | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | window := js.Global().Get("window") | |||
| 116 | + | handler := window.Get("webkit").Get("messageHandlers").Get("dumber") | |||
| 117 | + | if !handler.Truthy() { | |||
| 118 | + | return nil, errors.New("webkit message handler not available") | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | type result struct { | |||
| 122 | + | body []byte | |||
| 123 | + | err error | |||
| 124 | + | } | |||
| 125 | + | resultCh := make(chan result, 1) | |||
| 126 | + | ||||
| 127 | + | successFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 128 | + | payload, err := stringifyArgs(args) | |||
| 129 | + | if err != nil { | |||
| 130 | + | resultCh <- result{err: err} | |||
| 131 | + | return nil | |||
| 132 | + | } | |||
| 133 | + | normalized, err := normalizeNativeSuccessResponse(req.requestID, payload) | |||
| 134 | + | resultCh <- result{body: normalized, err: err} | |||
| 135 | + | return nil | |||
| 136 | + | }) | |||
| 137 | + | errorFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 138 | + | payload, err := stringifyArgs(args) | |||
| 139 | + | if err != nil { | |||
| 140 | + | resultCh <- result{err: err} | |||
| 141 | + | return nil | |||
| 142 | + | } | |||
| 143 | + | normalized, err := normalizeNativeErrorResponse(req.requestID, payload) | |||
| 144 | + | resultCh <- result{body: normalized, err: err} | |||
| 145 | + | return nil | |||
| 146 | + | }) | |||
| 147 | + | defer successFn.Release() | |||
| 148 | + | defer errorFn.Release() | |||
| 149 | + | ||||
| 150 | + | previousSuccess := window.Get(plan.success) | |||
| 151 | + | previousError := window.Get(plan.failure) | |||
| 152 | + | window.Set(plan.success, successFn) | |||
| 153 | + | window.Set(plan.failure, errorFn) | |||
| 154 | + | defer window.Set(plan.success, previousSuccess) | |||
| 155 | + | defer window.Set(plan.failure, previousError) | |||
| 156 | + | ||||
| 157 | + | handler.Call("postMessage", js.Global().Get("JSON").Call("parse", string(req.body))) | |||
| 158 | + | ||||
| 159 | + | select { | |||
| 160 | + | case res := <-resultCh: | |||
| 161 | + | return res.body, res.err | |||
| 162 | + | case <-ctx.Done(): | |||
| 163 | + | return nil, ctx.Err() | |||
| 164 | + | } | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | func fetchDirectAPI(ctx context.Context, requestID, endpoint string) ([]byte, error) { | |||
| 168 | + | response, err := awaitPromise(ctx, js.Global().Call("fetch", endpoint)) | |||
| 169 | + | if err != nil { | |||
| 170 | + | return nil, err | |||
| 171 | + | } | |||
| 172 | + | if !response.Get("ok").Bool() { | |||
| 173 | + | status := response.Get("status").Int() | |||
| 174 | + | statusText := response.Get("statusText").String() | |||
| 175 | + | if statusText == "" { | |||
| 176 | + | statusText = "request failed" | |||
| 177 | + | } | |||
| 178 | + | return nil, fmt.Errorf("direct API %s failed: %d %s", endpoint, status, statusText) | |||
| 179 | + | } | |||
| 180 | + | data, err := awaitPromise(ctx, response.Call("json")) | |||
| 181 | + | if err != nil { | |||
| 182 | + | return nil, err | |||
| 183 | + | } | |||
| 184 | + | raw, err := stringifyValue(data) | |||
| 185 | + | if err != nil { | |||
| 186 | + | return nil, err | |||
| 187 | + | } | |||
| 188 | + | return wrapDirectAPIResponse(requestID, raw) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | func normalizeNativeSuccessResponse(requestID string, payload []byte) ([]byte, error) { | |||
| 192 | + | trimmed := bytes.TrimSpace(payload) | |||
| 193 | + | if len(trimmed) == 0 { | |||
| 194 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Success: true}) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | var resp bridgeResponse | |||
| 198 | + | if err := json.Unmarshal(trimmed, &resp); err == nil { | |||
| 199 | + | hasEnvelope := resp.Success || resp.RequestID != "" || resp.Error != "" | |||
| 200 | + | if hasEnvelope { | |||
| 201 | + | if resp.RequestID == "" { | |||
| 202 | + | resp.RequestID = requestID | |||
| 203 | + | } | |||
| 204 | + | return json.Marshal(resp) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Success: true, Data: json.RawMessage(trimmed)}) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func normalizeNativeErrorResponse(requestID string, payload []byte) ([]byte, error) { | |||
| 212 | + | trimmed := bytes.TrimSpace(payload) | |||
| 213 | + | var message string | |||
| 214 | + | if len(trimmed) == 0 { | |||
| 215 | + | message = "bridge request failed" | |||
| 216 | + | } else if err := json.Unmarshal(trimmed, &message); err != nil { | |||
| 217 | + | message = strings.Trim(string(trimmed), `"`) | |||
| 218 | + | } | |||
| 219 | + | if message == "" { | |||
| 220 | + | message = "bridge request failed" | |||
| 221 | + | } | |||
| 222 | + | return json.Marshal(bridgeResponse{RequestID: requestID, Error: message}) | |||
| 223 | + | } | |||
| 224 | + | ||||
| 225 | + | func awaitPromise(ctx context.Context, promise js.Value) (js.Value, error) { | |||
| 226 | + | type result struct { | |||
| 227 | + | value js.Value | |||
| 228 | + | err error | |||
| 229 | + | } | |||
| 230 | + | resultCh := make(chan result, 1) | |||
| 231 | + | ||||
| 232 | + | thenFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 233 | + | if len(args) == 0 { | |||
| 234 | + | resultCh <- result{value: js.Undefined()} | |||
| 235 | + | return nil | |||
| 236 | + | } | |||
| 237 | + | resultCh <- result{value: args[0]} | |||
| 238 | + | return nil | |||
| 239 | + | }) | |||
| 240 | + | catchFn := js.FuncOf(func(_ js.Value, args []js.Value) any { | |||
| 241 | + | if len(args) == 0 { | |||
| 242 | + | resultCh <- result{err: errors.New("promise rejected")} | |||
| 243 | + | return nil | |||
| 244 | + | } | |||
| 245 | + | resultCh <- result{err: errors.New(args[0].String())} | |||
| 246 | + | return nil | |||
| 247 | + | }) | |||
| 248 | + | defer thenFn.Release() | |||
| 249 | + | defer catchFn.Release() | |||
| 250 | + | ||||
| 251 | + | promise.Call("then", thenFn).Call("catch", catchFn) | |||
| 252 | + | ||||
| 253 | + | select { | |||
| 254 | + | case res := <-resultCh: | |||
| 255 | + | return res.value, res.err | |||
| 256 | + | case <-ctx.Done(): | |||
| 257 | + | return js.Undefined(), ctx.Err() | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | func stringifyArgs(args []js.Value) ([]byte, error) { | |||
| 262 | + | if len(args) == 0 { | |||
| 263 | + | return nil, nil | |||
| 264 | + | } | |||
| 265 | + | return stringifyValue(args[0]) | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func stringifyValue(value js.Value) ([]byte, error) { | |||
| 269 | + | jsonValue := js.Global().Get("JSON") | |||
| 270 | + | if !jsonValue.Truthy() { | |||
| 271 | + | return nil, errors.New("JSON global not available") | |||
| 272 | + | } | |||
| 273 | + | stringified := jsonValue.Call("stringify", value) | |||
| 274 | + | if !stringified.Truthy() { | |||
| 275 | + | return nil, errors.New("failed to stringify JS value") | |||
| 276 | + | } | |||
| 277 | + | return []byte(stringified.String()), nil | |||
| 278 | + | } | |||
A internal/infrastructure/systemviewsbridge/browser_transport_test.go +130 -0
| @@ -0,0 +1,130 @@ | |||
| 1 | + | package systemviewsbridge | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestDecodeBrowserRequest_DirectConfigEndpoint(t *testing.T) { | |
| 10 | + | t.Parallel() | |
| 11 | + | ||
| 12 | + | body, err := buildMessageEnvelope("/api/config", struct { | |
| 13 | + | RequestID string `json:"requestId"` | |
| 14 | + | }{RequestID: "req-1"}) | |
| 15 | + | if err != nil { | |
| 16 | + | t.Fatalf("buildMessageEnvelope() error = %v", err) | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | req, err := decodeBrowserRequest(body) | |
| 20 | + | if err != nil { | |
| 21 | + | t.Fatalf("decodeBrowserRequest() error = %v", err) | |
| 22 | + | } | |
| 23 | + | if req.messageType != "/api/config" { | |
| 24 | + | t.Fatalf("messageType = %q, want %q", req.messageType, "/api/config") | |
| 25 | + | } | |
| 26 | + | if req.requestID != "req-1" { | |
| 27 | + | t.Fatalf("requestID = %q, want %q", req.requestID, "req-1") | |
| 28 | + | } | |
| 29 | + | if !req.directAPI { | |
| 30 | + | t.Fatal("directAPI = false, want true") | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func TestDecodeBrowserRequest_MessageEnvelopeUsesBridgeShim(t *testing.T) { | |
| 35 | + | t.Parallel() | |
| 36 | + | ||
| 37 | + | body, err := buildMessageEnvelope("history_timeline", struct { | |
| 38 | + | RequestID string `json:"requestId"` | |
| 39 | + | Limit int `json:"limit"` | |
| 40 | + | }{RequestID: "req-2", Limit: 25}) | |
| 41 | + | if err != nil { | |
| 42 | + | t.Fatalf("buildMessageEnvelope() error = %v", err) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | req, err := decodeBrowserRequest(body) | |
| 46 | + | if err != nil { | |
| 47 | + | t.Fatalf("decodeBrowserRequest() error = %v", err) | |
| 48 | + | } | |
| 49 | + | if req.messageType != "history_timeline" { | |
| 50 | + | t.Fatalf("messageType = %q, want %q", req.messageType, "history_timeline") | |
| 51 | + | } | |
| 52 | + | if req.requestID != "req-2" { | |
| 53 | + | t.Fatalf("requestID = %q, want %q", req.requestID, "req-2") | |
| 54 | + | } | |
| 55 | + | if req.directAPI { | |
| 56 | + | t.Fatal("directAPI = true, want false") | |
| 57 | + | } | |
| 58 | + | if !bytes.Equal(req.body, body) { | |
| 59 | + | t.Fatalf("body = %s, want %s", req.body, body) | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func TestNormalizeBridgeShimResponse_UnwrapsNestedBridgeEnvelope(t *testing.T) { | |
| 64 | + | t.Parallel() | |
| 65 | + | ||
| 66 | + | got, err := normalizeBridgeShimResponse("req-3", []byte(`{"data":{"requestId":"req-3","success":true,"data":[{"title":"Example"}]},"_callback":"__dumber_homepage_response"}`)) | |
| 67 | + | if err != nil { | |
| 68 | + | t.Fatalf("normalizeBridgeShimResponse() error = %v", err) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | var resp bridgeResponse | |
| 72 | + | if err := json.Unmarshal(got, &resp); err != nil { | |
| 73 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |
| 74 | + | } | |
| 75 | + | if !resp.Success { | |
| 76 | + | t.Fatal("success = false, want true") | |
| 77 | + | } | |
| 78 | + | if resp.RequestID != "req-3" { | |
| 79 | + | t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-3") | |
| 80 | + | } | |
| 81 | + | if string(resp.Data) != `[{"title":"Example"}]` { | |
| 82 | + | t.Fatalf("data = %s, want %s", resp.Data, `[{"title":"Example"}]`) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) { | |
| 87 | + | t.Parallel() | |
| 88 | + | ||
| 89 | + | got, err := normalizeBridgeShimResponse("req-4", []byte(`{"data":{"status":"success"},"_callback":"__dumber_config_saved"}`)) | |
| 90 | + | if err != nil { | |
| 91 | + | t.Fatalf("normalizeBridgeShimResponse() error = %v", err) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | var resp bridgeResponse | |
| 95 | + | if err := json.Unmarshal(got, &resp); err != nil { | |
| 96 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |
| 97 | + | } | |
| 98 | + | if !resp.Success { | |
| 99 | + | t.Fatal("success = false, want true") | |
| 100 | + | } | |
| 101 | + | if resp.RequestID != "req-4" { | |
| 102 | + | t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-4") | |
| 103 | + | } | |
| 104 | + | if string(resp.Data) != `{"status":"success"}` { | |
| 105 | + | t.Fatalf("data = %s, want %s", resp.Data, `{"status":"success"}`) | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func TestWrapDirectAPIResponse_PreservesPayload(t *testing.T) { | |
| 110 | + | t.Parallel() | |
| 111 | + | ||
| 112 | + | got, err := wrapDirectAPIResponse("req-5", []byte(`{"engine_type":"webkit"}`)) | |
| 113 | + | if err != nil { | |
| 114 | + | t.Fatalf("wrapDirectAPIResponse() error = %v", err) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | var resp bridgeResponse | |
| 118 | + | if err := json.Unmarshal(got, &resp); err != nil { | |
| 119 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |
| 120 | + | } | |
| 121 | + | if !resp.Success { | |
| 122 | + | t.Fatal("success = false, want true") | |
| 123 | + | } | |
| 124 | + | if resp.RequestID != "req-5" { | |
| 125 | + | t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-5") | |
| 126 | + | } | |
| 127 | + | if string(resp.Data) != `{"engine_type":"webkit"}` { | |
| 128 | + | t.Fatalf("data = %s, want %s", resp.Data, `{"engine_type":"webkit"}`) | |
| 129 | + | } | |
| 130 | + | } | |
| @@ -0,0 +1,130 @@ | |||||
| 1 | + | package systemviewsbridge | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestDecodeBrowserRequest_DirectConfigEndpoint(t *testing.T) { | |||
| 10 | + | t.Parallel() | |||
| 11 | + | ||||
| 12 | + | body, err := buildMessageEnvelope("/api/config", struct { | |||
| 13 | + | RequestID string `json:"requestId"` | |||
| 14 | + | }{RequestID: "req-1"}) | |||
| 15 | + | if err != nil { | |||
| 16 | + | t.Fatalf("buildMessageEnvelope() error = %v", err) | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | req, err := decodeBrowserRequest(body) | |||
| 20 | + | if err != nil { | |||
| 21 | + | t.Fatalf("decodeBrowserRequest() error = %v", err) | |||
| 22 | + | } | |||
| 23 | + | if req.messageType != "/api/config" { | |||
| 24 | + | t.Fatalf("messageType = %q, want %q", req.messageType, "/api/config") | |||
| 25 | + | } | |||
| 26 | + | if req.requestID != "req-1" { | |||
| 27 | + | t.Fatalf("requestID = %q, want %q", req.requestID, "req-1") | |||
| 28 | + | } | |||
| 29 | + | if !req.directAPI { | |||
| 30 | + | t.Fatal("directAPI = false, want true") | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestDecodeBrowserRequest_MessageEnvelopeUsesBridgeShim(t *testing.T) { | |||
| 35 | + | t.Parallel() | |||
| 36 | + | ||||
| 37 | + | body, err := buildMessageEnvelope("history_timeline", struct { | |||
| 38 | + | RequestID string `json:"requestId"` | |||
| 39 | + | Limit int `json:"limit"` | |||
| 40 | + | }{RequestID: "req-2", Limit: 25}) | |||
| 41 | + | if err != nil { | |||
| 42 | + | t.Fatalf("buildMessageEnvelope() error = %v", err) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | req, err := decodeBrowserRequest(body) | |||
| 46 | + | if err != nil { | |||
| 47 | + | t.Fatalf("decodeBrowserRequest() error = %v", err) | |||
| 48 | + | } | |||
| 49 | + | if req.messageType != "history_timeline" { | |||
| 50 | + | t.Fatalf("messageType = %q, want %q", req.messageType, "history_timeline") | |||
| 51 | + | } | |||
| 52 | + | if req.requestID != "req-2" { | |||
| 53 | + | t.Fatalf("requestID = %q, want %q", req.requestID, "req-2") | |||
| 54 | + | } | |||
| 55 | + | if req.directAPI { | |||
| 56 | + | t.Fatal("directAPI = true, want false") | |||
| 57 | + | } | |||
| 58 | + | if !bytes.Equal(req.body, body) { | |||
| 59 | + | t.Fatalf("body = %s, want %s", req.body, body) | |||
| 60 | + | } | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func TestNormalizeBridgeShimResponse_UnwrapsNestedBridgeEnvelope(t *testing.T) { | |||
| 64 | + | t.Parallel() | |||
| 65 | + | ||||
| 66 | + | got, err := normalizeBridgeShimResponse("req-3", []byte(`{"data":{"requestId":"req-3","success":true,"data":[{"title":"Example"}]},"_callback":"__dumber_homepage_response"}`)) | |||
| 67 | + | if err != nil { | |||
| 68 | + | t.Fatalf("normalizeBridgeShimResponse() error = %v", err) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | var resp bridgeResponse | |||
| 72 | + | if err := json.Unmarshal(got, &resp); err != nil { | |||
| 73 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |||
| 74 | + | } | |||
| 75 | + | if !resp.Success { | |||
| 76 | + | t.Fatal("success = false, want true") | |||
| 77 | + | } | |||
| 78 | + | if resp.RequestID != "req-3" { | |||
| 79 | + | t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-3") | |||
| 80 | + | } | |||
| 81 | + | if string(resp.Data) != `[{"title":"Example"}]` { | |||
| 82 | + | t.Fatalf("data = %s, want %s", resp.Data, `[{"title":"Example"}]`) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestNormalizeBridgeShimResponse_WrapsRawCallbackPayload(t *testing.T) { | |||
| 87 | + | t.Parallel() | |||
| 88 | + | ||||
| 89 | + | got, err := normalizeBridgeShimResponse("req-4", []byte(`{"data":{"status":"success"},"_callback":"__dumber_config_saved"}`)) | |||
| 90 | + | if err != nil { | |||
| 91 | + | t.Fatalf("normalizeBridgeShimResponse() error = %v", err) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | var resp bridgeResponse | |||
| 95 | + | if err := json.Unmarshal(got, &resp); err != nil { | |||
| 96 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |||
| 97 | + | } | |||
| 98 | + | if !resp.Success { | |||
| 99 | + | t.Fatal("success = false, want true") | |||
| 100 | + | } | |||
| 101 | + | if resp.RequestID != "req-4" { | |||
| 102 | + | t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-4") | |||
| 103 | + | } | |||
| 104 | + | if string(resp.Data) != `{"status":"success"}` { | |||
| 105 | + | t.Fatalf("data = %s, want %s", resp.Data, `{"status":"success"}`) | |||
| 106 | + | } | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func TestWrapDirectAPIResponse_PreservesPayload(t *testing.T) { | |||
| 110 | + | t.Parallel() | |||
| 111 | + | ||||
| 112 | + | got, err := wrapDirectAPIResponse("req-5", []byte(`{"engine_type":"webkit"}`)) | |||
| 113 | + | if err != nil { | |||
| 114 | + | t.Fatalf("wrapDirectAPIResponse() error = %v", err) | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | var resp bridgeResponse | |||
| 118 | + | if err := json.Unmarshal(got, &resp); err != nil { | |||
| 119 | + | t.Fatalf("json.Unmarshal() error = %v", err) | |||
| 120 | + | } | |||
| 121 | + | if !resp.Success { | |||
| 122 | + | t.Fatal("success = false, want true") | |||
| 123 | + | } | |||
| 124 | + | if resp.RequestID != "req-5" { | |||
| 125 | + | t.Fatalf("requestId = %q, want %q", resp.RequestID, "req-5") | |||
| 126 | + | } | |||
| 127 | + | if string(resp.Data) != `{"engine_type":"webkit"}` { | |||
| 128 | + | t.Fatalf("data = %s, want %s", resp.Data, `{"engine_type":"webkit"}`) | |||
| 129 | + | } | |||
| 130 | + | } | |||
M internal/infrastructure/systemviewsbridge/client.go +5 -2
| @@ -24,6 +24,8 @@ type Client struct { | |||
| 24 | 24 | } | |
| 25 | 25 | ||
| 26 | 26 | var _ port.SystemviewConfigService = (*Client)(nil) | |
| 27 | + | var _ port.SystemviewHistoryService = (*Client)(nil) | |
| 28 | + | var _ port.SystemviewFavoritesService = (*Client)(nil) | |
| 27 | 29 | ||
| 28 | 30 | var requestSeq atomic.Uint64 | |
| 29 | 31 | ||
| @@ -179,12 +181,13 @@ func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int6 | |||
| 179 | 181 | return err | |
| 180 | 182 | } | |
| 181 | 183 | ||
| 182 | - | func (c *Client) CreateFolder(ctx context.Context, name string, _ *int64) (*entity.Folder, error) { | |
| 184 | + | func (c *Client) CreateFolder(ctx context.Context, name string, parentID *int64) (*entity.Folder, error) { | |
| 183 | 185 | return request[*entity.Folder](c, ctx, "folder_create", struct { | |
| 184 | 186 | RequestID string `json:"requestId"` | |
| 185 | 187 | Name string `json:"name"` | |
| 186 | 188 | Icon *string `json:"icon"` | |
| 187 | - | }{RequestID: nextRequestID(), Name: name, Icon: nil}) | |
| 189 | + | ParentID *int64 `json:"parent_id,omitempty"` | |
| 190 | + | }{RequestID: nextRequestID(), Name: name, Icon: nil, ParentID: parentID}) | |
| 188 | 191 | } | |
| 189 | 192 | ||
| 190 | 193 | func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error { | |
| @@ -24,6 +24,8 @@ type Client struct { | |||||
| 24 | } | 24 | } | ||
| 25 | 25 | ||||
| 26 | var _ port.SystemviewConfigService = (*Client)(nil) | 26 | var _ port.SystemviewConfigService = (*Client)(nil) | ||
| 27 | + | var _ port.SystemviewHistoryService = (*Client)(nil) | |||
| 28 | + | var _ port.SystemviewFavoritesService = (*Client)(nil) | |||
| 27 | 29 | ||||
| 28 | var requestSeq atomic.Uint64 | 30 | var requestSeq atomic.Uint64 | ||
| 29 | 31 | ||||
| @@ -179,12 +181,13 @@ func (c *Client) SetFolder(ctx context.Context, favoriteID int64, folderID *int6 | |||||
| 179 | return err | 181 | return err | ||
| 180 | } | 182 | } | ||
| 181 | 183 | ||||
| 182 | - | func (c *Client) CreateFolder(ctx context.Context, name string, _ *int64) (*entity.Folder, error) { | 184 | + | func (c *Client) CreateFolder(ctx context.Context, name string, parentID *int64) (*entity.Folder, error) { |
| 183 | return request[*entity.Folder](c, ctx, "folder_create", struct { | 185 | return request[*entity.Folder](c, ctx, "folder_create", struct { | ||
| 184 | RequestID string `json:"requestId"` | 186 | RequestID string `json:"requestId"` | ||
| 185 | Name string `json:"name"` | 187 | Name string `json:"name"` | ||
| 186 | Icon *string `json:"icon"` | 188 | Icon *string `json:"icon"` | ||
| 187 | - | }{RequestID: nextRequestID(), Name: name, Icon: nil}) | 189 | + | ParentID *int64 `json:"parent_id,omitempty"` |
| 190 | + | }{RequestID: nextRequestID(), Name: name, Icon: nil, ParentID: parentID}) | |||
| 188 | } | 191 | } | ||
| 189 | 192 | ||||
| 190 | func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error { | 193 | func (c *Client) UpdateFolder(ctx context.Context, id int64, name, icon string) error { | ||
M internal/infrastructure/systemviewsbridge/client_test.go +52 -0
| @@ -356,6 +356,58 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||
| 356 | 356 | } | |
| 357 | 357 | } | |
| 358 | 358 | ||
| 359 | + | func TestClientCreateFolderIncludesParentID(t *testing.T) { | |
| 360 | + | t.Parallel() | |
| 361 | + | ||
| 362 | + | parentID := int64(42) | |
| 363 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"","parent_id":42}}`)} | |
| 364 | + | client := NewClient(transport, nil) | |
| 365 | + | ||
| 366 | + | folder, err := client.CreateFolder(context.Background(), "Nested", &parentID) | |
| 367 | + | if err != nil { | |
| 368 | + | t.Fatalf("CreateFolder() error = %v", err) | |
| 369 | + | } | |
| 370 | + | if folder == nil { | |
| 371 | + | t.Fatal("CreateFolder() returned nil folder") | |
| 372 | + | } | |
| 373 | + | if folder.Name != "Nested" { | |
| 374 | + | t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested") | |
| 375 | + | } | |
| 376 | + | if folder.ParentID == nil || int64(*folder.ParentID) != parentID { | |
| 377 | + | t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID) | |
| 378 | + | } | |
| 379 | + | ||
| 380 | + | var msg port.WebUIMessage | |
| 381 | + | if err := json.Unmarshal(transport.last, &msg); err != nil { | |
| 382 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |
| 383 | + | } | |
| 384 | + | if msg.Type != "folder_create" { | |
| 385 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "folder_create") | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | var payload struct { | |
| 389 | + | RequestID string `json:"requestId"` | |
| 390 | + | Name string `json:"name"` | |
| 391 | + | Icon *string `json:"icon"` | |
| 392 | + | ParentID *int64 `json:"parent_id"` | |
| 393 | + | } | |
| 394 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |
| 395 | + | t.Fatalf("unmarshal sent payload: %v", err) | |
| 396 | + | } | |
| 397 | + | if payload.ParentID == nil || *payload.ParentID != parentID { | |
| 398 | + | t.Fatalf("sent parent_id = %#v, want %d", payload.ParentID, parentID) | |
| 399 | + | } | |
| 400 | + | if payload.Name != "Nested" { | |
| 401 | + | t.Fatalf("sent name = %q, want %q", payload.Name, "Nested") | |
| 402 | + | } | |
| 403 | + | if payload.Icon != nil { | |
| 404 | + | t.Fatalf("sent icon = %#v, want nil", payload.Icon) | |
| 405 | + | } | |
| 406 | + | if payload.RequestID == "" { | |
| 407 | + | t.Fatal("sent requestId was empty") | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | ||
| 359 | 411 | type fakeTransport struct { | |
| 360 | 412 | available bool | |
| 361 | 413 | called bool | |
| @@ -356,6 +356,58 @@ func TestClientConfigActionsUseExpectedMessageTypes(t *testing.T) { | |||||
| 356 | } | 356 | } | ||
| 357 | } | 357 | } | ||
| 358 | 358 | ||||
| 359 | + | func TestClientCreateFolderIncludesParentID(t *testing.T) { | |||
| 360 | + | t.Parallel() | |||
| 361 | + | ||||
| 362 | + | parentID := int64(42) | |||
| 363 | + | transport := &fakeTransport{available: true, response: []byte(`{"requestId":"req-16","success":true,"data":{"id":9,"name":"Nested","icon":"","parent_id":42}}`)} | |||
| 364 | + | client := NewClient(transport, nil) | |||
| 365 | + | ||||
| 366 | + | folder, err := client.CreateFolder(context.Background(), "Nested", &parentID) | |||
| 367 | + | if err != nil { | |||
| 368 | + | t.Fatalf("CreateFolder() error = %v", err) | |||
| 369 | + | } | |||
| 370 | + | if folder == nil { | |||
| 371 | + | t.Fatal("CreateFolder() returned nil folder") | |||
| 372 | + | } | |||
| 373 | + | if folder.Name != "Nested" { | |||
| 374 | + | t.Fatalf("CreateFolder() folder name = %q, want %q", folder.Name, "Nested") | |||
| 375 | + | } | |||
| 376 | + | if folder.ParentID == nil || int64(*folder.ParentID) != parentID { | |||
| 377 | + | t.Fatalf("CreateFolder() folder parent_id = %#v, want %d", folder.ParentID, parentID) | |||
| 378 | + | } | |||
| 379 | + | ||||
| 380 | + | var msg port.WebUIMessage | |||
| 381 | + | if err := json.Unmarshal(transport.last, &msg); err != nil { | |||
| 382 | + | t.Fatalf("unmarshal sent envelope: %v", err) | |||
| 383 | + | } | |||
| 384 | + | if msg.Type != "folder_create" { | |||
| 385 | + | t.Fatalf("sent type = %q, want %q", msg.Type, "folder_create") | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | var payload struct { | |||
| 389 | + | RequestID string `json:"requestId"` | |||
| 390 | + | Name string `json:"name"` | |||
| 391 | + | Icon *string `json:"icon"` | |||
| 392 | + | ParentID *int64 `json:"parent_id"` | |||
| 393 | + | } | |||
| 394 | + | if err := json.Unmarshal(msg.Payload, &payload); err != nil { | |||
| 395 | + | t.Fatalf("unmarshal sent payload: %v", err) | |||
| 396 | + | } | |||
| 397 | + | if payload.ParentID == nil || *payload.ParentID != parentID { | |||
| 398 | + | t.Fatalf("sent parent_id = %#v, want %d", payload.ParentID, parentID) | |||
| 399 | + | } | |||
| 400 | + | if payload.Name != "Nested" { | |||
| 401 | + | t.Fatalf("sent name = %q, want %q", payload.Name, "Nested") | |||
| 402 | + | } | |||
| 403 | + | if payload.Icon != nil { | |||
| 404 | + | t.Fatalf("sent icon = %#v, want nil", payload.Icon) | |||
| 405 | + | } | |||
| 406 | + | if payload.RequestID == "" { | |||
| 407 | + | t.Fatal("sent requestId was empty") | |||
| 408 | + | } | |||
| 409 | + | } | |||
| 410 | + | ||||
| 359 | type fakeTransport struct { | 411 | type fakeTransport struct { | ||
| 360 | available bool | 412 | available bool | ||
| 361 | called bool | 413 | called bool | ||
M internal/infrastructure/webkit/scheme_handler.go +27 -6
| @@ -352,6 +352,28 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||
| 352 | 352 | } | |
| 353 | 353 | } | |
| 354 | 354 | ||
| 355 | + | func shouldAddCORSHeaders(path string) bool { | |
| 356 | + | path = strings.TrimSpace(path) | |
| 357 | + | return strings.HasPrefix(path, "/api/") || strings.HasSuffix(path, ".wasm") | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func responseHeadersForPath(path, contentType string) map[string]string { | |
| 361 | + | if !shouldAddCORSHeaders(path) { | |
| 362 | + | return nil | |
| 363 | + | } | |
| 364 | + | ||
| 365 | + | headers := map[string]string{ | |
| 366 | + | "Access-Control-Allow-Origin": "*", | |
| 367 | + | "Access-Control-Allow-Methods": "GET, POST, OPTIONS", | |
| 368 | + | "Access-Control-Allow-Headers": "Content-Type", | |
| 369 | + | "Access-Control-Max-Age": "86400", | |
| 370 | + | } | |
| 371 | + | if contentType != "" { | |
| 372 | + | headers["Content-Type"] = contentType | |
| 373 | + | } | |
| 374 | + | return headers | |
| 375 | + | } | |
| 376 | + | ||
| 355 | 377 | // sendResponse sends the response back to WebKit. | |
| 356 | 378 | func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) { | |
| 357 | 379 | if response == nil { | |
| @@ -384,13 +406,12 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response | |||
| 384 | 406 | schemeResp.SetStatus(uint(response.StatusCode), nil) | |
| 385 | 407 | ||
| 386 | 408 | // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch(). | |
| 387 | - | // We only add CORS headers for our internal API endpoints. | |
| 388 | - | if strings.HasPrefix(req.GetPath(), "/api/") { | |
| 409 | + | // Add CORS headers to fetch-backed endpoints, including the wasm runtime asset. | |
| 410 | + | if headers := responseHeadersForPath(req.GetPath(), contentType); len(headers) > 0 { | |
| 389 | 411 | hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue) | |
| 390 | - | hdrs.Append("Access-Control-Allow-Origin", "*") | |
| 391 | - | hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS") | |
| 392 | - | hdrs.Append("Access-Control-Allow-Headers", "Content-Type") | |
| 393 | - | hdrs.Append("Access-Control-Max-Age", "86400") | |
| 412 | + | for name, value := range headers { | |
| 413 | + | hdrs.Append(name, value) | |
| 414 | + | } | |
| 394 | 415 | schemeResp.SetHttpHeaders(hdrs) | |
| 395 | 416 | } | |
| 396 | 417 | ||
| @@ -352,6 +352,28 @@ func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |||||
| 352 | } | 352 | } | ||
| 353 | } | 353 | } | ||
| 354 | 354 | ||||
| 355 | + | func shouldAddCORSHeaders(path string) bool { | |||
| 356 | + | path = strings.TrimSpace(path) | |||
| 357 | + | return strings.HasPrefix(path, "/api/") || strings.HasSuffix(path, ".wasm") | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func responseHeadersForPath(path, contentType string) map[string]string { | |||
| 361 | + | if !shouldAddCORSHeaders(path) { | |||
| 362 | + | return nil | |||
| 363 | + | } | |||
| 364 | + | ||||
| 365 | + | headers := map[string]string{ | |||
| 366 | + | "Access-Control-Allow-Origin": "*", | |||
| 367 | + | "Access-Control-Allow-Methods": "GET, POST, OPTIONS", | |||
| 368 | + | "Access-Control-Allow-Headers": "Content-Type", | |||
| 369 | + | "Access-Control-Max-Age": "86400", | |||
| 370 | + | } | |||
| 371 | + | if contentType != "" { | |||
| 372 | + | headers["Content-Type"] = contentType | |||
| 373 | + | } | |||
| 374 | + | return headers | |||
| 375 | + | } | |||
| 376 | + | ||||
| 355 | // sendResponse sends the response back to WebKit. | 377 | // sendResponse sends the response back to WebKit. | ||
| 356 | func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) { | 378 | func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) { | ||
| 357 | if response == nil { | 379 | if response == nil { | ||
| @@ -384,13 +406,12 @@ func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response | |||||
| 384 | schemeResp.SetStatus(uint(response.StatusCode), nil) | 406 | schemeResp.SetStatus(uint(response.StatusCode), nil) | ||
| 385 | 407 | ||||
| 386 | // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch(). | 408 | // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch(). | ||
| 387 | - | // We only add CORS headers for our internal API endpoints. | 409 | + | // Add CORS headers to fetch-backed endpoints, including the wasm runtime asset. |
| 388 | - | if strings.HasPrefix(req.GetPath(), "/api/") { | 410 | + | if headers := responseHeadersForPath(req.GetPath(), contentType); len(headers) > 0 { |
| 389 | hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue) | 411 | hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue) | ||
| 390 | - | hdrs.Append("Access-Control-Allow-Origin", "*") | 412 | + | for name, value := range headers { |
| 391 | - | hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS") | 413 | + | hdrs.Append(name, value) |
| 392 | - | hdrs.Append("Access-Control-Allow-Headers", "Content-Type") | 414 | + | } |
| 393 | - | hdrs.Append("Access-Control-Max-Age", "86400") | |||
| 394 | schemeResp.SetHttpHeaders(hdrs) | 415 | schemeResp.SetHttpHeaders(hdrs) | ||
| 395 | } | 416 | } | ||
| 396 | 417 | ||||
M internal/infrastructure/webkit/scheme_handler_test.go +45 -0
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "net/http" | |
| 6 | 6 | "net/url" | |
| 7 | + | "strings" | |
| 7 | 8 | "testing" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/assets" | |
| @@ -104,6 +105,19 @@ func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) { | |||
| 104 | 105 | assert.Contains(t, string(resp.Data), "systemviews.wasm") | |
| 105 | 106 | } | |
| 106 | 107 | ||
| 108 | + | func TestHandleAsset_SystemviewsCSSIsServed(t *testing.T) { | |
| 109 | + | h := NewDumbSchemeHandler(context.Background()) | |
| 110 | + | h.SetAssets(assets.WebUIAssets) | |
| 111 | + | ||
| 112 | + | u, err := url.Parse("dumb://history/systemviews.css") | |
| 113 | + | require.NoError(t, err) | |
| 114 | + | ||
| 115 | + | resp := h.handleAsset(u) | |
| 116 | + | require.NotNil(t, resp) | |
| 117 | + | assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0]) | |
| 118 | + | assert.Contains(t, string(resp.Data), ".sv-app") | |
| 119 | + | } | |
| 120 | + | ||
| 107 | 121 | func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) { | |
| 108 | 122 | h := NewDumbSchemeHandler(context.Background()) | |
| 109 | 123 | h.SetAssets(assets.WebUIAssets) | |
| @@ -168,3 +182,34 @@ func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) { | |||
| 168 | 182 | assert.Equal(t, "application/json", defaultResp.ContentType) | |
| 169 | 183 | assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data) | |
| 170 | 184 | } | |
| 185 | + | ||
| 186 | + | func TestShouldAddCORSHeaders(t *testing.T) { | |
| 187 | + | t.Parallel() | |
| 188 | + | ||
| 189 | + | tests := []struct { | |
| 190 | + | name string | |
| 191 | + | path string | |
| 192 | + | want bool | |
| 193 | + | }{ | |
| 194 | + | {name: "api route", path: "/api/config", want: true}, | |
| 195 | + | {name: "wasm asset", path: "/systemviews.wasm", want: true}, | |
| 196 | + | {name: "html shell", path: "/", want: false}, | |
| 197 | + | {name: "js asset", path: "/wasm_exec.js", want: false}, | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | for _, tt := range tests { | |
| 201 | + | t.Run(tt.name, func(t *testing.T) { | |
| 202 | + | t.Parallel() | |
| 203 | + | assert.Equal(t, tt.want, shouldAddCORSHeaders(tt.path)) | |
| 204 | + | }) | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) { | |
| 209 | + | t.Parallel() | |
| 210 | + | ||
| 211 | + | headers := responseHeadersForPath("/systemviews.wasm", "application/wasm") | |
| 212 | + | assert.Equal(t, "application/wasm", headers["Content-Type"]) | |
| 213 | + | assert.Equal(t, "*", headers["Access-Control-Allow-Origin"]) | |
| 214 | + | assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"]) | |
| 215 | + | } | |
| @@ -4,6 +4,7 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "net/http" | 5 | "net/http" | ||
| 6 | "net/url" | 6 | "net/url" | ||
| 7 | + | "strings" | |||
| 7 | "testing" | 8 | "testing" | ||
| 8 | 9 | ||||
| 9 | "github.com/bnema/dumber/assets" | 10 | "github.com/bnema/dumber/assets" | ||
| @@ -104,6 +105,19 @@ func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) { | |||||
| 104 | assert.Contains(t, string(resp.Data), "systemviews.wasm") | 105 | assert.Contains(t, string(resp.Data), "systemviews.wasm") | ||
| 105 | } | 106 | } | ||
| 106 | 107 | ||||
| 108 | + | func TestHandleAsset_SystemviewsCSSIsServed(t *testing.T) { | |||
| 109 | + | h := NewDumbSchemeHandler(context.Background()) | |||
| 110 | + | h.SetAssets(assets.WebUIAssets) | |||
| 111 | + | ||||
| 112 | + | u, err := url.Parse("dumb://history/systemviews.css") | |||
| 113 | + | require.NoError(t, err) | |||
| 114 | + | ||||
| 115 | + | resp := h.handleAsset(u) | |||
| 116 | + | require.NotNil(t, resp) | |||
| 117 | + | assert.Equal(t, "text/css", strings.Split(resp.ContentType, ";")[0]) | |||
| 118 | + | assert.Contains(t, string(resp.Data), ".sv-app") | |||
| 119 | + | } | |||
| 120 | + | ||||
| 107 | func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) { | 121 | func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) { | ||
| 108 | h := NewDumbSchemeHandler(context.Background()) | 122 | h := NewDumbSchemeHandler(context.Background()) | ||
| 109 | h.SetAssets(assets.WebUIAssets) | 123 | h.SetAssets(assets.WebUIAssets) | ||
| @@ -168,3 +182,34 @@ func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) { | |||||
| 168 | assert.Equal(t, "application/json", defaultResp.ContentType) | 182 | assert.Equal(t, "application/json", defaultResp.ContentType) | ||
| 169 | assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data) | 183 | assert.Equal(t, []byte(`{"default":true}`), defaultResp.Data) | ||
| 170 | } | 184 | } | ||
| 185 | + | ||||
| 186 | + | func TestShouldAddCORSHeaders(t *testing.T) { | |||
| 187 | + | t.Parallel() | |||
| 188 | + | ||||
| 189 | + | tests := []struct { | |||
| 190 | + | name string | |||
| 191 | + | path string | |||
| 192 | + | want bool | |||
| 193 | + | }{ | |||
| 194 | + | {name: "api route", path: "/api/config", want: true}, | |||
| 195 | + | {name: "wasm asset", path: "/systemviews.wasm", want: true}, | |||
| 196 | + | {name: "html shell", path: "/", want: false}, | |||
| 197 | + | {name: "js asset", path: "/wasm_exec.js", want: false}, | |||
| 198 | + | } | |||
| 199 | + | ||||
| 200 | + | for _, tt := range tests { | |||
| 201 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 202 | + | t.Parallel() | |||
| 203 | + | assert.Equal(t, tt.want, shouldAddCORSHeaders(tt.path)) | |||
| 204 | + | }) | |||
| 205 | + | } | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) { | |||
| 209 | + | t.Parallel() | |||
| 210 | + | ||||
| 211 | + | headers := responseHeadersForPath("/systemviews.wasm", "application/wasm") | |||
| 212 | + | assert.Equal(t, "application/wasm", headers["Content-Type"]) | |||
| 213 | + | assert.Equal(t, "*", headers["Access-Control-Allow-Origin"]) | |||
| 214 | + | assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"]) | |||
| 215 | + | } | |||
A internal/infrastructure/webutil/mime_test.go +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | package webutil | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestGetMimeType_WASM(t *testing.T) { | |
| 6 | + | t.Parallel() | |
| 7 | + | ||
| 8 | + | if got := GetMimeType("systemviews.wasm"); got != "application/wasm" { | |
| 9 | + | t.Fatalf("GetMimeType(systemviews.wasm) = %q, want %q", got, "application/wasm") | |
| 10 | + | } | |
| 11 | + | } | |
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | package webutil | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestGetMimeType_WASM(t *testing.T) { | |||
| 6 | + | t.Parallel() | |||
| 7 | + | ||||
| 8 | + | if got := GetMimeType("systemviews.wasm"); got != "application/wasm" { | |||
| 9 | + | t.Fatalf("GetMimeType(systemviews.wasm) = %q, want %q", got, "application/wasm") | |||
| 10 | + | } | |||
| 11 | + | } | |||
M internal/ui/systemviews/app.go +53 -7
| @@ -19,6 +19,7 @@ type Dependencies struct { | |||
| 19 | 19 | type App struct { | |
| 20 | 20 | deps Dependencies | |
| 21 | 21 | currentRoute Route | |
| 22 | + | shellTheme shellTheme | |
| 22 | 23 | historyEntries []*entity.HistoryEntry | |
| 23 | 24 | favorites []*entity.Favorite | |
| 24 | 25 | folders []*entity.Folder | |
| @@ -54,6 +55,7 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||
| 54 | 55 | if a == nil { | |
| 55 | 56 | return errors.New("app is nil") | |
| 56 | 57 | } | |
| 58 | + | a.loadShellTheme(ctx) | |
| 57 | 59 | if a.currentRoute == "" || a.currentRoute == RouteUnknown { | |
| 58 | 60 | a.currentRoute = ParseRoute(a.deps.LocationURI) | |
| 59 | 61 | } | |
| @@ -67,7 +69,11 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||
| 67 | 69 | return a.loadConfigRoute(ctx) | |
| 68 | 70 | default: | |
| 69 | 71 | a.resetRouteState() | |
| 70 | - | a.renderedHTML = placeholderHTML(a.currentRoute) | |
| 72 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 73 | + | route: a.currentRoute, | |
| 74 | + | subtitle: string(a.currentRoute), | |
| 75 | + | body: placeholderHTML(a.currentRoute), | |
| 76 | + | }, a.shellTheme) | |
| 71 | 77 | return nil | |
| 72 | 78 | } | |
| 73 | 79 | } | |
| @@ -75,7 +81,11 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||
| 75 | 81 | func (a *App) loadHistoryRoute(ctx context.Context) error { | |
| 76 | 82 | if a.deps.History == nil { | |
| 77 | 83 | a.resetRouteState() | |
| 78 | - | a.renderedHTML = placeholderHTML(a.currentRoute) | |
| 84 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 85 | + | route: a.currentRoute, | |
| 86 | + | subtitle: "Recent visits", | |
| 87 | + | body: placeholderHTML(a.currentRoute), | |
| 88 | + | }, a.shellTheme) | |
| 79 | 89 | return nil | |
| 80 | 90 | } | |
| 81 | 91 | ||
| @@ -87,14 +97,22 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||
| 87 | 97 | a.folders = nil | |
| 88 | 98 | a.tags = nil | |
| 89 | 99 | a.historyEntries = entries | |
| 90 | - | a.renderedHTML = historyHTML(entries) | |
| 100 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 101 | + | route: RouteHistory, | |
| 102 | + | subtitle: "Recent visits", | |
| 103 | + | body: historyHTML(entries), | |
| 104 | + | }, a.shellTheme) | |
| 91 | 105 | return nil | |
| 92 | 106 | } | |
| 93 | 107 | ||
| 94 | 108 | func (a *App) loadFavoritesRoute(ctx context.Context) error { | |
| 95 | 109 | if a.deps.Favorites == nil { | |
| 96 | 110 | a.resetRouteState() | |
| 97 | - | a.renderedHTML = placeholderHTML(a.currentRoute) | |
| 111 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 112 | + | route: a.currentRoute, | |
| 113 | + | subtitle: "Saved bookmarks", | |
| 114 | + | body: placeholderHTML(a.currentRoute), | |
| 115 | + | }, a.shellTheme) | |
| 98 | 116 | return nil | |
| 99 | 117 | } | |
| 100 | 118 | ||
| @@ -115,14 +133,22 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error { | |||
| 115 | 133 | a.favorites = favorites | |
| 116 | 134 | a.folders = folders | |
| 117 | 135 | a.tags = tags | |
| 118 | - | a.renderedHTML = favoritesHTML(favorites, folders, tags) | |
| 136 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 137 | + | route: RouteFavorites, | |
| 138 | + | subtitle: "Saved bookmarks", | |
| 139 | + | body: favoritesHTML(favorites, folders, tags), | |
| 140 | + | }, a.shellTheme) | |
| 119 | 141 | return nil | |
| 120 | 142 | } | |
| 121 | 143 | ||
| 122 | 144 | func (a *App) loadConfigRoute(ctx context.Context) error { | |
| 123 | 145 | if a.deps.Config == nil { | |
| 124 | 146 | a.resetRouteState() | |
| 125 | - | a.renderedHTML = placeholderHTML(a.currentRoute) | |
| 147 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 148 | + | route: a.currentRoute, | |
| 149 | + | subtitle: "Browser settings", | |
| 150 | + | body: placeholderHTML(a.currentRoute), | |
| 151 | + | }, a.shellTheme) | |
| 126 | 152 | return nil | |
| 127 | 153 | } | |
| 128 | 154 | ||
| @@ -141,10 +167,30 @@ func (a *App) loadConfigRoute(ctx context.Context) error { | |||
| 141 | 167 | a.tags = nil | |
| 142 | 168 | a.config = &config | |
| 143 | 169 | a.keybindings = keybindings | |
| 144 | - | a.renderedHTML = configHTML(config, keybindings) | |
| 170 | + | a.renderedHTML = renderAppFrame(renderedPage{ | |
| 171 | + | route: RouteConfig, | |
| 172 | + | subtitle: "Browser settings", | |
| 173 | + | body: configHTML(config, keybindings), | |
| 174 | + | }, a.shellTheme) | |
| 145 | 175 | return nil | |
| 146 | 176 | } | |
| 147 | 177 | ||
| 178 | + | func (a *App) loadShellTheme(ctx context.Context) { | |
| 179 | + | if a == nil { | |
| 180 | + | return | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | a.shellTheme = shellTheme{} | |
| 184 | + | if a.deps.Config == nil { | |
| 185 | + | return | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | config, err := a.deps.Config.Current(ctx) | |
| 189 | + | if err == nil { | |
| 190 | + | a.shellTheme = resolveShellTheme(config.Appearance) | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 148 | 194 | func (a *App) resetRouteState() { | |
| 149 | 195 | a.historyEntries = nil | |
| 150 | 196 | a.favorites = nil | |
| @@ -19,6 +19,7 @@ type Dependencies struct { | |||||
| 19 | type App struct { | 19 | type App struct { | ||
| 20 | deps Dependencies | 20 | deps Dependencies | ||
| 21 | currentRoute Route | 21 | currentRoute Route | ||
| 22 | + | shellTheme shellTheme | |||
| 22 | historyEntries []*entity.HistoryEntry | 23 | historyEntries []*entity.HistoryEntry | ||
| 23 | favorites []*entity.Favorite | 24 | favorites []*entity.Favorite | ||
| 24 | folders []*entity.Folder | 25 | folders []*entity.Folder | ||
| @@ -54,6 +55,7 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||||
| 54 | if a == nil { | 55 | if a == nil { | ||
| 55 | return errors.New("app is nil") | 56 | return errors.New("app is nil") | ||
| 56 | } | 57 | } | ||
| 58 | + | a.loadShellTheme(ctx) | |||
| 57 | if a.currentRoute == "" || a.currentRoute == RouteUnknown { | 59 | if a.currentRoute == "" || a.currentRoute == RouteUnknown { | ||
| 58 | a.currentRoute = ParseRoute(a.deps.LocationURI) | 60 | a.currentRoute = ParseRoute(a.deps.LocationURI) | ||
| 59 | } | 61 | } | ||
| @@ -67,7 +69,11 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||||
| 67 | return a.loadConfigRoute(ctx) | 69 | return a.loadConfigRoute(ctx) | ||
| 68 | default: | 70 | default: | ||
| 69 | a.resetRouteState() | 71 | a.resetRouteState() | ||
| 70 | - | a.renderedHTML = placeholderHTML(a.currentRoute) | 72 | + | a.renderedHTML = renderAppFrame(renderedPage{ |
| 73 | + | route: a.currentRoute, | |||
| 74 | + | subtitle: string(a.currentRoute), | |||
| 75 | + | body: placeholderHTML(a.currentRoute), | |||
| 76 | + | }, a.shellTheme) | |||
| 71 | return nil | 77 | return nil | ||
| 72 | } | 78 | } | ||
| 73 | } | 79 | } | ||
| @@ -75,7 +81,11 @@ func (a *App) LoadInitial(ctx context.Context) error { | |||||
| 75 | func (a *App) loadHistoryRoute(ctx context.Context) error { | 81 | func (a *App) loadHistoryRoute(ctx context.Context) error { | ||
| 76 | if a.deps.History == nil { | 82 | if a.deps.History == nil { | ||
| 77 | a.resetRouteState() | 83 | a.resetRouteState() | ||
| 78 | - | a.renderedHTML = placeholderHTML(a.currentRoute) | 84 | + | a.renderedHTML = renderAppFrame(renderedPage{ |
| 85 | + | route: a.currentRoute, | |||
| 86 | + | subtitle: "Recent visits", | |||
| 87 | + | body: placeholderHTML(a.currentRoute), | |||
| 88 | + | }, a.shellTheme) | |||
| 79 | return nil | 89 | return nil | ||
| 80 | } | 90 | } | ||
| 81 | 91 | ||||
| @@ -87,14 +97,22 @@ func (a *App) loadHistoryRoute(ctx context.Context) error { | |||||
| 87 | a.folders = nil | 97 | a.folders = nil | ||
| 88 | a.tags = nil | 98 | a.tags = nil | ||
| 89 | a.historyEntries = entries | 99 | a.historyEntries = entries | ||
| 90 | - | a.renderedHTML = historyHTML(entries) | 100 | + | a.renderedHTML = renderAppFrame(renderedPage{ |
| 101 | + | route: RouteHistory, | |||
| 102 | + | subtitle: "Recent visits", | |||
| 103 | + | body: historyHTML(entries), | |||
| 104 | + | }, a.shellTheme) | |||
| 91 | return nil | 105 | return nil | ||
| 92 | } | 106 | } | ||
| 93 | 107 | ||||
| 94 | func (a *App) loadFavoritesRoute(ctx context.Context) error { | 108 | func (a *App) loadFavoritesRoute(ctx context.Context) error { | ||
| 95 | if a.deps.Favorites == nil { | 109 | if a.deps.Favorites == nil { | ||
| 96 | a.resetRouteState() | 110 | a.resetRouteState() | ||
| 97 | - | a.renderedHTML = placeholderHTML(a.currentRoute) | 111 | + | a.renderedHTML = renderAppFrame(renderedPage{ |
| 112 | + | route: a.currentRoute, | |||
| 113 | + | subtitle: "Saved bookmarks", | |||
| 114 | + | body: placeholderHTML(a.currentRoute), | |||
| 115 | + | }, a.shellTheme) | |||
| 98 | return nil | 116 | return nil | ||
| 99 | } | 117 | } | ||
| 100 | 118 | ||||
| @@ -115,14 +133,22 @@ func (a *App) loadFavoritesRoute(ctx context.Context) error { | |||||
| 115 | a.favorites = favorites | 133 | a.favorites = favorites | ||
| 116 | a.folders = folders | 134 | a.folders = folders | ||
| 117 | a.tags = tags | 135 | a.tags = tags | ||
| 118 | - | a.renderedHTML = favoritesHTML(favorites, folders, tags) | 136 | + | a.renderedHTML = renderAppFrame(renderedPage{ |
| 137 | + | route: RouteFavorites, | |||
| 138 | + | subtitle: "Saved bookmarks", | |||
| 139 | + | body: favoritesHTML(favorites, folders, tags), | |||
| 140 | + | }, a.shellTheme) | |||
| 119 | return nil | 141 | return nil | ||
| 120 | } | 142 | } | ||
| 121 | 143 | ||||
| 122 | func (a *App) loadConfigRoute(ctx context.Context) error { | 144 | func (a *App) loadConfigRoute(ctx context.Context) error { | ||
| 123 | if a.deps.Config == nil { | 145 | if a.deps.Config == nil { | ||
| 124 | a.resetRouteState() | 146 | a.resetRouteState() | ||
| 125 | - | a.renderedHTML = placeholderHTML(a.currentRoute) | 147 | + | a.renderedHTML = renderAppFrame(renderedPage{ |
| 148 | + | route: a.currentRoute, | |||
| 149 | + | subtitle: "Browser settings", | |||
| 150 | + | body: placeholderHTML(a.currentRoute), | |||
| 151 | + | }, a.shellTheme) | |||
| 126 | return nil | 152 | return nil | ||
| 127 | } | 153 | } | ||
| 128 | 154 | ||||
| @@ -141,10 +167,30 @@ func (a *App) loadConfigRoute(ctx context.Context) error { | |||||
| 141 | a.tags = nil | 167 | a.tags = nil | ||
| 142 | a.config = &config | 168 | a.config = &config | ||
| 143 | a.keybindings = keybindings | 169 | a.keybindings = keybindings | ||
| 144 | - | a.renderedHTML = configHTML(config, keybindings) | 170 | + | a.renderedHTML = renderAppFrame(renderedPage{ |
| 171 | + | route: RouteConfig, | |||
| 172 | + | subtitle: "Browser settings", | |||
| 173 | + | body: configHTML(config, keybindings), | |||
| 174 | + | }, a.shellTheme) | |||
| 145 | return nil | 175 | return nil | ||
| 146 | } | 176 | } | ||
| 147 | 177 | ||||
| 178 | + | func (a *App) loadShellTheme(ctx context.Context) { | |||
| 179 | + | if a == nil { | |||
| 180 | + | return | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | a.shellTheme = shellTheme{} | |||
| 184 | + | if a.deps.Config == nil { | |||
| 185 | + | return | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | config, err := a.deps.Config.Current(ctx) | |||
| 189 | + | if err == nil { | |||
| 190 | + | a.shellTheme = resolveShellTheme(config.Appearance) | |||
| 191 | + | } | |||
| 192 | + | } | |||
| 193 | + | ||||
| 148 | func (a *App) resetRouteState() { | 194 | func (a *App) resetRouteState() { | ||
| 149 | a.historyEntries = nil | 195 | a.historyEntries = nil | ||
| 150 | a.favorites = nil | 196 | a.favorites = nil | ||
M internal/ui/systemviews/app_test.go +179 -7
| @@ -52,7 +52,7 @@ func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) { | |||
| 52 | 52 | assert.Contains(t, dom.html, "systemviews") | |
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | - | func TestAppLoadInitialHistoryRouteRendersEntries(t *testing.T) { | |
| 55 | + | func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |
| 56 | 56 | t.Parallel() | |
| 57 | 57 | ||
| 58 | 58 | history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ | |
| @@ -73,6 +73,62 @@ func TestAppLoadInitialHistoryRouteRendersEntries(t *testing.T) { | |||
| 73 | 73 | assert.True(t, history.called) | |
| 74 | 74 | assert.Equal(t, 25, history.limit) | |
| 75 | 75 | assert.Equal(t, 0, history.offset) | |
| 76 | + | ||
| 77 | + | // Shell frame present, no full document wrapper. | |
| 78 | + | assert.NotContains(t, app.renderedHTML, "<html") | |
| 79 | + | assert.NotContains(t, app.renderedHTML, "<head") | |
| 80 | + | assert.NotContains(t, app.renderedHTML, "<body") | |
| 81 | + | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |
| 82 | + | assert.Contains(t, app.renderedHTML, `data-route="history"`) | |
| 83 | + | assert.Contains(t, app.renderedHTML, `class="sv-section"`) | |
| 84 | + | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) { | |
| 88 | + | t.Parallel() | |
| 89 | + | ||
| 90 | + | history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ | |
| 91 | + | URL: "https://example.com", | |
| 92 | + | Title: "Example", | |
| 93 | + | }}} | |
| 94 | + | config := &fakeConfigService{current: port.SystemviewConfigPayload{ | |
| 95 | + | Appearance: port.WebUIAppearanceConfig{ | |
| 96 | + | ColorScheme: "prefer-light", | |
| 97 | + | LightPalette: port.ColorPalette{ | |
| 98 | + | Background: "#ffffff", | |
| 99 | + | Surface: "#fafafa", | |
| 100 | + | SurfaceVariant: "#eeeeee", | |
| 101 | + | Text: "#111111", | |
| 102 | + | Muted: "#666666", | |
| 103 | + | Accent: "#0055ff", | |
| 104 | + | Border: "#dddddd", | |
| 105 | + | }, | |
| 106 | + | DarkPalette: port.ColorPalette{ | |
| 107 | + | Background: "#111111", | |
| 108 | + | Surface: "#1a1a1a", | |
| 109 | + | SurfaceVariant: "#2a2a2a", | |
| 110 | + | Text: "#f5f5f5", | |
| 111 | + | Muted: "#a0a0a0", | |
| 112 | + | Accent: "#66aaff", | |
| 113 | + | Border: "#333333", | |
| 114 | + | }, | |
| 115 | + | SansFont: "Inter", | |
| 116 | + | SerifFont: "Georgia", | |
| 117 | + | MonospaceFont: "JetBrains Mono", | |
| 118 | + | DefaultFontSize: 16, | |
| 119 | + | }, | |
| 120 | + | }} | |
| 121 | + | ||
| 122 | + | app := NewApp(Dependencies{ | |
| 123 | + | Config: config, | |
| 124 | + | History: history, | |
| 125 | + | LocationURI: "dumb://history", | |
| 126 | + | }) | |
| 127 | + | ||
| 128 | + | require.NoError(t, app.LoadInitial(context.Background())) | |
| 129 | + | assert.Contains(t, app.renderedHTML, `class="sv-app sv-light"`) | |
| 130 | + | assert.Contains(t, app.renderedHTML, `--sv-background: #ffffff;`) | |
| 131 | + | assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #eeeeee;`) | |
| 76 | 132 | } | |
| 77 | 133 | ||
| 78 | 134 | func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | |
| @@ -101,18 +157,92 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | |||
| 101 | 157 | assert.True(t, favorites.calledList) | |
| 102 | 158 | assert.True(t, favorites.calledFolders) | |
| 103 | 159 | assert.True(t, favorites.calledTags) | |
| 160 | + | ||
| 161 | + | // Shell frame present, no full document wrapper. | |
| 162 | + | assert.NotContains(t, app.renderedHTML, "<html") | |
| 163 | + | assert.NotContains(t, app.renderedHTML, "<head") | |
| 164 | + | assert.NotContains(t, app.renderedHTML, "<body") | |
| 165 | + | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |
| 166 | + | assert.Contains(t, app.renderedHTML, `data-route="favorites"`) | |
| 167 | + | assert.Contains(t, app.renderedHTML, `class="sv-section"`) | |
| 168 | + | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | |
| 104 | 169 | } | |
| 105 | 170 | ||
| 106 | 171 | func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |
| 107 | 172 | t.Parallel() | |
| 108 | 173 | ||
| 109 | 174 | config := port.SystemviewConfigPayload{ | |
| 110 | - | EngineType: "webkit", | |
| 111 | - | DefaultSearchEngine: "DuckDuckGo", | |
| 175 | + | EngineType: "webkit", | |
| 176 | + | Appearance: port.WebUIAppearanceConfig{ | |
| 177 | + | ColorScheme: "prefer-dark", | |
| 178 | + | SansFont: "Inter", | |
| 179 | + | SerifFont: "Georgia", | |
| 180 | + | MonospaceFont: "JetBrains Mono", | |
| 181 | + | DefaultFontSize: 16, | |
| 182 | + | LightPalette: port.ColorPalette{ | |
| 183 | + | Background: "#ffffff", | |
| 184 | + | Surface: "#fafafa", | |
| 185 | + | SurfaceVariant: "#eeeeee", | |
| 186 | + | Text: "#111111", | |
| 187 | + | Muted: "#666666", | |
| 188 | + | Accent: "#0055ff", | |
| 189 | + | Border: "#dddddd", | |
| 190 | + | }, | |
| 191 | + | DarkPalette: port.ColorPalette{ | |
| 192 | + | Background: "#111111", | |
| 193 | + | Surface: "#1a1a1a", | |
| 194 | + | SurfaceVariant: "#2a2a2a", | |
| 195 | + | Text: "#f5f5f5", | |
| 196 | + | Muted: "#a0a0a0", | |
| 197 | + | Accent: "#66aaff", | |
| 198 | + | Border: "#333333", | |
| 199 | + | }, | |
| 200 | + | }, | |
| 201 | + | Performance: port.SystemviewPerformancePayload{ | |
| 202 | + | Profile: "balanced", | |
| 203 | + | Custom: port.SystemviewCustomPerformancePayload{ | |
| 204 | + | SkiaCPUThreads: 4, | |
| 205 | + | SkiaGPUThreads: 2, | |
| 206 | + | WebProcessMemoryMB: 512, | |
| 207 | + | NetworkProcessMemoryMB: 128, | |
| 208 | + | WebViewPoolPrewarm: 1, | |
| 209 | + | }, | |
| 210 | + | Hardware: port.SystemviewHardwarePayload{ | |
| 211 | + | CPUCores: 8, | |
| 212 | + | CPUThreads: 16, | |
| 213 | + | TotalRAMMB: 32768, | |
| 214 | + | GPUVendor: "NVIDIA", | |
| 215 | + | GPUName: "RTX 4060", | |
| 216 | + | VRAMMB: 8192, | |
| 217 | + | }, | |
| 218 | + | }, | |
| 219 | + | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | |
| 220 | + | SearchShortcuts: map[string]port.SearchShortcut{ | |
| 221 | + | "ddg": { | |
| 222 | + | URL: "https://duckduckgo.com/?q=%s", | |
| 223 | + | Description: "DuckDuckGo", | |
| 224 | + | }, | |
| 225 | + | }, | |
| 112 | 226 | } | |
| 113 | 227 | service := &fakeConfigService{ | |
| 114 | - | current: config, | |
| 115 | - | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{Mode: "default", DisplayName: "Default", Bindings: []port.KeybindingEntry{{Action: "open", Description: "Open", Keys: []string{"ctrl+o"}}}}}}, | |
| 228 | + | current: config, | |
| 229 | + | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{ | |
| 230 | + | { | |
| 231 | + | Mode: "default", | |
| 232 | + | DisplayName: "Default", | |
| 233 | + | Bindings: []port.KeybindingEntry{ | |
| 234 | + | {Action: "open", Description: "Open", Keys: []string{"ctrl+o"}, DefaultKeys: []string{"ctrl+o"}}, | |
| 235 | + | {Action: "new-tab", Description: "New tab", Keys: []string{"ctrl+t"}, DefaultKeys: []string{"ctrl+shift+t"}, IsCustom: true}, | |
| 236 | + | }, | |
| 237 | + | }, | |
| 238 | + | { | |
| 239 | + | Mode: "search", | |
| 240 | + | DisplayName: "Search", | |
| 241 | + | Bindings: []port.KeybindingEntry{ | |
| 242 | + | {Action: "find", Description: "Find", Keys: []string{"ctrl+f"}, DefaultKeys: []string{"ctrl+f"}}, | |
| 243 | + | }, | |
| 244 | + | }, | |
| 245 | + | }}, | |
| 116 | 246 | } | |
| 117 | 247 | ||
| 118 | 248 | app := NewApp(Dependencies{ | |
| @@ -125,10 +255,52 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |||
| 125 | 255 | require.NotNil(t, app.config) | |
| 126 | 256 | assert.Equal(t, "webkit", app.config.EngineType) | |
| 127 | 257 | assert.Contains(t, app.renderedHTML, "webkit") | |
| 128 | - | assert.Contains(t, app.renderedHTML, "DuckDuckGo") | |
| 129 | - | assert.Contains(t, app.renderedHTML, "1 group") | |
| 258 | + | assert.Contains(t, app.renderedHTML, "https://duckduckgo.com/?q=%s") | |
| 259 | + | assert.Contains(t, app.renderedHTML, "appearance.color_scheme") | |
| 260 | + | assert.Contains(t, app.renderedHTML, "prefer-dark") | |
| 261 | + | assert.Contains(t, app.renderedHTML, "appearance.sans_font") | |
| 262 | + | assert.Contains(t, app.renderedHTML, "Inter") | |
| 263 | + | assert.Contains(t, app.renderedHTML, "appearance.light_palette.background") | |
| 264 | + | assert.Contains(t, app.renderedHTML, "#ffffff") | |
| 265 | + | assert.Contains(t, app.renderedHTML, "performance.profile") | |
| 266 | + | assert.Contains(t, app.renderedHTML, "balanced") | |
| 267 | + | assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads") | |
| 268 | + | assert.Contains(t, app.renderedHTML, "4") | |
| 269 | + | assert.Contains(t, app.renderedHTML, "performance.hardware.gpu_vendor") | |
| 270 | + | assert.Contains(t, app.renderedHTML, "NVIDIA") | |
| 271 | + | assert.Contains(t, app.renderedHTML, "search_shortcuts.ddg.url") | |
| 272 | + | assert.Contains(t, app.renderedHTML, "Default") | |
| 273 | + | assert.Contains(t, app.renderedHTML, "Search") | |
| 274 | + | assert.Contains(t, app.renderedHTML, "Open") | |
| 275 | + | assert.Contains(t, app.renderedHTML, "ctrl+o") | |
| 276 | + | assert.Contains(t, app.renderedHTML, "New tab") | |
| 277 | + | assert.Contains(t, app.renderedHTML, "ctrl+t") | |
| 278 | + | assert.Contains(t, app.renderedHTML, "ctrl+shift+t") | |
| 279 | + | assert.Contains(t, app.renderedHTML, "Find") | |
| 280 | + | assert.Contains(t, app.renderedHTML, "open") | |
| 281 | + | assert.Contains(t, app.renderedHTML, "default") | |
| 282 | + | assert.Contains(t, app.renderedHTML, "custom") | |
| 283 | + | assert.NotContains(t, app.renderedHTML, "groups[0].bindings[0].action") | |
| 130 | 284 | assert.True(t, service.calledCurrent) | |
| 131 | 285 | assert.True(t, service.calledKeybindings) | |
| 286 | + | ||
| 287 | + | // Shell frame present, no full document wrapper. | |
| 288 | + | assert.NotContains(t, app.renderedHTML, "<html") | |
| 289 | + | assert.NotContains(t, app.renderedHTML, "<head") | |
| 290 | + | assert.NotContains(t, app.renderedHTML, "<body") | |
| 291 | + | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |
| 292 | + | assert.Contains(t, app.renderedHTML, `data-route="config"`) | |
| 293 | + | assert.Contains(t, app.renderedHTML, `class="sv-section"`) | |
| 294 | + | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func TestConfigHTMLFallsBackForMalformedKeybindings(t *testing.T) { | |
| 298 | + | t.Parallel() | |
| 299 | + | ||
| 300 | + | html := configHTML(port.SystemviewConfigPayload{}, map[string]any{"groups": "oops"}) | |
| 301 | + | ||
| 302 | + | assert.Contains(t, html, "Keybindings unavailable") | |
| 303 | + | assert.NotContains(t, html, "groups[0].bindings[0].action") | |
| 132 | 304 | } | |
| 133 | 305 | ||
| 134 | 306 | type fakeDOM struct { | |
| @@ -52,7 +52,7 @@ func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) { | |||||
| 52 | assert.Contains(t, dom.html, "systemviews") | 52 | assert.Contains(t, dom.html, "systemviews") | ||
| 53 | } | 53 | } | ||
| 54 | 54 | ||||
| 55 | - | func TestAppLoadInitialHistoryRouteRendersEntries(t *testing.T) { | 55 | + | func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { |
| 56 | t.Parallel() | 56 | t.Parallel() | ||
| 57 | 57 | ||||
| 58 | history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ | 58 | history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ | ||
| @@ -73,6 +73,62 @@ func TestAppLoadInitialHistoryRouteRendersEntries(t *testing.T) { | |||||
| 73 | assert.True(t, history.called) | 73 | assert.True(t, history.called) | ||
| 74 | assert.Equal(t, 25, history.limit) | 74 | assert.Equal(t, 25, history.limit) | ||
| 75 | assert.Equal(t, 0, history.offset) | 75 | assert.Equal(t, 0, history.offset) | ||
| 76 | + | ||||
| 77 | + | // Shell frame present, no full document wrapper. | |||
| 78 | + | assert.NotContains(t, app.renderedHTML, "<html") | |||
| 79 | + | assert.NotContains(t, app.renderedHTML, "<head") | |||
| 80 | + | assert.NotContains(t, app.renderedHTML, "<body") | |||
| 81 | + | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |||
| 82 | + | assert.Contains(t, app.renderedHTML, `data-route="history"`) | |||
| 83 | + | assert.Contains(t, app.renderedHTML, `class="sv-section"`) | |||
| 84 | + | assert.Contains(t, app.renderedHTML, `class="sv-list"`) | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | func TestAppLoadInitialHistoryRouteAppliesThemeTokens(t *testing.T) { | |||
| 88 | + | t.Parallel() | |||
| 89 | + | ||||
| 90 | + | history := &fakeHistoryService{entries: []*entity.HistoryEntry{{ | |||
| 91 | + | URL: "https://example.com", | |||
| 92 | + | Title: "Example", | |||
| 93 | + | }}} | |||
| 94 | + | config := &fakeConfigService{current: port.SystemviewConfigPayload{ | |||
| 95 | + | Appearance: port.WebUIAppearanceConfig{ | |||
| 96 | + | ColorScheme: "prefer-light", | |||
| 97 | + | LightPalette: port.ColorPalette{ | |||
| 98 | + | Background: "#ffffff", | |||
| 99 | + | Surface: "#fafafa", | |||
| 100 | + | SurfaceVariant: "#eeeeee", | |||
| 101 | + | Text: "#111111", | |||
| 102 | + | Muted: "#666666", | |||
| 103 | + | Accent: "#0055ff", | |||
| 104 | + | Border: "#dddddd", | |||
| 105 | + | }, | |||
| 106 | + | DarkPalette: port.ColorPalette{ | |||
| 107 | + | Background: "#111111", | |||
| 108 | + | Surface: "#1a1a1a", | |||
| 109 | + | SurfaceVariant: "#2a2a2a", | |||
| 110 | + | Text: "#f5f5f5", | |||
| 111 | + | Muted: "#a0a0a0", | |||
| 112 | + | Accent: "#66aaff", | |||
| 113 | + | Border: "#333333", | |||
| 114 | + | }, | |||
| 115 | + | SansFont: "Inter", | |||
| 116 | + | SerifFont: "Georgia", | |||
| 117 | + | MonospaceFont: "JetBrains Mono", | |||
| 118 | + | DefaultFontSize: 16, | |||
| 119 | + | }, | |||
| 120 | + | }} | |||
| 121 | + | ||||
| 122 | + | app := NewApp(Dependencies{ | |||
| 123 | + | Config: config, | |||
| 124 | + | History: history, | |||
| 125 | + | LocationURI: "dumb://history", | |||
| 126 | + | }) | |||
| 127 | + | ||||
| 128 | + | require.NoError(t, app.LoadInitial(context.Background())) | |||
| 129 | + | assert.Contains(t, app.renderedHTML, `class="sv-app sv-light"`) | |||
| 130 | + | assert.Contains(t, app.renderedHTML, `--sv-background: #ffffff;`) | |||
| 131 | + | assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #eeeeee;`) | |||
| 76 | } | 132 | } | ||
| 77 | 133 | ||||
| 78 | func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | 134 | func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | ||
| @@ -101,18 +157,92 @@ func TestAppLoadInitialFavoritesRouteRendersData(t *testing.T) { | |||||
| 101 | assert.True(t, favorites.calledList) | 157 | assert.True(t, favorites.calledList) | ||
| 102 | assert.True(t, favorites.calledFolders) | 158 | assert.True(t, favorites.calledFolders) | ||
| 103 | assert.True(t, favorites.calledTags) | 159 | assert.True(t, favorites.calledTags) | ||
| 160 | + | ||||
| 161 | + | // Shell frame present, no full document wrapper. | |||
| 162 | + | assert.NotContains(t, app.renderedHTML, "<html") | |||
| 163 | + | assert.NotContains(t, app.renderedHTML, "<head") | |||
| 164 | + | assert.NotContains(t, app.renderedHTML, "<body") | |||
| 165 | + | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |||
| 166 | + | assert.Contains(t, app.renderedHTML, `data-route="favorites"`) | |||
| 167 | + | assert.Contains(t, app.renderedHTML, `class="sv-section"`) | |||
| 168 | + | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | |||
| 104 | } | 169 | } | ||
| 105 | 170 | ||||
| 106 | func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | 171 | func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | ||
| 107 | t.Parallel() | 172 | t.Parallel() | ||
| 108 | 173 | ||||
| 109 | config := port.SystemviewConfigPayload{ | 174 | config := port.SystemviewConfigPayload{ | ||
| 110 | - | EngineType: "webkit", | 175 | + | EngineType: "webkit", |
| 111 | - | DefaultSearchEngine: "DuckDuckGo", | 176 | + | Appearance: port.WebUIAppearanceConfig{ |
| 177 | + | ColorScheme: "prefer-dark", | |||
| 178 | + | SansFont: "Inter", | |||
| 179 | + | SerifFont: "Georgia", | |||
| 180 | + | MonospaceFont: "JetBrains Mono", | |||
| 181 | + | DefaultFontSize: 16, | |||
| 182 | + | LightPalette: port.ColorPalette{ | |||
| 183 | + | Background: "#ffffff", | |||
| 184 | + | Surface: "#fafafa", | |||
| 185 | + | SurfaceVariant: "#eeeeee", | |||
| 186 | + | Text: "#111111", | |||
| 187 | + | Muted: "#666666", | |||
| 188 | + | Accent: "#0055ff", | |||
| 189 | + | Border: "#dddddd", | |||
| 190 | + | }, | |||
| 191 | + | DarkPalette: port.ColorPalette{ | |||
| 192 | + | Background: "#111111", | |||
| 193 | + | Surface: "#1a1a1a", | |||
| 194 | + | SurfaceVariant: "#2a2a2a", | |||
| 195 | + | Text: "#f5f5f5", | |||
| 196 | + | Muted: "#a0a0a0", | |||
| 197 | + | Accent: "#66aaff", | |||
| 198 | + | Border: "#333333", | |||
| 199 | + | }, | |||
| 200 | + | }, | |||
| 201 | + | Performance: port.SystemviewPerformancePayload{ | |||
| 202 | + | Profile: "balanced", | |||
| 203 | + | Custom: port.SystemviewCustomPerformancePayload{ | |||
| 204 | + | SkiaCPUThreads: 4, | |||
| 205 | + | SkiaGPUThreads: 2, | |||
| 206 | + | WebProcessMemoryMB: 512, | |||
| 207 | + | NetworkProcessMemoryMB: 128, | |||
| 208 | + | WebViewPoolPrewarm: 1, | |||
| 209 | + | }, | |||
| 210 | + | Hardware: port.SystemviewHardwarePayload{ | |||
| 211 | + | CPUCores: 8, | |||
| 212 | + | CPUThreads: 16, | |||
| 213 | + | TotalRAMMB: 32768, | |||
| 214 | + | GPUVendor: "NVIDIA", | |||
| 215 | + | GPUName: "RTX 4060", | |||
| 216 | + | VRAMMB: 8192, | |||
| 217 | + | }, | |||
| 218 | + | }, | |||
| 219 | + | DefaultSearchEngine: "https://duckduckgo.com/?q=%s", | |||
| 220 | + | SearchShortcuts: map[string]port.SearchShortcut{ | |||
| 221 | + | "ddg": { | |||
| 222 | + | URL: "https://duckduckgo.com/?q=%s", | |||
| 223 | + | Description: "DuckDuckGo", | |||
| 224 | + | }, | |||
| 225 | + | }, | |||
| 112 | } | 226 | } | ||
| 113 | service := &fakeConfigService{ | 227 | service := &fakeConfigService{ | ||
| 114 | - | current: config, | 228 | + | current: config, |
| 115 | - | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{Mode: "default", DisplayName: "Default", Bindings: []port.KeybindingEntry{{Action: "open", Description: "Open", Keys: []string{"ctrl+o"}}}}}}, | 229 | + | keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{ |
| 230 | + | { | |||
| 231 | + | Mode: "default", | |||
| 232 | + | DisplayName: "Default", | |||
| 233 | + | Bindings: []port.KeybindingEntry{ | |||
| 234 | + | {Action: "open", Description: "Open", Keys: []string{"ctrl+o"}, DefaultKeys: []string{"ctrl+o"}}, | |||
| 235 | + | {Action: "new-tab", Description: "New tab", Keys: []string{"ctrl+t"}, DefaultKeys: []string{"ctrl+shift+t"}, IsCustom: true}, | |||
| 236 | + | }, | |||
| 237 | + | }, | |||
| 238 | + | { | |||
| 239 | + | Mode: "search", | |||
| 240 | + | DisplayName: "Search", | |||
| 241 | + | Bindings: []port.KeybindingEntry{ | |||
| 242 | + | {Action: "find", Description: "Find", Keys: []string{"ctrl+f"}, DefaultKeys: []string{"ctrl+f"}}, | |||
| 243 | + | }, | |||
| 244 | + | }, | |||
| 245 | + | }}, | |||
| 116 | } | 246 | } | ||
| 117 | 247 | ||||
| 118 | app := NewApp(Dependencies{ | 248 | app := NewApp(Dependencies{ | ||
| @@ -125,10 +255,52 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |||||
| 125 | require.NotNil(t, app.config) | 255 | require.NotNil(t, app.config) | ||
| 126 | assert.Equal(t, "webkit", app.config.EngineType) | 256 | assert.Equal(t, "webkit", app.config.EngineType) | ||
| 127 | assert.Contains(t, app.renderedHTML, "webkit") | 257 | assert.Contains(t, app.renderedHTML, "webkit") | ||
| 128 | - | assert.Contains(t, app.renderedHTML, "DuckDuckGo") | 258 | + | assert.Contains(t, app.renderedHTML, "https://duckduckgo.com/?q=%s") |
| 129 | - | assert.Contains(t, app.renderedHTML, "1 group") | 259 | + | assert.Contains(t, app.renderedHTML, "appearance.color_scheme") |
| 260 | + | assert.Contains(t, app.renderedHTML, "prefer-dark") | |||
| 261 | + | assert.Contains(t, app.renderedHTML, "appearance.sans_font") | |||
| 262 | + | assert.Contains(t, app.renderedHTML, "Inter") | |||
| 263 | + | assert.Contains(t, app.renderedHTML, "appearance.light_palette.background") | |||
| 264 | + | assert.Contains(t, app.renderedHTML, "#ffffff") | |||
| 265 | + | assert.Contains(t, app.renderedHTML, "performance.profile") | |||
| 266 | + | assert.Contains(t, app.renderedHTML, "balanced") | |||
| 267 | + | assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads") | |||
| 268 | + | assert.Contains(t, app.renderedHTML, "4") | |||
| 269 | + | assert.Contains(t, app.renderedHTML, "performance.hardware.gpu_vendor") | |||
| 270 | + | assert.Contains(t, app.renderedHTML, "NVIDIA") | |||
| 271 | + | assert.Contains(t, app.renderedHTML, "search_shortcuts.ddg.url") | |||
| 272 | + | assert.Contains(t, app.renderedHTML, "Default") | |||
| 273 | + | assert.Contains(t, app.renderedHTML, "Search") | |||
| 274 | + | assert.Contains(t, app.renderedHTML, "Open") | |||
| 275 | + | assert.Contains(t, app.renderedHTML, "ctrl+o") | |||
| 276 | + | assert.Contains(t, app.renderedHTML, "New tab") | |||
| 277 | + | assert.Contains(t, app.renderedHTML, "ctrl+t") | |||
| 278 | + | assert.Contains(t, app.renderedHTML, "ctrl+shift+t") | |||
| 279 | + | assert.Contains(t, app.renderedHTML, "Find") | |||
| 280 | + | assert.Contains(t, app.renderedHTML, "open") | |||
| 281 | + | assert.Contains(t, app.renderedHTML, "default") | |||
| 282 | + | assert.Contains(t, app.renderedHTML, "custom") | |||
| 283 | + | assert.NotContains(t, app.renderedHTML, "groups[0].bindings[0].action") | |||
| 130 | assert.True(t, service.calledCurrent) | 284 | assert.True(t, service.calledCurrent) | ||
| 131 | assert.True(t, service.calledKeybindings) | 285 | assert.True(t, service.calledKeybindings) | ||
| 286 | + | ||||
| 287 | + | // Shell frame present, no full document wrapper. | |||
| 288 | + | assert.NotContains(t, app.renderedHTML, "<html") | |||
| 289 | + | assert.NotContains(t, app.renderedHTML, "<head") | |||
| 290 | + | assert.NotContains(t, app.renderedHTML, "<body") | |||
| 291 | + | assert.Contains(t, app.renderedHTML, `class="sv-shell"`) | |||
| 292 | + | assert.Contains(t, app.renderedHTML, `data-route="config"`) | |||
| 293 | + | assert.Contains(t, app.renderedHTML, `class="sv-section"`) | |||
| 294 | + | assert.Contains(t, app.renderedHTML, `class="sv-meta"`) | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func TestConfigHTMLFallsBackForMalformedKeybindings(t *testing.T) { | |||
| 298 | + | t.Parallel() | |||
| 299 | + | ||||
| 300 | + | html := configHTML(port.SystemviewConfigPayload{}, map[string]any{"groups": "oops"}) | |||
| 301 | + | ||||
| 302 | + | assert.Contains(t, html, "Keybindings unavailable") | |||
| 303 | + | assert.NotContains(t, html, "groups[0].bindings[0].action") | |||
| 132 | } | 304 | } | ||
| 133 | 305 | ||||
| 134 | type fakeDOM struct { | 306 | type fakeDOM struct { | ||
M internal/ui/systemviews/config_view.go +422 -51
| @@ -3,6 +3,8 @@ package systemviews | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "html" | |
| 6 | + | "reflect" | |
| 7 | + | "sort" | |
| 6 | 8 | "strings" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -14,93 +16,462 @@ func configHTML(cfg port.SystemviewConfigPayload, keybindings any) string { | |||
| 14 | 16 | if loaded { | |
| 15 | 17 | status = "loaded" | |
| 16 | 18 | } | |
| 17 | - | ||
| 18 | - | return fmt.Sprintf(`<!doctype html> | |
| 19 | - | <html lang="en"> | |
| 20 | - | <head> | |
| 21 | - | <meta charset="utf-8"> | |
| 22 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 23 | - | <title>systemviews config</title> | |
| 24 | - | </head> | |
| 25 | - | <body> | |
| 26 | - | <main id="app" data-route="config"> | |
| 27 | - | <h1>Config</h1> | |
| 28 | - | <p>Engine: %s</p> | |
| 29 | - | <p>Default search engine: %s</p> | |
| 30 | - | <p>Keybindings: %s (%d %s, %d %s)</p> | |
| 31 | - | </main> | |
| 32 | - | </body> | |
| 33 | - | </html>`, | |
| 34 | - | html.EscapeString(cfg.EngineType), | |
| 35 | - | html.EscapeString(cfg.DefaultSearchEngine), | |
| 19 | + | keybindingSummary := fmt.Sprintf( | |
| 20 | + | "Keybindings %s (%d %s, %d %s)", | |
| 36 | 21 | status, | |
| 37 | - | groups, plural(groups, "group"), | |
| 38 | - | bindings, plural(bindings, "binding"), | |
| 22 | + | groups, | |
| 23 | + | plural(groups, "group"), | |
| 24 | + | bindings, | |
| 25 | + | plural(bindings, "binding"), | |
| 39 | 26 | ) | |
| 27 | + | content := []string{ | |
| 28 | + | metaHTML(keybindingSummary), | |
| 29 | + | "<h3>Config values</h3>" + kvHTML(flattenValues(cfg)), | |
| 30 | + | "<h3>Keybindings</h3>" + renderKeybindingsSection(keybindings), | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | return sectionHTML("", "Config", strings.Join(content, "")) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func flattenValues(value any) []kvPair { | |
| 37 | + | rows := make([]kvPair, 0) | |
| 38 | + | flattenValue(&rows, "", reflect.ValueOf(value)) | |
| 39 | + | return rows | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func flattenValue(rows *[]kvPair, path string, value reflect.Value) { | |
| 43 | + | if !value.IsValid() { | |
| 44 | + | appendFlatValue(rows, path, "null") | |
| 45 | + | return | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | for value.Kind() == reflect.Interface { | |
| 49 | + | if value.IsNil() { | |
| 50 | + | appendFlatValue(rows, path, "null") | |
| 51 | + | return | |
| 52 | + | } | |
| 53 | + | value = value.Elem() | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | for value.Kind() == reflect.Pointer { | |
| 57 | + | if value.IsNil() { | |
| 58 | + | appendFlatValue(rows, path, "null") | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | value = value.Elem() | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | switch value.Kind() { | |
| 65 | + | case reflect.Struct: | |
| 66 | + | typeOfValue := value.Type() | |
| 67 | + | for i := 0; i < value.NumField(); i++ { | |
| 68 | + | field := typeOfValue.Field(i) | |
| 69 | + | if !field.IsExported() { | |
| 70 | + | continue | |
| 71 | + | } | |
| 72 | + | name := jsonFieldName(field) | |
| 73 | + | if name == "-" { | |
| 74 | + | continue | |
| 75 | + | } | |
| 76 | + | nextPath := name | |
| 77 | + | if path != "" { | |
| 78 | + | nextPath = path + "." + name | |
| 79 | + | } | |
| 80 | + | flattenValue(rows, nextPath, value.Field(i)) | |
| 81 | + | } | |
| 82 | + | case reflect.Map: | |
| 83 | + | if value.Len() == 0 { | |
| 84 | + | appendFlatValue(rows, path, "{}") | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | keys := value.MapKeys() | |
| 89 | + | sort.Slice(keys, func(i, j int) bool { | |
| 90 | + | return mapKeyString(keys[i]) < mapKeyString(keys[j]) | |
| 91 | + | }) | |
| 92 | + | ||
| 93 | + | for _, key := range keys { | |
| 94 | + | name := mapKeyString(key) | |
| 95 | + | nextPath := name | |
| 96 | + | if path != "" { | |
| 97 | + | nextPath = path + "." + name | |
| 98 | + | } | |
| 99 | + | flattenValue(rows, nextPath, value.MapIndex(key)) | |
| 100 | + | } | |
| 101 | + | case reflect.Slice, reflect.Array: | |
| 102 | + | if value.Len() == 0 { | |
| 103 | + | appendFlatValue(rows, path, "[]") | |
| 104 | + | return | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | for i := 0; i < value.Len(); i++ { | |
| 108 | + | nextPath := fmt.Sprintf("%s[%d]", path, i) | |
| 109 | + | if path == "" { | |
| 110 | + | nextPath = fmt.Sprintf("[%d]", i) | |
| 111 | + | } | |
| 112 | + | flattenValue(rows, nextPath, value.Index(i)) | |
| 113 | + | } | |
| 114 | + | default: | |
| 115 | + | appendFlatValue(rows, path, formatFlatValue(value)) | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | func appendFlatValue(rows *[]kvPair, path, value string) { | |
| 120 | + | if strings.TrimSpace(path) == "" { | |
| 121 | + | path = "value" | |
| 122 | + | } | |
| 123 | + | *rows = append(*rows, kvPair{Label: path, Value: value}) | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | func formatFlatValue(value reflect.Value) string { | |
| 127 | + | if !value.IsValid() { | |
| 128 | + | return "null" | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | switch value.Kind() { | |
| 132 | + | case reflect.String: | |
| 133 | + | return value.String() | |
| 134 | + | case reflect.Bool: | |
| 135 | + | return fmt.Sprint(value.Bool()) | |
| 136 | + | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: | |
| 137 | + | return fmt.Sprint(value.Int()) | |
| 138 | + | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: | |
| 139 | + | return fmt.Sprint(value.Uint()) | |
| 140 | + | case reflect.Float32, reflect.Float64: | |
| 141 | + | return fmt.Sprint(value.Float()) | |
| 142 | + | case reflect.Complex64, reflect.Complex128: | |
| 143 | + | return fmt.Sprint(value.Complex()) | |
| 144 | + | default: | |
| 145 | + | return fmt.Sprint(value.Interface()) | |
| 146 | + | } | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | func jsonFieldName(field reflect.StructField) string { | |
| 150 | + | name := field.Tag.Get("json") | |
| 151 | + | if name == "" { | |
| 152 | + | return field.Name | |
| 153 | + | } | |
| 154 | + | if idx := strings.Index(name, ","); idx >= 0 { | |
| 155 | + | name = name[:idx] | |
| 156 | + | } | |
| 157 | + | if name == "" { | |
| 158 | + | return field.Name | |
| 159 | + | } | |
| 160 | + | return name | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | func mapKeyString(value reflect.Value) string { | |
| 164 | + | if value.Kind() == reflect.String { | |
| 165 | + | return value.String() | |
| 166 | + | } | |
| 167 | + | return fmt.Sprint(value.Interface()) | |
| 40 | 168 | } | |
| 41 | 169 | ||
| 42 | 170 | func summarizeKeybindings(keybindings any) (groups, bindings int, loaded bool) { | |
| 171 | + | renderedGroups, ok := collectKeybindingGroups(keybindings) | |
| 172 | + | if !ok { | |
| 173 | + | return 0, 0, false | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | for _, group := range renderedGroups { | |
| 177 | + | groups++ | |
| 178 | + | bindings += len(group.Bindings) | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | return groups, bindings, true | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | func renderKeybindingsSection(keybindings any) string { | |
| 185 | + | groups, ok := collectKeybindingGroups(keybindings) | |
| 186 | + | if !ok { | |
| 187 | + | return emptyStateHTML("Keybindings unavailable") | |
| 188 | + | } | |
| 189 | + | if len(groups) == 0 { | |
| 190 | + | return emptyStateHTML("No keybindings configured") | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | return renderKeybindingGroups(groups) | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | func collectKeybindingGroups(keybindings any) ([]renderedKeybindingGroup, bool) { | |
| 43 | 197 | switch v := keybindings.(type) { | |
| 44 | 198 | case nil: | |
| 45 | - | return 0, 0, false | |
| 199 | + | return nil, false | |
| 46 | 200 | case port.KeybindingsConfig: | |
| 47 | - | return countKeybindingGroups(v.Groups), countKeybindingBindings(v.Groups), true | |
| 201 | + | return convertPortKeybindingGroups(v.Groups), true | |
| 48 | 202 | case *port.KeybindingsConfig: | |
| 49 | 203 | if v == nil { | |
| 50 | - | return 0, 0, false | |
| 204 | + | return nil, false | |
| 51 | 205 | } | |
| 52 | - | return countKeybindingGroups(v.Groups), countKeybindingBindings(v.Groups), true | |
| 206 | + | return convertPortKeybindingGroups(v.Groups), true | |
| 53 | 207 | case map[string]any: | |
| 54 | - | return countBridgeKeybindings(v) | |
| 208 | + | return convertBridgeKeybindingGroups(v) | |
| 55 | 209 | default: | |
| 56 | - | return 1, 0, true | |
| 210 | + | return nil, false | |
| 211 | + | } | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func convertPortKeybindingGroups(groups []port.KeybindingGroup) []renderedKeybindingGroup { | |
| 215 | + | rendered := make([]renderedKeybindingGroup, 0, len(groups)) | |
| 216 | + | for _, group := range groups { | |
| 217 | + | rendered = append(rendered, renderedKeybindingGroup{ | |
| 218 | + | DisplayName: strings.TrimSpace(group.DisplayName), | |
| 219 | + | Mode: strings.TrimSpace(group.Mode), | |
| 220 | + | Activation: strings.TrimSpace(group.Activation), | |
| 221 | + | Bindings: convertPortKeybindingEntries(group.Bindings), | |
| 222 | + | }) | |
| 57 | 223 | } | |
| 224 | + | return rendered | |
| 58 | 225 | } | |
| 59 | 226 | ||
| 60 | - | func countBridgeKeybindings(data map[string]any) (groups, bindings int, loaded bool) { | |
| 61 | - | groupValue, ok := data["groups"] | |
| 227 | + | func convertPortKeybindingEntries(bindings []port.KeybindingEntry) []renderedKeybinding { | |
| 228 | + | rendered := make([]renderedKeybinding, 0, len(bindings)) | |
| 229 | + | for _, binding := range bindings { | |
| 230 | + | rendered = append(rendered, renderedKeybinding{ | |
| 231 | + | Description: strings.TrimSpace(binding.Description), | |
| 232 | + | Action: strings.TrimSpace(binding.Action), | |
| 233 | + | Keys: append([]string(nil), binding.Keys...), | |
| 234 | + | DefaultKeys: append([]string(nil), binding.DefaultKeys...), | |
| 235 | + | IsCustom: binding.IsCustom, | |
| 236 | + | }) | |
| 237 | + | } | |
| 238 | + | return rendered | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func convertBridgeKeybindingGroups(data map[string]any) ([]renderedKeybindingGroup, bool) { | |
| 242 | + | rawGroups, ok := data["groups"] | |
| 243 | + | if !ok || rawGroups == nil { | |
| 244 | + | return nil, false | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | groupValues, ok := anySlice(rawGroups) | |
| 62 | 248 | if !ok { | |
| 63 | - | return 1, 0, true | |
| 249 | + | return nil, false | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | rendered := make([]renderedKeybindingGroup, 0, len(groupValues)) | |
| 253 | + | for _, rawGroup := range groupValues { | |
| 254 | + | groupMap, ok := rawGroup.(map[string]any) | |
| 255 | + | if !ok { | |
| 256 | + | return nil, false | |
| 257 | + | } | |
| 258 | + | group, ok := convertBridgeKeybindingGroup(groupMap) | |
| 259 | + | if !ok { | |
| 260 | + | return nil, false | |
| 261 | + | } | |
| 262 | + | rendered = append(rendered, group) | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | return rendered, true | |
| 266 | + | } | |
| 267 | + | ||
| 268 | + | func convertBridgeKeybindingGroup(group map[string]any) (renderedKeybindingGroup, bool) { | |
| 269 | + | bindings := []renderedKeybinding{} | |
| 270 | + | if rawBindings, ok := group["bindings"]; ok && rawBindings != nil { | |
| 271 | + | converted, ok := convertBridgeKeybindingEntries(rawBindings) | |
| 272 | + | if !ok { | |
| 273 | + | return renderedKeybindingGroup{}, false | |
| 274 | + | } | |
| 275 | + | bindings = converted | |
| 64 | 276 | } | |
| 65 | 277 | ||
| 66 | - | groupList, ok := groupValue.([]any) | |
| 278 | + | return renderedKeybindingGroup{ | |
| 279 | + | DisplayName: strings.TrimSpace(stringValue(group["display_name"])), | |
| 280 | + | Mode: strings.TrimSpace(stringValue(group["mode"])), | |
| 281 | + | Activation: strings.TrimSpace(stringValue(group["activation"])), | |
| 282 | + | Bindings: bindings, | |
| 283 | + | }, true | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func convertBridgeKeybindingEntries(rawBindings any) ([]renderedKeybinding, bool) { | |
| 287 | + | bindingValues, ok := anySlice(rawBindings) | |
| 67 | 288 | if !ok { | |
| 68 | - | return 1, 0, true | |
| 289 | + | return nil, false | |
| 69 | 290 | } | |
| 70 | 291 | ||
| 71 | - | for _, group := range groupList { | |
| 72 | - | groupMap, ok := group.(map[string]any) | |
| 292 | + | rendered := make([]renderedKeybinding, 0, len(bindingValues)) | |
| 293 | + | for _, rawBinding := range bindingValues { | |
| 294 | + | bindingMap, ok := rawBinding.(map[string]any) | |
| 73 | 295 | if !ok { | |
| 74 | - | continue | |
| 296 | + | return nil, false | |
| 75 | 297 | } | |
| 76 | - | groups++ | |
| 77 | - | if bindingValue, ok := groupMap["bindings"]; ok { | |
| 78 | - | if bindingList, ok := bindingValue.([]any); ok { | |
| 79 | - | bindings += len(bindingList) | |
| 298 | + | rendered = append(rendered, renderedKeybinding{ | |
| 299 | + | Description: strings.TrimSpace(stringValue(bindingMap["description"])), | |
| 300 | + | Action: strings.TrimSpace(stringValue(bindingMap["action"])), | |
| 301 | + | Keys: stringSlice(bindingMap["keys"]), | |
| 302 | + | DefaultKeys: stringSlice(bindingMap["default_keys"]), | |
| 303 | + | IsCustom: boolValue(bindingMap["is_custom"]), | |
| 304 | + | }) | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | return rendered, true | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | func anySlice(value any) ([]any, bool) { | |
| 311 | + | switch v := value.(type) { | |
| 312 | + | case []any: | |
| 313 | + | return v, true | |
| 314 | + | case []map[string]any: | |
| 315 | + | items := make([]any, len(v)) | |
| 316 | + | for i, item := range v { | |
| 317 | + | items[i] = item | |
| 318 | + | } | |
| 319 | + | return items, true | |
| 320 | + | default: | |
| 321 | + | return nil, false | |
| 322 | + | } | |
| 323 | + | } | |
| 324 | + | ||
| 325 | + | func stringValue(value any) string { | |
| 326 | + | s, _ := value.(string) | |
| 327 | + | return s | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | func stringSlice(value any) []string { | |
| 331 | + | switch v := value.(type) { | |
| 332 | + | case []string: | |
| 333 | + | return append([]string(nil), v...) | |
| 334 | + | case []any: | |
| 335 | + | out := make([]string, 0, len(v)) | |
| 336 | + | for _, item := range v { | |
| 337 | + | text, ok := item.(string) | |
| 338 | + | if !ok { | |
| 339 | + | return nil | |
| 80 | 340 | } | |
| 341 | + | out = append(out, text) | |
| 81 | 342 | } | |
| 343 | + | return out | |
| 344 | + | default: | |
| 345 | + | return nil | |
| 82 | 346 | } | |
| 347 | + | } | |
| 83 | 348 | ||
| 84 | - | return groups, bindings, true | |
| 349 | + | func boolValue(value any) bool { | |
| 350 | + | b, _ := value.(bool) | |
| 351 | + | return b | |
| 85 | 352 | } | |
| 86 | 353 | ||
| 87 | - | func countKeybindingGroups(groups []port.KeybindingGroup) int { | |
| 88 | - | count := 0 | |
| 354 | + | func renderKeybindingGroups(groups []renderedKeybindingGroup) string { | |
| 355 | + | var rendered strings.Builder | |
| 89 | 356 | for _, group := range groups { | |
| 90 | - | if group.Mode == "" && group.DisplayName == "" && len(group.Bindings) == 0 { | |
| 91 | - | continue | |
| 357 | + | rendered.WriteString(renderKeybindingGroup(group)) | |
| 358 | + | } | |
| 359 | + | return rendered.String() | |
| 360 | + | } | |
| 361 | + | ||
| 362 | + | type renderedKeybindingGroup struct { | |
| 363 | + | DisplayName string | |
| 364 | + | Mode string | |
| 365 | + | Activation string | |
| 366 | + | Bindings []renderedKeybinding | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | type renderedKeybinding struct { | |
| 370 | + | Description string | |
| 371 | + | Action string | |
| 372 | + | Keys []string | |
| 373 | + | DefaultKeys []string | |
| 374 | + | IsCustom bool | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | func renderKeybindingGroup(group renderedKeybindingGroup) string { | |
| 378 | + | title := group.DisplayName | |
| 379 | + | if strings.TrimSpace(title) == "" { | |
| 380 | + | title = group.Mode | |
| 381 | + | } | |
| 382 | + | if strings.TrimSpace(title) == "" { | |
| 383 | + | title = "Keybinding group" | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | var body strings.Builder | |
| 387 | + | body.WriteString(`<div class="sv-group">`) | |
| 388 | + | body.WriteString(`<div class="sv-group-header">`) | |
| 389 | + | body.WriteString(`<div>`) | |
| 390 | + | body.WriteString(`<h4>` + html.EscapeString(title) + `</h4>`) | |
| 391 | + | ||
| 392 | + | meta := make([]string, 0, 2) | |
| 393 | + | if strings.TrimSpace(group.Mode) != "" { | |
| 394 | + | meta = append(meta, "mode: "+group.Mode) | |
| 395 | + | } | |
| 396 | + | if strings.TrimSpace(group.Activation) != "" { | |
| 397 | + | meta = append(meta, "activation: "+group.Activation) | |
| 398 | + | } | |
| 399 | + | if len(meta) > 0 { | |
| 400 | + | body.WriteString(`<p class="sv-meta">` + html.EscapeString(strings.Join(meta, " • ")) + `</p>`) | |
| 401 | + | } | |
| 402 | + | body.WriteString(`</div></div>`) | |
| 403 | + | body.WriteString(`<div class="sv-group-body">`) | |
| 404 | + | if len(group.Bindings) == 0 { | |
| 405 | + | body.WriteString(emptyStateHTML("No bindings in this group")) | |
| 406 | + | } else { | |
| 407 | + | for _, binding := range group.Bindings { | |
| 408 | + | body.WriteString(renderKeybinding(binding)) | |
| 92 | 409 | } | |
| 93 | - | count++ | |
| 94 | 410 | } | |
| 95 | - | return count | |
| 411 | + | body.WriteString(`</div></div>`) | |
| 412 | + | ||
| 413 | + | return body.String() | |
| 96 | 414 | } | |
| 97 | 415 | ||
| 98 | - | func countKeybindingBindings(groups []port.KeybindingGroup) int { | |
| 99 | - | count := 0 | |
| 100 | - | for _, group := range groups { | |
| 101 | - | count += len(group.Bindings) | |
| 416 | + | func renderKeybinding(binding renderedKeybinding) string { | |
| 417 | + | description := strings.TrimSpace(binding.Description) | |
| 418 | + | if description == "" { | |
| 419 | + | description = strings.TrimSpace(binding.Action) | |
| 420 | + | } | |
| 421 | + | if description == "" { | |
| 422 | + | description = "Unnamed binding" | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | action := strings.TrimSpace(binding.Action) | |
| 426 | + | if action == "" { | |
| 427 | + | action = "binding" | |
| 428 | + | } | |
| 429 | + | ||
| 430 | + | custom := binding.IsCustom || !sameStringSlice(binding.Keys, binding.DefaultKeys) | |
| 431 | + | status := "default" | |
| 432 | + | if custom { | |
| 433 | + | status = "custom" | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | var body strings.Builder | |
| 437 | + | body.WriteString(`<div class="sv-binding">`) | |
| 438 | + | body.WriteString(`<div class="sv-binding-header">`) | |
| 439 | + | body.WriteString(`<div class="sv-binding-description">` + html.EscapeString(description) + `</div>`) | |
| 440 | + | body.WriteString(`<div class="sv-binding-keys">` + keyListHTML(binding.Keys) + `</div>`) | |
| 441 | + | body.WriteString(`</div>`) | |
| 442 | + | body.WriteString(`<div class="sv-binding-meta">`) | |
| 443 | + | body.WriteString(`<span>` + html.EscapeString("action: "+action) + `</span>`) | |
| 444 | + | body.WriteString(`<span>` + html.EscapeString(status) + `</span>`) | |
| 445 | + | if custom && len(binding.DefaultKeys) > 0 { | |
| 446 | + | body.WriteString(`<span>default: ` + keyListHTML(binding.DefaultKeys) + `</span>`) | |
| 447 | + | } | |
| 448 | + | body.WriteString(`</div></div>`) | |
| 449 | + | ||
| 450 | + | return body.String() | |
| 451 | + | } | |
| 452 | + | ||
| 453 | + | func keyListHTML(keys []string) string { | |
| 454 | + | if len(keys) == 0 { | |
| 455 | + | return `<span class="sv-key sv-key-empty">Unassigned</span>` | |
| 456 | + | } | |
| 457 | + | ||
| 458 | + | parts := make([]string, 0, len(keys)) | |
| 459 | + | for _, key := range keys { | |
| 460 | + | parts = append(parts, `<span class="sv-key">`+html.EscapeString(key)+`</span>`) | |
| 461 | + | } | |
| 462 | + | return strings.Join(parts, " ") | |
| 463 | + | } | |
| 464 | + | ||
| 465 | + | func sameStringSlice(a, b []string) bool { | |
| 466 | + | if len(a) != len(b) { | |
| 467 | + | return false | |
| 468 | + | } | |
| 469 | + | for i := range a { | |
| 470 | + | if a[i] != b[i] { | |
| 471 | + | return false | |
| 472 | + | } | |
| 102 | 473 | } | |
| 103 | - | return count | |
| 474 | + | return true | |
| 104 | 475 | } | |
| 105 | 476 | ||
| 106 | 477 | func plural(count int, singular string) string { | |
| @@ -3,6 +3,8 @@ package systemviews | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "html" | 5 | "html" | ||
| 6 | + | "reflect" | |||
| 7 | + | "sort" | |||
| 6 | "strings" | 8 | "strings" | ||
| 7 | 9 | ||||
| 8 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -14,93 +16,462 @@ func configHTML(cfg port.SystemviewConfigPayload, keybindings any) string { | |||||
| 14 | if loaded { | 16 | if loaded { | ||
| 15 | status = "loaded" | 17 | status = "loaded" | ||
| 16 | } | 18 | } | ||
| 17 | - | 19 | + | keybindingSummary := fmt.Sprintf( | |
| 18 | - | return fmt.Sprintf(`<!doctype html> | 20 | + | "Keybindings %s (%d %s, %d %s)", |
| 19 | - | <html lang="en"> | |||
| 20 | - | <head> | |||
| 21 | - | <meta charset="utf-8"> | |||
| 22 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 23 | - | <title>systemviews config</title> | |||
| 24 | - | </head> | |||
| 25 | - | <body> | |||
| 26 | - | <main id="app" data-route="config"> | |||
| 27 | - | <h1>Config</h1> | |||
| 28 | - | <p>Engine: %s</p> | |||
| 29 | - | <p>Default search engine: %s</p> | |||
| 30 | - | <p>Keybindings: %s (%d %s, %d %s)</p> | |||
| 31 | - | </main> | |||
| 32 | - | </body> | |||
| 33 | - | </html>`, | |||
| 34 | - | html.EscapeString(cfg.EngineType), | |||
| 35 | - | html.EscapeString(cfg.DefaultSearchEngine), | |||
| 36 | status, | 21 | status, | ||
| 37 | - | groups, plural(groups, "group"), | 22 | + | groups, |
| 38 | - | bindings, plural(bindings, "binding"), | 23 | + | plural(groups, "group"), |
| 24 | + | bindings, | |||
| 25 | + | plural(bindings, "binding"), | |||
| 39 | ) | 26 | ) | ||
| 27 | + | content := []string{ | |||
| 28 | + | metaHTML(keybindingSummary), | |||
| 29 | + | "<h3>Config values</h3>" + kvHTML(flattenValues(cfg)), | |||
| 30 | + | "<h3>Keybindings</h3>" + renderKeybindingsSection(keybindings), | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | return sectionHTML("", "Config", strings.Join(content, "")) | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func flattenValues(value any) []kvPair { | |||
| 37 | + | rows := make([]kvPair, 0) | |||
| 38 | + | flattenValue(&rows, "", reflect.ValueOf(value)) | |||
| 39 | + | return rows | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func flattenValue(rows *[]kvPair, path string, value reflect.Value) { | |||
| 43 | + | if !value.IsValid() { | |||
| 44 | + | appendFlatValue(rows, path, "null") | |||
| 45 | + | return | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | for value.Kind() == reflect.Interface { | |||
| 49 | + | if value.IsNil() { | |||
| 50 | + | appendFlatValue(rows, path, "null") | |||
| 51 | + | return | |||
| 52 | + | } | |||
| 53 | + | value = value.Elem() | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | for value.Kind() == reflect.Pointer { | |||
| 57 | + | if value.IsNil() { | |||
| 58 | + | appendFlatValue(rows, path, "null") | |||
| 59 | + | return | |||
| 60 | + | } | |||
| 61 | + | value = value.Elem() | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | switch value.Kind() { | |||
| 65 | + | case reflect.Struct: | |||
| 66 | + | typeOfValue := value.Type() | |||
| 67 | + | for i := 0; i < value.NumField(); i++ { | |||
| 68 | + | field := typeOfValue.Field(i) | |||
| 69 | + | if !field.IsExported() { | |||
| 70 | + | continue | |||
| 71 | + | } | |||
| 72 | + | name := jsonFieldName(field) | |||
| 73 | + | if name == "-" { | |||
| 74 | + | continue | |||
| 75 | + | } | |||
| 76 | + | nextPath := name | |||
| 77 | + | if path != "" { | |||
| 78 | + | nextPath = path + "." + name | |||
| 79 | + | } | |||
| 80 | + | flattenValue(rows, nextPath, value.Field(i)) | |||
| 81 | + | } | |||
| 82 | + | case reflect.Map: | |||
| 83 | + | if value.Len() == 0 { | |||
| 84 | + | appendFlatValue(rows, path, "{}") | |||
| 85 | + | return | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | keys := value.MapKeys() | |||
| 89 | + | sort.Slice(keys, func(i, j int) bool { | |||
| 90 | + | return mapKeyString(keys[i]) < mapKeyString(keys[j]) | |||
| 91 | + | }) | |||
| 92 | + | ||||
| 93 | + | for _, key := range keys { | |||
| 94 | + | name := mapKeyString(key) | |||
| 95 | + | nextPath := name | |||
| 96 | + | if path != "" { | |||
| 97 | + | nextPath = path + "." + name | |||
| 98 | + | } | |||
| 99 | + | flattenValue(rows, nextPath, value.MapIndex(key)) | |||
| 100 | + | } | |||
| 101 | + | case reflect.Slice, reflect.Array: | |||
| 102 | + | if value.Len() == 0 { | |||
| 103 | + | appendFlatValue(rows, path, "[]") | |||
| 104 | + | return | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | for i := 0; i < value.Len(); i++ { | |||
| 108 | + | nextPath := fmt.Sprintf("%s[%d]", path, i) | |||
| 109 | + | if path == "" { | |||
| 110 | + | nextPath = fmt.Sprintf("[%d]", i) | |||
| 111 | + | } | |||
| 112 | + | flattenValue(rows, nextPath, value.Index(i)) | |||
| 113 | + | } | |||
| 114 | + | default: | |||
| 115 | + | appendFlatValue(rows, path, formatFlatValue(value)) | |||
| 116 | + | } | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | func appendFlatValue(rows *[]kvPair, path, value string) { | |||
| 120 | + | if strings.TrimSpace(path) == "" { | |||
| 121 | + | path = "value" | |||
| 122 | + | } | |||
| 123 | + | *rows = append(*rows, kvPair{Label: path, Value: value}) | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | func formatFlatValue(value reflect.Value) string { | |||
| 127 | + | if !value.IsValid() { | |||
| 128 | + | return "null" | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | switch value.Kind() { | |||
| 132 | + | case reflect.String: | |||
| 133 | + | return value.String() | |||
| 134 | + | case reflect.Bool: | |||
| 135 | + | return fmt.Sprint(value.Bool()) | |||
| 136 | + | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: | |||
| 137 | + | return fmt.Sprint(value.Int()) | |||
| 138 | + | case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: | |||
| 139 | + | return fmt.Sprint(value.Uint()) | |||
| 140 | + | case reflect.Float32, reflect.Float64: | |||
| 141 | + | return fmt.Sprint(value.Float()) | |||
| 142 | + | case reflect.Complex64, reflect.Complex128: | |||
| 143 | + | return fmt.Sprint(value.Complex()) | |||
| 144 | + | default: | |||
| 145 | + | return fmt.Sprint(value.Interface()) | |||
| 146 | + | } | |||
| 147 | + | } | |||
| 148 | + | ||||
| 149 | + | func jsonFieldName(field reflect.StructField) string { | |||
| 150 | + | name := field.Tag.Get("json") | |||
| 151 | + | if name == "" { | |||
| 152 | + | return field.Name | |||
| 153 | + | } | |||
| 154 | + | if idx := strings.Index(name, ","); idx >= 0 { | |||
| 155 | + | name = name[:idx] | |||
| 156 | + | } | |||
| 157 | + | if name == "" { | |||
| 158 | + | return field.Name | |||
| 159 | + | } | |||
| 160 | + | return name | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | func mapKeyString(value reflect.Value) string { | |||
| 164 | + | if value.Kind() == reflect.String { | |||
| 165 | + | return value.String() | |||
| 166 | + | } | |||
| 167 | + | return fmt.Sprint(value.Interface()) | |||
| 40 | } | 168 | } | ||
| 41 | 169 | ||||
| 42 | func summarizeKeybindings(keybindings any) (groups, bindings int, loaded bool) { | 170 | func summarizeKeybindings(keybindings any) (groups, bindings int, loaded bool) { | ||
| 171 | + | renderedGroups, ok := collectKeybindingGroups(keybindings) | |||
| 172 | + | if !ok { | |||
| 173 | + | return 0, 0, false | |||
| 174 | + | } | |||
| 175 | + | ||||
| 176 | + | for _, group := range renderedGroups { | |||
| 177 | + | groups++ | |||
| 178 | + | bindings += len(group.Bindings) | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | return groups, bindings, true | |||
| 182 | + | } | |||
| 183 | + | ||||
| 184 | + | func renderKeybindingsSection(keybindings any) string { | |||
| 185 | + | groups, ok := collectKeybindingGroups(keybindings) | |||
| 186 | + | if !ok { | |||
| 187 | + | return emptyStateHTML("Keybindings unavailable") | |||
| 188 | + | } | |||
| 189 | + | if len(groups) == 0 { | |||
| 190 | + | return emptyStateHTML("No keybindings configured") | |||
| 191 | + | } | |||
| 192 | + | ||||
| 193 | + | return renderKeybindingGroups(groups) | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func collectKeybindingGroups(keybindings any) ([]renderedKeybindingGroup, bool) { | |||
| 43 | switch v := keybindings.(type) { | 197 | switch v := keybindings.(type) { | ||
| 44 | case nil: | 198 | case nil: | ||
| 45 | - | return 0, 0, false | 199 | + | return nil, false |
| 46 | case port.KeybindingsConfig: | 200 | case port.KeybindingsConfig: | ||
| 47 | - | return countKeybindingGroups(v.Groups), countKeybindingBindings(v.Groups), true | 201 | + | return convertPortKeybindingGroups(v.Groups), true |
| 48 | case *port.KeybindingsConfig: | 202 | case *port.KeybindingsConfig: | ||
| 49 | if v == nil { | 203 | if v == nil { | ||
| 50 | - | return 0, 0, false | 204 | + | return nil, false |
| 51 | } | 205 | } | ||
| 52 | - | return countKeybindingGroups(v.Groups), countKeybindingBindings(v.Groups), true | 206 | + | return convertPortKeybindingGroups(v.Groups), true |
| 53 | case map[string]any: | 207 | case map[string]any: | ||
| 54 | - | return countBridgeKeybindings(v) | 208 | + | return convertBridgeKeybindingGroups(v) |
| 55 | default: | 209 | default: | ||
| 56 | - | return 1, 0, true | 210 | + | return nil, false |
| 211 | + | } | |||
| 212 | + | } | |||
| 213 | + | ||||
| 214 | + | func convertPortKeybindingGroups(groups []port.KeybindingGroup) []renderedKeybindingGroup { | |||
| 215 | + | rendered := make([]renderedKeybindingGroup, 0, len(groups)) | |||
| 216 | + | for _, group := range groups { | |||
| 217 | + | rendered = append(rendered, renderedKeybindingGroup{ | |||
| 218 | + | DisplayName: strings.TrimSpace(group.DisplayName), | |||
| 219 | + | Mode: strings.TrimSpace(group.Mode), | |||
| 220 | + | Activation: strings.TrimSpace(group.Activation), | |||
| 221 | + | Bindings: convertPortKeybindingEntries(group.Bindings), | |||
| 222 | + | }) | |||
| 57 | } | 223 | } | ||
| 224 | + | return rendered | |||
| 58 | } | 225 | } | ||
| 59 | 226 | ||||
| 60 | - | func countBridgeKeybindings(data map[string]any) (groups, bindings int, loaded bool) { | 227 | + | func convertPortKeybindingEntries(bindings []port.KeybindingEntry) []renderedKeybinding { |
| 61 | - | groupValue, ok := data["groups"] | 228 | + | rendered := make([]renderedKeybinding, 0, len(bindings)) |
| 229 | + | for _, binding := range bindings { | |||
| 230 | + | rendered = append(rendered, renderedKeybinding{ | |||
| 231 | + | Description: strings.TrimSpace(binding.Description), | |||
| 232 | + | Action: strings.TrimSpace(binding.Action), | |||
| 233 | + | Keys: append([]string(nil), binding.Keys...), | |||
| 234 | + | DefaultKeys: append([]string(nil), binding.DefaultKeys...), | |||
| 235 | + | IsCustom: binding.IsCustom, | |||
| 236 | + | }) | |||
| 237 | + | } | |||
| 238 | + | return rendered | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func convertBridgeKeybindingGroups(data map[string]any) ([]renderedKeybindingGroup, bool) { | |||
| 242 | + | rawGroups, ok := data["groups"] | |||
| 243 | + | if !ok || rawGroups == nil { | |||
| 244 | + | return nil, false | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | groupValues, ok := anySlice(rawGroups) | |||
| 62 | if !ok { | 248 | if !ok { | ||
| 63 | - | return 1, 0, true | 249 | + | return nil, false |
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | rendered := make([]renderedKeybindingGroup, 0, len(groupValues)) | |||
| 253 | + | for _, rawGroup := range groupValues { | |||
| 254 | + | groupMap, ok := rawGroup.(map[string]any) | |||
| 255 | + | if !ok { | |||
| 256 | + | return nil, false | |||
| 257 | + | } | |||
| 258 | + | group, ok := convertBridgeKeybindingGroup(groupMap) | |||
| 259 | + | if !ok { | |||
| 260 | + | return nil, false | |||
| 261 | + | } | |||
| 262 | + | rendered = append(rendered, group) | |||
| 263 | + | } | |||
| 264 | + | ||||
| 265 | + | return rendered, true | |||
| 266 | + | } | |||
| 267 | + | ||||
| 268 | + | func convertBridgeKeybindingGroup(group map[string]any) (renderedKeybindingGroup, bool) { | |||
| 269 | + | bindings := []renderedKeybinding{} | |||
| 270 | + | if rawBindings, ok := group["bindings"]; ok && rawBindings != nil { | |||
| 271 | + | converted, ok := convertBridgeKeybindingEntries(rawBindings) | |||
| 272 | + | if !ok { | |||
| 273 | + | return renderedKeybindingGroup{}, false | |||
| 274 | + | } | |||
| 275 | + | bindings = converted | |||
| 64 | } | 276 | } | ||
| 65 | 277 | ||||
| 66 | - | groupList, ok := groupValue.([]any) | 278 | + | return renderedKeybindingGroup{ |
| 279 | + | DisplayName: strings.TrimSpace(stringValue(group["display_name"])), | |||
| 280 | + | Mode: strings.TrimSpace(stringValue(group["mode"])), | |||
| 281 | + | Activation: strings.TrimSpace(stringValue(group["activation"])), | |||
| 282 | + | Bindings: bindings, | |||
| 283 | + | }, true | |||
| 284 | + | } | |||
| 285 | + | ||||
| 286 | + | func convertBridgeKeybindingEntries(rawBindings any) ([]renderedKeybinding, bool) { | |||
| 287 | + | bindingValues, ok := anySlice(rawBindings) | |||
| 67 | if !ok { | 288 | if !ok { | ||
| 68 | - | return 1, 0, true | 289 | + | return nil, false |
| 69 | } | 290 | } | ||
| 70 | 291 | ||||
| 71 | - | for _, group := range groupList { | 292 | + | rendered := make([]renderedKeybinding, 0, len(bindingValues)) |
| 72 | - | groupMap, ok := group.(map[string]any) | 293 | + | for _, rawBinding := range bindingValues { |
| 294 | + | bindingMap, ok := rawBinding.(map[string]any) | |||
| 73 | if !ok { | 295 | if !ok { | ||
| 74 | - | continue | 296 | + | return nil, false |
| 75 | } | 297 | } | ||
| 76 | - | groups++ | 298 | + | rendered = append(rendered, renderedKeybinding{ |
| 77 | - | if bindingValue, ok := groupMap["bindings"]; ok { | 299 | + | Description: strings.TrimSpace(stringValue(bindingMap["description"])), |
| 78 | - | if bindingList, ok := bindingValue.([]any); ok { | 300 | + | Action: strings.TrimSpace(stringValue(bindingMap["action"])), |
| 79 | - | bindings += len(bindingList) | 301 | + | Keys: stringSlice(bindingMap["keys"]), |
| 302 | + | DefaultKeys: stringSlice(bindingMap["default_keys"]), | |||
| 303 | + | IsCustom: boolValue(bindingMap["is_custom"]), | |||
| 304 | + | }) | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | return rendered, true | |||
| 308 | + | } | |||
| 309 | + | ||||
| 310 | + | func anySlice(value any) ([]any, bool) { | |||
| 311 | + | switch v := value.(type) { | |||
| 312 | + | case []any: | |||
| 313 | + | return v, true | |||
| 314 | + | case []map[string]any: | |||
| 315 | + | items := make([]any, len(v)) | |||
| 316 | + | for i, item := range v { | |||
| 317 | + | items[i] = item | |||
| 318 | + | } | |||
| 319 | + | return items, true | |||
| 320 | + | default: | |||
| 321 | + | return nil, false | |||
| 322 | + | } | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | func stringValue(value any) string { | |||
| 326 | + | s, _ := value.(string) | |||
| 327 | + | return s | |||
| 328 | + | } | |||
| 329 | + | ||||
| 330 | + | func stringSlice(value any) []string { | |||
| 331 | + | switch v := value.(type) { | |||
| 332 | + | case []string: | |||
| 333 | + | return append([]string(nil), v...) | |||
| 334 | + | case []any: | |||
| 335 | + | out := make([]string, 0, len(v)) | |||
| 336 | + | for _, item := range v { | |||
| 337 | + | text, ok := item.(string) | |||
| 338 | + | if !ok { | |||
| 339 | + | return nil | |||
| 80 | } | 340 | } | ||
| 341 | + | out = append(out, text) | |||
| 81 | } | 342 | } | ||
| 343 | + | return out | |||
| 344 | + | default: | |||
| 345 | + | return nil | |||
| 82 | } | 346 | } | ||
| 347 | + | } | |||
| 83 | 348 | ||||
| 84 | - | return groups, bindings, true | 349 | + | func boolValue(value any) bool { |
| 350 | + | b, _ := value.(bool) | |||
| 351 | + | return b | |||
| 85 | } | 352 | } | ||
| 86 | 353 | ||||
| 87 | - | func countKeybindingGroups(groups []port.KeybindingGroup) int { | 354 | + | func renderKeybindingGroups(groups []renderedKeybindingGroup) string { |
| 88 | - | count := 0 | 355 | + | var rendered strings.Builder |
| 89 | for _, group := range groups { | 356 | for _, group := range groups { | ||
| 90 | - | if group.Mode == "" && group.DisplayName == "" && len(group.Bindings) == 0 { | 357 | + | rendered.WriteString(renderKeybindingGroup(group)) |
| 91 | - | continue | 358 | + | } |
| 359 | + | return rendered.String() | |||
| 360 | + | } | |||
| 361 | + | ||||
| 362 | + | type renderedKeybindingGroup struct { | |||
| 363 | + | DisplayName string | |||
| 364 | + | Mode string | |||
| 365 | + | Activation string | |||
| 366 | + | Bindings []renderedKeybinding | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | type renderedKeybinding struct { | |||
| 370 | + | Description string | |||
| 371 | + | Action string | |||
| 372 | + | Keys []string | |||
| 373 | + | DefaultKeys []string | |||
| 374 | + | IsCustom bool | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | func renderKeybindingGroup(group renderedKeybindingGroup) string { | |||
| 378 | + | title := group.DisplayName | |||
| 379 | + | if strings.TrimSpace(title) == "" { | |||
| 380 | + | title = group.Mode | |||
| 381 | + | } | |||
| 382 | + | if strings.TrimSpace(title) == "" { | |||
| 383 | + | title = "Keybinding group" | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | var body strings.Builder | |||
| 387 | + | body.WriteString(`<div class="sv-group">`) | |||
| 388 | + | body.WriteString(`<div class="sv-group-header">`) | |||
| 389 | + | body.WriteString(`<div>`) | |||
| 390 | + | body.WriteString(`<h4>` + html.EscapeString(title) + `</h4>`) | |||
| 391 | + | ||||
| 392 | + | meta := make([]string, 0, 2) | |||
| 393 | + | if strings.TrimSpace(group.Mode) != "" { | |||
| 394 | + | meta = append(meta, "mode: "+group.Mode) | |||
| 395 | + | } | |||
| 396 | + | if strings.TrimSpace(group.Activation) != "" { | |||
| 397 | + | meta = append(meta, "activation: "+group.Activation) | |||
| 398 | + | } | |||
| 399 | + | if len(meta) > 0 { | |||
| 400 | + | body.WriteString(`<p class="sv-meta">` + html.EscapeString(strings.Join(meta, " • ")) + `</p>`) | |||
| 401 | + | } | |||
| 402 | + | body.WriteString(`</div></div>`) | |||
| 403 | + | body.WriteString(`<div class="sv-group-body">`) | |||
| 404 | + | if len(group.Bindings) == 0 { | |||
| 405 | + | body.WriteString(emptyStateHTML("No bindings in this group")) | |||
| 406 | + | } else { | |||
| 407 | + | for _, binding := range group.Bindings { | |||
| 408 | + | body.WriteString(renderKeybinding(binding)) | |||
| 92 | } | 409 | } | ||
| 93 | - | count++ | |||
| 94 | } | 410 | } | ||
| 95 | - | return count | 411 | + | body.WriteString(`</div></div>`) |
| 412 | + | ||||
| 413 | + | return body.String() | |||
| 96 | } | 414 | } | ||
| 97 | 415 | ||||
| 98 | - | func countKeybindingBindings(groups []port.KeybindingGroup) int { | 416 | + | func renderKeybinding(binding renderedKeybinding) string { |
| 99 | - | count := 0 | 417 | + | description := strings.TrimSpace(binding.Description) |
| 100 | - | for _, group := range groups { | 418 | + | if description == "" { |
| 101 | - | count += len(group.Bindings) | 419 | + | description = strings.TrimSpace(binding.Action) |
| 420 | + | } | |||
| 421 | + | if description == "" { | |||
| 422 | + | description = "Unnamed binding" | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | action := strings.TrimSpace(binding.Action) | |||
| 426 | + | if action == "" { | |||
| 427 | + | action = "binding" | |||
| 428 | + | } | |||
| 429 | + | ||||
| 430 | + | custom := binding.IsCustom || !sameStringSlice(binding.Keys, binding.DefaultKeys) | |||
| 431 | + | status := "default" | |||
| 432 | + | if custom { | |||
| 433 | + | status = "custom" | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | var body strings.Builder | |||
| 437 | + | body.WriteString(`<div class="sv-binding">`) | |||
| 438 | + | body.WriteString(`<div class="sv-binding-header">`) | |||
| 439 | + | body.WriteString(`<div class="sv-binding-description">` + html.EscapeString(description) + `</div>`) | |||
| 440 | + | body.WriteString(`<div class="sv-binding-keys">` + keyListHTML(binding.Keys) + `</div>`) | |||
| 441 | + | body.WriteString(`</div>`) | |||
| 442 | + | body.WriteString(`<div class="sv-binding-meta">`) | |||
| 443 | + | body.WriteString(`<span>` + html.EscapeString("action: "+action) + `</span>`) | |||
| 444 | + | body.WriteString(`<span>` + html.EscapeString(status) + `</span>`) | |||
| 445 | + | if custom && len(binding.DefaultKeys) > 0 { | |||
| 446 | + | body.WriteString(`<span>default: ` + keyListHTML(binding.DefaultKeys) + `</span>`) | |||
| 447 | + | } | |||
| 448 | + | body.WriteString(`</div></div>`) | |||
| 449 | + | ||||
| 450 | + | return body.String() | |||
| 451 | + | } | |||
| 452 | + | ||||
| 453 | + | func keyListHTML(keys []string) string { | |||
| 454 | + | if len(keys) == 0 { | |||
| 455 | + | return `<span class="sv-key sv-key-empty">Unassigned</span>` | |||
| 456 | + | } | |||
| 457 | + | ||||
| 458 | + | parts := make([]string, 0, len(keys)) | |||
| 459 | + | for _, key := range keys { | |||
| 460 | + | parts = append(parts, `<span class="sv-key">`+html.EscapeString(key)+`</span>`) | |||
| 461 | + | } | |||
| 462 | + | return strings.Join(parts, " ") | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | func sameStringSlice(a, b []string) bool { | |||
| 466 | + | if len(a) != len(b) { | |||
| 467 | + | return false | |||
| 468 | + | } | |||
| 469 | + | for i := range a { | |||
| 470 | + | if a[i] != b[i] { | |||
| 471 | + | return false | |||
| 472 | + | } | |||
| 102 | } | 473 | } | ||
| 103 | - | return count | 474 | + | return true |
| 104 | } | 475 | } | ||
| 105 | 476 | ||||
| 106 | func plural(count int, singular string) string { | 477 | func plural(count int, singular string) string { | ||
M internal/ui/systemviews/dom.go +1 -11
| @@ -15,15 +15,5 @@ func placeholderHTML(route Route) string { | |||
| 15 | 15 | name = "unknown" | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | - | return fmt.Sprintf(`<!doctype html> | |
| 19 | - | <html lang="en"> | |
| 20 | - | <head> | |
| 21 | - | <meta charset="utf-8"> | |
| 22 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 23 | - | <title>systemviews</title> | |
| 24 | - | </head> | |
| 25 | - | <body> | |
| 26 | - | <main id="app" data-route="%s">systemviews %s</main> | |
| 27 | - | </body> | |
| 28 | - | </html>`, name, name) | |
| 18 | + | return fmt.Sprintf(`<p>systemviews %s</p>`, name) | |
| 29 | 19 | } | |
| @@ -15,15 +15,5 @@ func placeholderHTML(route Route) string { | |||||
| 15 | name = "unknown" | 15 | name = "unknown" | ||
| 16 | } | 16 | } | ||
| 17 | 17 | ||||
| 18 | - | return fmt.Sprintf(`<!doctype html> | 18 | + | return fmt.Sprintf(`<p>systemviews %s</p>`, name) |
| 19 | - | <html lang="en"> | |||
| 20 | - | <head> | |||
| 21 | - | <meta charset="utf-8"> | |||
| 22 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 23 | - | <title>systemviews</title> | |||
| 24 | - | </head> | |||
| 25 | - | <body> | |||
| 26 | - | <main id="app" data-route="%s">systemviews %s</main> | |||
| 27 | - | </body> | |||
| 28 | - | </html>`, name, name) | |||
| 29 | } | 19 | } | ||
M internal/ui/systemviews/favorites_view.go +12 -50
| @@ -9,39 +9,18 @@ import ( | |||
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | 11 | func favoritesHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string { | |
| 12 | - | return fmt.Sprintf(`<!doctype html> | |
| 13 | - | <html lang="en"> | |
| 14 | - | <head> | |
| 15 | - | <meta charset="utf-8"> | |
| 16 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 17 | - | <title>systemviews favorites</title> | |
| 18 | - | </head> | |
| 19 | - | <body> | |
| 20 | - | <main id="app" data-route="favorites"> | |
| 21 | - | <h1>Favorites</h1> | |
| 22 | - | <p>%d favorites · %d folders · %d tags</p> | |
| 23 | - | <section> | |
| 24 | - | <h2>Favorites</h2> | |
| 25 | - | <ul>%s</ul> | |
| 26 | - | </section> | |
| 27 | - | <section> | |
| 28 | - | <h2>Folders</h2> | |
| 29 | - | <ul>%s</ul> | |
| 30 | - | </section> | |
| 31 | - | <section> | |
| 32 | - | <h2>Tags</h2> | |
| 33 | - | <ul>%s</ul> | |
| 34 | - | </section> | |
| 35 | - | </main> | |
| 36 | - | </body> | |
| 37 | - | </html>`, len(favorites), len(folders), len(tags), favoriteItemsHTML(favorites), folderItemsHTML(folders), tagItemsHTML(tags)) | |
| 12 | + | favoritesSummary := fmt.Sprintf("%d favorites · %d folders · %d tags", len(favorites), len(folders), len(tags)) | |
| 13 | + | foldersSummary := fmt.Sprintf("%d folders", len(folders)) | |
| 14 | + | tagsSummary := fmt.Sprintf("%d tags", len(tags)) | |
| 15 | + | sections := []string{ | |
| 16 | + | sectionHTML("", "Favorites", metaHTML(favoritesSummary)+listHTML(favoriteItemsHTML(favorites), "No favorites")), | |
| 17 | + | sectionHTML("", "Folders", metaHTML(foldersSummary)+listHTML(folderItemsHTML(folders), "No folders")), | |
| 18 | + | sectionHTML("", "Tags", metaHTML(tagsSummary)+listHTML(tagItemsHTML(tags), "No tags")), | |
| 19 | + | } | |
| 20 | + | return strings.Join(sections, "") | |
| 38 | 21 | } | |
| 39 | 22 | ||
| 40 | 23 | func favoriteItemsHTML(favorites []*entity.Favorite) string { | |
| 41 | - | if len(favorites) == 0 { | |
| 42 | - | return "<li>No favorites</li>" | |
| 43 | - | } | |
| 44 | - | ||
| 45 | 24 | var items strings.Builder | |
| 46 | 25 | for _, favorite := range favorites { | |
| 47 | 26 | if favorite == nil { | |
| @@ -51,46 +30,29 @@ func favoriteItemsHTML(favorites []*entity.Favorite) string { | |||
| 51 | 30 | if label == "" { | |
| 52 | 31 | label = favorite.URL | |
| 53 | 32 | } | |
| 54 | - | _, _ = fmt.Fprintf(&items, `<li><a href="%s">%s</a></li>`, html.EscapeString(favorite.URL), html.EscapeString(label)) | |
| 55 | - | } | |
| 56 | - | if items.Len() == 0 { | |
| 57 | - | return "<li>No favorites</li>" | |
| 33 | + | items.WriteString(listRowHTML(linkHTML(favorite.URL, label))) | |
| 58 | 34 | } | |
| 59 | 35 | return items.String() | |
| 60 | 36 | } | |
| 61 | 37 | ||
| 62 | 38 | func folderItemsHTML(folders []*entity.Folder) string { | |
| 63 | - | if len(folders) == 0 { | |
| 64 | - | return "<li>No folders</li>" | |
| 65 | - | } | |
| 66 | - | ||
| 67 | 39 | var items strings.Builder | |
| 68 | 40 | for _, folder := range folders { | |
| 69 | 41 | if folder == nil { | |
| 70 | 42 | continue | |
| 71 | 43 | } | |
| 72 | - | _, _ = fmt.Fprintf(&items, "<li>%s</li>", html.EscapeString(folder.Name)) | |
| 73 | - | } | |
| 74 | - | if items.Len() == 0 { | |
| 75 | - | return "<li>No folders</li>" | |
| 44 | + | items.WriteString(listRowHTML(html.EscapeString(folder.Name))) | |
| 76 | 45 | } | |
| 77 | 46 | return items.String() | |
| 78 | 47 | } | |
| 79 | 48 | ||
| 80 | 49 | func tagItemsHTML(tags []*entity.Tag) string { | |
| 81 | - | if len(tags) == 0 { | |
| 82 | - | return "<li>No tags</li>" | |
| 83 | - | } | |
| 84 | - | ||
| 85 | 50 | var items strings.Builder | |
| 86 | 51 | for _, tag := range tags { | |
| 87 | 52 | if tag == nil { | |
| 88 | 53 | continue | |
| 89 | 54 | } | |
| 90 | - | _, _ = fmt.Fprintf(&items, "<li>%s</li>", html.EscapeString(tag.Name)) | |
| 91 | - | } | |
| 92 | - | if items.Len() == 0 { | |
| 93 | - | return "<li>No tags</li>" | |
| 55 | + | items.WriteString(listRowHTML(html.EscapeString(tag.Name))) | |
| 94 | 56 | } | |
| 95 | 57 | return items.String() | |
| 96 | 58 | } | |
| @@ -9,39 +9,18 @@ import ( | |||||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | func favoritesHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string { | 11 | func favoritesHTML(favorites []*entity.Favorite, folders []*entity.Folder, tags []*entity.Tag) string { | ||
| 12 | - | return fmt.Sprintf(`<!doctype html> | 12 | + | favoritesSummary := fmt.Sprintf("%d favorites · %d folders · %d tags", len(favorites), len(folders), len(tags)) |
| 13 | - | <html lang="en"> | 13 | + | foldersSummary := fmt.Sprintf("%d folders", len(folders)) |
| 14 | - | <head> | 14 | + | tagsSummary := fmt.Sprintf("%d tags", len(tags)) |
| 15 | - | <meta charset="utf-8"> | 15 | + | sections := []string{ |
| 16 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | 16 | + | sectionHTML("", "Favorites", metaHTML(favoritesSummary)+listHTML(favoriteItemsHTML(favorites), "No favorites")), |
| 17 | - | <title>systemviews favorites</title> | 17 | + | sectionHTML("", "Folders", metaHTML(foldersSummary)+listHTML(folderItemsHTML(folders), "No folders")), |
| 18 | - | </head> | 18 | + | sectionHTML("", "Tags", metaHTML(tagsSummary)+listHTML(tagItemsHTML(tags), "No tags")), |
| 19 | - | <body> | 19 | + | } |
| 20 | - | <main id="app" data-route="favorites"> | 20 | + | return strings.Join(sections, "") |
| 21 | - | <h1>Favorites</h1> | |||
| 22 | - | <p>%d favorites · %d folders · %d tags</p> | |||
| 23 | - | <section> | |||
| 24 | - | <h2>Favorites</h2> | |||
| 25 | - | <ul>%s</ul> | |||
| 26 | - | </section> | |||
| 27 | - | <section> | |||
| 28 | - | <h2>Folders</h2> | |||
| 29 | - | <ul>%s</ul> | |||
| 30 | - | </section> | |||
| 31 | - | <section> | |||
| 32 | - | <h2>Tags</h2> | |||
| 33 | - | <ul>%s</ul> | |||
| 34 | - | </section> | |||
| 35 | - | </main> | |||
| 36 | - | </body> | |||
| 37 | - | </html>`, len(favorites), len(folders), len(tags), favoriteItemsHTML(favorites), folderItemsHTML(folders), tagItemsHTML(tags)) | |||
| 38 | } | 21 | } | ||
| 39 | 22 | ||||
| 40 | func favoriteItemsHTML(favorites []*entity.Favorite) string { | 23 | func favoriteItemsHTML(favorites []*entity.Favorite) string { | ||
| 41 | - | if len(favorites) == 0 { | |||
| 42 | - | return "<li>No favorites</li>" | |||
| 43 | - | } | |||
| 44 | - | ||||
| 45 | var items strings.Builder | 24 | var items strings.Builder | ||
| 46 | for _, favorite := range favorites { | 25 | for _, favorite := range favorites { | ||
| 47 | if favorite == nil { | 26 | if favorite == nil { | ||
| @@ -51,46 +30,29 @@ func favoriteItemsHTML(favorites []*entity.Favorite) string { | |||||
| 51 | if label == "" { | 30 | if label == "" { | ||
| 52 | label = favorite.URL | 31 | label = favorite.URL | ||
| 53 | } | 32 | } | ||
| 54 | - | _, _ = fmt.Fprintf(&items, `<li><a href="%s">%s</a></li>`, html.EscapeString(favorite.URL), html.EscapeString(label)) | 33 | + | items.WriteString(listRowHTML(linkHTML(favorite.URL, label))) |
| 55 | - | } | |||
| 56 | - | if items.Len() == 0 { | |||
| 57 | - | return "<li>No favorites</li>" | |||
| 58 | } | 34 | } | ||
| 59 | return items.String() | 35 | return items.String() | ||
| 60 | } | 36 | } | ||
| 61 | 37 | ||||
| 62 | func folderItemsHTML(folders []*entity.Folder) string { | 38 | func folderItemsHTML(folders []*entity.Folder) string { | ||
| 63 | - | if len(folders) == 0 { | |||
| 64 | - | return "<li>No folders</li>" | |||
| 65 | - | } | |||
| 66 | - | ||||
| 67 | var items strings.Builder | 39 | var items strings.Builder | ||
| 68 | for _, folder := range folders { | 40 | for _, folder := range folders { | ||
| 69 | if folder == nil { | 41 | if folder == nil { | ||
| 70 | continue | 42 | continue | ||
| 71 | } | 43 | } | ||
| 72 | - | _, _ = fmt.Fprintf(&items, "<li>%s</li>", html.EscapeString(folder.Name)) | 44 | + | items.WriteString(listRowHTML(html.EscapeString(folder.Name))) |
| 73 | - | } | |||
| 74 | - | if items.Len() == 0 { | |||
| 75 | - | return "<li>No folders</li>" | |||
| 76 | } | 45 | } | ||
| 77 | return items.String() | 46 | return items.String() | ||
| 78 | } | 47 | } | ||
| 79 | 48 | ||||
| 80 | func tagItemsHTML(tags []*entity.Tag) string { | 49 | func tagItemsHTML(tags []*entity.Tag) string { | ||
| 81 | - | if len(tags) == 0 { | |||
| 82 | - | return "<li>No tags</li>" | |||
| 83 | - | } | |||
| 84 | - | ||||
| 85 | var items strings.Builder | 50 | var items strings.Builder | ||
| 86 | for _, tag := range tags { | 51 | for _, tag := range tags { | ||
| 87 | if tag == nil { | 52 | if tag == nil { | ||
| 88 | continue | 53 | continue | ||
| 89 | } | 54 | } | ||
| 90 | - | _, _ = fmt.Fprintf(&items, "<li>%s</li>", html.EscapeString(tag.Name)) | 55 | + | items.WriteString(listRowHTML(html.EscapeString(tag.Name))) |
| 91 | - | } | |||
| 92 | - | if items.Len() == 0 { | |||
| 93 | - | return "<li>No tags</li>" | |||
| 94 | } | 56 | } | ||
| 95 | return items.String() | 57 | return items.String() | ||
| 96 | } | 58 | } | ||
M internal/ui/systemviews/history_view.go +24 -27
| @@ -2,41 +2,38 @@ package systemviews | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | - | "html" | |
| 6 | 5 | "strings" | |
| 7 | 6 | ||
| 8 | 7 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 8 | ) | |
| 10 | 9 | ||
| 11 | 10 | func historyHTML(entries []*entity.HistoryEntry) string { | |
| 11 | + | items := historyItemsHTML(entries) | |
| 12 | + | return sectionHTML("", "History", metaHTML(fmt.Sprintf("%d entries", countHistoryEntries(entries)))+listHTML(items, "No history entries")) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func historyItemsHTML(entries []*entity.HistoryEntry) string { | |
| 12 | 16 | var items strings.Builder | |
| 13 | - | if len(entries) == 0 { | |
| 14 | - | items.WriteString("<li>No history entries</li>") | |
| 15 | - | } else { | |
| 16 | - | for _, entry := range entries { | |
| 17 | - | if entry == nil { | |
| 18 | - | continue | |
| 19 | - | } | |
| 20 | - | label := entry.Title | |
| 21 | - | if label == "" { | |
| 22 | - | label = entry.URL | |
| 23 | - | } | |
| 24 | - | _, _ = fmt.Fprintf(&items, `<li><a href="%s">%s</a></li>`, html.EscapeString(entry.URL), html.EscapeString(label)) | |
| 17 | + | for _, entry := range entries { | |
| 18 | + | if entry == nil { | |
| 19 | + | continue | |
| 25 | 20 | } | |
| 21 | + | label := entry.Title | |
| 22 | + | if label == "" { | |
| 23 | + | label = entry.URL | |
| 24 | + | } | |
| 25 | + | items.WriteString(listRowHTML(linkHTML(entry.URL, label))) | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | - | return fmt.Sprintf(`<!doctype html> | |
| 29 | - | <html lang="en"> | |
| 30 | - | <head> | |
| 31 | - | <meta charset="utf-8"> | |
| 32 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 33 | - | <title>systemviews history</title> | |
| 34 | - | </head> | |
| 35 | - | <body> | |
| 36 | - | <main id="app" data-route="history"> | |
| 37 | - | <h1>History</h1> | |
| 38 | - | <ul>%s</ul> | |
| 39 | - | </main> | |
| 40 | - | </body> | |
| 41 | - | </html>`, items.String()) | |
| 28 | + | return items.String() | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func countHistoryEntries(entries []*entity.HistoryEntry) int { | |
| 32 | + | count := 0 | |
| 33 | + | for _, entry := range entries { | |
| 34 | + | if entry != nil { | |
| 35 | + | count++ | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | return count | |
| 42 | 39 | } | |
| @@ -2,41 +2,38 @@ package systemviews | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | - | "html" | |||
| 6 | "strings" | 5 | "strings" | ||
| 7 | 6 | ||||
| 8 | "github.com/bnema/dumber/internal/domain/entity" | 7 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 9 | ) | 8 | ) | ||
| 10 | 9 | ||||
| 11 | func historyHTML(entries []*entity.HistoryEntry) string { | 10 | func historyHTML(entries []*entity.HistoryEntry) string { | ||
| 11 | + | items := historyItemsHTML(entries) | |||
| 12 | + | return sectionHTML("", "History", metaHTML(fmt.Sprintf("%d entries", countHistoryEntries(entries)))+listHTML(items, "No history entries")) | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func historyItemsHTML(entries []*entity.HistoryEntry) string { | |||
| 12 | var items strings.Builder | 16 | var items strings.Builder | ||
| 13 | - | if len(entries) == 0 { | 17 | + | for _, entry := range entries { |
| 14 | - | items.WriteString("<li>No history entries</li>") | 18 | + | if entry == nil { |
| 15 | - | } else { | 19 | + | continue |
| 16 | - | for _, entry := range entries { | |||
| 17 | - | if entry == nil { | |||
| 18 | - | continue | |||
| 19 | - | } | |||
| 20 | - | label := entry.Title | |||
| 21 | - | if label == "" { | |||
| 22 | - | label = entry.URL | |||
| 23 | - | } | |||
| 24 | - | _, _ = fmt.Fprintf(&items, `<li><a href="%s">%s</a></li>`, html.EscapeString(entry.URL), html.EscapeString(label)) | |||
| 25 | } | 20 | } | ||
| 21 | + | label := entry.Title | |||
| 22 | + | if label == "" { | |||
| 23 | + | label = entry.URL | |||
| 24 | + | } | |||
| 25 | + | items.WriteString(listRowHTML(linkHTML(entry.URL, label))) | |||
| 26 | } | 26 | } | ||
| 27 | 27 | ||||
| 28 | - | return fmt.Sprintf(`<!doctype html> | 28 | + | return items.String() |
| 29 | - | <html lang="en"> | 29 | + | } |
| 30 | - | <head> | 30 | + | |
| 31 | - | <meta charset="utf-8"> | 31 | + | func countHistoryEntries(entries []*entity.HistoryEntry) int { |
| 32 | - | <meta name="viewport" content="width=device-width, initial-scale=1"> | 32 | + | count := 0 |
| 33 | - | <title>systemviews history</title> | 33 | + | for _, entry := range entries { |
| 34 | - | </head> | 34 | + | if entry != nil { |
| 35 | - | <body> | 35 | + | count++ |
| 36 | - | <main id="app" data-route="history"> | 36 | + | } |
| 37 | - | <h1>History</h1> | 37 | + | } |
| 38 | - | <ul>%s</ul> | 38 | + | return count |
| 39 | - | </main> | |||
| 40 | - | </body> | |||
| 41 | - | </html>`, items.String()) | |||
| 42 | } | 39 | } | ||
A internal/ui/systemviews/href.go +32 -0
| @@ -0,0 +1,32 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/url" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func sanitizeHref(raw string) string { | |
| 9 | + | trimmed := strings.TrimSpace(raw) | |
| 10 | + | if trimmed == "" { | |
| 11 | + | return "#" | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | parsed, err := url.Parse(trimmed) | |
| 15 | + | if err != nil { | |
| 16 | + | return "#" | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | if parsed.Scheme == "" { | |
| 20 | + | if parsed.Host != "" { | |
| 21 | + | return "#" | |
| 22 | + | } | |
| 23 | + | return trimmed | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | switch strings.ToLower(parsed.Scheme) { | |
| 27 | + | case "http", "https", "dumb": | |
| 28 | + | return trimmed | |
| 29 | + | default: | |
| 30 | + | return "#" | |
| 31 | + | } | |
| 32 | + | } | |
| @@ -0,0 +1,32 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/url" | |||
| 5 | + | "strings" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func sanitizeHref(raw string) string { | |||
| 9 | + | trimmed := strings.TrimSpace(raw) | |||
| 10 | + | if trimmed == "" { | |||
| 11 | + | return "#" | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | parsed, err := url.Parse(trimmed) | |||
| 15 | + | if err != nil { | |||
| 16 | + | return "#" | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | if parsed.Scheme == "" { | |||
| 20 | + | if parsed.Host != "" { | |||
| 21 | + | return "#" | |||
| 22 | + | } | |||
| 23 | + | return trimmed | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | switch strings.ToLower(parsed.Scheme) { | |||
| 27 | + | case "http", "https", "dumb": | |||
| 28 | + | return trimmed | |||
| 29 | + | default: | |||
| 30 | + | return "#" | |||
| 31 | + | } | |||
| 32 | + | } | |||
A internal/ui/systemviews/href_test.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | "github.com/stretchr/testify/require" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) { | |
| 12 | + | t.Parallel() | |
| 13 | + | ||
| 14 | + | html := historyHTML([]*entity.HistoryEntry{ | |
| 15 | + | {URL: "javascript:alert(1)", Title: "Bad"}, | |
| 16 | + | {URL: "https://example.com", Title: "Good"}, | |
| 17 | + | }) | |
| 18 | + | ||
| 19 | + | require.NotContains(t, html, `href="javascript:alert(1)"`) | |
| 20 | + | assert.Contains(t, html, `href="#"`) | |
| 21 | + | assert.Contains(t, html, `href="https://example.com"`) | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) { | |
| 25 | + | t.Parallel() | |
| 26 | + | ||
| 27 | + | html := favoriteItemsHTML([]*entity.Favorite{ | |
| 28 | + | {URL: "javascript:alert(1)", Title: "Bad"}, | |
| 29 | + | {URL: "https://example.com", Title: "Good"}, | |
| 30 | + | }) | |
| 31 | + | ||
| 32 | + | require.NotContains(t, html, `href="javascript:alert(1)"`) | |
| 33 | + | assert.Contains(t, html, `href="#"`) | |
| 34 | + | assert.Contains(t, html, `href="https://example.com"`) | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | "github.com/stretchr/testify/require" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestHistoryHTMLSanitizesHrefSchemes(t *testing.T) { | |||
| 12 | + | t.Parallel() | |||
| 13 | + | ||||
| 14 | + | html := historyHTML([]*entity.HistoryEntry{ | |||
| 15 | + | {URL: "javascript:alert(1)", Title: "Bad"}, | |||
| 16 | + | {URL: "https://example.com", Title: "Good"}, | |||
| 17 | + | }) | |||
| 18 | + | ||||
| 19 | + | require.NotContains(t, html, `href="javascript:alert(1)"`) | |||
| 20 | + | assert.Contains(t, html, `href="#"`) | |||
| 21 | + | assert.Contains(t, html, `href="https://example.com"`) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) { | |||
| 25 | + | t.Parallel() | |||
| 26 | + | ||||
| 27 | + | html := favoriteItemsHTML([]*entity.Favorite{ | |||
| 28 | + | {URL: "javascript:alert(1)", Title: "Bad"}, | |||
| 29 | + | {URL: "https://example.com", Title: "Good"}, | |||
| 30 | + | }) | |||
| 31 | + | ||||
| 32 | + | require.NotContains(t, html, `href="javascript:alert(1)"`) | |||
| 33 | + | assert.Contains(t, html, `href="#"`) | |||
| 34 | + | assert.Contains(t, html, `href="https://example.com"`) | |||
| 35 | + | } | |||
A internal/ui/systemviews/prefers_dark_js.go +20 -0
| @@ -0,0 +1,20 @@ | |||
| 1 | + | //go:build js && wasm | |
| 2 | + | ||
| 3 | + | package systemviews | |
| 4 | + | ||
| 5 | + | import "syscall/js" | |
| 6 | + | ||
| 7 | + | var currentPrefersDarkImpl = func() bool { | |
| 8 | + | window := js.Global() | |
| 9 | + | if !window.Truthy() { | |
| 10 | + | return false | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | matchMedia := window.Get("matchMedia") | |
| 14 | + | if !matchMedia.Truthy() { | |
| 15 | + | return false | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | media := matchMedia.Invoke("(prefers-color-scheme: dark)") | |
| 19 | + | return media.Truthy() && media.Get("matches").Bool() | |
| 20 | + | } | |
| @@ -0,0 +1,20 @@ | |||||
| 1 | + | //go:build js && wasm | |||
| 2 | + | ||||
| 3 | + | package systemviews | |||
| 4 | + | ||||
| 5 | + | import "syscall/js" | |||
| 6 | + | ||||
| 7 | + | var currentPrefersDarkImpl = func() bool { | |||
| 8 | + | window := js.Global() | |||
| 9 | + | if !window.Truthy() { | |||
| 10 | + | return false | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | matchMedia := window.Get("matchMedia") | |||
| 14 | + | if !matchMedia.Truthy() { | |||
| 15 | + | return false | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | media := matchMedia.Invoke("(prefers-color-scheme: dark)") | |||
| 19 | + | return media.Truthy() && media.Get("matches").Bool() | |||
| 20 | + | } | |||
A internal/ui/systemviews/prefers_dark_stub.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | //go:build !js || !wasm | |
| 2 | + | ||
| 3 | + | package systemviews | |
| 4 | + | ||
| 5 | + | var currentPrefersDarkImpl = func() bool { return false } | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 3 | + | package systemviews | |||
| 4 | + | ||||
| 5 | + | var currentPrefersDarkImpl = func() bool { return false } | |||
A internal/ui/systemviews/shell.go +111 -0
| @@ -0,0 +1,111 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "html" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type kvPair struct { | |
| 10 | + | Label string | |
| 11 | + | Value string | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // renderedPage holds the data needed by renderAppFrame to produce the shell | |
| 15 | + | // fragment. Route views populate this; the app frame wraps it. | |
| 16 | + | type renderedPage struct { | |
| 17 | + | route Route | |
| 18 | + | subtitle string | |
| 19 | + | body string | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // renderAppFrame produces a self-contained HTML fragment (no <html>, <head>, | |
| 23 | + | // or <body>) that wraps route content in the shared shell. | |
| 24 | + | func renderAppFrame(page renderedPage, theme shellTheme) string { | |
| 25 | + | rootClass := "sv-app" | |
| 26 | + | themeClass := theme.RootClass | |
| 27 | + | if themeClass == "" { | |
| 28 | + | themeClass = "sv-dark" | |
| 29 | + | } | |
| 30 | + | rootClass += " " + themeClass | |
| 31 | + | ||
| 32 | + | var styleAttr string | |
| 33 | + | if theme.InlineVars != "" { | |
| 34 | + | styleAttr = fmt.Sprintf(` style=%q`, html.EscapeString(theme.InlineVars)) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | routeAttr := html.EscapeString(string(page.route)) | |
| 38 | + | ||
| 39 | + | var subtitle string | |
| 40 | + | if page.subtitle != "" { | |
| 41 | + | subtitle = fmt.Sprintf(`<h1 class="sv-title">%s</h1>`, html.EscapeString(page.subtitle)) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | return fmt.Sprintf(`<div class=%q data-route=%q%s><div class=%q>%s<div class=%q>%s</div></div></div>`, | |
| 45 | + | html.EscapeString(rootClass), | |
| 46 | + | routeAttr, | |
| 47 | + | styleAttr, | |
| 48 | + | "sv-shell", | |
| 49 | + | subtitle, | |
| 50 | + | "sv-content", | |
| 51 | + | page.body, | |
| 52 | + | ) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func listHTML(rows, emptyMessage string) string { | |
| 56 | + | if strings.TrimSpace(rows) == "" { | |
| 57 | + | return emptyStateHTML(emptyMessage) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | return fmt.Sprintf(`<ul class="sv-list">%s</ul>`, rows) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func listRowHTML(inner string) string { | |
| 64 | + | return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func linkHTML(href, label string) string { | |
| 68 | + | return fmt.Sprintf(`<a class=%q href=%q>%s</a>`, "sv-link", html.EscapeString(sanitizeHref(href)), html.EscapeString(label)) | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func metaHTML(text string) string { | |
| 72 | + | return fmt.Sprintf(`<p class="sv-meta">%s</p>`, html.EscapeString(text)) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | func kvHTML(rows []kvPair) string { | |
| 76 | + | var items strings.Builder | |
| 77 | + | for _, row := range rows { | |
| 78 | + | if strings.TrimSpace(row.Label) == "" && strings.TrimSpace(row.Value) == "" { | |
| 79 | + | continue | |
| 80 | + | } | |
| 81 | + | items.WriteString(kvRowHTML(row.Label, row.Value)) | |
| 82 | + | } | |
| 83 | + | if items.Len() == 0 { | |
| 84 | + | return emptyStateHTML("No configuration details") | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | return fmt.Sprintf(`<dl class="sv-kv">%s</dl>`, items.String()) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | func kvRowHTML(label, value string) string { | |
| 91 | + | return fmt.Sprintf(`<div class=%q><dt>%s</dt><dd>%s</dd></div>`, "sv-kv-row", html.EscapeString(label), html.EscapeString(value)) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | // sectionHTML wraps content in a styled section with a heading. | |
| 95 | + | func sectionHTML(class, title, inner string) string { | |
| 96 | + | classes := "sv-section" | |
| 97 | + | if strings.TrimSpace(class) != "" { | |
| 98 | + | classes += " " + strings.TrimSpace(class) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | return fmt.Sprintf(`<section class=%q><h2>%s</h2>%s</section>`, | |
| 102 | + | html.EscapeString(classes), | |
| 103 | + | html.EscapeString(title), | |
| 104 | + | inner, | |
| 105 | + | ) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // emptyStateHTML returns a standard empty-state placeholder. | |
| 109 | + | func emptyStateHTML(message string) string { | |
| 110 | + | return fmt.Sprintf(`<div class="sv-empty"><p>%s</p></div>`, html.EscapeString(message)) | |
| 111 | + | } | |
| @@ -0,0 +1,111 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "html" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | type kvPair struct { | |||
| 10 | + | Label string | |||
| 11 | + | Value string | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | // renderedPage holds the data needed by renderAppFrame to produce the shell | |||
| 15 | + | // fragment. Route views populate this; the app frame wraps it. | |||
| 16 | + | type renderedPage struct { | |||
| 17 | + | route Route | |||
| 18 | + | subtitle string | |||
| 19 | + | body string | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // renderAppFrame produces a self-contained HTML fragment (no <html>, <head>, | |||
| 23 | + | // or <body>) that wraps route content in the shared shell. | |||
| 24 | + | func renderAppFrame(page renderedPage, theme shellTheme) string { | |||
| 25 | + | rootClass := "sv-app" | |||
| 26 | + | themeClass := theme.RootClass | |||
| 27 | + | if themeClass == "" { | |||
| 28 | + | themeClass = "sv-dark" | |||
| 29 | + | } | |||
| 30 | + | rootClass += " " + themeClass | |||
| 31 | + | ||||
| 32 | + | var styleAttr string | |||
| 33 | + | if theme.InlineVars != "" { | |||
| 34 | + | styleAttr = fmt.Sprintf(` style=%q`, html.EscapeString(theme.InlineVars)) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | routeAttr := html.EscapeString(string(page.route)) | |||
| 38 | + | ||||
| 39 | + | var subtitle string | |||
| 40 | + | if page.subtitle != "" { | |||
| 41 | + | subtitle = fmt.Sprintf(`<h1 class="sv-title">%s</h1>`, html.EscapeString(page.subtitle)) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | return fmt.Sprintf(`<div class=%q data-route=%q%s><div class=%q>%s<div class=%q>%s</div></div></div>`, | |||
| 45 | + | html.EscapeString(rootClass), | |||
| 46 | + | routeAttr, | |||
| 47 | + | styleAttr, | |||
| 48 | + | "sv-shell", | |||
| 49 | + | subtitle, | |||
| 50 | + | "sv-content", | |||
| 51 | + | page.body, | |||
| 52 | + | ) | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func listHTML(rows, emptyMessage string) string { | |||
| 56 | + | if strings.TrimSpace(rows) == "" { | |||
| 57 | + | return emptyStateHTML(emptyMessage) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | return fmt.Sprintf(`<ul class="sv-list">%s</ul>`, rows) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | func listRowHTML(inner string) string { | |||
| 64 | + | return fmt.Sprintf(`<li class="sv-list-row">%s</li>`, inner) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func linkHTML(href, label string) string { | |||
| 68 | + | return fmt.Sprintf(`<a class=%q href=%q>%s</a>`, "sv-link", html.EscapeString(sanitizeHref(href)), html.EscapeString(label)) | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | func metaHTML(text string) string { | |||
| 72 | + | return fmt.Sprintf(`<p class="sv-meta">%s</p>`, html.EscapeString(text)) | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | func kvHTML(rows []kvPair) string { | |||
| 76 | + | var items strings.Builder | |||
| 77 | + | for _, row := range rows { | |||
| 78 | + | if strings.TrimSpace(row.Label) == "" && strings.TrimSpace(row.Value) == "" { | |||
| 79 | + | continue | |||
| 80 | + | } | |||
| 81 | + | items.WriteString(kvRowHTML(row.Label, row.Value)) | |||
| 82 | + | } | |||
| 83 | + | if items.Len() == 0 { | |||
| 84 | + | return emptyStateHTML("No configuration details") | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | return fmt.Sprintf(`<dl class="sv-kv">%s</dl>`, items.String()) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | func kvRowHTML(label, value string) string { | |||
| 91 | + | return fmt.Sprintf(`<div class=%q><dt>%s</dt><dd>%s</dd></div>`, "sv-kv-row", html.EscapeString(label), html.EscapeString(value)) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | // sectionHTML wraps content in a styled section with a heading. | |||
| 95 | + | func sectionHTML(class, title, inner string) string { | |||
| 96 | + | classes := "sv-section" | |||
| 97 | + | if strings.TrimSpace(class) != "" { | |||
| 98 | + | classes += " " + strings.TrimSpace(class) | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | return fmt.Sprintf(`<section class=%q><h2>%s</h2>%s</section>`, | |||
| 102 | + | html.EscapeString(classes), | |||
| 103 | + | html.EscapeString(title), | |||
| 104 | + | inner, | |||
| 105 | + | ) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // emptyStateHTML returns a standard empty-state placeholder. | |||
| 109 | + | func emptyStateHTML(message string) string { | |||
| 110 | + | return fmt.Sprintf(`<div class="sv-empty"><p>%s</p></div>`, html.EscapeString(message)) | |||
| 111 | + | } | |||
A internal/ui/systemviews/shell_test.go +130 -0
| @@ -0,0 +1,130 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestRenderAppFrame_ReturnsFragment(t *testing.T) { | |
| 10 | + | t.Parallel() | |
| 11 | + | ||
| 12 | + | page := renderedPage{ | |
| 13 | + | route: RouteHistory, | |
| 14 | + | subtitle: "History", | |
| 15 | + | body: `<ul><li>hello</li></ul>`, | |
| 16 | + | } | |
| 17 | + | theme := shellTheme{RootClass: "sv-dark"} | |
| 18 | + | ||
| 19 | + | out := renderAppFrame(page, theme) | |
| 20 | + | ||
| 21 | + | // Must be a fragment — no full document tags. | |
| 22 | + | assert.NotContains(t, out, "<html") | |
| 23 | + | assert.NotContains(t, out, "<head") | |
| 24 | + | assert.NotContains(t, out, "<body") | |
| 25 | + | ||
| 26 | + | // Root div carries theme class. | |
| 27 | + | assert.Contains(t, out, `class="sv-app sv-dark"`) | |
| 28 | + | ||
| 29 | + | // Route is on the root element. | |
| 30 | + | assert.Contains(t, out, `data-route="history"`) | |
| 31 | + | ||
| 32 | + | // Shell wrapper present. | |
| 33 | + | assert.Contains(t, out, `class="sv-shell"`) | |
| 34 | + | ||
| 35 | + | // Body content passed through. | |
| 36 | + | assert.Contains(t, out, `<ul><li>hello</li></ul>`) | |
| 37 | + | ||
| 38 | + | // Subtitle rendered. | |
| 39 | + | assert.Contains(t, out, "History") | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestRenderAppFrame_DefaultThemeClass(t *testing.T) { | |
| 43 | + | t.Parallel() | |
| 44 | + | ||
| 45 | + | page := renderedPage{ | |
| 46 | + | route: RouteFavorites, | |
| 47 | + | subtitle: "Favorites", | |
| 48 | + | body: `<p>content</p>`, | |
| 49 | + | } | |
| 50 | + | // Empty theme — should still get sv-app with sv-dark default. | |
| 51 | + | out := renderAppFrame(page, shellTheme{}) | |
| 52 | + | ||
| 53 | + | assert.Contains(t, out, `class="sv-app sv-dark"`) | |
| 54 | + | assert.Contains(t, out, `data-route="favorites"`) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestRenderAppFrame_InlineVarsApplied(t *testing.T) { | |
| 58 | + | t.Parallel() | |
| 59 | + | ||
| 60 | + | page := renderedPage{ | |
| 61 | + | route: RouteConfig, | |
| 62 | + | subtitle: "Config", | |
| 63 | + | body: `<p>settings</p>`, | |
| 64 | + | } | |
| 65 | + | theme := shellTheme{ | |
| 66 | + | RootClass: "sv-light", | |
| 67 | + | InlineVars: "--sv-background: #fff;", | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | out := renderAppFrame(page, theme) | |
| 71 | + | ||
| 72 | + | assert.Contains(t, out, `style="--sv-background: #fff;"`) | |
| 73 | + | assert.Contains(t, out, `class="sv-app sv-light"`) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func TestListHTMLWrapsRows(t *testing.T) { | |
| 77 | + | t.Parallel() | |
| 78 | + | ||
| 79 | + | out := listHTML(`<li class="sv-list-row">entry</li>`, "Nothing here") | |
| 80 | + | ||
| 81 | + | assert.Contains(t, out, `class="sv-list"`) | |
| 82 | + | assert.Contains(t, out, `class="sv-list-row"`) | |
| 83 | + | assert.Contains(t, out, "entry") | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestListHTMLEmptyState(t *testing.T) { | |
| 87 | + | t.Parallel() | |
| 88 | + | ||
| 89 | + | out := listHTML("", "Nothing here") | |
| 90 | + | ||
| 91 | + | assert.Contains(t, out, `class="sv-empty"`) | |
| 92 | + | assert.Contains(t, out, "Nothing here") | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func TestLinkHTMLSanitizesHref(t *testing.T) { | |
| 96 | + | t.Parallel() | |
| 97 | + | ||
| 98 | + | out := linkHTML("javascript:alert(1)", "Bad") | |
| 99 | + | ||
| 100 | + | assert.Contains(t, out, `class="sv-link"`) | |
| 101 | + | assert.Contains(t, out, `href="#"`) | |
| 102 | + | assert.Contains(t, out, "Bad") | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | func TestKVHTMLRendersPairs(t *testing.T) { | |
| 106 | + | t.Parallel() | |
| 107 | + | ||
| 108 | + | out := kvHTML([]kvPair{{Label: "Engine", Value: "webkit"}}) | |
| 109 | + | ||
| 110 | + | assert.Contains(t, out, `class="sv-kv"`) | |
| 111 | + | assert.Contains(t, out, "Engine") | |
| 112 | + | assert.Contains(t, out, "webkit") | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func TestSectionHTML(t *testing.T) { | |
| 116 | + | t.Parallel() | |
| 117 | + | ||
| 118 | + | out := sectionHTML("test-section", "Test Title", "<p>inner</p>") | |
| 119 | + | assert.Contains(t, out, `class="sv-section test-section"`) | |
| 120 | + | assert.Contains(t, out, "Test Title") | |
| 121 | + | assert.Contains(t, out, "<p>inner</p>") | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func TestEmptyStateHTML(t *testing.T) { | |
| 125 | + | t.Parallel() | |
| 126 | + | ||
| 127 | + | out := emptyStateHTML("Nothing here") | |
| 128 | + | assert.Contains(t, out, `class="sv-empty"`) | |
| 129 | + | assert.Contains(t, out, "Nothing here") | |
| 130 | + | } | |
| @@ -0,0 +1,130 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestRenderAppFrame_ReturnsFragment(t *testing.T) { | |||
| 10 | + | t.Parallel() | |||
| 11 | + | ||||
| 12 | + | page := renderedPage{ | |||
| 13 | + | route: RouteHistory, | |||
| 14 | + | subtitle: "History", | |||
| 15 | + | body: `<ul><li>hello</li></ul>`, | |||
| 16 | + | } | |||
| 17 | + | theme := shellTheme{RootClass: "sv-dark"} | |||
| 18 | + | ||||
| 19 | + | out := renderAppFrame(page, theme) | |||
| 20 | + | ||||
| 21 | + | // Must be a fragment — no full document tags. | |||
| 22 | + | assert.NotContains(t, out, "<html") | |||
| 23 | + | assert.NotContains(t, out, "<head") | |||
| 24 | + | assert.NotContains(t, out, "<body") | |||
| 25 | + | ||||
| 26 | + | // Root div carries theme class. | |||
| 27 | + | assert.Contains(t, out, `class="sv-app sv-dark"`) | |||
| 28 | + | ||||
| 29 | + | // Route is on the root element. | |||
| 30 | + | assert.Contains(t, out, `data-route="history"`) | |||
| 31 | + | ||||
| 32 | + | // Shell wrapper present. | |||
| 33 | + | assert.Contains(t, out, `class="sv-shell"`) | |||
| 34 | + | ||||
| 35 | + | // Body content passed through. | |||
| 36 | + | assert.Contains(t, out, `<ul><li>hello</li></ul>`) | |||
| 37 | + | ||||
| 38 | + | // Subtitle rendered. | |||
| 39 | + | assert.Contains(t, out, "History") | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestRenderAppFrame_DefaultThemeClass(t *testing.T) { | |||
| 43 | + | t.Parallel() | |||
| 44 | + | ||||
| 45 | + | page := renderedPage{ | |||
| 46 | + | route: RouteFavorites, | |||
| 47 | + | subtitle: "Favorites", | |||
| 48 | + | body: `<p>content</p>`, | |||
| 49 | + | } | |||
| 50 | + | // Empty theme — should still get sv-app with sv-dark default. | |||
| 51 | + | out := renderAppFrame(page, shellTheme{}) | |||
| 52 | + | ||||
| 53 | + | assert.Contains(t, out, `class="sv-app sv-dark"`) | |||
| 54 | + | assert.Contains(t, out, `data-route="favorites"`) | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestRenderAppFrame_InlineVarsApplied(t *testing.T) { | |||
| 58 | + | t.Parallel() | |||
| 59 | + | ||||
| 60 | + | page := renderedPage{ | |||
| 61 | + | route: RouteConfig, | |||
| 62 | + | subtitle: "Config", | |||
| 63 | + | body: `<p>settings</p>`, | |||
| 64 | + | } | |||
| 65 | + | theme := shellTheme{ | |||
| 66 | + | RootClass: "sv-light", | |||
| 67 | + | InlineVars: "--sv-background: #fff;", | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | out := renderAppFrame(page, theme) | |||
| 71 | + | ||||
| 72 | + | assert.Contains(t, out, `style="--sv-background: #fff;"`) | |||
| 73 | + | assert.Contains(t, out, `class="sv-app sv-light"`) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestListHTMLWrapsRows(t *testing.T) { | |||
| 77 | + | t.Parallel() | |||
| 78 | + | ||||
| 79 | + | out := listHTML(`<li class="sv-list-row">entry</li>`, "Nothing here") | |||
| 80 | + | ||||
| 81 | + | assert.Contains(t, out, `class="sv-list"`) | |||
| 82 | + | assert.Contains(t, out, `class="sv-list-row"`) | |||
| 83 | + | assert.Contains(t, out, "entry") | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestListHTMLEmptyState(t *testing.T) { | |||
| 87 | + | t.Parallel() | |||
| 88 | + | ||||
| 89 | + | out := listHTML("", "Nothing here") | |||
| 90 | + | ||||
| 91 | + | assert.Contains(t, out, `class="sv-empty"`) | |||
| 92 | + | assert.Contains(t, out, "Nothing here") | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | func TestLinkHTMLSanitizesHref(t *testing.T) { | |||
| 96 | + | t.Parallel() | |||
| 97 | + | ||||
| 98 | + | out := linkHTML("javascript:alert(1)", "Bad") | |||
| 99 | + | ||||
| 100 | + | assert.Contains(t, out, `class="sv-link"`) | |||
| 101 | + | assert.Contains(t, out, `href="#"`) | |||
| 102 | + | assert.Contains(t, out, "Bad") | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | func TestKVHTMLRendersPairs(t *testing.T) { | |||
| 106 | + | t.Parallel() | |||
| 107 | + | ||||
| 108 | + | out := kvHTML([]kvPair{{Label: "Engine", Value: "webkit"}}) | |||
| 109 | + | ||||
| 110 | + | assert.Contains(t, out, `class="sv-kv"`) | |||
| 111 | + | assert.Contains(t, out, "Engine") | |||
| 112 | + | assert.Contains(t, out, "webkit") | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func TestSectionHTML(t *testing.T) { | |||
| 116 | + | t.Parallel() | |||
| 117 | + | ||||
| 118 | + | out := sectionHTML("test-section", "Test Title", "<p>inner</p>") | |||
| 119 | + | assert.Contains(t, out, `class="sv-section test-section"`) | |||
| 120 | + | assert.Contains(t, out, "Test Title") | |||
| 121 | + | assert.Contains(t, out, "<p>inner</p>") | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | func TestEmptyStateHTML(t *testing.T) { | |||
| 125 | + | t.Parallel() | |||
| 126 | + | ||||
| 127 | + | out := emptyStateHTML("Nothing here") | |||
| 128 | + | assert.Contains(t, out, `class="sv-empty"`) | |||
| 129 | + | assert.Contains(t, out, "Nothing here") | |||
| 130 | + | } | |||
A internal/ui/systemviews/theme.go +89 -0
| @@ -0,0 +1,89 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type shellTheme struct { | |
| 11 | + | RootClass string | |
| 12 | + | InlineVars string | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | shellDarkClass = "sv-dark" | |
| 17 | + | shellLightClass = "sv-light" | |
| 18 | + | ) | |
| 19 | + | ||
| 20 | + | func currentPrefersDark() bool { | |
| 21 | + | return currentPrefersDarkImpl() | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func resolveShellTheme(appearance port.WebUIAppearanceConfig) shellTheme { | |
| 25 | + | if isZeroAppearance(appearance) { | |
| 26 | + | return shellTheme{} | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | var palette port.ColorPalette | |
| 30 | + | var rootClass string | |
| 31 | + | ||
| 32 | + | switch strings.ToLower(strings.TrimSpace(appearance.ColorScheme)) { | |
| 33 | + | case "prefer-dark": | |
| 34 | + | palette = appearance.DarkPalette | |
| 35 | + | rootClass = shellDarkClass | |
| 36 | + | case "prefer-light": | |
| 37 | + | palette = appearance.LightPalette | |
| 38 | + | rootClass = shellLightClass | |
| 39 | + | default: | |
| 40 | + | if currentPrefersDark() { | |
| 41 | + | palette = appearance.DarkPalette | |
| 42 | + | rootClass = shellDarkClass | |
| 43 | + | } else { | |
| 44 | + | palette = appearance.LightPalette | |
| 45 | + | rootClass = shellLightClass | |
| 46 | + | } | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | return shellTheme{ | |
| 50 | + | RootClass: rootClass, | |
| 51 | + | InlineVars: buildInlineVars(appearance, palette), | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPalette) string { | |
| 56 | + | parts := make([]string, 0, 11) | |
| 57 | + | appendInlineVar := func(name, value string) { | |
| 58 | + | if strings.TrimSpace(value) == "" { | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | parts = append(parts, fmt.Sprintf("%s: %s;", name, value)) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | appendInlineVar("--sv-background", palette.Background) | |
| 65 | + | appendInlineVar("--sv-surface", palette.Surface) | |
| 66 | + | appendInlineVar("--sv-surface-variant", palette.SurfaceVariant) | |
| 67 | + | appendInlineVar("--sv-text", palette.Text) | |
| 68 | + | appendInlineVar("--sv-muted", palette.Muted) | |
| 69 | + | appendInlineVar("--sv-accent", palette.Accent) | |
| 70 | + | appendInlineVar("--sv-border", palette.Border) | |
| 71 | + | appendInlineVar("--sv-font-sans", appearance.SansFont) | |
| 72 | + | appendInlineVar("--sv-font-serif", appearance.SerifFont) | |
| 73 | + | appendInlineVar("--sv-font-mono", appearance.MonospaceFont) | |
| 74 | + | if appearance.DefaultFontSize > 0 { | |
| 75 | + | parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize)) | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | return strings.Join(parts, " ") | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | func isZeroAppearance(appearance port.WebUIAppearanceConfig) bool { | |
| 82 | + | return appearance.ColorScheme == "" && | |
| 83 | + | appearance.SansFont == "" && | |
| 84 | + | appearance.SerifFont == "" && | |
| 85 | + | appearance.MonospaceFont == "" && | |
| 86 | + | appearance.DefaultFontSize == 0 && | |
| 87 | + | appearance.LightPalette == (port.ColorPalette{}) && | |
| 88 | + | appearance.DarkPalette == (port.ColorPalette{}) | |
| 89 | + | } | |
| @@ -0,0 +1,89 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "strings" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type shellTheme struct { | |||
| 11 | + | RootClass string | |||
| 12 | + | InlineVars string | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | shellDarkClass = "sv-dark" | |||
| 17 | + | shellLightClass = "sv-light" | |||
| 18 | + | ) | |||
| 19 | + | ||||
| 20 | + | func currentPrefersDark() bool { | |||
| 21 | + | return currentPrefersDarkImpl() | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func resolveShellTheme(appearance port.WebUIAppearanceConfig) shellTheme { | |||
| 25 | + | if isZeroAppearance(appearance) { | |||
| 26 | + | return shellTheme{} | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | var palette port.ColorPalette | |||
| 30 | + | var rootClass string | |||
| 31 | + | ||||
| 32 | + | switch strings.ToLower(strings.TrimSpace(appearance.ColorScheme)) { | |||
| 33 | + | case "prefer-dark": | |||
| 34 | + | palette = appearance.DarkPalette | |||
| 35 | + | rootClass = shellDarkClass | |||
| 36 | + | case "prefer-light": | |||
| 37 | + | palette = appearance.LightPalette | |||
| 38 | + | rootClass = shellLightClass | |||
| 39 | + | default: | |||
| 40 | + | if currentPrefersDark() { | |||
| 41 | + | palette = appearance.DarkPalette | |||
| 42 | + | rootClass = shellDarkClass | |||
| 43 | + | } else { | |||
| 44 | + | palette = appearance.LightPalette | |||
| 45 | + | rootClass = shellLightClass | |||
| 46 | + | } | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | return shellTheme{ | |||
| 50 | + | RootClass: rootClass, | |||
| 51 | + | InlineVars: buildInlineVars(appearance, palette), | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func buildInlineVars(appearance port.WebUIAppearanceConfig, palette port.ColorPalette) string { | |||
| 56 | + | parts := make([]string, 0, 11) | |||
| 57 | + | appendInlineVar := func(name, value string) { | |||
| 58 | + | if strings.TrimSpace(value) == "" { | |||
| 59 | + | return | |||
| 60 | + | } | |||
| 61 | + | parts = append(parts, fmt.Sprintf("%s: %s;", name, value)) | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | appendInlineVar("--sv-background", palette.Background) | |||
| 65 | + | appendInlineVar("--sv-surface", palette.Surface) | |||
| 66 | + | appendInlineVar("--sv-surface-variant", palette.SurfaceVariant) | |||
| 67 | + | appendInlineVar("--sv-text", palette.Text) | |||
| 68 | + | appendInlineVar("--sv-muted", palette.Muted) | |||
| 69 | + | appendInlineVar("--sv-accent", palette.Accent) | |||
| 70 | + | appendInlineVar("--sv-border", palette.Border) | |||
| 71 | + | appendInlineVar("--sv-font-sans", appearance.SansFont) | |||
| 72 | + | appendInlineVar("--sv-font-serif", appearance.SerifFont) | |||
| 73 | + | appendInlineVar("--sv-font-mono", appearance.MonospaceFont) | |||
| 74 | + | if appearance.DefaultFontSize > 0 { | |||
| 75 | + | parts = append(parts, fmt.Sprintf("--sv-font-size: %dpx;", appearance.DefaultFontSize)) | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | return strings.Join(parts, " ") | |||
| 79 | + | } | |||
| 80 | + | ||||
| 81 | + | func isZeroAppearance(appearance port.WebUIAppearanceConfig) bool { | |||
| 82 | + | return appearance.ColorScheme == "" && | |||
| 83 | + | appearance.SansFont == "" && | |||
| 84 | + | appearance.SerifFont == "" && | |||
| 85 | + | appearance.MonospaceFont == "" && | |||
| 86 | + | appearance.DefaultFontSize == 0 && | |||
| 87 | + | appearance.LightPalette == (port.ColorPalette{}) && | |||
| 88 | + | appearance.DarkPalette == (port.ColorPalette{}) | |||
| 89 | + | } | |||
A internal/ui/systemviews/theme_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/stretchr/testify/assert" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestResolveShellTheme(t *testing.T) { | |
| 11 | + | orig := currentPrefersDarkImpl | |
| 12 | + | t.Cleanup(func() { | |
| 13 | + | currentPrefersDarkImpl = orig | |
| 14 | + | }) | |
| 15 | + | ||
| 16 | + | appearance := port.WebUIAppearanceConfig{ | |
| 17 | + | SansFont: "Inter", | |
| 18 | + | SerifFont: "Georgia", | |
| 19 | + | MonospaceFont: "JetBrains Mono", | |
| 20 | + | DefaultFontSize: 16, | |
| 21 | + | ColorScheme: "default", | |
| 22 | + | LightPalette: port.ColorPalette{ | |
| 23 | + | Background: "#ffffff", | |
| 24 | + | Surface: "#f8f8f8", | |
| 25 | + | SurfaceVariant: "#eeeeee", | |
| 26 | + | Text: "#111111", | |
| 27 | + | Muted: "#666666", | |
| 28 | + | Accent: "#0055ff", | |
| 29 | + | Border: "#dddddd", | |
| 30 | + | }, | |
| 31 | + | DarkPalette: port.ColorPalette{ | |
| 32 | + | Background: "#111111", | |
| 33 | + | Surface: "#1a1a1a", | |
| 34 | + | SurfaceVariant: "#2a2a2a", | |
| 35 | + | Text: "#f5f5f5", | |
| 36 | + | Muted: "#a0a0a0", | |
| 37 | + | Accent: "#66aaff", | |
| 38 | + | Border: "#333333", | |
| 39 | + | }, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | tests := []struct { | |
| 43 | + | name string | |
| 44 | + | colorScheme string | |
| 45 | + | prefersDark bool | |
| 46 | + | wantClass string | |
| 47 | + | wantBackground string | |
| 48 | + | wantSurfaceVariant string | |
| 49 | + | }{ | |
| 50 | + | {name: "prefer-dark", colorScheme: "prefer-dark", prefersDark: false, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"}, | |
| 51 | + | {name: "prefer-light", colorScheme: "prefer-light", prefersDark: true, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"}, | |
| 52 | + | {name: "default prefers dark", colorScheme: "default", prefersDark: true, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"}, | |
| 53 | + | {name: "default prefers light", colorScheme: "default", prefersDark: false, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"}, | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | for _, tt := range tests { | |
| 57 | + | t.Run(tt.name, func(t *testing.T) { | |
| 58 | + | currentPrefersDarkImpl = func() bool { return tt.prefersDark } | |
| 59 | + | appearance.ColorScheme = tt.colorScheme | |
| 60 | + | ||
| 61 | + | got := resolveShellTheme(appearance) | |
| 62 | + | ||
| 63 | + | assert.Equal(t, tt.wantClass, got.RootClass) | |
| 64 | + | assert.Contains(t, got.InlineVars, "--sv-background") | |
| 65 | + | assert.Contains(t, got.InlineVars, "--sv-font-sans") | |
| 66 | + | assert.Contains(t, got.InlineVars, tt.wantBackground) | |
| 67 | + | assert.Contains(t, got.InlineVars, tt.wantSurfaceVariant) | |
| 68 | + | }) | |
| 69 | + | } | |
| 70 | + | } | |
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | "github.com/stretchr/testify/assert" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestResolveShellTheme(t *testing.T) { | |||
| 11 | + | orig := currentPrefersDarkImpl | |||
| 12 | + | t.Cleanup(func() { | |||
| 13 | + | currentPrefersDarkImpl = orig | |||
| 14 | + | }) | |||
| 15 | + | ||||
| 16 | + | appearance := port.WebUIAppearanceConfig{ | |||
| 17 | + | SansFont: "Inter", | |||
| 18 | + | SerifFont: "Georgia", | |||
| 19 | + | MonospaceFont: "JetBrains Mono", | |||
| 20 | + | DefaultFontSize: 16, | |||
| 21 | + | ColorScheme: "default", | |||
| 22 | + | LightPalette: port.ColorPalette{ | |||
| 23 | + | Background: "#ffffff", | |||
| 24 | + | Surface: "#f8f8f8", | |||
| 25 | + | SurfaceVariant: "#eeeeee", | |||
| 26 | + | Text: "#111111", | |||
| 27 | + | Muted: "#666666", | |||
| 28 | + | Accent: "#0055ff", | |||
| 29 | + | Border: "#dddddd", | |||
| 30 | + | }, | |||
| 31 | + | DarkPalette: port.ColorPalette{ | |||
| 32 | + | Background: "#111111", | |||
| 33 | + | Surface: "#1a1a1a", | |||
| 34 | + | SurfaceVariant: "#2a2a2a", | |||
| 35 | + | Text: "#f5f5f5", | |||
| 36 | + | Muted: "#a0a0a0", | |||
| 37 | + | Accent: "#66aaff", | |||
| 38 | + | Border: "#333333", | |||
| 39 | + | }, | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | tests := []struct { | |||
| 43 | + | name string | |||
| 44 | + | colorScheme string | |||
| 45 | + | prefersDark bool | |||
| 46 | + | wantClass string | |||
| 47 | + | wantBackground string | |||
| 48 | + | wantSurfaceVariant string | |||
| 49 | + | }{ | |||
| 50 | + | {name: "prefer-dark", colorScheme: "prefer-dark", prefersDark: false, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"}, | |||
| 51 | + | {name: "prefer-light", colorScheme: "prefer-light", prefersDark: true, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"}, | |||
| 52 | + | {name: "default prefers dark", colorScheme: "default", prefersDark: true, wantClass: "sv-dark", wantBackground: "#111111", wantSurfaceVariant: "#2a2a2a"}, | |||
| 53 | + | {name: "default prefers light", colorScheme: "default", prefersDark: false, wantClass: "sv-light", wantBackground: "#ffffff", wantSurfaceVariant: "#eeeeee"}, | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | for _, tt := range tests { | |||
| 57 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 58 | + | currentPrefersDarkImpl = func() bool { return tt.prefersDark } | |||
| 59 | + | appearance.ColorScheme = tt.colorScheme | |||
| 60 | + | ||||
| 61 | + | got := resolveShellTheme(appearance) | |||
| 62 | + | ||||
| 63 | + | assert.Equal(t, tt.wantClass, got.RootClass) | |||
| 64 | + | assert.Contains(t, got.InlineVars, "--sv-background") | |||
| 65 | + | assert.Contains(t, got.InlineVars, "--sv-font-sans") | |||
| 66 | + | assert.Contains(t, got.InlineVars, tt.wantBackground) | |||
| 67 | + | assert.Contains(t, got.InlineVars, tt.wantSurfaceVariant) | |||
| 68 | + | }) | |||
| 69 | + | } | |||
| 70 | + | } | |||