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

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