v0.30.0 eb3068f

config_actions.go RawHistoryFolder

1package systemviews
2
3import (
4	"context"
5	"fmt"
6	"math"
7	"net/url"
8	"strconv"
9	"strings"
10	"sync/atomic"
11
12	"github.com/bnema/dumber/internal/application/dto"
13	"github.com/bnema/dumber/internal/application/port"
14)
15
16const (
17	configActionSaveAppearance          = "config.appearance.save"
18	configActionResetAppearance         = "config.appearance.reset"
19	configActionSaveSearch              = "config.search.save"
20	configActionCreateSearchShortcut    = "config.searchShortcut.create"
21	configActionUpdateSearchShortcut    = "config.searchShortcut.update"
22	configActionDeleteSearchShortcut    = "config.searchShortcut.delete"
23	configActionSavePerformance         = "config.performance.save"
24	configActionSetKeybinding           = "config.keybinding.set"
25	configActionResetKeybinding         = "config.keybinding.reset"
26	configActionResetAllKeybindings     = "config.keybinding.resetAll"
27	configRequestIDPrefix               = "systemviews-config"
28	balancedPerformanceProfile          = "balanced"
29	customPerformanceProfile            = "custom"
30	httpScheme                          = "http"
31	maxCustomPerformanceThreads         = 8
32	maxCustomPerformanceWebMemoryMB     = 16384
33	maxCustomPerformanceNetworkMemoryMB = 4096
34	maxCustomPerformancePrewarm         = 20
35)
36
37var configRequestCounter atomic.Uint64
38
39func nextConfigRequestID() string {
40	return fmt.Sprintf("%s-%d", configRequestIDPrefix, configRequestCounter.Add(1))
41}
42
43func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error {
44	if a.deps.Config == nil {
45		return fmt.Errorf("config service not configured")
46	}
47
48	switch event.Action {
49	case configActionSaveAppearance:
50		return a.saveAppearanceConfig(ctx, event.Data)
51	case configActionResetAppearance:
52		return a.resetAppearanceConfig(ctx)
53	case configActionSaveSearch:
54		return a.saveSearchConfig(ctx, event.Data)
55	case configActionCreateSearchShortcut:
56		return a.createSearchShortcut(ctx, event.Data)
57	case configActionUpdateSearchShortcut:
58		return a.updateSearchShortcut(ctx, event.Data)
59	case configActionDeleteSearchShortcut:
60		return a.deleteSearchShortcut(ctx, event.Data)
61	case configActionSavePerformance:
62		return a.savePerformanceConfig(ctx, event.Data)
63	case configActionSetKeybinding:
64		return a.setConfigKeybinding(ctx, event.Data)
65	case configActionResetKeybinding:
66		return a.resetConfigKeybinding(ctx, event.Data)
67	case configActionResetAllKeybindings:
68		if err := a.deps.Config.ResetAllKeybindings(ctx); err != nil {
69			return err
70		}
71		a.configNotice = "Reset all keybindings to defaults"
72		return nil
73	default:
74		return fmt.Errorf("unknown config action: %q", event.Action)
75	}
76}
77
78func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error {
79	cfg, err := a.editableConfig(ctx)
80	if err != nil {
81		return err
82	}
83
84	fontSize, err := parseConfigInt(data["default_font_size"], "default font size")
85	if err != nil {
86		return err
87	}
88	uiScale, err := parseConfigFloat(data["default_ui_scale"], "UI scale")
89	if err != nil {
90		return err
91	}
92	if fontSize <= 0 {
93		return fmt.Errorf("default font size must be positive")
94	}
95	if uiScale <= 0 || math.IsNaN(uiScale) || math.IsInf(uiScale, 0) {
96		return fmt.Errorf("UI scale must be a finite positive number")
97	}
98
99	cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"])
100	cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"])
101	cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"])
102	cfg.Appearance.GtkFont = strings.TrimSpace(data["gtk_font"])
103	cfg.Appearance.DefaultFontSize = fontSize
104	cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])
105	cfg.Appearance.ExternalTheme.Enabled = configCheckboxEnabled(data["external_theme_enabled"])
106	cfg.Appearance.ExternalTheme.Provider = strings.TrimSpace(data["external_theme_provider"])
107	cfg.Appearance.ExternalTheme.Format = strings.TrimSpace(data["external_theme_format"])
108	cfg.Appearance.ExternalTheme.Path = strings.TrimSpace(data["external_theme_path"])
109	cfg.Appearance.LightPalette = paletteFromForm(data, "light")
110	cfg.Appearance.DarkPalette = paletteFromForm(data, "dark")
111	cfg.DefaultUIScale = uiScale
112
113	if err := a.saveEditableConfig(ctx, cfg); err != nil {
114		return err
115	}
116	a.configNotice = "Saved appearance settings"
117	return nil
118}
119
120func configCheckboxEnabled(value string) bool {
121	switch strings.ToLower(strings.TrimSpace(value)) {
122	case "on", "true", "1", "yes":
123		return true
124	default:
125		return false
126	}
127}
128
129func (a *App) resetAppearanceConfig(ctx context.Context) error {
130	cfg, err := a.editableConfig(ctx)
131	if err != nil {
132		return err
133	}
134	defaults, err := a.deps.Config.Default(ctx)
135	if err != nil {
136		return err
137	}
138	cfg.Appearance = defaults.Appearance
139	cfg.DefaultUIScale = defaults.DefaultUIScale
140	if err := a.saveEditableConfig(ctx, cfg); err != nil {
141		return err
142	}
143	a.configNotice = "Reset appearance settings to defaults"
144	return nil
145}
146
147func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) error {
148	cfg, err := a.editableConfig(ctx)
149	if err != nil {
150		return err
151	}
152	defaultSearchEngine, err := requireSearchURLTemplate(data["default_search_engine"], "default search engine")
153	if err != nil {
154		return err
155	}
156	cfg.DefaultSearchEngine = defaultSearchEngine
157	if err := a.saveEditableConfig(ctx, cfg); err != nil {
158		return err
159	}
160	a.configNotice = "Saved default search engine"
161	return nil
162}
163
164func (a *App) createSearchShortcut(ctx context.Context, data map[string]string) error {
165	cfg, err := a.editableConfig(ctx)
166	if err != nil {
167		return err
168	}
169	key, shortcut := searchShortcutFromForm(data, "key")
170	if key == "" {
171		return fmt.Errorf("search shortcut key is required")
172	}
173	if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil {
174		return err
175	}
176	cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
177	if _, exists := cfg.SearchShortcuts[key]; exists {
178		return fmt.Errorf("search shortcut %q already exists", key)
179	}
180	cfg.SearchShortcuts[key] = shortcut
181	if err := a.saveEditableConfig(ctx, cfg); err != nil {
182		return err
183	}
184	a.configNotice = "Created search shortcut " + key
185	return nil
186}
187
188func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string) error {
189	cfg, err := a.editableConfig(ctx)
190	if err != nil {
191		return err
192	}
193	oldKey := strings.TrimSpace(data["key"])
194	if oldKey == "" {
195		return fmt.Errorf("search shortcut key is required")
196	}
197	newKey, shortcut := searchShortcutFromForm(data, "new_key")
198	if newKey == "" {
199		return fmt.Errorf("new search shortcut key is required")
200	}
201	if shortcut.URL, err = requireSearchURLTemplate(shortcut.URL, "search shortcut URL"); err != nil {
202		return err
203	}
204	cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
205	if _, exists := cfg.SearchShortcuts[oldKey]; !exists {
206		return fmt.Errorf("search shortcut %q not found", oldKey)
207	}
208	if newKey != oldKey {
209		if _, exists := cfg.SearchShortcuts[newKey]; exists {
210			return fmt.Errorf("search shortcut %q already exists", newKey)
211		}
212		delete(cfg.SearchShortcuts, oldKey)
213	}
214	cfg.SearchShortcuts[newKey] = shortcut
215	if err := a.saveEditableConfig(ctx, cfg); err != nil {
216		return err
217	}
218	a.configNotice = "Saved search shortcut " + newKey
219	return nil
220}
221
222func (a *App) deleteSearchShortcut(ctx context.Context, data map[string]string) error {
223	cfg, err := a.editableConfig(ctx)
224	if err != nil {
225		return err
226	}
227	key := strings.TrimSpace(data["key"])
228	if key == "" {
229		return fmt.Errorf("search shortcut key is required")
230	}
231	cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
232	if _, exists := cfg.SearchShortcuts[key]; !exists {
233		return fmt.Errorf("search shortcut %q not found", key)
234	}
235	delete(cfg.SearchShortcuts, key)
236	if err := a.saveEditableConfig(ctx, cfg); err != nil {
237		return err
238	}
239	a.configNotice = "Deleted search shortcut " + key
240	return nil
241}
242
243func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) error {
244	cfg, err := a.editableConfig(ctx)
245	if err != nil {
246		return err
247	}
248	profile := strings.TrimSpace(data["profile"])
249	if !isAllowedPerformanceProfile(profile) {
250		return fmt.Errorf("unknown performance profile: %q", profile)
251	}
252	cfg.Performance.Profile = profile
253	if profile != customPerformanceProfile {
254		if saveErr := a.saveEditableConfig(ctx, cfg); saveErr != nil {
255			return saveErr
256		}
257		a.configNotice = "Saved performance settings. Restart may be required."
258		return nil
259	}
260
261	skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads")
262	if err != nil {
263		return err
264	}
265	skiaGPU, err := parseConfigInt(data["skia_gpu_threads"], "Skia GPU threads")
266	if err != nil {
267		return err
268	}
269	webMemory, err := parseConfigInt(data["web_process_memory_mb"], "web process memory")
270	if err != nil {
271		return err
272	}
273	networkMemory, err := parseConfigInt(data["network_process_memory_mb"], "network process memory")
274	if err != nil {
275		return err
276	}
277	prewarm, err := parseConfigInt(data["webview_pool_prewarm"], "WebView pool prewarm")
278	if err != nil {
279		return err
280	}
281	if err := validatePerformanceInt(skiaCPU, "Skia CPU threads", 0, maxCustomPerformanceThreads); err != nil {
282		return err
283	}
284	if err := validatePerformanceInt(skiaGPU, "Skia GPU threads", -1, maxCustomPerformanceThreads); err != nil {
285		return err
286	}
287	if err := validatePerformanceInt(webMemory, "web process memory", 0, maxCustomPerformanceWebMemoryMB); err != nil {
288		return err
289	}
290	if err := validatePerformanceInt(networkMemory, "network process memory", 0, maxCustomPerformanceNetworkMemoryMB); err != nil {
291		return err
292	}
293	if err := validatePerformanceInt(prewarm, "WebView pool prewarm", 0, maxCustomPerformancePrewarm); err != nil {
294		return err
295	}
296
297	cfg.Performance.Custom = dto.WebUICustomPerformanceConfig{
298		SkiaCPUThreads:         skiaCPU,
299		SkiaGPUThreads:         skiaGPU,
300		WebProcessMemoryMB:     webMemory,
301		NetworkProcessMemoryMB: networkMemory,
302		WebViewPoolPrewarm:     prewarm,
303	}
304	if err := a.saveEditableConfig(ctx, cfg); err != nil {
305		return err
306	}
307	a.configNotice = "Saved performance settings. Restart may be required."
308	return nil
309}
310
311func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) error {
312	mode := strings.TrimSpace(data["mode"])
313	action := strings.TrimSpace(data["action"])
314	keys := parseKeyList(data["keys"])
315	if mode == "" || action == "" {
316		return fmt.Errorf("mode and action are required")
317	}
318	if err := a.requireKeybindingTarget(ctx, mode, action); err != nil {
319		return err
320	}
321	resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{
322		RequestID: nextConfigRequestID(),
323		Mode:      mode,
324		Action:    action,
325		Keys:      keys,
326	})
327	if err != nil {
328		return err
329	}
330	if conflicts := len(resp.Conflicts); conflicts > 0 {
331		a.configNotice = fmt.Sprintf("Saved keybinding with %d conflict(s)", conflicts)
332		return nil
333	}
334	a.configNotice = "Saved keybinding " + action
335	return nil
336}
337
338func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) error {
339	mode := strings.TrimSpace(data["mode"])
340	action := strings.TrimSpace(data["action"])
341	if mode == "" || action == "" {
342		return fmt.Errorf("mode and action are required")
343	}
344	if err := a.requireKeybindingTarget(ctx, mode, action); err != nil {
345		return err
346	}
347	if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{
348		RequestID: nextConfigRequestID(),
349		Mode:      mode,
350		Action:    action,
351	}); err != nil {
352		return err
353	}
354	a.configNotice = "Reset keybinding " + action
355	return nil
356}
357
358func (a *App) editableConfig(ctx context.Context) (dto.WebUIConfig, error) {
359	cfg, err := a.deps.Config.Current(ctx)
360	if err != nil {
361		return dto.WebUIConfig{}, err
362	}
363	a.config = &cfg
364	return webUIConfigFromPayload(cfg), nil
365}
366
367func (a *App) requireKeybindingTarget(ctx context.Context, mode, action string) error {
368	keybindings, err := a.deps.Config.GetKeybindings(ctx)
369	if err != nil {
370		return err
371	}
372	a.keybindings = keybindings
373	groups := configKeybindingGroups(keybindings)
374	for _, group := range groups {
375		if group.Mode != mode {
376			continue
377		}
378		for _, binding := range group.Bindings {
379			if binding.Action == action {
380				return nil
381			}
382		}
383	}
384	return fmt.Errorf("keybinding %s/%s not found", mode, action)
385}
386
387func (a *App) saveEditableConfig(ctx context.Context, cfg dto.WebUIConfig) error {
388	if err := a.deps.Config.Save(ctx, cfg); err != nil {
389		return err
390	}
391	a.config = nil
392	return nil
393}
394
395func webUIConfigFromPayload(payload dto.SystemviewConfigPayload) dto.WebUIConfig {
396	return dto.WebUIConfig{
397		Appearance:          payload.Appearance,
398		DefaultUIScale:      payload.DefaultUIScale,
399		DefaultSearchEngine: payload.DefaultSearchEngine,
400		SearchShortcuts:     cloneSearchShortcuts(payload.SearchShortcuts),
401		Performance: dto.WebUIPerformanceConfig{
402			Profile: payload.Performance.Profile,
403			Custom: dto.WebUICustomPerformanceConfig{
404				SkiaCPUThreads:         payload.Performance.Custom.SkiaCPUThreads,
405				SkiaGPUThreads:         payload.Performance.Custom.SkiaGPUThreads,
406				WebProcessMemoryMB:     payload.Performance.Custom.WebProcessMemoryMB,
407				NetworkProcessMemoryMB: payload.Performance.Custom.NetworkProcessMemoryMB,
408				WebViewPoolPrewarm:     payload.Performance.Custom.WebViewPoolPrewarm,
409			},
410		},
411	}
412}
413
414func cloneSearchShortcuts(shortcuts map[string]dto.SearchShortcut) map[string]dto.SearchShortcut {
415	clone := make(map[string]dto.SearchShortcut, len(shortcuts))
416	for key, shortcut := range shortcuts {
417		clone[key] = shortcut
418	}
419	return clone
420}
421
422func paletteFromForm(data map[string]string, prefix string) dto.ColorPalette {
423	color := func(field string) string {
424		return strings.TrimSpace(data[prefix+"_"+field])
425	}
426
427	return dto.ColorPalette{
428		Background:     color("background"),
429		Surface:        color("surface"),
430		SurfaceVariant: color("surface_variant"),
431		Text:           color("text"),
432		Muted:          color("muted"),
433		Accent:         color("accent"),
434		Border:         color("border"),
435	}
436}
437
438func searchShortcutFromForm(data map[string]string, keyField string) (string, dto.SearchShortcut) {
439	return strings.TrimSpace(data[keyField]), dto.SearchShortcut{
440		URL:         strings.TrimSpace(data["url"]),
441		Description: strings.TrimSpace(data["description"]),
442	}
443}
444
445func requireSearchURLTemplate(raw, label string) (string, error) {
446	value := strings.TrimSpace(raw)
447	if value == "" {
448		return "", fmt.Errorf("%s is required", label)
449	}
450	if !strings.Contains(value, "%s") {
451		return "", fmt.Errorf("%s must include %%s placeholder", label)
452	}
453	parsed, err := url.Parse(strings.Replace(value, "%s", "example", 1))
454	if err != nil || parsed.Host == "" || (parsed.Scheme != httpScheme && parsed.Scheme != "https") {
455		return "", fmt.Errorf("%s must be an absolute http(s) URL", label)
456	}
457	return value, nil
458}
459
460func parseConfigInt(raw, label string) (int, error) {
461	value, err := strconv.Atoi(strings.TrimSpace(raw))
462	if err != nil {
463		return 0, fmt.Errorf("invalid %s", label)
464	}
465	return value, nil
466}
467
468func parseConfigFloat(raw, label string) (float64, error) {
469	value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
470	if err != nil {
471		return 0, fmt.Errorf("invalid %s", label)
472	}
473	return value, nil
474}
475
476func isAllowedPerformanceProfile(profile string) bool {
477	switch profile {
478	case "default", balancedPerformanceProfile, "lite", "max", customPerformanceProfile:
479		return true
480	default:
481		return false
482	}
483}
484
485func validatePerformanceInt(value int, label string, minValue, maxValue int) error {
486	if value < minValue || value > maxValue {
487		return fmt.Errorf("%s must be between %d and %d", label, minValue, maxValue)
488	}
489	return nil
490}
491
492func parseKeyList(raw string) []string {
493	raw = strings.TrimSpace(raw)
494	if raw == "" {
495		return nil
496	}
497	raw = strings.ReplaceAll(raw, ",", " ")
498	parts := strings.Fields(raw)
499	keys := make([]string, 0, len(parts))
500	for _, part := range parts {
501		key := strings.TrimSpace(part)
502		if key != "" {
503			keys = append(keys, key)
504		}
505	}
506	return keys
507}

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