detached d944cf1

fix(systemviews): polish and harden config views

commit
d944cf143852a011393b1ced3da06da4b84eef4c
author
brice · · 2026-04-24 03:17 UTC
parent
9da5cc3
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +190 -32
M assets/systemviews/systemviews.css +108 -11
@@ -8,10 +8,10 @@
88
99 body {
1010 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);
11+ font-family: var(--sv-font-sans, Inter), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
12+ font-size: var(--sv-font-size, 14px);
13+ background: var(--sv-background, #0f1115);
14+ color: var(--sv-text, #e8ebf0);
1515 }
1616
1717 .sv-app {
@@ -24,6 +24,60 @@ body {
2424 padding: 1rem;
2525 }
2626
27+.sv-title {
28+ margin: 0;
29+ font-size: clamp(1.35rem, 2.4vw, 2rem);
30+ line-height: 1.15;
31+}
32+
33+.sv-content {
34+ display: grid;
35+ gap: 1rem;
36+}
37+
38+.sv-section > h2 {
39+ margin: 0 0 0.75rem;
40+ font-size: 1rem;
41+ letter-spacing: 0.02em;
42+}
43+
44+.sv-list {
45+ display: grid;
46+ gap: 0.6rem;
47+ margin: 0.75rem 0 0;
48+ padding: 0;
49+ list-style: none;
50+}
51+
52+.sv-list-row {
53+ padding: 0.75rem;
54+ border: 1px solid var(--sv-border, #2a313d);
55+ border-radius: 0.65rem;
56+ background: color-mix(in srgb, var(--sv-surface-variant, #1d2129) 72%, transparent);
57+}
58+
59+.sv-link {
60+ color: var(--sv-accent, #8ab4ff);
61+ font-weight: 700;
62+ text-decoration: none;
63+}
64+
65+.sv-link:hover {
66+ text-decoration: underline;
67+}
68+
69+.sv-badge {
70+ display: inline-flex;
71+ align-items: center;
72+ border: 1px solid var(--sv-border, #2a313d);
73+ border-radius: 999px;
74+ padding: 0.15rem 0.45rem;
75+ background: var(--sv-surface-variant, #1d2129);
76+ color: var(--sv-muted, #9aa3b2);
77+ font-size: 12px;
78+ font-weight: 700;
79+}
80+
2781 .sv-header {
2882 background: var(--sv-surface-variant, #1d2129);
2983 border: 1px solid var(--sv-border, #2a313d);
@@ -209,7 +263,7 @@ body {
209263 border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));
210264 background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));
211265 border-radius: 0.75rem;
212- color: var(--sv-fg, #e8ebf0);
266+ color: var(--sv-text, #e8ebf0);
213267 padding: 1rem;
214268 }
215269
@@ -298,7 +352,7 @@ body {
298352 border-radius: 0.55rem;
299353 padding: 0.55rem 0.65rem;
300354 background: var(--sv-surface-variant, #1d2129);
301- color: var(--sv-fg, #e8ebf0);
355+ color: var(--sv-text, #e8ebf0);
302356 font: inherit;
303357 }
304358
@@ -321,14 +375,35 @@ body {
321375 .sv-button-secondary {
322376 border-color: var(--sv-border, #2a313d);
323377 background: var(--sv-surface-variant, #1d2129);
324- color: var(--sv-fg, #e8ebf0);
378+ color: var(--sv-text, #e8ebf0);
325379 }
326380
327381 .sv-button-danger {
328- border-color: color-mix(in srgb, #ef4444 70%, var(--sv-border, #2a313d));
382+ border-color: color-mix(in srgb, #ef4444 78%, var(--sv-border, #2a313d));
383+ background: color-mix(in srgb, #ef4444 14%, var(--sv-surface-variant, #1d2129));
329384 color: #fecaca;
330385 }
331386
387+.sv-button-danger:hover {
388+ background: color-mix(in srgb, #ef4444 24%, var(--sv-surface-variant, #1d2129));
389+}
390+
391+.sv-button:focus-visible,
392+.sv-icon-button:focus-visible,
393+.sv-domain-filter:focus-visible,
394+.sv-filter-chip button:focus-visible,
395+.sv-link:focus-visible,
396+.sv-search-label input:focus-visible,
397+.sv-card-form input:focus-visible,
398+.sv-card-form select:focus-visible,
399+.sv-inline-form input:focus-visible,
400+.sv-inline-form select:focus-visible,
401+.sv-fieldset input:focus-visible,
402+.sv-fieldset select:focus-visible {
403+ outline: 2px solid var(--sv-accent, #8ab4ff);
404+ outline-offset: 2px;
405+}
406+
332407 .sv-button:disabled {
333408 cursor: not-allowed;
334409 opacity: 0.45;
@@ -355,7 +430,7 @@ body {
355430 }
356431
357432 .sv-domain-filter {
358- color: var(--sv-fg, #e8ebf0);
433+ color: var(--sv-text, #e8ebf0);
359434 font-weight: 700;
360435 padding: 0;
361436 text-align: left;
@@ -431,7 +506,7 @@ body {
431506 border-radius: 0.55rem;
432507 padding: 0.5rem 0.6rem;
433508 background: var(--sv-surface-variant, #1d2129);
434- color: var(--sv-fg, #e8ebf0);
509+ color: var(--sv-text, #e8ebf0);
435510 font: inherit;
436511 }
437512
@@ -511,8 +586,30 @@ body {
511586
512587 @media (max-width: 720px) {
513588 .sv-favorite-header,
514- .sv-keybinding-row {
589+ .sv-keybinding-row,
590+ .sv-history-item,
591+ .sv-history-actions-panel,
592+ .sv-toolbar {
515593 display: grid;
516594 grid-template-columns: 1fr;
517595 }
596+
597+ .sv-kv-row {
598+ grid-template-columns: 1fr;
599+ }
600+
601+ .sv-card-form,
602+ .sv-inline-form,
603+ .sv-fieldset,
604+ .sv-button-row {
605+ align-items: stretch;
606+ }
607+
608+ .sv-card-form label,
609+ .sv-inline-form label,
610+ .sv-fieldset label,
611+ .sv-button,
612+ .sv-button-secondary {
613+ width: 100%;
614+ }
518615 }
@@ -8,10 +8,10 @@
8 8
9 body {9 body {
10 margin: 0;10 margin: 0;
11- font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;11+ font-family: var(--sv-font-sans, Inter), system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
12- font-size: 14px;12+ font-size: var(--sv-font-size, 14px);
13- background: var(--sv-bg, #0f1115);13+ background: var(--sv-background, #0f1115);
14- color: var(--sv-fg, #e8ebf0);14+ color: var(--sv-text, #e8ebf0);
15 }15 }
16 16
17 .sv-app {17 .sv-app {
@@ -24,6 +24,60 @@ body {
24 padding: 1rem;24 padding: 1rem;
25 }25 }
26 26
27+.sv-title {
28+ margin: 0;
29+ font-size: clamp(1.35rem, 2.4vw, 2rem);
30+ line-height: 1.15;
31+}
32+
33+.sv-content {
34+ display: grid;
35+ gap: 1rem;
36+}
37+
38+.sv-section > h2 {
39+ margin: 0 0 0.75rem;
40+ font-size: 1rem;
41+ letter-spacing: 0.02em;
42+}
43+
44+.sv-list {
45+ display: grid;
46+ gap: 0.6rem;
47+ margin: 0.75rem 0 0;
48+ padding: 0;
49+ list-style: none;
50+}
51+
52+.sv-list-row {
53+ padding: 0.75rem;
54+ border: 1px solid var(--sv-border, #2a313d);
55+ border-radius: 0.65rem;
56+ background: color-mix(in srgb, var(--sv-surface-variant, #1d2129) 72%, transparent);
57+}
58+
59+.sv-link {
60+ color: var(--sv-accent, #8ab4ff);
61+ font-weight: 700;
62+ text-decoration: none;
63+}
64+
65+.sv-link:hover {
66+ text-decoration: underline;
67+}
68+
69+.sv-badge {
70+ display: inline-flex;
71+ align-items: center;
72+ border: 1px solid var(--sv-border, #2a313d);
73+ border-radius: 999px;
74+ padding: 0.15rem 0.45rem;
75+ background: var(--sv-surface-variant, #1d2129);
76+ color: var(--sv-muted, #9aa3b2);
77+ font-size: 12px;
78+ font-weight: 700;
79+}
80+
27 .sv-header {81 .sv-header {
28 background: var(--sv-surface-variant, #1d2129);82 background: var(--sv-surface-variant, #1d2129);
29 border: 1px solid var(--sv-border, #2a313d);83 border: 1px solid var(--sv-border, #2a313d);
@@ -209,7 +263,7 @@ body {
209 border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));263 border: 1px solid color-mix(in srgb, #ef4444 65%, var(--sv-border, #2a313d));
210 background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));264 background: color-mix(in srgb, #ef4444 12%, var(--sv-surface, #171a21));
211 border-radius: 0.75rem;265 border-radius: 0.75rem;
212- color: var(--sv-fg, #e8ebf0);266+ color: var(--sv-text, #e8ebf0);
213 padding: 1rem;267 padding: 1rem;
214 }268 }
215 269
@@ -298,7 +352,7 @@ body {
298 border-radius: 0.55rem;352 border-radius: 0.55rem;
299 padding: 0.55rem 0.65rem;353 padding: 0.55rem 0.65rem;
300 background: var(--sv-surface-variant, #1d2129);354 background: var(--sv-surface-variant, #1d2129);
301- color: var(--sv-fg, #e8ebf0);355+ color: var(--sv-text, #e8ebf0);
302 font: inherit;356 font: inherit;
303 }357 }
304 358
@@ -321,14 +375,35 @@ body {
321 .sv-button-secondary {375 .sv-button-secondary {
322 border-color: var(--sv-border, #2a313d);376 border-color: var(--sv-border, #2a313d);
323 background: var(--sv-surface-variant, #1d2129);377 background: var(--sv-surface-variant, #1d2129);
324- color: var(--sv-fg, #e8ebf0);378+ color: var(--sv-text, #e8ebf0);
325 }379 }
326 380
327 .sv-button-danger {381 .sv-button-danger {
328- border-color: color-mix(in srgb, #ef4444 70%, var(--sv-border, #2a313d));382+ border-color: color-mix(in srgb, #ef4444 78%, var(--sv-border, #2a313d));
383+ background: color-mix(in srgb, #ef4444 14%, var(--sv-surface-variant, #1d2129));
329 color: #fecaca;384 color: #fecaca;
330 }385 }
331 386
387+.sv-button-danger:hover {
388+ background: color-mix(in srgb, #ef4444 24%, var(--sv-surface-variant, #1d2129));
389+}
390+
391+.sv-button:focus-visible,
392+.sv-icon-button:focus-visible,
393+.sv-domain-filter:focus-visible,
394+.sv-filter-chip button:focus-visible,
395+.sv-link:focus-visible,
396+.sv-search-label input:focus-visible,
397+.sv-card-form input:focus-visible,
398+.sv-card-form select:focus-visible,
399+.sv-inline-form input:focus-visible,
400+.sv-inline-form select:focus-visible,
401+.sv-fieldset input:focus-visible,
402+.sv-fieldset select:focus-visible {
403+ outline: 2px solid var(--sv-accent, #8ab4ff);
404+ outline-offset: 2px;
405+}
406+
332 .sv-button:disabled {407 .sv-button:disabled {
333 cursor: not-allowed;408 cursor: not-allowed;
334 opacity: 0.45;409 opacity: 0.45;
@@ -355,7 +430,7 @@ body {
355 }430 }
356 431
357 .sv-domain-filter {432 .sv-domain-filter {
358- color: var(--sv-fg, #e8ebf0);433+ color: var(--sv-text, #e8ebf0);
359 font-weight: 700;434 font-weight: 700;
360 padding: 0;435 padding: 0;
361 text-align: left;436 text-align: left;
@@ -431,7 +506,7 @@ body {
431 border-radius: 0.55rem;506 border-radius: 0.55rem;
432 padding: 0.5rem 0.6rem;507 padding: 0.5rem 0.6rem;
433 background: var(--sv-surface-variant, #1d2129);508 background: var(--sv-surface-variant, #1d2129);
434- color: var(--sv-fg, #e8ebf0);509+ color: var(--sv-text, #e8ebf0);
435 font: inherit;510 font: inherit;
436 }511 }
437 512
@@ -511,8 +586,30 @@ body {
511 586
512 @media (max-width: 720px) {587 @media (max-width: 720px) {
513 .sv-favorite-header,588 .sv-favorite-header,
514- .sv-keybinding-row {589+ .sv-keybinding-row,
590+ .sv-history-item,
591+ .sv-history-actions-panel,
592+ .sv-toolbar {
515 display: grid;593 display: grid;
516 grid-template-columns: 1fr;594 grid-template-columns: 1fr;
517 }595 }
596+
597+ .sv-kv-row {
598+ grid-template-columns: 1fr;
599+ }
600+
601+ .sv-card-form,
602+ .sv-inline-form,
603+ .sv-fieldset,
604+ .sv-button-row {
605+ align-items: stretch;
606+ }
607+
608+ .sv-card-form label,
609+ .sv-inline-form label,
610+ .sv-fieldset label,
611+ .sv-button,
612+ .sv-button-secondary {
613+ width: 100%;
614+ }
518 }615 }
M cmd/systemviews/bootstrap_test.go +10 -0
@@ -138,6 +138,16 @@ func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) {
138138 return f.favorites, nil
139139 }
140140
141+func (*fakeBridgeService) CreateFavorite(context.Context, port.FavoriteCreateInput) (*entity.Favorite, error) {
142+ return nil, nil
143+}
144+
145+func (*fakeBridgeService) UpdateFavorite(context.Context, port.FavoriteUpdateInput) (*entity.Favorite, error) {
146+ return nil, nil
147+}
148+
149+func (*fakeBridgeService) DeleteFavorite(context.Context, int64) error { return nil }
150+
141151 func (f *fakeBridgeService) ListFolders(context.Context) ([]*entity.Folder, error) {
142152 return f.folders, nil
143153 }
@@ -138,6 +138,16 @@ func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) {
138 return f.favorites, nil138 return f.favorites, nil
139 }139 }
140 140
141+func (*fakeBridgeService) CreateFavorite(context.Context, port.FavoriteCreateInput) (*entity.Favorite, error) {
142+ return nil, nil
143+}
144+
145+func (*fakeBridgeService) UpdateFavorite(context.Context, port.FavoriteUpdateInput) (*entity.Favorite, error) {
146+ return nil, nil
147+}
148+
149+func (*fakeBridgeService) DeleteFavorite(context.Context, int64) error { return nil }
150+
141 func (f *fakeBridgeService) ListFolders(context.Context) ([]*entity.Folder, error) {151 func (f *fakeBridgeService) ListFolders(context.Context) ([]*entity.Folder, error) {
142 return f.folders, nil152 return f.folders, nil
143 }153 }
M internal/ui/systemviews/app.go +2 -0
@@ -4,6 +4,7 @@ import (
44 "context"
55 "errors"
66 "strings"
7+ "sync"
78
89 "github.com/bnema/dumber/internal/application/port"
910 "github.com/bnema/dumber/internal/domain/entity"
@@ -18,6 +19,7 @@ type Dependencies struct {
1819 }
1920
2021 type App struct {
22+ mu sync.Mutex
2123 deps Dependencies
2224 currentRoute Route
2325 shellTheme shellTheme
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "errors"5 "errors"
6 "strings"6 "strings"
7+ "sync"
7 8
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
@@ -18,6 +19,7 @@ type Dependencies struct {
18 }19 }
19 20
20 type App struct {21 type App struct {
22+ mu sync.Mutex
21 deps Dependencies23 deps Dependencies
22 currentRoute Route24 currentRoute Route
23 shellTheme shellTheme25 shellTheme shellTheme
M internal/ui/systemviews/config_actions.go +28 -10
@@ -183,6 +183,9 @@ func (a *App) deleteSearchShortcut(ctx context.Context, data map[string]string)
183183 return fmt.Errorf("search shortcut key is required")
184184 }
185185 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
186+ if _, exists := cfg.SearchShortcuts[key]; !exists {
187+ return fmt.Errorf("search shortcut %q not found", key)
188+ }
186189 delete(cfg.SearchShortcuts, key)
187190 if err := a.saveEditableConfig(ctx, cfg); err != nil {
188191 return err
@@ -243,6 +246,9 @@ func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) e
243246 if mode == "" || action == "" {
244247 return fmt.Errorf("mode and action are required")
245248 }
249+ if err := a.requireKeybindingTarget(ctx, mode, action); err != nil {
250+ return err
251+ }
246252 resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{
247253 RequestID: configRequestIDPrefix,
248254 Mode: mode,
@@ -266,6 +272,9 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string)
266272 if mode == "" || action == "" {
267273 return fmt.Errorf("mode and action are required")
268274 }
275+ if err := a.requireKeybindingTarget(ctx, mode, action); err != nil {
276+ return err
277+ }
269278 if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{
270279 RequestID: configRequestIDPrefix,
271280 Mode: mode,
@@ -278,23 +287,32 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string)
278287 }
279288
280289 func (a *App) editableConfig(ctx context.Context) (port.WebUIConfig, error) {
281- payload, err := a.currentConfigPayload(ctx)
290+ cfg, err := a.deps.Config.Current(ctx)
282291 if err != nil {
283292 return port.WebUIConfig{}, err
284293 }
285- return webUIConfigFromPayload(payload), nil
294+ a.config = &cfg
295+ return webUIConfigFromPayload(cfg), nil
286296 }
287297
288-func (a *App) currentConfigPayload(ctx context.Context) (port.SystemviewConfigPayload, error) {
289- if a.config != nil {
290- return *a.config, nil
291- }
292- cfg, err := a.deps.Config.Current(ctx)
298+func (a *App) requireKeybindingTarget(ctx context.Context, mode, action string) error {
299+ keybindings, err := a.deps.Config.GetKeybindings(ctx)
293300 if err != nil {
294- return port.SystemviewConfigPayload{}, err
301+ return err
295302 }
296- a.config = &cfg
297- return cfg, nil
303+ a.keybindings = keybindings
304+ groups := configKeybindingGroups(keybindings)
305+ for _, group := range groups {
306+ if group.Mode != mode {
307+ continue
308+ }
309+ for _, binding := range group.Bindings {
310+ if binding.Action == action {
311+ return nil
312+ }
313+ }
314+ }
315+ return fmt.Errorf("keybinding %s/%s not found", mode, action)
298316 }
299317
300318 func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) error {
@@ -183,6 +183,9 @@ func (a *App) deleteSearchShortcut(ctx context.Context, data map[string]string)
183 return fmt.Errorf("search shortcut key is required")183 return fmt.Errorf("search shortcut key is required")
184 }184 }
185 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)185 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
186+ if _, exists := cfg.SearchShortcuts[key]; !exists {
187+ return fmt.Errorf("search shortcut %q not found", key)
188+ }
186 delete(cfg.SearchShortcuts, key)189 delete(cfg.SearchShortcuts, key)
187 if err := a.saveEditableConfig(ctx, cfg); err != nil {190 if err := a.saveEditableConfig(ctx, cfg); err != nil {
188 return err191 return err
@@ -243,6 +246,9 @@ func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) e
243 if mode == "" || action == "" {246 if mode == "" || action == "" {
244 return fmt.Errorf("mode and action are required")247 return fmt.Errorf("mode and action are required")
245 }248 }
249+ if err := a.requireKeybindingTarget(ctx, mode, action); err != nil {
250+ return err
251+ }
246 resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{252 resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{
247 RequestID: configRequestIDPrefix,253 RequestID: configRequestIDPrefix,
248 Mode: mode,254 Mode: mode,
@@ -266,6 +272,9 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string)
266 if mode == "" || action == "" {272 if mode == "" || action == "" {
267 return fmt.Errorf("mode and action are required")273 return fmt.Errorf("mode and action are required")
268 }274 }
275+ if err := a.requireKeybindingTarget(ctx, mode, action); err != nil {
276+ return err
277+ }
269 if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{278 if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{
270 RequestID: configRequestIDPrefix,279 RequestID: configRequestIDPrefix,
271 Mode: mode,280 Mode: mode,
@@ -278,23 +287,32 @@ func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string)
278 }287 }
279 288
280 func (a *App) editableConfig(ctx context.Context) (port.WebUIConfig, error) {289 func (a *App) editableConfig(ctx context.Context) (port.WebUIConfig, error) {
281- payload, err := a.currentConfigPayload(ctx)290+ cfg, err := a.deps.Config.Current(ctx)
282 if err != nil {291 if err != nil {
283 return port.WebUIConfig{}, err292 return port.WebUIConfig{}, err
284 }293 }
285- return webUIConfigFromPayload(payload), nil294+ a.config = &cfg
295+ return webUIConfigFromPayload(cfg), nil
286 }296 }
287 297
288-func (a *App) currentConfigPayload(ctx context.Context) (port.SystemviewConfigPayload, error) {298+func (a *App) requireKeybindingTarget(ctx context.Context, mode, action string) error {
289- if a.config != nil {299+ keybindings, err := a.deps.Config.GetKeybindings(ctx)
290- return *a.config, nil
291- }
292- cfg, err := a.deps.Config.Current(ctx)
293 if err != nil {300 if err != nil {
294- return port.SystemviewConfigPayload{}, err301+ return err
295 }302 }
296- a.config = &cfg303+ a.keybindings = keybindings
297- return cfg, nil304+ groups := configKeybindingGroups(keybindings)
305+ for _, group := range groups {
306+ if group.Mode != mode {
307+ continue
308+ }
309+ for _, binding := range group.Bindings {
310+ if binding.Action == action {
311+ return nil
312+ }
313+ }
314+ }
315+ return fmt.Errorf("keybinding %s/%s not found", mode, action)
298 }316 }
299 317
300 func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) error {318 func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) error {
M internal/ui/systemviews/favorites.templ +3 -3
@@ -80,7 +80,7 @@ templ FavoritesControls(data favoritesRenderData) {
8080 for _, tag := range data.Tags {
8181 if tag != nil {
8282 <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }>
83- <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>{ tag.Name }
83+ <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name }
8484 </button>
8585 }
8686 }
@@ -129,7 +129,7 @@ templ FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) {
129129 for _, tag := range tags {
130130 if tag != nil {
131131 <button type="button" class={ favoriteTagButtonClass(favorite, tag) } data-sv-action={ favoriteTagButtonAction(favorite, tag) } data-favorite-id={ fmt.Sprintf("%d", favorite.ID) } data-tag-id={ fmt.Sprintf("%d", tag.ID) }>
132- <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>{ favoriteTagButtonLabel(favorite, tag) }
132+ <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ favoriteTagButtonLabel(favorite, tag) }
133133 </button>
134134 }
135135 }
@@ -169,7 +169,7 @@ templ TagItem(tag *entity.Tag) {
169169 <li class="sv-list-row">
170170 <form class="sv-inline-form" data-sv-action="tag.update">
171171 <input type="hidden" name="id" value={ fmt.Sprintf("%d", tag.ID) }/>
172- <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>
172+ <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>
173173 <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label>
174174 <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label>
175175 <button class="sv-button sv-button-secondary" type="submit">Save</button>
@@ -80,7 +80,7 @@ templ FavoritesControls(data favoritesRenderData) {
80 for _, tag := range data.Tags {80 for _, tag := range data.Tags {
81 if tag != nil {81 if tag != nil {
82 <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }>82 <button type="button" class={ favoriteFilterClass(favoriteTagFilterActive(data, tag.ID)) } data-sv-action="favorite.filterTag" data-tag-id={ fmt.Sprintf("%d", tag.ID) }>
83- <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>{ tag.Name }83+ <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ tag.Name }
84 </button>84 </button>
85 }85 }
86 }86 }
@@ -129,7 +129,7 @@ templ FavoriteTagControls(favorite *entity.Favorite, tags []*entity.Tag) {
129 for _, tag := range tags {129 for _, tag := range tags {
130 if tag != nil {130 if tag != nil {
131 <button type="button" class={ favoriteTagButtonClass(favorite, tag) } data-sv-action={ favoriteTagButtonAction(favorite, tag) } data-favorite-id={ fmt.Sprintf("%d", favorite.ID) } data-tag-id={ fmt.Sprintf("%d", tag.ID) }>131 <button type="button" class={ favoriteTagButtonClass(favorite, tag) } data-sv-action={ favoriteTagButtonAction(favorite, tag) } data-favorite-id={ fmt.Sprintf("%d", favorite.ID) } data-tag-id={ fmt.Sprintf("%d", tag.ID) }>
132- <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>{ favoriteTagButtonLabel(favorite, tag) }132+ <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>{ favoriteTagButtonLabel(favorite, tag) }
133 </button>133 </button>
134 }134 }
135 }135 }
@@ -169,7 +169,7 @@ templ TagItem(tag *entity.Tag) {
169 <li class="sv-list-row">169 <li class="sv-list-row">
170 <form class="sv-inline-form" data-sv-action="tag.update">170 <form class="sv-inline-form" data-sv-action="tag.update">
171 <input type="hidden" name="id" value={ fmt.Sprintf("%d", tag.ID) }/>171 <input type="hidden" name="id" value={ fmt.Sprintf("%d", tag.ID) }/>
172- <span class="sv-tag-dot" style={ "background:" + tag.Color }></span>172+ <span class="sv-tag-dot" style={ "background:" + safeTagColor(tag.Color) }></span>
173 <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label>173 <label><span>Name</span><input name="name" type="text" value={ tag.Name } required/></label>
174 <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label>174 <label><span>Color</span><input name="color" type="text" value={ tag.Color }/></label>
175 <button class="sv-button sv-button-secondary" type="submit">Save</button>175 <button class="sv-button sv-button-secondary" type="submit">Save</button>
M internal/ui/systemviews/favorites_templ.go +8 -8 generated file: collapsed
M internal/ui/systemviews/favorites_view.go +17 -0
@@ -103,6 +103,23 @@ func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string {
103103 return classes
104104 }
105105
106+func safeTagColor(raw string) string {
107+ value := strings.TrimSpace(raw)
108+ if len(value) != 4 && len(value) != 7 {
109+ return "#808080"
110+ }
111+ if value[0] != '#' {
112+ return "#808080"
113+ }
114+ for _, ch := range value[1:] {
115+ if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {
116+ continue
117+ }
118+ return "#808080"
119+ }
120+ return value
121+}
122+
106123 func folderDisplayName(folder *entity.Folder) string {
107124 if folder == nil {
108125 return ""
@@ -103,6 +103,23 @@ func favoriteTagButtonClass(favorite *entity.Favorite, tag *entity.Tag) string {
103 return classes103 return classes
104 }104 }
105 105
106+func safeTagColor(raw string) string {
107+ value := strings.TrimSpace(raw)
108+ if len(value) != 4 && len(value) != 7 {
109+ return "#808080"
110+ }
111+ if value[0] != '#' {
112+ return "#808080"
113+ }
114+ for _, ch := range value[1:] {
115+ if (ch >= '0' && ch <= '9') || (ch >= 'a' && ch <= 'f') || (ch >= 'A' && ch <= 'F') {
116+ continue
117+ }
118+ return "#808080"
119+ }
120+ return value
121+}
122+
106 func folderDisplayName(folder *entity.Folder) string {123 func folderDisplayName(folder *entity.Folder) string {
107 if folder == nil {124 if folder == nil {
108 return ""125 return ""
M internal/ui/systemviews/history_actions.go +2 -0
@@ -25,6 +25,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
2525 if a == nil {
2626 return fmt.Errorf("app is nil")
2727 }
28+ a.mu.Lock()
29+ defer a.mu.Unlock()
2830 if a.currentRoute == RouteUnknown || a.currentRoute == "" {
2931 a.currentRoute = ParseRoute(a.deps.LocationURI)
3032 }
@@ -25,6 +25,8 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
25 if a == nil {25 if a == nil {
26 return fmt.Errorf("app is nil")26 return fmt.Errorf("app is nil")
27 }27 }
28+ a.mu.Lock()
29+ defer a.mu.Unlock()
28 if a.currentRoute == RouteUnknown || a.currentRoute == "" {30 if a.currentRoute == RouteUnknown || a.currentRoute == "" {
29 a.currentRoute = ParseRoute(a.deps.LocationURI)31 a.currentRoute = ParseRoute(a.deps.LocationURI)
30 }32 }
M internal/ui/systemviews/href_test.go +12 -0
@@ -33,3 +33,15 @@ func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) {
3333 assert.Contains(t, html, `href="#"`)
3434 assert.Contains(t, html, `href="https://example.com"`)
3535 }
36+
37+func TestFavoritesHTMLSanitizesTagColors(t *testing.T) {
38+ t.Parallel()
39+
40+ html := favoritesHTML(favoritesRenderData{
41+ Favorites: []*entity.Favorite{{ID: 1, URL: "https://example.com", Title: "Example"}},
42+ Tags: []*entity.Tag{{ID: 7, Name: "Bad", Color: `red;background:url(javascript:alert(1))`}},
43+ })
44+
45+ require.NotContains(t, html, `style="background:red`)
46+ assert.Contains(t, html, "background:#808080")
47+}
@@ -33,3 +33,15 @@ func TestFavoriteItemsHTMLSanitizesHrefSchemes(t *testing.T) {
33 assert.Contains(t, html, `href="#"`)33 assert.Contains(t, html, `href="#"`)
34 assert.Contains(t, html, `href="https://example.com"`)34 assert.Contains(t, html, `href="https://example.com"`)
35 }35 }
36+
37+func TestFavoritesHTMLSanitizesTagColors(t *testing.T) {
38+ t.Parallel()
39+
40+ html := favoritesHTML(favoritesRenderData{
41+ Favorites: []*entity.Favorite{{ID: 1, URL: "https://example.com", Title: "Example"}},
42+ Tags: []*entity.Tag{{ID: 7, Name: "Bad", Color: `red;background:url(javascript:alert(1))`}},
43+ })
44+
45+ require.NotContains(t, html, `style="background:red`)
46+ assert.Contains(t, html, "background:#808080")
47+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close