v0.21.0 73bf644

validation.go RawHistoryFolder

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

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