detached 608a18d

refactor(config): add typed runtime config provider

commit
608a18d46442171391326e6dc518aab956418b0a
author
brice · · 2026-06-23 14:03 UTC
committer
brice · · 2026-06-23 20:01 UTC
parent
aebb5c1
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +129 -0
M internal/bootstrap/runtime_config.go +64 -0
@@ -1,10 +1,58 @@
11 package bootstrap
22
33 import (
4+ "sync"
5+
46 "github.com/bnema/dumber/internal/application/port"
57 "github.com/bnema/dumber/internal/infrastructure/config"
68 )
79
10+type configChangeManager interface {
11+ Watch() error
12+ OnConfigChange(func(*config.Config))
13+}
14+
15+type runtimeConfigProvider struct {
16+ mu sync.RWMutex
17+ current port.RuntimeConfigSnapshot
18+ manager configChangeManager
19+}
20+
21+func NewRuntimeConfigProvider(cfg *config.Config, manager configChangeManager) port.RuntimeConfigProvider {
22+ return &runtimeConfigProvider{
23+ current: RuntimeConfigSnapshotFromConfig(cfg),
24+ manager: manager,
25+ }
26+}
27+
28+func (p *runtimeConfigProvider) Current() port.RuntimeConfigSnapshot {
29+ p.mu.RLock()
30+ defer p.mu.RUnlock()
31+ return cloneRuntimeConfigSnapshot(p.current)
32+}
33+
34+func (p *runtimeConfigProvider) Watch() error {
35+ if p == nil || p.manager == nil {
36+ return nil
37+ }
38+ return p.manager.Watch()
39+}
40+
41+func (p *runtimeConfigProvider) OnChange(callback func(port.RuntimeConfigSnapshot)) {
42+ if p == nil || p.manager == nil {
43+ return
44+ }
45+ p.manager.OnConfigChange(func(cfg *config.Config) {
46+ snapshot := RuntimeConfigSnapshotFromConfig(cfg)
47+ p.mu.Lock()
48+ p.current = snapshot
49+ p.mu.Unlock()
50+ if callback != nil {
51+ callback(cloneRuntimeConfigSnapshot(snapshot))
52+ }
53+ })
54+}
55+
856 func EngineSettingsPayloadFromConfig(cfg *config.Config) port.EngineSettingsPayload {
957 if cfg == nil {
1058 return port.EngineSettingsPayload{}
@@ -65,6 +113,22 @@ func runtimeSearchShortcutsFromConfig(in map[string]config.SearchShortcut) map[s
65113 return out
66114 }
67115
116+func cloneRuntimeConfigSnapshot(snapshot port.RuntimeConfigSnapshot) port.RuntimeConfigSnapshot {
117+ snapshot.UI.SearchShortcuts = cloneRuntimeSearchShortcuts(snapshot.UI.SearchShortcuts)
118+ return snapshot
119+}
120+
121+func cloneRuntimeSearchShortcuts(in map[string]port.RuntimeSearchShortcut) map[string]port.RuntimeSearchShortcut {
122+ if in == nil {
123+ return nil
124+ }
125+ out := make(map[string]port.RuntimeSearchShortcut, len(in))
126+ for key, shortcut := range in {
127+ out[key] = shortcut
128+ }
129+ return out
130+}
131+
68132 func engineHardwareDecodingModeFromConfig(mode config.HardwareDecodingMode) port.EngineHardwareDecodingMode {
69133 switch mode {
70134 case config.HardwareDecodingForce:
@@ -1,10 +1,58 @@
1 package bootstrap1 package bootstrap
2 2
3 import (3 import (
4+ "sync"
5+
4 "github.com/bnema/dumber/internal/application/port"6 "github.com/bnema/dumber/internal/application/port"
5 "github.com/bnema/dumber/internal/infrastructure/config"7 "github.com/bnema/dumber/internal/infrastructure/config"
6 )8 )
7 9
10+type configChangeManager interface {
11+ Watch() error
12+ OnConfigChange(func(*config.Config))
13+}
14+
15+type runtimeConfigProvider struct {
16+ mu sync.RWMutex
17+ current port.RuntimeConfigSnapshot
18+ manager configChangeManager
19+}
20+
21+func NewRuntimeConfigProvider(cfg *config.Config, manager configChangeManager) port.RuntimeConfigProvider {
22+ return &runtimeConfigProvider{
23+ current: RuntimeConfigSnapshotFromConfig(cfg),
24+ manager: manager,
25+ }
26+}
27+
28+func (p *runtimeConfigProvider) Current() port.RuntimeConfigSnapshot {
29+ p.mu.RLock()
30+ defer p.mu.RUnlock()
31+ return cloneRuntimeConfigSnapshot(p.current)
32+}
33+
34+func (p *runtimeConfigProvider) Watch() error {
35+ if p == nil || p.manager == nil {
36+ return nil
37+ }
38+ return p.manager.Watch()
39+}
40+
41+func (p *runtimeConfigProvider) OnChange(callback func(port.RuntimeConfigSnapshot)) {
42+ if p == nil || p.manager == nil {
43+ return
44+ }
45+ p.manager.OnConfigChange(func(cfg *config.Config) {
46+ snapshot := RuntimeConfigSnapshotFromConfig(cfg)
47+ p.mu.Lock()
48+ p.current = snapshot
49+ p.mu.Unlock()
50+ if callback != nil {
51+ callback(cloneRuntimeConfigSnapshot(snapshot))
52+ }
53+ })
54+}
55+
8 func EngineSettingsPayloadFromConfig(cfg *config.Config) port.EngineSettingsPayload {56 func EngineSettingsPayloadFromConfig(cfg *config.Config) port.EngineSettingsPayload {
9 if cfg == nil {57 if cfg == nil {
10 return port.EngineSettingsPayload{}58 return port.EngineSettingsPayload{}
@@ -65,6 +113,22 @@ func runtimeSearchShortcutsFromConfig(in map[string]config.SearchShortcut) map[s
65 return out113 return out
66 }114 }
67 115
116+func cloneRuntimeConfigSnapshot(snapshot port.RuntimeConfigSnapshot) port.RuntimeConfigSnapshot {
117+ snapshot.UI.SearchShortcuts = cloneRuntimeSearchShortcuts(snapshot.UI.SearchShortcuts)
118+ return snapshot
119+}
120+
121+func cloneRuntimeSearchShortcuts(in map[string]port.RuntimeSearchShortcut) map[string]port.RuntimeSearchShortcut {
122+ if in == nil {
123+ return nil
124+ }
125+ out := make(map[string]port.RuntimeSearchShortcut, len(in))
126+ for key, shortcut := range in {
127+ out[key] = shortcut
128+ }
129+ return out
130+}
131+
68 func engineHardwareDecodingModeFromConfig(mode config.HardwareDecodingMode) port.EngineHardwareDecodingMode {132 func engineHardwareDecodingModeFromConfig(mode config.HardwareDecodingMode) port.EngineHardwareDecodingMode {
69 switch mode {133 switch mode {
70 case config.HardwareDecodingForce:134 case config.HardwareDecodingForce:
M internal/bootstrap/runtime_config_test.go +65 -0
@@ -8,6 +8,20 @@ import (
88 "github.com/bnema/dumber/internal/infrastructure/config"
99 )
1010
11+type fakeConfigManager struct {
12+ watchCalls int
13+ callbacks []func(*config.Config)
14+}
15+
16+func (f *fakeConfigManager) Watch() error {
17+ f.watchCalls++
18+ return nil
19+}
20+
21+func (f *fakeConfigManager) OnConfigChange(callback func(*config.Config)) {
22+ f.callbacks = append(f.callbacks, callback)
23+}
24+
1125 func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) {
1226 cfg := config.DefaultConfig()
1327 cfg.DefaultUIScale = 1.25
@@ -41,6 +55,57 @@ func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) {
4155 }
4256 }
4357
58+func TestRuntimeConfigProviderUpdatesSnapshotBeforeCallback(t *testing.T) {
59+ initial := config.DefaultConfig()
60+ initial.DefaultUIScale = 1
61+ manager := &fakeConfigManager{}
62+ provider := NewRuntimeConfigProvider(initial, manager)
63+
64+ var seen port.RuntimeConfigSnapshot
65+ provider.OnChange(func(snapshot port.RuntimeConfigSnapshot) {
66+ seen = snapshot
67+ })
68+
69+ next := config.DefaultConfig()
70+ next.DefaultUIScale = 1.8
71+ manager.callbacks[0](next)
72+
73+ if got := provider.Current().UI.DefaultUIScale; got != 1.8 {
74+ t.Fatalf("Current DefaultUIScale=%v, want 1.8", got)
75+ }
76+ if seen.UI.DefaultUIScale != 1.8 {
77+ t.Fatalf("callback DefaultUIScale=%v, want 1.8", seen.UI.DefaultUIScale)
78+ }
79+}
80+
81+func TestRuntimeConfigProviderWatchDelegatesToManager(t *testing.T) {
82+ manager := &fakeConfigManager{}
83+ provider := NewRuntimeConfigProvider(config.DefaultConfig(), manager)
84+
85+ if err := provider.Watch(); err != nil {
86+ t.Fatalf("Watch returned error: %v", err)
87+ }
88+ if manager.watchCalls != 1 {
89+ t.Fatalf("watchCalls=%d, want 1", manager.watchCalls)
90+ }
91+}
92+
93+func TestRuntimeConfigProviderCurrentReturnsMapClone(t *testing.T) {
94+ cfg := config.DefaultConfig()
95+ cfg.SearchShortcuts = map[string]config.SearchShortcut{
96+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"},
97+ }
98+ provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{})
99+
100+ first := provider.Current()
101+ first.UI.SearchShortcuts["gh"] = port.RuntimeSearchShortcut{URL: "mutated"}
102+ second := provider.Current()
103+
104+ if second.UI.SearchShortcuts["gh"].URL == "mutated" {
105+ t.Fatal("Current must return a snapshot clone so callers cannot mutate provider state")
106+ }
107+}
108+
44109 func TestEngineSettingsPayloadFromNilConfigReturnsZeroPayload(t *testing.T) {
45110 got := EngineSettingsPayloadFromConfig(nil)
46111 if got != (port.EngineSettingsPayload{}) {
@@ -8,6 +8,20 @@ import (
8 "github.com/bnema/dumber/internal/infrastructure/config"8 "github.com/bnema/dumber/internal/infrastructure/config"
9 )9 )
10 10
11+type fakeConfigManager struct {
12+ watchCalls int
13+ callbacks []func(*config.Config)
14+}
15+
16+func (f *fakeConfigManager) Watch() error {
17+ f.watchCalls++
18+ return nil
19+}
20+
21+func (f *fakeConfigManager) OnConfigChange(callback func(*config.Config)) {
22+ f.callbacks = append(f.callbacks, callback)
23+}
24+
11 func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) {25 func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) {
12 cfg := config.DefaultConfig()26 cfg := config.DefaultConfig()
13 cfg.DefaultUIScale = 1.2527 cfg.DefaultUIScale = 1.25
@@ -41,6 +55,57 @@ func TestEngineSettingsPayloadFromConfigMapsRuntimeFields(t *testing.T) {
41 }55 }
42 }56 }
43 57
58+func TestRuntimeConfigProviderUpdatesSnapshotBeforeCallback(t *testing.T) {
59+ initial := config.DefaultConfig()
60+ initial.DefaultUIScale = 1
61+ manager := &fakeConfigManager{}
62+ provider := NewRuntimeConfigProvider(initial, manager)
63+
64+ var seen port.RuntimeConfigSnapshot
65+ provider.OnChange(func(snapshot port.RuntimeConfigSnapshot) {
66+ seen = snapshot
67+ })
68+
69+ next := config.DefaultConfig()
70+ next.DefaultUIScale = 1.8
71+ manager.callbacks[0](next)
72+
73+ if got := provider.Current().UI.DefaultUIScale; got != 1.8 {
74+ t.Fatalf("Current DefaultUIScale=%v, want 1.8", got)
75+ }
76+ if seen.UI.DefaultUIScale != 1.8 {
77+ t.Fatalf("callback DefaultUIScale=%v, want 1.8", seen.UI.DefaultUIScale)
78+ }
79+}
80+
81+func TestRuntimeConfigProviderWatchDelegatesToManager(t *testing.T) {
82+ manager := &fakeConfigManager{}
83+ provider := NewRuntimeConfigProvider(config.DefaultConfig(), manager)
84+
85+ if err := provider.Watch(); err != nil {
86+ t.Fatalf("Watch returned error: %v", err)
87+ }
88+ if manager.watchCalls != 1 {
89+ t.Fatalf("watchCalls=%d, want 1", manager.watchCalls)
90+ }
91+}
92+
93+func TestRuntimeConfigProviderCurrentReturnsMapClone(t *testing.T) {
94+ cfg := config.DefaultConfig()
95+ cfg.SearchShortcuts = map[string]config.SearchShortcut{
96+ "gh": {URL: "https://github.com/search?q=%s", Description: "GitHub"},
97+ }
98+ provider := NewRuntimeConfigProvider(cfg, &fakeConfigManager{})
99+
100+ first := provider.Current()
101+ first.UI.SearchShortcuts["gh"] = port.RuntimeSearchShortcut{URL: "mutated"}
102+ second := provider.Current()
103+
104+ if second.UI.SearchShortcuts["gh"].URL == "mutated" {
105+ t.Fatal("Current must return a snapshot clone so callers cannot mutate provider state")
106+ }
107+}
108+
44 func TestEngineSettingsPayloadFromNilConfigReturnsZeroPayload(t *testing.T) {109 func TestEngineSettingsPayloadFromNilConfigReturnsZeroPayload(t *testing.T) {
45 got := EngineSettingsPayloadFromConfig(nil)110 got := EngineSettingsPayloadFromConfig(nil)
46 if got != (port.EngineSettingsPayload{}) {111 if got != (port.EngineSettingsPayload{}) {

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