v0.20.0 c741a39

validation.go RawHistoryFolder

1package config
2
3import (
4	"fmt"
5	"strings"
6
7	domainvalidation "github.com/bnema/dumber/internal/domain/validation"
8)
9
10// validateConfig performs comprehensive validation of configuration values
11func validateConfig(config *Config) error {
12	var validationErrors []string
13
14	validationErrors = append(validationErrors, validateHistory(config)...)
15	validationErrors = append(validationErrors, validateDmenu(config)...)
16	validationErrors = append(validationErrors, validateAppearance(config)...)
17	validationErrors = append(validationErrors, validateSearchEngine(config)...)
18	validationErrors = append(validationErrors, validatePopups(config)...)
19	validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)
20	validationErrors = append(validationErrors, validatePaneMode(config)...)
21	validationErrors = append(validationErrors, validateTabBar(config)...)
22	validationErrors = append(validationErrors, validateTabMode(config)...)
23	validationErrors = append(validationErrors, validateLogging(config)...)
24	validationErrors = append(validationErrors, validateOmnibox(config)...)
25	validationErrors = append(validationErrors, validateRendering(config)...)
26	validationErrors = append(validationErrors, validateColorScheme(config)...)
27
28	// If there are validation errors, return them
29	if len(validationErrors) > 0 {
30		return fmt.Errorf("config validation failed:\n  - %s", strings.Join(validationErrors, "\n  - "))
31	}
32
33	return nil
34}
35
36func validateHistory(config *Config) []string {
37	var validationErrors []string
38	if config.History.MaxEntries < 0 {
39		validationErrors = append(validationErrors, "history.max_entries must be non-negative")
40	}
41	if config.History.RetentionPeriodDays < 0 {
42		validationErrors = append(validationErrors, "history.retention_period_days must be non-negative")
43	}
44	if config.History.CleanupIntervalDays < 0 {
45		validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative")
46	}
47	return validationErrors
48}
49
50func validateDmenu(config *Config) []string {
51	if config.Dmenu.MaxHistoryItems < 0 {
52		return []string{"dmenu.max_history_items must be non-negative"}
53	}
54	return nil
55}
56
57func validateAppearance(config *Config) []string {
58	var validationErrors []string
59	if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 {
60		validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
61	}
62	validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...)
63	validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...)
64	validationErrors = append(
65		validationErrors,
66		domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...,
67	)
68	validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
69		"appearance.light_palette",
70		config.Appearance.LightPalette.Background,
71		config.Appearance.LightPalette.Surface,
72		config.Appearance.LightPalette.SurfaceVariant,
73		config.Appearance.LightPalette.Text,
74		config.Appearance.LightPalette.Muted,
75		config.Appearance.LightPalette.Accent,
76		config.Appearance.LightPalette.Border,
77	)...)
78	validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
79		"appearance.dark_palette",
80		config.Appearance.DarkPalette.Background,
81		config.Appearance.DarkPalette.Surface,
82		config.Appearance.DarkPalette.SurfaceVariant,
83		config.Appearance.DarkPalette.Text,
84		config.Appearance.DarkPalette.Muted,
85		config.Appearance.DarkPalette.Accent,
86		config.Appearance.DarkPalette.Border,
87	)...)
88	if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 {
89		validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0")
90	}
91	if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {
92		validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")
93	}
94	return validationErrors
95}
96
97func validateSearchEngine(config *Config) []string {
98	if config.DefaultSearchEngine == "" {
99		return []string{"default_search_engine cannot be empty"}
100	}
101	if !strings.Contains(config.DefaultSearchEngine, "%s") {
102		return []string{"default_search_engine must contain %s placeholder for the search query"}
103	}
104	return nil
105}
106
107func validatePopups(config *Config) []string {
108	var validationErrors []string
109	switch config.Workspace.Popups.Behavior {
110	case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
111	default:
112		validationErrors = append(validationErrors, fmt.Sprintf(
113			"workspace.popups.behavior must be one of: split, stacked, tabbed, windowed (got: %s)",
114			config.Workspace.Popups.Behavior,
115		))
116	}
117
118	if config.Workspace.Popups.Behavior == PopupBehaviorSplit {
119		switch config.Workspace.Popups.Placement {
120		case "right", "left", "top", "bottom":
121		default:
122			validationErrors = append(validationErrors, fmt.Sprintf(
123				"workspace.popups.placement must be one of: right, left, top, bottom (got: %s)",
124				config.Workspace.Popups.Placement,
125			))
126		}
127	}
128
129	switch config.Workspace.Popups.BlankTargetBehavior {
130	case "split", "stacked", "tabbed":
131	default:
132		validationErrors = append(validationErrors, fmt.Sprintf(
133			"workspace.popups.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)",
134			config.Workspace.Popups.BlankTargetBehavior,
135		))
136	}
137	return validationErrors
138}
139
140func validateWorkspaceStyling(config *Config) []string {
141	var validationErrors []string
142	if config.Workspace.Styling.BorderWidth < 0 {
143		validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
144	}
145	if config.Workspace.Styling.PaneModeBorderWidth < 0 {
146		validationErrors = append(validationErrors, "workspace.styling.pane_mode_border_width must be non-negative")
147	}
148	if config.Workspace.Styling.TabModeBorderWidth < 0 {
149		validationErrors = append(validationErrors, "workspace.styling.tab_mode_border_width must be non-negative")
150	}
151	if config.Workspace.Styling.TransitionDuration < 0 {
152		validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
153	}
154	return validationErrors
155}
156
157func validatePaneMode(config *Config) []string {
158	var validationErrors []string
159	if config.Workspace.PaneMode.TimeoutMilliseconds < 0 {
160		validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative")
161	}
162	if len(config.Workspace.PaneMode.Actions) == 0 {
163		validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty")
164	}
165
166	seenKeys := make(map[string]string)
167	for action, keys := range config.Workspace.PaneMode.Actions {
168		if len(keys) == 0 {
169			validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action))
170		}
171		for _, key := range keys {
172			if existingAction, exists := seenKeys[key]; exists {
173				validationErrors = append(validationErrors, fmt.Sprintf(
174					"duplicate key binding '%s' found in pane_mode actions '%s' and '%s'",
175					key,
176					existingAction,
177					action,
178				))
179			}
180			seenKeys[key] = action
181		}
182	}
183	return validationErrors
184}
185
186func validateTabBar(config *Config) []string {
187	switch config.Workspace.TabBarPosition {
188	case "top", "bottom":
189		return nil
190	default:
191		return []string{fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition)}
192	}
193}
194
195func validateTabMode(config *Config) []string {
196	var validationErrors []string
197	if config.Workspace.TabMode.TimeoutMilliseconds < 0 {
198		validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative")
199	}
200	if len(config.Workspace.TabMode.Actions) == 0 {
201		validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty")
202	}
203
204	tabSeenKeys := make(map[string]string)
205	for action, keys := range config.Workspace.TabMode.Actions {
206		if len(keys) == 0 {
207			validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action))
208		}
209		for _, key := range keys {
210			if existingAction, exists := tabSeenKeys[key]; exists {
211				validationErrors = append(validationErrors, fmt.Sprintf(
212					"duplicate key binding '%s' found in tab_mode actions '%s' and '%s'",
213					key,
214					existingAction,
215					action,
216				))
217			}
218			tabSeenKeys[key] = action
219		}
220	}
221	return validationErrors
222}
223
224func validateLogging(config *Config) []string {
225	var validationErrors []string
226	if config.Logging.MaxAge < 0 {
227		validationErrors = append(validationErrors, "logging.max_age must be non-negative")
228	}
229	switch config.Logging.Level {
230	case "trace", "debug", "info", "warn", "error", "fatal", "":
231	default:
232		validationErrors = append(validationErrors, fmt.Sprintf(
233			"logging.level must be one of: trace, debug, info, warn, error, fatal (got: %s)",
234			config.Logging.Level,
235		))
236	}
237	switch config.Logging.Format {
238	case "text", "json", "console", "":
239	default:
240		validationErrors = append(validationErrors, fmt.Sprintf(
241			"logging.format must be one of: text, json, console (got: %s)",
242			config.Logging.Format,
243		))
244	}
245	return validationErrors
246}
247
248func validateOmnibox(config *Config) []string {
249	switch config.Omnibox.InitialBehavior {
250	case "recent", "most_visited", "none":
251		return nil
252	default:
253		return []string{fmt.Sprintf(
254			"omnibox.initial_behavior must be one of: recent, most_visited, none (got: %s)",
255			config.Omnibox.InitialBehavior,
256		)}
257	}
258}
259
260func validateRendering(config *Config) []string {
261	var validationErrors []string
262
263	switch config.Rendering.Mode {
264	case RenderingModeAuto, RenderingModeGPU, RenderingModeCPU, "":
265	default:
266		validationErrors = append(
267			validationErrors,
268			fmt.Sprintf("rendering.mode must be one of: auto, gpu, cpu (got: %s)", config.Rendering.Mode),
269		)
270	}
271
272	switch config.Rendering.GSKRenderer {
273	case GSKRendererAuto, GSKRendererOpenGL, GSKRendererVulkan, GSKRendererCairo, "":
274	default:
275		validationErrors = append(
276			validationErrors,
277			fmt.Sprintf(
278				"rendering.gsk_renderer must be one of: auto, opengl, vulkan, cairo (got: %s)",
279				config.Rendering.GSKRenderer,
280			),
281		)
282	}
283
284	if config.Rendering.ForceCompositingMode && config.Rendering.DisableCompositingMode {
285		validationErrors = append(validationErrors,
286			"rendering.force_compositing_mode and rendering.disable_compositing_mode cannot both be true",
287		)
288	}
289
290	return validationErrors
291}
292
293func validateColorScheme(config *Config) []string {
294	switch config.Appearance.ColorScheme {
295	case ThemePreferDark, ThemePreferLight, ThemeDefault, "":
296		return nil
297	default:
298		return []string{fmt.Sprintf(
299			"appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)",
300			config.Appearance.ColorScheme,
301		)}
302	}
303}

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