v0.30.0 eb3068f

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	if len(cfg.SearchShortcuts) > 0 {
62		normalized := make(map[string]dto.SearchShortcut, len(cfg.SearchShortcuts))
63		for key, shortcut := range cfg.SearchShortcuts {
64			trimmedKey := strings.TrimSpace(key)
65			if trimmedKey == "" {
66				continue
67			}
68			shortcut.URL = strings.TrimSpace(shortcut.URL)
69			shortcut.Description = strings.TrimSpace(shortcut.Description)
70			normalized[trimmedKey] = shortcut
71		}
72		cfg.SearchShortcuts = normalized
73	}
74	return cfg
75}
76
77func validateWebUIConfig(cfg dto.WebUIConfig) error {
78	var errs []string
79
80	if cfg.Appearance.DefaultFontSize < 1 || cfg.Appearance.DefaultFontSize > 72 {
81		errs = append(errs, "appearance.default_font_size must be between 1 and 72")
82	}
83	if math.IsNaN(cfg.DefaultUIScale) || math.IsInf(cfg.DefaultUIScale, 0) || cfg.DefaultUIScale < 0.5 || cfg.DefaultUIScale > 3.0 {
84		errs = append(errs, "default_ui_scale must be a finite value between 0.5 and 3.0")
85	}
86
87	errs = append(errs, validation.ValidateFontFamily("appearance.sans_font", cfg.Appearance.SansFont)...)
88	errs = append(errs, validation.ValidateFontFamily("appearance.serif_font", cfg.Appearance.SerifFont)...)
89	errs = append(errs, validation.ValidateFontFamily("appearance.monospace_font", cfg.Appearance.MonospaceFont)...)
90	errs = append(errs, validation.ValidateFontFamily("appearance.gtk_font", cfg.Appearance.GtkFont)...)
91
92	for _, err := range validation.ValidateShortcutURL(cfg.DefaultSearchEngine) {
93		errs = append(errs, fmt.Sprintf("default_search_engine: %s", err))
94	}
95
96	for key, shortcut := range cfg.SearchShortcuts {
97		for _, err := range validation.ValidateShortcutKey(key) {
98			errs = append(errs, fmt.Sprintf("search_shortcuts.%s: %s", key, err))
99		}
100		for _, err := range validation.ValidateShortcutURL(shortcut.URL) {
101			errs = append(errs, fmt.Sprintf("search_shortcuts.%s.url: %s", key, err))
102		}
103		for _, err := range validation.ValidateShortcutDescription(shortcut.Description) {
104			errs = append(errs, fmt.Sprintf("search_shortcuts.%s.description: %s", key, err))
105		}
106	}
107
108	switch cfg.Performance.Profile {
109	case "", "default", "lite", "balanced", "max", "custom":
110		// ok
111	default:
112		errs = append(errs, fmt.Sprintf(
113			"performance.profile must be one of: default, lite, balanced, max, custom (got: %s)",
114			cfg.Performance.Profile,
115		))
116	}
117
118	switch cfg.Appearance.ColorScheme {
119	case "default", "prefer-dark", "prefer-light":
120		// ok
121	default:
122		errs = append(errs, fmt.Sprintf(
123			"appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)",
124			cfg.Appearance.ColorScheme,
125		))
126	}
127
128	errs = append(errs, validation.ValidatePaletteHex(
129		"appearance.light_palette",
130		cfg.Appearance.LightPalette.Background,
131		cfg.Appearance.LightPalette.Surface,
132		cfg.Appearance.LightPalette.SurfaceVariant,
133		cfg.Appearance.LightPalette.Text,
134		cfg.Appearance.LightPalette.Muted,
135		cfg.Appearance.LightPalette.Accent,
136		cfg.Appearance.LightPalette.Border,
137	)...)
138	errs = append(errs, validation.ValidatePaletteHex(
139		"appearance.dark_palette",
140		cfg.Appearance.DarkPalette.Background,
141		cfg.Appearance.DarkPalette.Surface,
142		cfg.Appearance.DarkPalette.SurfaceVariant,
143		cfg.Appearance.DarkPalette.Text,
144		cfg.Appearance.DarkPalette.Muted,
145		cfg.Appearance.DarkPalette.Accent,
146		cfg.Appearance.DarkPalette.Border,
147	)...)
148
149	if len(errs) > 0 {
150		return fmt.Errorf("config validation failed:\n  - %s", strings.Join(errs, "\n  - "))
151	}
152	return nil
153}

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