From ed8ac04b35a897a92ef21550c1c2c7eb01459723 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 22:42:15 +0200 Subject: [PATCH] fix: unambiguous repo key names, clearer config and key names, and a newer lazysecrets's files no longer stop an older one --- cmd/lazysecrets/main.go | 16 ++++++- cmd/lazysecrets/update.go | 2 +- docs/config.md | 9 +++- docs/repos.md | 3 +- internal/config/config.go | 18 ++++++-- internal/config/config_test.go | 9 ++-- internal/provider/repo/repo.go | 66 ++++++++++++++++++++++------- internal/provider/repo/repo_test.go | 21 +++++++++ internal/ref/ref.go | 2 +- internal/store/store.go | 19 ++++++--- internal/store/store_test.go | 15 +++++-- internal/ui/app/app.go | 6 +++ internal/ui/app/update_test.go | 8 ++++ internal/ui/keys/keys.go | 12 +++--- internal/ui/styles/theme.go | 25 +++++++---- internal/ui/styles/theme_test.go | 16 +++++-- 16 files changed, 189 insertions(+), 58 deletions(-) diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index c1c11da..32a63f4 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -5,6 +5,7 @@ package main import ( "cmp" "context" + "errors" "flag" "fmt" "io" @@ -194,6 +195,11 @@ Flags: if err != nil { return err } + // Warnings show when the window opens. + var warnings []string + if w := styles.Unknown(cfg.Theme); w != "" { + warnings = append(warnings, w) + } keymap := keys.Default() if err := keymap.Apply(cfg.Keybinding); err != nil { return err @@ -208,6 +214,9 @@ Flags: // Precedence: flag > environment > config file > default. set := map[string]bool{} flag.Visit(func(f *flag.Flag) { set[f.Name] = true }) + if *timeout <= 0 { + return fmt.Errorf("--timeout: %s is not positive", *timeout) + } // Each provider's own timeout, unless --timeout sets them all. timeouts := map[provider.Kind]time.Duration{} if !set["timeout"] { @@ -265,7 +274,9 @@ Flags: statePath = filepath.Join(stateDir, "state.json") } st, err := store.Load(statePath) - if err != nil { + if errors.Is(err, store.ErrNewer) { + warnings = append(warnings, err.Error()+": stars, notes and the layout won't be saved; update lazysecrets") + } else if err != nil { return err } @@ -406,13 +417,14 @@ Flags: ExpiryWarning: time.Duration(expiryDays) * 24 * time.Hour, HideOnBlur: config.On(cfg.Values.HideOnBlur), HandlerSetup: setup, - Updater: updater(cfg.Updates.UpdateSources), + Updater: updater(cfg.Updates.Sources), UpdateCheck: config.On(cfg.Updates.AutoCheck), UpdateEvery: updateEvery, Keys: &keymap, Commands: cfg.CustomCommands, Version: versionString(), Store: st, + Warning: strings.Join(warnings, " · "), CacheFile: cacheFile, ConfigFiles: configFiles(*configPath), StatePath: statePath, diff --git a/cmd/lazysecrets/update.go b/cmd/lazysecrets/update.go index 82db68d..38d85c9 100644 --- a/cmd/lazysecrets/update.go +++ b/cmd/lazysecrets/update.go @@ -43,7 +43,7 @@ func updateCmd(configPath string, args []string) error { current := versionString() ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) defer cancel() - c := &update.Client{Servers: cfg.Updates.UpdateSources} + c := &update.Client{Servers: cfg.Updates.Sources} r, err := c.Latest(ctx) if err != nil { return fmt.Errorf("looking for a newer lazysecrets: %w", err) diff --git a/docs/config.md b/docs/config.md index 4bc23dd..40e4e3b 100644 --- a/docs/config.md +++ b/docs/config.md @@ -23,7 +23,7 @@ storage: updates: autoCheck: true # look for a newer release at startup (U and lazysecrets update work either way) autoCheckInterval: 24h # at most this often - updateSources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server) + sources: # where updates come from, tried in order (GitHub, or a Gitea or Forgejo server) - https://github.com/karleinarb/lazysecrets - https://git.kebcloud.no/keb/lazysecrets providers: @@ -91,6 +91,9 @@ theme: repoColor: ["#c099ff"] ``` +A role lazysecrets doesn't know, a typo or one from a newer version, is +skipped with a warning. + Icons need a [Nerd Font](https://www.nerdfonts.com) (v3+). If they render as boxes, turn them off with `ui.icons: false`. @@ -116,6 +119,8 @@ starred the item under (a vault two of your accounts can see can be starred for one); a note describes the item, so it shows under every account. It's JSON so an editor plugin can read it with a built-in parser; several lazysecrets windows can save to it without losing each other's changes. +One written by a newer lazysecrets is read but not saved to, with a +warning, until you update. Revealing, copying, opening in the browser, sharing and jumping to an item count as using it: its record keeps when it was last used and how often, @@ -149,7 +154,7 @@ At startup, in the background, lazysecrets asks for the latest release until then it reuses the answer from the state file. Releases are published on GitHub and on git.kebcloud.no, and it asks them in that order, the next when one doesn't answer within 5 seconds; -`updates.updateSources` lists others, such as a mirror of your own. Offline, or with +`updates.sources` lists others, such as a mirror of your own. Offline, or with every server down, it says nothing. A newer release shows in the footer, `v0.1.1 ↑ v0.2.0`; `U` (or `lazysecrets update --check`) shows what changed since your version, across the last 10 releases. diff --git a/docs/repos.md b/docs/repos.md index 96260e4..47da3a9 100644 --- a/docs/repos.md +++ b/docs/repos.md @@ -3,7 +3,8 @@ lazysecrets shows git repositories under **Repos**: their [SOPS](https://github.com/getsops/sops)-encrypted files (YAML, JSON, `.env`, INI) and plain env files as vaults, and the keys in them -(`db.password`, `list[0]`) as secrets. +(`db.password`, `list[0]`) as secrets. A dot or bracket inside a key is +escaped, `app\.name`, so it doesn't read as nesting. ## Which repos diff --git a/internal/config/config.go b/internal/config/config.go index c82970b..d2844c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -82,10 +82,10 @@ type Updates struct { AutoCheck *bool `yaml:"autoCheck"` // AutoCheckInterval defaults to 24h. AutoCheckInterval *time.Duration `yaml:"autoCheckInterval"` - // UpdateSources are the repositories updates come from, tried in + // Sources are the repositories updates come from, tried in // order: on GitHub, or a Gitea or Forgejo server. Defaults to GitHub, // then git.kebcloud.no (update.DefaultServers). - UpdateSources []string `yaml:"updateSources"` + Sources []string `yaml:"sources"` } type LinkHandler struct { @@ -268,9 +268,9 @@ func (c Config) validate(path string) error { if d := c.Updates.AutoCheckInterval; d != nil && *d <= 0 { return fmt.Errorf("%s: updates.autoCheckInterval: %s is not positive (autoCheck: false turns it off)", path, *d) } - for i, r := range c.Updates.UpdateSources { + for i, r := range c.Updates.Sources { if _, err := update.API(r); err != nil || !strings.HasPrefix(r, "https://") { - return fmt.Errorf("%s: updates.updateSources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo) + return fmt.Errorf("%s: updates.sources[%d]: %q is not an https:// repository address, like %s", path, i, r, update.GitHubRepo) } } if k := c.Sharing.Key; k != "" { @@ -284,6 +284,16 @@ func (c Config) validate(path string) error { if n := c.Sharing.KeyRules.MinClasses; n < 0 || n > 4 { return fmt.Errorf("%s: sharing.keyRules.minClasses: %d is not between 1 and 4", path, n) } + for name, d := range map[string]time.Duration{ + "providers.timeout": c.Providers.Timeout, + "providers.azure.timeout": c.Providers.Azure.Timeout, + "providers.databricks.timeout": c.Providers.Databricks.Timeout, + "providers.repos.timeout": c.Providers.Repos.Timeout, + } { + if d < 0 { + return fmt.Errorf("%s: %s: %s is negative", path, name, d) + } + } for i, v := range c.Providers.Azure.Vaults { if !strings.HasPrefix(v, "https://") { return fmt.Errorf("%s: providers.azure.vaults[%d]: %q is not an https:// vault URL", path, i, v) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 28a3fe8..7cd0c4d 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -75,9 +75,10 @@ func TestLoadRejectsMistakes(t *testing.T) { "old layout": "azure:\n vaults: [https://kv.vault.azure.net/]\n", "flat keys": "icons: false\n", "duration": "providers:\n timeout: soon\n", + "negative": "providers:\n repos:\n timeout: -1s\n", "view": "ui:\n sourcesView: tree\n", - "sources": "updates:\n updateSources: [git.example.com/team/lazysecrets]\n", - "http": "updates:\n updateSources: [http://git.example.com/team/lazysecrets]\n", + "sources": "updates:\n sources: [git.example.com/team/lazysecrets]\n", + "http": "updates:\n sources: [http://git.example.com/team/lazysecrets]\n", "interval": "updates:\n autoCheckInterval: 0s\n", "share key": "sharing:\n key: https://kv.vault.azure.net/\n", "workspace": "sharing:\n key: \"{{secrets/team/env-share}}\"\n", @@ -131,8 +132,8 @@ theme: } func TestUpdates(t *testing.T) { - c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n updateSources: [https://git.example.com/team/lazysecrets]\n")) - if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.UpdateSources) != 1 { + c, err := Load(write(t, "updates:\n autoCheck: false\n autoCheckInterval: 168h\n sources: [https://git.example.com/team/lazysecrets]\n")) + if err != nil || On(c.Updates.AutoCheck) || *c.Updates.AutoCheckInterval != 7*24*time.Hour || len(c.Updates.Sources) != 1 { t.Errorf("updates = %+v, %v", c.Updates, err) } } diff --git a/internal/provider/repo/repo.go b/internal/provider/repo/repo.go index af139f7..3f61d07 100644 --- a/internal/provider/repo/repo.go +++ b/internal/provider/repo/repo.go @@ -403,10 +403,11 @@ func (p *Provider) Versions(context.Context, provider.Object) ([]provider.Object func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) { file, extract, ok := strings.Cut(o.ID, "\x00") if !ok { // made from a link: the repo's root, the file's path, the key - file, extract = o.Container.ID, extractFor(o.Name) + file = o.Container.ID if file == "" { file = filepath.Join(o.Container.Source.ID, filepath.FromSlash(o.Container.Name)) } + extract = extractFor(file, o.Name) } if _, isSops := readMeta(file); !isSops { return plainValue(file, o.Name) @@ -1012,7 +1013,7 @@ func formatOf(file string) string { } type key struct { - name string // db.password, list[0] + name string // db.password, list[0], with . [ ] \ in keys escaped extract string // sops --extract: ["db"]["password"] encrypted bool line int // 1-based @@ -1057,7 +1058,7 @@ func parseKeys(file string, b []byte) ([]key, error) { continue } k, v = strings.TrimSpace(k), strings.TrimSpace(v) - out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1}) + out = append(out, key{name: escape(section) + "." + escape(k), extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1}) } } return out, nil @@ -1074,9 +1075,9 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) { if top && k == "sops" { continue } - child := k + child := escape(k) if name != "" { - child = name + "." + k + child = name + "." + child } walk(n.Content[i+1], child, extract+quote(k), false, out) } @@ -1098,23 +1099,56 @@ func quote(k string) string { return `["` + strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(k) + `"]` } +// escape keeps a key's dots and brackets from reading as nesting in a +// name: app\.name is one key, app.name two. +func escape(k string) string { + return strings.NewReplacer(`\`, `\\`, `.`, `\.`, `[`, `\[`, `]`, `\]`).Replace(k) +} + // extractFor rebuilds the extract path from a key name, for objects made -// from a link (db.password, list[0]). Names with dots in keys can't be -// told apart from nesting: those come from the listing instead. -func extractFor(name string) string { - var b strings.Builder - for _, part := range strings.Split(name, ".") { - base, idx, _ := strings.Cut(part, "[") - if base != "" { - b.WriteString(quote(base)) - } - if idx != "" { - b.WriteString("[" + idx) +// from a link (db.password, list[0], app\.name). A dotenv file is flat: +// its names are the keys as they are. +func extractFor(file, name string) string { + if formatOf(file) == "dotenv" { + return quote(name) + } + var b, part strings.Builder + flush := func() { + if part.Len() > 0 { + b.WriteString(quote(part.String())) + part.Reset() } } + for i := 0; i < len(name); i++ { + switch c := name[i]; c { + case '\\': + if i+1 < len(name) { + i++ + } + part.WriteByte(name[i]) + case '.': + flush() + case '[': + end := strings.IndexByte(name[i:], ']') + if end < 0 || !digits(name[i+1:i+end]) { + part.WriteByte(c) + continue + } + flush() + b.WriteString(name[i : i+end+1]) + i += end + default: + part.WriteByte(c) + } + } + flush() return b.String() } +func digits(s string) bool { + return s != "" && strings.Trim(s, "0123456789") == "" +} + func absPath(p string) string { if a, err := filepath.Abs(p); err == nil { return a diff --git a/internal/provider/repo/repo_test.go b/internal/provider/repo/repo_test.go index e22001a..2251c0b 100644 --- a/internal/provider/repo/repo_test.go +++ b/internal/provider/repo/repo_test.go @@ -545,3 +545,24 @@ func TestFileDetails(t *testing.T) { t.Errorf("untracked env: %+v", d) } } + +func TestKeyNamesEscaped(t *testing.T) { + keys, err := parseKeys("s.yaml", []byte("app.name: x\ndb:\n password: y\nlist: [a, b]\nodd[1]: z\n")) + if err != nil { + t.Fatal(err) + } + var got []string + for _, k := range keys { + got = append(got, k.name+" "+k.extract) + if e := extractFor("s.yaml", k.name); e != k.extract { + t.Errorf("extractFor(%q) = %s, want %s", k.name, e, k.extract) + } + } + want := `app\.name ["app.name"]|db.password ["db"]["password"]|list[0] ["list"][0]|list[1] ["list"][1]|odd\[1\] ["odd[1]"]` + if strings.Join(got, "|") != want { + t.Errorf("keys = %s\nwant %s", strings.Join(got, "|"), want) + } + if e := extractFor(".env", "spring.url"); e != `["spring.url"]` { + t.Errorf("dotenv: %s", e) + } +} diff --git a/internal/ref/ref.go b/internal/ref/ref.go index 4cf181e..df5ff2b 100644 --- a/internal/ref/ref.go +++ b/internal/ref/ref.go @@ -310,7 +310,7 @@ func parseLink(s string) (Ref, error) { } default: - return Ref{}, fmt.Errorf("unknown provider %q in link", u.Host) + return Ref{}, fmt.Errorf("unknown provider %q in link: a newer lazysecrets may know it (lazysecrets update)", u.Host) } return r, nil } diff --git a/internal/store/store.go b/internal/store/store.go index faf9dd0..04f708a 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -145,16 +145,23 @@ type Store struct { data file } +// ErrNewer is a state file written by a newer lazysecrets. +var ErrNewer = errors.New("written by a newer lazysecrets") + // Load reads path; a missing file is an empty store. path "" gives a store -// that never saves, for tests and --demo. +// that never saves, for tests and --demo. A file from a newer lazysecrets +// is read but never saved, so nothing it keeps is lost: Load returns the +// store with an ErrNewer error. func Load(path string) (*Store, error) { s := &Store{path: path} d, err := read(path) - if err != nil { + if errors.Is(err, ErrNewer) { + s.path = "" + } else if err != nil { return nil, err } s.data = d - return s, nil + return s, err } func read(path string) (file, error) { @@ -172,12 +179,12 @@ func read(path string) (file, error) { if err := json.Unmarshal(b, &d); err != nil { return d, fmt.Errorf("%s: %w", path, err) } - if d.Version > version { - return d, fmt.Errorf("%s: written by a newer lazysecrets (version %d)", path, d.Version) - } if d.Nodes == nil { d.Nodes = map[string]Node{} } + if d.Version > version { + return d, fmt.Errorf("%s: %w (version %d)", path, ErrNewer, d.Version) + } return d, nil } diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 37d8caf..0be187c 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -1,6 +1,7 @@ package store import ( + "errors" "fmt" "os" "path/filepath" @@ -65,11 +66,17 @@ func TestTwoWindowsDontLoseEachOthersChanges(t *testing.T) { } } -func TestNewerVersionRefused(t *testing.T) { +func TestNewerVersionReadOnly(t *testing.T) { path := filepath.Join(t.TempDir(), "state.json") - _ = os.WriteFile(path, []byte(`{"version": 99, "nodes": {}}`), 0o600) - if _, err := Load(path); err == nil { - t.Error("a newer file was accepted") + newer := `{"version": 99, "nodes": {"one": {"note": "hi"}}}` + _ = os.WriteFile(path, []byte(newer), 0o600) + s, err := Load(path) + if !errors.Is(err, ErrNewer) || s == nil || s.Node("one").Note != "hi" { + t.Fatalf("Load = %v, %v", s, err) + } + _ = s.SetFlag("x", true) + if b, _ := os.ReadFile(path); string(b) != newer { + t.Errorf("the newer file was overwritten: %s", b) } } diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index d764300..37fc60e 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -79,6 +79,9 @@ type Options struct { // and keeps its state, for the status pane. ConfigFiles []string StatePath string + // Warning shows when the window opens: what lazysecrets skipped + // instead of failing to start. + Warning string // Changelog is CHANGELOG.md, for the status pane. Changelog string // CacheFile keeps what was loaded between runs; "" keeps it for this @@ -474,6 +477,9 @@ func New(providers []provider.Provider, opts Options) Model { m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{} m.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog) + if opts.Warning != "" { + m.flash = warning(opts.Warning) + } if m.cachePath != "" { m.loadCacheFile() if len(m.sourceResults) > 0 { diff --git a/internal/ui/app/update_test.go b/internal/ui/app/update_test.go index 8bda118..0ddc409 100644 --- a/internal/ui/app/update_test.go +++ b/internal/ui/app/update_test.go @@ -122,3 +122,11 @@ func TestUpdateAsked(t *testing.T) { t.Errorf("U on a dev build:\n%s", dev.screen()) } } + +func TestStartupWarning(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{Warning: "theme: skipped unknown activeBorder"}) + if !strings.Contains(d.screen(), "theme: skipped unknown activeBorder") { + t.Errorf("no warning:\n%s", d.screen()) + } +} diff --git a/internal/ui/keys/keys.go b/internal/ui/keys/keys.go index fc0238a..be1adad 100644 --- a/internal/ui/keys/keys.go +++ b/internal/ui/keys/keys.go @@ -95,7 +95,7 @@ func (m *Map) Actions() []Action { a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."), a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."), a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."), - a(Lists, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."), + a(Lists, "open", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."), a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."), a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."), @@ -103,7 +103,7 @@ func (m *Map) Actions() []Action { a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."), a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."), a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."), - a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."), + a(Lists, "goTo", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one. Again in Go to: only the source or group you're in, and back to all. On the left, the scope: All, an account (or Databricks, or Repos), or one source (○ ◐ ● for how much of it is loaded)."), a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."), a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."), a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."), @@ -116,7 +116,7 @@ func (m *Map) Actions() []Action { a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."), a(Lists, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line; on the status pane, your config (made if it isn't there yet). SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."), - a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."), + a(Lists, "copyLink", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."), a(Lists, "shareMenu", &m.ShareMenu, "share", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, the web page, or env files as an encrypted link."), a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."), @@ -184,13 +184,13 @@ var defaults = map[string][]string{ "lists.top": {"g", "home"}, "lists.bottom": {"G", "end"}, "lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"}, "lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"}, - "lists.enter": {"enter"}, "lists.back": {"esc", "backspace"}, + "lists.open": {"enter"}, "lists.back": {"esc", "backspace"}, "lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, - "lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"}, + "lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"ctrl+s"}, "lists.note": {"n"}, "lists.commands": {"x"}, "lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"}, "lists.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"}, - "lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"}, + "lists.copyLink": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"}, "lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"}, "lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"}, "lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="}, diff --git a/internal/ui/styles/theme.go b/internal/ui/styles/theme.go index 6142d1f..6576e32 100644 --- a/internal/ui/styles/theme.go +++ b/internal/ui/styles/theme.go @@ -45,7 +45,9 @@ var Roles = map[string]string{ } // FromTheme returns the default styles with the theme's roles applied, and -// the default text colour if the theme sets one. +// the default text colour if the theme sets one. Roles it doesn't know are +// skipped (see Unknown): a theme file made for a newer lazysecrets still +// works. func FromTheme(theme map[string][]string) (Styles, color.Color, error) { st := Default() var fg color.Color @@ -56,7 +58,7 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) { sort.Strings(roles) for _, role := range roles { if _, ok := Roles[role]; !ok { - return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", ")) + continue } v, err := parse(theme[role]) if err != nil { @@ -107,13 +109,20 @@ func FromTheme(theme map[string][]string) (Styles, color.Color, error) { return st, fg, nil } -func sortedRoles() []string { - var out []string - for r := range Roles { - out = append(out, r) +// Unknown says which of the theme's roles aren't known, for a warning, or +// is empty. +func Unknown(theme map[string][]string) string { + var unknown []string + for r := range theme { + if _, ok := Roles[r]; !ok { + unknown = append(unknown, r) + } } - slices.Sort(out) - return out + if len(unknown) == 0 { + return "" + } + slices.Sort(unknown) + return "theme: skipped unknown " + strings.Join(unknown, ", ") + " (a typo, or from a newer lazysecrets)" } // value is a parsed role: a colour (nil for default) and attributes. diff --git a/internal/ui/styles/theme_test.go b/internal/ui/styles/theme_test.go index 4ae4d4d..791d474 100644 --- a/internal/ui/styles/theme_test.go +++ b/internal/ui/styles/theme_test.go @@ -44,19 +44,29 @@ func TestFromTheme(t *testing.T) { func TestFromThemeRejectsMistakes(t *testing.T) { for name, theme := range map[string]map[string][]string{ - "role": {"activeBorder": {"red"}}, "colour": {"errorColor": {"redish"}}, "hex": {"errorColor": {"#fff"}}, "palette": {"errorColor": {"300"}}, } { if _, _, err := FromTheme(theme); err == nil { t.Errorf("%s: no error", name) - } else if name == "role" && !strings.Contains(err.Error(), "activeBorderColor") { - t.Errorf("unknown role error should list the known ones: %v", err) } } } +func TestUnknownRolesSkipped(t *testing.T) { + theme := map[string][]string{"activeBorder": {"red"}, "errorColor": {"red"}} + if _, _, err := FromTheme(theme); err != nil { + t.Errorf("unknown role failed the theme: %v", err) + } + if w := Unknown(theme); !strings.Contains(w, "unknown activeBorder ") || strings.Contains(w, "errorColor") { + t.Errorf("warning = %q", w) + } + if w := Unknown(map[string][]string{"errorColor": {"red"}}); w != "" { + t.Errorf("warning for known roles: %q", w) + } +} + func TestEmptyThemeIsDefault(t *testing.T) { st, fg, err := FromTheme(nil) if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack { -- 2.54.0