v0.23.2 bfd5eb4

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	validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
32
33	// If there are validation errors, return them
34	if len(validationErrors) > 0 {
35		return fmt.Errorf("config validation failed:\n  - %s", strings.Join(validationErrors, "\n  - "))
36	}
37
38	return nil
39}
40
41func validateHistory(config *Config) []string {
42	var validationErrors []string
43	if config.History.MaxEntries < 0 {
44		validationErrors = append(validationErrors, "history.max_entries must be non-negative")
45	}
46	if config.History.RetentionPeriodDays < 0 {
47		validationErrors = append(validationErrors, "history.retention_period_days must be non-negative")
48	}
49	if config.History.CleanupIntervalDays < 0 {
50		validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative")
51	}
52	return validationErrors
53}
54
55func validateDmenu(config *Config) []string {
56	if config.Dmenu.MaxHistoryDays < 0 {
57		return []string{"dmenu.max_history_days must be non-negative"}
58	}
59	return nil
60}
61
62func validateAppearance(config *Config) []string {
63	var validationErrors []string
64	if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 {
65		validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
66	}
67	validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...)
68	validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...)
69	validationErrors = append(
70		validationErrors,
71		domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...,
72	)
73	validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
74		"appearance.light_palette",
75		config.Appearance.LightPalette.Background,
76		config.Appearance.LightPalette.Surface,
77		config.Appearance.LightPalette.SurfaceVariant,
78		config.Appearance.LightPalette.Text,
79		config.Appearance.LightPalette.Muted,
80		config.Appearance.LightPalette.Accent,
81		config.Appearance.LightPalette.Border,
82	)...)
83	validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
84		"appearance.dark_palette",
85		config.Appearance.DarkPalette.Background,
86		config.Appearance.DarkPalette.Surface,
87		config.Appearance.DarkPalette.SurfaceVariant,
88		config.Appearance.DarkPalette.Text,
89		config.Appearance.DarkPalette.Muted,
90		config.Appearance.DarkPalette.Accent,
91		config.Appearance.DarkPalette.Border,
92	)...)
93	if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 {
94		validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0")
95	}
96	if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {
97		validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")
98	}
99	return validationErrors
100}
101
102func validateSearchEngine(config *Config) []string {
103	if config.DefaultSearchEngine == "" {
104		return []string{"default_search_engine cannot be empty"}
105	}
106	if !strings.Contains(config.DefaultSearchEngine, "%s") {
107		return []string{"default_search_engine must contain %s placeholder for the search query"}
108	}
109	return nil
110}
111
112func validatePopups(config *Config) []string {
113	var validationErrors []string
114	switch config.Workspace.Popups.Behavior {
115	case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
116	default:
117		validationErrors = append(validationErrors, fmt.Sprintf(
118			"workspace.popups.behavior must be one of: split, stacked, tabbed, windowed (got: %s)",
119			config.Workspace.Popups.Behavior,
120		))
121	}
122
123	if config.Workspace.Popups.Behavior == PopupBehaviorSplit {
124		switch config.Workspace.Popups.Placement {
125		case "right", "left", "top", "bottom":
126		default:
127			validationErrors = append(validationErrors, fmt.Sprintf(
128				"workspace.popups.placement must be one of: right, left, top, bottom (got: %s)",
129				config.Workspace.Popups.Placement,
130			))
131		}
132	}
133
134	switch config.Workspace.Popups.BlankTargetBehavior {
135	case "split", "stacked", "tabbed":
136	default:
137		validationErrors = append(validationErrors, fmt.Sprintf(
138			"workspace.popups.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)",
139			config.Workspace.Popups.BlankTargetBehavior,
140		))
141	}
142	return validationErrors
143}
144
145func validateWorkspaceStyling(config *Config) []string {
146	var validationErrors []string
147	if config.Workspace.Styling.BorderWidth < 0 {
148		validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
149	}
150	if config.Workspace.Styling.ModeBorderWidth < 0 {
151		validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative")
152	}
153	if config.Workspace.Styling.TransitionDuration < 0 {
154		validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
155	}
156	return validationErrors
157}
158
159func validatePaneMode(config *Config) []string {
160	var validationErrors []string
161	if config.Workspace.PaneMode.TimeoutMilliseconds < 0 {
162		validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative")
163	}
164	if len(config.Workspace.PaneMode.Actions) == 0 {
165		validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty")
166	}
167
168	seenKeys := make(map[string]string)
169	for action, keys := range config.Workspace.PaneMode.Actions {
170		if len(keys) == 0 {
171			validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action))
172		}
173		for _, key := range keys {
174			if existingAction, exists := seenKeys[key]; exists {
175				validationErrors = append(validationErrors, fmt.Sprintf(
176					"duplicate key binding '%s' found in pane_mode actions '%s' and '%s'",
177					key,
178					existingAction,
179					action,
180				))
181			}
182			seenKeys[key] = action
183		}
184	}
185	return validationErrors
186}
187
188func validateTabBar(config *Config) []string {
189	switch config.Workspace.TabBarPosition {
190	case "top", "bottom":
191		return nil
192	default:
193		return []string{fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition)}
194	}
195}
196
197func validateTabMode(config *Config) []string {
198	var validationErrors []string
199	if config.Workspace.TabMode.TimeoutMilliseconds < 0 {
200		validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative")
201	}
202	if len(config.Workspace.TabMode.Actions) == 0 {
203		validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty")
204	}
205
206	tabSeenKeys := make(map[string]string)
207	for action, keys := range config.Workspace.TabMode.Actions {
208		if len(keys) == 0 {
209			validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action))
210		}
211		for _, key := range keys {
212			if existingAction, exists := tabSeenKeys[key]; exists {
213				validationErrors = append(validationErrors, fmt.Sprintf(
214					"duplicate key binding '%s' found in tab_mode actions '%s' and '%s'",
215					key,
216					existingAction,
217					action,
218				))
219			}
220			tabSeenKeys[key] = action
221		}
222	}
223	return validationErrors
224}
225
226func validateLogging(config *Config) []string {
227	var validationErrors []string
228	if config.Logging.MaxAge < 0 {
229		validationErrors = append(validationErrors, "logging.max_age must be non-negative")
230	}
231	switch config.Logging.Level {
232	case "trace", "debug", "info", "warn", "error", "fatal", "":
233	default:
234		validationErrors = append(validationErrors, fmt.Sprintf(
235			"logging.level must be one of: trace, debug, info, warn, error, fatal (got: %s)",
236			config.Logging.Level,
237		))
238	}
239	switch config.Logging.Format {
240	case "text", "json", "console", "":
241	default:
242		validationErrors = append(validationErrors, fmt.Sprintf(
243			"logging.format must be one of: text, json, console (got: %s)",
244			config.Logging.Format,
245		))
246	}
247	return validationErrors
248}
249
250func validateWorkspaceNewPaneURL(config *Config) []string {
251	var validationErrors []string
252
253	if config.Workspace.NewPaneURL == "" {
254		validationErrors = append(validationErrors, "workspace.new_pane_url cannot be empty")
255		return validationErrors
256	}
257
258	normalized := domainurl.Normalize(config.Workspace.NewPaneURL)
259	parsed, err := url.Parse(normalized)
260	if err != nil {
261		validationErrors = append(validationErrors, fmt.Sprintf(
262			"workspace.new_pane_url must be a valid URL (got: %s)",
263			config.Workspace.NewPaneURL,
264		))
265		return validationErrors
266	}
267
268	switch parsed.Scheme {
269	case "http", "https", "dumb", "file", "about":
270		// ok
271	default:
272		validationErrors = append(validationErrors, fmt.Sprintf(
273			"workspace.new_pane_url must use one of: http, https, dumb, file, about (got: %s)",
274			parsed.Scheme,
275		))
276	}
277
278	return validationErrors
279}
280
281func validateOmnibox(config *Config) []string {
282	switch config.Omnibox.InitialBehavior {
283	case "recent", "most_visited", "none":
284		return nil
285	default:
286		return []string{fmt.Sprintf(
287			"omnibox.initial_behavior must be one of: recent, most_visited, none (got: %s)",
288			config.Omnibox.InitialBehavior,
289		)}
290	}
291}
292
293func validateRendering(config *Config) []string {
294	var validationErrors []string
295
296	switch config.Rendering.Mode {
297	case RenderingModeAuto, RenderingModeGPU, RenderingModeCPU, "":
298	default:
299		validationErrors = append(
300			validationErrors,
301			fmt.Sprintf("rendering.mode must be one of: auto, gpu, cpu (got: %s)", config.Rendering.Mode),
302		)
303	}
304
305	switch config.Rendering.GSKRenderer {
306	case GSKRendererAuto, GSKRendererOpenGL, GSKRendererVulkan, GSKRendererCairo, "":
307	default:
308		validationErrors = append(
309			validationErrors,
310			fmt.Sprintf(
311				"rendering.gsk_renderer must be one of: auto, opengl, vulkan, cairo (got: %s)",
312				config.Rendering.GSKRenderer,
313			),
314		)
315	}
316
317	if config.Rendering.ForceCompositingMode && config.Rendering.DisableCompositingMode {
318		validationErrors = append(validationErrors,
319			"rendering.force_compositing_mode and rendering.disable_compositing_mode cannot both be true",
320		)
321	}
322
323	return validationErrors
324}
325
326func validateColorScheme(config *Config) []string {
327	switch config.Appearance.ColorScheme {
328	case ThemePreferDark, ThemePreferLight, ThemeDefault, "":
329		return nil
330	default:
331		return []string{fmt.Sprintf(
332			"appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)",
333			config.Appearance.ColorScheme,
334		)}
335	}
336}
337
338func validateSession(config *Config) []string {
339	var validationErrors []string
340	if config.Session.MaxExitedSessions < 0 {
341		validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative")
342	}
343	if config.Session.MaxExitedSessionAgeDays < 0 {
344		validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative")
345	}
346	if config.Session.SnapshotIntervalMs < 0 {
347		validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative")
348	}
349	return validationErrors
350}
351
352func validatePerformanceProfile(config *Config) []string {
353	var validationErrors []string
354
355	// Validate profile name
356	if !IsValidPerformanceProfile(config.Performance.Profile) {
357		validationErrors = append(validationErrors, fmt.Sprintf(
358			"performance.profile must be one of: default, lite, max, custom (got: %s)",
359			config.Performance.Profile,
360		))
361	}
362
363	// When profile is not "custom", warn if individual fields are set
364	// (they will be ignored in favor of profile-computed values)
365	if config.Performance.Profile != ProfileCustom && config.Performance.Profile != "" {
366		if HasCustomPerformanceFields(&config.Performance) {
367			validationErrors = append(validationErrors,
368				"performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+
369					"set profile = \"custom\" to use individual field values",
370			)
371		}
372	}
373
374	// Validate memory pressure threshold ordering: WebKit requires conservative < strict
375	webCons := config.Performance.WebProcessMemoryConservativeThreshold
376	webStrict := config.Performance.WebProcessMemoryStrictThreshold
377	if webCons > 0 && webStrict > 0 && webCons >= webStrict {
378		validationErrors = append(validationErrors,
379			"performance.web_process_memory_conservative_threshold must be less than "+
380				"web_process_memory_strict_threshold when both are set",
381		)
382	}
383
384	netCons := config.Performance.NetworkProcessMemoryConservativeThreshold
385	netStrict := config.Performance.NetworkProcessMemoryStrictThreshold
386	if netCons > 0 && netStrict > 0 && netCons >= netStrict {
387		validationErrors = append(validationErrors,
388			"performance.network_process_memory_conservative_threshold must be less than "+
389				"network_process_memory_strict_threshold when both are set",
390		)
391	}
392
393	return validationErrors
394}

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