detached 7d8f24d

refactor(daemon): load runtime config from one snapshot

commit
7d8f24d0c5c1ea4be80d73b2759f9c58fae5cfc2
author
brice · · 2026-10-08 05:59 UTC
committer
brice · · 2026-10-08 08:12 UTC
parent
9ff586d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
9 files · +106 -110
M internal/usecase/daemon/attachment_suspension_expiry_test.go +1 -1
@@ -142,7 +142,7 @@ func TestSuspendedAttachmentSafetyExpiryEvictsAfterTimer(t *testing.T) {
142142
143143 func TestSuspendedAttachmentExpiryLeavesEphemeralSessionRegistered(t *testing.T) {
144144 d, sess, ac, timer, _ := newSuspendedExpiryFixture(t)
145- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: false})
145+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: false} })
146146 sess.mu.Lock()
147147 sess.ephemeral = true
148148 sess.mu.Unlock()
@@ -142,7 +142,7 @@ func TestSuspendedAttachmentSafetyExpiryEvictsAfterTimer(t *testing.T) {
142 142
143 func TestSuspendedAttachmentExpiryLeavesEphemeralSessionRegistered(t *testing.T) {143 func TestSuspendedAttachmentExpiryLeavesEphemeralSessionRegistered(t *testing.T) {
144 d, sess, ac, timer, _ := newSuspendedExpiryFixture(t)144 d, sess, ac, timer, _ := newSuspendedExpiryFixture(t)
145- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: false})145+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: false} })
146 sess.mu.Lock()146 sess.mu.Lock()
147 sess.ephemeral = true147 sess.ephemeral = true
148 sess.mu.Unlock()148 sess.mu.Unlock()
M internal/usecase/daemon/config.go +68 -61
@@ -18,7 +18,7 @@ import (
1818
1919 var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
2020
21-// themeConfigSnapshot is published as one immutable value. Its zero value is
21+// themeConfigSnapshot is the theme slice of runtimeConfig. Its zero value is
2222 // the default theme configuration: automatic mode with palette inheritance on.
2323 type themeConfigSnapshot struct {
2424 mode domain.ThemeMode
@@ -26,17 +26,54 @@ type themeConfigSnapshot struct {
2626 accent domain.ThemeAccent
2727 }
2828
29-func (d *Daemon) storeThemeConfig(cfg domain.Config) {
30- snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}
31- d.themeConfig.Store(&snapshot)
29+// runtimeConfig is the immutable derived configuration published by
30+// ApplyConfig as one atomic snapshot, so a reader never observes values from
31+// two different reloads. Never mutate a published snapshot; build a new one.
32+//
33+// The key bindings are deliberately not part of the snapshot: keys.Router reads
34+// them through its own *atomic.Pointer[keys.Bindings] (see Daemon.bindings).
35+type runtimeConfig struct {
36+ codeOverrides map[string]string
37+ restoreProcessAllowlist map[string]struct{}
38+ floating domain.FloatingConfig
39+ copy domain.CopyConfig
40+ palette domain.PaletteConfig
41+ nav domain.NavConfig
42+ tabs domain.TabsConfig
43+ ephemeral domain.EphemeralConfig
44+ scrollback domain.ScrollbackConfig
45+ theme themeConfigSnapshot
3246 }
3347
34-func (d *Daemon) currentThemeConfig() themeConfigSnapshot {
35- snapshot := d.themeConfig.Load()
36- if snapshot == nil {
37- return themeConfigSnapshot{}
48+// defaultRuntimeConfig is the configuration in force before the first
49+// ApplyConfig: vev defaults for every section, no code overrides, and the
50+// default theme snapshot.
51+func defaultRuntimeConfig() *runtimeConfig {
52+ defaults := domain.Defaults()
53+ return &runtimeConfig{
54+ codeOverrides: map[string]string{},
55+ restoreProcessAllowlist: buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses()),
56+ floating: defaults.Floating,
57+ copy: defaults.Copy,
58+ palette: defaults.Palette,
59+ nav: defaults.Nav,
60+ tabs: defaults.Tabs,
61+ ephemeral: defaults.Ephemeral,
62+ scrollback: domain.DefaultScrollbackConfig(),
3863 }
39- return *snapshot
64+}
65+
66+// runtimeConfig returns the current snapshot, or the defaults when none has
67+// been published (zero-value Daemon). The result is never nil.
68+func (d *Daemon) runtimeConfig() *runtimeConfig {
69+ if cfg := d.config.Load(); cfg != nil {
70+ return cfg
71+ }
72+ return defaultRuntimeConfig()
73+}
74+
75+func (d *Daemon) currentThemeConfig() themeConfigSnapshot {
76+ return d.runtimeConfig().theme
4077 }
4178
4279 // ApplyConfig validates and atomically swaps daemon runtime configuration.
@@ -49,27 +86,23 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
4986 scrollback = domain.DefaultScrollbackConfig()
5087 allWarnings = append(allWarnings, domain.Warning{Msg: "invalid scrollback limits; using vev defaults"})
5188 }
52- d.scrollbackConfig.Store(&scrollback)
89+ snapshot := &runtimeConfig{
90+ codeOverrides: overrides,
91+ restoreProcessAllowlist: restoreProcessAllowlistFromConfig(cfg.Snapshot),
92+ floating: cfg.Floating,
93+ copy: cfg.Copy,
94+ palette: cfg.Palette,
95+ nav: cfg.Nav,
96+ tabs: cfg.Tabs,
97+ ephemeral: cfg.Ephemeral,
98+ scrollback: scrollback,
99+ theme: themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent},
100+ }
101+ d.bindings.Store(bindings)
102+ d.config.Store(snapshot)
53103 for _, warning := range allWarnings {
54104 d.logConfigWarning(warning)
55105 }
56- allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot)
57- d.bindings.Store(bindings)
58- d.codeOverrides.Store(&overrides)
59- d.restoreProcessAllowlist.Store(&allowlist)
60- floating := cfg.Floating
61- d.floatingConfig.Store(&floating)
62- copyConfig := cfg.Copy
63- d.copyConfig.Store(&copyConfig)
64- palette := cfg.Palette
65- d.paletteConfig.Store(&palette)
66- nav := cfg.Nav
67- d.navConfig.Store(&nav)
68- tabs := cfg.Tabs
69- d.tabsConfig.Store(&tabs)
70- ephemeral := cfg.Ephemeral
71- d.ephemeralConfig.Store(&ephemeral)
72- d.storeThemeConfig(cfg)
73106 d.applyHistoryLimits()
74107 barChanged := false
75108 if d.barScripts != nil {
@@ -245,11 +278,7 @@ func buildRestoreProcessAllowlist(values []string) map[string]struct{} {
245278 }
246279
247280 func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} {
248- allow := d.restoreProcessAllowlist.Load()
249- if allow == nil {
250- return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
251- }
252- return *allow
281+ return d.runtimeConfig().restoreProcessAllowlist
253282 }
254283
255284 func (d *Daemon) logConfigWarning(w domain.Warning) {
@@ -261,53 +290,31 @@ func (d *Daemon) logConfigWarning(w domain.Warning) {
261290 }
262291
263292 func (d *Daemon) currentCopyConfig() domain.CopyConfig {
264- if cfg := d.copyConfig.Load(); cfg != nil {
265- return *cfg
266- }
267- return domain.Defaults().Copy
293+ return d.runtimeConfig().copy
268294 }
269295
270296 func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
271- if cfg := d.paletteConfig.Load(); cfg != nil {
272- return *cfg
273- }
274- return domain.Defaults().Palette
297+ return d.runtimeConfig().palette
275298 }
276299
277300 func (d *Daemon) currentNavConfig() domain.NavConfig {
278- if cfg := d.navConfig.Load(); cfg != nil {
279- return *cfg
280- }
281- return domain.Defaults().Nav
301+ return d.runtimeConfig().nav
282302 }
283303
284304 func (d *Daemon) currentTabsConfig() domain.TabsConfig {
285- if cfg := d.tabsConfig.Load(); cfg != nil {
286- return *cfg
287- }
288- return domain.Defaults().Tabs
305+ return d.runtimeConfig().tabs
289306 }
290307
291308 func (d *Daemon) currentEphemeralConfig() domain.EphemeralConfig {
292- if cfg := d.ephemeralConfig.Load(); cfg != nil {
293- return *cfg
294- }
295- return domain.Defaults().Ephemeral
309+ return d.runtimeConfig().ephemeral
296310 }
297311
298312 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
299- if cfg := d.floatingConfig.Load(); cfg != nil {
300- return *cfg
301- }
302- return domain.Defaults().Floating
313+ return d.runtimeConfig().floating
303314 }
304315
305316 func (d *Daemon) codeOverrideSnapshot() map[string]string {
306- overrides := d.codeOverrides.Load()
307- if overrides == nil {
308- return nil
309- }
310- return *overrides
317+ return d.runtimeConfig().codeOverrides
311318 }
312319
313320 func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {
@@ -18,7 +18,7 @@ import (
18 18
19 var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)19 var commandCodePattern = regexp.MustCompile(`^[A-Z0-9]{2,3}$`)
20 20
21-// themeConfigSnapshot is published as one immutable value. Its zero value is21+// themeConfigSnapshot is the theme slice of runtimeConfig. Its zero value is
22 // the default theme configuration: automatic mode with palette inheritance on.22 // the default theme configuration: automatic mode with palette inheritance on.
23 type themeConfigSnapshot struct {23 type themeConfigSnapshot struct {
24 mode domain.ThemeMode24 mode domain.ThemeMode
@@ -26,17 +26,54 @@ type themeConfigSnapshot struct {
26 accent domain.ThemeAccent26 accent domain.ThemeAccent
27 }27 }
28 28
29-func (d *Daemon) storeThemeConfig(cfg domain.Config) {29+// runtimeConfig is the immutable derived configuration published by
30- snapshot := themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}30+// ApplyConfig as one atomic snapshot, so a reader never observes values from
31- d.themeConfig.Store(&snapshot)31+// two different reloads. Never mutate a published snapshot; build a new one.
32+//
33+// The key bindings are deliberately not part of the snapshot: keys.Router reads
34+// them through its own *atomic.Pointer[keys.Bindings] (see Daemon.bindings).
35+type runtimeConfig struct {
36+ codeOverrides map[string]string
37+ restoreProcessAllowlist map[string]struct{}
38+ floating domain.FloatingConfig
39+ copy domain.CopyConfig
40+ palette domain.PaletteConfig
41+ nav domain.NavConfig
42+ tabs domain.TabsConfig
43+ ephemeral domain.EphemeralConfig
44+ scrollback domain.ScrollbackConfig
45+ theme themeConfigSnapshot
32 }46 }
33 47
34-func (d *Daemon) currentThemeConfig() themeConfigSnapshot {48+// defaultRuntimeConfig is the configuration in force before the first
35- snapshot := d.themeConfig.Load()49+// ApplyConfig: vev defaults for every section, no code overrides, and the
36- if snapshot == nil {50+// default theme snapshot.
37- return themeConfigSnapshot{}51+func defaultRuntimeConfig() *runtimeConfig {
52+ defaults := domain.Defaults()
53+ return &runtimeConfig{
54+ codeOverrides: map[string]string{},
55+ restoreProcessAllowlist: buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses()),
56+ floating: defaults.Floating,
57+ copy: defaults.Copy,
58+ palette: defaults.Palette,
59+ nav: defaults.Nav,
60+ tabs: defaults.Tabs,
61+ ephemeral: defaults.Ephemeral,
62+ scrollback: domain.DefaultScrollbackConfig(),
38 }63 }
39- return *snapshot64+}
65+
66+// runtimeConfig returns the current snapshot, or the defaults when none has
67+// been published (zero-value Daemon). The result is never nil.
68+func (d *Daemon) runtimeConfig() *runtimeConfig {
69+ if cfg := d.config.Load(); cfg != nil {
70+ return cfg
71+ }
72+ return defaultRuntimeConfig()
73+}
74+
75+func (d *Daemon) currentThemeConfig() themeConfigSnapshot {
76+ return d.runtimeConfig().theme
40 }77 }
41 78
42 // ApplyConfig validates and atomically swaps daemon runtime configuration.79 // ApplyConfig validates and atomically swaps daemon runtime configuration.
@@ -49,27 +86,23 @@ func (d *Daemon) ApplyConfig(cfg domain.Config) {
49 scrollback = domain.DefaultScrollbackConfig()86 scrollback = domain.DefaultScrollbackConfig()
50 allWarnings = append(allWarnings, domain.Warning{Msg: "invalid scrollback limits; using vev defaults"})87 allWarnings = append(allWarnings, domain.Warning{Msg: "invalid scrollback limits; using vev defaults"})
51 }88 }
52- d.scrollbackConfig.Store(&scrollback)89+ snapshot := &runtimeConfig{
90+ codeOverrides: overrides,
91+ restoreProcessAllowlist: restoreProcessAllowlistFromConfig(cfg.Snapshot),
92+ floating: cfg.Floating,
93+ copy: cfg.Copy,
94+ palette: cfg.Palette,
95+ nav: cfg.Nav,
96+ tabs: cfg.Tabs,
97+ ephemeral: cfg.Ephemeral,
98+ scrollback: scrollback,
99+ theme: themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent},
100+ }
101+ d.bindings.Store(bindings)
102+ d.config.Store(snapshot)
53 for _, warning := range allWarnings {103 for _, warning := range allWarnings {
54 d.logConfigWarning(warning)104 d.logConfigWarning(warning)
55 }105 }
56- allowlist := restoreProcessAllowlistFromConfig(cfg.Snapshot)
57- d.bindings.Store(bindings)
58- d.codeOverrides.Store(&overrides)
59- d.restoreProcessAllowlist.Store(&allowlist)
60- floating := cfg.Floating
61- d.floatingConfig.Store(&floating)
62- copyConfig := cfg.Copy
63- d.copyConfig.Store(&copyConfig)
64- palette := cfg.Palette
65- d.paletteConfig.Store(&palette)
66- nav := cfg.Nav
67- d.navConfig.Store(&nav)
68- tabs := cfg.Tabs
69- d.tabsConfig.Store(&tabs)
70- ephemeral := cfg.Ephemeral
71- d.ephemeralConfig.Store(&ephemeral)
72- d.storeThemeConfig(cfg)
73 d.applyHistoryLimits()106 d.applyHistoryLimits()
74 barChanged := false107 barChanged := false
75 if d.barScripts != nil {108 if d.barScripts != nil {
@@ -245,11 +278,7 @@ func buildRestoreProcessAllowlist(values []string) map[string]struct{} {
245 }278 }
246 279
247 func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} {280 func (d *Daemon) restoreProcessAllowlistSnapshot() map[string]struct{} {
248- allow := d.restoreProcessAllowlist.Load()281+ return d.runtimeConfig().restoreProcessAllowlist
249- if allow == nil {
250- return buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
251- }
252- return *allow
253 }282 }
254 283
255 func (d *Daemon) logConfigWarning(w domain.Warning) {284 func (d *Daemon) logConfigWarning(w domain.Warning) {
@@ -261,53 +290,31 @@ func (d *Daemon) logConfigWarning(w domain.Warning) {
261 }290 }
262 291
263 func (d *Daemon) currentCopyConfig() domain.CopyConfig {292 func (d *Daemon) currentCopyConfig() domain.CopyConfig {
264- if cfg := d.copyConfig.Load(); cfg != nil {293+ return d.runtimeConfig().copy
265- return *cfg
266- }
267- return domain.Defaults().Copy
268 }294 }
269 295
270 func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {296 func (d *Daemon) currentPaletteConfig() domain.PaletteConfig {
271- if cfg := d.paletteConfig.Load(); cfg != nil {297+ return d.runtimeConfig().palette
272- return *cfg
273- }
274- return domain.Defaults().Palette
275 }298 }
276 299
277 func (d *Daemon) currentNavConfig() domain.NavConfig {300 func (d *Daemon) currentNavConfig() domain.NavConfig {
278- if cfg := d.navConfig.Load(); cfg != nil {301+ return d.runtimeConfig().nav
279- return *cfg
280- }
281- return domain.Defaults().Nav
282 }302 }
283 303
284 func (d *Daemon) currentTabsConfig() domain.TabsConfig {304 func (d *Daemon) currentTabsConfig() domain.TabsConfig {
285- if cfg := d.tabsConfig.Load(); cfg != nil {305+ return d.runtimeConfig().tabs
286- return *cfg
287- }
288- return domain.Defaults().Tabs
289 }306 }
290 307
291 func (d *Daemon) currentEphemeralConfig() domain.EphemeralConfig {308 func (d *Daemon) currentEphemeralConfig() domain.EphemeralConfig {
292- if cfg := d.ephemeralConfig.Load(); cfg != nil {309+ return d.runtimeConfig().ephemeral
293- return *cfg
294- }
295- return domain.Defaults().Ephemeral
296 }310 }
297 311
298 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {312 func (d *Daemon) currentFloatingConfig() domain.FloatingConfig {
299- if cfg := d.floatingConfig.Load(); cfg != nil {313+ return d.runtimeConfig().floating
300- return *cfg
301- }
302- return domain.Defaults().Floating
303 }314 }
304 315
305 func (d *Daemon) codeOverrideSnapshot() map[string]string {316 func (d *Daemon) codeOverrideSnapshot() map[string]string {
306- overrides := d.codeOverrides.Load()317+ return d.runtimeConfig().codeOverrides
307- if overrides == nil {
308- return nil
309- }
310- return *overrides
311 }318 }
312 319
313 func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {320 func commandWithOverrides(cmd command.Command, overrides map[string]string) command.Command {
M internal/usecase/daemon/config_test.go +19 -5
@@ -164,6 +164,20 @@ func TestEffectiveThemePaletteGate(t *testing.T) {
164164 }
165165 }
166166
167+// updateRuntimeConfig publishes a modified copy of the current runtime config
168+// snapshot, the way ApplyConfig does, without touching unrelated sections.
169+func updateRuntimeConfig(d *Daemon, mutate func(*runtimeConfig)) {
170+ next := *d.runtimeConfig()
171+ mutate(&next)
172+ d.config.Store(&next)
173+}
174+
175+func storeThemeForTest(d *Daemon, cfg domain.Config) {
176+ updateRuntimeConfig(d, func(rc *runtimeConfig) {
177+ rc.theme = themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}
178+ })
179+}
180+
167181 func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
168182 d := newTestDaemon(t, nil, stubClock{})
169183
@@ -184,7 +198,7 @@ func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
184198 {name: "light palette disabled", mode: domain.ThemeLight, gate: false},
185199 } {
186200 t.Run(tt.name, func(t *testing.T) {
187- d.storeThemeConfig(domain.Config{Theme: tt.mode, ThemePalette: tt.gate})
201+ storeThemeForTest(d, domain.Config{Theme: tt.mode, ThemePalette: tt.gate})
188202 require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig())
189203 })
190204 }
@@ -209,15 +223,15 @@ func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
209223 wg.Add(2)
210224 go func() {
211225 defer wg.Done()
212- d.storeThemeConfig(first)
226+ storeThemeForTest(d, first)
213227 close(firstPublished)
214228 <-firstObserved
215- d.storeThemeConfig(second)
229+ storeThemeForTest(d, second)
216230 close(secondPublished)
217231 <-secondObserved
218232 for range 10_000 {
219- d.storeThemeConfig(second)
220- d.storeThemeConfig(first)
233+ storeThemeForTest(d, second)
234+ storeThemeForTest(d, first)
221235 }
222236 }()
223237 go func() {
@@ -164,6 +164,20 @@ func TestEffectiveThemePaletteGate(t *testing.T) {
164 }164 }
165 }165 }
166 166
167+// updateRuntimeConfig publishes a modified copy of the current runtime config
168+// snapshot, the way ApplyConfig does, without touching unrelated sections.
169+func updateRuntimeConfig(d *Daemon, mutate func(*runtimeConfig)) {
170+ next := *d.runtimeConfig()
171+ mutate(&next)
172+ d.config.Store(&next)
173+}
174+
175+func storeThemeForTest(d *Daemon, cfg domain.Config) {
176+ updateRuntimeConfig(d, func(rc *runtimeConfig) {
177+ rc.theme = themeConfigSnapshot{mode: cfg.Theme, paletteOff: !cfg.ThemePalette, accent: cfg.ThemeAccent}
178+ })
179+}
180+
167 func TestThemeConfigSnapshotIsAtomic(t *testing.T) {181 func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
168 d := newTestDaemon(t, nil, stubClock{})182 d := newTestDaemon(t, nil, stubClock{})
169 183
@@ -184,7 +198,7 @@ func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
184 {name: "light palette disabled", mode: domain.ThemeLight, gate: false},198 {name: "light palette disabled", mode: domain.ThemeLight, gate: false},
185 } {199 } {
186 t.Run(tt.name, func(t *testing.T) {200 t.Run(tt.name, func(t *testing.T) {
187- d.storeThemeConfig(domain.Config{Theme: tt.mode, ThemePalette: tt.gate})201+ storeThemeForTest(d, domain.Config{Theme: tt.mode, ThemePalette: tt.gate})
188 require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig())202 require.Equal(t, themeConfigSnapshot{mode: tt.mode, paletteOff: !tt.gate}, d.currentThemeConfig())
189 })203 })
190 }204 }
@@ -209,15 +223,15 @@ func TestThemeConfigSnapshotIsAtomic(t *testing.T) {
209 wg.Add(2)223 wg.Add(2)
210 go func() {224 go func() {
211 defer wg.Done()225 defer wg.Done()
212- d.storeThemeConfig(first)226+ storeThemeForTest(d, first)
213 close(firstPublished)227 close(firstPublished)
214 <-firstObserved228 <-firstObserved
215- d.storeThemeConfig(second)229+ storeThemeForTest(d, second)
216 close(secondPublished)230 close(secondPublished)
217 <-secondObserved231 <-secondObserved
218 for range 10_000 {232 for range 10_000 {
219- d.storeThemeConfig(second)233+ storeThemeForTest(d, second)
220- d.storeThemeConfig(first)234+ storeThemeForTest(d, first)
221 }235 }
222 }()236 }()
223 go func() {237 go func() {
M internal/usecase/daemon/copy_scroll_animation_test.go +2 -2
@@ -113,13 +113,13 @@ func TestCopyScrollReducedMotionAndKeyboardCancellation(t *testing.T) {
113113 f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
114114 clock := newCoordinatorMockClock(t, 8)
115115 f.d.clock = clock.clock
116- f.d.copyConfig.Store(&domain.CopyConfig{ReduceMotion: true})
116+ updateRuntimeConfig(f.d, func(rc *runtimeConfig) { rc.copy = domain.CopyConfig{ReduceMotion: true} })
117117 rt := f.ac.overlays
118118 start := rt.copyMode.ViewportTop
119119 f.d.smoothCopyWheel(f.sess, f.ac, -3)
120120 require.Equal(t, start-3, rt.copyMode.ViewportTop)
121121 require.Empty(t, clock.timers, "reduced motion must not arm an animation")
122- f.d.copyConfig.Store(&domain.CopyConfig{})
122+ updateRuntimeConfig(f.d, func(rc *runtimeConfig) { rc.copy = domain.CopyConfig{} })
123123 f.d.smoothCopyWheel(f.sess, f.ac, -3)
124124 timer := awaitCoordinatorScheduledTimer(t, clock)
125125 require.True(t, rt.HandleInput(f.d, []byte("k")))
@@ -113,13 +113,13 @@ func TestCopyScrollReducedMotionAndKeyboardCancellation(t *testing.T) {
113 f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)113 f.ac.ackOutputState(f.ac.output.currentEpoch(), f.ac.output.next)
114 clock := newCoordinatorMockClock(t, 8)114 clock := newCoordinatorMockClock(t, 8)
115 f.d.clock = clock.clock115 f.d.clock = clock.clock
116- f.d.copyConfig.Store(&domain.CopyConfig{ReduceMotion: true})116+ updateRuntimeConfig(f.d, func(rc *runtimeConfig) { rc.copy = domain.CopyConfig{ReduceMotion: true} })
117 rt := f.ac.overlays117 rt := f.ac.overlays
118 start := rt.copyMode.ViewportTop118 start := rt.copyMode.ViewportTop
119 f.d.smoothCopyWheel(f.sess, f.ac, -3)119 f.d.smoothCopyWheel(f.sess, f.ac, -3)
120 require.Equal(t, start-3, rt.copyMode.ViewportTop)120 require.Equal(t, start-3, rt.copyMode.ViewportTop)
121 require.Empty(t, clock.timers, "reduced motion must not arm an animation")121 require.Empty(t, clock.timers, "reduced motion must not arm an animation")
122- f.d.copyConfig.Store(&domain.CopyConfig{})122+ updateRuntimeConfig(f.d, func(rc *runtimeConfig) { rc.copy = domain.CopyConfig{} })
123 f.d.smoothCopyWheel(f.sess, f.ac, -3)123 f.d.smoothCopyWheel(f.sess, f.ac, -3)
124 timer := awaitCoordinatorScheduledTimer(t, clock)124 timer := awaitCoordinatorScheduledTimer(t, clock)
125 require.True(t, rt.HandleInput(f.d, []byte("k")))125 require.True(t, rt.HandleInput(f.d, []byte("k")))
M internal/usecase/daemon/daemon.go +11 -32
@@ -230,21 +230,16 @@ type Daemon struct {
230230 procArgv func(int) ([]string, error)
231231 procGroupArgv func(int, int) ([]string, error)
232232 dirOrHome func(string) string
233- bindings atomic.Pointer[keys.Bindings]
234- codeOverrides atomic.Pointer[map[string]string]
235- restoreProcessAllowlist atomic.Pointer[map[string]struct{}]
236- floatingConfig atomic.Pointer[domain.FloatingConfig]
237- copyConfig atomic.Pointer[domain.CopyConfig]
238- paletteConfig atomic.Pointer[domain.PaletteConfig]
239- navConfig atomic.Pointer[domain.NavConfig]
240- tabsConfig atomic.Pointer[domain.TabsConfig]
241- ephemeralConfig atomic.Pointer[domain.EphemeralConfig]
242- scrollbackConfig atomic.Pointer[domain.ScrollbackConfig]
243- themeConfig atomic.Pointer[themeConfigSnapshot]
244- barScripts *barScriptState
245- notices *noticeCenter
246- resumeParkGrace time.Duration
247- suspendedSafetyExpiry time.Duration
233+ // bindings is read by keys.Router through its own atomic pointer, so it is
234+ // published next to (not inside) the runtime config snapshot.
235+ bindings atomic.Pointer[keys.Bindings]
236+ // config is the immutable runtime configuration snapshot built and
237+ // stored once by ApplyConfig; read it through runtimeConfig.
238+ config atomic.Pointer[runtimeConfig]
239+ barScripts *barScriptState
240+ notices *noticeCenter
241+ resumeParkGrace time.Duration
242+ suspendedSafetyExpiry time.Duration
248243 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
249244 // (see clipboard.go); empty means use os.TempDir().
250245 tempDir string
@@ -750,15 +745,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
750745 reload: make(chan struct{}, 1),
751746 },
752747 }
753- defaults := domain.Defaults()
754- defaultFloating := defaults.Floating
755- d.floatingConfig.Store(&defaultFloating)
756- defaultCopy := defaults.Copy
757- d.copyConfig.Store(&defaultCopy)
758- defaultPalette := defaults.Palette
759- d.paletteConfig.Store(&defaultPalette)
760- defaultTabs := defaults.Tabs
761- d.tabsConfig.Store(&defaultTabs)
748+ d.config.Store(defaultRuntimeConfig())
762749 for _, o := range opts {
763750 o(d)
764751 }
@@ -768,14 +755,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
768755 if d.bindings.Load() == nil {
769756 d.bindings.Store(keys.DefaultBindings())
770757 }
771- if d.codeOverrides.Load() == nil {
772- empty := map[string]string{}
773- d.codeOverrides.Store(&empty)
774- }
775- if d.restoreProcessAllowlist.Load() == nil {
776- allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
777- d.restoreProcessAllowlist.Store(&allow)
778- }
779758 records := d.catalogueRecords
780759 var maxSeq uint64
781760 var maxCreatedAt int64
@@ -230,21 +230,16 @@ type Daemon struct {
230 procArgv func(int) ([]string, error)230 procArgv func(int) ([]string, error)
231 procGroupArgv func(int, int) ([]string, error)231 procGroupArgv func(int, int) ([]string, error)
232 dirOrHome func(string) string232 dirOrHome func(string) string
233- bindings atomic.Pointer[keys.Bindings]233+ // bindings is read by keys.Router through its own atomic pointer, so it is
234- codeOverrides atomic.Pointer[map[string]string]234+ // published next to (not inside) the runtime config snapshot.
235- restoreProcessAllowlist atomic.Pointer[map[string]struct{}]235+ bindings atomic.Pointer[keys.Bindings]
236- floatingConfig atomic.Pointer[domain.FloatingConfig]236+ // config is the immutable runtime configuration snapshot built and
237- copyConfig atomic.Pointer[domain.CopyConfig]237+ // stored once by ApplyConfig; read it through runtimeConfig.
238- paletteConfig atomic.Pointer[domain.PaletteConfig]238+ config atomic.Pointer[runtimeConfig]
239- navConfig atomic.Pointer[domain.NavConfig]239+ barScripts *barScriptState
240- tabsConfig atomic.Pointer[domain.TabsConfig]240+ notices *noticeCenter
241- ephemeralConfig atomic.Pointer[domain.EphemeralConfig]241+ resumeParkGrace time.Duration
242- scrollbackConfig atomic.Pointer[domain.ScrollbackConfig]242+ suspendedSafetyExpiry time.Duration
243- themeConfig atomic.Pointer[themeConfigSnapshot]
244- barScripts *barScriptState
245- notices *noticeCenter
246- resumeParkGrace time.Duration
247- suspendedSafetyExpiry time.Duration
248 // tempDir overrides os.TempDir() for clipboard-image-transfer writes243 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
249 // (see clipboard.go); empty means use os.TempDir().244 // (see clipboard.go); empty means use os.TempDir().
250 tempDir string245 tempDir string
@@ -750,15 +745,7 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
750 reload: make(chan struct{}, 1),745 reload: make(chan struct{}, 1),
751 },746 },
752 }747 }
753- defaults := domain.Defaults()748+ d.config.Store(defaultRuntimeConfig())
754- defaultFloating := defaults.Floating
755- d.floatingConfig.Store(&defaultFloating)
756- defaultCopy := defaults.Copy
757- d.copyConfig.Store(&defaultCopy)
758- defaultPalette := defaults.Palette
759- d.paletteConfig.Store(&defaultPalette)
760- defaultTabs := defaults.Tabs
761- d.tabsConfig.Store(&defaultTabs)
762 for _, o := range opts {749 for _, o := range opts {
763 o(d)750 o(d)
764 }751 }
@@ -768,14 +755,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
768 if d.bindings.Load() == nil {755 if d.bindings.Load() == nil {
769 d.bindings.Store(keys.DefaultBindings())756 d.bindings.Store(keys.DefaultBindings())
770 }757 }
771- if d.codeOverrides.Load() == nil {
772- empty := map[string]string{}
773- d.codeOverrides.Store(&empty)
774- }
775- if d.restoreProcessAllowlist.Load() == nil {
776- allow := buildRestoreProcessAllowlist(domain.DefaultSnapshotRestoreProcesses())
777- d.restoreProcessAllowlist.Store(&allow)
778- }
779 records := d.catalogueRecords758 records := d.catalogueRecords
780 var maxSeq uint64759 var maxSeq uint64
781 var maxCreatedAt int64760 var maxCreatedAt int64
M internal/usecase/daemon/ephemeral_close_test.go +2 -2
@@ -30,7 +30,7 @@ func TestEphemeralCloseOnExit(t *testing.T) {
3030 pty, release := newBlockingPTY(t)
3131 defer release()
3232 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
33- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: tt.closeOnExit})
33+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: tt.closeOnExit} })
3434
3535 tr := &closeTrackingTransport{}
3636 sess, ac, err := d.route(helloResumeCapable(tt.intent, tt.sessionName, 0), tr)
@@ -122,7 +122,7 @@ func TestDetachFrameEphemeralCloseOnExit(t *testing.T) {
122122 pty, release := newBlockingPTY(t)
123123 defer release()
124124 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
125- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: tt.closeOnExit})
125+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: tt.closeOnExit} })
126126 tr, sends, _ := newConn(t,
127127 mustHello(tt.intent, tt.sessionName, domain.Size{Cols: 80, Rows: 24}),
128128 mustClientEnvelope(tt.detach),
@@ -30,7 +30,7 @@ func TestEphemeralCloseOnExit(t *testing.T) {
30 pty, release := newBlockingPTY(t)30 pty, release := newBlockingPTY(t)
31 defer release()31 defer release()
32 d := newTestDaemon(t, newFactory(t, pty), stubClock{})32 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
33- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: tt.closeOnExit})33+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: tt.closeOnExit} })
34 34
35 tr := &closeTrackingTransport{}35 tr := &closeTrackingTransport{}
36 sess, ac, err := d.route(helloResumeCapable(tt.intent, tt.sessionName, 0), tr)36 sess, ac, err := d.route(helloResumeCapable(tt.intent, tt.sessionName, 0), tr)
@@ -122,7 +122,7 @@ func TestDetachFrameEphemeralCloseOnExit(t *testing.T) {
122 pty, release := newBlockingPTY(t)122 pty, release := newBlockingPTY(t)
123 defer release()123 defer release()
124 d := newTestDaemon(t, newFactory(t, pty), stubClock{})124 d := newTestDaemon(t, newFactory(t, pty), stubClock{})
125- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: tt.closeOnExit})125+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: tt.closeOnExit} })
126 tr, sends, _ := newConn(t,126 tr, sends, _ := newConn(t,
127 mustHello(tt.intent, tt.sessionName, domain.Size{Cols: 80, Rows: 24}),127 mustHello(tt.intent, tt.sessionName, domain.Size{Cols: 80, Rows: 24}),
128 mustClientEnvelope(tt.detach),128 mustClientEnvelope(tt.detach),
M internal/usecase/daemon/history_policy.go +1 -5
@@ -16,11 +16,7 @@ func historyConfigFor(policy domain.ScrollbackConfig) vt.HistoryConfig {
1616 }
1717
1818 func (d *Daemon) currentHistoryConfig() vt.HistoryConfig {
19- policy := d.scrollbackConfig.Load()
20- if policy == nil {
21- return historyConfigFor(domain.DefaultScrollbackConfig())
22- }
23- return historyConfigFor(*policy)
19+ return historyConfigFor(d.runtimeConfig().scrollback)
2420 }
2521
2622 // historyPanes gathers references without holding a session/tab lock while
@@ -16,11 +16,7 @@ func historyConfigFor(policy domain.ScrollbackConfig) vt.HistoryConfig {
16 }16 }
17 17
18 func (d *Daemon) currentHistoryConfig() vt.HistoryConfig {18 func (d *Daemon) currentHistoryConfig() vt.HistoryConfig {
19- policy := d.scrollbackConfig.Load()19+ return historyConfigFor(d.runtimeConfig().scrollback)
20- if policy == nil {
21- return historyConfigFor(domain.DefaultScrollbackConfig())
22- }
23- return historyConfigFor(*policy)
24 }20 }
25 21
26 // historyPanes gathers references without holding a session/tab lock while22 // historyPanes gathers references without holding a session/tab lock while
M internal/usecase/daemon/palette_test.go +1 -1
@@ -923,7 +923,7 @@ func TestCommandByEffectiveCodeExcludesAPIOnlyCommands(t *testing.T) {
923923 "new-tab": "TAB",
924924 "toast": "API",
925925 }
926- d.codeOverrides.Store(&overrides)
926+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.codeOverrides = overrides })
927927
928928 cmd, ok := d.commandByEffectiveCode("tab")
929929 require.True(t, ok)
@@ -923,7 +923,7 @@ func TestCommandByEffectiveCodeExcludesAPIOnlyCommands(t *testing.T) {
923 "new-tab": "TAB",923 "new-tab": "TAB",
924 "toast": "API",924 "toast": "API",
925 }925 }
926- d.codeOverrides.Store(&overrides)926+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.codeOverrides = overrides })
927 927
928 cmd, ok := d.commandByEffectiveCode("tab")928 cmd, ok := d.commandByEffectiveCode("tab")
929 require.True(t, ok)929 require.True(t, ok)
M internal/usecase/daemon/resume_test.go +1 -1
@@ -1397,7 +1397,7 @@ func TestEphemeralParkExpiryKeepsSessionWhenCloseOnExitOff(t *testing.T) {
13971397 pty, release := newBlockingPTY(t)
13981398 defer release()
13991399 d := newTestDaemon(t, newFactory(t, pty), clk)
1400- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: false})
1400+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: false} })
14011401
14021402 tr := &closeTrackingTransport{}
14031403 sess, ac, err := d.route(helloResumeCapable(protocol.IntentEphemeral, "", 0), tr)
@@ -1397,7 +1397,7 @@ func TestEphemeralParkExpiryKeepsSessionWhenCloseOnExitOff(t *testing.T) {
1397 pty, release := newBlockingPTY(t)1397 pty, release := newBlockingPTY(t)
1398 defer release()1398 defer release()
1399 d := newTestDaemon(t, newFactory(t, pty), clk)1399 d := newTestDaemon(t, newFactory(t, pty), clk)
1400- d.ephemeralConfig.Store(&domain.EphemeralConfig{CloseOnExit: false})1400+ updateRuntimeConfig(d, func(rc *runtimeConfig) { rc.ephemeral = domain.EphemeralConfig{CloseOnExit: false} })
1401 1401
1402 tr := &closeTrackingTransport{}1402 tr := &closeTrackingTransport{}
1403 sess, ac, err := d.route(helloResumeCapable(protocol.IntentEphemeral, "", 0), tr)1403 sess, ac, err := d.route(helloResumeCapable(protocol.IntentEphemeral, "", 0), tr)

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 12

Tags 11

↑↓ moveenter openesc close