config_actions.go RawHistoryFolder

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

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