v0.33.1 9577fee

save_webui_config.go RawHistoryFolder

1package usecase
2
3import (
4	"context"
5	"fmt"
6	"math"
7	"strings"
8
9	"github.com/bnema/dumber/internal/application/dto"
10	"github.com/bnema/dumber/internal/application/port"
11	"github.com/bnema/dumber/internal/domain/validation"
12)
13
14type SaveWebUIConfigUseCase struct {
15	saver port.WebUIConfigSaver
16}
17
18func NewSaveWebUIConfigUseCase(saver port.WebUIConfigSaver) *SaveWebUIConfigUseCase {
19	return &SaveWebUIConfigUseCase{saver: saver}
20}
21
22func (uc *SaveWebUIConfigUseCase) Execute(ctx context.Context, cfg dto.WebUIConfig) error {
23	if uc == nil || uc.saver == nil {
24		return fmt.Errorf("config saver is nil")
25	}
26
27	if err := validateSearchShortcutKeyCollisions(cfg.SearchShortcuts); err != nil {
28		return err
29	}
30
31	normalized := normalizeWebUIConfig(cfg)
32	if err := validateWebUIConfig(normalized); err != nil {
33		return err
34	}
35
36	return uc.saver.SaveWebUIConfig(ctx, normalized)
37}
38
39func 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
54func normalizeWebUIConfig(cfg dto.WebUIConfig) dto.WebUIConfig {
55	cfg.Appearance.SansFont = strings.TrimSpace(cfg.Appearance.SansFont)
56	cfg.Appearance.SerifFont = strings.TrimSpace(cfg.Appearance.SerifFont)
57	cfg.Appearance.MonospaceFont = strings.TrimSpace(cfg.Appearance.MonospaceFont)
58	cfg.Appearance.GtkFont = strings.TrimSpace(cfg.Appearance.GtkFont)
59	cfg.Appearance.ColorScheme = strings.TrimSpace(cfg.Appearance.ColorScheme)
60	cfg.DefaultSearchEngine = strings.TrimSpace(cfg.DefaultSearchEngine)
61	cfg.Performance.Profile = strings.TrimSpace(cfg.Performance.Profile)
62	if cfg.Performance.Profile == performanceProfileCustom {
63		cfg.Performance.Custom = clampCustomPerformance(cfg.Performance.Custom)
64	}
65	if len(cfg.SearchShortcuts) > 0 {
66		normalized := make(map[string]dto.SearchShortcut, len(cfg.SearchShortcuts))
67		for key, shortcut := range cfg.SearchShortcuts {
68			trimmedKey := strings.TrimSpace(key)
69			if trimmedKey == "" {
70				continue
71			}
72			shortcut.URL = strings.TrimSpace(shortcut.URL)
73			shortcut.Description = strings.TrimSpace(shortcut.Description)
74			normalized[trimmedKey] = shortcut
75		}
76		cfg.SearchShortcuts = normalized
77	}
78	return cfg
79}
80
81const performanceProfileCustom = "custom"
82
83// Bounds for user-editable custom performance fields.
84const (
85	maxSkiaCPUThreads         = 8
86	maxSkiaGPUThreads         = 8
87	maxWebProcessMemoryMB     = 16384
88	maxNetworkProcessMemoryMB = 4096
89	maxWebViewPoolPrewarm     = 20
90)
91
92// clampCustomPerformance keeps custom profile values inside supported bounds.
93// A GPU thread count of -1 means "engine default".
94func clampCustomPerformance(c dto.WebUICustomPerformanceConfig) dto.WebUICustomPerformanceConfig {
95	c.SkiaCPUThreads = min(max(c.SkiaCPUThreads, 0), maxSkiaCPUThreads)
96	c.SkiaGPUThreads = min(max(c.SkiaGPUThreads, -1), maxSkiaGPUThreads)
97	c.WebProcessMemoryMB = min(max(c.WebProcessMemoryMB, 0), maxWebProcessMemoryMB)
98	c.NetworkProcessMemoryMB = min(max(c.NetworkProcessMemoryMB, 0), maxNetworkProcessMemoryMB)
99	c.WebViewPoolPrewarm = min(max(c.WebViewPoolPrewarm, 0), maxWebViewPoolPrewarm)
100	return c
101}
102
103func validateWebUIConfig(cfg dto.WebUIConfig) error {
104	var errs []string
105
106	if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {
107		errs = append(errs, "appearance.default_font_size must be between 1 and 72")
108	}
109	if math.IsNaN(cfg.DefaultUIScale) || math.IsInf(cfg.DefaultUIScale, 0) || cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {
110		errs = append(errs, "default_ui_scale must be a finite value between 0.5 and 3.0")
111	}
112
113	errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...)
114	errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...)
115	errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...)
116	errs = append(errs, validation.ValidateFontFamily("appearance.gtk_font", cfg.Appearance.GtkFont)...)
117
118	for _, err := range validation.ValidateShortcutURL(cfg.DefaultSearchEngine) {
119		errs = append(errs, fmt.Sprintf("default_search_engine: %s", err))
120	}
121
122	for key, shortcut := range cfg.SearchShortcuts {
123		for _, err := range validation.ValidateShortcutKey(key) {
124			errs = append(errs, fmt.Sprintf("search_shortcuts.%s: %s", key, err))
125		}
126		for _, err := range validation.ValidateShortcutURL(shortcut.URL) {
127			errs = append(errs, fmt.Sprintf("search_shortcuts.%s.url: %s", key, err))
128		}
129		for _, err := range validation.ValidateShortcutDescription(shortcut.Description) {
130			errs = append(errs, fmt.Sprintf("search_shortcuts.%s.description: %s", key, err))
131		}
132	}
133
134	switch cfg.Performance.Profile {
135	case "", "default", "lite", "balanced", "max", "custom":
136		// ok
137	default:
138		errs = append(errs, fmt.Sprintf(
139			"performance.profile must be one of: default, lite, balanced, max, custom (got: %s)",
140			cfg.Performance.Profile,
141		))
142	}
143
144	switch cfg.Appearance.ColorScheme {
145	case "default", "prefer-dark", "prefer-light":
146		// ok
147	default:
148		errs = append(errs, fmt.Sprintf(
149			"appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)",
150			cfg.Appearance.ColorScheme,
151		))
152	}
153
154	errs = append(errs, validation.ValidatePaletteHex(
155		"appearance.light_palette",
156		cfg.Appearance.LightPalette.Background,
157		cfg.Appearance.LightPalette.Surface,
158		cfg.Appearance.LightPalette.SurfaceVariant,
159		cfg.Appearance.LightPalette.Text,
160		cfg.Appearance.LightPalette.Muted,
161		cfg.Appearance.LightPalette.Accent,
162		cfg.Appearance.LightPalette.Border,
163	)...)
164	errs = append(errs, validation.ValidatePaletteHex(
165		"appearance.dark_palette",
166		cfg.Appearance.DarkPalette.Background,
167		cfg.Appearance.DarkPalette.Surface,
168		cfg.Appearance.DarkPalette.SurfaceVariant,
169		cfg.Appearance.DarkPalette.Text,
170		cfg.Appearance.DarkPalette.Muted,
171		cfg.Appearance.DarkPalette.Accent,
172		cfg.Appearance.DarkPalette.Border,
173	)...)
174
175	if len(errs) > 0 {
176		return fmt.Errorf("config validation failed:\n  - %s", strings.Join(errs, "\n  - "))
177	}
178	return nil
179}

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