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

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