detached 1c8fbda

fix(systemviews): address follow-up review issues

commit
1c8fbda978e8d52fb3ba5c5b55f26e5c282e55c9
author
brice · · 2026-04-26 07:32 UTC
parent
4f7fc7c
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +211 -134
M assets/systemviews/systemviews.css +2 -2
@@ -671,8 +671,8 @@ body {
671671 padding: 0;
672672 margin: -1px;
673673 overflow: hidden;
674- -webkit-clip-path: inset(0 0 0 0);
675- clip-path: inset(0 0 0 0);
674+ -webkit-clip-path: inset(50%);
675+ clip-path: inset(50%);
676676 white-space: nowrap;
677677 border: 0;
678678 }
@@ -671,8 +671,8 @@ body {
671 padding: 0;671 padding: 0;
672 margin: -1px;672 margin: -1px;
673 overflow: hidden;673 overflow: hidden;
674- -webkit-clip-path: inset(0 0 0 0);674+ -webkit-clip-path: inset(50%);
675- clip-path: inset(0 0 0 0);675+ clip-path: inset(50%);
676 white-space: nowrap;676 white-space: nowrap;
677 border: 0;677 border: 0;
678 }678 }
M cmd/systemviews/bootstrap_test.go +26 -25
@@ -3,6 +3,7 @@ package main
33 import (
44 "context"
55 "strings"
6+ "sync/atomic"
67 "testing"
78 "time"
89
@@ -20,10 +21,10 @@ func TestNewBridgeApp_WiresHistoryService(t *testing.T) {
2021 app := newBridgeApp(&fakeDOM{}, "dumb://history", bridge)
2122
2223 require.NoError(t, app.LoadInitial(context.Background()))
23- assert.True(t, bridge.calledHistory)
24- assert.False(t, bridge.calledFavorites)
25- assert.True(t, bridge.calledConfig)
26- assert.False(t, bridge.calledKeybindings)
24+ assert.True(t, bridge.calledHistory.Load())
25+ assert.False(t, bridge.calledFavorites.Load())
26+ assert.True(t, bridge.calledConfig.Load())
27+ assert.False(t, bridge.calledKeybindings.Load())
2728 }
2829
2930 func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {
@@ -50,9 +51,9 @@ func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {
5051 app := newBridgeApp(dom, "dumb://history", bridge)
5152
5253 require.NoError(t, app.Run())
53- require.Eventually(t, func() bool { return bridge.calledHistory }, time.Second, 10*time.Millisecond)
54- assert.True(t, bridge.calledConfig)
55- assert.False(t, bridge.calledKeybindings)
54+ require.Eventually(t, bridge.calledHistory.Load, time.Second, 10*time.Millisecond)
55+ assert.True(t, bridge.calledConfig.Load())
56+ assert.False(t, bridge.calledKeybindings.Load())
5657 html := receiveMountContaining(t, dom.mounts, `class="sv-app sv-dark"`, `--sv-background: #111111;`)
5758 assert.Contains(t, html, `class="sv-app sv-dark"`)
5859 assert.Contains(t, html, `--sv-background: #111111;`)
@@ -69,10 +70,10 @@ func TestNewBridgeApp_WiresFavoritesService(t *testing.T) {
6970 app := newBridgeApp(&fakeDOM{}, "dumb://favorites", bridge)
7071
7172 require.NoError(t, app.LoadInitial(context.Background()))
72- assert.False(t, bridge.calledHistory)
73- assert.True(t, bridge.calledFavorites)
74- assert.True(t, bridge.calledConfig)
75- assert.False(t, bridge.calledKeybindings)
73+ assert.False(t, bridge.calledHistory.Load())
74+ assert.True(t, bridge.calledFavorites.Load())
75+ assert.True(t, bridge.calledConfig.Load())
76+ assert.False(t, bridge.calledKeybindings.Load())
7677 }
7778
7879 func TestNewBridgeApp_WiresConfigService(t *testing.T) {
@@ -85,10 +86,10 @@ func TestNewBridgeApp_WiresConfigService(t *testing.T) {
8586 app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge)
8687
8788 require.NoError(t, app.LoadInitial(context.Background()))
88- assert.False(t, bridge.calledHistory)
89- assert.False(t, bridge.calledFavorites)
90- assert.True(t, bridge.calledConfig)
91- assert.True(t, bridge.calledKeybindings)
89+ assert.False(t, bridge.calledHistory.Load())
90+ assert.False(t, bridge.calledFavorites.Load())
91+ assert.True(t, bridge.calledConfig.Load())
92+ assert.True(t, bridge.calledKeybindings.Load())
9293 }
9394
9495 type fakeDOM struct {
@@ -133,10 +134,10 @@ func receiveMountContaining(t *testing.T, mounts <-chan string, values ...string
133134 // Handwritten fake intentionally tracks state across history, favorites, config,
134135 // and keybindings boundaries for the composite bootstrap assertions.
135136 type fakeBridgeService struct {
136- calledHistory bool
137- calledFavorites bool
138- calledConfig bool
139- calledKeybindings bool
137+ calledHistory atomic.Bool
138+ calledFavorites atomic.Bool
139+ calledConfig atomic.Bool
140+ calledKeybindings atomic.Bool
140141
141142 historyEntries []*entity.HistoryEntry
142143 favorites []*entity.Favorite
@@ -147,17 +148,17 @@ type fakeBridgeService struct {
147148 }
148149
149150 func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) {
150- f.calledHistory = true
151+ f.calledHistory.Store(true)
151152 return f.historyEntries, nil
152153 }
153154
154155 func (f *fakeBridgeService) TimelineByDomain(context.Context, string, int, int) ([]*entity.HistoryEntry, error) {
155- f.calledHistory = true
156+ f.calledHistory.Store(true)
156157 return f.historyEntries, nil
157158 }
158159
159160 func (f *fakeBridgeService) TimelineWindow(_ context.Context, before time.Time, _ string) (*entity.HistoryWindow, error) {
160- f.calledHistory = true
161+ f.calledHistory.Store(true)
161162 return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil
162163 }
163164
@@ -184,7 +185,7 @@ func (*fakeBridgeService) DomainStats(context.Context, int) ([]*entity.DomainSta
184185 func (*fakeBridgeService) DeleteDomain(context.Context, string) error { return nil }
185186
186187 func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) {
187- f.calledFavorites = true
188+ f.calledFavorites.Store(true)
188189 return f.favorites, nil
189190 }
190191
@@ -231,7 +232,7 @@ func (*fakeBridgeService) AssignTag(context.Context, int64, int64) error { retur
231232 func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil }
232233
233234 func (f *fakeBridgeService) Current(context.Context) (dto.SystemviewConfigPayload, error) {
234- f.calledConfig = true
235+ f.calledConfig.Store(true)
235236 return f.currentConfig, nil
236237 }
237238
@@ -242,7 +243,7 @@ func (*fakeBridgeService) Default(context.Context) (dto.SystemviewConfigPayload,
242243 func (*fakeBridgeService) Save(context.Context, dto.WebUIConfig) error { return nil }
243244
244245 func (f *fakeBridgeService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
245- f.calledKeybindings = true
246+ f.calledKeybindings.Store(true)
246247 return f.keybindings, nil
247248 }
248249
@@ -3,6 +3,7 @@ package main
3 import (3 import (
4 "context"4 "context"
5 "strings"5 "strings"
6+ "sync/atomic"
6 "testing"7 "testing"
7 "time"8 "time"
8 9
@@ -20,10 +21,10 @@ func TestNewBridgeApp_WiresHistoryService(t *testing.T) {
20 app := newBridgeApp(&fakeDOM{}, "dumb://history", bridge)21 app := newBridgeApp(&fakeDOM{}, "dumb://history", bridge)
21 22
22 require.NoError(t, app.LoadInitial(context.Background()))23 require.NoError(t, app.LoadInitial(context.Background()))
23- assert.True(t, bridge.calledHistory)24+ assert.True(t, bridge.calledHistory.Load())
24- assert.False(t, bridge.calledFavorites)25+ assert.False(t, bridge.calledFavorites.Load())
25- assert.True(t, bridge.calledConfig)26+ assert.True(t, bridge.calledConfig.Load())
26- assert.False(t, bridge.calledKeybindings)27+ assert.False(t, bridge.calledKeybindings.Load())
27 }28 }
28 29
29 func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {30 func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {
@@ -50,9 +51,9 @@ func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) {
50 app := newBridgeApp(dom, "dumb://history", bridge)51 app := newBridgeApp(dom, "dumb://history", bridge)
51 52
52 require.NoError(t, app.Run())53 require.NoError(t, app.Run())
53- require.Eventually(t, func() bool { return bridge.calledHistory }, time.Second, 10*time.Millisecond)54+ require.Eventually(t, bridge.calledHistory.Load, time.Second, 10*time.Millisecond)
54- assert.True(t, bridge.calledConfig)55+ assert.True(t, bridge.calledConfig.Load())
55- assert.False(t, bridge.calledKeybindings)56+ assert.False(t, bridge.calledKeybindings.Load())
56 html := receiveMountContaining(t, dom.mounts, `class="sv-app sv-dark"`, `--sv-background: #111111;`)57 html := receiveMountContaining(t, dom.mounts, `class="sv-app sv-dark"`, `--sv-background: #111111;`)
57 assert.Contains(t, html, `class="sv-app sv-dark"`)58 assert.Contains(t, html, `class="sv-app sv-dark"`)
58 assert.Contains(t, html, `--sv-background: #111111;`)59 assert.Contains(t, html, `--sv-background: #111111;`)
@@ -69,10 +70,10 @@ func TestNewBridgeApp_WiresFavoritesService(t *testing.T) {
69 app := newBridgeApp(&fakeDOM{}, "dumb://favorites", bridge)70 app := newBridgeApp(&fakeDOM{}, "dumb://favorites", bridge)
70 71
71 require.NoError(t, app.LoadInitial(context.Background()))72 require.NoError(t, app.LoadInitial(context.Background()))
72- assert.False(t, bridge.calledHistory)73+ assert.False(t, bridge.calledHistory.Load())
73- assert.True(t, bridge.calledFavorites)74+ assert.True(t, bridge.calledFavorites.Load())
74- assert.True(t, bridge.calledConfig)75+ assert.True(t, bridge.calledConfig.Load())
75- assert.False(t, bridge.calledKeybindings)76+ assert.False(t, bridge.calledKeybindings.Load())
76 }77 }
77 78
78 func TestNewBridgeApp_WiresConfigService(t *testing.T) {79 func TestNewBridgeApp_WiresConfigService(t *testing.T) {
@@ -85,10 +86,10 @@ func TestNewBridgeApp_WiresConfigService(t *testing.T) {
85 app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge)86 app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge)
86 87
87 require.NoError(t, app.LoadInitial(context.Background()))88 require.NoError(t, app.LoadInitial(context.Background()))
88- assert.False(t, bridge.calledHistory)89+ assert.False(t, bridge.calledHistory.Load())
89- assert.False(t, bridge.calledFavorites)90+ assert.False(t, bridge.calledFavorites.Load())
90- assert.True(t, bridge.calledConfig)91+ assert.True(t, bridge.calledConfig.Load())
91- assert.True(t, bridge.calledKeybindings)92+ assert.True(t, bridge.calledKeybindings.Load())
92 }93 }
93 94
94 type fakeDOM struct {95 type fakeDOM struct {
@@ -133,10 +134,10 @@ func receiveMountContaining(t *testing.T, mounts <-chan string, values ...string
133 // Handwritten fake intentionally tracks state across history, favorites, config,134 // Handwritten fake intentionally tracks state across history, favorites, config,
134 // and keybindings boundaries for the composite bootstrap assertions.135 // and keybindings boundaries for the composite bootstrap assertions.
135 type fakeBridgeService struct {136 type fakeBridgeService struct {
136- calledHistory bool137+ calledHistory atomic.Bool
137- calledFavorites bool138+ calledFavorites atomic.Bool
138- calledConfig bool139+ calledConfig atomic.Bool
139- calledKeybindings bool140+ calledKeybindings atomic.Bool
140 141
141 historyEntries []*entity.HistoryEntry142 historyEntries []*entity.HistoryEntry
142 favorites []*entity.Favorite143 favorites []*entity.Favorite
@@ -147,17 +148,17 @@ type fakeBridgeService struct {
147 }148 }
148 149
149 func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) {150 func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) {
150- f.calledHistory = true151+ f.calledHistory.Store(true)
151 return f.historyEntries, nil152 return f.historyEntries, nil
152 }153 }
153 154
154 func (f *fakeBridgeService) TimelineByDomain(context.Context, string, int, int) ([]*entity.HistoryEntry, error) {155 func (f *fakeBridgeService) TimelineByDomain(context.Context, string, int, int) ([]*entity.HistoryEntry, error) {
155- f.calledHistory = true156+ f.calledHistory.Store(true)
156 return f.historyEntries, nil157 return f.historyEntries, nil
157 }158 }
158 159
159 func (f *fakeBridgeService) TimelineWindow(_ context.Context, before time.Time, _ string) (*entity.HistoryWindow, error) {160 func (f *fakeBridgeService) TimelineWindow(_ context.Context, before time.Time, _ string) (*entity.HistoryWindow, error) {
160- f.calledHistory = true161+ f.calledHistory.Store(true)
161 return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil162 return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil
162 }163 }
163 164
@@ -184,7 +185,7 @@ func (*fakeBridgeService) DomainStats(context.Context, int) ([]*entity.DomainSta
184 func (*fakeBridgeService) DeleteDomain(context.Context, string) error { return nil }185 func (*fakeBridgeService) DeleteDomain(context.Context, string) error { return nil }
185 186
186 func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) {187 func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) {
187- f.calledFavorites = true188+ f.calledFavorites.Store(true)
188 return f.favorites, nil189 return f.favorites, nil
189 }190 }
190 191
@@ -231,7 +232,7 @@ func (*fakeBridgeService) AssignTag(context.Context, int64, int64) error { retur
231 func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil }232 func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil }
232 233
233 func (f *fakeBridgeService) Current(context.Context) (dto.SystemviewConfigPayload, error) {234 func (f *fakeBridgeService) Current(context.Context) (dto.SystemviewConfigPayload, error) {
234- f.calledConfig = true235+ f.calledConfig.Store(true)
235 return f.currentConfig, nil236 return f.currentConfig, nil
236 }237 }
237 238
@@ -242,7 +243,7 @@ func (*fakeBridgeService) Default(context.Context) (dto.SystemviewConfigPayload,
242 func (*fakeBridgeService) Save(context.Context, dto.WebUIConfig) error { return nil }243 func (*fakeBridgeService) Save(context.Context, dto.WebUIConfig) error { return nil }
243 244
244 func (f *fakeBridgeService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {245 func (f *fakeBridgeService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) {
245- f.calledKeybindings = true246+ f.calledKeybindings.Store(true)
246 return f.keybindings, nil247 return f.keybindings, nil
247 }248 }
248 249
M internal/application/usecase/save_webui_config.go +19 -0
@@ -24,6 +24,10 @@ func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg dto.WebUIConf
2424 return fmt.Errorf("config saver is nil")
2525 }
2626
27+ if err := validateSearchShortcutKeyCollisions(cfg.SearchShortcuts); err != nil {
28+ return err
29+ }
30+
2731 normalized := normalizeWebUIConfig(cfg)
2832 if err := validateWebUIConfig(normalized); err != nil {
2933 return err
@@ -32,6 +36,21 @@ func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg dto.WebUIConf
3236 return uc.saver.SaveWebUIConfig(ctx, normalized)
3337 }
3438
39+func validateSearchShortcutKeyCollisions(shortcuts map[string]dto.SearchShortcut) error {
40+ seen := make(map[string]string, len(shortcuts))
41+ for key := range shortcuts {
42+ trimmedKey := strings.TrimSpace(key)
43+ if trimmedKey == "" {
44+ continue
45+ }
46+ if previous, ok := seen[trimmedKey]; ok && previous != key {
47+ return fmt.Errorf("search shortcut key %q duplicates %q after trimming", key, previous)
48+ }
49+ seen[trimmedKey] = key
50+ }
51+ return nil
52+}
53+
3554 func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig {
3655 cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)
3756 cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)
@@ -24,6 +24,10 @@ func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg dto.WebUIConf
24 return fmt.Errorf("config saver is nil")24 return fmt.Errorf("config saver is nil")
25 }25 }
26 26
27+ if err := validateSearchShortcutKeyCollisions(cfg.SearchShortcuts); err != nil {
28+ return err
29+ }
30+
27 normalized := normalizeWebUIConfig(cfg)31 normalized := normalizeWebUIConfig(cfg)
28 if err := validateWebUIConfig(normalized); err != nil {32 if err := validateWebUIConfig(normalized); err != nil {
29 return err33 return err
@@ -32,6 +36,21 @@ func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg dto.WebUIConf
32 return uc.saver.SaveWebUIConfig(ctx, normalized)36 return uc.saver.SaveWebUIConfig(ctx, normalized)
33 }37 }
34 38
39+func validateSearchShortcutKeyCollisions(shortcuts map[string]dto.SearchShortcut) error {
40+ seen := make(map[string]string, len(shortcuts))
41+ for key := range shortcuts {
42+ trimmedKey := strings.TrimSpace(key)
43+ if trimmedKey == "" {
44+ continue
45+ }
46+ if previous, ok := seen[trimmedKey]; ok && previous != key {
47+ return fmt.Errorf("search shortcut key %q duplicates %q after trimming", key, previous)
48+ }
49+ seen[trimmedKey] = key
50+ }
51+ return nil
52+}
53+
35 func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig {54 func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig {
36 cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)55 cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)
37 cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)56 cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)
M internal/application/usecase/save_webui_config_test.go +13 -0
@@ -63,6 +63,19 @@ func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
6363 }
6464 }
6565
66+func TestSaveWebUIConfigUseCase_RejectsDuplicateTrimmedSearchShortcutKeys(t *testing.T) {
67+ saver := portmocks.NewMockWebUIConfigSaver(t)
68+ uc := usecase.NewSaveWebUIConfigUseCase(saver)
69+ cfg := validWebUIConfig()
70+ cfg.SearchShortcuts["ddg"] = dto.SearchShortcut{URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"}
71+ cfg.SearchShortcuts[" ddg "] = dto.SearchShortcut{URL: "https://example.com/?q=%s", Description: "Duplicate"}
72+
73+ err := uc.Execute(context.Background(), cfg)
74+
75+ require.Error(t, err)
76+ require.Contains(t, err.Error(), "duplicates")
77+}
78+
6679 func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {
6780 saver := portmocks.NewMockWebUIConfigSaver(t)
6881 var saved dto.WebUIConfig
@@ -63,6 +63,19 @@ func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
63 }63 }
64 }64 }
65 65
66+func TestSaveWebUIConfigUseCase_RejectsDuplicateTrimmedSearchShortcutKeys(t *testing.T) {
67+ saver := portmocks.NewMockWebUIConfigSaver(t)
68+ uc := usecase.NewSaveWebUIConfigUseCase(saver)
69+ cfg := validWebUIConfig()
70+ cfg.SearchShortcuts["ddg"] = dto.SearchShortcut{URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"}
71+ cfg.SearchShortcuts[" ddg "] = dto.SearchShortcut{URL: "https://example.com/?q=%s", Description: "Duplicate"}
72+
73+ err := uc.Execute(context.Background(), cfg)
74+
75+ require.Error(t, err)
76+ require.Contains(t, err.Error(), "duplicates")
77+}
78+
66 func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {79 func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {
67 saver := portmocks.NewMockWebUIConfigSaver(t)80 saver := portmocks.NewMockWebUIConfigSaver(t)
68 var saved dto.WebUIConfig81 var saved dto.WebUIConfig
M internal/infrastructure/cef/scheme_handler_systemviews_test.go +4 -1
@@ -49,7 +49,10 @@ func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) {
4949 if err != nil {
5050 t.Fatalf("readAssetWithEncoding() error = %v", err)
5151 }
52- if len(data) < 4 || string(data[:4]) != "\x00asm" {
52+ if len(data) < 4 {
53+ t.Fatalf("decompressed wasm too short: got %d bytes", len(data))
54+ }
55+ if string(data[:4]) != "\x00asm" {
5356 t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4])
5457 }
5558 }
@@ -49,7 +49,10 @@ func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) {
49 if err != nil {49 if err != nil {
50 t.Fatalf("readAssetWithEncoding() error = %v", err)50 t.Fatalf("readAssetWithEncoding() error = %v", err)
51 }51 }
52- if len(data) < 4 || string(data[:4]) != "\x00asm" {52+ if len(data) < 4 {
53+ t.Fatalf("decompressed wasm too short: got %d bytes", len(data))
54+ }
55+ if string(data[:4]) != "\x00asm" {
53 t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4])56 t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4])
54 }57 }
55 }58 }
M internal/infrastructure/cef/transcoding_handler.go +21 -5
@@ -75,6 +75,7 @@ func (rh *transcodingResourceHandler) Open(
7575 Str("source_url", sourceURL).
7676 Msg("cef: rejected transcoding source")
7777 rh.setStartErr(err, httpStatusForbidden)
78+ rh.cancel()
7879 callback.Cont()
7980 return
8081 }
@@ -118,6 +119,19 @@ func (rh *transcodingResourceHandler) getStartErr() (int, error) {
118119 return rh.startErrStatus, rh.startErr
119120 }
120121
122+func (rh *transcodingResourceHandler) addTotalBytes(n int) int64 {
123+ rh.mu.Lock()
124+ defer rh.mu.Unlock()
125+ rh.totalBytes += int64(n)
126+ return rh.totalBytes
127+}
128+
129+func (rh *transcodingResourceHandler) getTotalBytes() int64 {
130+ rh.mu.Lock()
131+ defer rh.mu.Unlock()
132+ return rh.totalBytes
133+}
134+
121135 // GetResponseHeaders sets the streaming response metadata.
122136 func (rh *transcodingResourceHandler) GetResponseHeaders(
123137 response purecef.Response, responseLength *int64, _ uintptr,
@@ -168,8 +182,8 @@ func (rh *transcodingResourceHandler) Read(
168182 dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead))
169183 n, err := session.Read(dst)
170184 if n > 0 {
171- rh.totalBytes += int64(n)
172- if rh.totalBytes == int64(n) {
185+ totalBytes := rh.addTotalBytes(n)
186+ if totalBytes == int64(n) {
173187 rh.logger().Info().
174188 Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)).
175189 Int("first_chunk_bytes", n).
@@ -181,13 +195,14 @@ func (rh *transcodingResourceHandler) Read(
181195 return 1
182196 }
183197 if err != nil {
198+ totalBytes := rh.getTotalBytes()
184199 sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
185200 log := rh.logger().With().
186201 Str("source_url", sourceURL).
187- Int64("total_bytes", rh.totalBytes).
202+ Int64("total_bytes", totalBytes).
188203 Logger()
189204 switch {
190- case errors.Is(err, io.EOF) && rh.totalBytes == 0:
205+ case errors.Is(err, io.EOF) && totalBytes == 0:
191206 log.Warn().Msg("cef: transcoding resource stream ended before producing data")
192207 case errors.Is(err, io.EOF):
193208 log.Debug().Msg("cef: transcoding resource stream reached EOF")
@@ -202,9 +217,10 @@ func (rh *transcodingResourceHandler) Read(
202217 // Cancel aborts the transcode session and releases resources.
203218 func (rh *transcodingResourceHandler) Cancel() {
204219 sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
220+ totalBytes := rh.getTotalBytes()
205221 rh.logger().Debug().
206222 Str("source_url", sourceURL).
207- Int64("total_bytes", rh.totalBytes).
223+ Int64("total_bytes", totalBytes).
208224 Msg("cef: transcoding resource stream canceled")
209225 rh.mu.Lock()
210226 session := rh.session
@@ -75,6 +75,7 @@ func (rh *transcodingResourceHandler) Open(
75 Str("source_url", sourceURL).75 Str("source_url", sourceURL).
76 Msg("cef: rejected transcoding source")76 Msg("cef: rejected transcoding source")
77 rh.setStartErr(err, httpStatusForbidden)77 rh.setStartErr(err, httpStatusForbidden)
78+ rh.cancel()
78 callback.Cont()79 callback.Cont()
79 return80 return
80 }81 }
@@ -118,6 +119,19 @@ func (rh *transcodingResourceHandler) getStartErr() (int, error) {
118 return rh.startErrStatus, rh.startErr119 return rh.startErrStatus, rh.startErr
119 }120 }
120 121
122+func (rh *transcodingResourceHandler) addTotalBytes(n int) int64 {
123+ rh.mu.Lock()
124+ defer rh.mu.Unlock()
125+ rh.totalBytes += int64(n)
126+ return rh.totalBytes
127+}
128+
129+func (rh *transcodingResourceHandler) getTotalBytes() int64 {
130+ rh.mu.Lock()
131+ defer rh.mu.Unlock()
132+ return rh.totalBytes
133+}
134+
121 // GetResponseHeaders sets the streaming response metadata.135 // GetResponseHeaders sets the streaming response metadata.
122 func (rh *transcodingResourceHandler) GetResponseHeaders(136 func (rh *transcodingResourceHandler) GetResponseHeaders(
123 response purecef.Response, responseLength *int64, _ uintptr,137 response purecef.Response, responseLength *int64, _ uintptr,
@@ -168,8 +182,8 @@ func (rh *transcodingResourceHandler) Read(
168 dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead))182 dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead))
169 n, err := session.Read(dst)183 n, err := session.Read(dst)
170 if n > 0 {184 if n > 0 {
171- rh.totalBytes += int64(n)185+ totalBytes := rh.addTotalBytes(n)
172- if rh.totalBytes == int64(n) {186+ if totalBytes == int64(n) {
173 rh.logger().Info().187 rh.logger().Info().
174 Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)).188 Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)).
175 Int("first_chunk_bytes", n).189 Int("first_chunk_bytes", n).
@@ -181,13 +195,14 @@ func (rh *transcodingResourceHandler) Read(
181 return 1195 return 1
182 }196 }
183 if err != nil {197 if err != nil {
198+ totalBytes := rh.getTotalBytes()
184 sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)199 sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
185 log := rh.logger().With().200 log := rh.logger().With().
186 Str("source_url", sourceURL).201 Str("source_url", sourceURL).
187- Int64("total_bytes", rh.totalBytes).202+ Int64("total_bytes", totalBytes).
188 Logger()203 Logger()
189 switch {204 switch {
190- case errors.Is(err, io.EOF) && rh.totalBytes == 0:205+ case errors.Is(err, io.EOF) && totalBytes == 0:
191 log.Warn().Msg("cef: transcoding resource stream ended before producing data")206 log.Warn().Msg("cef: transcoding resource stream ended before producing data")
192 case errors.Is(err, io.EOF):207 case errors.Is(err, io.EOF):
193 log.Debug().Msg("cef: transcoding resource stream reached EOF")208 log.Debug().Msg("cef: transcoding resource stream reached EOF")
@@ -202,9 +217,10 @@ func (rh *transcodingResourceHandler) Read(
202 // Cancel aborts the transcode session and releases resources.217 // Cancel aborts the transcode session and releases resources.
203 func (rh *transcodingResourceHandler) Cancel() {218 func (rh *transcodingResourceHandler) Cancel() {
204 sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)219 sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)
220+ totalBytes := rh.getTotalBytes()
205 rh.logger().Debug().221 rh.logger().Debug().
206 Str("source_url", sourceURL).222 Str("source_url", sourceURL).
207- Int64("total_bytes", rh.totalBytes).223+ Int64("total_bytes", totalBytes).
208 Msg("cef: transcoding resource stream canceled")224 Msg("cef: transcoding resource stream canceled")
209 rh.mu.Lock()225 rh.mu.Lock()
210 session := rh.session226 session := rh.session
M internal/infrastructure/handlers/homepage/favorites.go +4 -0
@@ -218,6 +218,10 @@ func (h *FavoritesHandlers) HandleSetShortcut() port.WebUIMessageHandler {
218218 Int64("favorite_id", req.FavoriteID).
219219 Msg("handling favorite_set_shortcut")
220220
221+ if req.FavoriteID <= 0 {
222+ return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite id")), nil
223+ }
224+
221225 if err := h.favoritesUC.SetShortcut(ctx, entity.FavoriteID(req.FavoriteID), req.ShortcutKey); err != nil {
222226 return NewErrorResponse(req.RequestID, err), nil
223227 }
@@ -218,6 +218,10 @@ func (h *FavoritesHandlers) HandleSetShortcut() port.WebUIMessageHandler {
218 Int64("favorite_id", req.FavoriteID).218 Int64("favorite_id", req.FavoriteID).
219 Msg("handling favorite_set_shortcut")219 Msg("handling favorite_set_shortcut")
220 220
221+ if req.FavoriteID <= 0 {
222+ return NewErrorResponse(req.RequestID, fmt.Errorf("invalid favorite id")), nil
223+ }
224+
221 if err := h.favoritesUC.SetShortcut(ctx, entity.FavoriteID(req.FavoriteID), req.ShortcutKey); err != nil {225 if err := h.favoritesUC.SetShortcut(ctx, entity.FavoriteID(req.FavoriteID), req.ShortcutKey); err != nil {
222 return NewErrorResponse(req.RequestID, err), nil226 return NewErrorResponse(req.RequestID, err), nil
223 }227 }
M internal/infrastructure/transcoder/http_client_test.go +17 -0
@@ -108,6 +108,23 @@ func TestValidateTranscodeRedirectRejectsPrivateTarget(t *testing.T) {
108108 }
109109 }
110110
111+func TestValidateTranscodeRedirectRejectsTenHopRedirect(t *testing.T) {
112+ requestURL, err := url.Parse("https://93.184.216.34/video.mp4")
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+
117+ via := make([]*http.Request, 10)
118+ for i := range via {
119+ via[i] = &http.Request{URL: requestURL}
120+ }
121+
122+ err = validateTranscodeRedirect(&http.Request{URL: requestURL}, via)
123+ if err == nil {
124+ t.Fatal("validateTranscodeRedirect() expected error for ten-hop redirect")
125+ }
126+}
127+
111128 func TestValidateTranscodeRedirectRejectsStreamingManifestTarget(t *testing.T) {
112129 requestURL, err := url.Parse("https://93.184.216.34/playlist.m3u8")
113130 if err != nil {
@@ -108,6 +108,23 @@ func TestValidateTranscodeRedirectRejectsPrivateTarget(t *testing.T) {
108 }108 }
109 }109 }
110 110
111+func TestValidateTranscodeRedirectRejectsTenHopRedirect(t *testing.T) {
112+ requestURL, err := url.Parse("https://93.184.216.34/video.mp4")
113+ if err != nil {
114+ t.Fatal(err)
115+ }
116+
117+ via := make([]*http.Request, 10)
118+ for i := range via {
119+ via[i] = &http.Request{URL: requestURL}
120+ }
121+
122+ err = validateTranscodeRedirect(&http.Request{URL: requestURL}, via)
123+ if err == nil {
124+ t.Fatal("validateTranscodeRedirect() expected error for ten-hop redirect")
125+ }
126+}
127+
111 func TestValidateTranscodeRedirectRejectsStreamingManifestTarget(t *testing.T) {128 func TestValidateTranscodeRedirectRejectsStreamingManifestTarget(t *testing.T) {
112 requestURL, err := url.Parse("https://93.184.216.34/playlist.m3u8")129 requestURL, err := url.Parse("https://93.184.216.34/playlist.m3u8")
113 if err != nil {130 if err != nil {
M internal/ui/systemviews/app.go +23 -4
@@ -143,11 +143,30 @@ func (a *App) refreshCurrentRouteAsync(ctx context.Context) {
143143 if closed || snapshot.route != RouteHistory {
144144 return
145145 }
146+
147+ a.lockAction()
148+ asyncCtx := a.actionCtx
149+ if asyncCtx == nil {
150+ asyncCtx = ctx
151+ }
152+ ready := !a.actionClosed && asyncCtx != nil && asyncCtx.Err() == nil
153+ if ready {
154+ a.actionWG.Add(1)
155+ }
156+ a.unlockAction()
157+ if !ready {
158+ return
159+ }
160+
146161 go func() {
147- result, err := a.renderHistoryRouteSnapshot(ctx, snapshot)
162+ defer a.actionWG.Done()
163+ if asyncCtx.Err() != nil {
164+ return
165+ }
166+ result, err := a.renderHistoryRouteSnapshot(asyncCtx, snapshot)
148167
149168 a.lockState()
150- if a.closed || ctx.Err() != nil || a.currentRoute != snapshot.route || a.renderGeneration != generation {
169+ if a.closed || asyncCtx.Err() != nil || a.currentRoute != snapshot.route || a.renderGeneration != generation {
151170 a.unlockState()
152171 return
153172 }
@@ -161,8 +180,8 @@ func (a *App) refreshCurrentRouteAsync(ctx context.Context) {
161180 html := a.renderedHTML
162181 a.unlockState()
163182
164- if err := a.mountHTMLIfCurrent(ctx, html, mountGeneration); err != nil {
165- logActionMountError(ctx, err, nil)
183+ if err := a.mountHTMLIfCurrent(asyncCtx, html, mountGeneration); err != nil {
184+ logActionMountError(asyncCtx, err, nil)
166185 }
167186 }()
168187 }
@@ -143,11 +143,30 @@ func (a *App) refreshCurrentRouteAsync(ctx context.Context) {
143 if closed || snapshot.route != RouteHistory {143 if closed || snapshot.route != RouteHistory {
144 return144 return
145 }145 }
146+
147+ a.lockAction()
148+ asyncCtx := a.actionCtx
149+ if asyncCtx == nil {
150+ asyncCtx = ctx
151+ }
152+ ready := !a.actionClosed && asyncCtx != nil && asyncCtx.Err() == nil
153+ if ready {
154+ a.actionWG.Add(1)
155+ }
156+ a.unlockAction()
157+ if !ready {
158+ return
159+ }
160+
146 go func() {161 go func() {
147- result, err := a.renderHistoryRouteSnapshot(ctx, snapshot)162+ defer a.actionWG.Done()
163+ if asyncCtx.Err() != nil {
164+ return
165+ }
166+ result, err := a.renderHistoryRouteSnapshot(asyncCtx, snapshot)
148 167
149 a.lockState()168 a.lockState()
150- if a.closed || ctx.Err() != nil || a.currentRoute != snapshot.route || a.renderGeneration != generation {169+ if a.closed || asyncCtx.Err() != nil || a.currentRoute != snapshot.route || a.renderGeneration != generation {
151 a.unlockState()170 a.unlockState()
152 return171 return
153 }172 }
@@ -161,8 +180,8 @@ func (a *App) refreshCurrentRouteAsync(ctx context.Context) {
161 html := a.renderedHTML180 html := a.renderedHTML
162 a.unlockState()181 a.unlockState()
163 182
164- if err := a.mountHTMLIfCurrent(ctx, html, mountGeneration); err != nil {183+ if err := a.mountHTMLIfCurrent(asyncCtx, html, mountGeneration); err != nil {
165- logActionMountError(ctx, err, nil)184+ logActionMountError(asyncCtx, err, nil)
166 }185 }
167 }()186 }()
168 }187 }
M internal/ui/systemviews/app_test.go +61 -30
@@ -56,9 +56,9 @@ func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) {
5656
5757 require.NoError(t, app.Run())
5858 assert.Equal(t, RouteHistory, app.CurrentRoute())
59- assert.True(t, dom.mounted)
60- assert.Contains(t, dom.html, "history")
61- assert.Contains(t, dom.html, "systemviews")
59+ assert.True(t, dom.Mounted())
60+ assert.Contains(t, dom.HTML(), "history")
61+ assert.Contains(t, dom.HTML(), "systemviews")
6262 }
6363
6464 func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
@@ -161,8 +161,8 @@ func TestSurfaceActionErrorPreventsStaleAsyncHydrationMount(t *testing.T) {
161161
162162 require.NoError(t, app.mountHTMLIfCurrent(ctx, staleHTML, staleGeneration))
163163 assertNoMount(t, dom.mounts, 100*time.Millisecond)
164- assert.Contains(t, dom.html, "action worker failed")
165- assert.NotContains(t, dom.html, "Stale hydrated entry")
164+ assert.Contains(t, dom.HTML(), "action worker failed")
165+ assert.NotContains(t, dom.HTML(), "Stale hydrated entry")
166166 }
167167
168168 func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testing.T) {
@@ -194,8 +194,8 @@ func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testi
194194
195195 close(history.releaseTimeline)
196196 assertNoMount(t, dom.mounts, 100*time.Millisecond)
197- assert.Contains(t, dom.html, "action worker failed")
198- assert.NotContains(t, dom.html, "Stale hydrated entry")
197+ assert.Contains(t, dom.HTML(), "action worker failed")
198+ assert.NotContains(t, dom.HTML(), "Stale hydrated entry")
199199 }
200200
201201 func TestCurrentHistoryRouteSnapshotIncludesWindowAfter(t *testing.T) {
@@ -231,9 +231,9 @@ func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
231231 }))
232232 assert.Equal(t, cursor, history.windowBefore)
233233 assert.Len(t, app.historyEntries, 2)
234- assert.Contains(t, dom.appendedHTML, "Older entry")
235- assert.Contains(t, dom.appendedHTML, `data-sv-action="history.loadMore"`)
236- assert.Empty(t, dom.html, "load-more should append a fragment instead of remounting the whole page")
234+ assert.Contains(t, dom.AppendedHTML(), "Older entry")
235+ assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`)
236+ assert.Empty(t, dom.HTML(), "load-more should append a fragment instead of remounting the whole page")
237237 }
238238
239239 func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
@@ -259,8 +259,8 @@ func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
259259 }))
260260 assert.Equal(t, cursor, history.windowBefore)
261261 assert.Len(t, app.historyEntries, 2)
262- assert.Contains(t, dom.html, "Older entry")
263- assert.Contains(t, dom.html, `data-sv-action="history.loadMore"`)
262+ assert.Contains(t, dom.HTML(), "Older entry")
263+ assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`)
264264 }
265265
266266 func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) {
@@ -279,7 +279,7 @@ func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) {
279279 Data: map[string]string{"before": cursor.Add(-24 * time.Hour).Format(time.RFC3339Nano)},
280280 }))
281281 assert.False(t, history.called)
282- assert.Empty(t, dom.appendedHTML)
282+ assert.Empty(t, dom.AppendedHTML())
283283 }
284284
285285 func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
@@ -327,8 +327,8 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
327327 }))
328328 assert.True(t, history.searchCalled)
329329 assert.Equal(t, "example", history.query)
330- assert.Contains(t, dom.html, "Search result")
331- assert.Contains(t, dom.html, "Query: example")
330+ assert.Contains(t, dom.HTML(), "Search result")
331+ assert.Contains(t, dom.HTML(), "Query: example")
332332
333333 app.historyOffset = historyTimelineLimit
334334 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -337,14 +337,14 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
337337 }))
338338 assert.Equal(t, int64(42), history.deletedEntryID)
339339 assert.Equal(t, 0, app.historyOffset)
340- assert.Contains(t, dom.html, "Deleted history entry")
340+ assert.Contains(t, dom.HTML(), "Deleted history entry")
341341
342342 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
343343 Action: historyActionDeleteRange,
344344 Data: map[string]string{"range": "week"},
345345 }))
346346 assert.Equal(t, "week", history.deletedRangeID)
347- assert.Contains(t, dom.html, "Deleted history from this week")
347+ assert.Contains(t, dom.HTML(), "Deleted history from this week")
348348
349349 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: historyActionClear}))
350350 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -360,7 +360,7 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
360360 Data: map[string]string{"domain": "example.com"},
361361 }))
362362 assert.Equal(t, "example.com", history.deletedDomain)
363- assert.Contains(t, dom.html, "Deleted history for example.com")
363+ assert.Contains(t, dom.HTML(), "Deleted history for example.com")
364364 }
365365
366366 func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) {
@@ -562,7 +562,7 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
562562 assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)
563563 assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID)
564564 assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags)
565- assert.Contains(t, dom.html, "Added favorite New")
565+ assert.Contains(t, dom.HTML(), "Added favorite New")
566566
567567 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
568568 Action: favoriteActionUpdate,
@@ -572,7 +572,7 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
572572 assert.Equal(t, "Updated", favorites.updatedFavorite.Title)
573573 require.NotNil(t, favorites.updatedFavorite.ShortcutKey)
574574 assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)
575- assert.Contains(t, dom.html, "Saved favorite Updated")
575+ assert.Contains(t, dom.HTML(), "Saved favorite Updated")
576576
577577 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
578578 Action: tagActionAssign,
@@ -603,14 +603,14 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
603603 }))
604604 assert.Equal(t, "Reading", favorites.createdFolder)
605605 assert.Equal(t, "📚", favorites.createdFolderIcon)
606- assert.Contains(t, dom.html, "Created folder 📚 Reading")
606+ assert.Contains(t, dom.HTML(), "Created folder 📚 Reading")
607607
608608 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
609609 Action: favoriteActionDelete,
610610 Data: map[string]string{"id": "42"},
611611 }))
612612 assert.Equal(t, int64(42), favorites.deletedFavorite)
613- assert.Contains(t, dom.html, "Deleted favorite")
613+ assert.Contains(t, dom.HTML(), "Deleted favorite")
614614 }
615615
616616 func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
@@ -803,15 +803,15 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
803803 assert.True(t, service.calledSave)
804804 assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)
805805 assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001)
806- assert.Contains(t, dom.html, "Saved appearance settings")
807- assert.Contains(t, dom.html, `class="sv-app sv-light"`)
806+ assert.Contains(t, dom.HTML(), "Saved appearance settings")
807+ assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`)
808808
809809 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
810810 Action: configActionCreateSearchShortcut,
811811 Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"},
812812 }))
813813 assert.Equal(t, "Google", service.savedConfig.SearchShortcuts["g"].Description)
814- assert.Contains(t, dom.html, "Created search shortcut g")
814+ assert.Contains(t, dom.HTML(), "Created search shortcut g")
815815
816816 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
817817 Action: configActionSavePerformance,
@@ -822,14 +822,14 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
822822 }))
823823 assert.Equal(t, "custom", service.savedConfig.Performance.Profile)
824824 assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB)
825- assert.Contains(t, dom.html, "Saved performance settings")
825+ assert.Contains(t, dom.HTML(), "Saved performance settings")
826826
827827 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
828828 Action: configActionSavePerformance,
829829 Data: map[string]string{"profile": "default"},
830830 }))
831831 assert.Equal(t, "default", service.savedConfig.Performance.Profile)
832- assert.Contains(t, dom.html, "Saved performance settings")
832+ assert.Contains(t, dom.HTML(), "Saved performance settings")
833833
834834 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
835835 Action: configActionSetKeybinding,
@@ -838,7 +838,7 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
838838 assert.True(t, service.calledSet)
839839 assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys)
840840 assert.Contains(t, service.setReq.RequestID, "systemviews-config-")
841- assert.Contains(t, dom.html, "Saved keybinding toggle_history_systemview")
841+ assert.Contains(t, dom.HTML(), "Saved keybinding toggle_history_systemview")
842842
843843 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
844844 Action: configActionResetKeybinding,
@@ -848,11 +848,11 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
848848 assert.Equal(t, "toggle_history_systemview", service.resetReq.Action)
849849 assert.Contains(t, service.resetReq.RequestID, "systemviews-config-")
850850 assert.NotEqual(t, service.setReq.RequestID, service.resetReq.RequestID)
851- assert.Contains(t, dom.html, "Reset keybinding toggle_history_systemview")
851+ assert.Contains(t, dom.HTML(), "Reset keybinding toggle_history_systemview")
852852
853853 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings}))
854854 assert.True(t, service.calledResetAll)
855- assert.Contains(t, dom.html, "Reset all keybindings to defaults")
855+ assert.Contains(t, dom.HTML(), "Reset all keybindings to defaults")
856856 }
857857
858858 func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) {
@@ -932,6 +932,24 @@ func (d *fakeDOM) Mount(markup string) error {
932932 return nil
933933 }
934934
935+func (d *fakeDOM) Mounted() bool {
936+ d.mu.Lock()
937+ defer d.mu.Unlock()
938+ return d.mounted
939+}
940+
941+func (d *fakeDOM) HTML() string {
942+ d.mu.Lock()
943+ defer d.mu.Unlock()
944+ return d.html
945+}
946+
947+func (d *fakeDOM) AppendedHTML() string {
948+ d.mu.Lock()
949+ defer d.mu.Unlock()
950+ return d.appendedHTML
951+}
952+
935953 func receiveMount(t *testing.T, mounts <-chan string) string {
936954 t.Helper()
937955 select {
@@ -1306,6 +1324,19 @@ func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) {
13061324 assert.Equal(t, "dumb://config", favorites.createdFavorite.URL)
13071325 }
13081326
1327+func TestHandleFavoriteCreateRejectsUnknownDumbRoutes(t *testing.T) {
1328+ favorites := &fakeFavoritesService{}
1329+ app := NewApp(Dependencies{Favorites: favorites})
1330+
1331+ err := app.handleFavoriteAction(context.Background(), DOMAction{
1332+ Action: favoriteActionCreate,
1333+ Data: map[string]string{"url": "dumb:unknown", "title": "Unknown"},
1334+ })
1335+
1336+ require.Error(t, err)
1337+ assert.Contains(t, err.Error(), "known systemview route")
1338+}
1339+
13091340 func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) {
13101341 _, err := parsePositiveInt64("abc", "tag id")
13111342 require.Error(t, err)
@@ -56,9 +56,9 @@ func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) {
56 56
57 require.NoError(t, app.Run())57 require.NoError(t, app.Run())
58 assert.Equal(t, RouteHistory, app.CurrentRoute())58 assert.Equal(t, RouteHistory, app.CurrentRoute())
59- assert.True(t, dom.mounted)59+ assert.True(t, dom.Mounted())
60- assert.Contains(t, dom.html, "history")60+ assert.Contains(t, dom.HTML(), "history")
61- assert.Contains(t, dom.html, "systemviews")61+ assert.Contains(t, dom.HTML(), "systemviews")
62 }62 }
63 63
64 func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {64 func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) {
@@ -161,8 +161,8 @@ func TestSurfaceActionErrorPreventsStaleAsyncHydrationMount(t *testing.T) {
161 161
162 require.NoError(t, app.mountHTMLIfCurrent(ctx, staleHTML, staleGeneration))162 require.NoError(t, app.mountHTMLIfCurrent(ctx, staleHTML, staleGeneration))
163 assertNoMount(t, dom.mounts, 100*time.Millisecond)163 assertNoMount(t, dom.mounts, 100*time.Millisecond)
164- assert.Contains(t, dom.html, "action worker failed")164+ assert.Contains(t, dom.HTML(), "action worker failed")
165- assert.NotContains(t, dom.html, "Stale hydrated entry")165+ assert.NotContains(t, dom.HTML(), "Stale hydrated entry")
166 }166 }
167 167
168 func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testing.T) {168 func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testing.T) {
@@ -194,8 +194,8 @@ func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testi
194 194
195 close(history.releaseTimeline)195 close(history.releaseTimeline)
196 assertNoMount(t, dom.mounts, 100*time.Millisecond)196 assertNoMount(t, dom.mounts, 100*time.Millisecond)
197- assert.Contains(t, dom.html, "action worker failed")197+ assert.Contains(t, dom.HTML(), "action worker failed")
198- assert.NotContains(t, dom.html, "Stale hydrated entry")198+ assert.NotContains(t, dom.HTML(), "Stale hydrated entry")
199 }199 }
200 200
201 func TestCurrentHistoryRouteSnapshotIncludesWindowAfter(t *testing.T) {201 func TestCurrentHistoryRouteSnapshotIncludesWindowAfter(t *testing.T) {
@@ -231,9 +231,9 @@ func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) {
231 }))231 }))
232 assert.Equal(t, cursor, history.windowBefore)232 assert.Equal(t, cursor, history.windowBefore)
233 assert.Len(t, app.historyEntries, 2)233 assert.Len(t, app.historyEntries, 2)
234- assert.Contains(t, dom.appendedHTML, "Older entry")234+ assert.Contains(t, dom.AppendedHTML(), "Older entry")
235- assert.Contains(t, dom.appendedHTML, `data-sv-action="history.loadMore"`)235+ assert.Contains(t, dom.AppendedHTML(), `data-sv-action="history.loadMore"`)
236- assert.Empty(t, dom.html, "load-more should append a fragment instead of remounting the whole page")236+ assert.Empty(t, dom.HTML(), "load-more should append a fragment instead of remounting the whole page")
237 }237 }
238 238
239 func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {239 func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
@@ -259,8 +259,8 @@ func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) {
259 }))259 }))
260 assert.Equal(t, cursor, history.windowBefore)260 assert.Equal(t, cursor, history.windowBefore)
261 assert.Len(t, app.historyEntries, 2)261 assert.Len(t, app.historyEntries, 2)
262- assert.Contains(t, dom.html, "Older entry")262+ assert.Contains(t, dom.HTML(), "Older entry")
263- assert.Contains(t, dom.html, `data-sv-action="history.loadMore"`)263+ assert.Contains(t, dom.HTML(), `data-sv-action="history.loadMore"`)
264 }264 }
265 265
266 func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) {266 func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) {
@@ -279,7 +279,7 @@ func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) {
279 Data: map[string]string{"before": cursor.Add(-24 * time.Hour).Format(time.RFC3339Nano)},279 Data: map[string]string{"before": cursor.Add(-24 * time.Hour).Format(time.RFC3339Nano)},
280 }))280 }))
281 assert.False(t, history.called)281 assert.False(t, history.called)
282- assert.Empty(t, dom.appendedHTML)282+ assert.Empty(t, dom.AppendedHTML())
283 }283 }
284 284
285 func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {285 func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) {
@@ -327,8 +327,8 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
327 }))327 }))
328 assert.True(t, history.searchCalled)328 assert.True(t, history.searchCalled)
329 assert.Equal(t, "example", history.query)329 assert.Equal(t, "example", history.query)
330- assert.Contains(t, dom.html, "Search result")330+ assert.Contains(t, dom.HTML(), "Search result")
331- assert.Contains(t, dom.html, "Query: example")331+ assert.Contains(t, dom.HTML(), "Query: example")
332 332
333 app.historyOffset = historyTimelineLimit333 app.historyOffset = historyTimelineLimit
334 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{334 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -337,14 +337,14 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
337 }))337 }))
338 assert.Equal(t, int64(42), history.deletedEntryID)338 assert.Equal(t, int64(42), history.deletedEntryID)
339 assert.Equal(t, 0, app.historyOffset)339 assert.Equal(t, 0, app.historyOffset)
340- assert.Contains(t, dom.html, "Deleted history entry")340+ assert.Contains(t, dom.HTML(), "Deleted history entry")
341 341
342 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{342 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
343 Action: historyActionDeleteRange,343 Action: historyActionDeleteRange,
344 Data: map[string]string{"range": "week"},344 Data: map[string]string{"range": "week"},
345 }))345 }))
346 assert.Equal(t, "week", history.deletedRangeID)346 assert.Equal(t, "week", history.deletedRangeID)
347- assert.Contains(t, dom.html, "Deleted history from this week")347+ assert.Contains(t, dom.HTML(), "Deleted history from this week")
348 348
349 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: historyActionClear}))349 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: historyActionClear}))
350 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{350 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
@@ -360,7 +360,7 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) {
360 Data: map[string]string{"domain": "example.com"},360 Data: map[string]string{"domain": "example.com"},
361 }))361 }))
362 assert.Equal(t, "example.com", history.deletedDomain)362 assert.Equal(t, "example.com", history.deletedDomain)
363- assert.Contains(t, dom.html, "Deleted history for example.com")363+ assert.Contains(t, dom.HTML(), "Deleted history for example.com")
364 }364 }
365 365
366 func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) {366 func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) {
@@ -562,7 +562,7 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
562 assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)562 assert.Equal(t, "https://new.example", favorites.createdFavorite.URL)
563 assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID)563 assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID)
564 assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags)564 assert.Equal(t, []entity.TagID{7}, favorites.createdFavorite.Tags)
565- assert.Contains(t, dom.html, "Added favorite New")565+ assert.Contains(t, dom.HTML(), "Added favorite New")
566 566
567 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{567 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
568 Action: favoriteActionUpdate,568 Action: favoriteActionUpdate,
@@ -572,7 +572,7 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
572 assert.Equal(t, "Updated", favorites.updatedFavorite.Title)572 assert.Equal(t, "Updated", favorites.updatedFavorite.Title)
573 require.NotNil(t, favorites.updatedFavorite.ShortcutKey)573 require.NotNil(t, favorites.updatedFavorite.ShortcutKey)
574 assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)574 assert.Equal(t, 4, *favorites.updatedFavorite.ShortcutKey)
575- assert.Contains(t, dom.html, "Saved favorite Updated")575+ assert.Contains(t, dom.HTML(), "Saved favorite Updated")
576 576
577 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{577 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
578 Action: tagActionAssign,578 Action: tagActionAssign,
@@ -603,14 +603,14 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) {
603 }))603 }))
604 assert.Equal(t, "Reading", favorites.createdFolder)604 assert.Equal(t, "Reading", favorites.createdFolder)
605 assert.Equal(t, "📚", favorites.createdFolderIcon)605 assert.Equal(t, "📚", favorites.createdFolderIcon)
606- assert.Contains(t, dom.html, "Created folder 📚 Reading")606+ assert.Contains(t, dom.HTML(), "Created folder 📚 Reading")
607 607
608 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{608 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
609 Action: favoriteActionDelete,609 Action: favoriteActionDelete,
610 Data: map[string]string{"id": "42"},610 Data: map[string]string{"id": "42"},
611 }))611 }))
612 assert.Equal(t, int64(42), favorites.deletedFavorite)612 assert.Equal(t, int64(42), favorites.deletedFavorite)
613- assert.Contains(t, dom.html, "Deleted favorite")613+ assert.Contains(t, dom.HTML(), "Deleted favorite")
614 }614 }
615 615
616 func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {616 func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
@@ -803,15 +803,15 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
803 assert.True(t, service.calledSave)803 assert.True(t, service.calledSave)
804 assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)804 assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)
805 assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001)805 assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001)
806- assert.Contains(t, dom.html, "Saved appearance settings")806+ assert.Contains(t, dom.HTML(), "Saved appearance settings")
807- assert.Contains(t, dom.html, `class="sv-app sv-light"`)807+ assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`)
808 808
809 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{809 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
810 Action: configActionCreateSearchShortcut,810 Action: configActionCreateSearchShortcut,
811 Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"},811 Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"},
812 }))812 }))
813 assert.Equal(t, "Google", service.savedConfig.SearchShortcuts["g"].Description)813 assert.Equal(t, "Google", service.savedConfig.SearchShortcuts["g"].Description)
814- assert.Contains(t, dom.html, "Created search shortcut g")814+ assert.Contains(t, dom.HTML(), "Created search shortcut g")
815 815
816 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{816 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
817 Action: configActionSavePerformance,817 Action: configActionSavePerformance,
@@ -822,14 +822,14 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
822 }))822 }))
823 assert.Equal(t, "custom", service.savedConfig.Performance.Profile)823 assert.Equal(t, "custom", service.savedConfig.Performance.Profile)
824 assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB)824 assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB)
825- assert.Contains(t, dom.html, "Saved performance settings")825+ assert.Contains(t, dom.HTML(), "Saved performance settings")
826 826
827 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{827 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
828 Action: configActionSavePerformance,828 Action: configActionSavePerformance,
829 Data: map[string]string{"profile": "default"},829 Data: map[string]string{"profile": "default"},
830 }))830 }))
831 assert.Equal(t, "default", service.savedConfig.Performance.Profile)831 assert.Equal(t, "default", service.savedConfig.Performance.Profile)
832- assert.Contains(t, dom.html, "Saved performance settings")832+ assert.Contains(t, dom.HTML(), "Saved performance settings")
833 833
834 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{834 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
835 Action: configActionSetKeybinding,835 Action: configActionSetKeybinding,
@@ -838,7 +838,7 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
838 assert.True(t, service.calledSet)838 assert.True(t, service.calledSet)
839 assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys)839 assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys)
840 assert.Contains(t, service.setReq.RequestID, "systemviews-config-")840 assert.Contains(t, service.setReq.RequestID, "systemviews-config-")
841- assert.Contains(t, dom.html, "Saved keybinding toggle_history_systemview")841+ assert.Contains(t, dom.HTML(), "Saved keybinding toggle_history_systemview")
842 842
843 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{843 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
844 Action: configActionResetKeybinding,844 Action: configActionResetKeybinding,
@@ -848,11 +848,11 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
848 assert.Equal(t, "toggle_history_systemview", service.resetReq.Action)848 assert.Equal(t, "toggle_history_systemview", service.resetReq.Action)
849 assert.Contains(t, service.resetReq.RequestID, "systemviews-config-")849 assert.Contains(t, service.resetReq.RequestID, "systemviews-config-")
850 assert.NotEqual(t, service.setReq.RequestID, service.resetReq.RequestID)850 assert.NotEqual(t, service.setReq.RequestID, service.resetReq.RequestID)
851- assert.Contains(t, dom.html, "Reset keybinding toggle_history_systemview")851+ assert.Contains(t, dom.HTML(), "Reset keybinding toggle_history_systemview")
852 852
853 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings}))853 require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings}))
854 assert.True(t, service.calledResetAll)854 assert.True(t, service.calledResetAll)
855- assert.Contains(t, dom.html, "Reset all keybindings to defaults")855+ assert.Contains(t, dom.HTML(), "Reset all keybindings to defaults")
856 }856 }
857 857
858 func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) {858 func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) {
@@ -932,6 +932,24 @@ func (d *fakeDOM) Mount(markup string) error {
932 return nil932 return nil
933 }933 }
934 934
935+func (d *fakeDOM) Mounted() bool {
936+ d.mu.Lock()
937+ defer d.mu.Unlock()
938+ return d.mounted
939+}
940+
941+func (d *fakeDOM) HTML() string {
942+ d.mu.Lock()
943+ defer d.mu.Unlock()
944+ return d.html
945+}
946+
947+func (d *fakeDOM) AppendedHTML() string {
948+ d.mu.Lock()
949+ defer d.mu.Unlock()
950+ return d.appendedHTML
951+}
952+
935 func receiveMount(t *testing.T, mounts <-chan string) string {953 func receiveMount(t *testing.T, mounts <-chan string) string {
936 t.Helper()954 t.Helper()
937 select {955 select {
@@ -1306,6 +1324,19 @@ func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) {
1306 assert.Equal(t, "dumb://config", favorites.createdFavorite.URL)1324 assert.Equal(t, "dumb://config", favorites.createdFavorite.URL)
1307 }1325 }
1308 1326
1327+func TestHandleFavoriteCreateRejectsUnknownDumbRoutes(t *testing.T) {
1328+ favorites := &fakeFavoritesService{}
1329+ app := NewApp(Dependencies{Favorites: favorites})
1330+
1331+ err := app.handleFavoriteAction(context.Background(), DOMAction{
1332+ Action: favoriteActionCreate,
1333+ Data: map[string]string{"url": "dumb:unknown", "title": "Unknown"},
1334+ })
1335+
1336+ require.Error(t, err)
1337+ assert.Contains(t, err.Error(), "known systemview route")
1338+}
1339+
1309 func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) {1340 func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) {
1310 _, err := parsePositiveInt64("abc", "tag id")1341 _, err := parsePositiveInt64("abc", "tag id")
1311 require.Error(t, err)1342 require.Error(t, err)
M internal/ui/systemviews/config_actions.go +13 -58
@@ -101,16 +101,8 @@ func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string)
101101 cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"])
102102 cfg.Appearance.DefaultFontSize = fontSize
103103 cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])
104- lightPalette, err := paletteFromForm(data, "light")
105- if err != nil {
106- return err
107- }
108- darkPalette, err := paletteFromForm(data, "dark")
109- if err != nil {
110- return err
111- }
112- cfg.Appearance.LightPalette = lightPalette
113- cfg.Appearance.DarkPalette = darkPalette
104+ cfg.Appearance.LightPalette = paletteFromForm(data, "light")
105+ cfg.Appearance.DarkPalette = paletteFromForm(data, "dark")
114106 cfg.DefaultUIScale = uiScale
115107
116108 if err := a.saveEditableConfig(ctx, cfg); err != nil {
@@ -413,57 +405,20 @@ func cloneSearchShortcuts(shortcuts map[string]dto.SearchShortcut) map[string]dt
413405 return clone
414406 }
415407
416-func paletteFromForm(data map[string]string, prefix string) (dto.ColorPalette, error) {
417- color := func(field string) (string, error) {
418- value := strings.TrimSpace(data[prefix+"_"+field])
419- if value == "" || isValidHexColor(value) {
420- return value, nil
421- }
422- return "", fmt.Errorf("%s_%s must be a hex color", prefix, field)
423- }
424-
425- background, err := color("background")
426- if err != nil {
427- return dto.ColorPalette{}, err
428- }
429- surface, err := color("surface")
430- if err != nil {
431- return dto.ColorPalette{}, err
432- }
433- surfaceVariant, err := color("surface_variant")
434- if err != nil {
435- return dto.ColorPalette{}, err
436- }
437- text, err := color("text")
438- if err != nil {
439- return dto.ColorPalette{}, err
440- }
441- muted, err := color("muted")
442- if err != nil {
443- return dto.ColorPalette{}, err
444- }
445- accent, err := color("accent")
446- if err != nil {
447- return dto.ColorPalette{}, err
448- }
449- border, err := color("border")
450- if err != nil {
451- return dto.ColorPalette{}, err
408+func paletteFromForm(data map[string]string, prefix string) dto.ColorPalette {
409+ color := func(field string) string {
410+ return strings.TrimSpace(data[prefix+"_"+field])
452411 }
453412
454413 return dto.ColorPalette{
455- Background: background,
456- Surface: surface,
457- SurfaceVariant: surfaceVariant,
458- Text: text,
459- Muted: muted,
460- Accent: accent,
461- Border: border,
462- }, nil
463-}
464-
465-func isValidHexColor(value string) bool {
466- return isHexColor(strings.TrimSpace(value))
414+ Background: color("background"),
415+ Surface: color("surface"),
416+ SurfaceVariant: color("surface_variant"),
417+ Text: color("text"),
418+ Muted: color("muted"),
419+ Accent: color("accent"),
420+ Border: color("border"),
421+ }
467422 }
468423
469424 func searchShortcutFromForm(data map[string]string, keyField string) (string, dto.SearchShortcut) {
@@ -101,16 +101,8 @@ func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string)
101 cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"])101 cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"])
102 cfg.Appearance.DefaultFontSize = fontSize102 cfg.Appearance.DefaultFontSize = fontSize
103 cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])103 cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])
104- lightPalette, err := paletteFromForm(data, "light")104+ cfg.Appearance.LightPalette = paletteFromForm(data, "light")
105- if err != nil {105+ cfg.Appearance.DarkPalette = paletteFromForm(data, "dark")
106- return err
107- }
108- darkPalette, err := paletteFromForm(data, "dark")
109- if err != nil {
110- return err
111- }
112- cfg.Appearance.LightPalette = lightPalette
113- cfg.Appearance.DarkPalette = darkPalette
114 cfg.DefaultUIScale = uiScale106 cfg.DefaultUIScale = uiScale
115 107
116 if err := a.saveEditableConfig(ctx, cfg); err != nil {108 if err := a.saveEditableConfig(ctx, cfg); err != nil {
@@ -413,57 +405,20 @@ func cloneSearchShortcuts(shortcuts map[string]dto.SearchShortcut) map[string]dt
413 return clone405 return clone
414 }406 }
415 407
416-func paletteFromForm(data map[string]string, prefix string) (dto.ColorPalette, error) {408+func paletteFromForm(data map[string]string, prefix string) dto.ColorPalette {
417- color := func(field string) (string, error) {409+ color := func(field string) string {
418- value := strings.TrimSpace(data[prefix+"_"+field])410+ return strings.TrimSpace(data[prefix+"_"+field])
419- if value == "" || isValidHexColor(value) {
420- return value, nil
421- }
422- return "", fmt.Errorf("%s_%s must be a hex color", prefix, field)
423- }
424-
425- background, err := color("background")
426- if err != nil {
427- return dto.ColorPalette{}, err
428- }
429- surface, err := color("surface")
430- if err != nil {
431- return dto.ColorPalette{}, err
432- }
433- surfaceVariant, err := color("surface_variant")
434- if err != nil {
435- return dto.ColorPalette{}, err
436- }
437- text, err := color("text")
438- if err != nil {
439- return dto.ColorPalette{}, err
440- }
441- muted, err := color("muted")
442- if err != nil {
443- return dto.ColorPalette{}, err
444- }
445- accent, err := color("accent")
446- if err != nil {
447- return dto.ColorPalette{}, err
448- }
449- border, err := color("border")
450- if err != nil {
451- return dto.ColorPalette{}, err
452 }411 }
453 412
454 return dto.ColorPalette{413 return dto.ColorPalette{
455- Background: background,414+ Background: color("background"),
456- Surface: surface,415+ Surface: color("surface"),
457- SurfaceVariant: surfaceVariant,416+ SurfaceVariant: color("surface_variant"),
458- Text: text,417+ Text: color("text"),
459- Muted: muted,418+ Muted: color("muted"),
460- Accent: accent,419+ Accent: color("accent"),
461- Border: border,420+ Border: color("border"),
462- }, nil421+ }
463-}
464-
465-func isValidHexColor(value string) bool {
466- return isHexColor(strings.TrimSpace(value))
467 }422 }
468 423
469 func searchShortcutFromForm(data map[string]string, keyField string) (string, dto.SearchShortcut) {424 func searchShortcutFromForm(data map[string]string, keyField string) (string, dto.SearchShortcut) {
M internal/ui/systemviews/config_view.go +6 -0
@@ -25,6 +25,9 @@ func configHTML(data configRenderData) string {
2525 }
2626
2727 func configKeybindingSummary(keybindings port.KeybindingsConfig) string {
28+ if keybindings.Groups == nil {
29+ return "Keybindings unavailable"
30+ }
2831 groups, bindings := summarizeKeybindings(keybindings)
2932 return fmt.Sprintf(
3033 "Keybindings loaded (%d %s, %d %s)",
@@ -36,6 +39,9 @@ func configKeybindingSummary(keybindings port.KeybindingsConfig) string {
3639 }
3740
3841 func configKeybindingGroups(keybindings port.KeybindingsConfig) []renderedKeybindingGroup {
42+ if keybindings.Groups == nil {
43+ return nil
44+ }
3945 return convertPortKeybindingGroups(keybindings.Groups)
4046 }
4147
@@ -25,6 +25,9 @@ func configHTML(data configRenderData) string {
25 }25 }
26 26
27 func configKeybindingSummary(keybindings port.KeybindingsConfig) string {27 func configKeybindingSummary(keybindings port.KeybindingsConfig) string {
28+ if keybindings.Groups == nil {
29+ return "Keybindings unavailable"
30+ }
28 groups, bindings := summarizeKeybindings(keybindings)31 groups, bindings := summarizeKeybindings(keybindings)
29 return fmt.Sprintf(32 return fmt.Sprintf(
30 "Keybindings loaded (%d %s, %d %s)",33 "Keybindings loaded (%d %s, %d %s)",
@@ -36,6 +39,9 @@ func configKeybindingSummary(keybindings port.KeybindingsConfig) string {
36 }39 }
37 40
38 func configKeybindingGroups(keybindings port.KeybindingsConfig) []renderedKeybindingGroup {41 func configKeybindingGroups(keybindings port.KeybindingsConfig) []renderedKeybindingGroup {
42+ if keybindings.Groups == nil {
43+ return nil
44+ }
39 return convertPortKeybindingGroups(keybindings.Groups)45 return convertPortKeybindingGroups(keybindings.Groups)
40 }46 }
41 47
M internal/ui/systemviews/favorite_actions.go +2 -5
@@ -219,13 +219,10 @@ func validateFavoriteURL(raw string) (string, error) {
219219 return "", fmt.Errorf("favorite URL host is required")
220220 }
221221 case "dumb":
222- if parsed.Host != "" {
222+ if routeFromPageHost(parsed.Host, parsed.Opaque) != RouteUnknown {
223223 return favoriteURL, nil
224224 }
225- if parsed.Opaque != "" {
226- return favoriteURL, nil
227- }
228- return "", fmt.Errorf("favorite URL host is required")
225+ return "", fmt.Errorf("favorite URL must target a known systemview route")
229226 default:
230227 return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
231228 }
@@ -219,13 +219,10 @@ func validateFavoriteURL(raw string) (string, error) {
219 return "", fmt.Errorf("favorite URL host is required")219 return "", fmt.Errorf("favorite URL host is required")
220 }220 }
221 case "dumb":221 case "dumb":
222- if parsed.Host != "" {222+ if routeFromPageHost(parsed.Host, parsed.Opaque) != RouteUnknown {
223 return favoriteURL, nil223 return favoriteURL, nil
224 }224 }
225- if parsed.Opaque != "" {225+ return "", fmt.Errorf("favorite URL must target a known systemview route")
226- return favoriteURL, nil
227- }
228- return "", fmt.Errorf("favorite URL host is required")
229 default:226 default:
230 return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")227 return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme")
231 }228 }
M internal/ui/systemviews/history_view.go +0 -4
@@ -139,10 +139,6 @@ func showHistoryLoadMore(data historyRenderData) bool {
139139 return !data.Loading && strings.TrimSpace(data.Query) == "" && data.HasMore
140140 }
141141
142-func historySummaryLoading(data historyRenderData) bool {
143- return data.Loading
144-}
145-
146142 func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) {
147143 if data.Stats != nil {
148144 return data.Stats.TotalEntries, data.Stats.TotalVisits, data.Stats.UniqueDays
@@ -139,10 +139,6 @@ func showHistoryLoadMore(data historyRenderData) bool {
139 return !data.Loading && strings.TrimSpace(data.Query) == "" && data.HasMore139 return !data.Loading && strings.TrimSpace(data.Query) == "" && data.HasMore
140 }140 }
141 141
142-func historySummaryLoading(data historyRenderData) bool {
143- return data.Loading
144-}
145-
146 func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) {142 func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) {
147 if data.Stats != nil {143 if data.Stats != nil {
148 return data.Stats.TotalEntries, data.Stats.TotalVisits, data.Stats.UniqueDays144 return data.Stats.TotalEntries, data.Stats.TotalVisits, data.Stats.UniqueDays

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