detached 67f7842

fix(config): derive external theme fallback paths

commit
67f784236bbc51ae16e499340cdf016f019e2d35
author
brice · · 2026-06-10 05:51 UTC
committer
brice · · 2026-06-10 06:37 UTC
parent
66d7b57
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
10 files · +101 -21
M docs/config/index.md +7 -3
@@ -144,7 +144,7 @@ auto_open_on_new_pane = false
144144 | `appearance.external_theme.enabled` | bool | `false` | Enable an external palette source |
145145 | `appearance.external_theme.provider` | string | `"noctalia"` | External provider. Only `noctalia` is supported |
146146 | `appearance.external_theme.format` | string | `"colors-json"` | External file format: `colors-json` or `dumber-json` |
147-| `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | Path to the external theme JSON file |
147+| `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` for `colors-json`; built-in dumber-json template path for `dumber-json` | Path to the external theme JSON file |
148148
149149 ### Color Palettes
150150
@@ -174,7 +174,7 @@ border = "#363636"
174174
175175 ### External Theme: Noctalia
176176
177-Dumber can follow Noctalia by reading the native Noctalia palette file directly. No user template is required for the default integration.
177+Dumber can integrate with Noctalia by reading the native Noctalia palette file directly. No user template is required for the default integration.
178178
179179 ```toml
180180 [appearance.external_theme]
@@ -203,7 +203,11 @@ Dumber maps Noctalia roles like this:
203203 | `mPrimary` | `accent` |
204204 | `mOutline` | `border` |
205205
206-Every mapped value must be a CSS-safe 6-digit hex color such as `#AABBCC`. `mShadow` and `mHover` are optional nuance roles; when they are present they are validated and used to preserve Dumber's background/surface/input contrast. Because Noctalia's native file contains the active palette only, Dumber applies that mapped palette to both resolved light and dark palettes so the app follows the file immediately.
206+Every mapped value must be a CSS-safe 6-digit hex color such as `#aabbcc`.
207+
208+`mShadow` and `mHover` are optional nuance roles. When present, they are validated and used to preserve Dumber's background/surface/input contrast.
209+
210+Because Noctalia's native file contains the active palette only, Dumber applies that mapped palette to both resolved light and dark palettes so the app follows the file immediately.
207211
208212 #### Advanced: `dumber-json` user template
209213
@@ -144,7 +144,7 @@ auto_open_on_new_pane = false
144 | `appearance.external_theme.enabled` | bool | `false` | Enable an external palette source |144 | `appearance.external_theme.enabled` | bool | `false` | Enable an external palette source |
145 | `appearance.external_theme.provider` | string | `"noctalia"` | External provider. Only `noctalia` is supported |145 | `appearance.external_theme.provider` | string | `"noctalia"` | External provider. Only `noctalia` is supported |
146 | `appearance.external_theme.format` | string | `"colors-json"` | External file format: `colors-json` or `dumber-json` |146 | `appearance.external_theme.format` | string | `"colors-json"` | External file format: `colors-json` or `dumber-json` |
147-| `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | Path to the external theme JSON file |147+| `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` for `colors-json`; built-in dumber-json template path for `dumber-json` | Path to the external theme JSON file |
148 148
149 ### Color Palettes149 ### Color Palettes
150 150
@@ -174,7 +174,7 @@ border = "#363636"
174 174
175 ### External Theme: Noctalia175 ### External Theme: Noctalia
176 176
177-Dumber can follow Noctalia by reading the native Noctalia palette file directly. No user template is required for the default integration.177+Dumber can integrate with Noctalia by reading the native Noctalia palette file directly. No user template is required for the default integration.
178 178
179 ```toml179 ```toml
180 [appearance.external_theme]180 [appearance.external_theme]
@@ -203,7 +203,11 @@ Dumber maps Noctalia roles like this:
203 | `mPrimary` | `accent` |203 | `mPrimary` | `accent` |
204 | `mOutline` | `border` |204 | `mOutline` | `border` |
205 205
206-Every mapped value must be a CSS-safe 6-digit hex color such as `#AABBCC`. `mShadow` and `mHover` are optional nuance roles; when they are present they are validated and used to preserve Dumber's background/surface/input contrast. Because Noctalia's native file contains the active palette only, Dumber applies that mapped palette to both resolved light and dark palettes so the app follows the file immediately.206+Every mapped value must be a CSS-safe 6-digit hex color such as `#aabbcc`.
207+
208+`mShadow` and `mHover` are optional nuance roles. When present, they are validated and used to preserve Dumber's background/surface/input contrast.
209+
210+Because Noctalia's native file contains the active palette only, Dumber applies that mapped palette to both resolved light and dark palettes so the app follows the file immediately.
207 211
208 #### Advanced: `dumber-json` user template212 #### Advanced: `dumber-json` user template
209 213
M docs/reference/configuration.md +1 -1
@@ -36,7 +36,7 @@
3636 | `appearance.external_theme.enabled` | bool | `false` | |
3737 | `appearance.external_theme.provider` | string | `noctalia` | `noctalia` |
3838 | `appearance.external_theme.format` | string | `colors-json` | `colors-json`, `dumber-json` |
39-| `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | path to Noctalia colors.json or explicit dumber-json |
39+| `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | path to Noctalia `colors.json` or a `dumber-json` theme file; when empty and `format = "dumber-json"`, dumber uses the built-in dumber-json template path |
4040 | `appearance.light_palette.background` | string | `#f8f8f8` | |
4141 | `appearance.light_palette.surface` | string | `#f2f2f2` | |
4242 | `appearance.light_palette.surface_variant` | string | `#ececec` | |
@@ -36,7 +36,7 @@
36 | `appearance.external_theme.enabled` | bool | `false` | |36 | `appearance.external_theme.enabled` | bool | `false` | |
37 | `appearance.external_theme.provider` | string | `noctalia` | `noctalia` |37 | `appearance.external_theme.provider` | string | `noctalia` | `noctalia` |
38 | `appearance.external_theme.format` | string | `colors-json` | `colors-json`, `dumber-json` |38 | `appearance.external_theme.format` | string | `colors-json` | `colors-json`, `dumber-json` |
39-| `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | path to Noctalia colors.json or explicit dumber-json |39+| `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | path to Noctalia `colors.json` or a `dumber-json` theme file; when empty and `format = "dumber-json"`, dumber uses the built-in dumber-json template path |
40 | `appearance.light_palette.background` | string | `#f8f8f8` | |40 | `appearance.light_palette.background` | string | `#f8f8f8` | |
41 | `appearance.light_palette.surface` | string | `#f2f2f2` | |41 | `appearance.light_palette.surface` | string | `#f2f2f2` | |
42 | `appearance.light_palette.surface_variant` | string | `#ececec` | |42 | `appearance.light_palette.surface_variant` | string | `#ececec` | |
M internal/cli/styles/theme_test.go +13 -0
@@ -48,3 +48,16 @@ func TestNewThemeFromResolvedFallsBackToDarkPalette(t *testing.T) {
4848 require.Equal(t, lipgloss.Color("#111111"), theme.Background)
4949 require.Equal(t, lipgloss.Color("#fedcba"), theme.Accent)
5050 }
51+
52+func TestNewThemeFromResolvedFallsBackToDefaultDarkPalette(t *testing.T) {
53+ resolved := entity.ResolvedTheme{}
54+ palette := styles.DefaultDarkPalette()
55+
56+ theme := styles.NewThemeFromResolved(resolved)
57+
58+ require.Equal(t, lipgloss.Color(palette.Background), theme.Background)
59+ require.Equal(t, lipgloss.Color(palette.Surface), theme.Surface)
60+ require.Equal(t, lipgloss.Color(palette.Text), theme.Text)
61+ require.Equal(t, lipgloss.Color(palette.Accent), theme.Accent)
62+ require.Equal(t, lipgloss.Color(palette.Accent), theme.Success)
63+}
@@ -48,3 +48,16 @@ func TestNewThemeFromResolvedFallsBackToDarkPalette(t *testing.T) {
48 require.Equal(t, lipgloss.Color("#111111"), theme.Background)48 require.Equal(t, lipgloss.Color("#111111"), theme.Background)
49 require.Equal(t, lipgloss.Color("#fedcba"), theme.Accent)49 require.Equal(t, lipgloss.Color("#fedcba"), theme.Accent)
50 }50 }
51+
52+func TestNewThemeFromResolvedFallsBackToDefaultDarkPalette(t *testing.T) {
53+ resolved := entity.ResolvedTheme{}
54+ palette := styles.DefaultDarkPalette()
55+
56+ theme := styles.NewThemeFromResolved(resolved)
57+
58+ require.Equal(t, lipgloss.Color(palette.Background), theme.Background)
59+ require.Equal(t, lipgloss.Color(palette.Surface), theme.Surface)
60+ require.Equal(t, lipgloss.Color(palette.Text), theme.Text)
61+ require.Equal(t, lipgloss.Color(palette.Accent), theme.Accent)
62+ require.Equal(t, lipgloss.Color(palette.Accent), theme.Success)
63+}
M internal/infrastructure/config/external_theme_test.go +26 -4
@@ -27,10 +27,7 @@ func TestManagerSetDefaults_ExternalThemeViperDefaults(t *testing.T) {
2727 require.False(t, mgr.viper.GetBool("appearance.external_theme.enabled"))
2828 require.Equal(t, "noctalia", mgr.viper.GetString("appearance.external_theme.provider"))
2929 require.Equal(t, "colors-json", mgr.viper.GetString("appearance.external_theme.format"))
30- require.Equal(t, filepath.Join("noctalia", "colors.json"), lastPathElements(
31- mgr.viper.GetString("appearance.external_theme.path"),
32- 2,
33- ))
30+ require.Empty(t, mgr.viper.GetString("appearance.external_theme.path"))
3431 }
3532
3633 func TestManagerLoad_ExternalThemeEnabledEmptyPathUsesDefaultColorsJSON(t *testing.T) {
@@ -84,6 +81,31 @@ path = ''
8481 require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path)
8582 }
8683
84+func TestManagerLoad_ExternalThemeDumberJSONOmittedPathUsesTemplateDefault(t *testing.T) {
85+ configHome := t.TempDir()
86+ t.Setenv("ENV", "")
87+ t.Setenv("XDG_CONFIG_HOME", configHome)
88+
89+ configDir := filepath.Join(configHome, appName)
90+ require.NoError(t, os.MkdirAll(configDir, dirPerm))
91+ require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(`
92+[appearance.external_theme]
93+enabled = true
94+provider = 'noctalia'
95+format = 'dumber-json'
96+`), filePerm))
97+
98+ mgr, err := NewManager()
99+ require.NoError(t, err)
100+ require.NoError(t, mgr.Load())
101+
102+ externalTheme := mgr.Get().Appearance.ExternalTheme
103+ require.True(t, externalTheme.Enabled)
104+ require.Equal(t, "noctalia", externalTheme.Provider)
105+ require.Equal(t, "dumber-json", externalTheme.Format)
106+ require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path)
107+}
108+
87109 func TestValidateConfig_ExternalTheme(t *testing.T) {
88110 tests := []struct {
89111 name string
@@ -27,10 +27,7 @@ func TestManagerSetDefaults_ExternalThemeViperDefaults(t *testing.T) {
27 require.False(t, mgr.viper.GetBool("appearance.external_theme.enabled"))27 require.False(t, mgr.viper.GetBool("appearance.external_theme.enabled"))
28 require.Equal(t, "noctalia", mgr.viper.GetString("appearance.external_theme.provider"))28 require.Equal(t, "noctalia", mgr.viper.GetString("appearance.external_theme.provider"))
29 require.Equal(t, "colors-json", mgr.viper.GetString("appearance.external_theme.format"))29 require.Equal(t, "colors-json", mgr.viper.GetString("appearance.external_theme.format"))
30- require.Equal(t, filepath.Join("noctalia", "colors.json"), lastPathElements(30+ require.Empty(t, mgr.viper.GetString("appearance.external_theme.path"))
31- mgr.viper.GetString("appearance.external_theme.path"),
32- 2,
33- ))
34 }31 }
35 32
36 func TestManagerLoad_ExternalThemeEnabledEmptyPathUsesDefaultColorsJSON(t *testing.T) {33 func TestManagerLoad_ExternalThemeEnabledEmptyPathUsesDefaultColorsJSON(t *testing.T) {
@@ -84,6 +81,31 @@ path = ''
84 require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path)81 require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path)
85 }82 }
86 83
84+func TestManagerLoad_ExternalThemeDumberJSONOmittedPathUsesTemplateDefault(t *testing.T) {
85+ configHome := t.TempDir()
86+ t.Setenv("ENV", "")
87+ t.Setenv("XDG_CONFIG_HOME", configHome)
88+
89+ configDir := filepath.Join(configHome, appName)
90+ require.NoError(t, os.MkdirAll(configDir, dirPerm))
91+ require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(`
92+[appearance.external_theme]
93+enabled = true
94+provider = 'noctalia'
95+format = 'dumber-json'
96+`), filePerm))
97+
98+ mgr, err := NewManager()
99+ require.NoError(t, err)
100+ require.NoError(t, mgr.Load())
101+
102+ externalTheme := mgr.Get().Appearance.ExternalTheme
103+ require.True(t, externalTheme.Enabled)
104+ require.Equal(t, "noctalia", externalTheme.Provider)
105+ require.Equal(t, "dumber-json", externalTheme.Format)
106+ require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path)
107+}
108+
87 func TestValidateConfig_ExternalTheme(t *testing.T) {109 func TestValidateConfig_ExternalTheme(t *testing.T) {
88 tests := []struct {110 tests := []struct {
89 name string111 name string
M internal/infrastructure/config/loader.go +0 -1
@@ -659,7 +659,6 @@ func (m *Manager) setAppearanceDefaults(defaults *Config) {
659659 m.viper.SetDefault("appearance.external_theme.enabled", defaults.Appearance.ExternalTheme.Enabled)
660660 m.viper.SetDefault("appearance.external_theme.provider", defaults.Appearance.ExternalTheme.Provider)
661661 m.viper.SetDefault("appearance.external_theme.format", defaults.Appearance.ExternalTheme.Format)
662- m.viper.SetDefault("appearance.external_theme.path", defaults.Appearance.ExternalTheme.Path)
663662 }
664663
665664 func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {
@@ -659,7 +659,6 @@ func (m *Manager) setAppearanceDefaults(defaults *Config) {
659 m.viper.SetDefault("appearance.external_theme.enabled", defaults.Appearance.ExternalTheme.Enabled)659 m.viper.SetDefault("appearance.external_theme.enabled", defaults.Appearance.ExternalTheme.Enabled)
660 m.viper.SetDefault("appearance.external_theme.provider", defaults.Appearance.ExternalTheme.Provider)660 m.viper.SetDefault("appearance.external_theme.provider", defaults.Appearance.ExternalTheme.Provider)
661 m.viper.SetDefault("appearance.external_theme.format", defaults.Appearance.ExternalTheme.Format)661 m.viper.SetDefault("appearance.external_theme.format", defaults.Appearance.ExternalTheme.Format)
662- m.viper.SetDefault("appearance.external_theme.path", defaults.Appearance.ExternalTheme.Path)
663 }662 }
664 663
665 func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {664 func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {
M internal/infrastructure/config/webui_gateway.go +1 -4
@@ -64,10 +64,7 @@ func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIC
6464 Accent: cfg.Appearance.DarkPalette.Accent,
6565 Border: cfg.Appearance.DarkPalette.Border,
6666 }
67- current.Appearance.ExternalTheme.Enabled = cfg.Appearance.ExternalTheme.Enabled
68- current.Appearance.ExternalTheme.Provider = cfg.Appearance.ExternalTheme.Provider
69- current.Appearance.ExternalTheme.Format = cfg.Appearance.ExternalTheme.Format
70- current.Appearance.ExternalTheme.Path = cfg.Appearance.ExternalTheme.Path
67+ current.Appearance.ExternalTheme = cfg.Appearance.ExternalTheme
7168 current.DefaultUIScale = cfg.DefaultUIScale
7269 current.DefaultSearchEngine = cfg.DefaultSearchEngine
7370 if len(cfg.SearchShortcuts) == 0 {
@@ -64,10 +64,7 @@ func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIC
64 Accent: cfg.Appearance.DarkPalette.Accent,64 Accent: cfg.Appearance.DarkPalette.Accent,
65 Border: cfg.Appearance.DarkPalette.Border,65 Border: cfg.Appearance.DarkPalette.Border,
66 }66 }
67- current.Appearance.ExternalTheme.Enabled = cfg.Appearance.ExternalTheme.Enabled67+ current.Appearance.ExternalTheme = cfg.Appearance.ExternalTheme
68- current.Appearance.ExternalTheme.Provider = cfg.Appearance.ExternalTheme.Provider
69- current.Appearance.ExternalTheme.Format = cfg.Appearance.ExternalTheme.Format
70- current.Appearance.ExternalTheme.Path = cfg.Appearance.ExternalTheme.Path
71 current.DefaultUIScale = cfg.DefaultUIScale68 current.DefaultUIScale = cfg.DefaultUIScale
72 current.DefaultSearchEngine = cfg.DefaultSearchEngine69 current.DefaultSearchEngine = cfg.DefaultSearchEngine
73 if len(cfg.SearchShortcuts) == 0 {70 if len(cfg.SearchShortcuts) == 0 {
M internal/infrastructure/externaltheme/noctalia/source.go +5 -1
@@ -63,13 +63,17 @@ func (s *FileSource) Configure(cfg entity.ExternalThemeConfig) {
6363 provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format)
6464 enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format)
6565 path := strings.TrimSpace(cfg.Path)
66+ identityPath := path
67+ if expandedPath, err := ExpandPath(path); err == nil {
68+ identityPath = expandedPath
69+ }
6670
6771 s.mu.Lock()
6872 defer s.mu.Unlock()
6973 s.enabled = enabled
7074 s.path = path
7175 s.format = format
72- s.identity = externalThemeIdentity(enabled, provider, format, path)
76+ s.identity = externalThemeIdentity(enabled, provider, format, identityPath)
7377 }
7478
7579 // ExternalThemeIdentity returns the current normalized external theme identity.
@@ -63,13 +63,17 @@ func (s *FileSource) Configure(cfg entity.ExternalThemeConfig) {
63 provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format)63 provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format)
64 enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format)64 enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format)
65 path := strings.TrimSpace(cfg.Path)65 path := strings.TrimSpace(cfg.Path)
66+ identityPath := path
67+ if expandedPath, err := ExpandPath(path); err == nil {
68+ identityPath = expandedPath
69+ }
66 70
67 s.mu.Lock()71 s.mu.Lock()
68 defer s.mu.Unlock()72 defer s.mu.Unlock()
69 s.enabled = enabled73 s.enabled = enabled
70 s.path = path74 s.path = path
71 s.format = format75 s.format = format
72- s.identity = externalThemeIdentity(enabled, provider, format, path)76+ s.identity = externalThemeIdentity(enabled, provider, format, identityPath)
73 }77 }
74 78
75 // ExternalThemeIdentity returns the current normalized external theme identity.79 // ExternalThemeIdentity returns the current normalized external theme identity.
M internal/infrastructure/externaltheme/noctalia/source_test.go +38 -4
@@ -4,6 +4,7 @@ import (
44 "context"
55 "os"
66 "path/filepath"
7+ "runtime"
78 "testing"
89
910 "github.com/bnema/dumber/internal/domain/entity"
@@ -38,7 +39,7 @@ func TestParseColorsJSONValidActivePalette(t *testing.T) {
3839 }
3940
4041 func TestParseColorsJSONDocsExample(t *testing.T) {
41- path := filepath.Join("..", "..", "..", "..", "docs", "examples", "noctalia-colors.json")
42+ path := docsExamplePath(t, "noctalia-colors.json")
4243 data, err := os.ReadFile(path)
4344 if err != nil {
4445 t.Fatalf("ReadFile(%q) error = %v", path, err)
@@ -48,7 +49,7 @@ func TestParseColorsJSONDocsExample(t *testing.T) {
4849 if err != nil {
4950 t.Fatalf("ParseColorsJSON(docs example) error = %v", err)
5051 }
51- if theme.Provider != "noctalia" || theme.DarkPalette.Accent == "" {
52+ if theme == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.DarkPalette.Accent == "" {
5253 t.Fatalf("parsed docs example = %+v", theme)
5354 }
5455 }
@@ -142,7 +143,7 @@ func TestParseDumberJSONValidFullPalette(t *testing.T) {
142143 }
143144
144145 func TestParseDumberJSONDocsExample(t *testing.T) {
145- path := filepath.Join("..", "..", "..", "..", "docs", "examples", "noctalia-dumber-theme.json")
146+ path := docsExamplePath(t, "noctalia-dumber-theme.json")
146147 data, err := os.ReadFile(path)
147148 if err != nil {
148149 t.Fatalf("ReadFile(%q) error = %v", path, err)
@@ -152,7 +153,7 @@ func TestParseDumberJSONDocsExample(t *testing.T) {
152153 if err != nil {
153154 t.Fatalf("ParseDumberJSON(docs example) error = %v", err)
154155 }
155- if theme.Provider != "noctalia" || theme.LightPalette.Background == "" || theme.DarkPalette.Background == "" {
156+ if theme == nil || theme.LightPalette == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.LightPalette.Background == "" || theme.DarkPalette.Background == "" {
156157 t.Fatalf("parsed docs example = %+v", theme)
157158 }
158159 }
@@ -234,6 +235,30 @@ func TestExpandPath(t *testing.T) {
234235 }
235236 }
236237
238+func TestFileSourceIdentityUsesExpandedPath(t *testing.T) {
239+ home := t.TempDir()
240+ t.Setenv("HOME", home)
241+
242+ path := filepath.Join(home, ".config", "noctalia", "colors.json")
243+ source := NewFileSourceFromConfig(entity.ExternalThemeConfig{
244+ Enabled: true,
245+ Provider: "noctalia",
246+ Format: "colors-json",
247+ Path: path,
248+ })
249+ absoluteIdentity := source.ExternalThemeIdentity()
250+
251+ source.Configure(entity.ExternalThemeConfig{
252+ Enabled: true,
253+ Provider: "noctalia",
254+ Format: "colors-json",
255+ Path: "~/.config/noctalia/colors.json",
256+ })
257+ if source.ExternalThemeIdentity() != absoluteIdentity {
258+ t.Fatalf("ExternalThemeIdentity() = %q, want %q", source.ExternalThemeIdentity(), absoluteIdentity)
259+ }
260+}
261+
237262 func TestFileSourceDisabledBehavior(t *testing.T) {
238263 source := NewFileSource(false, "/does/not/exist")
239264 if source.IsEnabled() {
@@ -335,6 +360,15 @@ func TestFileSourceConfigureUpdatesSettings(t *testing.T) {
335360 }
336361 }
337362
363+func docsExamplePath(t *testing.T, name string) string {
364+ t.Helper()
365+ _, file, _, ok := runtime.Caller(0)
366+ if !ok {
367+ t.Fatal("runtime.Caller(0) failed")
368+ }
369+ return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "docs", "examples", name)
370+}
371+
338372 func noctaliaColorsJSON() []byte {
339373 return []byte(`{
340374 "mError": "#ff8080",
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "os"5 "os"
6 "path/filepath"6 "path/filepath"
7+ "runtime"
7 "testing"8 "testing"
8 9
9 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
@@ -38,7 +39,7 @@ func TestParseColorsJSONValidActivePalette(t *testing.T) {
38 }39 }
39 40
40 func TestParseColorsJSONDocsExample(t *testing.T) {41 func TestParseColorsJSONDocsExample(t *testing.T) {
41- path := filepath.Join("..", "..", "..", "..", "docs", "examples", "noctalia-colors.json")42+ path := docsExamplePath(t, "noctalia-colors.json")
42 data, err := os.ReadFile(path)43 data, err := os.ReadFile(path)
43 if err != nil {44 if err != nil {
44 t.Fatalf("ReadFile(%q) error = %v", path, err)45 t.Fatalf("ReadFile(%q) error = %v", path, err)
@@ -48,7 +49,7 @@ func TestParseColorsJSONDocsExample(t *testing.T) {
48 if err != nil {49 if err != nil {
49 t.Fatalf("ParseColorsJSON(docs example) error = %v", err)50 t.Fatalf("ParseColorsJSON(docs example) error = %v", err)
50 }51 }
51- if theme.Provider != "noctalia" || theme.DarkPalette.Accent == "" {52+ if theme == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.DarkPalette.Accent == "" {
52 t.Fatalf("parsed docs example = %+v", theme)53 t.Fatalf("parsed docs example = %+v", theme)
53 }54 }
54 }55 }
@@ -142,7 +143,7 @@ func TestParseDumberJSONValidFullPalette(t *testing.T) {
142 }143 }
143 144
144 func TestParseDumberJSONDocsExample(t *testing.T) {145 func TestParseDumberJSONDocsExample(t *testing.T) {
145- path := filepath.Join("..", "..", "..", "..", "docs", "examples", "noctalia-dumber-theme.json")146+ path := docsExamplePath(t, "noctalia-dumber-theme.json")
146 data, err := os.ReadFile(path)147 data, err := os.ReadFile(path)
147 if err != nil {148 if err != nil {
148 t.Fatalf("ReadFile(%q) error = %v", path, err)149 t.Fatalf("ReadFile(%q) error = %v", path, err)
@@ -152,7 +153,7 @@ func TestParseDumberJSONDocsExample(t *testing.T) {
152 if err != nil {153 if err != nil {
153 t.Fatalf("ParseDumberJSON(docs example) error = %v", err)154 t.Fatalf("ParseDumberJSON(docs example) error = %v", err)
154 }155 }
155- if theme.Provider != "noctalia" || theme.LightPalette.Background == "" || theme.DarkPalette.Background == "" {156+ if theme == nil || theme.LightPalette == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.LightPalette.Background == "" || theme.DarkPalette.Background == "" {
156 t.Fatalf("parsed docs example = %+v", theme)157 t.Fatalf("parsed docs example = %+v", theme)
157 }158 }
158 }159 }
@@ -234,6 +235,30 @@ func TestExpandPath(t *testing.T) {
234 }235 }
235 }236 }
236 237
238+func TestFileSourceIdentityUsesExpandedPath(t *testing.T) {
239+ home := t.TempDir()
240+ t.Setenv("HOME", home)
241+
242+ path := filepath.Join(home, ".config", "noctalia", "colors.json")
243+ source := NewFileSourceFromConfig(entity.ExternalThemeConfig{
244+ Enabled: true,
245+ Provider: "noctalia",
246+ Format: "colors-json",
247+ Path: path,
248+ })
249+ absoluteIdentity := source.ExternalThemeIdentity()
250+
251+ source.Configure(entity.ExternalThemeConfig{
252+ Enabled: true,
253+ Provider: "noctalia",
254+ Format: "colors-json",
255+ Path: "~/.config/noctalia/colors.json",
256+ })
257+ if source.ExternalThemeIdentity() != absoluteIdentity {
258+ t.Fatalf("ExternalThemeIdentity() = %q, want %q", source.ExternalThemeIdentity(), absoluteIdentity)
259+ }
260+}
261+
237 func TestFileSourceDisabledBehavior(t *testing.T) {262 func TestFileSourceDisabledBehavior(t *testing.T) {
238 source := NewFileSource(false, "/does/not/exist")263 source := NewFileSource(false, "/does/not/exist")
239 if source.IsEnabled() {264 if source.IsEnabled() {
@@ -335,6 +360,15 @@ func TestFileSourceConfigureUpdatesSettings(t *testing.T) {
335 }360 }
336 }361 }
337 362
363+func docsExamplePath(t *testing.T, name string) string {
364+ t.Helper()
365+ _, file, _, ok := runtime.Caller(0)
366+ if !ok {
367+ t.Fatal("runtime.Caller(0) failed")
368+ }
369+ return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "docs", "examples", name)
370+}
371+
338 func noctaliaColorsJSON() []byte {372 func noctaliaColorsJSON() []byte {
339 return []byte(`{373 return []byte(`{
340 "mError": "#ff8080",374 "mError": "#ff8080",
M internal/infrastructure/externaltheme/noctalia/watcher.go +9 -2
@@ -43,7 +43,9 @@ func NewFileWatcher() *FileWatcher {
4343 func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig, onChange func()) error {
4444 path, enabled, err := watchPathFromConfig(cfg)
4545 if err != nil {
46- _ = w.Stop()
46+ if stopErr := w.Stop(); stopErr != nil {
47+ return errors.Join(err, fmt.Errorf("stop noctalia theme watcher: %w", stopErr))
48+ }
4749 return err
4850 }
4951 if !enabled || path == "" {
@@ -75,7 +77,12 @@ func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig,
7577 return fmt.Errorf("create noctalia theme watcher: %w", err)
7678 }
7779 if err := watcher.Add(parent); err != nil {
78- _ = watcher.Close()
80+ if closeErr := watcher.Close(); closeErr != nil {
81+ return errors.Join(
82+ fmt.Errorf("watch noctalia theme parent directory: %w", err),
83+ fmt.Errorf("close noctalia theme watcher after add failure: %w", closeErr),
84+ )
85+ }
7986 return fmt.Errorf("watch noctalia theme parent directory: %w", err)
8087 }
8188
@@ -43,7 +43,9 @@ func NewFileWatcher() *FileWatcher {
43 func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig, onChange func()) error {43 func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig, onChange func()) error {
44 path, enabled, err := watchPathFromConfig(cfg)44 path, enabled, err := watchPathFromConfig(cfg)
45 if err != nil {45 if err != nil {
46- _ = w.Stop()46+ if stopErr := w.Stop(); stopErr != nil {
47+ return errors.Join(err, fmt.Errorf("stop noctalia theme watcher: %w", stopErr))
48+ }
47 return err49 return err
48 }50 }
49 if !enabled || path == "" {51 if !enabled || path == "" {
@@ -75,7 +77,12 @@ func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig,
75 return fmt.Errorf("create noctalia theme watcher: %w", err)77 return fmt.Errorf("create noctalia theme watcher: %w", err)
76 }78 }
77 if err := watcher.Add(parent); err != nil {79 if err := watcher.Add(parent); err != nil {
78- _ = watcher.Close()80+ if closeErr := watcher.Close(); closeErr != nil {
81+ return errors.Join(
82+ fmt.Errorf("watch noctalia theme parent directory: %w", err),
83+ fmt.Errorf("close noctalia theme watcher after add failure: %w", closeErr),
84+ )
85+ }
79 return fmt.Errorf("watch noctalia theme parent directory: %w", err)86 return fmt.Errorf("watch noctalia theme parent directory: %w", err)
80 }87 }
81 88
M internal/infrastructure/externaltheme/noctalia/watcher_test.go +1 -1
@@ -238,7 +238,7 @@ func waitForStringChange(t *testing.T, ch <-chan string) string {
238238 case <-time.After(500 * time.Millisecond):
239239 t.Fatal("timed out waiting for callback")
240240 }
241- return ""
241+ panic("unreachable")
242242 }
243243
244244 func assertNoStringChange(t *testing.T, ch <-chan string, d time.Duration) {
@@ -238,7 +238,7 @@ func waitForStringChange(t *testing.T, ch <-chan string) string {
238 case <-time.After(500 * time.Millisecond):238 case <-time.After(500 * time.Millisecond):
239 t.Fatal("timed out waiting for callback")239 t.Fatal("timed out waiting for callback")
240 }240 }
241- return ""241+ panic("unreachable")
242 }242 }
243 243
244 func assertNoStringChange(t *testing.T, ch <-chan string, d time.Duration) {244 func assertNoStringChange(t *testing.T, ch <-chan string, d time.Duration) {

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