main ba1e2c7

validation.go RawHistoryFolder

1package config
2
3import (
4	"fmt"
5	"math"
6	"net/url"
7	"os"
8	"path/filepath"
9	"sort"
10	"strings"
11
12	"github.com/bnema/dumber/internal/domain/keychord"
13	domainurl "github.com/bnema/dumber/internal/domain/url"
14	domainvalidation "github.com/bnema/dumber/internal/domain/validation"
15	"github.com/bnema/dumber/internal/domain/vimkeys"
16)
17
18const cefLogSeverityDisabled = 99
19
20// validateConfig performs comprehensive validation of configuration values
21func validateConfig(config *Config) error {
22	var validationErrors []string
23
24	validationErrors = append(validationErrors, validateHistory(config)...)
25	validationErrors = append(validationErrors, validateDmenu(config)...)
26	validationErrors = append(validationErrors, validateAppearance(config)...)
27	validationErrors = append(validationErrors, validateSearchEngine(config)...)
28	validationErrors = append(validationErrors, validatePopups(config)...)
29	validationErrors = append(validationErrors, validateExternalLinks(config)...)
30	validationErrors = append(validationErrors, validateWorkspaceStyling(config)...)
31	validationErrors = append(validationErrors, validateKeymap(config)...)
32	validationErrors = append(validationErrors, validatePaneMode(config)...)
33	validationErrors = append(validationErrors, validateTabBar(config)...)
34	validationErrors = append(validationErrors, validateTabMode(config)...)
35	validationErrors = append(validationErrors, validateVimMode(config)...)
36	validationErrors = append(validationErrors, validateFloatingPane(config)...)
37	validationErrors = append(validationErrors, validateLogging(config)...)
38	validationErrors = append(validationErrors, validateWorkspaceNewPaneURL(config)...)
39	validationErrors = append(validationErrors, validateOmnibox(config)...)
40	validationErrors = append(validationErrors, validateEngine(config)...)
41	validationErrors = append(validationErrors, validateRendering(config)...)
42	validationErrors = append(validationErrors, validatePrivacy(config)...)
43	validationErrors = append(validationErrors, validateColorScheme(config)...)
44	validationErrors = append(validationErrors, validateSession(config)...)
45	validationErrors = append(validationErrors, validatePerformanceProfile(config)...)
46	validationErrors = append(validationErrors, validateCEF(config)...)
47
48	// If there are validation errors, return them
49	if len(validationErrors) > 0 {
50		return fmt.Errorf("config validation failed:\n  - %s", strings.Join(validationErrors, "\n  - "))
51	}
52
53	return nil
54}
55
56func validateHistory(config *Config) []string {
57	var validationErrors []string
58	if config.History.MaxEntries < 0 {
59		validationErrors = append(validationErrors, "history.max_entries must be non-negative")
60	}
61	if config.History.RetentionPeriodDays < 0 {
62		validationErrors = append(validationErrors, "history.retention_period_days must be non-negative")
63	}
64	if config.History.CleanupIntervalDays < 0 {
65		validationErrors = append(validationErrors, "history.cleanup_interval_days must be non-negative")
66	}
67	return validationErrors
68}
69
70func validateDmenu(config *Config) []string {
71	if config.Dmenu.MaxHistoryDays < 0 {
72		return []string{"dmenu.max_history_days must be non-negative"}
73	}
74	return nil
75}
76
77func validateAppearance(config *Config) []string {
78	var validationErrors []string
79	if config.Appearance.DefaultFontSize < 1 || config.Appearance.DefaultFontSize > 72 {
80		validationErrors = append(validationErrors, "appearance.default_font_size must be between 1 and 72")
81	}
82	validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.sans_font", config.Appearance.SansFont)...)
83	validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.serif_font", config.Appearance.SerifFont)...)
84	validationErrors = append(validationErrors, domainvalidation.ValidateFontFamily("appearance.gtk_font", config.Appearance.GtkFont)...)
85	validationErrors = append(
86		validationErrors,
87		domainvalidation.ValidateFontFamily("appearance.monospace_font", config.Appearance.MonospaceFont)...,
88	)
89	validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
90		"appearance.light_palette",
91		config.Appearance.LightPalette.Background,
92		config.Appearance.LightPalette.Surface,
93		config.Appearance.LightPalette.SurfaceVariant,
94		config.Appearance.LightPalette.Text,
95		config.Appearance.LightPalette.Muted,
96		config.Appearance.LightPalette.Accent,
97		config.Appearance.LightPalette.Border,
98	)...)
99	validationErrors = append(validationErrors, domainvalidation.ValidatePaletteHex(
100		"appearance.dark_palette",
101		config.Appearance.DarkPalette.Background,
102		config.Appearance.DarkPalette.Surface,
103		config.Appearance.DarkPalette.SurfaceVariant,
104		config.Appearance.DarkPalette.Text,
105		config.Appearance.DarkPalette.Muted,
106		config.Appearance.DarkPalette.Accent,
107		config.Appearance.DarkPalette.Border,
108	)...)
109	if config.DefaultWebpageZoom < 0.1 || config.DefaultWebpageZoom > 5.0 {
110		validationErrors = append(validationErrors, "default_webpage_zoom must be between 0.1 and 5.0")
111	}
112	if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {
113		validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")
114	}
115	if config.SidebarWidth != 0 && (config.SidebarWidth < 280 || config.SidebarWidth > 380) {
116		validationErrors = append(validationErrors, "sidebar_width must be between 280 and 380, or 0 for default")
117	}
118	validationErrors = append(validationErrors, validateExternalTheme(config)...)
119	return validationErrors
120}
121
122func validateExternalTheme(config *Config) []string {
123	externalTheme := config.Appearance.ExternalTheme
124	if !externalTheme.Enabled {
125		return nil
126	}
127
128	var validationErrors []string
129	if externalTheme.Provider != defaultExternalThemeProvider {
130		validationErrors = append(validationErrors, "appearance.external_theme.provider must be one of: noctalia")
131	}
132	if !isSupportedExternalThemeFormat(externalTheme.Format) {
133		validationErrors = append(validationErrors, "appearance.external_theme.format must be one of: colors-json, dumber-json")
134	}
135	if strings.TrimSpace(externalTheme.Path) == "" {
136		validationErrors = append(validationErrors, "appearance.external_theme.path is required when external_theme is enabled")
137	}
138	return validationErrors
139}
140
141func isSupportedExternalThemeFormat(format string) bool {
142	switch format {
143	case defaultExternalThemeFormat, defaultExternalThemeTemplateFormat:
144		return true
145	default:
146		return false
147	}
148}
149
150func validateSearchEngine(config *Config) []string {
151	if config.DefaultSearchEngine == "" {
152		return []string{"default_search_engine cannot be empty"}
153	}
154	if !strings.Contains(config.DefaultSearchEngine, "%s") {
155		return []string{"default_search_engine must contain %s placeholder for the search query"}
156	}
157	return nil
158}
159
160func validatePopups(config *Config) []string {
161	var validationErrors []string
162	// Validate against BrowsingContexts (canonical field).
163	// Popups is a runtime alias synced during normalization.
164	switch config.Workspace.BrowsingContexts.Behavior {
165	case PopupBehaviorSplit, PopupBehaviorStacked, PopupBehaviorTabbed, PopupBehaviorWindowed:
166	default:
167		validationErrors = append(validationErrors, fmt.Sprintf(
168			"workspace.browsing_contexts.behavior must be one of: split, stacked, tabbed, windowed (got: %s)",
169			config.Workspace.BrowsingContexts.Behavior,
170		))
171	}
172
173	if config.Workspace.BrowsingContexts.Behavior == PopupBehaviorSplit {
174		switch config.Workspace.BrowsingContexts.Placement {
175		case "right", "left", "top", "bottom":
176		default:
177			validationErrors = append(validationErrors, fmt.Sprintf(
178				"workspace.browsing_contexts.placement must be one of: right, left, top, bottom (got: %s)",
179				config.Workspace.BrowsingContexts.Placement,
180			))
181		}
182	}
183
184	switch config.Workspace.BrowsingContexts.BlankTargetBehavior {
185	case "split", "stacked", "tabbed":
186	default:
187		validationErrors = append(validationErrors, fmt.Sprintf(
188			"workspace.browsing_contexts.blank_target_behavior must be one of: split, stacked, tabbed (got: %s)",
189			config.Workspace.BrowsingContexts.BlankTargetBehavior,
190		))
191	}
192	return validationErrors
193}
194
195func validateExternalLinks(config *Config) []string {
196	var validationErrors []string
197	switch config.Workspace.ExternalLinks.Behavior {
198	case ExternalLinkBehaviorWindowed, ExternalLinkBehaviorTabbed, ExternalLinkBehaviorSplit, ExternalLinkBehaviorStacked:
199	default:
200		validationErrors = append(validationErrors, fmt.Sprintf(
201			"workspace.external_links.behavior must be one of: windowed, tabbed, split, stacked (got: %s)",
202			config.Workspace.ExternalLinks.Behavior,
203		))
204	}
205
206	switch config.Workspace.ExternalLinks.Placement {
207	case ExternalLinkPlacementRight, ExternalLinkPlacementLeft, ExternalLinkPlacementTop, ExternalLinkPlacementBottom:
208	default:
209		validationErrors = append(validationErrors, fmt.Sprintf(
210			"workspace.external_links.placement must be one of: right, left, top, bottom (got: %s)",
211			config.Workspace.ExternalLinks.Placement,
212		))
213	}
214	return validationErrors
215}
216
217func validateWorkspaceStyling(config *Config) []string {
218	var validationErrors []string
219	if config.Workspace.Styling.BorderWidth < 0 {
220		validationErrors = append(validationErrors, "workspace.styling.border_width must be non-negative")
221	}
222	if config.Workspace.Styling.ModeBorderWidth < 0 {
223		validationErrors = append(validationErrors, "workspace.styling.mode_border_width must be non-negative")
224	}
225	if config.Workspace.Styling.TransitionDuration < 0 {
226		validationErrors = append(validationErrors, "workspace.styling.transition_duration must be non-negative")
227	}
228	switch config.Workspace.Styling.ModeLegend {
229	case "always", "delay", "off":
230	default:
231		validationErrors = append(validationErrors, "workspace.styling.mode_legend must be always, delay, or off")
232	}
233	if config.Workspace.Styling.ModeLegendDelayMs < 0 {
234		validationErrors = append(validationErrors, "workspace.styling.mode_legend_delay_ms must be non-negative")
235	}
236	return validationErrors
237}
238
239func validateKeymap(config *Config) []string {
240	var validationErrors []string
241	switch config.Workspace.Keymap {
242	case KeymapZellij, KeymapTmux:
243	default:
244		validationErrors = append(validationErrors, fmt.Sprintf(
245			"workspace.keymap must be 'zellij' or 'tmux' (got: %s)", config.Workspace.Keymap))
246	}
247	validationErrors = append(validationErrors, validateModalModeActions(
248		"prefix_mode", config.Workspace.PrefixMode.TimeoutMilliseconds, config.Workspace.PrefixMode.Actions)...)
249	return validationErrors
250}
251
252func validatePaneMode(config *Config) []string {
253	return validateModalModeActions("pane_mode", config.Workspace.PaneMode.TimeoutMilliseconds, config.Workspace.PaneMode.Actions)
254}
255
256func validateTabBar(config *Config) []string {
257	switch config.Workspace.TabBarPosition {
258	case "top", "bottom":
259		return nil
260	default:
261		return []string{fmt.Sprintf("workspace.tab_bar_position must be 'top' or 'bottom' (got: %s)", config.Workspace.TabBarPosition)}
262	}
263}
264
265func validateTabMode(config *Config) []string {
266	return validateModalModeActions("tab_mode", config.Workspace.TabMode.TimeoutMilliseconds, config.Workspace.TabMode.Actions)
267}
268
269func validateVimMode(config *Config) []string {
270	var validationErrors []string
271	if config.Workspace.VimMode.SequenceTimeoutMilliseconds < 0 {
272		validationErrors = append(validationErrors, "workspace.vim_mode.sequence_timeout_ms must be non-negative")
273	}
274	validationErrors = append(validationErrors, validateModalModeActionsWithKeyCanon(
275		"vim_mode",
276		config.Workspace.VimMode.TimeoutMilliseconds,
277		config.Workspace.VimMode.Actions,
278		canonicalizeVimModeSequenceKey,
279	)...)
280	return validationErrors
281}
282
283// validateModalModeActions shared timeout/empty-actions/duplicate-key checks for
284// pane, tab, and page modal modes. Messages stay path-exact via modePath.
285func validateModalModeActions(modePath string, timeoutMS int, actions map[string]ActionBinding) []string {
286	return validateModalModeActionsWithKeyCanon(modePath, timeoutMS, actions, nil)
287}
288
289func validateModalModeActionsWithKeyCanon(
290	modePath string,
291	timeoutMS int,
292	actions map[string]ActionBinding,
293	keyCanon func(string) (string, error),
294) []string {
295	var validationErrors []string
296	if timeoutMS < 0 {
297		validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.timeout_ms must be non-negative", modePath))
298	}
299	if len(actions) == 0 {
300		validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath))
301	}
302
303	// Single-chord maps accept several spellings of one key under the same
304	// action (for example "M" and "shift+m"); that is redundant, not a conflict.
305	// Vim sequences keep reporting any repeated sequence.
306	allowSameActionAliases := keyCanon == nil
307	if keyCanon == nil {
308		keyCanon = canonicalizeChordKey
309	}
310
311	actionNames := make([]string, 0, len(actions))
312	for action := range actions {
313		actionNames = append(actionNames, action)
314	}
315	sort.Strings(actionNames)
316
317	seenSequence := make(map[string]string)
318	for _, action := range actionNames {
319		binding := actions[action]
320		// An action with no keys is disabled; it binds nothing and is valid.
321		keys := append([]string(nil), binding.Keys...)
322		sort.Strings(keys)
323		for _, key := range keys {
324			canonical, err := keyCanon(key)
325			if err != nil {
326				validationErrors = append(validationErrors, fmt.Sprintf(
327					"workspace.%s.actions.%s has invalid key binding '%s': %v",
328					modePath,
329					action,
330					key,
331					err,
332				))
333				continue
334			}
335			if modePath == "vim_mode" {
336				validationErrors = append(validationErrors, validateVimBindingKey(modePath, action, canonical)...)
337			}
338			existingAction, exists := seenSequence[canonical]
339			if exists && (!allowSameActionAliases || existingAction != action) {
340				validationErrors = append(validationErrors, fmt.Sprintf(
341					"duplicate key binding '%s' found in %s actions '%s' and '%s'",
342					canonical,
343					modePath,
344					existingAction,
345					action,
346				))
347			}
348			seenSequence[canonical] = action
349		}
350	}
351	return validationErrors
352}
353
354// validateVimBindingKey checks the Vim-specific rules of one canonical binding.
355func validateVimBindingKey(modePath, action, canonical string) []string {
356	sequence, err := vimkeys.ParseBinding(canonical)
357	if err != nil {
358		return nil
359	}
360	var validationErrors []string
361	if len(sequence) > 0 && isVimCountStartKey(sequence[0]) {
362		validationErrors = append(validationErrors, fmt.Sprintf(
363			"workspace.%s.actions.%s cannot start with a count digit (1-9): '%s'",
364			modePath,
365			action,
366			canonical,
367		))
368	}
369	legacyExit := action == "confirm" || action == "cancel"
370	if legacyExit {
371		return validationErrors
372	}
373	for _, key := range sequence {
374		if key.Mods == 0 && (key.Sym == "CR" || key.Sym == "Esc") {
375			validationErrors = append(validationErrors, fmt.Sprintf(
376				"workspace.%s.actions.%s cannot bind Enter or Escape outside the modal fallback actions",
377				modePath,
378				action,
379			))
380			break
381		}
382	}
383	return validationErrors
384}
385
386// canonicalizeChordKey canonicalizes a single-chord key string the way the
387// input layer resolves it (aliases, modifier spelling, letter case), so
388// differently spelled bindings of one key compare equal. A string that is not a
389// valid chord is returned unchanged: the input layer rejects it with a warning
390// when it is loaded, and it cannot collide with a valid binding.
391func canonicalizeChordKey(key string) (string, error) {
392	if canonical, ok := keychord.Canonicalize(key); ok {
393		return canonical, nil
394	}
395	return key, nil
396}
397
398func isVimCountStartKey(key vimkeys.Key) bool {
399	return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'
400}
401
402func canonicalizeVimModeBinding(key string) string {
403	switch strings.ToLower(strings.TrimSpace(key)) {
404	case "enter":
405		return "<Return>"
406	case "escape":
407		return "<Escape>"
408	default:
409		return key
410	}
411}
412
413func canonicalizeVimModeSequenceKey(key string) (string, error) {
414	seq, err := vimkeys.ParseBinding(canonicalizeVimModeBinding(key))
415	if err != nil {
416		return "", err
417	}
418	return seq.String(), nil
419}
420
421func validateFloatingPane(config *Config) []string {
422	var validationErrors []string
423	seenProfileKeys := make(map[string]string)
424
425	if config.Workspace.FloatingPane.WidthPct <= 0 || config.Workspace.FloatingPane.WidthPct > 1 {
426		validationErrors = append(validationErrors, "workspace.floating_pane.width_pct must be in (0,1]")
427	}
428
429	if config.Workspace.FloatingPane.HeightPct <= 0 || config.Workspace.FloatingPane.HeightPct > 1 {
430		validationErrors = append(validationErrors, "workspace.floating_pane.height_pct must be in (0,1]")
431	}
432
433	profileNames := make([]string, 0, len(config.Workspace.FloatingPane.Profiles))
434	for profileName := range config.Workspace.FloatingPane.Profiles {
435		profileNames = append(profileNames, profileName)
436	}
437	sort.Strings(profileNames)
438
439	for _, profileName := range profileNames {
440		profile := config.Workspace.FloatingPane.Profiles[profileName]
441		if strings.TrimSpace(profile.URL) == "" {
442			validationErrors = append(validationErrors, fmt.Sprintf(
443				"workspace.floating_pane.profiles.%s.url is required",
444				profileName,
445			))
446		} else {
447			validationErrors = append(validationErrors, validateWorkspaceURLValue(
448				fmt.Sprintf("workspace.floating_pane.profiles.%s.url", profileName),
449				profile.URL,
450			)...)
451		}
452		if len(profile.Keys) == 0 {
453			validationErrors = append(validationErrors, fmt.Sprintf(
454				"workspace.floating_pane.profiles.%s.keys must have at least one entry",
455				profileName,
456			))
457		}
458
459		for _, key := range profile.Keys {
460			normalizedKey := normalizeBindingKeyString(key)
461			if normalizedKey == "" {
462				validationErrors = append(validationErrors, fmt.Sprintf(
463					"workspace.floating_pane.profiles.%s.keys contains empty or whitespace-only key binding %q",
464					profileName,
465					key,
466				))
467				continue
468			}
469
470			if existingProfile, exists := seenProfileKeys[normalizedKey]; exists {
471				validationErrors = append(validationErrors, fmt.Sprintf(
472					"workspace.floating_pane.profiles.%s.keys contains duplicate key binding %q already used by profile %q",
473					profileName,
474					key,
475					existingProfile,
476				))
477				continue
478			}
479
480			seenProfileKeys[normalizedKey] = profileName
481		}
482	}
483
484	return validationErrors
485}
486
487func normalizeBindingKeyString(key string) string {
488	normalized := strings.ToLower(strings.TrimSpace(key))
489	if normalized == "" {
490		return ""
491	}
492	return strings.Join(strings.Fields(normalized), "")
493}
494
495func validateLogging(config *Config) []string {
496	var validationErrors []string
497	if config.Logging.MaxAge < 0 {
498		validationErrors = append(validationErrors, "logging.max_age must be non-negative")
499	}
500	if config.Logging.MaxFiles < 0 {
501		validationErrors = append(validationErrors, "logging.max_files must be non-negative")
502	}
503	switch config.Logging.Level {
504	case "trace", "debug", "info", "warn", "error", "fatal", "":
505	default:
506		validationErrors = append(validationErrors, fmt.Sprintf(
507			"logging.level must be one of: trace, debug, info, warn, error, fatal (got: %s)",
508			config.Logging.Level,
509		))
510	}
511	switch config.Logging.Format {
512	case "text", "json", "console", "":
513	default:
514		validationErrors = append(validationErrors, fmt.Sprintf(
515			"logging.format must be one of: text, json, console (got: %s)",
516			config.Logging.Format,
517		))
518	}
519	return validationErrors
520}
521
522func validateCEF(config *Config) []string {
523	var validationErrors []string
524
525	switch config.Engine.CEF.CEFRenderStack() {
526	case CEFRenderStackVulkan, CEFRenderStackEGL:
527	default:
528		validationErrors = append(validationErrors, fmt.Sprintf(
529			"engine.cef.render_stack must be one of: vulkan, egl (got: %s)",
530			config.Engine.CEF.RenderStack,
531		))
532	}
533
534	switch config.Engine.CEF.LogSeverity {
535	case 0, 1, 2, 3, 4, cefLogSeverityDisabled:
536	default:
537		validationErrors = append(validationErrors, fmt.Sprintf(
538			"engine.cef.log_severity must be one of: 0, 1, 2, 3, 4, %d (got: %d)",
539			cefLogSeverityDisabled,
540			config.Engine.CEF.LogSeverity,
541		))
542	}
543
544	if config.Engine.CEF.WindowlessFrameRate < 0 {
545		validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate must be >= 0")
546	}
547	if config.Engine.CEF.WindowlessFrameRateMax < 0 {
548		validationErrors = append(validationErrors, "engine.cef.windowless_frame_rate_max must be >= 0")
549	}
550	if config.Engine.CEF.IdleRuntimeTimeoutMs < 0 || config.Engine.CEF.IdleRuntimeTimeoutMs > 300000 {
551		validationErrors = append(validationErrors, "engine.cef.idle_runtime_timeout_ms must be within 0..300000")
552	}
553
554	input := config.Engine.CEF.Input
555	if invalidPositiveFloat(input.ScrollWheelMultiplier) {
556		validationErrors = append(validationErrors, "engine.cef.input.scroll_wheel_multiplier must be > 0")
557	}
558	if invalidPositiveFloat(input.ScrollPreciseMultiplier) {
559		validationErrors = append(validationErrors, "engine.cef.input.scroll_precise_multiplier must be > 0")
560	}
561	if invalidPositiveFloat(input.ScrollHorizontalMultiplier) {
562		validationErrors = append(validationErrors, "engine.cef.input.scroll_horizontal_multiplier must be > 0")
563	}
564	if invalidPositiveFloat(input.ScrollVerticalMultiplier) {
565		validationErrors = append(validationErrors, "engine.cef.input.scroll_vertical_multiplier must be > 0")
566	}
567	if input.ScrollMaxDelta < 0 {
568		validationErrors = append(validationErrors, "engine.cef.input.scroll_max_delta must be >= 0")
569	}
570	if invalidPositiveFloat(input.TouchpadNavigationMinDelta) {
571		validationErrors = append(validationErrors, "engine.cef.input.touchpad_navigation_min_delta must be > 0")
572	}
573	if invalidPositiveFloat(input.TouchpadNavigationMaxVerticalRatio) {
574		validationErrors = append(validationErrors, "engine.cef.input.touchpad_navigation_max_vertical_ratio must be > 0")
575	}
576
577	return validationErrors
578}
579
580func invalidPositiveFloat(value float64) bool {
581	return value <= 0 || math.IsNaN(value) || math.IsInf(value, 0)
582}
583
584func validateWorkspaceNewPaneURL(config *Config) []string {
585	var validationErrors []string
586
587	if config.Workspace.NewPaneURL == "" {
588		validationErrors = append(validationErrors, "workspace.new_pane_url cannot be empty")
589		return validationErrors
590	}
591
592	validationErrors = append(validationErrors, validateWorkspaceURLValue(
593		"workspace.new_pane_url",
594		config.Workspace.NewPaneURL,
595	)...)
596
597	return validationErrors
598}
599
600func validateWorkspaceURLValue(fieldPath, value string) []string {
601	trimmed := strings.TrimSpace(value)
602	if isRelativeWorkspacePath(trimmed) {
603		return []string{fmt.Sprintf(
604			"%s relative local path cannot be resolved without config file context (got: %s)",
605			fieldPath,
606			value,
607		)}
608	}
609	if isLocalPathLikeWorkspaceURL(trimmed) {
610		if isExistingWorkspacePath(trimmed) {
611			return nil
612		}
613		return []string{fmt.Sprintf("%s local path must exist (got: %s)", fieldPath, value)}
614	}
615	candidate := trimmed
616	parsed, err := url.Parse(candidate)
617	if err != nil || parsed.Scheme == "" {
618		candidate = domainurl.Normalize(trimmed)
619		parsed, err = url.Parse(candidate)
620	}
621	if err != nil {
622		return []string{fmt.Sprintf("%s must be a valid URL (got: %s)", fieldPath, value)}
623	}
624
625	switch parsed.Scheme {
626	case "http", "https", "dumb", "file", "about":
627		return nil
628	default:
629		return []string{fmt.Sprintf(
630			"%s must use one of: http, https, dumb, file, about (got: %s)",
631			fieldPath,
632			parsed.Scheme,
633		)}
634	}
635}
636
637func isLocalPathLikeWorkspaceURL(value string) bool {
638	return strings.HasPrefix(value, "/") ||
639		strings.HasPrefix(value, "./") ||
640		strings.HasPrefix(value, "../") ||
641		strings.HasPrefix(value, "~/")
642}
643
644func isExistingWorkspacePath(value string) bool {
645	if value == "" || strings.Contains(value, "://") {
646		return false
647	}
648	candidate := value
649	if strings.HasPrefix(candidate, "~/") {
650		homeDir, err := os.UserHomeDir()
651		if err != nil || homeDir == "" {
652			return false
653		}
654		candidate = filepath.Join(homeDir, strings.TrimPrefix(candidate, "~/"))
655	}
656	absPath, err := filepath.Abs(candidate)
657	if err != nil {
658		return false
659	}
660	_, err = os.Stat(absPath)
661	return err == nil
662}
663
664func isRelativeWorkspacePath(value string) bool {
665	if value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "~/") ||
666		strings.Contains(value, "://") {
667		return false
668	}
669	if strings.HasPrefix(value, "./") || strings.HasPrefix(value, "../") ||
670		looksLikeBareRelativeWorkspacePath(value) {
671		return true
672	}
673	return !domainurl.LooksLikeURL(value) && !strings.Contains(value, ":")
674}
675
676func looksLikeBareRelativeWorkspacePath(value string) bool {
677	if strings.ContainsAny(value, `/\`) {
678		firstSegment, _, _ := strings.Cut(strings.ReplaceAll(value, `\`, "/"), "/")
679		return firstSegment == "" || (!strings.Contains(firstSegment, ".") &&
680			firstSegment != "localhost" && !strings.HasPrefix(firstSegment, "localhost:"))
681	}
682
683	switch strings.ToLower(filepath.Ext(value)) {
684	case ".css", ".gif", ".htm", ".html", ".jpeg", ".jpg", ".js", ".json",
685		".md", ".pdf", ".png", ".svg", ".toml", ".txt", ".webp", ".xml",
686		".yaml", ".yml":
687		return true
688	default:
689		return false
690	}
691}
692
693func validateOmnibox(config *Config) []string {
694	var validationErrors []string
695	switch config.Omnibox.Style {
696	case "multiplexer", "command", "minimal":
697	default:
698		validationErrors = append(validationErrors, fmt.Sprintf(
699			"omnibox.style must be one of: multiplexer, command, minimal (got: %s)",
700			config.Omnibox.Style,
701		))
702	}
703	if config.Omnibox.MaxHistoryDays < 0 {
704		validationErrors = append(validationErrors, "omnibox.max_history_days must be non-negative")
705	}
706	switch config.Omnibox.InitialBehavior {
707	case OmniboxInitialBehaviorRecent, OmniboxInitialBehaviorMostVisited, OmniboxInitialBehaviorNone:
708	default:
709		validationErrors = append(validationErrors, fmt.Sprintf(
710			"omnibox.initial_behavior must be one of: %s, %s, %s (got: %s)",
711			OmniboxInitialBehaviorRecent,
712			OmniboxInitialBehaviorMostVisited,
713			OmniboxInitialBehaviorNone,
714			config.Omnibox.InitialBehavior,
715		))
716	}
717	return validationErrors
718}
719
720func validateEngine(config *Config) []string {
721	switch config.Engine.Type {
722	case EngineTypeCEF, EngineTypeWebKit:
723		return nil
724	default:
725		return []string{fmt.Sprintf(
726			"engine.type must be one of: cef, webkit (got: %s)",
727			config.Engine.Type,
728		)}
729	}
730}
731
732func validateRendering(config *Config) []string {
733	var validationErrors []string
734
735	switch config.Engine.WebKit.GSKRenderer {
736	case GSKRendererAuto, GSKRendererOpenGL, GSKRendererVulkan, GSKRendererCairo, "":
737	default:
738		validationErrors = append(
739			validationErrors,
740			fmt.Sprintf(
741				"engine.webkit.gsk_renderer must be one of: auto, opengl, vulkan, cairo (got: %s)",
742				config.Engine.WebKit.GSKRenderer,
743			),
744		)
745	}
746
747	switch config.Engine.WebKit.GLRenderingMode {
748	case GLRenderingModeAuto, GLRenderingModeGLES2, GLRenderingModeGL3, GLRenderingModeNone, "":
749	default:
750		validationErrors = append(
751			validationErrors,
752			fmt.Sprintf(
753				"engine.webkit.gl_rendering_mode must be one of: auto, gles2, gl3, none (got: %s)",
754				config.Engine.WebKit.GLRenderingMode,
755			),
756		)
757	}
758
759	if config.Engine.WebKit.GStreamerDebugLevel < 0 || config.Engine.WebKit.GStreamerDebugLevel > 5 {
760		validationErrors = append(validationErrors, fmt.Sprintf(
761			"engine.webkit.gstreamer_debug_level must be between 0 and 5 (got: %d)",
762			config.Engine.WebKit.GStreamerDebugLevel,
763		))
764	}
765
766	if config.Engine.WebKit.ForceCompositingMode && config.Engine.WebKit.DisableCompositingMode {
767		validationErrors = append(validationErrors,
768			"engine.webkit.force_compositing_mode and engine.webkit.disable_compositing_mode cannot both be true",
769		)
770	}
771
772	return validationErrors
773}
774
775func validatePrivacy(config *Config) []string {
776	switch config.Engine.CookiePolicy {
777	case CookiePolicyAlways, CookiePolicyNoThirdParty, CookiePolicyNever, "":
778		return nil
779	default:
780		return []string{fmt.Sprintf(
781			"engine.cookie_policy must be one of: always, no_third_party, never (got: %s)",
782			config.Engine.CookiePolicy,
783		)}
784	}
785}
786
787func validateColorScheme(config *Config) []string {
788	switch config.Appearance.ColorScheme {
789	case ThemePreferDark, ThemePreferLight, ThemeDefault, "":
790		return nil
791	default:
792		return []string{fmt.Sprintf(
793			"appearance.color_scheme must be one of: prefer-dark, prefer-light, default (got: %s)",
794			config.Appearance.ColorScheme,
795		)}
796	}
797}
798
799func validateSession(config *Config) []string {
800	var validationErrors []string
801	if config.Session.MaxExitedSessions < 0 {
802		validationErrors = append(validationErrors, "session.max_exited_sessions must be non-negative")
803	}
804	if config.Session.MaxExitedSessionAgeDays < 0 {
805		validationErrors = append(validationErrors, "session.max_exited_session_age_days must be non-negative")
806	}
807	if config.Session.SnapshotIntervalMs < 0 {
808		validationErrors = append(validationErrors, "session.snapshot_interval_ms must be non-negative")
809	}
810	return validationErrors
811}
812
813func validatePerformanceProfile(config *Config) []string {
814	var validationErrors []string
815
816	// Performance profiles and webkit-specific tuning only apply to the webkit engine.
817	engineType := config.Engine.ResolveEngineType()
818	if engineType != "webkit" {
819		return nil
820	}
821
822	// Validate profile name
823	if !IsValidPerformanceProfile(config.Engine.Profile) {
824		validationErrors = append(validationErrors, fmt.Sprintf(
825			"engine.profile must be one of: default, lite, balanced, max, custom (got: %s)",
826			config.Engine.Profile,
827		))
828	}
829
830	// When profile is not "custom", warn if individual fields are set
831	// (they will be ignored in favor of profile-computed values)
832	if config.Engine.Profile != ProfileCustom && config.Engine.Profile != "" {
833		perfCfg := PerformanceConfigFromEngine(&config.Engine)
834		if HasCustomPerformanceFields(&perfCfg) {
835			validationErrors = append(validationErrors,
836				"engine performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+
837					"set profile = \"custom\" to use individual field values",
838			)
839		}
840	}
841
842	// Validate memory pressure threshold ordering: WebKit requires conservative < strict
843	webCons := config.Engine.WebKit.WebProcessMemoryConservativeThreshold
844	webStrict := config.Engine.WebKit.WebProcessMemoryStrictThreshold
845	if webCons > 0 && webStrict > 0 && webCons >= webStrict {
846		validationErrors = append(validationErrors,
847			"engine.webkit.web_process_memory_conservative_threshold must be less than "+
848				"web_process_memory_strict_threshold when both are set",
849		)
850	}
851
852	netCons := config.Engine.WebKit.NetworkProcessMemoryConservativeThreshold
853	netStrict := config.Engine.WebKit.NetworkProcessMemoryStrictThreshold
854	if netCons > 0 && netStrict > 0 && netCons >= netStrict {
855		validationErrors = append(validationErrors,
856			"engine.webkit.network_process_memory_conservative_threshold must be less than "+
857				"network_process_memory_strict_threshold when both are set",
858		)
859	}
860
861	return validationErrors
862}

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