fix(systemviews): address follow-up review issues
- commit
- 1c8fbda978e8d52fb3ba5c5b55f26e5c282e55c9
- author
- brice · · 2026-04-26 07:32 UTC
- parent
- 4f7fc7c
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +211 -134
M assets/systemviews/systemviews.css +2 -2
| @@ -671,8 +671,8 @@ body { | |||
| 671 | 671 | padding: 0; | |
| 672 | 672 | margin: -1px; | |
| 673 | 673 | 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%); | |
| 676 | 676 | white-space: nowrap; | |
| 677 | 677 | border: 0; | |
| 678 | 678 | } | |
| @@ -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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "strings" | |
| 6 | + | "sync/atomic" | |
| 6 | 7 | "testing" | |
| 7 | 8 | "time" | |
| 8 | 9 | ||
| @@ -20,10 +21,10 @@ func TestNewBridgeApp_WiresHistoryService(t *testing.T) { | |||
| 20 | 21 | app := newBridgeApp(&fakeDOM{}, "dumb://history", bridge) | |
| 21 | 22 | ||
| 22 | 23 | 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()) | |
| 27 | 28 | } | |
| 28 | 29 | ||
| 29 | 30 | func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) { | |
| @@ -50,9 +51,9 @@ func TestNewBridgeApp_UsesCurrentConfigForNonConfigRoutes(t *testing.T) { | |||
| 50 | 51 | app := newBridgeApp(dom, "dumb://history", bridge) | |
| 51 | 52 | ||
| 52 | 53 | 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()) | |
| 56 | 57 | html := receiveMountContaining(t, dom.mounts, `class="sv-app sv-dark"`, `--sv-background: #111111;`) | |
| 57 | 58 | assert.Contains(t, html, `class="sv-app sv-dark"`) | |
| 58 | 59 | assert.Contains(t, html, `--sv-background: #111111;`) | |
| @@ -69,10 +70,10 @@ func TestNewBridgeApp_WiresFavoritesService(t *testing.T) { | |||
| 69 | 70 | app := newBridgeApp(&fakeDOM{}, "dumb://favorites", bridge) | |
| 70 | 71 | ||
| 71 | 72 | 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()) | |
| 76 | 77 | } | |
| 77 | 78 | ||
| 78 | 79 | func TestNewBridgeApp_WiresConfigService(t *testing.T) { | |
| @@ -85,10 +86,10 @@ func TestNewBridgeApp_WiresConfigService(t *testing.T) { | |||
| 85 | 86 | app := newBridgeApp(&fakeDOM{}, "dumb://config", bridge) | |
| 86 | 87 | ||
| 87 | 88 | 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()) | |
| 92 | 93 | } | |
| 93 | 94 | ||
| 94 | 95 | type fakeDOM struct { | |
| @@ -133,10 +134,10 @@ func receiveMountContaining(t *testing.T, mounts <-chan string, values ...string | |||
| 133 | 134 | // Handwritten fake intentionally tracks state across history, favorites, config, | |
| 134 | 135 | // and keybindings boundaries for the composite bootstrap assertions. | |
| 135 | 136 | 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 | |
| 140 | 141 | ||
| 141 | 142 | historyEntries []*entity.HistoryEntry | |
| 142 | 143 | favorites []*entity.Favorite | |
| @@ -147,17 +148,17 @@ type fakeBridgeService struct { | |||
| 147 | 148 | } | |
| 148 | 149 | ||
| 149 | 150 | func (f *fakeBridgeService) Timeline(context.Context, int, int) ([]*entity.HistoryEntry, error) { | |
| 150 | - | f.calledHistory = true | |
| 151 | + | f.calledHistory.Store(true) | |
| 151 | 152 | return f.historyEntries, nil | |
| 152 | 153 | } | |
| 153 | 154 | ||
| 154 | 155 | func (f *fakeBridgeService) TimelineByDomain(context.Context, string, int, int) ([]*entity.HistoryEntry, error) { | |
| 155 | - | f.calledHistory = true | |
| 156 | + | f.calledHistory.Store(true) | |
| 156 | 157 | return f.historyEntries, nil | |
| 157 | 158 | } | |
| 158 | 159 | ||
| 159 | 160 | func (f *fakeBridgeService) TimelineWindow(_ context.Context, before time.Time, _ string) (*entity.HistoryWindow, error) { | |
| 160 | - | f.calledHistory = true | |
| 161 | + | f.calledHistory.Store(true) | |
| 161 | 162 | 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 | 185 | func (*fakeBridgeService) DeleteDomain(context.Context, string) error { return nil } | |
| 185 | 186 | ||
| 186 | 187 | func (f *fakeBridgeService) List(context.Context) ([]*entity.Favorite, error) { | |
| 187 | - | f.calledFavorites = true | |
| 188 | + | f.calledFavorites.Store(true) | |
| 188 | 189 | return f.favorites, nil | |
| 189 | 190 | } | |
| 190 | 191 | ||
| @@ -231,7 +232,7 @@ func (*fakeBridgeService) AssignTag(context.Context, int64, int64) error { retur | |||
| 231 | 232 | func (*fakeBridgeService) RemoveTag(context.Context, int64, int64) error { return nil } | |
| 232 | 233 | ||
| 233 | 234 | func (f *fakeBridgeService) Current(context.Context) (dto.SystemviewConfigPayload, error) { | |
| 234 | - | f.calledConfig = true | |
| 235 | + | f.calledConfig.Store(true) | |
| 235 | 236 | return f.currentConfig, nil | |
| 236 | 237 | } | |
| 237 | 238 | ||
| @@ -242,7 +243,7 @@ func (*fakeBridgeService) Default(context.Context) (dto.SystemviewConfigPayload, | |||
| 242 | 243 | func (*fakeBridgeService) Save(context.Context, dto.WebUIConfig) error { return nil } | |
| 243 | 244 | ||
| 244 | 245 | func (f *fakeBridgeService) GetKeybindings(context.Context) (port.KeybindingsConfig, error) { | |
| 245 | - | f.calledKeybindings = true | |
| 246 | + | f.calledKeybindings.Store(true) | |
| 246 | 247 | return f.keybindings, nil | |
| 247 | 248 | } | |
| 248 | 249 | ||
| @@ -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 bool | 137 | + | calledHistory atomic.Bool |
| 137 | - | calledFavorites bool | 138 | + | calledFavorites atomic.Bool |
| 138 | - | calledConfig bool | 139 | + | calledConfig atomic.Bool |
| 139 | - | calledKeybindings bool | 140 | + | calledKeybindings atomic.Bool |
| 140 | 141 | ||||
| 141 | historyEntries []*entity.HistoryEntry | 142 | historyEntries []*entity.HistoryEntry | ||
| 142 | favorites []*entity.Favorite | 143 | 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 = true | 151 | + | f.calledHistory.Store(true) |
| 151 | return f.historyEntries, nil | 152 | 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 = true | 156 | + | f.calledHistory.Store(true) |
| 156 | return f.historyEntries, nil | 157 | 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 = true | 161 | + | f.calledHistory.Store(true) |
| 161 | return &entity.HistoryWindow{Entries: f.historyEntries, Before: before, After: before.Add(-24 * time.Hour)}, nil | 162 | 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 = true | 188 | + | f.calledFavorites.Store(true) |
| 188 | return f.favorites, nil | 189 | 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 = true | 235 | + | f.calledConfig.Store(true) |
| 235 | return f.currentConfig, nil | 236 | 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 = true | 246 | + | f.calledKeybindings.Store(true) |
| 246 | return f.keybindings, nil | 247 | 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 | |||
| 24 | 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 | 31 | normalized := normalizeWebUIConfig(cfg) | |
| 28 | 32 | if err := validateWebUIConfig(normalized); err != nil { | |
| 29 | 33 | return err | |
| @@ -32,6 +36,21 @@ func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg dto.WebUIConf | |||
| 32 | 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 | 54 | func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig { | |
| 36 | 55 | cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont) | |
| 37 | 56 | 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 err | 33 | 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) { | |||
| 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 | 79 | func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) { | |
| 67 | 80 | saver := portmocks.NewMockWebUIConfigSaver(t) | |
| 68 | 81 | 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.WebUIConfig | 81 | 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) { | |||
| 49 | 49 | if err != nil { | |
| 50 | 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 | 56 | t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4]) | |
| 54 | 57 | } | |
| 55 | 58 | } | |
| @@ -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( | |||
| 75 | 75 | Str("source_url", sourceURL). | |
| 76 | 76 | Msg("cef: rejected transcoding source") | |
| 77 | 77 | rh.setStartErr(err, httpStatusForbidden) | |
| 78 | + | rh.cancel() | |
| 78 | 79 | callback.Cont() | |
| 79 | 80 | return | |
| 80 | 81 | } | |
| @@ -118,6 +119,19 @@ func (rh *transcodingResourceHandler) getStartErr() (int, error) { | |||
| 118 | 119 | 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 | 135 | // GetResponseHeaders sets the streaming response metadata. | |
| 122 | 136 | func (rh *transcodingResourceHandler) GetResponseHeaders( | |
| 123 | 137 | response purecef.Response, responseLength *int64, _ uintptr, | |
| @@ -168,8 +182,8 @@ func (rh *transcodingResourceHandler) Read( | |||
| 168 | 182 | dst := unsafe.Slice((*byte)(dataOut), int(bytesToRead)) | |
| 169 | 183 | n, err := session.Read(dst) | |
| 170 | 184 | if n > 0 { | |
| 171 | - | rh.totalBytes += int64(n) | |
| 172 | - | if rh.totalBytes == int64(n) { | |
| 185 | + | totalBytes := rh.addTotalBytes(n) | |
| 186 | + | if totalBytes == int64(n) { | |
| 173 | 187 | rh.logger().Info(). | |
| 174 | 188 | Str("source_url", logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength)). | |
| 175 | 189 | Int("first_chunk_bytes", n). | |
| @@ -181,13 +195,14 @@ func (rh *transcodingResourceHandler) Read( | |||
| 181 | 195 | return 1 | |
| 182 | 196 | } | |
| 183 | 197 | if err != nil { | |
| 198 | + | totalBytes := rh.getTotalBytes() | |
| 184 | 199 | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |
| 185 | 200 | log := rh.logger().With(). | |
| 186 | 201 | Str("source_url", sourceURL). | |
| 187 | - | Int64("total_bytes", rh.totalBytes). | |
| 202 | + | Int64("total_bytes", totalBytes). | |
| 188 | 203 | Logger() | |
| 189 | 204 | switch { | |
| 190 | - | case errors.Is(err, io.EOF) && rh.totalBytes == 0: | |
| 205 | + | case errors.Is(err, io.EOF) && totalBytes == 0: | |
| 191 | 206 | log.Warn().Msg("cef: transcoding resource stream ended before producing data") | |
| 192 | 207 | case errors.Is(err, io.EOF): | |
| 193 | 208 | log.Debug().Msg("cef: transcoding resource stream reached EOF") | |
| @@ -202,9 +217,10 @@ func (rh *transcodingResourceHandler) Read( | |||
| 202 | 217 | // Cancel aborts the transcode session and releases resources. | |
| 203 | 218 | func (rh *transcodingResourceHandler) Cancel() { | |
| 204 | 219 | sourceURL := logging.TruncateURL(rh.sourceURL, maxTranscodingURLLength) | |
| 220 | + | totalBytes := rh.getTotalBytes() | |
| 205 | 221 | rh.logger().Debug(). | |
| 206 | 222 | Str("source_url", sourceURL). | |
| 207 | - | Int64("total_bytes", rh.totalBytes). | |
| 223 | + | Int64("total_bytes", totalBytes). | |
| 208 | 224 | Msg("cef: transcoding resource stream canceled") | |
| 209 | 225 | rh.mu.Lock() | |
| 210 | 226 | 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 | return | 80 | return | ||
| 80 | } | 81 | } | ||
| @@ -118,6 +119,19 @@ func (rh *transcodingResourceHandler) getStartErr() (int, error) { | |||||
| 118 | return rh.startErrStatus, rh.startErr | 119 | 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 1 | 195 | 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.session | 226 | session := rh.session | ||
M internal/infrastructure/handlers/homepage/favorites.go +4 -0
| @@ -218,6 +218,10 @@ func (h *FavoritesHandlers) HandleSetShortcut() port.WebUIMessageHandler { | |||
| 218 | 218 | Int64("favorite_id", req.FavoriteID). | |
| 219 | 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 | 225 | if err := h.favoritesUC.SetShortcut(ctx, entity.FavoriteID(req.FavoriteID), req.ShortcutKey); err != nil { | |
| 222 | 226 | return NewErrorResponse(req.RequestID, err), nil | |
| 223 | 227 | } | |
| @@ -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), nil | 226 | 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) { | |||
| 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 | 128 | func TestValidateTranscodeRedirectRejectsStreamingManifestTarget(t *testing.T) { | |
| 112 | 129 | requestURL, err := url.Parse("https://93.184.216.34/playlist.m3u8") | |
| 113 | 130 | 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) { | |||
| 143 | 143 | if closed || snapshot.route != RouteHistory { | |
| 144 | 144 | 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 | 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 | 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 | 170 | a.unlockState() | |
| 152 | 171 | return | |
| 153 | 172 | } | |
| @@ -161,8 +180,8 @@ func (a *App) refreshCurrentRouteAsync(ctx context.Context) { | |||
| 161 | 180 | html := a.renderedHTML | |
| 162 | 181 | a.unlockState() | |
| 163 | 182 | ||
| 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) | |
| 166 | 185 | } | |
| 167 | 186 | }() | |
| 168 | 187 | } | |
| @@ -143,11 +143,30 @@ func (a *App) refreshCurrentRouteAsync(ctx context.Context) { | |||||
| 143 | if closed || snapshot.route != RouteHistory { | 143 | if closed || snapshot.route != RouteHistory { | ||
| 144 | return | 144 | 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 | return | 171 | return | ||
| 153 | } | 172 | } | ||
| @@ -161,8 +180,8 @@ func (a *App) refreshCurrentRouteAsync(ctx context.Context) { | |||||
| 161 | html := a.renderedHTML | 180 | 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) { | |||
| 56 | 56 | ||
| 57 | 57 | require.NoError(t, app.Run()) | |
| 58 | 58 | 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") | |
| 62 | 62 | } | |
| 63 | 63 | ||
| 64 | 64 | func TestAppLoadInitialHistoryRouteUsesStyledSections(t *testing.T) { | |
| @@ -161,8 +161,8 @@ func TestSurfaceActionErrorPreventsStaleAsyncHydrationMount(t *testing.T) { | |||
| 161 | 161 | ||
| 162 | 162 | require.NoError(t, app.mountHTMLIfCurrent(ctx, staleHTML, staleGeneration)) | |
| 163 | 163 | 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") | |
| 166 | 166 | } | |
| 167 | 167 | ||
| 168 | 168 | func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testing.T) { | |
| @@ -194,8 +194,8 @@ func TestSurfaceActionErrorWhileAsyncHydrationBlockedPreventsStaleMount(t *testi | |||
| 194 | 194 | ||
| 195 | 195 | close(history.releaseTimeline) | |
| 196 | 196 | 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") | |
| 199 | 199 | } | |
| 200 | 200 | ||
| 201 | 201 | func TestCurrentHistoryRouteSnapshotIncludesWindowAfter(t *testing.T) { | |
| @@ -231,9 +231,9 @@ func TestAppHandleHistoryLoadMoreAppendsOlderWindow(t *testing.T) { | |||
| 231 | 231 | })) | |
| 232 | 232 | assert.Equal(t, cursor, history.windowBefore) | |
| 233 | 233 | 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") | |
| 237 | 237 | } | |
| 238 | 238 | ||
| 239 | 239 | func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) { | |
| @@ -259,8 +259,8 @@ func TestAppHandleHistoryLoadMoreRemountsWhenAppendUnavailable(t *testing.T) { | |||
| 259 | 259 | })) | |
| 260 | 260 | assert.Equal(t, cursor, history.windowBefore) | |
| 261 | 261 | 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"`) | |
| 264 | 264 | } | |
| 265 | 265 | ||
| 266 | 266 | func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) { | |
| @@ -279,7 +279,7 @@ func TestAppHandleHistoryLoadMoreIgnoresStaleCursor(t *testing.T) { | |||
| 279 | 279 | Data: map[string]string{"before": cursor.Add(-24 * time.Hour).Format(time.RFC3339Nano)}, | |
| 280 | 280 | })) | |
| 281 | 281 | assert.False(t, history.called) | |
| 282 | - | assert.Empty(t, dom.appendedHTML) | |
| 282 | + | assert.Empty(t, dom.AppendedHTML()) | |
| 283 | 283 | } | |
| 284 | 284 | ||
| 285 | 285 | func TestAppLoadInitialHistoryRouteRendersManagementActions(t *testing.T) { | |
| @@ -327,8 +327,8 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) { | |||
| 327 | 327 | })) | |
| 328 | 328 | assert.True(t, history.searchCalled) | |
| 329 | 329 | 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") | |
| 332 | 332 | ||
| 333 | 333 | app.historyOffset = historyTimelineLimit | |
| 334 | 334 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| @@ -337,14 +337,14 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) { | |||
| 337 | 337 | })) | |
| 338 | 338 | assert.Equal(t, int64(42), history.deletedEntryID) | |
| 339 | 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 | 342 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 343 | 343 | Action: historyActionDeleteRange, | |
| 344 | 344 | Data: map[string]string{"range": "week"}, | |
| 345 | 345 | })) | |
| 346 | 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 | 349 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: historyActionClear})) | |
| 350 | 350 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| @@ -360,7 +360,7 @@ func TestAppHandleHistoryActionsRefreshesDOM(t *testing.T) { | |||
| 360 | 360 | Data: map[string]string{"domain": "example.com"}, | |
| 361 | 361 | })) | |
| 362 | 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 | 366 | func TestAppCloseStopsActionWorkerAndReleasesDOM(t *testing.T) { | |
| @@ -562,7 +562,7 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |||
| 562 | 562 | assert.Equal(t, "https://new.example", favorites.createdFavorite.URL) | |
| 563 | 563 | assert.Equal(t, entity.FolderID(5), *favorites.createdFavorite.FolderID) | |
| 564 | 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 | 567 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 568 | 568 | Action: favoriteActionUpdate, | |
| @@ -572,7 +572,7 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |||
| 572 | 572 | assert.Equal(t, "Updated", favorites.updatedFavorite.Title) | |
| 573 | 573 | require.NotNil(t, favorites.updatedFavorite.ShortcutKey) | |
| 574 | 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 | 577 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 578 | 578 | Action: tagActionAssign, | |
| @@ -603,14 +603,14 @@ func TestAppHandleFavoriteActionsRefreshesDOM(t *testing.T) { | |||
| 603 | 603 | })) | |
| 604 | 604 | assert.Equal(t, "Reading", favorites.createdFolder) | |
| 605 | 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 | 608 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 609 | 609 | Action: favoriteActionDelete, | |
| 610 | 610 | Data: map[string]string{"id": "42"}, | |
| 611 | 611 | })) | |
| 612 | 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 | 616 | func TestAppLoadInitialConfigRouteRendersData(t *testing.T) { | |
| @@ -803,15 +803,15 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||
| 803 | 803 | assert.True(t, service.calledSave) | |
| 804 | 804 | assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize) | |
| 805 | 805 | 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"`) | |
| 808 | 808 | ||
| 809 | 809 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 810 | 810 | Action: configActionCreateSearchShortcut, | |
| 811 | 811 | Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"}, | |
| 812 | 812 | })) | |
| 813 | 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 | 816 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 817 | 817 | Action: configActionSavePerformance, | |
| @@ -822,14 +822,14 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||
| 822 | 822 | })) | |
| 823 | 823 | assert.Equal(t, "custom", service.savedConfig.Performance.Profile) | |
| 824 | 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 | 827 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 828 | 828 | Action: configActionSavePerformance, | |
| 829 | 829 | Data: map[string]string{"profile": "default"}, | |
| 830 | 830 | })) | |
| 831 | 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 | 834 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 835 | 835 | Action: configActionSetKeybinding, | |
| @@ -838,7 +838,7 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||
| 838 | 838 | assert.True(t, service.calledSet) | |
| 839 | 839 | assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys) | |
| 840 | 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 | 843 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{ | |
| 844 | 844 | Action: configActionResetKeybinding, | |
| @@ -848,11 +848,11 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) { | |||
| 848 | 848 | assert.Equal(t, "toggle_history_systemview", service.resetReq.Action) | |
| 849 | 849 | assert.Contains(t, service.resetReq.RequestID, "systemviews-config-") | |
| 850 | 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 | 853 | require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings})) | |
| 854 | 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 | 858 | func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) { | |
| @@ -932,6 +932,24 @@ func (d *fakeDOM) Mount(markup string) error { | |||
| 932 | 932 | 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 | 953 | func receiveMount(t *testing.T, mounts <-chan string) string { | |
| 936 | 954 | t.Helper() | |
| 937 | 955 | select { | |
| @@ -1306,6 +1324,19 @@ func TestHandleFavoriteCreateAcceptsInternalDumbRoutes(t *testing.T) { | |||
| 1306 | 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 | 1340 | func TestParsePositiveInt64DistinguishesParseAndRangeErrors(t *testing.T) { | |
| 1310 | 1341 | _, err := parsePositiveInt64("abc", "tag id") | |
| 1311 | 1342 | 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 = historyTimelineLimit | 333 | 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 nil | 932 | 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) | |||
| 101 | 101 | cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"]) | |
| 102 | 102 | cfg.Appearance.DefaultFontSize = fontSize | |
| 103 | 103 | 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") | |
| 114 | 106 | cfg.DefaultUIScale = uiScale | |
| 115 | 107 | ||
| 116 | 108 | if err := a.saveEditableConfig(ctx, cfg); err != nil { | |
| @@ -413,57 +405,20 @@ func cloneSearchShortcuts(shortcuts map[string]dto.SearchShortcut) map[string]dt | |||
| 413 | 405 | return clone | |
| 414 | 406 | } | |
| 415 | 407 | ||
| 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]) | |
| 452 | 411 | } | |
| 453 | 412 | ||
| 454 | 413 | 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 | + | } | |
| 467 | 422 | } | |
| 468 | 423 | ||
| 469 | 424 | 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 = fontSize | 102 | 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 = uiScale | 106 | 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 clone | 405 | 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 | - | }, nil | 421 | + | } |
| 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 { | |||
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | 27 | func configKeybindingSummary(keybindings port.KeybindingsConfig) string { | |
| 28 | + | if keybindings.Groups == nil { | |
| 29 | + | return "Keybindings unavailable" | |
| 30 | + | } | |
| 28 | 31 | groups, bindings := summarizeKeybindings(keybindings) | |
| 29 | 32 | return fmt.Sprintf( | |
| 30 | 33 | "Keybindings loaded (%d %s, %d %s)", | |
| @@ -36,6 +39,9 @@ func configKeybindingSummary(keybindings port.KeybindingsConfig) string { | |||
| 36 | 39 | } | |
| 37 | 40 | ||
| 38 | 41 | func configKeybindingGroups(keybindings port.KeybindingsConfig) []renderedKeybindingGroup { | |
| 42 | + | if keybindings.Groups == nil { | |
| 43 | + | return nil | |
| 44 | + | } | |
| 39 | 45 | return convertPortKeybindingGroups(keybindings.Groups) | |
| 40 | 46 | } | |
| 41 | 47 | ||
| @@ -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) { | |||
| 219 | 219 | return "", fmt.Errorf("favorite URL host is required") | |
| 220 | 220 | } | |
| 221 | 221 | case "dumb": | |
| 222 | - | if parsed.Host != "" { | |
| 222 | + | if routeFromPageHost(parsed.Host, parsed.Opaque) != RouteUnknown { | |
| 223 | 223 | return favoriteURL, nil | |
| 224 | 224 | } | |
| 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") | |
| 229 | 226 | default: | |
| 230 | 227 | return "", fmt.Errorf("favorite URL must use http, https, or dumb scheme") | |
| 231 | 228 | } | |
| @@ -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, nil | 223 | 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 { | |||
| 139 | 139 | 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 | 142 | func historySummaryValues(data historyRenderData) (entries, visits, uniqueDays int64) { | |
| 147 | 143 | if data.Stats != nil { | |
| 148 | 144 | 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.HasMore | 139 | 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.UniqueDays | 144 | return data.Stats.TotalEntries, data.Stats.TotalVisits, data.Stats.UniqueDays | ||