detached 1ed380e

refactor(systemviews): tighten hexagonal boundaries

commit
1ed380e2522beff943b09dd2b8a0034e99663b40
author
brice · · 2026-04-24 07:33 UTC
parent
9d4fa93
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +190 -104
M internal/application/usecase/save_webui_config.go +13 -7
@@ -42,9 +42,7 @@ func normalizeWebUIConfig(cfg port.WebUIConfig) port.WebUIConfig {
4242 trimmedKey := strings.TrimSpace(key)
4343 shortcut.URL = strings.TrimSpace(shortcut.URL)
4444 shortcut.Description = strings.TrimSpace(shortcut.Description)
45- if trimmedKey != "" {
46- normalized[trimmedKey] = shortcut
47- }
45+ normalized[trimmedKey] = shortcut
4846 }
4947 cfg.SearchShortcuts = normalized
5048 }
@@ -65,10 +63,8 @@ func validateWebUIConfig(cfg port.WebUIConfig) error {
6563 errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...)
6664 errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...)
6765
68- if cfg.DefaultSearchEngine == "" {
69- errs = append(errs, "default_search_engine cannot be empty")
70- } else if !strings.Contains(cfg.DefaultSearchEngine, "%s") {
71- errs = append(errs, "default_search_engine must contain %s placeholder for the search query")
66+ for _, err := range validation.ValidateShortcutURL(cfg.DefaultSearchEngine) {
67+ errs = append(errs, fmt.Sprintf("default_search_engine: %s", err))
7268 }
7369
7470 for key, shortcut := range cfg.SearchShortcuts {
@@ -83,6 +79,16 @@ func validateWebUIConfig(cfg port.WebUIConfig) error {
8379 }
8480 }
8581
82+ switch cfg.Performance.Profile {
83+ case "", "default", "lite", "max", "custom":
84+ // ok
85+ default:
86+ errs = append(errs, fmt.Sprintf(
87+ "performance.profile must be one of: default, lite, max, custom (got: %s)",
88+ cfg.Performance.Profile,
89+ ))
90+ }
91+
8692 switch cfg.Appearance.ColorScheme {
8793 case "default", "prefer-dark", "prefer-light":
8894 // ok
@@ -42,9 +42,7 @@ func normalizeWebUIConfig(cfg port.WebUIConfig) port.WebUIConfig {
42 trimmedKey := strings.TrimSpace(key)42 trimmedKey := strings.TrimSpace(key)
43 shortcut.URL = strings.TrimSpace(shortcut.URL)43 shortcut.URL = strings.TrimSpace(shortcut.URL)
44 shortcut.Description = strings.TrimSpace(shortcut.Description)44 shortcut.Description = strings.TrimSpace(shortcut.Description)
45- if trimmedKey != "" {45+ normalized[trimmedKey] = shortcut
46- normalized[trimmedKey] = shortcut
47- }
48 }46 }
49 cfg.SearchShortcuts = normalized47 cfg.SearchShortcuts = normalized
50 }48 }
@@ -65,10 +63,8 @@ func validateWebUIConfig(cfg port.WebUIConfig) error {
65 errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...)63 errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...)
66 errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...)64 errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...)
67 65
68- if cfg.DefaultSearchEngine == "" {66+ for _, err := range validation.ValidateShortcutURL(cfg.DefaultSearchEngine) {
69- errs = append(errs, "default_search_engine cannot be empty")67+ errs = append(errs, fmt.Sprintf("default_search_engine: %s", err))
70- } else if !strings.Contains(cfg.DefaultSearchEngine, "%s") {
71- errs = append(errs, "default_search_engine must contain %s placeholder for the search query")
72 }68 }
73 69
74 for key, shortcut := range cfg.SearchShortcuts {70 for key, shortcut := range cfg.SearchShortcuts {
@@ -83,6 +79,16 @@ func validateWebUIConfig(cfg port.WebUIConfig) error {
83 }79 }
84 }80 }
85 81
82+ switch cfg.Performance.Profile {
83+ case "", "default", "lite", "max", "custom":
84+ // ok
85+ default:
86+ errs = append(errs, fmt.Sprintf(
87+ "performance.profile must be one of: default, lite, max, custom (got: %s)",
88+ cfg.Performance.Profile,
89+ ))
90+ }
91+
86 switch cfg.Appearance.ColorScheme {92 switch cfg.Appearance.ColorScheme {
87 case "default", "prefer-dark", "prefer-light":93 case "default", "prefer-dark", "prefer-light":
88 // ok94 // ok
A internal/application/usecase/save_webui_config_test.go +111 -0
@@ -0,0 +1,111 @@
1+package usecase_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ mutate func(*port.WebUIConfig)
16+ wantErr string
17+ }{
18+ {
19+ name: "rejects invalid default search URL",
20+ mutate: func(cfg *port.WebUIConfig) {
21+ cfg.DefaultSearchEngine = "not-a-url-%s"
22+ },
23+ wantErr: "default_search_engine",
24+ },
25+ {
26+ name: "rejects blank search shortcut key",
27+ mutate: func(cfg *port.WebUIConfig) {
28+ cfg.SearchShortcuts[" "] = port.SearchShortcut{URL: "https://example.com?q=%s", Description: "Example"}
29+ },
30+ wantErr: "shortcut key cannot be empty",
31+ },
32+ {
33+ name: "rejects invalid performance profile",
34+ mutate: func(cfg *port.WebUIConfig) {
35+ cfg.Performance.Profile = "turbo"
36+ },
37+ wantErr: "performance.profile",
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ cfg := validWebUIConfig()
44+ tt.mutate(&cfg)
45+ uc := usecase.NewSaveWebUIConfigUseCase(fakeWebUIConfigSaver{})
46+
47+ err := uc.Execute(context.Background(), cfg)
48+
49+ require.Error(t, err)
50+ require.Contains(t, err.Error(), tt.wantErr)
51+ })
52+ }
53+}
54+
55+func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {
56+ saver := &capturingWebUIConfigSaver{}
57+ uc := usecase.NewSaveWebUIConfigUseCase(saver)
58+ cfg := validWebUIConfig()
59+ cfg.SearchShortcuts[" ddg "] = port.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "}
60+
61+ err := uc.Execute(context.Background(), cfg)
62+
63+ require.NoError(t, err)
64+ require.Contains(t, saver.saved.SearchShortcuts, "ddg")
65+ require.Equal(t, "https://duckduckgo.com/?q=%s", saver.saved.SearchShortcuts["ddg"].URL)
66+ require.Equal(t, "DuckDuckGo", saver.saved.SearchShortcuts["ddg"].Description)
67+}
68+
69+func validWebUIConfig() port.WebUIConfig {
70+ palette := port.ColorPalette{
71+ Background: "#ffffff",
72+ Surface: "#f8f8f8",
73+ SurfaceVariant: "#eeeeee",
74+ Text: "#111111",
75+ Muted: "#666666",
76+ Accent: "#0055ff",
77+ Border: "#dddddd",
78+ }
79+ return port.WebUIConfig{
80+ Appearance: port.WebUIAppearanceConfig{
81+ SansFont: "Inter",
82+ SerifFont: "Georgia",
83+ MonospaceFont: "JetBrains Mono",
84+ DefaultFontSize: 16,
85+ ColorScheme: "prefer-dark",
86+ LightPalette: palette,
87+ DarkPalette: palette,
88+ },
89+ Performance: port.WebUIPerformanceConfig{
90+ Profile: "default",
91+ },
92+ DefaultUIScale: 1,
93+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
94+ SearchShortcuts: map[string]port.SearchShortcut{},
95+ }
96+}
97+
98+type fakeWebUIConfigSaver struct{}
99+
100+func (fakeWebUIConfigSaver) SaveWebUIConfig(context.Context, port.WebUIConfig) error {
101+ return nil
102+}
103+
104+type capturingWebUIConfigSaver struct {
105+ saved port.WebUIConfig
106+}
107+
108+func (s *capturingWebUIConfigSaver) SaveWebUIConfig(_ context.Context, cfg port.WebUIConfig) error {
109+ s.saved = cfg
110+ return nil
111+}
@@ -0,0 +1,111 @@
1+package usecase_test
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestSaveWebUIConfigUseCase_Validation(t *testing.T) {
13+ tests := []struct {
14+ name string
15+ mutate func(*port.WebUIConfig)
16+ wantErr string
17+ }{
18+ {
19+ name: "rejects invalid default search URL",
20+ mutate: func(cfg *port.WebUIConfig) {
21+ cfg.DefaultSearchEngine = "not-a-url-%s"
22+ },
23+ wantErr: "default_search_engine",
24+ },
25+ {
26+ name: "rejects blank search shortcut key",
27+ mutate: func(cfg *port.WebUIConfig) {
28+ cfg.SearchShortcuts[" "] = port.SearchShortcut{URL: "https://example.com?q=%s", Description: "Example"}
29+ },
30+ wantErr: "shortcut key cannot be empty",
31+ },
32+ {
33+ name: "rejects invalid performance profile",
34+ mutate: func(cfg *port.WebUIConfig) {
35+ cfg.Performance.Profile = "turbo"
36+ },
37+ wantErr: "performance.profile",
38+ },
39+ }
40+
41+ for _, tt := range tests {
42+ t.Run(tt.name, func(t *testing.T) {
43+ cfg := validWebUIConfig()
44+ tt.mutate(&cfg)
45+ uc := usecase.NewSaveWebUIConfigUseCase(fakeWebUIConfigSaver{})
46+
47+ err := uc.Execute(context.Background(), cfg)
48+
49+ require.Error(t, err)
50+ require.Contains(t, err.Error(), tt.wantErr)
51+ })
52+ }
53+}
54+
55+func TestSaveWebUIConfigUseCase_NormalizesAndSavesValidConfig(t *testing.T) {
56+ saver := &capturingWebUIConfigSaver{}
57+ uc := usecase.NewSaveWebUIConfigUseCase(saver)
58+ cfg := validWebUIConfig()
59+ cfg.SearchShortcuts[" ddg "] = port.SearchShortcut{URL: " https://duckduckgo.com/?q=%s ", Description: " DuckDuckGo "}
60+
61+ err := uc.Execute(context.Background(), cfg)
62+
63+ require.NoError(t, err)
64+ require.Contains(t, saver.saved.SearchShortcuts, "ddg")
65+ require.Equal(t, "https://duckduckgo.com/?q=%s", saver.saved.SearchShortcuts["ddg"].URL)
66+ require.Equal(t, "DuckDuckGo", saver.saved.SearchShortcuts["ddg"].Description)
67+}
68+
69+func validWebUIConfig() port.WebUIConfig {
70+ palette := port.ColorPalette{
71+ Background: "#ffffff",
72+ Surface: "#f8f8f8",
73+ SurfaceVariant: "#eeeeee",
74+ Text: "#111111",
75+ Muted: "#666666",
76+ Accent: "#0055ff",
77+ Border: "#dddddd",
78+ }
79+ return port.WebUIConfig{
80+ Appearance: port.WebUIAppearanceConfig{
81+ SansFont: "Inter",
82+ SerifFont: "Georgia",
83+ MonospaceFont: "JetBrains Mono",
84+ DefaultFontSize: 16,
85+ ColorScheme: "prefer-dark",
86+ LightPalette: palette,
87+ DarkPalette: palette,
88+ },
89+ Performance: port.WebUIPerformanceConfig{
90+ Profile: "default",
91+ },
92+ DefaultUIScale: 1,
93+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
94+ SearchShortcuts: map[string]port.SearchShortcut{},
95+ }
96+}
97+
98+type fakeWebUIConfigSaver struct{}
99+
100+func (fakeWebUIConfigSaver) SaveWebUIConfig(context.Context, port.WebUIConfig) error {
101+ return nil
102+}
103+
104+type capturingWebUIConfigSaver struct {
105+ saved port.WebUIConfig
106+}
107+
108+func (s *capturingWebUIConfigSaver) SaveWebUIConfig(_ context.Context, cfg port.WebUIConfig) error {
109+ s.saved = cfg
110+ return nil
111+}
M internal/domain/url/normalize.go +29 -1
@@ -178,7 +178,35 @@ func ExtractDomain(rawURL string) string {
178178 if err != nil || parsed.Host == "" {
179179 return ""
180180 }
181- return strings.TrimPrefix(parsed.Host, "www.")
181+ return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
182+}
183+
184+// CanonicalDomain extracts a normalized domain from either a URL string or a
185+// host-like domain string. It keeps non-default ports so callers that need to
186+// round-trip repository domain keys do not lose identity.
187+func CanonicalDomain(raw string) string {
188+ raw = strings.TrimSpace(strings.ToLower(raw))
189+ if raw == "" {
190+ return ""
191+ }
192+ if domain := ExtractDomain(raw); domain != "" {
193+ return domain
194+ }
195+ for _, sep := range []string{"/", "?", "#"} {
196+ if before, _, ok := strings.Cut(raw, sep); ok {
197+ raw = before
198+ }
199+ }
200+ return strings.TrimPrefix(raw, "www.")
201+}
202+
203+// DisplayDomain returns a user-facing domain label with any port removed.
204+func DisplayDomain(raw string) string {
205+ domain := CanonicalDomain(raw)
206+ if host, _, err := net.SplitHostPort(domain); err == nil {
207+ return host
208+ }
209+ return domain
182210 }
183211
184212 // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.
@@ -178,7 +178,35 @@ func ExtractDomain(rawURL string) string {
178 if err != nil || parsed.Host == "" {178 if err != nil || parsed.Host == "" {
179 return ""179 return ""
180 }180 }
181- return strings.TrimPrefix(parsed.Host, "www.")181+ return strings.TrimPrefix(strings.ToLower(parsed.Host), "www.")
182+}
183+
184+// CanonicalDomain extracts a normalized domain from either a URL string or a
185+// host-like domain string. It keeps non-default ports so callers that need to
186+// round-trip repository domain keys do not lose identity.
187+func CanonicalDomain(raw string) string {
188+ raw = strings.TrimSpace(strings.ToLower(raw))
189+ if raw == "" {
190+ return ""
191+ }
192+ if domain := ExtractDomain(raw); domain != "" {
193+ return domain
194+ }
195+ for _, sep := range []string{"/", "?", "#"} {
196+ if before, _, ok := strings.Cut(raw, sep); ok {
197+ raw = before
198+ }
199+ }
200+ return strings.TrimPrefix(raw, "www.")
201+}
202+
203+// DisplayDomain returns a user-facing domain label with any port removed.
204+func DisplayDomain(raw string) string {
205+ domain := CanonicalDomain(raw)
206+ if host, _, err := net.SplitHostPort(domain); err == nil {
207+ return host
208+ }
209+ return domain
182 }210 }
183 211
184 // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.212 // SanitizeDomainForFilename converts a domain to a safe filename with .ico extension.
M internal/domain/url/normalize_test.go +27 -0
@@ -692,3 +692,30 @@ func TestExtractOrigin_InvalidURI(t *testing.T) {
692692 })
693693 }
694694 }
695+
696+func TestCanonicalDomain(t *testing.T) {
697+ tests := []struct {
698+ name string
699+ input string
700+ want string
701+ }{
702+ {name: "url", input: "https://www.example.com/path", want: "example.com"},
703+ {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com:8443"},
704+ {name: "domain", input: "www.example.com", want: "example.com"},
705+ {name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"},
706+ }
707+
708+ for _, tt := range tests {
709+ t.Run(tt.name, func(t *testing.T) {
710+ if got := CanonicalDomain(tt.input); got != tt.want {
711+ t.Errorf("CanonicalDomain(%q) = %q, want %q", tt.input, got, tt.want)
712+ }
713+ })
714+ }
715+}
716+
717+func TestDisplayDomain(t *testing.T) {
718+ if got := DisplayDomain("https://www.example.com:8443/path"); got != "example.com" {
719+ t.Errorf("DisplayDomain() = %q, want %q", got, "example.com")
720+ }
721+}
@@ -692,3 +692,30 @@ func TestExtractOrigin_InvalidURI(t *testing.T) {
692 })692 })
693 }693 }
694 }694 }
695+
696+func TestCanonicalDomain(t *testing.T) {
697+ tests := []struct {
698+ name string
699+ input string
700+ want string
701+ }{
702+ {name: "url", input: "https://www.example.com/path", want: "example.com"},
703+ {name: "url with port", input: "https://www.example.com:8443/path", want: "example.com:8443"},
704+ {name: "domain", input: "www.example.com", want: "example.com"},
705+ {name: "domain with path", input: "www.example.com/path?q=1", want: "example.com"},
706+ }
707+
708+ for _, tt := range tests {
709+ t.Run(tt.name, func(t *testing.T) {
710+ if got := CanonicalDomain(tt.input); got != tt.want {
711+ t.Errorf("CanonicalDomain(%q) = %q, want %q", tt.input, got, tt.want)
712+ }
713+ })
714+ }
715+}
716+
717+func TestDisplayDomain(t *testing.T) {
718+ if got := DisplayDomain("https://www.example.com:8443/path"); got != "example.com" {
719+ t.Errorf("DisplayDomain() = %q, want %q", got, "example.com")
720+ }
721+}
M internal/ui/systemviews/config_actions.go +6 -55
@@ -7,7 +7,6 @@ import (
77 "strings"
88
99 "github.com/bnema/dumber/internal/application/port"
10- "github.com/bnema/dumber/internal/domain/validation"
1110 )
1211
1312 const (
@@ -112,9 +111,6 @@ func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) erro
112111 return err
113112 }
114113 cfg.DefaultSearchEngine = strings.TrimSpace(data["default_search_engine"])
115- if err := validateSearchURL(cfg.DefaultSearchEngine, "default search engine"); err != nil {
116- return err
117- }
118114 if err := a.saveEditableConfig(ctx, cfg); err != nil {
119115 return err
120116 }
@@ -127,10 +123,7 @@ func (a *App) createSearchShortcut(ctx context.Context, data map[string]string)
127123 if err != nil {
128124 return err
129125 }
130- key, shortcut, err := searchShortcutFromForm(data, "key")
131- if err != nil {
132- return err
133- }
126+ key, shortcut := searchShortcutFromForm(data, "key")
134127 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
135128 if _, exists := cfg.SearchShortcuts[key]; exists {
136129 return fmt.Errorf("search shortcut %q already exists", key)
@@ -152,10 +145,7 @@ func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string)
152145 if oldKey == "" {
153146 return fmt.Errorf("search shortcut key is required")
154147 }
155- newKey, shortcut, err := searchShortcutFromForm(data, "new_key")
156- if err != nil {
157- return err
158- }
148+ newKey, shortcut := searchShortcutFromForm(data, "new_key")
159149 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
160150 if _, exists := cfg.SearchShortcuts[oldKey]; !exists {
161151 return fmt.Errorf("search shortcut %q not found", oldKey)
@@ -201,9 +191,6 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string)
201191 return err
202192 }
203193 profile := strings.TrimSpace(data["profile"])
204- if !validPerformanceProfile(profile) {
205- return fmt.Errorf("invalid performance profile")
206- }
207194 skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads")
208195 if err != nil {
209196 return err
@@ -363,46 +350,10 @@ func paletteFromForm(data map[string]string, prefix string) port.ColorPalette {
363350 }
364351 }
365352
366-func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut, error) {
367- key := strings.TrimSpace(data[keyField])
368- url := strings.TrimSpace(data["url"])
369- description := strings.TrimSpace(data["description"])
370- if err := validateSearchShortcut(key, url, description); err != nil {
371- return "", port.SearchShortcut{}, err
372- }
373- return key, port.SearchShortcut{URL: url, Description: description}, nil
374-}
375-
376-func validateSearchURL(raw, label string) error {
377- if errs := validation.ValidateShortcutURL(raw); len(errs) > 0 {
378- return fmt.Errorf("%s: %s", label, strings.Join(errs, "; "))
379- }
380- return nil
381-}
382-
383-func validateSearchShortcut(key, url, description string) error {
384- var errs []string
385- for _, err := range validation.ValidateShortcutKey(key) {
386- errs = append(errs, "key: "+err)
387- }
388- for _, err := range validation.ValidateShortcutURL(url) {
389- errs = append(errs, "url: "+err)
390- }
391- for _, err := range validation.ValidateShortcutDescription(description) {
392- errs = append(errs, "description: "+err)
393- }
394- if len(errs) > 0 {
395- return fmt.Errorf("invalid search shortcut: %s", strings.Join(errs, "; "))
396- }
397- return nil
398-}
399-
400-func validPerformanceProfile(profile string) bool {
401- switch profile {
402- case "default", "lite", "max", "custom", "":
403- return true
404- default:
405- return false
353+func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut) {
354+ return strings.TrimSpace(data[keyField]), port.SearchShortcut{
355+ URL: strings.TrimSpace(data["url"]),
356+ Description: strings.TrimSpace(data["description"]),
406357 }
407358 }
408359
@@ -7,7 +7,6 @@ import (
7 "strings"7 "strings"
8 8
9 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
10- "github.com/bnema/dumber/internal/domain/validation"
11 )10 )
12 11
13 const (12 const (
@@ -112,9 +111,6 @@ func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) erro
112 return err111 return err
113 }112 }
114 cfg.DefaultSearchEngine = strings.TrimSpace(data["default_search_engine"])113 cfg.DefaultSearchEngine = strings.TrimSpace(data["default_search_engine"])
115- if err := validateSearchURL(cfg.DefaultSearchEngine, "default search engine"); err != nil {
116- return err
117- }
118 if err := a.saveEditableConfig(ctx, cfg); err != nil {114 if err := a.saveEditableConfig(ctx, cfg); err != nil {
119 return err115 return err
120 }116 }
@@ -127,10 +123,7 @@ func (a *App) createSearchShortcut(ctx context.Context, data map[string]string)
127 if err != nil {123 if err != nil {
128 return err124 return err
129 }125 }
130- key, shortcut, err := searchShortcutFromForm(data, "key")126+ key, shortcut := searchShortcutFromForm(data, "key")
131- if err != nil {
132- return err
133- }
134 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)127 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
135 if _, exists := cfg.SearchShortcuts[key]; exists {128 if _, exists := cfg.SearchShortcuts[key]; exists {
136 return fmt.Errorf("search shortcut %q already exists", key)129 return fmt.Errorf("search shortcut %q already exists", key)
@@ -152,10 +145,7 @@ func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string)
152 if oldKey == "" {145 if oldKey == "" {
153 return fmt.Errorf("search shortcut key is required")146 return fmt.Errorf("search shortcut key is required")
154 }147 }
155- newKey, shortcut, err := searchShortcutFromForm(data, "new_key")148+ newKey, shortcut := searchShortcutFromForm(data, "new_key")
156- if err != nil {
157- return err
158- }
159 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)149 cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
160 if _, exists := cfg.SearchShortcuts[oldKey]; !exists {150 if _, exists := cfg.SearchShortcuts[oldKey]; !exists {
161 return fmt.Errorf("search shortcut %q not found", oldKey)151 return fmt.Errorf("search shortcut %q not found", oldKey)
@@ -201,9 +191,6 @@ func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string)
201 return err191 return err
202 }192 }
203 profile := strings.TrimSpace(data["profile"])193 profile := strings.TrimSpace(data["profile"])
204- if !validPerformanceProfile(profile) {
205- return fmt.Errorf("invalid performance profile")
206- }
207 skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads")194 skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads")
208 if err != nil {195 if err != nil {
209 return err196 return err
@@ -363,46 +350,10 @@ func paletteFromForm(data map[string]string, prefix string) port.ColorPalette {
363 }350 }
364 }351 }
365 352
366-func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut, error) {353+func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut) {
367- key := strings.TrimSpace(data[keyField])354+ return strings.TrimSpace(data[keyField]), port.SearchShortcut{
368- url := strings.TrimSpace(data["url"])355+ URL: strings.TrimSpace(data["url"]),
369- description := strings.TrimSpace(data["description"])356+ Description: strings.TrimSpace(data["description"]),
370- if err := validateSearchShortcut(key, url, description); err != nil {
371- return "", port.SearchShortcut{}, err
372- }
373- return key, port.SearchShortcut{URL: url, Description: description}, nil
374-}
375-
376-func validateSearchURL(raw, label string) error {
377- if errs := validation.ValidateShortcutURL(raw); len(errs) > 0 {
378- return fmt.Errorf("%s: %s", label, strings.Join(errs, "; "))
379- }
380- return nil
381-}
382-
383-func validateSearchShortcut(key, url, description string) error {
384- var errs []string
385- for _, err := range validation.ValidateShortcutKey(key) {
386- errs = append(errs, "key: "+err)
387- }
388- for _, err := range validation.ValidateShortcutURL(url) {
389- errs = append(errs, "url: "+err)
390- }
391- for _, err := range validation.ValidateShortcutDescription(description) {
392- errs = append(errs, "description: "+err)
393- }
394- if len(errs) > 0 {
395- return fmt.Errorf("invalid search shortcut: %s", strings.Join(errs, "; "))
396- }
397- return nil
398-}
399-
400-func validPerformanceProfile(profile string) bool {
401- switch profile {
402- case "default", "lite", "max", "custom", "":
403- return true
404- default:
405- return false
406 }357 }
407 }358 }
408 359
M internal/ui/systemviews/history_actions.go +0 -11
@@ -5,9 +5,6 @@ import (
55 "fmt"
66 "strconv"
77 "strings"
8- "time"
9-
10- historydomain "github.com/bnema/dumber/internal/domain/history"
118 )
129
1310 const (
@@ -117,9 +114,6 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
117114 a.historyNotice = "Deleted history entry"
118115 case historyActionDeleteRange:
119116 rangeID := strings.TrimSpace(data["range"])
120- if !validHistoryRange(rangeID) {
121- return fmt.Errorf("invalid history cleanup range")
122- }
123117 if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {
124118 return err
125119 }
@@ -149,11 +143,6 @@ func (a *App) mountRenderedHTML() error {
149143 return a.deps.DOM.Mount(a.renderedHTML)
150144 }
151145
152-func validHistoryRange(rangeID string) bool {
153- _, _, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
154- return ok
155-}
156-
157146 func historyRangeNotice(rangeID string) string {
158147 for _, item := range historyCleanupItems() {
159148 if item.RangeID == rangeID {
@@ -5,9 +5,6 @@ import (
5 "fmt"5 "fmt"
6 "strconv"6 "strconv"
7 "strings"7 "strings"
8- "time"
9-
10- historydomain "github.com/bnema/dumber/internal/domain/history"
11 )8 )
12 9
13 const (10 const (
@@ -117,9 +114,6 @@ func (a *App) handleHistoryAction(ctx context.Context, event DOMAction) error {
117 a.historyNotice = "Deleted history entry"114 a.historyNotice = "Deleted history entry"
118 case historyActionDeleteRange:115 case historyActionDeleteRange:
119 rangeID := strings.TrimSpace(data["range"])116 rangeID := strings.TrimSpace(data["range"])
120- if !validHistoryRange(rangeID) {
121- return fmt.Errorf("invalid history cleanup range")
122- }
123 if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {117 if err := a.deps.History.DeleteRange(ctx, rangeID); err != nil {
124 return err118 return err
125 }119 }
@@ -149,11 +143,6 @@ func (a *App) mountRenderedHTML() error {
149 return a.deps.DOM.Mount(a.renderedHTML)143 return a.deps.DOM.Mount(a.renderedHTML)
150 }144 }
151 145
152-func validHistoryRange(rangeID string) bool {
153- _, _, ok := historydomain.DeleteRangeCutoff(rangeID, time.Now())
154- return ok
155-}
156-
157 func historyRangeNotice(rangeID string) string {146 func historyRangeNotice(rangeID string) string {
158 for _, item := range historyCleanupItems() {147 for _, item := range historyCleanupItems() {
159 if item.RangeID == rangeID {148 if item.RangeID == rangeID {
M internal/ui/systemviews/history_view.go +4 -30
@@ -2,12 +2,11 @@ package systemviews
22
33 import (
44 "fmt"
5- "net"
6- "net/url"
75 "strings"
86 "time"
97
108 "github.com/bnema/dumber/internal/domain/entity"
9+ browserurl "github.com/bnema/dumber/internal/domain/url"
1110 )
1211
1312 type historyRenderData struct {
@@ -197,7 +196,7 @@ func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
197196 }
198197
199198 func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) []*entity.HistoryEntry {
200- domain = canonicalHistoryDomain(domain)
199+ domain = browserurl.CanonicalDomain(domain)
201200 if domain == "" {
202201 return entries
203202 }
@@ -206,7 +205,7 @@ func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string)
206205 if entry == nil {
207206 continue
208207 }
209- if canonicalHistoryDomain(entry.URL) == domain {
208+ if browserurl.CanonicalDomain(entry.URL) == domain {
210209 filtered = append(filtered, entry)
211210 }
212211 }
@@ -214,11 +213,7 @@ func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string)
214213 }
215214
216215 func displayHistoryDomain(raw string) string {
217- domain := canonicalHistoryDomain(raw)
218- if host, _, err := net.SplitHostPort(domain); err == nil {
219- domain = host
220- }
221- return domain
216+ return browserurl.DisplayDomain(raw)
222217 }
223218
224219 func historyDomainActionKey(domain *entity.DomainStat) string {
@@ -235,27 +230,6 @@ func historyDomainDisplayLabel(domain *entity.DomainStat) string {
235230 return displayHistoryDomain(domain.Domain)
236231 }
237232
238-func canonicalHistoryDomain(raw string) string {
239- raw = strings.TrimSpace(strings.ToLower(raw))
240- if raw == "" {
241- return ""
242- }
243- if parsed, err := url.Parse(raw); err == nil && parsed.Host != "" {
244- return strings.TrimPrefix(parsed.Host, "www.")
245- }
246- if schemeIdx := strings.Index(raw, "://"); schemeIdx >= 0 {
247- if parsed, err := url.Parse(raw[schemeIdx+3:]); err == nil && parsed.Host != "" {
248- return strings.TrimPrefix(parsed.Host, "www.")
249- }
250- }
251- for _, sep := range []string{"/", "?", "#"} {
252- if before, _, ok := strings.Cut(raw, sep); ok {
253- raw = before
254- }
255- }
256- return strings.TrimPrefix(raw, "www.")
257-}
258-
259233 func formatHistoryTime(ts time.Time) string {
260234 if ts.IsZero() {
261235 return ""
@@ -2,12 +2,11 @@ package systemviews
2 2
3 import (3 import (
4 "fmt"4 "fmt"
5- "net"
6- "net/url"
7 "strings"5 "strings"
8 "time"6 "time"
9 7
10 "github.com/bnema/dumber/internal/domain/entity"8 "github.com/bnema/dumber/internal/domain/entity"
9+ browserurl "github.com/bnema/dumber/internal/domain/url"
11 )10 )
12 11
13 type historyRenderData struct {12 type historyRenderData struct {
@@ -197,7 +196,7 @@ func countUniqueHistoryDays(entries []*entity.HistoryEntry) int64 {
197 }196 }
198 197
199 func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) []*entity.HistoryEntry {198 func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string) []*entity.HistoryEntry {
200- domain = canonicalHistoryDomain(domain)199+ domain = browserurl.CanonicalDomain(domain)
201 if domain == "" {200 if domain == "" {
202 return entries201 return entries
203 }202 }
@@ -206,7 +205,7 @@ func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string)
206 if entry == nil {205 if entry == nil {
207 continue206 continue
208 }207 }
209- if canonicalHistoryDomain(entry.URL) == domain {208+ if browserurl.CanonicalDomain(entry.URL) == domain {
210 filtered = append(filtered, entry)209 filtered = append(filtered, entry)
211 }210 }
212 }211 }
@@ -214,11 +213,7 @@ func filterHistoryEntriesByDomain(entries []*entity.HistoryEntry, domain string)
214 }213 }
215 214
216 func displayHistoryDomain(raw string) string {215 func displayHistoryDomain(raw string) string {
217- domain := canonicalHistoryDomain(raw)216+ return browserurl.DisplayDomain(raw)
218- if host, _, err := net.SplitHostPort(domain); err == nil {
219- domain = host
220- }
221- return domain
222 }217 }
223 218
224 func historyDomainActionKey(domain *entity.DomainStat) string {219 func historyDomainActionKey(domain *entity.DomainStat) string {
@@ -235,27 +230,6 @@ func historyDomainDisplayLabel(domain *entity.DomainStat) string {
235 return displayHistoryDomain(domain.Domain)230 return displayHistoryDomain(domain.Domain)
236 }231 }
237 232
238-func canonicalHistoryDomain(raw string) string {
239- raw = strings.TrimSpace(strings.ToLower(raw))
240- if raw == "" {
241- return ""
242- }
243- if parsed, err := url.Parse(raw); err == nil && parsed.Host != "" {
244- return strings.TrimPrefix(parsed.Host, "www.")
245- }
246- if schemeIdx := strings.Index(raw, "://"); schemeIdx >= 0 {
247- if parsed, err := url.Parse(raw[schemeIdx+3:]); err == nil && parsed.Host != "" {
248- return strings.TrimPrefix(parsed.Host, "www.")
249- }
250- }
251- for _, sep := range []string{"/", "?", "#"} {
252- if before, _, ok := strings.Cut(raw, sep); ok {
253- raw = before
254- }
255- }
256- return strings.TrimPrefix(raw, "www.")
257-}
258-
259 func formatHistoryTime(ts time.Time) string {233 func formatHistoryTime(ts time.Time) string {
260 if ts.IsZero() {234 if ts.IsZero() {
261 return ""235 return ""

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