detached 44d4d49

feat(theme): make auto read $TERMINAL only, and stop ghostty themes from recursing forever

commit
44d4d499006156d2f303059c67aeec1940c4c2cb
author
brice · · 2026-10-05 16:00 UTC
parent
eff799f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +122 -94
M internal/theme/auto.go +33 -57
@@ -1,6 +1,7 @@
11 package theme
22
33 import (
4+ "fmt"
45 "os"
56 "path/filepath"
67 "strings"
@@ -10,16 +11,16 @@ import (
1011 type terminal struct {
1112 name string
1213 format string
13- rel string // path under the config directory
14- match []string // substrings of $TERMINAL or a desktop-file id
14+ rel string // path under the config directory
1515 }
1616
17-// terminals is the order used when nothing says which one is in use.
18-var terminals = []terminal{
19- {"kitty", "kitty", "kitty/kitty.conf", []string{"kitty"}},
20- {"ghostty", "ghostty", "ghostty/config", []string{"ghostty"}},
21- {"foot", "foot", "foot/foot.ini", []string{"foot"}},
22- {"alacritty", "alacritty", "alacritty/alacritty.toml", []string{"alacritty"}},
17+// terminals are the terminals auto knows how to read, by the name $TERMINAL
18+// gives them.
19+var terminals = map[string]terminal{
20+ "kitty": {"kitty", "kitty", "kitty/kitty.conf"},
21+ "ghostty": {"ghostty", "ghostty", "ghostty/config"},
22+ "foot": {"foot", "foot", "foot/foot.ini"},
23+ "alacritty": {"alacritty", "alacritty", "alacritty/alacritty.toml"},
2324 }
2425
2526 // Env is how Auto reads its surroundings, so tests can fake them.
@@ -38,58 +39,33 @@ func SystemEnv() Env {
3839 return Env{Getenv: os.Getenv, ConfigDir: dir}
3940 }
4041
41-// preferred names the terminal the user says they use, or "". It looks at
42-// $TERMINAL, then at the first entry of xdg-terminals.list (the file read by
43-// xdg-terminal-exec).
44-func (e Env) preferred() (string, string) {
45- if v := e.Getenv("TERMINAL"); v != "" {
46- return filepath.Base(strings.Fields(v)[0]), "$TERMINAL"
42+// Auto reads the theme of the terminal named by $TERMINAL: a bare name such as
43+// "kitty" or a path such as "/usr/bin/foot". It is deliberately the only rule,
44+// so there is one place to look when the colors are not the ones you expect.
45+func Auto(e Env) (Theme, error) {
46+ value := strings.TrimSpace(e.Getenv("TERMINAL"))
47+ if value == "" {
48+ return Theme{}, fmt.Errorf(`theme = "auto" needs $TERMINAL (for example TERMINAL=kitty). ` +
49+ `Put it in ~/.config/environment.d/10-terminal.conf and start a new session`)
4750 }
48- data, err := os.ReadFile(filepath.Join(e.ConfigDir, "xdg-terminals.list"))
49- if err == nil {
50- for _, l := range strings.Split(string(data), "\n") {
51- if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {
52- return l, "xdg-terminals.list"
53- }
54- }
51+ // Only the program name counts: "/usr/bin/foot --server" is foot.
52+ name := filepath.Base(strings.Fields(value)[0])
53+ term, ok := terminals[name]
54+ if !ok {
55+ return Theme{}, fmt.Errorf("$TERMINAL is %q, but neferbar reads the themes of kitty, ghostty, foot and alacritty only; "+
56+ `set bar.theme to the path of a theme file instead`, name)
5557 }
56- return "", ""
57-}
58-
59-// Auto reads the theme of the terminal in use: the one named by $TERMINAL or
60-// xdg-terminals.list when its config exists, otherwise the first of kitty,
61-// ghostty, foot and alacritty that has a config with colors in it.
62-func Auto(e Env) (Theme, error) {
63- var order []terminal
64- why := "first terminal config found"
65- if want, src := e.preferred(); want != "" {
66- for _, t := range terminals {
67- for _, m := range t.match {
68- if strings.Contains(strings.ToLower(want), m) {
69- order = append(order, t)
70- }
71- }
72- }
73- if len(order) > 0 {
74- why = src + " says " + want
75- }
58+ path := filepath.Join(e.ConfigDir, term.rel)
59+ if !fileExists(path) {
60+ return Theme{}, fmt.Errorf("$TERMINAL is %s, but %s does not exist", name, path)
7661 }
77- for _, t := range terminals {
78- order = append(order, t)
62+ th, err := formats[term.format].load(path)
63+ if err != nil {
64+ return Theme{}, fmt.Errorf("cannot read the %s config %s: %w", name, path, err)
7965 }
80- var tried []string
81- for _, t := range order {
82- path := filepath.Join(e.ConfigDir, t.rel)
83- tried = append(tried, path)
84- if !fileExists(path) {
85- continue
86- }
87- th, err := formats[t.format].load(path)
88- if err != nil || th.Defines() == 0 {
89- continue
90- }
91- th.Source = t.name + " (" + path + "): " + why
92- return th, nil
66+ if th.Defines() == 0 {
67+ return Theme{}, fmt.Errorf("the %s config %s sets no colors", name, path)
9368 }
94- return Theme{}, noTheme(tried)
69+ th.Source = fmt.Sprintf("%s (%s), from $TERMINAL", name, path)
70+ return th, nil
9571 }
@@ -1,6 +1,7 @@
1 package theme1 package theme
2 2
3 import (3 import (
4+ "fmt"
4 "os"5 "os"
5 "path/filepath"6 "path/filepath"
6 "strings"7 "strings"
@@ -10,16 +11,16 @@ import (
10 type terminal struct {11 type terminal struct {
11 name string12 name string
12 format string13 format string
13- rel string // path under the config directory14+ rel string // path under the config directory
14- match []string // substrings of $TERMINAL or a desktop-file id
15 }15 }
16 16
17-// terminals is the order used when nothing says which one is in use.17+// terminals are the terminals auto knows how to read, by the name $TERMINAL
18-var terminals = []terminal{18+// gives them.
19- {"kitty", "kitty", "kitty/kitty.conf", []string{"kitty"}},19+var terminals = map[string]terminal{
20- {"ghostty", "ghostty", "ghostty/config", []string{"ghostty"}},20+ "kitty": {"kitty", "kitty", "kitty/kitty.conf"},
21- {"foot", "foot", "foot/foot.ini", []string{"foot"}},21+ "ghostty": {"ghostty", "ghostty", "ghostty/config"},
22- {"alacritty", "alacritty", "alacritty/alacritty.toml", []string{"alacritty"}},22+ "foot": {"foot", "foot", "foot/foot.ini"},
23+ "alacritty": {"alacritty", "alacritty", "alacritty/alacritty.toml"},
23 }24 }
24 25
25 // Env is how Auto reads its surroundings, so tests can fake them.26 // Env is how Auto reads its surroundings, so tests can fake them.
@@ -38,58 +39,33 @@ func SystemEnv() Env {
38 return Env{Getenv: os.Getenv, ConfigDir: dir}39 return Env{Getenv: os.Getenv, ConfigDir: dir}
39 }40 }
40 41
41-// preferred names the terminal the user says they use, or "". It looks at42+// Auto reads the theme of the terminal named by $TERMINAL: a bare name such as
42-// $TERMINAL, then at the first entry of xdg-terminals.list (the file read by43+// "kitty" or a path such as "/usr/bin/foot". It is deliberately the only rule,
43-// xdg-terminal-exec).44+// so there is one place to look when the colors are not the ones you expect.
44-func (e Env) preferred() (string, string) {45+func Auto(e Env) (Theme, error) {
45- if v := e.Getenv("TERMINAL"); v != "" {46+ value := strings.TrimSpace(e.Getenv("TERMINAL"))
46- return filepath.Base(strings.Fields(v)[0]), "$TERMINAL"47+ if value == "" {
48+ return Theme{}, fmt.Errorf(`theme = "auto" needs $TERMINAL (for example TERMINAL=kitty). ` +
49+ `Put it in ~/.config/environment.d/10-terminal.conf and start a new session`)
47 }50 }
48- data, err := os.ReadFile(filepath.Join(e.ConfigDir, "xdg-terminals.list"))51+ // Only the program name counts: "/usr/bin/foot --server" is foot.
49- if err == nil {52+ name := filepath.Base(strings.Fields(value)[0])
50- for _, l := range strings.Split(string(data), "\n") {53+ term, ok := terminals[name]
51- if l = strings.TrimSpace(l); l != "" && !strings.HasPrefix(l, "#") {54+ if !ok {
52- return l, "xdg-terminals.list"55+ return Theme{}, fmt.Errorf("$TERMINAL is %q, but neferbar reads the themes of kitty, ghostty, foot and alacritty only; "+
53- }56+ `set bar.theme to the path of a theme file instead`, name)
54- }
55 }57 }
56- return "", ""58+ path := filepath.Join(e.ConfigDir, term.rel)
57-}59+ if !fileExists(path) {
58-60+ return Theme{}, fmt.Errorf("$TERMINAL is %s, but %s does not exist", name, path)
59-// Auto reads the theme of the terminal in use: the one named by $TERMINAL or
60-// xdg-terminals.list when its config exists, otherwise the first of kitty,
61-// ghostty, foot and alacritty that has a config with colors in it.
62-func Auto(e Env) (Theme, error) {
63- var order []terminal
64- why := "first terminal config found"
65- if want, src := e.preferred(); want != "" {
66- for _, t := range terminals {
67- for _, m := range t.match {
68- if strings.Contains(strings.ToLower(want), m) {
69- order = append(order, t)
70- }
71- }
72- }
73- if len(order) > 0 {
74- why = src + " says " + want
75- }
76 }61 }
77- for _, t := range terminals {62+ th, err := formats[term.format].load(path)
78- order = append(order, t)63+ if err != nil {
64+ return Theme{}, fmt.Errorf("cannot read the %s config %s: %w", name, path, err)
79 }65 }
80- var tried []string66+ if th.Defines() == 0 {
81- for _, t := range order {67+ return Theme{}, fmt.Errorf("the %s config %s sets no colors", name, path)
82- path := filepath.Join(e.ConfigDir, t.rel)
83- tried = append(tried, path)
84- if !fileExists(path) {
85- continue
86- }
87- th, err := formats[t.format].load(path)
88- if err != nil || th.Defines() == 0 {
89- continue
90- }
91- th.Source = t.name + " (" + path + "): " + why
92- return th, nil
93 }68 }
94- return Theme{}, noTheme(tried)69+ th.Source = fmt.Sprintf("%s (%s), from $TERMINAL", name, path)
70+ return th, nil
95 }71 }
M internal/theme/formats.go +13 -13
@@ -2,8 +2,6 @@ package theme
22
33 import (
44 "bufio"
5- "errors"
6- "fmt"
75 "os"
86 "path/filepath"
97 "strconv"
@@ -29,6 +27,15 @@ func lines(path string) ([]string, error) {
2927 return out, sc.Err()
3028 }
3129
30+// splitFirstWord splits "key value" at the first run of spaces or tabs.
31+func splitFirstWord(l string) (key, val string) {
32+ i := strings.IndexAny(l, " \t")
33+ if i < 0 {
34+ return l, ""
35+ }
36+ return l[:i], strings.TrimSpace(l[i:])
37+}
38+
3239 // ---- kitty: "key value" lines, "include file" ------------------------------
3340
3441 func loadKitty(path string, depth int) (Theme, error) {
@@ -42,8 +49,7 @@ func loadKitty(path string, depth int) (Theme, error) {
4249 if strings.HasPrefix(l, "#") {
4350 continue
4451 }
45- key, val, _ := strings.Cut(l, " ")
46- val = strings.TrimSpace(val)
52+ key, val := splitFirstWord(l)
4753 switch {
4854 case key == "include" && depth < maxDepth:
4955 inc, err := loadKitty(expand(val, filepath.Dir(path)), depth+1)
@@ -158,7 +164,7 @@ func ghosttyDirs(config string) []string {
158164 }
159165 }
160166
161-func loadGhostty(path string) (Theme, error) {
167+func loadGhostty(path string, depth int) (Theme, error) {
162168 var own Theme
163169 themeName := ""
164170 ls, err := lines(path)
@@ -201,8 +207,8 @@ func loadGhostty(path string) (Theme, error) {
201207 // The named theme is applied first; the config's own colors win over it.
202208 var t Theme
203209 if themeName != "" {
204- if file := findGhosttyTheme(path, themeName); file != "" {
205- if th, err := loadGhostty(file); err == nil {
210+ if file := findGhosttyTheme(path, themeName); file != "" && depth < maxDepth {
211+ if th, err := loadGhostty(file, depth+1); err == nil {
206212 t.overlay(th)
207213 }
208214 }
@@ -289,9 +295,3 @@ func fileExists(p string) bool {
289295 st, err := os.Stat(p)
290296 return err == nil && !st.IsDir()
291297 }
292-
293-var errNoTerminal = errors.New("theme: no terminal config found")
294-
295-func noTheme(tried []string) error {
296- return fmt.Errorf("%w (looked for %s)", errNoTerminal, strings.Join(tried, ", "))
297-}
@@ -2,8 +2,6 @@ package theme
2 2
3 import (3 import (
4 "bufio"4 "bufio"
5- "errors"
6- "fmt"
7 "os"5 "os"
8 "path/filepath"6 "path/filepath"
9 "strconv"7 "strconv"
@@ -29,6 +27,15 @@ func lines(path string) ([]string, error) {
29 return out, sc.Err()27 return out, sc.Err()
30 }28 }
31 29
30+// splitFirstWord splits "key value" at the first run of spaces or tabs.
31+func splitFirstWord(l string) (key, val string) {
32+ i := strings.IndexAny(l, " \t")
33+ if i < 0 {
34+ return l, ""
35+ }
36+ return l[:i], strings.TrimSpace(l[i:])
37+}
38+
32 // ---- kitty: "key value" lines, "include file" ------------------------------39 // ---- kitty: "key value" lines, "include file" ------------------------------
33 40
34 func loadKitty(path string, depth int) (Theme, error) {41 func loadKitty(path string, depth int) (Theme, error) {
@@ -42,8 +49,7 @@ func loadKitty(path string, depth int) (Theme, error) {
42 if strings.HasPrefix(l, "#") {49 if strings.HasPrefix(l, "#") {
43 continue50 continue
44 }51 }
45- key, val, _ := strings.Cut(l, " ")52+ key, val := splitFirstWord(l)
46- val = strings.TrimSpace(val)
47 switch {53 switch {
48 case key == "include" && depth < maxDepth:54 case key == "include" && depth < maxDepth:
49 inc, err := loadKitty(expand(val, filepath.Dir(path)), depth+1)55 inc, err := loadKitty(expand(val, filepath.Dir(path)), depth+1)
@@ -158,7 +164,7 @@ func ghosttyDirs(config string) []string {
158 }164 }
159 }165 }
160 166
161-func loadGhostty(path string) (Theme, error) {167+func loadGhostty(path string, depth int) (Theme, error) {
162 var own Theme168 var own Theme
163 themeName := ""169 themeName := ""
164 ls, err := lines(path)170 ls, err := lines(path)
@@ -201,8 +207,8 @@ func loadGhostty(path string) (Theme, error) {
201 // The named theme is applied first; the config's own colors win over it.207 // The named theme is applied first; the config's own colors win over it.
202 var t Theme208 var t Theme
203 if themeName != "" {209 if themeName != "" {
204- if file := findGhosttyTheme(path, themeName); file != "" {210+ if file := findGhosttyTheme(path, themeName); file != "" && depth < maxDepth {
205- if th, err := loadGhostty(file); err == nil {211+ if th, err := loadGhostty(file, depth+1); err == nil {
206 t.overlay(th)212 t.overlay(th)
207 }213 }
208 }214 }
@@ -289,9 +295,3 @@ func fileExists(p string) bool {
289 st, err := os.Stat(p)295 st, err := os.Stat(p)
290 return err == nil && !st.IsDir()296 return err == nil && !st.IsDir()
291 }297 }
292-
293-var errNoTerminal = errors.New("theme: no terminal config found")
294-
295-func noTheme(tried []string) error {
296- return fmt.Errorf("%w (looked for %s)", errNoTerminal, strings.Join(tried, ", "))
297-}
M internal/theme/theme.go +23 -8
@@ -60,6 +60,19 @@ func (t Theme) Fill(def Theme) Theme {
6060 return t
6161 }
6262
63+// dedupFiles removes repeated entries, keeping the first of each.
64+func dedupFiles(files []string) []string {
65+ seen := make(map[string]bool, len(files))
66+ out := files[:0:0]
67+ for _, f := range files {
68+ if !seen[f] {
69+ seen[f] = true
70+ out = append(out, f)
71+ }
72+ }
73+ return out
74+}
75+
6376 // Defines reports how many of the 18 colors the source set.
6477 func (t Theme) Defines() int {
6578 n := 0
@@ -142,6 +155,7 @@ func Load(path string) (Theme, error) {
142155 return Theme{}, fmt.Errorf("theme: %s defines no colors (read as %s)", path, f.name)
143156 }
144157 t.Source = fmt.Sprintf("%s (%s)", path, f.name)
158+ t.Files = dedupFiles(t.Files)
145159 return t, nil
146160 }
147161
@@ -176,7 +190,7 @@ var formats = map[string]format{}
176190 func init() {
177191 formats["kitty"] = format{"kitty", func(p string) (Theme, error) { return loadKitty(p, 0) }}
178192 formats["foot"] = format{"foot", func(p string) (Theme, error) { return loadFoot(p, 0) }}
179- formats["ghostty"] = format{"ghostty", loadGhostty}
193+ formats["ghostty"] = format{"ghostty", func(p string) (Theme, error) { return loadGhostty(p, 0) }}
180194 formats["alacritty"] = format{"alacritty", func(p string) (Theme, error) { return loadAlacritty(p, 0) }}
181195 }
182196
@@ -185,18 +199,19 @@ func init() {
185199 // path is taken from baseDir. Colors the source leaves out come from Default.
186200 func Resolve(spec, baseDir string, env Env) (Theme, error) {
187201 def := Default()
202+ var t Theme
203+ var err error
188204 if spec == "auto" {
189- t, err := Auto(env)
190- if err != nil {
191- return def, err
192- }
193- return t.Fill(def), nil
205+ t, err = Auto(env)
206+ } else {
207+ t, err = Load(expand(spec, baseDir))
194208 }
195- t, err := Load(expand(spec, baseDir))
196209 if err != nil {
197210 return def, err
198211 }
199- return t.Fill(def), nil
212+ t = t.Fill(def)
213+ t.Files = dedupFiles(t.Files)
214+ return t, nil
200215 }
201216
202217 // Mix returns a blended with b: t=0 is a, t=1 is b.
@@ -60,6 +60,19 @@ func (t Theme) Fill(def Theme) Theme {
60 return t60 return t
61 }61 }
62 62
63+// dedupFiles removes repeated entries, keeping the first of each.
64+func dedupFiles(files []string) []string {
65+ seen := make(map[string]bool, len(files))
66+ out := files[:0:0]
67+ for _, f := range files {
68+ if !seen[f] {
69+ seen[f] = true
70+ out = append(out, f)
71+ }
72+ }
73+ return out
74+}
75+
63 // Defines reports how many of the 18 colors the source set.76 // Defines reports how many of the 18 colors the source set.
64 func (t Theme) Defines() int {77 func (t Theme) Defines() int {
65 n := 078 n := 0
@@ -142,6 +155,7 @@ func Load(path string) (Theme, error) {
142 return Theme{}, fmt.Errorf("theme: %s defines no colors (read as %s)", path, f.name)155 return Theme{}, fmt.Errorf("theme: %s defines no colors (read as %s)", path, f.name)
143 }156 }
144 t.Source = fmt.Sprintf("%s (%s)", path, f.name)157 t.Source = fmt.Sprintf("%s (%s)", path, f.name)
158+ t.Files = dedupFiles(t.Files)
145 return t, nil159 return t, nil
146 }160 }
147 161
@@ -176,7 +190,7 @@ var formats = map[string]format{}
176 func init() {190 func init() {
177 formats["kitty"] = format{"kitty", func(p string) (Theme, error) { return loadKitty(p, 0) }}191 formats["kitty"] = format{"kitty", func(p string) (Theme, error) { return loadKitty(p, 0) }}
178 formats["foot"] = format{"foot", func(p string) (Theme, error) { return loadFoot(p, 0) }}192 formats["foot"] = format{"foot", func(p string) (Theme, error) { return loadFoot(p, 0) }}
179- formats["ghostty"] = format{"ghostty", loadGhostty}193+ formats["ghostty"] = format{"ghostty", func(p string) (Theme, error) { return loadGhostty(p, 0) }}
180 formats["alacritty"] = format{"alacritty", func(p string) (Theme, error) { return loadAlacritty(p, 0) }}194 formats["alacritty"] = format{"alacritty", func(p string) (Theme, error) { return loadAlacritty(p, 0) }}
181 }195 }
182 196
@@ -185,18 +199,19 @@ func init() {
185 // path is taken from baseDir. Colors the source leaves out come from Default.199 // path is taken from baseDir. Colors the source leaves out come from Default.
186 func Resolve(spec, baseDir string, env Env) (Theme, error) {200 func Resolve(spec, baseDir string, env Env) (Theme, error) {
187 def := Default()201 def := Default()
202+ var t Theme
203+ var err error
188 if spec == "auto" {204 if spec == "auto" {
189- t, err := Auto(env)205+ t, err = Auto(env)
190- if err != nil {206+ } else {
191- return def, err207+ t, err = Load(expand(spec, baseDir))
192- }
193- return t.Fill(def), nil
194 }208 }
195- t, err := Load(expand(spec, baseDir))
196 if err != nil {209 if err != nil {
197 return def, err210 return def, err
198 }211 }
199- return t.Fill(def), nil212+ t = t.Fill(def)
213+ t.Files = dedupFiles(t.Files)
214+ return t, nil
200 }215 }
201 216
202 // Mix returns a blended with b: t=0 is a, t=1 is b.217 // Mix returns a blended with b: t=0 is a, t=1 is b.
M internal/theme/theme_test.go +53 -16
@@ -3,6 +3,7 @@ package theme
33 import (
44 "os"
55 "path/filepath"
6+ "strings"
67 "testing"
78 )
89
@@ -136,34 +137,70 @@ func fakeEnv(dir string, vars map[string]string) Env {
136137 return Env{ConfigDir: dir, Getenv: func(k string) string { return vars[k] }}
137138 }
138139
139-func TestAutoPrefersTheTerminalInUse(t *testing.T) {
140+func TestAutoReadsOnlyTheTerminalVariable(t *testing.T) {
140141 d := t.TempDir()
141142 put(t, d, "kitty/kitty.conf", "color1 #000001\n")
142143 put(t, d, "foot/foot.ini", "[colors]\nregular1 = fd4663\n")
143- got, err := Auto(fakeEnv(d, map[string]string{"TERMINAL": "/usr/bin/foot"}))
144- if err != nil || got.Palette[1] != red {
145- t.Fatalf("$TERMINAL=foot: err=%v c1=%v", err, got.Palette[1])
144+ for _, value := range []string{"foot", "/usr/bin/foot", "/usr/bin/foot --server", " foot "} {
145+ got, err := Auto(fakeEnv(d, map[string]string{"TERMINAL": value}))
146+ if err != nil || got.Palette[1] != red {
147+ t.Errorf("TERMINAL=%q: err=%v c1=%v", value, err, got.Palette[1])
148+ }
146149 }
147- put(t, d, "xdg-terminals.list", "# comment\nkitty.desktop\n")
148- got, _ = Auto(fakeEnv(d, nil))
149- if got.Palette[1] != (RGB{0, 0, 1}) {
150- t.Fatalf("xdg-terminals.list=kitty: c1=%v", got.Palette[1])
150+ // A config for another terminal must not be picked up by accident.
151+ if got, _ := Auto(fakeEnv(d, map[string]string{"TERMINAL": "kitty"})); got.Palette[1] != (RGB{0, 0, 1}) {
152+ t.Errorf("TERMINAL=kitty read the wrong file: c1=%v", got.Palette[1])
151153 }
152154 }
153155
154-func TestAutoFallsBackToTheFirstConfigWithColors(t *testing.T) {
156+func TestAutoErrorsAreOneClearSentence(t *testing.T) {
155157 d := t.TempDir()
156- put(t, d, "kitty/kitty.conf", "font_size 12\n") // exists but has no colors
157- put(t, d, "alacritty/alacritty.toml", "[colors.normal]\nred = '#fd4663'\n")
158- got, err := Auto(fakeEnv(d, nil))
158+ put(t, d, "kitty/kitty.conf", "font_size 12\n") // no colors
159+ cases := map[string]struct{ env, contains string }{
160+ "unset": {"", "needs $TERMINAL"},
161+ "blank": {" ", "needs $TERMINAL"},
162+ "unknown": {"xterm", "kitty, ghostty, foot and alacritty only"},
163+ "no config": {"foot", "does not exist"},
164+ "no colors": {"kitty", "sets no colors"},
165+ }
166+ for name, c := range cases {
167+ _, err := Auto(fakeEnv(d, map[string]string{"TERMINAL": c.env}))
168+ if err == nil || !strings.Contains(err.Error(), c.contains) {
169+ t.Errorf("%s: err = %v, want it to mention %q", name, err, c.contains)
170+ }
171+ }
172+ // The unset message tells the user where to put the variable.
173+ _, err := Auto(fakeEnv(d, nil))
174+ if err == nil || !strings.Contains(err.Error(), "environment.d") {
175+ t.Errorf("unset message must name environment.d: %v", err)
176+ }
177+}
178+
179+func TestGhosttySelfReferenceTerminates(t *testing.T) {
180+ d := t.TempDir()
181+ self := filepath.Join(d, "config")
182+ put(t, d, "config", "theme = "+self+"\npalette = 1=#fd4663\n")
183+ got, err := Load(self)
159184 if err != nil || got.Palette[1] != red {
160- t.Fatalf("err=%v c1=%v", err, got.Palette[1])
185+ t.Fatalf("self-referencing ghostty theme: err=%v c1=%v", err, got.Palette[1])
186+ }
187+}
188+
189+func TestKittyKeysSeparatedByATab(t *testing.T) {
190+ p := put(t, t.TempDir(), "k.conf", "color1\t#fd4663\nbackground\t\t#070722\n")
191+ got, err := Load(p)
192+ if err != nil || got.Palette[1] != red || got.Background != bg {
193+ t.Fatalf("tab-separated keys: err=%v c1=%v bg=%v", err, got.Palette[1], got.Background)
161194 }
162195 }
163196
164-func TestAutoWithNoTerminalIsAnError(t *testing.T) {
165- if _, err := Auto(fakeEnv(t.TempDir(), nil)); err == nil {
166- t.Fatal("no terminal config must be an error the caller can report")
197+func TestFilesAreDeduplicated(t *testing.T) {
198+ d := t.TempDir()
199+ put(t, d, "t.conf", "color1 #fd4663\n")
200+ main := put(t, d, "k.conf", "include t.conf\ninclude t.conf\ninclude t.conf\n")
201+ got, err := Load(main)
202+ if err != nil || len(got.Files) != 2 {
203+ t.Fatalf("Files = %v (err %v), want the config and one include", got.Files, err)
167204 }
168205 }
169206
@@ -3,6 +3,7 @@ package theme
3 import (3 import (
4 "os"4 "os"
5 "path/filepath"5 "path/filepath"
6+ "strings"
6 "testing"7 "testing"
7 )8 )
8 9
@@ -136,34 +137,70 @@ func fakeEnv(dir string, vars map[string]string) Env {
136 return Env{ConfigDir: dir, Getenv: func(k string) string { return vars[k] }}137 return Env{ConfigDir: dir, Getenv: func(k string) string { return vars[k] }}
137 }138 }
138 139
139-func TestAutoPrefersTheTerminalInUse(t *testing.T) {140+func TestAutoReadsOnlyTheTerminalVariable(t *testing.T) {
140 d := t.TempDir()141 d := t.TempDir()
141 put(t, d, "kitty/kitty.conf", "color1 #000001\n")142 put(t, d, "kitty/kitty.conf", "color1 #000001\n")
142 put(t, d, "foot/foot.ini", "[colors]\nregular1 = fd4663\n")143 put(t, d, "foot/foot.ini", "[colors]\nregular1 = fd4663\n")
143- got, err := Auto(fakeEnv(d, map[string]string{"TERMINAL": "/usr/bin/foot"}))144+ for _, value := range []string{"foot", "/usr/bin/foot", "/usr/bin/foot --server", " foot "} {
144- if err != nil || got.Palette[1] != red {145+ got, err := Auto(fakeEnv(d, map[string]string{"TERMINAL": value}))
145- t.Fatalf("$TERMINAL=foot: err=%v c1=%v", err, got.Palette[1])146+ if err != nil || got.Palette[1] != red {
147+ t.Errorf("TERMINAL=%q: err=%v c1=%v", value, err, got.Palette[1])
148+ }
146 }149 }
147- put(t, d, "xdg-terminals.list", "# comment\nkitty.desktop\n")150+ // A config for another terminal must not be picked up by accident.
148- got, _ = Auto(fakeEnv(d, nil))151+ if got, _ := Auto(fakeEnv(d, map[string]string{"TERMINAL": "kitty"})); got.Palette[1] != (RGB{0, 0, 1}) {
149- if got.Palette[1] != (RGB{0, 0, 1}) {152+ t.Errorf("TERMINAL=kitty read the wrong file: c1=%v", got.Palette[1])
150- t.Fatalf("xdg-terminals.list=kitty: c1=%v", got.Palette[1])
151 }153 }
152 }154 }
153 155
154-func TestAutoFallsBackToTheFirstConfigWithColors(t *testing.T) {156+func TestAutoErrorsAreOneClearSentence(t *testing.T) {
155 d := t.TempDir()157 d := t.TempDir()
156- put(t, d, "kitty/kitty.conf", "font_size 12\n") // exists but has no colors158+ put(t, d, "kitty/kitty.conf", "font_size 12\n") // no colors
157- put(t, d, "alacritty/alacritty.toml", "[colors.normal]\nred = '#fd4663'\n")159+ cases := map[string]struct{ env, contains string }{
158- got, err := Auto(fakeEnv(d, nil))160+ "unset": {"", "needs $TERMINAL"},
161+ "blank": {" ", "needs $TERMINAL"},
162+ "unknown": {"xterm", "kitty, ghostty, foot and alacritty only"},
163+ "no config": {"foot", "does not exist"},
164+ "no colors": {"kitty", "sets no colors"},
165+ }
166+ for name, c := range cases {
167+ _, err := Auto(fakeEnv(d, map[string]string{"TERMINAL": c.env}))
168+ if err == nil || !strings.Contains(err.Error(), c.contains) {
169+ t.Errorf("%s: err = %v, want it to mention %q", name, err, c.contains)
170+ }
171+ }
172+ // The unset message tells the user where to put the variable.
173+ _, err := Auto(fakeEnv(d, nil))
174+ if err == nil || !strings.Contains(err.Error(), "environment.d") {
175+ t.Errorf("unset message must name environment.d: %v", err)
176+ }
177+}
178+
179+func TestGhosttySelfReferenceTerminates(t *testing.T) {
180+ d := t.TempDir()
181+ self := filepath.Join(d, "config")
182+ put(t, d, "config", "theme = "+self+"\npalette = 1=#fd4663\n")
183+ got, err := Load(self)
159 if err != nil || got.Palette[1] != red {184 if err != nil || got.Palette[1] != red {
160- t.Fatalf("err=%v c1=%v", err, got.Palette[1])185+ t.Fatalf("self-referencing ghostty theme: err=%v c1=%v", err, got.Palette[1])
186+ }
187+}
188+
189+func TestKittyKeysSeparatedByATab(t *testing.T) {
190+ p := put(t, t.TempDir(), "k.conf", "color1\t#fd4663\nbackground\t\t#070722\n")
191+ got, err := Load(p)
192+ if err != nil || got.Palette[1] != red || got.Background != bg {
193+ t.Fatalf("tab-separated keys: err=%v c1=%v bg=%v", err, got.Palette[1], got.Background)
161 }194 }
162 }195 }
163 196
164-func TestAutoWithNoTerminalIsAnError(t *testing.T) {197+func TestFilesAreDeduplicated(t *testing.T) {
165- if _, err := Auto(fakeEnv(t.TempDir(), nil)); err == nil {198+ d := t.TempDir()
166- t.Fatal("no terminal config must be an error the caller can report")199+ put(t, d, "t.conf", "color1 #fd4663\n")
200+ main := put(t, d, "k.conf", "include t.conf\ninclude t.conf\ninclude t.conf\n")
201+ got, err := Load(main)
202+ if err != nil || len(got.Files) != 2 {
203+ t.Fatalf("Files = %v (err %v), want the config and one include", got.Files, err)
167 }204 }
168 }205 }
169 206

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 5

Tags 4

↑↓ moveenter openesc close