detached 7831cd5

refactor(config): move webui save to usecase + port/gateway; add shared domain validation

commit
7831cd55e962954bb3bea2da8b069f375afb7f2a
author
brice · · 2025-12-20 18:47 UTC
parent
c7dc5f2
signature
unsigned
10 files · +413 -6
A internal/application/port/webui_config.go +35 -0
@@ -0,0 +1,35 @@
1+package port
2+
3+import "context"
4+
5+// WebUIConfig represents the subset of configuration editable in dumb://config.
6+type WebUIConfig struct {
7+ Appearance WebUIAppearanceConfig `json:"appearance"`
8+ DefaultUIScale float64 `json:"default_ui_scale"`
9+ DefaultSearchEngine string `json:"default_search_engine"`
10+}
11+
12+type WebUIAppearanceConfig struct {
13+ SansFont string `json:"sans_font"`
14+ SerifFont string `json:"serif_font"`
15+ MonospaceFont string `json:"monospace_font"`
16+ DefaultFontSize int `json:"default_font_size"`
17+ ColorScheme string `json:"color_scheme"`
18+ LightPalette ColorPalette `json:"light_palette"`
19+ DarkPalette ColorPalette `json:"dark_palette"`
20+}
21+
22+type ColorPalette struct {
23+ Background string `json:"background"`
24+ Surface string `json:"surface"`
25+ SurfaceVariant string `json:"surface_variant"`
26+ Text string `json:"text"`
27+ Muted string `json:"muted"`
28+ Accent string `json:"accent"`
29+ Border string `json:"border"`
30+}
31+
32+// WebUIConfigSaver persists WebUI configuration changes.
33+type WebUIConfigSaver interface {
34+ SaveWebUIConfig(ctx context.Context, cfg WebUIConfig) error
35+}
@@ -0,0 +1,35 @@
1+package port
2+
3+import "context"
4+
5+// WebUIConfig represents the subset of configuration editable in dumb://config.
6+type WebUIConfig struct {
7+ Appearance WebUIAppearanceConfig `json:"appearance"`
8+ DefaultUIScale float64 `json:"default_ui_scale"`
9+ DefaultSearchEngine string `json:"default_search_engine"`
10+}
11+
12+type WebUIAppearanceConfig struct {
13+ SansFont string `json:"sans_font"`
14+ SerifFont string `json:"serif_font"`
15+ MonospaceFont string `json:"monospace_font"`
16+ DefaultFontSize int `json:"default_font_size"`
17+ ColorScheme string `json:"color_scheme"`
18+ LightPalette ColorPalette `json:"light_palette"`
19+ DarkPalette ColorPalette `json:"dark_palette"`
20+}
21+
22+type ColorPalette struct {
23+ Background string `json:"background"`
24+ Surface string `json:"surface"`
25+ SurfaceVariant string `json:"surface_variant"`
26+ Text string `json:"text"`
27+ Muted string `json:"muted"`
28+ Accent string `json:"accent"`
29+ Border string `json:"border"`
30+}
31+
32+// WebUIConfigSaver persists WebUI configuration changes.
33+type WebUIConfigSaver interface {
34+ SaveWebUIConfig(ctx context.Context, cfg WebUIConfig) error
35+}
A internal/application/usecase/save_webui_config.go +94 -0
@@ -0,0 +1,94 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/validation"
10+)
11+
12+type SaveWebUIConfigUseCase struct {
13+ saver port.WebUIConfigSaver
14+}
15+
16+func NewSaveWebUIConfigUseCase(saver port.WebUIConfigSaver) *SaveWebUIConfigUseCase {
17+ return &SaveWebUIConfigUseCase{saver: saver}
18+}
19+
20+func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUIConfig) error {
21+ if uc == nil || uc.saver == nil {
22+ return fmt.Errorf("config saver is nil")
23+ }
24+
25+ normalized := normalizeWebUIConfig(cfg)
26+ if err := validateWebUIConfig(normalized); err != nil {
27+ return err
28+ }
29+
30+ return uc.saver.SaveWebUIConfig(ctx, normalized)
31+}
32+
33+func normalizeWebUIConfig(cfg port.WebUIConfig) port.WebUIConfig {
34+ cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)
35+ cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)
36+ cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont)
37+ cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme)
38+ cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine)
39+ return cfg
40+}
41+
42+func validateWebUIConfig(cfg port.WebUIConfig) error {
43+ var errs []string
44+
45+ if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {
46+ errs = append(errs, "appearance.default_font_size must be between 1 and 72")
47+ }
48+ if cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {
49+ errs = append(errs, "default_ui_scale must be between 0.5 and 3.0")
50+ }
51+
52+ errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...)
53+ errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...)
54+ errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...)
55+
56+ if cfg.DefaultSearchEngine == "" {
57+ errs = append(errs, "default_search_engine cannot be empty")
58+ } else if !strings.Contains(cfg.DefaultSearchEngine, "%s") {
59+ errs = append(errs, "default_search_engine must contain %s placeholder for the search query")
60+ }
61+
62+ switch cfg.Appearance.ColorScheme {
63+ case "default", "prefer-dark", "prefer-light":
64+ // ok
65+ default:
66+ errs = append(errs, fmt.Sprintf("appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)", cfg.Appearance.ColorScheme))
67+ }
68+
69+ errs = append(errs, validation.ValidatePaletteHex(
70+ "appearance.light_palette",
71+ cfg.Appearance.LightPalette.Background,
72+ cfg.Appearance.LightPalette.Surface,
73+ cfg.Appearance.LightPalette.SurfaceVariant,
74+ cfg.Appearance.LightPalette.Text,
75+ cfg.Appearance.LightPalette.Muted,
76+ cfg.Appearance.LightPalette.Accent,
77+ cfg.Appearance.LightPalette.Border,
78+ )...)
79+ errs = append(errs, validation.ValidatePaletteHex(
80+ "appearance.dark_palette",
81+ cfg.Appearance.DarkPalette.Background,
82+ cfg.Appearance.DarkPalette.Surface,
83+ cfg.Appearance.DarkPalette.SurfaceVariant,
84+ cfg.Appearance.DarkPalette.Text,
85+ cfg.Appearance.DarkPalette.Muted,
86+ cfg.Appearance.DarkPalette.Accent,
87+ cfg.Appearance.DarkPalette.Border,
88+ )...)
89+
90+ if len(errs) > 0 {
91+ return fmt.Errorf("config validation failed:\n - %s", strings.Join(errs, "\n - "))
92+ }
93+ return nil
94+}
@@ -0,0 +1,94 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/validation"
10+)
11+
12+type SaveWebUIConfigUseCase struct {
13+ saver port.WebUIConfigSaver
14+}
15+
16+func NewSaveWebUIConfigUseCase(saver port.WebUIConfigSaver) *SaveWebUIConfigUseCase {
17+ return &SaveWebUIConfigUseCase{saver: saver}
18+}
19+
20+func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg port.WebUIConfig) error {
21+ if uc == nil || uc.saver == nil {
22+ return fmt.Errorf("config saver is nil")
23+ }
24+
25+ normalized := normalizeWebUIConfig(cfg)
26+ if err := validateWebUIConfig(normalized); err != nil {
27+ return err
28+ }
29+
30+ return uc.saver.SaveWebUIConfig(ctx, normalized)
31+}
32+
33+func normalizeWebUIConfig(cfg port.WebUIConfig) port.WebUIConfig {
34+ cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)
35+ cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)
36+ cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont)
37+ cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme)
38+ cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine)
39+ return cfg
40+}
41+
42+func validateWebUIConfig(cfg port.WebUIConfig) error {
43+ var errs []string
44+
45+ if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {
46+ errs = append(errs, "appearance.default_font_size must be between 1 and 72")
47+ }
48+ if cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {
49+ errs = append(errs, "default_ui_scale must be between 0.5 and 3.0")
50+ }
51+
52+ errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...)
53+ errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...)
54+ errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...)
55+
56+ if cfg.DefaultSearchEngine == "" {
57+ errs = append(errs, "default_search_engine cannot be empty")
58+ } else if !strings.Contains(cfg.DefaultSearchEngine, "%s") {
59+ errs = append(errs, "default_search_engine must contain %s placeholder for the search query")
60+ }
61+
62+ switch cfg.Appearance.ColorScheme {
63+ case "default", "prefer-dark", "prefer-light":
64+ // ok
65+ default:
66+ errs = append(errs, fmt.Sprintf("appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)", cfg.Appearance.ColorScheme))
67+ }
68+
69+ errs = append(errs, validation.ValidatePaletteHex(
70+ "appearance.light_palette",
71+ cfg.Appearance.LightPalette.Background,
72+ cfg.Appearance.LightPalette.Surface,
73+ cfg.Appearance.LightPalette.SurfaceVariant,
74+ cfg.Appearance.LightPalette.Text,
75+ cfg.Appearance.LightPalette.Muted,
76+ cfg.Appearance.LightPalette.Accent,
77+ cfg.Appearance.LightPalette.Border,
78+ )...)
79+ errs = append(errs, validation.ValidatePaletteHex(
80+ "appearance.dark_palette",
81+ cfg.Appearance.DarkPalette.Background,
82+ cfg.Appearance.DarkPalette.Surface,
83+ cfg.Appearance.DarkPalette.SurfaceVariant,
84+ cfg.Appearance.DarkPalette.Text,
85+ cfg.Appearance.DarkPalette.Muted,
86+ cfg.Appearance.DarkPalette.Accent,
87+ cfg.Appearance.DarkPalette.Border,
88+ )...)
89+
90+ if len(errs) > 0 {
91+ return fmt.Errorf("config validation failed:\n - %s", strings.Join(errs, "\n - "))
92+ }
93+ return nil
94+}
A internal/domain/validation/colors.go +46 -0
@@ -0,0 +1,46 @@
1+package validation
2+
3+import "regexp"
4+
5+var hexColorRE = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
6+
7+func IsHexColor(value string) bool {
8+ return hexColorRE.MatchString(value)
9+}
10+
11+func ValidatePaletteHex(
12+ prefix string,
13+ background string,
14+ surface string,
15+ surfaceVariant string,
16+ text string,
17+ muted string,
18+ accent string,
19+ border string,
20+) []string {
21+ var errs []string
22+
23+ if !IsHexColor(background) {
24+ errs = append(errs, prefix+".background must be a hex color like #RRGGBB")
25+ }
26+ if !IsHexColor(surface) {
27+ errs = append(errs, prefix+".surface must be a hex color like #RRGGBB")
28+ }
29+ if !IsHexColor(surfaceVariant) {
30+ errs = append(errs, prefix+".surface_variant must be a hex color like #RRGGBB")
31+ }
32+ if !IsHexColor(text) {
33+ errs = append(errs, prefix+".text must be a hex color like #RRGGBB")
34+ }
35+ if !IsHexColor(muted) {
36+ errs = append(errs, prefix+".muted must be a hex color like #RRGGBB")
37+ }
38+ if !IsHexColor(accent) {
39+ errs = append(errs, prefix+".accent must be a hex color like #RRGGBB")
40+ }
41+ if !IsHexColor(border) {
42+ errs = append(errs, prefix+".border must be a hex color like #RRGGBB")
43+ }
44+
45+ return errs
46+}
@@ -0,0 +1,46 @@
1+package validation
2+
3+import "regexp"
4+
5+var hexColorRE = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
6+
7+func IsHexColor(value string) bool {
8+ return hexColorRE.MatchString(value)
9+}
10+
11+func ValidatePaletteHex(
12+ prefix string,
13+ background string,
14+ surface string,
15+ surfaceVariant string,
16+ text string,
17+ muted string,
18+ accent string,
19+ border string,
20+) []string {
21+ var errs []string
22+
23+ if !IsHexColor(background) {
24+ errs = append(errs, prefix+".background must be a hex color like #RRGGBB")
25+ }
26+ if !IsHexColor(surface) {
27+ errs = append(errs, prefix+".surface must be a hex color like #RRGGBB")
28+ }
29+ if !IsHexColor(surfaceVariant) {
30+ errs = append(errs, prefix+".surface_variant must be a hex color like #RRGGBB")
31+ }
32+ if !IsHexColor(text) {
33+ errs = append(errs, prefix+".text must be a hex color like #RRGGBB")
34+ }
35+ if !IsHexColor(muted) {
36+ errs = append(errs, prefix+".muted must be a hex color like #RRGGBB")
37+ }
38+ if !IsHexColor(accent) {
39+ errs = append(errs, prefix+".accent must be a hex color like #RRGGBB")
40+ }
41+ if !IsHexColor(border) {
42+ errs = append(errs, prefix+".border must be a hex color like #RRGGBB")
43+ }
44+
45+ return errs
46+}
A internal/domain/validation/fonts.go +23 -0
@@ -0,0 +1,23 @@
1+package validation
2+
3+import "strings"
4+
5+func ValidateFontFamily(field string, value string) []string {
6+ value = strings.TrimSpace(value)
7+ var errs []string
8+
9+ if value == "" {
10+ errs = append(errs, field+" cannot be empty")
11+ return errs
12+ }
13+
14+ if strings.ContainsAny(value, "\r\n") {
15+ errs = append(errs, field+" must not contain newlines")
16+ }
17+
18+ if len(value) > 200 {
19+ errs = append(errs, field+" is too long")
20+ }
21+
22+ return errs
23+}
@@ -0,0 +1,23 @@
1+package validation
2+
3+import "strings"
4+
5+func ValidateFontFamily(field string, value string) []string {
6+ value = strings.TrimSpace(value)
7+ var errs []string
8+
9+ if value == "" {
10+ errs = append(errs, field+" cannot be empty")
11+ return errs
12+ }
13+
14+ if strings.ContainsAny(value, "\r\n") {
15+ errs = append(errs, field+" must not contain newlines")
16+ }
17+
18+ if len(value) > 200 {
19+ errs = append(errs, field+" is too long")
20+ }
21+
22+ return errs
23+}
M internal/infrastructure/config/loader.go +44 -0
@@ -180,6 +180,50 @@ func (m *Manager) Get() *Config {
180180 return &configCopy
181181 }
182182
183+// Save saves the provided configuration to disk and updates Viper.
184+func (m *Manager) Save(cfg *Config) error {
185+ m.mu.Lock()
186+ defer m.mu.Unlock()
187+
188+ if cfg == nil {
189+ return fmt.Errorf("config is nil")
190+ }
191+
192+ // Validate before writing so UI gets immediate errors.
193+ if err := validateConfig(cfg); err != nil {
194+ return fmt.Errorf("configuration validation failed: %w", err)
195+ }
196+
197+ // Update Viper with the new values.
198+ // Since we can't easily update Viper from a struct automatically while preserving
199+ // all settings, we update the main keys we care about.
200+ m.viper.Set("appearance.sans_font", cfg.Appearance.SansFont)
201+ m.viper.Set("appearance.serif_font", cfg.Appearance.SerifFont)
202+ m.viper.Set("appearance.monospace_font", cfg.Appearance.MonospaceFont)
203+ m.viper.Set("appearance.default_font_size", cfg.Appearance.DefaultFontSize)
204+ m.viper.Set("appearance.color_scheme", cfg.Appearance.ColorScheme)
205+ m.viper.Set("appearance.light_palette", cfg.Appearance.LightPalette)
206+ m.viper.Set("appearance.dark_palette", cfg.Appearance.DarkPalette)
207+ m.viper.Set("default_webpage_zoom", cfg.DefaultWebpageZoom)
208+ m.viper.Set("default_ui_scale", cfg.DefaultUIScale)
209+ m.viper.Set("default_search_engine", cfg.DefaultSearchEngine)
210+
211+ if err := m.viper.WriteConfig(); err != nil {
212+ return fmt.Errorf("failed to write config: %w", err)
213+ }
214+
215+ // We don't update m.config here manually because viper.OnConfigChange
216+ // will trigger reload() if Watch() is active.
217+ // If Watch() is not active, we should call reload() manually.
218+ if !m.watching {
219+ if err := m.reload(); err != nil {
220+ return err
221+ }
222+ }
223+
224+ return nil
225+}
226+
183227 // GetConfigFile returns the path to the configuration file being used.
184228 func (m *Manager) GetConfigFile() string {
185229 return m.viper.ConfigFileUsed()
@@ -180,6 +180,50 @@ func (m *Manager) Get() *Config {
180 return &configCopy180 return &configCopy
181 }181 }
182 182
183+// Save saves the provided configuration to disk and updates Viper.
184+func (m *Manager) Save(cfg *Config) error {
185+ m.mu.Lock()
186+ defer m.mu.Unlock()
187+
188+ if cfg == nil {
189+ return fmt.Errorf("config is nil")
190+ }
191+
192+ // Validate before writing so UI gets immediate errors.
193+ if err := validateConfig(cfg); err != nil {
194+ return fmt.Errorf("configuration validation failed: %w", err)
195+ }
196+
197+ // Update Viper with the new values.
198+ // Since we can't easily update Viper from a struct automatically while preserving
199+ // all settings, we update the main keys we care about.
200+ m.viper.Set("appearance.sans_font", cfg.Appearance.SansFont)
201+ m.viper.Set("appearance.serif_font", cfg.Appearance.SerifFont)
202+ m.viper.Set("appearance.monospace_font", cfg.Appearance.MonospaceFont)
203+ m.viper.Set("appearance.default_font_size", cfg.Appearance.DefaultFontSize)
204+ m.viper.Set("appearance.color_scheme", cfg.Appearance.ColorScheme)
205+ m.viper.Set("appearance.light_palette", cfg.Appearance.LightPalette)
206+ m.viper.Set("appearance.dark_palette", cfg.Appearance.DarkPalette)
207+ m.viper.Set("default_webpage_zoom", cfg.DefaultWebpageZoom)
208+ m.viper.Set("default_ui_scale", cfg.DefaultUIScale)
209+ m.viper.Set("default_search_engine", cfg.DefaultSearchEngine)
210+
211+ if err := m.viper.WriteConfig(); err != nil {
212+ return fmt.Errorf("failed to write config: %w", err)
213+ }
214+
215+ // We don't update m.config here manually because viper.OnConfigChange
216+ // will trigger reload() if Watch() is active.
217+ // If Watch() is not active, we should call reload() manually.
218+ if !m.watching {
219+ if err := m.reload(); err != nil {
220+ return err
221+ }
222+ }
223+
224+ return nil
225+}
226+
183 // GetConfigFile returns the path to the configuration file being used.227 // GetConfigFile returns the path to the configuration file being used.
184 func (m *Manager) GetConfigFile() string {228 func (m *Manager) GetConfigFile() string {
185 return m.viper.ConfigFileUsed()229 return m.viper.ConfigFileUsed()
M internal/infrastructure/config/validation.go +27 -0
@@ -3,6 +3,8 @@ package config
33 import (
44 "fmt"
55 "strings"
6+
7+ domainvalidation "github.com/bnema/dumber/internal/domain/validation"
68 )
79
810 // validateConfig performs comprehensive validation of configuration values
@@ -28,6 +30,31 @@ func validateConfig(config *Config) error {
2830 validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
2931 }
3032
33+ validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...)
34+ validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...)
35+ validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...)
36+
37+ validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
38+ "appearance.light_palette",
39+ config.Appearance.LightPalette.Background,
40+ config.Appearance.LightPalette.Surface,
41+ config.Appearance.LightPalette.SurfaceVariant,
42+ config.Appearance.LightPalette.Text,
43+ config.Appearance.LightPalette.Muted,
44+ config.Appearance.LightPalette.Accent,
45+ config.Appearance.LightPalette.Border,
46+ )...)
47+ validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
48+ "appearance.dark_palette",
49+ config.Appearance.DarkPalette.Background,
50+ config.Appearance.DarkPalette.Surface,
51+ config.Appearance.DarkPalette.SurfaceVariant,
52+ config.Appearance.DarkPalette.Text,
53+ config.Appearance.DarkPalette.Muted,
54+ config.Appearance.DarkPalette.Accent,
55+ config.Appearance.DarkPalette.Border,
56+ )...)
57+
3158 if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 {
3259 validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0")
3360 }
@@ -3,6 +3,8 @@ package config
3 import (3 import (
4 "fmt"4 "fmt"
5 "strings"5 "strings"
6+
7+ domainvalidation "github.com/bnema/dumber/internal/domain/validation"
6 )8 )
7 9
8 // validateConfig performs comprehensive validation of configuration values10 // validateConfig performs comprehensive validation of configuration values
@@ -28,6 +30,31 @@ func validateConfig(config *Config) error {
28 validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")30 validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
29 }31 }
30 32
33+ validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...)
34+ validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...)
35+ validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...)
36+
37+ validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
38+ "appearance.light_palette",
39+ config.Appearance.LightPalette.Background,
40+ config.Appearance.LightPalette.Surface,
41+ config.Appearance.LightPalette.SurfaceVariant,
42+ config.Appearance.LightPalette.Text,
43+ config.Appearance.LightPalette.Muted,
44+ config.Appearance.LightPalette.Accent,
45+ config.Appearance.LightPalette.Border,
46+ )...)
47+ validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
48+ "appearance.dark_palette",
49+ config.Appearance.DarkPalette.Background,
50+ config.Appearance.DarkPalette.Surface,
51+ config.Appearance.DarkPalette.SurfaceVariant,
52+ config.Appearance.DarkPalette.Text,
53+ config.Appearance.DarkPalette.Muted,
54+ config.Appearance.DarkPalette.Accent,
55+ config.Appearance.DarkPalette.Border,
56+ )...)
57+
31 if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 {58 if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 {
32 validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0")59 validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0")
33 }60 }
A internal/infrastructure/config/webui_gateway.go +52 -0
@@ -0,0 +1,52 @@
1+package config
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+type WebUIConfigGateway struct {
11+ mgr *Manager
12+}
13+
14+func NewWebUIConfigGateway(mgr *Manager) *WebUIConfigGateway {
15+ return &WebUIConfigGateway{mgr: mgr}
16+}
17+
18+func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error {
19+ _ = ctx
20+ if g == nil || g.mgr == nil {
21+ return fmt.Errorf("config manager not initialized")
22+ }
23+
24+ current := Get()
25+ current.Appearance.SansFont = cfg.Appearance.SansFont
26+ current.Appearance.SerifFont = cfg.Appearance.SerifFont
27+ current.Appearance.MonospaceFont = cfg.Appearance.MonospaceFont
28+ current.Appearance.DefaultFontSize = cfg.Appearance.DefaultFontSize
29+ current.Appearance.ColorScheme = cfg.Appearance.ColorScheme
30+ current.Appearance.LightPalette = ColorPalette{
31+ Background: cfg.Appearance.LightPalette.Background,
32+ Surface: cfg.Appearance.LightPalette.Surface,
33+ SurfaceVariant: cfg.Appearance.LightPalette.SurfaceVariant,
34+ Text: cfg.Appearance.LightPalette.Text,
35+ Muted: cfg.Appearance.LightPalette.Muted,
36+ Accent: cfg.Appearance.LightPalette.Accent,
37+ Border: cfg.Appearance.LightPalette.Border,
38+ }
39+ current.Appearance.DarkPalette = ColorPalette{
40+ Background: cfg.Appearance.DarkPalette.Background,
41+ Surface: cfg.Appearance.DarkPalette.Surface,
42+ SurfaceVariant: cfg.Appearance.DarkPalette.SurfaceVariant,
43+ Text: cfg.Appearance.DarkPalette.Text,
44+ Muted: cfg.Appearance.DarkPalette.Muted,
45+ Accent: cfg.Appearance.DarkPalette.Accent,
46+ Border: cfg.Appearance.DarkPalette.Border,
47+ }
48+ current.DefaultUIScale = cfg.DefaultUIScale
49+ current.DefaultSearchEngine = cfg.DefaultSearchEngine
50+
51+ return g.mgr.Save(current)
52+}
@@ -0,0 +1,52 @@
1+package config
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+)
9+
10+type WebUIConfigGateway struct {
11+ mgr *Manager
12+}
13+
14+func NewWebUIConfigGateway(mgr *Manager) *WebUIConfigGateway {
15+ return &WebUIConfigGateway{mgr: mgr}
16+}
17+
18+func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUIConfig) error {
19+ _ = ctx
20+ if g == nil || g.mgr == nil {
21+ return fmt.Errorf("config manager not initialized")
22+ }
23+
24+ current := Get()
25+ current.Appearance.SansFont = cfg.Appearance.SansFont
26+ current.Appearance.SerifFont = cfg.Appearance.SerifFont
27+ current.Appearance.MonospaceFont = cfg.Appearance.MonospaceFont
28+ current.Appearance.DefaultFontSize = cfg.Appearance.DefaultFontSize
29+ current.Appearance.ColorScheme = cfg.Appearance.ColorScheme
30+ current.Appearance.LightPalette = ColorPalette{
31+ Background: cfg.Appearance.LightPalette.Background,
32+ Surface: cfg.Appearance.LightPalette.Surface,
33+ SurfaceVariant: cfg.Appearance.LightPalette.SurfaceVariant,
34+ Text: cfg.Appearance.LightPalette.Text,
35+ Muted: cfg.Appearance.LightPalette.Muted,
36+ Accent: cfg.Appearance.LightPalette.Accent,
37+ Border: cfg.Appearance.LightPalette.Border,
38+ }
39+ current.Appearance.DarkPalette = ColorPalette{
40+ Background: cfg.Appearance.DarkPalette.Background,
41+ Surface: cfg.Appearance.DarkPalette.Surface,
42+ SurfaceVariant: cfg.Appearance.DarkPalette.SurfaceVariant,
43+ Text: cfg.Appearance.DarkPalette.Text,
44+ Muted: cfg.Appearance.DarkPalette.Muted,
45+ Accent: cfg.Appearance.DarkPalette.Accent,
46+ Border: cfg.Appearance.DarkPalette.Border,
47+ }
48+ current.DefaultUIScale = cfg.DefaultUIScale
49+ current.DefaultSearchEngine = cfg.DefaultSearchEngine
50+
51+ return g.mgr.Save(current)
52+}
A internal/infrastructure/webkit/handlers/config.go +73 -0
@@ -0,0 +1,73 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+// ConfigHandler handles configuration-related messages.
16+type ConfigHandler struct{}
17+
18+// NewConfigHandler creates a new ConfigHandler.
19+func NewConfigHandler() *ConfigHandler {
20+ return &ConfigHandler{}
21+}
22+
23+// Handle processes the save_config message.
24+func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
25+ log := logging.FromContext(ctx).With().Str("handler", "config").Logger()
26+
27+ var payloadCfg port.WebUIConfig
28+ if err := json.Unmarshal(payload, &payloadCfg); err != nil {
29+ log.Error().Err(err).Msg("failed to unmarshal config payload")
30+ return nil, fmt.Errorf("invalid config format: %w", err)
31+ }
32+
33+ log.Info().Msg("saving appearance configuration from webui")
34+ mgr := config.GetManager()
35+ if mgr == nil {
36+ return nil, fmt.Errorf("config manager not initialized")
37+ }
38+
39+ uc := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr))
40+ if err := uc.Execute(ctx, payloadCfg); err != nil {
41+ log.Error().Err(err).Msg("failed to save config")
42+ return nil, fmt.Errorf("failed to save config: %w", err)
43+ }
44+
45+ return map[string]any{"status": "success"}, nil
46+}
47+
48+// RegisterConfigHandlers registers configuration handlers with the router.
49+func RegisterConfigHandlers(ctx context.Context, router *webkit.MessageRouter) error {
50+ handler := NewConfigHandler()
51+
52+ // worldName empty means main world (since dumb:// pages run in main world)
53+ // callback/errorCallback can be used if we want to notify JS of success/fail
54+ // In Svelte we use fetch() for GET, but for POST we use message bridge
55+ // Actually, if we use message bridge, we might want response callbacks.
56+
57+ log := logging.FromContext(ctx).With().Str("component", "handlers").Logger()
58+
59+ // Message type: save_config
60+ if err := router.RegisterHandlerWithCallbacks(
61+ "save_config",
62+ "__dumber_config_saved",
63+ "__dumber_config_error",
64+ "",
65+ handler,
66+ ); err != nil {
67+ return err
68+ }
69+
70+ log.Info().Msg("registered save_config handler")
71+
72+ return nil
73+}
@@ -0,0 +1,73 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+// ConfigHandler handles configuration-related messages.
16+type ConfigHandler struct{}
17+
18+// NewConfigHandler creates a new ConfigHandler.
19+func NewConfigHandler() *ConfigHandler {
20+ return &ConfigHandler{}
21+}
22+
23+// Handle processes the save_config message.
24+func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
25+ log := logging.FromContext(ctx).With().Str("handler", "config").Logger()
26+
27+ var payloadCfg port.WebUIConfig
28+ if err := json.Unmarshal(payload, &payloadCfg); err != nil {
29+ log.Error().Err(err).Msg("failed to unmarshal config payload")
30+ return nil, fmt.Errorf("invalid config format: %w", err)
31+ }
32+
33+ log.Info().Msg("saving appearance configuration from webui")
34+ mgr := config.GetManager()
35+ if mgr == nil {
36+ return nil, fmt.Errorf("config manager not initialized")
37+ }
38+
39+ uc := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr))
40+ if err := uc.Execute(ctx, payloadCfg); err != nil {
41+ log.Error().Err(err).Msg("failed to save config")
42+ return nil, fmt.Errorf("failed to save config: %w", err)
43+ }
44+
45+ return map[string]any{"status": "success"}, nil
46+}
47+
48+// RegisterConfigHandlers registers configuration handlers with the router.
49+func RegisterConfigHandlers(ctx context.Context, router *webkit.MessageRouter) error {
50+ handler := NewConfigHandler()
51+
52+ // worldName empty means main world (since dumb:// pages run in main world)
53+ // callback/errorCallback can be used if we want to notify JS of success/fail
54+ // In Svelte we use fetch() for GET, but for POST we use message bridge
55+ // Actually, if we use message bridge, we might want response callbacks.
56+
57+ log := logging.FromContext(ctx).With().Str("component", "handlers").Logger()
58+
59+ // Message type: save_config
60+ if err := router.RegisterHandlerWithCallbacks(
61+ "save_config",
62+ "__dumber_config_saved",
63+ "__dumber_config_error",
64+ "",
65+ handler,
66+ ); err != nil {
67+ return err
68+ }
69+
70+ log.Info().Msg("registered save_config handler")
71+
72+ return nil
73+}
M internal/infrastructure/webkit/handlers/register.go +11 -4
@@ -17,10 +17,17 @@ type Config struct {
1717 // RegisterAll registers all message handlers with the router.
1818 func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) error {
1919 // Homepage handlers (history, favorites, folders, tags)
20- if err := homepage.RegisterHandlers(ctx, router, homepage.Config{
21- HistoryUC: cfg.HistoryUC,
22- FavoritesUC: cfg.FavoritesUC,
23- }); err != nil {
20+ if cfg.HistoryUC != nil && cfg.FavoritesUC != nil {
21+ if err := homepage.RegisterHandlers(ctx, router, homepage.Config{
22+ HistoryUC: cfg.HistoryUC,
23+ FavoritesUC: cfg.FavoritesUC,
24+ }); err != nil {
25+ return err
26+ }
27+ }
28+
29+ // Configuration handlers (always available)
30+ if err := RegisterConfigHandlers(ctx, router); err != nil {
2431 return err
2532 }
2633
@@ -17,10 +17,17 @@ type Config struct {
17 // RegisterAll registers all message handlers with the router.17 // RegisterAll registers all message handlers with the router.
18 func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) error {18 func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) error {
19 // Homepage handlers (history, favorites, folders, tags)19 // Homepage handlers (history, favorites, folders, tags)
20- if err := homepage.RegisterHandlers(ctx, router, homepage.Config{20+ if cfg.HistoryUC != nil && cfg.FavoritesUC != nil {
21- HistoryUC: cfg.HistoryUC,21+ if err := homepage.RegisterHandlers(ctx, router, homepage.Config{
22- FavoritesUC: cfg.FavoritesUC,22+ HistoryUC: cfg.HistoryUC,
23- }); err != nil {23+ FavoritesUC: cfg.FavoritesUC,
24+ }); err != nil {
25+ return err
26+ }
27+ }
28+
29+ // Configuration handlers (always available)
30+ if err := RegisterConfigHandlers(ctx, router); err != nil {
24 return err31 return err
25 }32 }
26 33
M internal/infrastructure/webkit/messaging.go +8 -2
@@ -146,11 +146,11 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, worl
146146 return 0, fmt.Errorf("failed to register script message handler %q in main world", MessageHandlerName)
147147 }
148148
149- log.Debug().
149+ log.Info().
150150 Str("handler", MessageHandlerName).
151151 Str("world", "main").
152152 Uint32("signal_id", signalID).
153- Msg("script message handler connected successfully")
153+ Msg("script message handler connected")
154154
155155 return signalID, nil
156156 }
@@ -196,6 +196,12 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
196196 return
197197 }
198198
199+ log.Info().
200+ Str("type", msg.Type).
201+ Uint64("webview_id", msg.WebViewID).
202+ Int("payload_len", len(msg.Payload)).
203+ Msg("received script message")
204+
199205 resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload)
200206 if err != nil {
201207 log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")
@@ -146,11 +146,11 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, worl
146 return 0, fmt.Errorf("failed to register script message handler %q in main world", MessageHandlerName)146 return 0, fmt.Errorf("failed to register script message handler %q in main world", MessageHandlerName)
147 }147 }
148 148
149- log.Debug().149+ log.Info().
150 Str("handler", MessageHandlerName).150 Str("handler", MessageHandlerName).
151 Str("world", "main").151 Str("world", "main").
152 Uint32("signal_id", signalID).152 Uint32("signal_id", signalID).
153- Msg("script message handler connected successfully")153+ Msg("script message handler connected")
154 154
155 return signalID, nil155 return signalID, nil
156 }156 }
@@ -196,6 +196,12 @@ func (r *MessageRouter) handleScriptMessage(valuePtr uintptr) {
196 return196 return
197 }197 }
198 198
199+ log.Info().
200+ Str("type", msg.Type).
201+ Uint64("webview_id", msg.WebViewID).
202+ Int("payload_len", len(msg.Payload)).
203+ Msg("received script message")
204+
199 resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload)205 resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload)
200 if err != nil {206 if err != nil {
201 log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")207 log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error")

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