detached 9da5cc3

feat(systemviews): add config editing controls

commit
9da5cc35e9393221aa73b3271ec292afe16b890b
author
brice · · 2026-04-24 03:02 UTC
parent
18449b5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
8 files · +1790 -106
M assets/systemviews/systemviews.css +37 -3
@@ -411,7 +411,8 @@ body {
411411 }
412412
413413 .sv-card-form label,
414-.sv-inline-form label {
414+.sv-inline-form label,
415+.sv-fieldset label {
415416 display: grid;
416417 gap: 0.25rem;
417418 min-width: min(14rem, 100%);
@@ -423,7 +424,9 @@ body {
423424 .sv-card-form input,
424425 .sv-card-form select,
425426 .sv-inline-form input,
426-.sv-inline-form select {
427+.sv-inline-form select,
428+.sv-fieldset input,
429+.sv-fieldset select {
427430 border: 1px solid var(--sv-border, #2a313d);
428431 border-radius: 0.55rem;
429432 padding: 0.5rem 0.6rem;
@@ -432,6 +435,35 @@ body {
432435 font: inherit;
433436 }
434437
438+.sv-config-form,
439+.sv-fieldset {
440+ display: flex;
441+ flex-wrap: wrap;
442+ gap: 0.65rem;
443+ align-items: end;
444+}
445+
446+.sv-fieldset {
447+ width: 100%;
448+ margin: 0;
449+ padding: 0.75rem;
450+ border: 1px solid var(--sv-border, #2a313d);
451+ border-radius: 0.65rem;
452+}
453+
454+.sv-fieldset legend {
455+ color: var(--sv-muted, #9aa3b2);
456+ font-weight: 700;
457+ padding: 0 0.35rem;
458+}
459+
460+.sv-keybinding-row {
461+ display: grid;
462+ grid-template-columns: minmax(0, 1fr) minmax(18rem, auto);
463+ gap: 1rem;
464+ align-items: start;
465+}
466+
435467 .sv-filter-panel {
436468 display: grid;
437469 gap: 0.6rem;
@@ -478,7 +510,9 @@ body {
478510 }
479511
480512 @media (max-width: 720px) {
481- .sv-favorite-header {
513+ .sv-favorite-header,
514+ .sv-keybinding-row {
482515 display: grid;
516+ grid-template-columns: 1fr;
483517 }
484518 }
@@ -411,7 +411,8 @@ body {
411 }411 }
412 412
413 .sv-card-form label,413 .sv-card-form label,
414-.sv-inline-form label {414+.sv-inline-form label,
415+.sv-fieldset label {
415 display: grid;416 display: grid;
416 gap: 0.25rem;417 gap: 0.25rem;
417 min-width: min(14rem, 100%);418 min-width: min(14rem, 100%);
@@ -423,7 +424,9 @@ body {
423 .sv-card-form input,424 .sv-card-form input,
424 .sv-card-form select,425 .sv-card-form select,
425 .sv-inline-form input,426 .sv-inline-form input,
426-.sv-inline-form select {427+.sv-inline-form select,
428+.sv-fieldset input,
429+.sv-fieldset select {
427 border: 1px solid var(--sv-border, #2a313d);430 border: 1px solid var(--sv-border, #2a313d);
428 border-radius: 0.55rem;431 border-radius: 0.55rem;
429 padding: 0.5rem 0.6rem;432 padding: 0.5rem 0.6rem;
@@ -432,6 +435,35 @@ body {
432 font: inherit;435 font: inherit;
433 }436 }
434 437
438+.sv-config-form,
439+.sv-fieldset {
440+ display: flex;
441+ flex-wrap: wrap;
442+ gap: 0.65rem;
443+ align-items: end;
444+}
445+
446+.sv-fieldset {
447+ width: 100%;
448+ margin: 0;
449+ padding: 0.75rem;
450+ border: 1px solid var(--sv-border, #2a313d);
451+ border-radius: 0.65rem;
452+}
453+
454+.sv-fieldset legend {
455+ color: var(--sv-muted, #9aa3b2);
456+ font-weight: 700;
457+ padding: 0 0.35rem;
458+}
459+
460+.sv-keybinding-row {
461+ display: grid;
462+ grid-template-columns: minmax(0, 1fr) minmax(18rem, auto);
463+ gap: 1rem;
464+ align-items: start;
465+}
466+
435 .sv-filter-panel {467 .sv-filter-panel {
436 display: grid;468 display: grid;
437 gap: 0.6rem;469 gap: 0.6rem;
@@ -478,7 +510,9 @@ body {
478 }510 }
479 511
480 @media (max-width: 720px) {512 @media (max-width: 720px) {
481- .sv-favorite-header {513+ .sv-favorite-header,
514+ .sv-keybinding-row {
482 display: grid;515 display: grid;
516+ grid-template-columns: 1fr;
483 }517 }
484 }518 }
M internal/ui/systemviews/app.go +10 -1
@@ -38,6 +38,8 @@ type App struct {
3838 favoritesError string
3939 config *port.SystemviewConfigPayload
4040 keybindings any
41+ configNotice string
42+ configError string
4143 renderedHTML string
4244 }
4345
@@ -261,7 +263,12 @@ func (a *App) loadConfigRoute(ctx context.Context) error {
261263 a.renderedHTML = renderAppFrame(renderedPage{
262264 route: RouteConfig,
263265 subtitle: "Browser settings",
264- body: configHTML(config, keybindings),
266+ body: configHTML(configRenderData{
267+ Config: config,
268+ Keybindings: keybindings,
269+ Notice: a.configNotice,
270+ Error: a.configError,
271+ }),
265272 }, a.shellTheme)
266273 return nil
267274 }
@@ -300,6 +307,8 @@ func (a *App) resetRouteState() {
300307 a.favoritesError = ""
301308 a.config = nil
302309 a.keybindings = nil
310+ a.configNotice = ""
311+ a.configError = ""
303312 }
304313
305314 func (a *App) CurrentRoute() Route {
@@ -38,6 +38,8 @@ type App struct {
38 favoritesError string38 favoritesError string
39 config *port.SystemviewConfigPayload39 config *port.SystemviewConfigPayload
40 keybindings any40 keybindings any
41+ configNotice string
42+ configError string
41 renderedHTML string43 renderedHTML string
42 }44 }
43 45
@@ -261,7 +263,12 @@ func (a *App) loadConfigRoute(ctx context.Context) error {
261 a.renderedHTML = renderAppFrame(renderedPage{263 a.renderedHTML = renderAppFrame(renderedPage{
262 route: RouteConfig,264 route: RouteConfig,
263 subtitle: "Browser settings",265 subtitle: "Browser settings",
264- body: configHTML(config, keybindings),266+ body: configHTML(configRenderData{
267+ Config: config,
268+ Keybindings: keybindings,
269+ Notice: a.configNotice,
270+ Error: a.configError,
271+ }),
265 }, a.shellTheme)272 }, a.shellTheme)
266 return nil273 return nil
267 }274 }
@@ -300,6 +307,8 @@ func (a *App) resetRouteState() {
300 a.favoritesError = ""307 a.favoritesError = ""
301 a.config = nil308 a.config = nil
302 a.keybindings = nil309 a.keybindings = nil
310+ a.configNotice = ""
311+ a.configError = ""
303 }312 }
304 313
305 func (a *App) CurrentRoute() Route {314 func (a *App) CurrentRoute() Route {
M internal/ui/systemviews/app_test.go +161 -5
@@ -455,12 +455,149 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
455455 func TestConfigHTMLFallsBackForMalformedKeybindings(t *testing.T) {
456456 t.Parallel()
457457
458- html := configHTML(port.SystemviewConfigPayload{}, map[string]any{"groups": "oops"})
458+ html := configHTML(configRenderData{Config: port.SystemviewConfigPayload{}, Keybindings: map[string]any{"groups": "oops"}})
459459
460460 assert.Contains(t, html, "Keybindings unavailable")
461461 assert.NotContains(t, html, "groups[0].bindings[0].action")
462462 }
463463
464+func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) {
465+ t.Parallel()
466+
467+ service := &fakeConfigService{
468+ current: testConfigPayload(),
469+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
470+ Mode: "default",
471+ DisplayName: "Default",
472+ Bindings: []port.KeybindingEntry{{
473+ Action: "toggle_history_systemview",
474+ Description: "Toggle history",
475+ Keys: []string{"ctrl+h"},
476+ DefaultKeys: []string{"ctrl+h"},
477+ }},
478+ }}},
479+ }
480+ app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
481+
482+ require.NoError(t, app.LoadInitial(context.Background()))
483+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`)
484+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`)
485+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`)
486+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`)
487+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`)
488+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.delete"`)
489+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.performance.save"`)
490+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.set"`)
491+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.reset"`)
492+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.resetAll"`)
493+ assert.Contains(t, app.renderedHTML, "may require restarting")
494+}
495+
496+func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
497+ dom := &fakeDOM{}
498+ service := &fakeConfigService{
499+ current: testConfigPayload(),
500+ defaultCfg: testDefaultConfigPayload(),
501+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
502+ Mode: "default",
503+ DisplayName: "Default",
504+ Bindings: []port.KeybindingEntry{{
505+ Action: "toggle_history_systemview",
506+ Description: "Toggle history",
507+ Keys: []string{"ctrl+h"},
508+ DefaultKeys: []string{"ctrl+h"},
509+ }},
510+ }}},
511+ setResp: port.SetKeybindingResponse{},
512+ }
513+ app := NewApp(Dependencies{DOM: dom, Config: service, LocationURI: "dumb://config"})
514+
515+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
516+ Action: configActionSaveAppearance,
517+ Data: map[string]string{
518+ "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono",
519+ "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light",
520+ "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd",
521+ "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333",
522+ },
523+ }))
524+ assert.True(t, service.calledSave)
525+ assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)
526+ assert.Equal(t, 1.25, service.savedConfig.DefaultUIScale)
527+ assert.Contains(t, dom.html, "Saved appearance settings")
528+ assert.Contains(t, dom.html, `class="sv-app sv-light"`)
529+
530+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
531+ Action: configActionCreateSearchShortcut,
532+ Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"},
533+ }))
534+ assert.Equal(t, "Google", service.savedConfig.SearchShortcuts["g"].Description)
535+ assert.Contains(t, dom.html, "Created search shortcut g")
536+
537+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
538+ Action: configActionSavePerformance,
539+ Data: map[string]string{
540+ "profile": "custom", "skia_cpu_threads": "4", "skia_gpu_threads": "2",
541+ "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4",
542+ },
543+ }))
544+ assert.Equal(t, "custom", service.savedConfig.Performance.Profile)
545+ assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB)
546+ assert.Contains(t, dom.html, "Saved performance settings")
547+
548+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
549+ Action: configActionSetKeybinding,
550+ Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"},
551+ }))
552+ assert.True(t, service.calledSet)
553+ assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys)
554+ assert.Contains(t, dom.html, "Saved keybinding toggle_history_systemview")
555+
556+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
557+ Action: configActionResetKeybinding,
558+ Data: map[string]string{"mode": "default", "action": "toggle_history_systemview"},
559+ }))
560+ assert.True(t, service.calledReset)
561+ assert.Equal(t, "toggle_history_systemview", service.resetReq.Action)
562+ assert.Contains(t, dom.html, "Reset keybinding toggle_history_systemview")
563+
564+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings}))
565+ assert.True(t, service.calledResetAll)
566+ assert.Contains(t, dom.html, "Reset all keybindings to defaults")
567+}
568+
569+func testConfigPayload() port.SystemviewConfigPayload {
570+ return port.SystemviewConfigPayload{
571+ EngineType: "webkit",
572+ Appearance: port.WebUIAppearanceConfig{
573+ ColorScheme: "prefer-dark",
574+ SansFont: "Inter",
575+ SerifFont: "Georgia",
576+ MonospaceFont: "JetBrains Mono",
577+ DefaultFontSize: 16,
578+ LightPalette: port.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},
579+ DarkPalette: port.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"},
580+ },
581+ DefaultUIScale: 1,
582+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
583+ SearchShortcuts: map[string]port.SearchShortcut{
584+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
585+ },
586+ Performance: port.SystemviewPerformancePayload{
587+ Profile: "default",
588+ Custom: port.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2},
589+ Hardware: port.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096},
590+ },
591+ }
592+}
593+
594+func testDefaultConfigPayload() port.SystemviewConfigPayload {
595+ cfg := testConfigPayload()
596+ cfg.Appearance.DefaultFontSize = 14
597+ cfg.DefaultUIScale = 1
598+ return cfg
599+}
600+
464601 type fakeDOM struct {
465602 mounted bool
466603 html string
@@ -641,6 +778,10 @@ type fakeConfigService struct {
641778 current port.SystemviewConfigPayload
642779 defaultCfg port.SystemviewConfigPayload
643780 keybindings any
781+ savedConfig port.WebUIConfig
782+ setReq port.SetKeybindingRequest
783+ setResp any
784+ resetReq port.ResetKeybindingRequest
644785 }
645786
646787 func (s *fakeConfigService) Current(context.Context) (port.SystemviewConfigPayload, error) {
@@ -653,8 +794,21 @@ func (s *fakeConfigService) Default(context.Context) (port.SystemviewConfigPaylo
653794 return s.defaultCfg, nil
654795 }
655796
656-func (s *fakeConfigService) Save(context.Context, port.WebUIConfig) error {
797+func (s *fakeConfigService) Save(_ context.Context, cfg port.WebUIConfig) error {
657798 s.calledSave = true
799+ s.savedConfig = cfg
800+ s.current.Appearance = cfg.Appearance
801+ s.current.DefaultUIScale = cfg.DefaultUIScale
802+ s.current.DefaultSearchEngine = cfg.DefaultSearchEngine
803+ s.current.SearchShortcuts = cfg.SearchShortcuts
804+ s.current.Performance.Profile = cfg.Performance.Profile
805+ s.current.Performance.Custom = port.SystemviewCustomPerformancePayload{
806+ SkiaCPUThreads: cfg.Performance.Custom.SkiaCPUThreads,
807+ SkiaGPUThreads: cfg.Performance.Custom.SkiaGPUThreads,
808+ WebProcessMemoryMB: cfg.Performance.Custom.WebProcessMemoryMB,
809+ NetworkProcessMemoryMB: cfg.Performance.Custom.NetworkProcessMemoryMB,
810+ WebViewPoolPrewarm: cfg.Performance.Custom.WebViewPoolPrewarm,
811+ }
658812 return nil
659813 }
660814
@@ -663,13 +817,15 @@ func (s *fakeConfigService) GetKeybindings(context.Context) (any, error) {
663817 return s.keybindings, nil
664818 }
665819
666-func (s *fakeConfigService) SetKeybinding(context.Context, port.SetKeybindingRequest) (any, error) {
820+func (s *fakeConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (any, error) {
667821 s.calledSet = true
668- return nil, nil
822+ s.setReq = req
823+ return s.setResp, nil
669824 }
670825
671-func (s *fakeConfigService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error {
826+func (s *fakeConfigService) ResetKeybinding(_ context.Context, req port.ResetKeybindingRequest) error {
672827 s.calledReset = true
828+ s.resetReq = req
673829 return nil
674830 }
675831
@@ -455,12 +455,149 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
455 func TestConfigHTMLFallsBackForMalformedKeybindings(t *testing.T) {455 func TestConfigHTMLFallsBackForMalformedKeybindings(t *testing.T) {
456 t.Parallel()456 t.Parallel()
457 457
458- html := configHTML(port.SystemviewConfigPayload{}, map[string]any{"groups": "oops"})458+ html := configHTML(configRenderData{Config: port.SystemviewConfigPayload{}, Keybindings: map[string]any{"groups": "oops"}})
459 459
460 assert.Contains(t, html, "Keybindings unavailable")460 assert.Contains(t, html, "Keybindings unavailable")
461 assert.NotContains(t, html, "groups[0].bindings[0].action")461 assert.NotContains(t, html, "groups[0].bindings[0].action")
462 }462 }
463 463
464+func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) {
465+ t.Parallel()
466+
467+ service := &fakeConfigService{
468+ current: testConfigPayload(),
469+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
470+ Mode: "default",
471+ DisplayName: "Default",
472+ Bindings: []port.KeybindingEntry{{
473+ Action: "toggle_history_systemview",
474+ Description: "Toggle history",
475+ Keys: []string{"ctrl+h"},
476+ DefaultKeys: []string{"ctrl+h"},
477+ }},
478+ }}},
479+ }
480+ app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
481+
482+ require.NoError(t, app.LoadInitial(context.Background()))
483+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`)
484+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`)
485+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`)
486+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`)
487+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`)
488+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.delete"`)
489+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.performance.save"`)
490+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.set"`)
491+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.reset"`)
492+ assert.Contains(t, app.renderedHTML, `data-sv-action="config.keybinding.resetAll"`)
493+ assert.Contains(t, app.renderedHTML, "may require restarting")
494+}
495+
496+func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
497+ dom := &fakeDOM{}
498+ service := &fakeConfigService{
499+ current: testConfigPayload(),
500+ defaultCfg: testDefaultConfigPayload(),
501+ keybindings: port.KeybindingsConfig{Groups: []port.KeybindingGroup{{
502+ Mode: "default",
503+ DisplayName: "Default",
504+ Bindings: []port.KeybindingEntry{{
505+ Action: "toggle_history_systemview",
506+ Description: "Toggle history",
507+ Keys: []string{"ctrl+h"},
508+ DefaultKeys: []string{"ctrl+h"},
509+ }},
510+ }}},
511+ setResp: port.SetKeybindingResponse{},
512+ }
513+ app := NewApp(Dependencies{DOM: dom, Config: service, LocationURI: "dumb://config"})
514+
515+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
516+ Action: configActionSaveAppearance,
517+ Data: map[string]string{
518+ "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono",
519+ "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light",
520+ "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd",
521+ "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333",
522+ },
523+ }))
524+ assert.True(t, service.calledSave)
525+ assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)
526+ assert.Equal(t, 1.25, service.savedConfig.DefaultUIScale)
527+ assert.Contains(t, dom.html, "Saved appearance settings")
528+ assert.Contains(t, dom.html, `class="sv-app sv-light"`)
529+
530+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
531+ Action: configActionCreateSearchShortcut,
532+ Data: map[string]string{"key": "g", "url": "https://google.com/search?q=%s", "description": "Google"},
533+ }))
534+ assert.Equal(t, "Google", service.savedConfig.SearchShortcuts["g"].Description)
535+ assert.Contains(t, dom.html, "Created search shortcut g")
536+
537+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
538+ Action: configActionSavePerformance,
539+ Data: map[string]string{
540+ "profile": "custom", "skia_cpu_threads": "4", "skia_gpu_threads": "2",
541+ "web_process_memory_mb": "2048", "network_process_memory_mb": "512", "webview_pool_prewarm": "4",
542+ },
543+ }))
544+ assert.Equal(t, "custom", service.savedConfig.Performance.Profile)
545+ assert.Equal(t, 2048, service.savedConfig.Performance.Custom.WebProcessMemoryMB)
546+ assert.Contains(t, dom.html, "Saved performance settings")
547+
548+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
549+ Action: configActionSetKeybinding,
550+ Data: map[string]string{"mode": "default", "action": "toggle_history_systemview", "keys": "ctrl+h, alt+h"},
551+ }))
552+ assert.True(t, service.calledSet)
553+ assert.Equal(t, []string{"ctrl+h", "alt+h"}, service.setReq.Keys)
554+ assert.Contains(t, dom.html, "Saved keybinding toggle_history_systemview")
555+
556+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{
557+ Action: configActionResetKeybinding,
558+ Data: map[string]string{"mode": "default", "action": "toggle_history_systemview"},
559+ }))
560+ assert.True(t, service.calledReset)
561+ assert.Equal(t, "toggle_history_systemview", service.resetReq.Action)
562+ assert.Contains(t, dom.html, "Reset keybinding toggle_history_systemview")
563+
564+ require.NoError(t, app.HandleDOMAction(context.Background(), DOMAction{Action: configActionResetAllKeybindings}))
565+ assert.True(t, service.calledResetAll)
566+ assert.Contains(t, dom.html, "Reset all keybindings to defaults")
567+}
568+
569+func testConfigPayload() port.SystemviewConfigPayload {
570+ return port.SystemviewConfigPayload{
571+ EngineType: "webkit",
572+ Appearance: port.WebUIAppearanceConfig{
573+ ColorScheme: "prefer-dark",
574+ SansFont: "Inter",
575+ SerifFont: "Georgia",
576+ MonospaceFont: "JetBrains Mono",
577+ DefaultFontSize: 16,
578+ LightPalette: port.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},
579+ DarkPalette: port.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"},
580+ },
581+ DefaultUIScale: 1,
582+ DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
583+ SearchShortcuts: map[string]port.SearchShortcut{
584+ "ddg": {URL: "https://duckduckgo.com/?q=%s", Description: "DuckDuckGo"},
585+ },
586+ Performance: port.SystemviewPerformancePayload{
587+ Profile: "default",
588+ Custom: port.SystemviewCustomPerformancePayload{SkiaCPUThreads: 2, SkiaGPUThreads: 1, WebProcessMemoryMB: 1024, NetworkProcessMemoryMB: 256, WebViewPoolPrewarm: 2},
589+ Hardware: port.SystemviewHardwarePayload{CPUCores: 4, CPUThreads: 8, TotalRAMMB: 16384, GPUVendor: "AMD", GPUName: "Radeon", VRAMMB: 4096},
590+ },
591+ }
592+}
593+
594+func testDefaultConfigPayload() port.SystemviewConfigPayload {
595+ cfg := testConfigPayload()
596+ cfg.Appearance.DefaultFontSize = 14
597+ cfg.DefaultUIScale = 1
598+ return cfg
599+}
600+
464 type fakeDOM struct {601 type fakeDOM struct {
465 mounted bool602 mounted bool
466 html string603 html string
@@ -641,6 +778,10 @@ type fakeConfigService struct {
641 current port.SystemviewConfigPayload778 current port.SystemviewConfigPayload
642 defaultCfg port.SystemviewConfigPayload779 defaultCfg port.SystemviewConfigPayload
643 keybindings any780 keybindings any
781+ savedConfig port.WebUIConfig
782+ setReq port.SetKeybindingRequest
783+ setResp any
784+ resetReq port.ResetKeybindingRequest
644 }785 }
645 786
646 func (s *fakeConfigService) Current(context.Context) (port.SystemviewConfigPayload, error) {787 func (s *fakeConfigService) Current(context.Context) (port.SystemviewConfigPayload, error) {
@@ -653,8 +794,21 @@ func (s *fakeConfigService) Default(context.Context) (port.SystemviewConfigPaylo
653 return s.defaultCfg, nil794 return s.defaultCfg, nil
654 }795 }
655 796
656-func (s *fakeConfigService) Save(context.Context, port.WebUIConfig) error {797+func (s *fakeConfigService) Save(_ context.Context, cfg port.WebUIConfig) error {
657 s.calledSave = true798 s.calledSave = true
799+ s.savedConfig = cfg
800+ s.current.Appearance = cfg.Appearance
801+ s.current.DefaultUIScale = cfg.DefaultUIScale
802+ s.current.DefaultSearchEngine = cfg.DefaultSearchEngine
803+ s.current.SearchShortcuts = cfg.SearchShortcuts
804+ s.current.Performance.Profile = cfg.Performance.Profile
805+ s.current.Performance.Custom = port.SystemviewCustomPerformancePayload{
806+ SkiaCPUThreads: cfg.Performance.Custom.SkiaCPUThreads,
807+ SkiaGPUThreads: cfg.Performance.Custom.SkiaGPUThreads,
808+ WebProcessMemoryMB: cfg.Performance.Custom.WebProcessMemoryMB,
809+ NetworkProcessMemoryMB: cfg.Performance.Custom.NetworkProcessMemoryMB,
810+ WebViewPoolPrewarm: cfg.Performance.Custom.WebViewPoolPrewarm,
811+ }
658 return nil812 return nil
659 }813 }
660 814
@@ -663,13 +817,15 @@ func (s *fakeConfigService) GetKeybindings(context.Context) (any, error) {
663 return s.keybindings, nil817 return s.keybindings, nil
664 }818 }
665 819
666-func (s *fakeConfigService) SetKeybinding(context.Context, port.SetKeybindingRequest) (any, error) {820+func (s *fakeConfigService) SetKeybinding(_ context.Context, req port.SetKeybindingRequest) (any, error) {
667 s.calledSet = true821 s.calledSet = true
668- return nil, nil822+ s.setReq = req
823+ return s.setResp, nil
669 }824 }
670 825
671-func (s *fakeConfigService) ResetKeybinding(context.Context, port.ResetKeybindingRequest) error {826+func (s *fakeConfigService) ResetKeybinding(_ context.Context, req port.ResetKeybindingRequest) error {
672 s.calledReset = true827 s.calledReset = true
828+ s.resetReq = req
673 return nil829 return nil
674 }830 }
675 831
M internal/ui/systemviews/config.templ +136 -12
@@ -1,17 +1,134 @@
11 package systemviews
22
3+import "github.com/bnema/dumber/internal/application/port"
4+
35 templ ConfigView(data configRenderData) {
4- @Section("sv-config-values", "Config values") {
5- @KV(flattenValues(data.Config))
6+ @Alert("success", data.Notice)
7+ @Alert("error", data.Error)
8+ @Section("sv-config-appearance", "Appearance") {
9+ @AppearanceForm(data.Config)
10+ }
11+ @Section("sv-config-search", "Search") {
12+ @SearchSettings(data.Config)
13+ }
14+ @Section("sv-config-performance", "Performance") {
15+ @PerformanceSettings(data.Config)
616 }
717 @Section("sv-config-keybindings", "Keybindings") {
818 @Meta(configKeybindingSummary(data.Keybindings))
919 @ConfigKeybindings(data.Keybindings)
1020 }
21+ @Section("sv-config-values", "Current payload") {
22+ @KV(flattenValues(data.Config))
23+ }
24+}
25+
26+templ AppearanceForm(config port.SystemviewConfigPayload) {
27+ <form class="sv-card-form sv-config-form" data-sv-action="config.appearance.save">
28+ <label><span>Sans font</span><input name="sans_font" type="text" value={ config.Appearance.SansFont } required/></label>
29+ <label><span>Serif font</span><input name="serif_font" type="text" value={ config.Appearance.SerifFont } required/></label>
30+ <label><span>Monospace font</span><input name="monospace_font" type="text" value={ config.Appearance.MonospaceFont } required/></label>
31+ <label><span>Default font size</span><input name="default_font_size" type="number" min="1" max="72" value={ formatConfigInt(config.Appearance.DefaultFontSize) } required/></label>
32+ <label><span>UI scale</span><input name="default_ui_scale" type="number" min="0.5" max="3" step="0.05" value={ formatConfigFloat(config.DefaultUIScale) } required/></label>
33+ <label><span>Color scheme</span><select name="color_scheme">
34+ <option value="default" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "default") }>System default</option>
35+ <option value="prefer-light" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-light") }>Prefer light</option>
36+ <option value="prefer-dark" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-dark") }>Prefer dark</option>
37+ </select></label>
38+ @PaletteFields("Light palette", "light", config.Appearance.LightPalette)
39+ @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette)
40+ <div class="sv-button-row sv-form-actions">
41+ <button class="sv-button" type="submit">Save appearance</button>
42+ <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.appearance.reset" data-sv-confirm="Reset appearance settings to defaults?">Reset appearance</button>
43+ </div>
44+ </form>
45+}
46+
47+templ PaletteFields(title string, prefix string, palette port.ColorPalette) {
48+ <fieldset class="sv-fieldset">
49+ <legend>{ title }</legend>
50+ <label><span>Background</span><input name={ prefix + "_background" } type="text" value={ palette.Background } required/></label>
51+ <label><span>Surface</span><input name={ prefix + "_surface" } type="text" value={ palette.Surface } required/></label>
52+ <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="text" value={ palette.SurfaceVariant } required/></label>
53+ <label><span>Text</span><input name={ prefix + "_text" } type="text" value={ palette.Text } required/></label>
54+ <label><span>Muted</span><input name={ prefix + "_muted" } type="text" value={ palette.Muted } required/></label>
55+ <label><span>Accent</span><input name={ prefix + "_accent" } type="text" value={ palette.Accent } required/></label>
56+ <label><span>Border</span><input name={ prefix + "_border" } type="text" value={ palette.Border } required/></label>
57+ </fieldset>
58+}
59+
60+templ SearchSettings(config port.SystemviewConfigPayload) {
61+ <form class="sv-card-form" data-sv-action="config.search.save">
62+ <label><span>Default search engine</span><input name="default_search_engine" type="text" value={ config.DefaultSearchEngine } placeholder="https://duckduckgo.com/?q=%s" required/></label>
63+ <button class="sv-button" type="submit">Save search default</button>
64+ </form>
65+ <div class="sv-group">
66+ <div class="sv-group-header"><h4>Search shortcuts</h4></div>
67+ <p class="sv-meta">Shortcut URLs must include the %s placeholder for the search query.</p>
68+ <form class="sv-inline-form" data-sv-action="config.searchShortcut.create">
69+ <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label>
70+ <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" required/></label>
71+ <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo" required/></label>
72+ <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button>
73+ </form>
74+ if len(searchShortcutRows(config.SearchShortcuts)) == 0 {
75+ @EmptyState("No search shortcuts configured")
76+ } else {
77+ <ul class="sv-list">
78+ for _, shortcut := range searchShortcutRows(config.SearchShortcuts) {
79+ @SearchShortcutItem(shortcut)
80+ }
81+ </ul>
82+ }
83+ </div>
84+}
85+
86+templ SearchShortcutItem(shortcut searchShortcutRow) {
87+ <li class="sv-list-row">
88+ <form class="sv-inline-form" data-sv-action="config.searchShortcut.update">
89+ <input type="hidden" name="key" value={ shortcut.Key }/>
90+ <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label>
91+ <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } required/></label>
92+ <label><span>Description</span><input name="description" type="text" value={ shortcut.Description } required/></label>
93+ <button class="sv-button sv-button-secondary" type="submit">Save</button>
94+ <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button>
95+ </form>
96+ </li>
97+}
98+
99+templ PerformanceSettings(config port.SystemviewConfigPayload) {
100+ <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save">
101+ <label><span>Profile</span><select name="profile">
102+ <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option>
103+ <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option>
104+ <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option>
105+ <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option>
106+ </select></label>
107+ <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) }/></label>
108+ <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) }/></label>
109+ <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) }/></label>
110+ <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) }/></label>
111+ <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) }/></label>
112+ <button class="sv-button" type="submit">Save performance</button>
113+ </form>
114+ <div class="sv-stat-grid">
115+ <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.CPUThreads) }</span><span class="sv-stat-label">CPU threads</span></div>
116+ <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.TotalRAMMB) }</span><span class="sv-stat-label">RAM MB</span></div>
117+ <div class="sv-stat-card"><span class="sv-stat-value">{ config.Performance.Hardware.GPUName }</span><span class="sv-stat-label">GPU</span></div>
118+ <div class="sv-stat-card"><span class="sv-stat-value">{ config.EngineType }</span><span class="sv-stat-label">Engine</span></div>
119+ </div>
120+ if !engineIsWebKit(config.EngineType) {
121+ <p class="sv-meta">Performance tuning applies only to the WebKit engine.</p>
122+ } else {
123+ <p class="sv-meta">Performance profile changes may require restarting the browser to affect existing engine processes.</p>
124+ }
11125 }
12126
13127 templ ConfigKeybindings(keybindings any) {
14128 {{ groups := configKeybindingGroups(keybindings) }}
129+ <div class="sv-button-row">
130+ <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.keybinding.resetAll" data-sv-confirm="Reset all keybindings to defaults?">Reset all keybindings</button>
131+ </div>
15132 if groups == nil {
16133 @EmptyState("Keybindings unavailable")
17134 } else if len(groups) == 0 {
@@ -39,7 +156,7 @@ templ ConfigKeybindingGroup(group renderedKeybindingGroup) {
39156 } else {
40157 <ul class="sv-list">
41158 for _, binding := range group.Bindings {
42- @ConfigKeybinding(binding)
159+ @ConfigKeybinding(group, binding)
43160 }
44161 </ul>
45162 }
@@ -47,21 +164,28 @@ templ ConfigKeybindingGroup(group renderedKeybindingGroup) {
47164 </div>
48165 }
49166
50-templ ConfigKeybinding(binding renderedKeybinding) {
167+templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding) {
51168 <li class="sv-list-row">
52169 <div class="sv-keybinding-row">
53170 <div>
54171 <strong>{ keybindingDescription(binding) }</strong>
55172 <p class="sv-meta">{ keybindingAction(binding) }</p>
173+ <div class="sv-binding-keys">
174+ @KeyList(binding.Keys)
175+ <span class="sv-badge">{ keybindingStatus(binding) }</span>
176+ if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 {
177+ <span class="sv-meta">default:</span>
178+ @KeyList(binding.DefaultKeys)
179+ }
180+ </div>
56181 </div>
57- <div class="sv-keybinding-keys">
58- @KeyList(binding.Keys)
59- <span class="sv-badge">{ keybindingStatus(binding) }</span>
60- if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 {
61- <span class="sv-meta">default:</span>
62- @KeyList(binding.DefaultKeys)
63- }
64- </div>
182+ <form class="sv-inline-form" data-sv-action="config.keybinding.set">
183+ <input type="hidden" name="mode" value={ group.Mode }/>
184+ <input type="hidden" name="action" value={ binding.Action }/>
185+ <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h" required/></label>
186+ <button class="sv-button sv-button-secondary" type="submit">Save</button>
187+ <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>Reset</button>
188+ </form>
65189 </div>
66190 </li>
67191 }
@@ -1,17 +1,134 @@
1 package systemviews1 package systemviews
2 2
3+import "github.com/bnema/dumber/internal/application/port"
4+
3 templ ConfigView(data configRenderData) {5 templ ConfigView(data configRenderData) {
4- @Section("sv-config-values", "Config values") {6+ @Alert("success", data.Notice)
5- @KV(flattenValues(data.Config))7+ @Alert("error", data.Error)
8+ @Section("sv-config-appearance", "Appearance") {
9+ @AppearanceForm(data.Config)
10+ }
11+ @Section("sv-config-search", "Search") {
12+ @SearchSettings(data.Config)
13+ }
14+ @Section("sv-config-performance", "Performance") {
15+ @PerformanceSettings(data.Config)
6 }16 }
7 @Section("sv-config-keybindings", "Keybindings") {17 @Section("sv-config-keybindings", "Keybindings") {
8 @Meta(configKeybindingSummary(data.Keybindings))18 @Meta(configKeybindingSummary(data.Keybindings))
9 @ConfigKeybindings(data.Keybindings)19 @ConfigKeybindings(data.Keybindings)
10 }20 }
21+ @Section("sv-config-values", "Current payload") {
22+ @KV(flattenValues(data.Config))
23+ }
24+}
25+
26+templ AppearanceForm(config port.SystemviewConfigPayload) {
27+ <form class="sv-card-form sv-config-form" data-sv-action="config.appearance.save">
28+ <label><span>Sans font</span><input name="sans_font" type="text" value={ config.Appearance.SansFont } required/></label>
29+ <label><span>Serif font</span><input name="serif_font" type="text" value={ config.Appearance.SerifFont } required/></label>
30+ <label><span>Monospace font</span><input name="monospace_font" type="text" value={ config.Appearance.MonospaceFont } required/></label>
31+ <label><span>Default font size</span><input name="default_font_size" type="number" min="1" max="72" value={ formatConfigInt(config.Appearance.DefaultFontSize) } required/></label>
32+ <label><span>UI scale</span><input name="default_ui_scale" type="number" min="0.5" max="3" step="0.05" value={ formatConfigFloat(config.DefaultUIScale) } required/></label>
33+ <label><span>Color scheme</span><select name="color_scheme">
34+ <option value="default" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "default") }>System default</option>
35+ <option value="prefer-light" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-light") }>Prefer light</option>
36+ <option value="prefer-dark" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-dark") }>Prefer dark</option>
37+ </select></label>
38+ @PaletteFields("Light palette", "light", config.Appearance.LightPalette)
39+ @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette)
40+ <div class="sv-button-row sv-form-actions">
41+ <button class="sv-button" type="submit">Save appearance</button>
42+ <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.appearance.reset" data-sv-confirm="Reset appearance settings to defaults?">Reset appearance</button>
43+ </div>
44+ </form>
45+}
46+
47+templ PaletteFields(title string, prefix string, palette port.ColorPalette) {
48+ <fieldset class="sv-fieldset">
49+ <legend>{ title }</legend>
50+ <label><span>Background</span><input name={ prefix + "_background" } type="text" value={ palette.Background } required/></label>
51+ <label><span>Surface</span><input name={ prefix + "_surface" } type="text" value={ palette.Surface } required/></label>
52+ <label><span>Surface variant</span><input name={ prefix + "_surface_variant" } type="text" value={ palette.SurfaceVariant } required/></label>
53+ <label><span>Text</span><input name={ prefix + "_text" } type="text" value={ palette.Text } required/></label>
54+ <label><span>Muted</span><input name={ prefix + "_muted" } type="text" value={ palette.Muted } required/></label>
55+ <label><span>Accent</span><input name={ prefix + "_accent" } type="text" value={ palette.Accent } required/></label>
56+ <label><span>Border</span><input name={ prefix + "_border" } type="text" value={ palette.Border } required/></label>
57+ </fieldset>
58+}
59+
60+templ SearchSettings(config port.SystemviewConfigPayload) {
61+ <form class="sv-card-form" data-sv-action="config.search.save">
62+ <label><span>Default search engine</span><input name="default_search_engine" type="text" value={ config.DefaultSearchEngine } placeholder="https://duckduckgo.com/?q=%s" required/></label>
63+ <button class="sv-button" type="submit">Save search default</button>
64+ </form>
65+ <div class="sv-group">
66+ <div class="sv-group-header"><h4>Search shortcuts</h4></div>
67+ <p class="sv-meta">Shortcut URLs must include the %s placeholder for the search query.</p>
68+ <form class="sv-inline-form" data-sv-action="config.searchShortcut.create">
69+ <label><span>Key</span><input name="key" type="text" placeholder="ddg" required/></label>
70+ <label><span>URL</span><input name="url" type="text" placeholder="https://duckduckgo.com/?q=%s" required/></label>
71+ <label><span>Description</span><input name="description" type="text" placeholder="DuckDuckGo" required/></label>
72+ <button class="sv-button sv-button-secondary" type="submit">Add shortcut</button>
73+ </form>
74+ if len(searchShortcutRows(config.SearchShortcuts)) == 0 {
75+ @EmptyState("No search shortcuts configured")
76+ } else {
77+ <ul class="sv-list">
78+ for _, shortcut := range searchShortcutRows(config.SearchShortcuts) {
79+ @SearchShortcutItem(shortcut)
80+ }
81+ </ul>
82+ }
83+ </div>
84+}
85+
86+templ SearchShortcutItem(shortcut searchShortcutRow) {
87+ <li class="sv-list-row">
88+ <form class="sv-inline-form" data-sv-action="config.searchShortcut.update">
89+ <input type="hidden" name="key" value={ shortcut.Key }/>
90+ <label><span>Key</span><input name="new_key" type="text" value={ shortcut.Key } required/></label>
91+ <label><span>URL</span><input name="url" type="text" value={ shortcut.URL } required/></label>
92+ <label><span>Description</span><input name="description" type="text" value={ shortcut.Description } required/></label>
93+ <button class="sv-button sv-button-secondary" type="submit">Save</button>
94+ <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.searchShortcut.delete" data-key={ shortcut.Key } data-sv-confirm="Delete this search shortcut?">Delete</button>
95+ </form>
96+ </li>
97+}
98+
99+templ PerformanceSettings(config port.SystemviewConfigPayload) {
100+ <form class="sv-card-form sv-config-form" data-sv-action="config.performance.save">
101+ <label><span>Profile</span><select name="profile">
102+ <option value="default" selected?={ performanceProfileSelected(config.Performance.Profile, "default") }>Default</option>
103+ <option value="lite" selected?={ performanceProfileSelected(config.Performance.Profile, "lite") }>Lite</option>
104+ <option value="max" selected?={ performanceProfileSelected(config.Performance.Profile, "max") }>Max</option>
105+ <option value="custom" selected?={ performanceProfileSelected(config.Performance.Profile, "custom") }>Custom</option>
106+ </select></label>
107+ <label><span>Skia CPU threads</span><input name="skia_cpu_threads" type="number" min="0" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaCPUThreads) }/></label>
108+ <label><span>Skia GPU threads</span><input name="skia_gpu_threads" type="number" min="-1" max="8" value={ formatConfigInt(config.Performance.Custom.SkiaGPUThreads) }/></label>
109+ <label><span>Web process memory MB</span><input name="web_process_memory_mb" type="number" min="0" max="16384" value={ formatConfigInt(config.Performance.Custom.WebProcessMemoryMB) }/></label>
110+ <label><span>Network process memory MB</span><input name="network_process_memory_mb" type="number" min="0" max="4096" value={ formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB) }/></label>
111+ <label><span>WebView pool prewarm</span><input name="webview_pool_prewarm" type="number" min="0" max="20" value={ formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm) }/></label>
112+ <button class="sv-button" type="submit">Save performance</button>
113+ </form>
114+ <div class="sv-stat-grid">
115+ <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.CPUThreads) }</span><span class="sv-stat-label">CPU threads</span></div>
116+ <div class="sv-stat-card"><span class="sv-stat-value">{ formatConfigInt(config.Performance.Hardware.TotalRAMMB) }</span><span class="sv-stat-label">RAM MB</span></div>
117+ <div class="sv-stat-card"><span class="sv-stat-value">{ config.Performance.Hardware.GPUName }</span><span class="sv-stat-label">GPU</span></div>
118+ <div class="sv-stat-card"><span class="sv-stat-value">{ config.EngineType }</span><span class="sv-stat-label">Engine</span></div>
119+ </div>
120+ if !engineIsWebKit(config.EngineType) {
121+ <p class="sv-meta">Performance tuning applies only to the WebKit engine.</p>
122+ } else {
123+ <p class="sv-meta">Performance profile changes may require restarting the browser to affect existing engine processes.</p>
124+ }
11 }125 }
12 126
13 templ ConfigKeybindings(keybindings any) {127 templ ConfigKeybindings(keybindings any) {
14 {{ groups := configKeybindingGroups(keybindings) }}128 {{ groups := configKeybindingGroups(keybindings) }}
129+ <div class="sv-button-row">
130+ <button class="sv-button sv-button-secondary sv-button-danger" type="button" data-sv-action="config.keybinding.resetAll" data-sv-confirm="Reset all keybindings to defaults?">Reset all keybindings</button>
131+ </div>
15 if groups == nil {132 if groups == nil {
16 @EmptyState("Keybindings unavailable")133 @EmptyState("Keybindings unavailable")
17 } else if len(groups) == 0 {134 } else if len(groups) == 0 {
@@ -39,7 +156,7 @@ templ ConfigKeybindingGroup(group renderedKeybindingGroup) {
39 } else {156 } else {
40 <ul class="sv-list">157 <ul class="sv-list">
41 for _, binding := range group.Bindings {158 for _, binding := range group.Bindings {
42- @ConfigKeybinding(binding)159+ @ConfigKeybinding(group, binding)
43 }160 }
44 </ul>161 </ul>
45 }162 }
@@ -47,21 +164,28 @@ templ ConfigKeybindingGroup(group renderedKeybindingGroup) {
47 </div>164 </div>
48 }165 }
49 166
50-templ ConfigKeybinding(binding renderedKeybinding) {167+templ ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding) {
51 <li class="sv-list-row">168 <li class="sv-list-row">
52 <div class="sv-keybinding-row">169 <div class="sv-keybinding-row">
53 <div>170 <div>
54 <strong>{ keybindingDescription(binding) }</strong>171 <strong>{ keybindingDescription(binding) }</strong>
55 <p class="sv-meta">{ keybindingAction(binding) }</p>172 <p class="sv-meta">{ keybindingAction(binding) }</p>
173+ <div class="sv-binding-keys">
174+ @KeyList(binding.Keys)
175+ <span class="sv-badge">{ keybindingStatus(binding) }</span>
176+ if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 {
177+ <span class="sv-meta">default:</span>
178+ @KeyList(binding.DefaultKeys)
179+ }
180+ </div>
56 </div>181 </div>
57- <div class="sv-keybinding-keys">182+ <form class="sv-inline-form" data-sv-action="config.keybinding.set">
58- @KeyList(binding.Keys)183+ <input type="hidden" name="mode" value={ group.Mode }/>
59- <span class="sv-badge">{ keybindingStatus(binding) }</span>184+ <input type="hidden" name="action" value={ binding.Action }/>
60- if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 {185+ <label><span>Keys</span><input name="keys" type="text" value={ keybindingKeysValue(binding.Keys) } placeholder="ctrl+h" required/></label>
61- <span class="sv-meta">default:</span>186+ <button class="sv-button sv-button-secondary" type="submit">Save</button>
62- @KeyList(binding.DefaultKeys)187+ <button class="sv-button sv-button-secondary" type="button" data-sv-action="config.keybinding.reset" data-mode={ group.Mode } data-action={ binding.Action }>Reset</button>
63- }188+ </form>
64- </div>
65 </div>189 </div>
66 </li>190 </li>
67 }191 }
A internal/ui/systemviews/config_actions.go +434 -0
@@ -0,0 +1,434 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+const (
13+ configActionSaveAppearance = "config.appearance.save"
14+ configActionResetAppearance = "config.appearance.reset"
15+ configActionSaveSearch = "config.search.save"
16+ configActionCreateSearchShortcut = "config.searchShortcut.create"
17+ configActionUpdateSearchShortcut = "config.searchShortcut.update"
18+ configActionDeleteSearchShortcut = "config.searchShortcut.delete"
19+ configActionSavePerformance = "config.performance.save"
20+ configActionSetKeybinding = "config.keybinding.set"
21+ configActionResetKeybinding = "config.keybinding.reset"
22+ configActionResetAllKeybindings = "config.keybinding.resetAll"
23+ configRequestIDPrefix = "systemviews-config"
24+)
25+
26+func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error {
27+ if a.deps.Config == nil {
28+ return fmt.Errorf("config service not configured")
29+ }
30+
31+ switch event.Action {
32+ case configActionSaveAppearance:
33+ return a.saveAppearanceConfig(ctx, event.Data)
34+ case configActionResetAppearance:
35+ return a.resetAppearanceConfig(ctx)
36+ case configActionSaveSearch:
37+ return a.saveSearchConfig(ctx, event.Data)
38+ case configActionCreateSearchShortcut:
39+ return a.createSearchShortcut(ctx, event.Data)
40+ case configActionUpdateSearchShortcut:
41+ return a.updateSearchShortcut(ctx, event.Data)
42+ case configActionDeleteSearchShortcut:
43+ return a.deleteSearchShortcut(ctx, event.Data)
44+ case configActionSavePerformance:
45+ return a.savePerformanceConfig(ctx, event.Data)
46+ case configActionSetKeybinding:
47+ return a.setConfigKeybinding(ctx, event.Data)
48+ case configActionResetKeybinding:
49+ return a.resetConfigKeybinding(ctx, event.Data)
50+ case configActionResetAllKeybindings:
51+ if err := a.deps.Config.ResetAllKeybindings(ctx); err != nil {
52+ return err
53+ }
54+ a.configNotice = "Reset all keybindings to defaults"
55+ }
56+ return nil
57+}
58+
59+func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error {
60+ cfg, err := a.editableConfig(ctx)
61+ if err != nil {
62+ return err
63+ }
64+
65+ fontSize, err := parseConfigInt(data["default_font_size"], "default font size")
66+ if err != nil {
67+ return err
68+ }
69+ uiScale, err := parseConfigFloat(data["default_ui_scale"], "UI scale")
70+ if err != nil {
71+ return err
72+ }
73+
74+ cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"])
75+ cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"])
76+ cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"])
77+ cfg.Appearance.DefaultFontSize = fontSize
78+ cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])
79+ cfg.Appearance.LightPalette = paletteFromForm(data, "light")
80+ cfg.Appearance.DarkPalette = paletteFromForm(data, "dark")
81+ cfg.DefaultUIScale = uiScale
82+
83+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
84+ return err
85+ }
86+ a.configNotice = "Saved appearance settings"
87+ return nil
88+}
89+
90+func (a *App) resetAppearanceConfig(ctx context.Context) error {
91+ cfg, err := a.editableConfig(ctx)
92+ if err != nil {
93+ return err
94+ }
95+ defaults, err := a.deps.Config.Default(ctx)
96+ if err != nil {
97+ return err
98+ }
99+ cfg.Appearance = defaults.Appearance
100+ cfg.DefaultUIScale = defaults.DefaultUIScale
101+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
102+ return err
103+ }
104+ a.configNotice = "Reset appearance settings to defaults"
105+ return nil
106+}
107+
108+func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) error {
109+ cfg, err := a.editableConfig(ctx)
110+ if err != nil {
111+ return err
112+ }
113+ cfg.DefaultSearchEngine = strings.TrimSpace(data["default_search_engine"])
114+ if err := validateSearchURL(cfg.DefaultSearchEngine, "default search engine"); err != nil {
115+ return err
116+ }
117+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
118+ return err
119+ }
120+ a.configNotice = "Saved default search engine"
121+ return nil
122+}
123+
124+func (a *App) createSearchShortcut(ctx context.Context, data map[string]string) error {
125+ cfg, err := a.editableConfig(ctx)
126+ if err != nil {
127+ return err
128+ }
129+ key, shortcut, err := searchShortcutFromForm(data, "key")
130+ if err != nil {
131+ return err
132+ }
133+ cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
134+ if _, exists := cfg.SearchShortcuts[key]; exists {
135+ return fmt.Errorf("search shortcut %q already exists", key)
136+ }
137+ cfg.SearchShortcuts[key] = shortcut
138+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
139+ return err
140+ }
141+ a.configNotice = "Created search shortcut " + key
142+ return nil
143+}
144+
145+func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string) error {
146+ cfg, err := a.editableConfig(ctx)
147+ if err != nil {
148+ return err
149+ }
150+ oldKey := strings.TrimSpace(data["key"])
151+ if oldKey == "" {
152+ return fmt.Errorf("search shortcut key is required")
153+ }
154+ newKey, shortcut, err := searchShortcutFromForm(data, "new_key")
155+ if err != nil {
156+ return err
157+ }
158+ cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
159+ if _, exists := cfg.SearchShortcuts[oldKey]; !exists {
160+ return fmt.Errorf("search shortcut %q not found", oldKey)
161+ }
162+ if newKey != oldKey {
163+ if _, exists := cfg.SearchShortcuts[newKey]; exists {
164+ return fmt.Errorf("search shortcut %q already exists", newKey)
165+ }
166+ delete(cfg.SearchShortcuts, oldKey)
167+ }
168+ cfg.SearchShortcuts[newKey] = shortcut
169+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
170+ return err
171+ }
172+ a.configNotice = "Saved search shortcut " + newKey
173+ return nil
174+}
175+
176+func (a *App) deleteSearchShortcut(ctx context.Context, data map[string]string) error {
177+ cfg, err := a.editableConfig(ctx)
178+ if err != nil {
179+ return err
180+ }
181+ key := strings.TrimSpace(data["key"])
182+ if key == "" {
183+ return fmt.Errorf("search shortcut key is required")
184+ }
185+ cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
186+ delete(cfg.SearchShortcuts, key)
187+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
188+ return err
189+ }
190+ a.configNotice = "Deleted search shortcut " + key
191+ return nil
192+}
193+
194+func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) error {
195+ cfg, err := a.editableConfig(ctx)
196+ if err != nil {
197+ return err
198+ }
199+ profile := strings.TrimSpace(data["profile"])
200+ if !validPerformanceProfile(profile) {
201+ return fmt.Errorf("invalid performance profile")
202+ }
203+ skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads")
204+ if err != nil {
205+ return err
206+ }
207+ skiaGPU, err := parseConfigInt(data["skia_gpu_threads"], "Skia GPU threads")
208+ if err != nil {
209+ return err
210+ }
211+ webMemory, err := parseConfigInt(data["web_process_memory_mb"], "web process memory")
212+ if err != nil {
213+ return err
214+ }
215+ networkMemory, err := parseConfigInt(data["network_process_memory_mb"], "network process memory")
216+ if err != nil {
217+ return err
218+ }
219+ prewarm, err := parseConfigInt(data["webview_pool_prewarm"], "WebView pool prewarm")
220+ if err != nil {
221+ return err
222+ }
223+
224+ cfg.Performance.Profile = profile
225+ cfg.Performance.Custom = port.WebUICustomPerformanceConfig{
226+ SkiaCPUThreads: skiaCPU,
227+ SkiaGPUThreads: skiaGPU,
228+ WebProcessMemoryMB: webMemory,
229+ NetworkProcessMemoryMB: networkMemory,
230+ WebViewPoolPrewarm: prewarm,
231+ }
232+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
233+ return err
234+ }
235+ a.configNotice = "Saved performance settings. Restart may be required."
236+ return nil
237+}
238+
239+func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) error {
240+ mode := strings.TrimSpace(data["mode"])
241+ action := strings.TrimSpace(data["action"])
242+ keys := parseKeyList(data["keys"])
243+ if mode == "" || action == "" {
244+ return fmt.Errorf("mode and action are required")
245+ }
246+ resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{
247+ RequestID: configRequestIDPrefix,
248+ Mode: mode,
249+ Action: action,
250+ Keys: keys,
251+ })
252+ if err != nil {
253+ return err
254+ }
255+ if conflicts := keybindingConflictCount(resp); conflicts > 0 {
256+ a.configNotice = fmt.Sprintf("Saved keybinding with %d conflict(s)", conflicts)
257+ return nil
258+ }
259+ a.configNotice = "Saved keybinding " + action
260+ return nil
261+}
262+
263+func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) error {
264+ mode := strings.TrimSpace(data["mode"])
265+ action := strings.TrimSpace(data["action"])
266+ if mode == "" || action == "" {
267+ return fmt.Errorf("mode and action are required")
268+ }
269+ if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{
270+ RequestID: configRequestIDPrefix,
271+ Mode: mode,
272+ Action: action,
273+ }); err != nil {
274+ return err
275+ }
276+ a.configNotice = "Reset keybinding " + action
277+ return nil
278+}
279+
280+func (a *App) editableConfig(ctx context.Context) (port.WebUIConfig, error) {
281+ payload, err := a.currentConfigPayload(ctx)
282+ if err != nil {
283+ return port.WebUIConfig{}, err
284+ }
285+ return webUIConfigFromPayload(payload), nil
286+}
287+
288+func (a *App) currentConfigPayload(ctx context.Context) (port.SystemviewConfigPayload, error) {
289+ if a.config != nil {
290+ return *a.config, nil
291+ }
292+ cfg, err := a.deps.Config.Current(ctx)
293+ if err != nil {
294+ return port.SystemviewConfigPayload{}, err
295+ }
296+ a.config = &cfg
297+ return cfg, nil
298+}
299+
300+func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) error {
301+ if err := a.deps.Config.Save(ctx, cfg); err != nil {
302+ return err
303+ }
304+ a.config = nil
305+ return nil
306+}
307+
308+func webUIConfigFromPayload(payload port.SystemviewConfigPayload) port.WebUIConfig {
309+ return port.WebUIConfig{
310+ Appearance: payload.Appearance,
311+ DefaultUIScale: payload.DefaultUIScale,
312+ DefaultSearchEngine: payload.DefaultSearchEngine,
313+ SearchShortcuts: cloneSearchShortcuts(payload.SearchShortcuts),
314+ Performance: port.WebUIPerformanceConfig{
315+ Profile: payload.Performance.Profile,
316+ Custom: port.WebUICustomPerformanceConfig{
317+ SkiaCPUThreads: payload.Performance.Custom.SkiaCPUThreads,
318+ SkiaGPUThreads: payload.Performance.Custom.SkiaGPUThreads,
319+ WebProcessMemoryMB: payload.Performance.Custom.WebProcessMemoryMB,
320+ NetworkProcessMemoryMB: payload.Performance.Custom.NetworkProcessMemoryMB,
321+ WebViewPoolPrewarm: payload.Performance.Custom.WebViewPoolPrewarm,
322+ },
323+ },
324+ }
325+}
326+
327+func cloneSearchShortcuts(shortcuts map[string]port.SearchShortcut) map[string]port.SearchShortcut {
328+ clone := make(map[string]port.SearchShortcut, len(shortcuts))
329+ for key, shortcut := range shortcuts {
330+ clone[key] = shortcut
331+ }
332+ return clone
333+}
334+
335+func paletteFromForm(data map[string]string, prefix string) port.ColorPalette {
336+ return port.ColorPalette{
337+ Background: strings.TrimSpace(data[prefix+"_background"]),
338+ Surface: strings.TrimSpace(data[prefix+"_surface"]),
339+ SurfaceVariant: strings.TrimSpace(data[prefix+"_surface_variant"]),
340+ Text: strings.TrimSpace(data[prefix+"_text"]),
341+ Muted: strings.TrimSpace(data[prefix+"_muted"]),
342+ Accent: strings.TrimSpace(data[prefix+"_accent"]),
343+ Border: strings.TrimSpace(data[prefix+"_border"]),
344+ }
345+}
346+
347+func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut, error) {
348+ key := strings.TrimSpace(data[keyField])
349+ url := strings.TrimSpace(data["url"])
350+ description := strings.TrimSpace(data["description"])
351+ if key == "" {
352+ return "", port.SearchShortcut{}, fmt.Errorf("search shortcut key is required")
353+ }
354+ if description == "" {
355+ return "", port.SearchShortcut{}, fmt.Errorf("search shortcut description is required")
356+ }
357+ if err := validateSearchURL(url, "search shortcut URL"); err != nil {
358+ return "", port.SearchShortcut{}, err
359+ }
360+ return key, port.SearchShortcut{URL: url, Description: description}, nil
361+}
362+
363+func validateSearchURL(raw, label string) error {
364+ if strings.TrimSpace(raw) == "" {
365+ return fmt.Errorf("%s is required", label)
366+ }
367+ if !strings.Contains(raw, "%s") {
368+ return fmt.Errorf("%s must contain %%s placeholder", label)
369+ }
370+ return nil
371+}
372+
373+func validPerformanceProfile(profile string) bool {
374+ switch profile {
375+ case "default", "lite", "max", "custom", "":
376+ return true
377+ default:
378+ return false
379+ }
380+}
381+
382+func parseConfigInt(raw, label string) (int, error) {
383+ value, err := strconv.Atoi(strings.TrimSpace(raw))
384+ if err != nil {
385+ return 0, fmt.Errorf("invalid %s", label)
386+ }
387+ return value, nil
388+}
389+
390+func parseConfigFloat(raw, label string) (float64, error) {
391+ value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
392+ if err != nil {
393+ return 0, fmt.Errorf("invalid %s", label)
394+ }
395+ return value, nil
396+}
397+
398+func parseKeyList(raw string) []string {
399+ raw = strings.TrimSpace(raw)
400+ if raw == "" {
401+ return nil
402+ }
403+ parts := strings.Split(raw, ",")
404+ if len(parts) == 1 {
405+ parts = strings.Fields(raw)
406+ }
407+ keys := make([]string, 0, len(parts))
408+ for _, part := range parts {
409+ key := strings.TrimSpace(part)
410+ if key != "" {
411+ keys = append(keys, key)
412+ }
413+ }
414+ return keys
415+}
416+
417+func keybindingConflictCount(resp any) int {
418+ switch v := resp.(type) {
419+ case nil:
420+ return 0
421+ case port.SetKeybindingResponse:
422+ return len(v.Conflicts)
423+ case *port.SetKeybindingResponse:
424+ if v == nil {
425+ return 0
426+ }
427+ return len(v.Conflicts)
428+ case map[string]any:
429+ if conflicts, ok := anySlice(v["conflicts"]); ok {
430+ return len(conflicts)
431+ }
432+ }
433+ return 0
434+}
@@ -0,0 +1,434 @@
1+package systemviews
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strconv"
7+ "strings"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+)
11+
12+const (
13+ configActionSaveAppearance = "config.appearance.save"
14+ configActionResetAppearance = "config.appearance.reset"
15+ configActionSaveSearch = "config.search.save"
16+ configActionCreateSearchShortcut = "config.searchShortcut.create"
17+ configActionUpdateSearchShortcut = "config.searchShortcut.update"
18+ configActionDeleteSearchShortcut = "config.searchShortcut.delete"
19+ configActionSavePerformance = "config.performance.save"
20+ configActionSetKeybinding = "config.keybinding.set"
21+ configActionResetKeybinding = "config.keybinding.reset"
22+ configActionResetAllKeybindings = "config.keybinding.resetAll"
23+ configRequestIDPrefix = "systemviews-config"
24+)
25+
26+func (a *App) handleConfigAction(ctx context.Context, event DOMAction) error {
27+ if a.deps.Config == nil {
28+ return fmt.Errorf("config service not configured")
29+ }
30+
31+ switch event.Action {
32+ case configActionSaveAppearance:
33+ return a.saveAppearanceConfig(ctx, event.Data)
34+ case configActionResetAppearance:
35+ return a.resetAppearanceConfig(ctx)
36+ case configActionSaveSearch:
37+ return a.saveSearchConfig(ctx, event.Data)
38+ case configActionCreateSearchShortcut:
39+ return a.createSearchShortcut(ctx, event.Data)
40+ case configActionUpdateSearchShortcut:
41+ return a.updateSearchShortcut(ctx, event.Data)
42+ case configActionDeleteSearchShortcut:
43+ return a.deleteSearchShortcut(ctx, event.Data)
44+ case configActionSavePerformance:
45+ return a.savePerformanceConfig(ctx, event.Data)
46+ case configActionSetKeybinding:
47+ return a.setConfigKeybinding(ctx, event.Data)
48+ case configActionResetKeybinding:
49+ return a.resetConfigKeybinding(ctx, event.Data)
50+ case configActionResetAllKeybindings:
51+ if err := a.deps.Config.ResetAllKeybindings(ctx); err != nil {
52+ return err
53+ }
54+ a.configNotice = "Reset all keybindings to defaults"
55+ }
56+ return nil
57+}
58+
59+func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string) error {
60+ cfg, err := a.editableConfig(ctx)
61+ if err != nil {
62+ return err
63+ }
64+
65+ fontSize, err := parseConfigInt(data["default_font_size"], "default font size")
66+ if err != nil {
67+ return err
68+ }
69+ uiScale, err := parseConfigFloat(data["default_ui_scale"], "UI scale")
70+ if err != nil {
71+ return err
72+ }
73+
74+ cfg.Appearance.SansFont = strings.TrimSpace(data["sans_font"])
75+ cfg.Appearance.SerifFont = strings.TrimSpace(data["serif_font"])
76+ cfg.Appearance.MonospaceFont = strings.TrimSpace(data["monospace_font"])
77+ cfg.Appearance.DefaultFontSize = fontSize
78+ cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])
79+ cfg.Appearance.LightPalette = paletteFromForm(data, "light")
80+ cfg.Appearance.DarkPalette = paletteFromForm(data, "dark")
81+ cfg.DefaultUIScale = uiScale
82+
83+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
84+ return err
85+ }
86+ a.configNotice = "Saved appearance settings"
87+ return nil
88+}
89+
90+func (a *App) resetAppearanceConfig(ctx context.Context) error {
91+ cfg, err := a.editableConfig(ctx)
92+ if err != nil {
93+ return err
94+ }
95+ defaults, err := a.deps.Config.Default(ctx)
96+ if err != nil {
97+ return err
98+ }
99+ cfg.Appearance = defaults.Appearance
100+ cfg.DefaultUIScale = defaults.DefaultUIScale
101+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
102+ return err
103+ }
104+ a.configNotice = "Reset appearance settings to defaults"
105+ return nil
106+}
107+
108+func (a *App) saveSearchConfig(ctx context.Context, data map[string]string) error {
109+ cfg, err := a.editableConfig(ctx)
110+ if err != nil {
111+ return err
112+ }
113+ cfg.DefaultSearchEngine = strings.TrimSpace(data["default_search_engine"])
114+ if err := validateSearchURL(cfg.DefaultSearchEngine, "default search engine"); err != nil {
115+ return err
116+ }
117+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
118+ return err
119+ }
120+ a.configNotice = "Saved default search engine"
121+ return nil
122+}
123+
124+func (a *App) createSearchShortcut(ctx context.Context, data map[string]string) error {
125+ cfg, err := a.editableConfig(ctx)
126+ if err != nil {
127+ return err
128+ }
129+ key, shortcut, err := searchShortcutFromForm(data, "key")
130+ if err != nil {
131+ return err
132+ }
133+ cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
134+ if _, exists := cfg.SearchShortcuts[key]; exists {
135+ return fmt.Errorf("search shortcut %q already exists", key)
136+ }
137+ cfg.SearchShortcuts[key] = shortcut
138+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
139+ return err
140+ }
141+ a.configNotice = "Created search shortcut " + key
142+ return nil
143+}
144+
145+func (a *App) updateSearchShortcut(ctx context.Context, data map[string]string) error {
146+ cfg, err := a.editableConfig(ctx)
147+ if err != nil {
148+ return err
149+ }
150+ oldKey := strings.TrimSpace(data["key"])
151+ if oldKey == "" {
152+ return fmt.Errorf("search shortcut key is required")
153+ }
154+ newKey, shortcut, err := searchShortcutFromForm(data, "new_key")
155+ if err != nil {
156+ return err
157+ }
158+ cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
159+ if _, exists := cfg.SearchShortcuts[oldKey]; !exists {
160+ return fmt.Errorf("search shortcut %q not found", oldKey)
161+ }
162+ if newKey != oldKey {
163+ if _, exists := cfg.SearchShortcuts[newKey]; exists {
164+ return fmt.Errorf("search shortcut %q already exists", newKey)
165+ }
166+ delete(cfg.SearchShortcuts, oldKey)
167+ }
168+ cfg.SearchShortcuts[newKey] = shortcut
169+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
170+ return err
171+ }
172+ a.configNotice = "Saved search shortcut " + newKey
173+ return nil
174+}
175+
176+func (a *App) deleteSearchShortcut(ctx context.Context, data map[string]string) error {
177+ cfg, err := a.editableConfig(ctx)
178+ if err != nil {
179+ return err
180+ }
181+ key := strings.TrimSpace(data["key"])
182+ if key == "" {
183+ return fmt.Errorf("search shortcut key is required")
184+ }
185+ cfg.SearchShortcuts = cloneSearchShortcuts(cfg.SearchShortcuts)
186+ delete(cfg.SearchShortcuts, key)
187+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
188+ return err
189+ }
190+ a.configNotice = "Deleted search shortcut " + key
191+ return nil
192+}
193+
194+func (a *App) savePerformanceConfig(ctx context.Context, data map[string]string) error {
195+ cfg, err := a.editableConfig(ctx)
196+ if err != nil {
197+ return err
198+ }
199+ profile := strings.TrimSpace(data["profile"])
200+ if !validPerformanceProfile(profile) {
201+ return fmt.Errorf("invalid performance profile")
202+ }
203+ skiaCPU, err := parseConfigInt(data["skia_cpu_threads"], "Skia CPU threads")
204+ if err != nil {
205+ return err
206+ }
207+ skiaGPU, err := parseConfigInt(data["skia_gpu_threads"], "Skia GPU threads")
208+ if err != nil {
209+ return err
210+ }
211+ webMemory, err := parseConfigInt(data["web_process_memory_mb"], "web process memory")
212+ if err != nil {
213+ return err
214+ }
215+ networkMemory, err := parseConfigInt(data["network_process_memory_mb"], "network process memory")
216+ if err != nil {
217+ return err
218+ }
219+ prewarm, err := parseConfigInt(data["webview_pool_prewarm"], "WebView pool prewarm")
220+ if err != nil {
221+ return err
222+ }
223+
224+ cfg.Performance.Profile = profile
225+ cfg.Performance.Custom = port.WebUICustomPerformanceConfig{
226+ SkiaCPUThreads: skiaCPU,
227+ SkiaGPUThreads: skiaGPU,
228+ WebProcessMemoryMB: webMemory,
229+ NetworkProcessMemoryMB: networkMemory,
230+ WebViewPoolPrewarm: prewarm,
231+ }
232+ if err := a.saveEditableConfig(ctx, cfg); err != nil {
233+ return err
234+ }
235+ a.configNotice = "Saved performance settings. Restart may be required."
236+ return nil
237+}
238+
239+func (a *App) setConfigKeybinding(ctx context.Context, data map[string]string) error {
240+ mode := strings.TrimSpace(data["mode"])
241+ action := strings.TrimSpace(data["action"])
242+ keys := parseKeyList(data["keys"])
243+ if mode == "" || action == "" {
244+ return fmt.Errorf("mode and action are required")
245+ }
246+ resp, err := a.deps.Config.SetKeybinding(ctx, port.SetKeybindingRequest{
247+ RequestID: configRequestIDPrefix,
248+ Mode: mode,
249+ Action: action,
250+ Keys: keys,
251+ })
252+ if err != nil {
253+ return err
254+ }
255+ if conflicts := keybindingConflictCount(resp); conflicts > 0 {
256+ a.configNotice = fmt.Sprintf("Saved keybinding with %d conflict(s)", conflicts)
257+ return nil
258+ }
259+ a.configNotice = "Saved keybinding " + action
260+ return nil
261+}
262+
263+func (a *App) resetConfigKeybinding(ctx context.Context, data map[string]string) error {
264+ mode := strings.TrimSpace(data["mode"])
265+ action := strings.TrimSpace(data["action"])
266+ if mode == "" || action == "" {
267+ return fmt.Errorf("mode and action are required")
268+ }
269+ if err := a.deps.Config.ResetKeybinding(ctx, port.ResetKeybindingRequest{
270+ RequestID: configRequestIDPrefix,
271+ Mode: mode,
272+ Action: action,
273+ }); err != nil {
274+ return err
275+ }
276+ a.configNotice = "Reset keybinding " + action
277+ return nil
278+}
279+
280+func (a *App) editableConfig(ctx context.Context) (port.WebUIConfig, error) {
281+ payload, err := a.currentConfigPayload(ctx)
282+ if err != nil {
283+ return port.WebUIConfig{}, err
284+ }
285+ return webUIConfigFromPayload(payload), nil
286+}
287+
288+func (a *App) currentConfigPayload(ctx context.Context) (port.SystemviewConfigPayload, error) {
289+ if a.config != nil {
290+ return *a.config, nil
291+ }
292+ cfg, err := a.deps.Config.Current(ctx)
293+ if err != nil {
294+ return port.SystemviewConfigPayload{}, err
295+ }
296+ a.config = &cfg
297+ return cfg, nil
298+}
299+
300+func (a *App) saveEditableConfig(ctx context.Context, cfg port.WebUIConfig) error {
301+ if err := a.deps.Config.Save(ctx, cfg); err != nil {
302+ return err
303+ }
304+ a.config = nil
305+ return nil
306+}
307+
308+func webUIConfigFromPayload(payload port.SystemviewConfigPayload) port.WebUIConfig {
309+ return port.WebUIConfig{
310+ Appearance: payload.Appearance,
311+ DefaultUIScale: payload.DefaultUIScale,
312+ DefaultSearchEngine: payload.DefaultSearchEngine,
313+ SearchShortcuts: cloneSearchShortcuts(payload.SearchShortcuts),
314+ Performance: port.WebUIPerformanceConfig{
315+ Profile: payload.Performance.Profile,
316+ Custom: port.WebUICustomPerformanceConfig{
317+ SkiaCPUThreads: payload.Performance.Custom.SkiaCPUThreads,
318+ SkiaGPUThreads: payload.Performance.Custom.SkiaGPUThreads,
319+ WebProcessMemoryMB: payload.Performance.Custom.WebProcessMemoryMB,
320+ NetworkProcessMemoryMB: payload.Performance.Custom.NetworkProcessMemoryMB,
321+ WebViewPoolPrewarm: payload.Performance.Custom.WebViewPoolPrewarm,
322+ },
323+ },
324+ }
325+}
326+
327+func cloneSearchShortcuts(shortcuts map[string]port.SearchShortcut) map[string]port.SearchShortcut {
328+ clone := make(map[string]port.SearchShortcut, len(shortcuts))
329+ for key, shortcut := range shortcuts {
330+ clone[key] = shortcut
331+ }
332+ return clone
333+}
334+
335+func paletteFromForm(data map[string]string, prefix string) port.ColorPalette {
336+ return port.ColorPalette{
337+ Background: strings.TrimSpace(data[prefix+"_background"]),
338+ Surface: strings.TrimSpace(data[prefix+"_surface"]),
339+ SurfaceVariant: strings.TrimSpace(data[prefix+"_surface_variant"]),
340+ Text: strings.TrimSpace(data[prefix+"_text"]),
341+ Muted: strings.TrimSpace(data[prefix+"_muted"]),
342+ Accent: strings.TrimSpace(data[prefix+"_accent"]),
343+ Border: strings.TrimSpace(data[prefix+"_border"]),
344+ }
345+}
346+
347+func searchShortcutFromForm(data map[string]string, keyField string) (string, port.SearchShortcut, error) {
348+ key := strings.TrimSpace(data[keyField])
349+ url := strings.TrimSpace(data["url"])
350+ description := strings.TrimSpace(data["description"])
351+ if key == "" {
352+ return "", port.SearchShortcut{}, fmt.Errorf("search shortcut key is required")
353+ }
354+ if description == "" {
355+ return "", port.SearchShortcut{}, fmt.Errorf("search shortcut description is required")
356+ }
357+ if err := validateSearchURL(url, "search shortcut URL"); err != nil {
358+ return "", port.SearchShortcut{}, err
359+ }
360+ return key, port.SearchShortcut{URL: url, Description: description}, nil
361+}
362+
363+func validateSearchURL(raw, label string) error {
364+ if strings.TrimSpace(raw) == "" {
365+ return fmt.Errorf("%s is required", label)
366+ }
367+ if !strings.Contains(raw, "%s") {
368+ return fmt.Errorf("%s must contain %%s placeholder", label)
369+ }
370+ return nil
371+}
372+
373+func validPerformanceProfile(profile string) bool {
374+ switch profile {
375+ case "default", "lite", "max", "custom", "":
376+ return true
377+ default:
378+ return false
379+ }
380+}
381+
382+func parseConfigInt(raw, label string) (int, error) {
383+ value, err := strconv.Atoi(strings.TrimSpace(raw))
384+ if err != nil {
385+ return 0, fmt.Errorf("invalid %s", label)
386+ }
387+ return value, nil
388+}
389+
390+func parseConfigFloat(raw, label string) (float64, error) {
391+ value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
392+ if err != nil {
393+ return 0, fmt.Errorf("invalid %s", label)
394+ }
395+ return value, nil
396+}
397+
398+func parseKeyList(raw string) []string {
399+ raw = strings.TrimSpace(raw)
400+ if raw == "" {
401+ return nil
402+ }
403+ parts := strings.Split(raw, ",")
404+ if len(parts) == 1 {
405+ parts = strings.Fields(raw)
406+ }
407+ keys := make([]string, 0, len(parts))
408+ for _, part := range parts {
409+ key := strings.TrimSpace(part)
410+ if key != "" {
411+ keys = append(keys, key)
412+ }
413+ }
414+ return keys
415+}
416+
417+func keybindingConflictCount(resp any) int {
418+ switch v := resp.(type) {
419+ case nil:
420+ return 0
421+ case port.SetKeybindingResponse:
422+ return len(v.Conflicts)
423+ case *port.SetKeybindingResponse:
424+ if v == nil {
425+ return 0
426+ }
427+ return len(v.Conflicts)
428+ case map[string]any:
429+ if conflicts, ok := anySlice(v["conflicts"]); ok {
430+ return len(conflicts)
431+ }
432+ }
433+ return 0
434+}
M internal/ui/systemviews/config_templ.go +946 -83 generated file: collapsed
M internal/ui/systemviews/config_view.go +55 -2
@@ -12,10 +12,12 @@ import (
1212 type configRenderData struct {
1313 Config port.SystemviewConfigPayload
1414 Keybindings any
15+ Notice string
16+ Error string
1517 }
1618
17-func configHTML(cfg port.SystemviewConfigPayload, keybindings any) string {
18- return mustRenderComponent(ConfigView(configRenderData{Config: cfg, Keybindings: keybindings}))
19+func configHTML(data configRenderData) string {
20+ return mustRenderComponent(ConfigView(data))
1921 }
2022
2123 func configKeybindingSummary(keybindings any) string {
@@ -94,6 +96,57 @@ func keybindingStatus(binding renderedKeybinding) string {
9496 return "default"
9597 }
9698
99+func keybindingKeysValue(keys []string) string {
100+ return strings.Join(keys, ", ")
101+}
102+
103+type searchShortcutRow struct {
104+ Key string
105+ URL string
106+ Description string
107+}
108+
109+func searchShortcutRows(shortcuts map[string]port.SearchShortcut) []searchShortcutRow {
110+ if len(shortcuts) == 0 {
111+ return nil
112+ }
113+ keys := make([]string, 0, len(shortcuts))
114+ for key := range shortcuts {
115+ keys = append(keys, key)
116+ }
117+ sort.Strings(keys)
118+ rows := make([]searchShortcutRow, 0, len(keys))
119+ for _, key := range keys {
120+ shortcut := shortcuts[key]
121+ rows = append(rows, searchShortcutRow{
122+ Key: key,
123+ URL: shortcut.URL,
124+ Description: shortcut.Description,
125+ })
126+ }
127+ return rows
128+}
129+
130+func formatConfigInt(value int) string {
131+ return fmt.Sprintf("%d", value)
132+}
133+
134+func formatConfigFloat(value float64) string {
135+ return fmt.Sprintf("%g", value)
136+}
137+
138+func colorSchemeSelected(current, option string) bool {
139+ return strings.EqualFold(strings.TrimSpace(current), option)
140+}
141+
142+func performanceProfileSelected(current, option string) bool {
143+ return strings.EqualFold(strings.TrimSpace(current), option)
144+}
145+
146+func engineIsWebKit(engine string) bool {
147+ return strings.EqualFold(strings.TrimSpace(engine), "webkit")
148+}
149+
97150 func flattenValues(value any) []kvPair {
98151 rows := make([]kvPair, 0)
99152 flattenValue(&rows, "", reflect.ValueOf(value))
@@ -12,10 +12,12 @@ import (
12 type configRenderData struct {12 type configRenderData struct {
13 Config port.SystemviewConfigPayload13 Config port.SystemviewConfigPayload
14 Keybindings any14 Keybindings any
15+ Notice string
16+ Error string
15 }17 }
16 18
17-func configHTML(cfg port.SystemviewConfigPayload, keybindings any) string {19+func configHTML(data configRenderData) string {
18- return mustRenderComponent(ConfigView(configRenderData{Config: cfg, Keybindings: keybindings}))20+ return mustRenderComponent(ConfigView(data))
19 }21 }
20 22
21 func configKeybindingSummary(keybindings any) string {23 func configKeybindingSummary(keybindings any) string {
@@ -94,6 +96,57 @@ func keybindingStatus(binding renderedKeybinding) string {
94 return "default"96 return "default"
95 }97 }
96 98
99+func keybindingKeysValue(keys []string) string {
100+ return strings.Join(keys, ", ")
101+}
102+
103+type searchShortcutRow struct {
104+ Key string
105+ URL string
106+ Description string
107+}
108+
109+func searchShortcutRows(shortcuts map[string]port.SearchShortcut) []searchShortcutRow {
110+ if len(shortcuts) == 0 {
111+ return nil
112+ }
113+ keys := make([]string, 0, len(shortcuts))
114+ for key := range shortcuts {
115+ keys = append(keys, key)
116+ }
117+ sort.Strings(keys)
118+ rows := make([]searchShortcutRow, 0, len(keys))
119+ for _, key := range keys {
120+ shortcut := shortcuts[key]
121+ rows = append(rows, searchShortcutRow{
122+ Key: key,
123+ URL: shortcut.URL,
124+ Description: shortcut.Description,
125+ })
126+ }
127+ return rows
128+}
129+
130+func formatConfigInt(value int) string {
131+ return fmt.Sprintf("%d", value)
132+}
133+
134+func formatConfigFloat(value float64) string {
135+ return fmt.Sprintf("%g", value)
136+}
137+
138+func colorSchemeSelected(current, option string) bool {
139+ return strings.EqualFold(strings.TrimSpace(current), option)
140+}
141+
142+func performanceProfileSelected(current, option string) bool {
143+ return strings.EqualFold(strings.TrimSpace(current), option)
144+}
145+
146+func engineIsWebKit(engine string) bool {
147+ return strings.EqualFold(strings.TrimSpace(engine), "webkit")
148+}
149+
97 func flattenValues(value any) []kvPair {150 func flattenValues(value any) []kvPair {
98 rows := make([]kvPair, 0)151 rows := make([]kvPair, 0)
99 flattenValue(&rows, "", reflect.ValueOf(value))152 flattenValue(&rows, "", reflect.ValueOf(value))
M internal/ui/systemviews/history_actions.go +11 -0
@@ -50,6 +50,17 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
5050 a.renderRouteError(err)
5151 }
5252 return a.mountRenderedHTML()
53+ case RouteConfig:
54+ a.configError = ""
55+ if err := a.handleConfigAction(ctx, event); err != nil {
56+ a.configNotice = ""
57+ a.configError = err.Error()
58+ }
59+ a.loadShellTheme(ctx)
60+ if err := a.loadConfigRoute(ctx); err != nil {
61+ a.renderRouteError(err)
62+ }
63+ return a.mountRenderedHTML()
5364 default:
5465 return nil
5566 }
@@ -50,6 +50,17 @@ func (a *App) HandleDOMAction(ctx context.Context, event DOMAction) error {
50 a.renderRouteError(err)50 a.renderRouteError(err)
51 }51 }
52 return a.mountRenderedHTML()52 return a.mountRenderedHTML()
53+ case RouteConfig:
54+ a.configError = ""
55+ if err := a.handleConfigAction(ctx, event); err != nil {
56+ a.configNotice = ""
57+ a.configError = err.Error()
58+ }
59+ a.loadShellTheme(ctx)
60+ if err := a.loadConfigRoute(ctx); err != nil {
61+ a.renderRouteError(err)
62+ }
63+ return a.mountRenderedHTML()
53 default:64 default:
54 return nil65 return nil
55 }66 }

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