v0.27.2 5960dd8

validation.go RawHistoryFolder

1package config
2
3import (
4	"fmt"
5	"net/url"
6	"sort"
7	"strings"
8
9	domainurl "github.com/bnema/dumber/internal/domain/url"
10	domainvalidation "github.com/bnema/dumber/internal/domain/validation"
11)
12
13// validateConfig performs comprehensive validation of configuration values
14func validateConfig(config *Config) error {
15	var validationErrors []string
16
17	validationErrors = append(validationErrors, validateHistory(config)...)
18	validationErrors = append(validationErrors, validateDmenu(config)...)
19	validationErrors = append(validationErrors, validateAppearance(config)...)
20	validationErrors = append(validationErrors, validateSearchEngine(config)...)
21	validationErrors = append(validationErrors, validatePopups(config)...)
22	validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)
23	validationErrors = append(validationErrors, validatePaneMode(config)...)
24	validationErrors = append(validationErrors, validateTabBar(config)...)
25	validationErrors = append(validationErrors, validateTabMode(config)...)
26	validationErrors = append(validationErrors, validateFloatingPane(config)...)
27	validationErrors = append(validationErrors, validateLogging(config)...)
28	validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
29	validationErrors = append(validationErrors, validateOmnibox(config)...)
30	validationErrors = append(validationErrors, validateRendering(config)...)
31	validationErrors = append(validationErrors, validatePrivacy(config)...)
32	validationErrors = append(validationErrors, validateColorScheme(config)...)
33	validationErrors = append(validationErrors, validateSession(config)...)
34	validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
35
36	// If there are validation errors, return them
37	if len(validationErrors) > 0 {
38		return fmt.Errorf("config validation failed:\n  - %s", strings.Join(validationErrors, "\n  - "))
39	}
40
41	return nil
42}
43
44func validateHistory(config *Config) []string {
45	var validationErrors []string
46	if config.History.MaxEntries < 0 {
47		validationErrors = append(validationErrors, "history.max_entries must be non-negative")
48	}
49	if config.History.RetentionPeriodDays < 0 {
50		validationErrors = append(validationErrors, "history.retention_period_days must be non-negative")
51	}
52	if config.History.CleanupIntervalDays < 0 {
53		validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative")
54	}
55	return validationErrors
56}
57
58func validateDmenu(config *Config) []string {
59	if config.Dmenu.MaxHistoryDays < 0 {
60		return []string{"dmenu.max_history_days must be non-negative"}
61	}
62	return nil
63}
64
65func validateAppearance(config *Config) []string {
66	var validationErrors []string
67	if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 {
68		validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
69	}
70	validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...)
71	validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...)
72	validationErrors = append(
73		validationErrors,
74		domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...,
75	)
76	validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
77		"appearance.light_palette",
78		config.Appearance.LightPalette.Background,
79		config.Appearance.LightPalette.Surface,
80		config.Appearance.LightPalette.SurfaceVariant,
81		config.Appearance.LightPalette.Text,
82		config.Appearance.LightPalette.Muted,
83		config.Appearance.LightPalette.Accent,
84		config.Appearance.LightPalette.Border,
85	)...)
86	validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
87		"appearance.dark_palette",
88		config.Appearance.DarkPalette.Background,
89		config.Appearance.DarkPalette.Surface,
90		config.Appearance.DarkPalette.SurfaceVariant,
91		config.Appearance.DarkPalette.Text,
92		config.Appearance.DarkPalette.Muted,
93		config.Appearance.DarkPalette.Accent,
94		config.Appearance.DarkPalette.Border,
95	)...)
96	if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 {
97		validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0")
98	}
99	if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {
100		validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")
101	}
102	return validationErrors
103}
104
105func validateSearchEngine(config *Config) []string {
106	if config.DefaultSearchEngine == "" {
107		return []string{"default_search_engine cannot be empty"}
108	}
109	if !strings.Contains(config.DefaultSearchEngine, "%s") {
110		return []string{"default_search_engine must contain %s placeholder for the search query"}
111	}
112	return nil
113}
114
115func validatePopups(config *Config) []string {
116	var validationErrors []string
117	switch config.Workspace.Popups.Behavior {
118	case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
119	default:
120		validationErrors = append(validationErrors, fmt.Sprintf(
121			"workspace.popups.behavior must be one of: split, stacked, tabbed, windowed (got: %s)",
122			config.Workspace.Popups.Behavior,
123		))
124	}
125
126	if config.Workspace.Popups.Behavior == PopupBehaviorSplit {
127		switch config.Workspace.Popups.Placement {
128		case "right", "left", "top", "bottom":
129		default:
130			validationErrors = append(validationErrors, fmt.Sprintf(
131				"workspace.popups.placement must be one of: right, left, top, bottom (got: %s)",
132				config.Workspace.Popups.Placement,
133			))
134		}
135	}
136
137	switch config.Workspace.Popups.BlankTargetBehavior {
138	case "split", "stacked", "tabbed":
139	default:
140		validationErrors = append(validationErrors, fmt.Sprintf(
141			"workspace.popups.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)",
142			config.Workspace.Popups.BlankTargetBehavior,
143		))
144	}
145	return validationErrors
146}
147
148func validateWorkspaceStyling(config *Config) []string {
149	var validationErrors []string
150	if config.Workspace.Styling.BorderWidth < 0 {
151		validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
152	}
153	if config.Workspace.Styling.ModeBorderWidth < 0 {
154		validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative")
155	}
156	if config.Workspace.Styling.TransitionDuration < 0 {
157		validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
158	}
159	return validationErrors
160}
161
162func validatePaneMode(config *Config) []string {
163	var validationErrors []string
164	if config.Workspace.PaneMode.TimeoutMilliseconds < 0 {
165		validationErrors = append(validationErrors, "workspace.pane_mode.timeout_ms must be non-negative")
166	}
167	if len(config.Workspace.PaneMode.Actions) == 0 {
168		validationErrors = append(validationErrors, "workspace.pane_mode.actions cannot be empty")
169	}
170
171	seenKeys := make(map[string]string)
172	for action, binding := range config.Workspace.PaneMode.Actions {
173		if len(binding.Keys) == 0 {
174			validationErrors = append(validationErrors, fmt.Sprintf("workspace.pane_mode.actions.%s must have at least one key binding", action))
175		}
176		for _, key := range binding.Keys {
177			if existingAction, exists := seenKeys[key]; exists {
178				validationErrors = append(validationErrors, fmt.Sprintf(
179					"duplicate key binding '%s' found in pane_mode actions '%s' and '%s'",
180					key,
181					existingAction,
182					action,
183				))
184			}
185			seenKeys[key] = action
186		}
187	}
188	return validationErrors
189}
190
191func validateTabBar(config *Config) []string {
192	switch config.Workspace.TabBarPosition {
193	case "top", "bottom":
194		return nil
195	default:
196		return []string{fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition)}
197	}
198}
199
200func validateTabMode(config *Config) []string {
201	var validationErrors []string
202	if config.Workspace.TabMode.TimeoutMilliseconds < 0 {
203		validationErrors = append(validationErrors, "workspace.tab_mode.timeout_ms must be non-negative")
204	}
205	if len(config.Workspace.TabMode.Actions) == 0 {
206		validationErrors = append(validationErrors, "workspace.tab_mode.actions cannot be empty")
207	}
208
209	tabSeenKeys := make(map[string]string)
210	for action, binding := range config.Workspace.TabMode.Actions {
211		if len(binding.Keys) == 0 {
212			validationErrors = append(validationErrors, fmt.Sprintf("workspace.tab_mode.actions.%s must have at least one key binding", action))
213		}
214		for _, key := range binding.Keys {
215			if existingAction, exists := tabSeenKeys[key]; exists {
216				validationErrors = append(validationErrors, fmt.Sprintf(
217					"duplicate key binding '%s' found in tab_mode actions '%s' and '%s'",
218					key,
219					existingAction,
220					action,
221				))
222			}
223			tabSeenKeys[key] = action
224		}
225	}
226	return validationErrors
227}
228
229func validateFloatingPane(config *Config) []string {
230	var validationErrors []string
231	seenProfileKeys := make(map[string]string)
232
233	if config.Workspace.FloatingPane.WidthPct <= 0 || config.Workspace.FloatingPane.WidthPct > 1 {
234		validationErrors = append(validationErrors, "workspace.floating_pane.width_pct must be in (0,1]")
235	}
236
237	if config.Workspace.FloatingPane.HeightPct <= 0 || config.Workspace.FloatingPane.HeightPct > 1 {
238		validationErrors = append(validationErrors, "workspace.floating_pane.height_pct must be in (0,1]")
239	}
240
241	profileNames := make([]string, 0, len(config.Workspace.FloatingPane.Profiles))
242	for profileName := range config.Workspace.FloatingPane.Profiles {
243		profileNames = append(profileNames, profileName)
244	}
245	sort.Strings(profileNames)
246
247	for _, profileName := range profileNames {
248		profile := config.Workspace.FloatingPane.Profiles[profileName]
249		if strings.TrimSpace(profile.URL) == "" {
250			validationErrors = append(validationErrors, fmt.Sprintf(
251				"workspace.floating_pane.profiles.%s.url is required",
252				profileName,
253			))
254		} else {
255			validationErrors = append(validationErrors, validateWorkspaceURLValue(
256				fmt.Sprintf("workspace.floating_pane.profiles.%s.url", profileName),
257				profile.URL,
258			)...)
259		}
260		if len(profile.Keys) == 0 {
261			validationErrors = append(validationErrors, fmt.Sprintf(
262				"workspace.floating_pane.profiles.%s.keys must have at least one entry",
263				profileName,
264			))
265		}
266
267		for _, key := range profile.Keys {
268			normalizedKey := normalizeBindingKeyString(key)
269			if normalizedKey == "" {
270				validationErrors = append(validationErrors, fmt.Sprintf(
271					"workspace.floating_pane.profiles.%s.keys contains empty or whitespace-only key binding %q",
272					profileName,
273					key,
274				))
275				continue
276			}
277
278			if existingProfile, exists := seenProfileKeys[normalizedKey]; exists {
279				validationErrors = append(validationErrors, fmt.Sprintf(
280					"workspace.floating_pane.profiles.%s.keys contains duplicate key binding %q already used by profile %q",
281					profileName,
282					key,
283					existingProfile,
284				))
285				continue
286			}
287
288			seenProfileKeys[normalizedKey] = profileName
289		}
290	}
291
292	return validationErrors
293}
294
295func normalizeBindingKeyString(key string) string {
296	normalized := strings.ToLower(strings.TrimSpace(key))
297	if normalized == "" {
298		return ""
299	}
300	return strings.Join(strings.Fields(normalized), "")
301}
302
303func validateLogging(config *Config) []string {
304	var validationErrors []string
305	if config.Logging.MaxAge < 0 {
306		validationErrors = append(validationErrors, "logging.max_age must be non-negative")
307	}
308	switch config.Logging.Level {
309	case "trace", "debug", "info", "warn", "error", "fatal", "":
310	default:
311		validationErrors = append(validationErrors, fmt.Sprintf(
312			"logging.level must be one of: trace, debug, info, warn, error, fatal (got: %s)",
313			config.Logging.Level,
314		))
315	}
316	switch config.Logging.Format {
317	case "text", "json", "console", "":
318	default:
319		validationErrors = append(validationErrors, fmt.Sprintf(
320			"logging.format must be one of: text, json, console (got: %s)",
321			config.Logging.Format,
322		))
323	}
324	return validationErrors
325}
326
327func validateWorkspaceNewPaneURL(config *Config) []string {
328	var validationErrors []string
329
330	if config.Workspace.NewPaneURL == "" {
331		validationErrors = append(validationErrors, "workspace.new_pane_url cannot be empty")
332		return validationErrors
333	}
334
335	validationErrors = append(validationErrors, validateWorkspaceURLValue(
336		"workspace.new_pane_url",
337		config.Workspace.NewPaneURL,
338	)...)
339
340	return validationErrors
341}
342
343func validateWorkspaceURLValue(fieldPath, value string) []string {
344	trimmed := strings.TrimSpace(value)
345	candidate := trimmed
346	parsed, err := url.Parse(candidate)
347	if err != nil || parsed.Scheme == "" {
348		candidate = domainurl.Normalize(trimmed)
349		parsed, err = url.Parse(candidate)
350	}
351	if err != nil {
352		return []string{fmt.Sprintf("%s must be a valid URL (got: %s)", fieldPath, value)}
353	}
354
355	switch parsed.Scheme {
356	case "http", "https", "dumb", "file", "about":
357		return nil
358	default:
359		return []string{fmt.Sprintf(
360			"%s must use one of: http, https, dumb, file, about (got: %s)",
361			fieldPath,
362			parsed.Scheme,
363		)}
364	}
365}
366
367func validateOmnibox(config *Config) []string {
368	switch config.Omnibox.InitialBehavior {
369	case "recent", "most_visited", "none":
370		return nil
371	default:
372		return []string{fmt.Sprintf(
373			"omnibox.initial_behavior must be one of: recent, most_visited, none (got: %s)",
374			config.Omnibox.InitialBehavior,
375		)}
376	}
377}
378
379func validateRendering(config *Config) []string {
380	var validationErrors []string
381
382	switch config.Rendering.Mode {
383	case RenderingModeAuto, RenderingModeGPU, RenderingModeCPU, "":
384	default:
385		validationErrors = append(
386			validationErrors,
387			fmt.Sprintf("rendering.mode must be one of: auto, gpu, cpu (got: %s)", config.Rendering.Mode),
388		)
389	}
390
391	switch config.Rendering.GSKRenderer {
392	case GSKRendererAuto, GSKRendererOpenGL, GSKRendererVulkan, GSKRendererCairo, "":
393	default:
394		validationErrors = append(
395			validationErrors,
396			fmt.Sprintf(
397				"rendering.gsk_renderer must be one of: auto, opengl, vulkan, cairo (got: %s)",
398				config.Rendering.GSKRenderer,
399			),
400		)
401	}
402
403	if config.Rendering.ForceCompositingMode && config.Rendering.DisableCompositingMode {
404		validationErrors = append(validationErrors,
405			"rendering.force_compositing_mode and rendering.disable_compositing_mode cannot both be true",
406		)
407	}
408
409	return validationErrors
410}
411
412func validatePrivacy(config *Config) []string {
413	switch config.Privacy.CookiePolicy {
414	case CookiePolicyAlways, CookiePolicyNoThirdParty, CookiePolicyNever, "":
415		return nil
416	default:
417		return []string{fmt.Sprintf(
418			"privacy.cookie_policy must be one of: always, no_third_party, never (got: %s)",
419			config.Privacy.CookiePolicy,
420		)}
421	}
422}
423
424func validateColorScheme(config *Config) []string {
425	switch config.Appearance.ColorScheme {
426	case ThemePreferDark, ThemePreferLight, ThemeDefault, "":
427		return nil
428	default:
429		return []string{fmt.Sprintf(
430			"appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)",
431			config.Appearance.ColorScheme,
432		)}
433	}
434}
435
436func validateSession(config *Config) []string {
437	var validationErrors []string
438	if config.Session.MaxExitedSessions < 0 {
439		validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative")
440	}
441	if config.Session.MaxExitedSessionAgeDays < 0 {
442		validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative")
443	}
444	if config.Session.SnapshotIntervalMs < 0 {
445		validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative")
446	}
447	return validationErrors
448}
449
450func validatePerformanceProfile(config *Config) []string {
451	var validationErrors []string
452
453	// Validate profile name
454	if !IsValidPerformanceProfile(config.Performance.Profile) {
455		validationErrors = append(validationErrors, fmt.Sprintf(
456			"performance.profile must be one of: default, lite, max, custom (got: %s)",
457			config.Performance.Profile,
458		))
459	}
460
461	// When profile is not "custom", warn if individual fields are set
462	// (they will be ignored in favor of profile-computed values)
463	if config.Performance.Profile != ProfileCustom && config.Performance.Profile != "" {
464		if HasCustomPerformanceFields(&config.Performance) {
465			validationErrors = append(validationErrors,
466				"performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+
467					"set profile = \"custom\" to use individual field values",
468			)
469		}
470	}
471
472	// Validate memory pressure threshold ordering: WebKit requires conservative < strict
473	webCons := config.Performance.WebProcessMemoryConservativeThreshold
474	webStrict := config.Performance.WebProcessMemoryStrictThreshold
475	if webCons > 0 && webStrict > 0 && webCons >= webStrict {
476		validationErrors = append(validationErrors,
477			"performance.web_process_memory_conservative_threshold must be less than "+
478				"web_process_memory_strict_threshold when both are set",
479		)
480	}
481
482	netCons := config.Performance.NetworkProcessMemoryConservativeThreshold
483	netStrict := config.Performance.NetworkProcessMemoryStrictThreshold
484	if netCons > 0 && netStrict > 0 && netCons >= netStrict {
485		validationErrors = append(validationErrors,
486			"performance.network_process_memory_conservative_threshold must be less than "+
487				"network_process_memory_strict_threshold when both are set",
488		)
489	}
490
491	return validationErrors
492}

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