detached ba63da0

fix(config): decode each load from a transformed copy instead of persistent viper overrides

commit
ba63da0a0d9f1ee852f09275a2cfbaabc2032bd4
author
brice · · 2026-10-14 08:32 UTC
parent
9a83276
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +157 -50
M internal/infrastructure/config/browsing_contexts_test.go +17 -14
@@ -273,20 +273,21 @@ func TestLegacyPopupsFullRoundTrip(t *testing.T) {
273273 m.viper.Set("workspace.popups.oauth_auto_close", false)
274274
275275 // Transform should map popups → browsing_contexts.
276- m.transformLegacyConfig()
276+ settings, err := m.transformedSettings()
277+ require.NoError(t, err)
277278
278279 // popups key should be gone from viper after transform.
279- assert.False(t, m.viper.IsSet("workspace.popups.behavior"),
280+ assert.False(t, settings.IsSet("workspace.popups.behavior"),
280281 "workspace.popups.behavior should not exist after transform")
281282
282283 // browsing_contexts should now hold the values.
283- assert.Equal(t, "stacked", m.viper.GetString("workspace.browsing_contexts.behavior"))
284- assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))
285- assert.False(t, m.viper.GetBool("workspace.browsing_contexts.oauth_auto_close"))
284+ assert.Equal(t, "stacked", settings.GetString("workspace.browsing_contexts.behavior"))
285+ assert.Equal(t, "left", settings.GetString("workspace.browsing_contexts.placement"))
286+ assert.False(t, settings.GetBool("workspace.browsing_contexts.oauth_auto_close"))
286287
287288 // Unmarshal should succeed.
288289 cfg := &Config{}
289- err := m.viper.Unmarshal(cfg)
290+ err = settings.Unmarshal(cfg)
290291 require.NoError(t, err)
291292
292293 // BrowsingContexts should have the legacy values.
@@ -319,14 +320,15 @@ oauth_auto_close = false
319320 m.setDefaults()
320321 require.NoError(t, m.viper.ReadInConfig())
321322
322- m.transformLegacyConfig()
323+ settings, err := m.transformedSettings()
324+ require.NoError(t, err)
323325
324- assert.Equal(t, "stacked", m.viper.GetString("workspace.browsing_contexts.behavior"))
325- assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))
326- assert.False(t, m.viper.GetBool("workspace.browsing_contexts.oauth_auto_close"))
326+ assert.Equal(t, "stacked", settings.GetString("workspace.browsing_contexts.behavior"))
327+ assert.Equal(t, "left", settings.GetString("workspace.browsing_contexts.placement"))
328+ assert.False(t, settings.GetBool("workspace.browsing_contexts.oauth_auto_close"))
327329
328330 cfg := &Config{}
329- require.NoError(t, m.viper.Unmarshal(cfg))
331+ require.NoError(t, settings.Unmarshal(cfg))
330332 normalizeConfig(cfg)
331333
332334 assert.Equal(t, PopupBehaviorStacked, cfg.Workspace.BrowsingContexts.Behavior)
@@ -354,10 +356,11 @@ placement = "left"
354356 m.setDefaults()
355357 require.NoError(t, m.viper.ReadInConfig())
356358
357- m.transformLegacyConfig()
359+ settings, err := m.transformedSettings()
360+ require.NoError(t, err)
358361
359- assert.Equal(t, "tabbed", m.viper.GetString("workspace.browsing_contexts.behavior"))
360- assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))
362+ assert.Equal(t, "tabbed", settings.GetString("workspace.browsing_contexts.behavior"))
363+ assert.Equal(t, "left", settings.GetString("workspace.browsing_contexts.placement"))
361364 }
362365
363366 func TestReload_ReappliesLegacyPopupsCompatibility(t *testing.T) {
@@ -273,20 +273,21 @@ func TestLegacyPopupsFullRoundTrip(t *testing.T) {
273 m.viper.Set("workspace.popups.oauth_auto_close", false)273 m.viper.Set("workspace.popups.oauth_auto_close", false)
274 274
275 // Transform should map popups → browsing_contexts.275 // Transform should map popups → browsing_contexts.
276- m.transformLegacyConfig()276+ settings, err := m.transformedSettings()
277+ require.NoError(t, err)
277 278
278 // popups key should be gone from viper after transform.279 // popups key should be gone from viper after transform.
279- assert.False(t, m.viper.IsSet("workspace.popups.behavior"),280+ assert.False(t, settings.IsSet("workspace.popups.behavior"),
280 "workspace.popups.behavior should not exist after transform")281 "workspace.popups.behavior should not exist after transform")
281 282
282 // browsing_contexts should now hold the values.283 // browsing_contexts should now hold the values.
283- assert.Equal(t, "stacked", m.viper.GetString("workspace.browsing_contexts.behavior"))284+ assert.Equal(t, "stacked", settings.GetString("workspace.browsing_contexts.behavior"))
284- assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))285+ assert.Equal(t, "left", settings.GetString("workspace.browsing_contexts.placement"))
285- assert.False(t, m.viper.GetBool("workspace.browsing_contexts.oauth_auto_close"))286+ assert.False(t, settings.GetBool("workspace.browsing_contexts.oauth_auto_close"))
286 287
287 // Unmarshal should succeed.288 // Unmarshal should succeed.
288 cfg := &Config{}289 cfg := &Config{}
289- err := m.viper.Unmarshal(cfg)290+ err = settings.Unmarshal(cfg)
290 require.NoError(t, err)291 require.NoError(t, err)
291 292
292 // BrowsingContexts should have the legacy values.293 // BrowsingContexts should have the legacy values.
@@ -319,14 +320,15 @@ oauth_auto_close = false
319 m.setDefaults()320 m.setDefaults()
320 require.NoError(t, m.viper.ReadInConfig())321 require.NoError(t, m.viper.ReadInConfig())
321 322
322- m.transformLegacyConfig()323+ settings, err := m.transformedSettings()
324+ require.NoError(t, err)
323 325
324- assert.Equal(t, "stacked", m.viper.GetString("workspace.browsing_contexts.behavior"))326+ assert.Equal(t, "stacked", settings.GetString("workspace.browsing_contexts.behavior"))
325- assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))327+ assert.Equal(t, "left", settings.GetString("workspace.browsing_contexts.placement"))
326- assert.False(t, m.viper.GetBool("workspace.browsing_contexts.oauth_auto_close"))328+ assert.False(t, settings.GetBool("workspace.browsing_contexts.oauth_auto_close"))
327 329
328 cfg := &Config{}330 cfg := &Config{}
329- require.NoError(t, m.viper.Unmarshal(cfg))331+ require.NoError(t, settings.Unmarshal(cfg))
330 normalizeConfig(cfg)332 normalizeConfig(cfg)
331 333
332 assert.Equal(t, PopupBehaviorStacked, cfg.Workspace.BrowsingContexts.Behavior)334 assert.Equal(t, PopupBehaviorStacked, cfg.Workspace.BrowsingContexts.Behavior)
@@ -354,10 +356,11 @@ placement = "left"
354 m.setDefaults()356 m.setDefaults()
355 require.NoError(t, m.viper.ReadInConfig())357 require.NoError(t, m.viper.ReadInConfig())
356 358
357- m.transformLegacyConfig()359+ settings, err := m.transformedSettings()
360+ require.NoError(t, err)
358 361
359- assert.Equal(t, "tabbed", m.viper.GetString("workspace.browsing_contexts.behavior"))362+ assert.Equal(t, "tabbed", settings.GetString("workspace.browsing_contexts.behavior"))
360- assert.Equal(t, "left", m.viper.GetString("workspace.browsing_contexts.placement"))363+ assert.Equal(t, "left", settings.GetString("workspace.browsing_contexts.placement"))
361 }364 }
362 365
363 func TestReload_ReappliesLegacyPopupsCompatibility(t *testing.T) {366 func TestReload_ReappliesLegacyPopupsCompatibility(t *testing.T) {
M internal/infrastructure/config/loader.go +22 -10
@@ -145,15 +145,19 @@ func (m *Manager) readConfigFile() error {
145145 }
146146 }
147147
148- // Transform legacy action bindings and engine config before unmarshaling.
149- m.transformLegacyConfig()
150-
151148 return nil
152149 }
153150
154-// transformLegacyConfig converts old-format action bindings and engine config to the new format.
155-// This is called after reading config but before unmarshaling.
156-func (m *Manager) transformLegacyConfig() {
151+// transformedSettings returns a throwaway Viper holding the effective settings
152+// (config file, defaults and environment as resolved by the long-lived Viper)
153+// after the legacy transforms: old-format action bindings, legacy engine config
154+// and legacy popups.
155+//
156+// The transformed values are deliberately NOT written back to m.viper with
157+// Set(). Set() registers top-priority overrides that ReadInConfig never
158+// clears, so a later reload would keep returning the values of the first read
159+// instead of the file contents.
160+func (m *Manager) transformedSettings() (*viper.Viper, error) {
157161 legacyPopupsInConfig := m.viper.InConfig("workspace.popups")
158162
159163 rawConfig := m.viper.AllSettings()
@@ -175,10 +179,11 @@ func (m *Manager) transformLegacyConfig() {
175179 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176180 }
177181
178- // Apply transformed config back to viper
179- for key, value := range rawConfig {
180- m.viper.Set(key, value)
182+ settings := viper.New()
183+ if err := settings.MergeConfigMap(rawConfig); err != nil {
184+ return nil, fmt.Errorf("failed to merge transformed config: %w", err)
181185 }
186+ return settings, nil
182187 }
183188
184189 func (m *Manager) configureAutomaticEnv() {
@@ -348,9 +353,16 @@ func (m *Manager) hasEngineSectionInConfig() bool {
348353 return m.viper.InConfig("engine.type")
349354 }
350355
356+// unmarshalConfig decodes the current file, defaults and environment, with the
357+// legacy transforms applied, into a fresh Config. It is used by both the initial
358+// load and every reload, so neither depends on state left by a previous read.
351359 func (m *Manager) unmarshalConfig() (*Config, error) {
360+ settings, err := m.transformedSettings()
361+ if err != nil {
362+ return nil, err
363+ }
352364 config := &Config{}
353- if err := m.viper.Unmarshal(config); err != nil {
365+ if err := settings.Unmarshal(config); err != nil {
354366 configFile := m.viper.ConfigFileUsed()
355367 return nil, fmt.Errorf(
356368 "failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches",
@@ -145,15 +145,19 @@ func (m *Manager) readConfigFile() error {
145 }145 }
146 }146 }
147 147
148- // Transform legacy action bindings and engine config before unmarshaling.
149- m.transformLegacyConfig()
150-
151 return nil148 return nil
152 }149 }
153 150
154-// transformLegacyConfig converts old-format action bindings and engine config to the new format.151+// transformedSettings returns a throwaway Viper holding the effective settings
155-// This is called after reading config but before unmarshaling.152+// (config file, defaults and environment as resolved by the long-lived Viper)
156-func (m *Manager) transformLegacyConfig() {153+// after the legacy transforms: old-format action bindings, legacy engine config
154+// and legacy popups.
155+//
156+// The transformed values are deliberately NOT written back to m.viper with
157+// Set(). Set() registers top-priority overrides that ReadInConfig never
158+// clears, so a later reload would keep returning the values of the first read
159+// instead of the file contents.
160+func (m *Manager) transformedSettings() (*viper.Viper, error) {
157 legacyPopupsInConfig := m.viper.InConfig("workspace.popups")161 legacyPopupsInConfig := m.viper.InConfig("workspace.popups")
158 162
159 rawConfig := m.viper.AllSettings()163 rawConfig := m.viper.AllSettings()
@@ -175,10 +179,11 @@ func (m *Manager) transformLegacyConfig() {
175 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)179 transformer.TransformLegacyPopupsToBrowsingContexts(rawConfig)
176 }180 }
177 181
178- // Apply transformed config back to viper182+ settings := viper.New()
179- for key, value := range rawConfig {183+ if err := settings.MergeConfigMap(rawConfig); err != nil {
180- m.viper.Set(key, value)184+ return nil, fmt.Errorf("failed to merge transformed config: %w", err)
181 }185 }
186+ return settings, nil
182 }187 }
183 188
184 func (m *Manager) configureAutomaticEnv() {189 func (m *Manager) configureAutomaticEnv() {
@@ -348,9 +353,16 @@ func (m *Manager) hasEngineSectionInConfig() bool {
348 return m.viper.InConfig("engine.type")353 return m.viper.InConfig("engine.type")
349 }354 }
350 355
356+// unmarshalConfig decodes the current file, defaults and environment, with the
357+// legacy transforms applied, into a fresh Config. It is used by both the initial
358+// load and every reload, so neither depends on state left by a previous read.
351 func (m *Manager) unmarshalConfig() (*Config, error) {359 func (m *Manager) unmarshalConfig() (*Config, error) {
360+ settings, err := m.transformedSettings()
361+ if err != nil {
362+ return nil, err
363+ }
352 config := &Config{}364 config := &Config{}
353- if err := m.viper.Unmarshal(config); err != nil {365+ if err := settings.Unmarshal(config); err != nil {
354 configFile := m.viper.ConfigFileUsed()366 configFile := m.viper.ConfigFileUsed()
355 return nil, fmt.Errorf(367 return nil, fmt.Errorf(
356 "failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches",368 "failed to parse config file at %s: %w\nCheck for syntax errors, invalid values, or type mismatches",
M internal/infrastructure/config/loader_legacy_test.go +18 -14
@@ -149,11 +149,12 @@ func TestTransformLegacyConfig_DefaultAdaptiveDoesNotBlockLegacyCEFMigration(t *
149149 m.setDefaults()
150150 require.NoError(t, m.viper.ReadInConfig())
151151
152- m.transformLegacyConfig()
152+ settings, err := m.transformedSettings()
153+ require.NoError(t, err)
153154
154- require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
155- require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))
156- require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max"))
155+ require.True(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
156+ require.Equal(t, 0, settings.GetInt("engine.cef.windowless_frame_rate"))
157+ require.Equal(t, defaultCEFWindowlessFrameRateMax, settings.GetInt("engine.cef.windowless_frame_rate_max"))
157158 }
158159
159160 func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigration(t *testing.T) {
@@ -169,10 +170,11 @@ func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigrati
169170 m.setDefaults()
170171 require.NoError(t, m.viper.ReadInConfig())
171172
172- m.transformLegacyConfig()
173+ settings, err := m.transformedSettings()
174+ require.NoError(t, err)
173175
174- require.False(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
175- require.Equal(t, 60, m.viper.GetInt("engine.cef.windowless_frame_rate"))
176+ require.False(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
177+ require.Equal(t, 60, settings.GetInt("engine.cef.windowless_frame_rate"))
176178 }
177179
178180 func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *testing.T) {
@@ -187,10 +189,11 @@ func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *t
187189 m.setDefaults()
188190 require.NoError(t, m.viper.ReadInConfig())
189191
190- m.transformLegacyConfig()
192+ settings, err := m.transformedSettings()
193+ require.NoError(t, err)
191194
192- require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
193- require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))
195+ require.True(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
196+ require.Equal(t, 0, settings.GetInt("engine.cef.windowless_frame_rate"))
194197 }
195198
196199 func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t *testing.T) {
@@ -206,9 +209,10 @@ func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t
206209 m.setDefaults()
207210 require.NoError(t, m.viper.ReadInConfig())
208211
209- m.transformLegacyConfig()
212+ settings, err := m.transformedSettings()
213+ require.NoError(t, err)
210214
211- require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))
212- require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))
213- require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max"))
215+ require.True(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
216+ require.Equal(t, 0, settings.GetInt("engine.cef.windowless_frame_rate"))
217+ require.Equal(t, defaultCEFWindowlessFrameRateMax, settings.GetInt("engine.cef.windowless_frame_rate_max"))
214218 }
@@ -149,11 +149,12 @@ func TestTransformLegacyConfig_DefaultAdaptiveDoesNotBlockLegacyCEFMigration(t *
149 m.setDefaults()149 m.setDefaults()
150 require.NoError(t, m.viper.ReadInConfig())150 require.NoError(t, m.viper.ReadInConfig())
151 151
152- m.transformLegacyConfig()152+ settings, err := m.transformedSettings()
153+ require.NoError(t, err)
153 154
154- require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))155+ require.True(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
155- require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))156+ require.Equal(t, 0, settings.GetInt("engine.cef.windowless_frame_rate"))
156- require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max"))157+ require.Equal(t, defaultCEFWindowlessFrameRateMax, settings.GetInt("engine.cef.windowless_frame_rate_max"))
157 }158 }
158 159
159 func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigration(t *testing.T) {160 func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigration(t *testing.T) {
@@ -169,10 +170,11 @@ func TestTransformLegacyConfig_ExplicitAdaptiveEnvOverrideBlocksLegacyCEFMigrati
169 m.setDefaults()170 m.setDefaults()
170 require.NoError(t, m.viper.ReadInConfig())171 require.NoError(t, m.viper.ReadInConfig())
171 172
172- m.transformLegacyConfig()173+ settings, err := m.transformedSettings()
174+ require.NoError(t, err)
173 175
174- require.False(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))176+ require.False(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
175- require.Equal(t, 60, m.viper.GetInt("engine.cef.windowless_frame_rate"))177+ require.Equal(t, 60, settings.GetInt("engine.cef.windowless_frame_rate"))
176 }178 }
177 179
178 func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *testing.T) {180 func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *testing.T) {
@@ -187,10 +189,11 @@ func TestTransformLegacyConfig_UnconfiguredManagerIgnoresAmbientAdaptiveEnv(t *t
187 m.setDefaults()189 m.setDefaults()
188 require.NoError(t, m.viper.ReadInConfig())190 require.NoError(t, m.viper.ReadInConfig())
189 191
190- m.transformLegacyConfig()192+ settings, err := m.transformedSettings()
193+ require.NoError(t, err)
191 194
192- require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))195+ require.True(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
193- require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))196+ require.Equal(t, 0, settings.GetInt("engine.cef.windowless_frame_rate"))
194 }197 }
195 198
196 func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t *testing.T) {199 func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t *testing.T) {
@@ -206,9 +209,10 @@ func TestTransformLegacyConfig_ExplicitAdaptiveEnvTrueMigratesLegacyCEFDefault(t
206 m.setDefaults()209 m.setDefaults()
207 require.NoError(t, m.viper.ReadInConfig())210 require.NoError(t, m.viper.ReadInConfig())
208 211
209- m.transformLegacyConfig()212+ settings, err := m.transformedSettings()
213+ require.NoError(t, err)
210 214
211- require.True(t, m.viper.GetBool("engine.cef.adaptive_windowless_frame_rate"))215+ require.True(t, settings.GetBool("engine.cef.adaptive_windowless_frame_rate"))
212- require.Equal(t, 0, m.viper.GetInt("engine.cef.windowless_frame_rate"))216+ require.Equal(t, 0, settings.GetInt("engine.cef.windowless_frame_rate"))
213- require.Equal(t, defaultCEFWindowlessFrameRateMax, m.viper.GetInt("engine.cef.windowless_frame_rate_max"))217+ require.Equal(t, defaultCEFWindowlessFrameRateMax, settings.GetInt("engine.cef.windowless_frame_rate_max"))
214 }218 }
A internal/infrastructure/config/loader_reload_test.go +96 -0
@@ -0,0 +1,96 @@
1+package config
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// setLegendDelayInFile rewrites the mode_legend_delay_ms line of the manager's
12+// config file, simulating an external edit.
13+func setLegendDelayInFile(t *testing.T, mgr *Manager, delayMs string) {
14+ t.Helper()
15+
16+ path := mgr.GetConfigFile()
17+ require.NotEmpty(t, path)
18+ data, err := os.ReadFile(path)
19+ require.NoError(t, err)
20+
21+ lines := strings.Split(string(data), "\n")
22+ replaced := false
23+ for i, line := range lines {
24+ if strings.HasPrefix(strings.TrimSpace(line), "mode_legend_delay_ms") {
25+ lines[i] = "mode_legend_delay_ms = " + delayMs
26+ replaced = true
27+ }
28+ }
29+ require.True(t, replaced, "mode_legend_delay_ms not found in written config")
30+ require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o600))
31+}
32+
33+func TestManagerReload_KeepsSavedValue(t *testing.T) {
34+ mgr := newTestOmniboxPreferencesGatewayManager(t, t.TempDir())
35+ require.Equal(t, 500, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
36+
37+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = 700 }))
38+ require.Equal(t, 700, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
39+
40+ mgr.mu.Lock()
41+ err := mgr.reload()
42+ mgr.mu.Unlock()
43+ require.NoError(t, err)
44+ require.Equal(t, 700, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
45+}
46+
47+func TestManagerReload_PicksUpExternalEditAfterSave(t *testing.T) {
48+ mgr := newTestOmniboxPreferencesGatewayManager(t, t.TempDir())
49+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = 700 }))
50+
51+ setLegendDelayInFile(t, mgr, "900")
52+
53+ mgr.mu.Lock()
54+ err := mgr.reload()
55+ mgr.mu.Unlock()
56+ require.NoError(t, err)
57+ require.Equal(t, 900, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
58+}
59+
60+func TestManagerReload_PicksUpExternalEditWithoutPriorSave(t *testing.T) {
61+ mgr := newTestOmniboxPreferencesGatewayManager(t, t.TempDir())
62+ require.Equal(t, 500, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
63+
64+ setLegendDelayInFile(t, mgr, "900")
65+
66+ mgr.mu.Lock()
67+ err := mgr.reload()
68+ mgr.mu.Unlock()
69+ require.NoError(t, err)
70+ require.Equal(t, 900, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
71+
72+ setLegendDelayInFile(t, mgr, "300")
73+ mgr.mu.Lock()
74+ err = mgr.reload()
75+ mgr.mu.Unlock()
76+ require.NoError(t, err)
77+ require.Equal(t, 300, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
78+}
79+
80+func TestManagerUpdate_ConsecutiveUpdatesEachPersist(t *testing.T) {
81+ configHome := t.TempDir()
82+ mgr := newTestOmniboxPreferencesGatewayManager(t, configHome)
83+
84+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendCollapsed = true }))
85+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = 700 }))
86+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = 800 }))
87+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendCollapsed = false }))
88+
89+ for name, got := range map[string]*Config{
90+ "in-memory": mgr.Get(),
91+ "on-disk": newTestOmniboxPreferencesGatewayManager(t, configHome).Get(),
92+ } {
93+ require.False(t, got.Workspace.Styling.ModeLegendCollapsed, name)
94+ require.Equal(t, 800, got.Workspace.Styling.ModeLegendDelayMs, name)
95+ }
96+}
@@ -0,0 +1,96 @@
1+package config
2+
3+import (
4+ "os"
5+ "strings"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+)
10+
11+// setLegendDelayInFile rewrites the mode_legend_delay_ms line of the manager's
12+// config file, simulating an external edit.
13+func setLegendDelayInFile(t *testing.T, mgr *Manager, delayMs string) {
14+ t.Helper()
15+
16+ path := mgr.GetConfigFile()
17+ require.NotEmpty(t, path)
18+ data, err := os.ReadFile(path)
19+ require.NoError(t, err)
20+
21+ lines := strings.Split(string(data), "\n")
22+ replaced := false
23+ for i, line := range lines {
24+ if strings.HasPrefix(strings.TrimSpace(line), "mode_legend_delay_ms") {
25+ lines[i] = "mode_legend_delay_ms = " + delayMs
26+ replaced = true
27+ }
28+ }
29+ require.True(t, replaced, "mode_legend_delay_ms not found in written config")
30+ require.NoError(t, os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o600))
31+}
32+
33+func TestManagerReload_KeepsSavedValue(t *testing.T) {
34+ mgr := newTestOmniboxPreferencesGatewayManager(t, t.TempDir())
35+ require.Equal(t, 500, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
36+
37+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = 700 }))
38+ require.Equal(t, 700, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
39+
40+ mgr.mu.Lock()
41+ err := mgr.reload()
42+ mgr.mu.Unlock()
43+ require.NoError(t, err)
44+ require.Equal(t, 700, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
45+}
46+
47+func TestManagerReload_PicksUpExternalEditAfterSave(t *testing.T) {
48+ mgr := newTestOmniboxPreferencesGatewayManager(t, t.TempDir())
49+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = 700 }))
50+
51+ setLegendDelayInFile(t, mgr, "900")
52+
53+ mgr.mu.Lock()
54+ err := mgr.reload()
55+ mgr.mu.Unlock()
56+ require.NoError(t, err)
57+ require.Equal(t, 900, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
58+}
59+
60+func TestManagerReload_PicksUpExternalEditWithoutPriorSave(t *testing.T) {
61+ mgr := newTestOmniboxPreferencesGatewayManager(t, t.TempDir())
62+ require.Equal(t, 500, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
63+
64+ setLegendDelayInFile(t, mgr, "900")
65+
66+ mgr.mu.Lock()
67+ err := mgr.reload()
68+ mgr.mu.Unlock()
69+ require.NoError(t, err)
70+ require.Equal(t, 900, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
71+
72+ setLegendDelayInFile(t, mgr, "300")
73+ mgr.mu.Lock()
74+ err = mgr.reload()
75+ mgr.mu.Unlock()
76+ require.NoError(t, err)
77+ require.Equal(t, 300, mgr.Get().Workspace.Styling.ModeLegendDelayMs)
78+}
79+
80+func TestManagerUpdate_ConsecutiveUpdatesEachPersist(t *testing.T) {
81+ configHome := t.TempDir()
82+ mgr := newTestOmniboxPreferencesGatewayManager(t, configHome)
83+
84+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendCollapsed = true }))
85+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = 700 }))
86+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendDelayMs = 800 }))
87+ require.NoError(t, mgr.Update(func(cfg *Config) { cfg.Workspace.Styling.ModeLegendCollapsed = false }))
88+
89+ for name, got := range map[string]*Config{
90+ "in-memory": mgr.Get(),
91+ "on-disk": newTestOmniboxPreferencesGatewayManager(t, configHome).Get(),
92+ } {
93+ require.False(t, got.Workspace.Styling.ModeLegendCollapsed, name)
94+ require.Equal(t, 800, got.Workspace.Styling.ModeLegendDelayMs, name)
95+ }
96+}
M internal/infrastructure/config/watcher.go +4 -12
@@ -87,20 +87,12 @@ func (m *Manager) reload() error {
8787 return err
8888 }
8989
90- m.transformLegacyConfig()
91-
92- config := &Config{}
93- if err := m.viper.Unmarshal(config); err != nil {
90+ config, err := m.unmarshalConfig()
91+ if err != nil {
9492 return err
9593 }
96-
97- // Set database path if not specified
98- if config.Database.Path == "" {
99- dbPath, err := GetDatabaseFile()
100- if err != nil {
101- return fmt.Errorf("failed to get database path: %w", err)
102- }
103- config.Database.Path = dbPath
94+ if err := ensureDatabasePath(config); err != nil {
95+ return err
10496 }
10597
10698 normalizeConfig(config)
@@ -87,20 +87,12 @@ func (m *Manager) reload() error {
87 return err87 return err
88 }88 }
89 89
90- m.transformLegacyConfig()90+ config, err := m.unmarshalConfig()
91-91+ if err != nil {
92- config := &Config{}
93- if err := m.viper.Unmarshal(config); err != nil {
94 return err92 return err
95 }93 }
96-94+ if err := ensureDatabasePath(config); err != nil {
97- // Set database path if not specified95+ return err
98- if config.Database.Path == "" {
99- dbPath, err := GetDatabaseFile()
100- if err != nil {
101- return fmt.Errorf("failed to get database path: %w", err)
102- }
103- config.Database.Path = dbPath
104 }96 }
105 97
106 normalizeConfig(config)98 normalizeConfig(config)

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