v0.22.0 a85da87

validation.go RawHistoryFolder

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

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