From 46af233bd30e99bd9dff49235e261d7b9e843725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Wed, 14 Oct 2026 15:04:33 +0200 Subject: [PATCH] feat: lazysecrets list puts the star and icon in a field of their own, so ^ searches match names --- cmd/lazysecrets/list.go | 92 ++++++++++++++++++++---------------- cmd/lazysecrets/list_test.go | 45 ++++++++++-------- 2 files changed, 76 insertions(+), 61 deletions(-) diff --git a/cmd/lazysecrets/list.go b/cmd/lazysecrets/list.go index 1354db0..4b1b51a 100644 --- a/cmd/lazysecrets/list.go +++ b/cmd/lazysecrets/list.go @@ -27,12 +27,13 @@ import ( // listCmd prints every source, vault and secret lazysecrets can find, one // per line, for fzf, editor pickers and scripts: // -// lazysecrets list | fzf --ansi --with-nth='2..-3' --accept-nth=-1 +// lazysecrets list | fzf --ansi --delimiter='\t' --with-nth='2..-3' --nth='2..' --accept-nth=-1 // -// Each line is the kind, the name, why it couldn't be read (empty unless -// it failed), the account and the link, tab-separated: kind, account and -// link are one word each, so even fields split on spaces keep them first -// and last two. The name is coloured from the theme, +// Each line is the kind, a mark (★ and the provider's icon), the name, +// why it couldn't be read (empty unless it failed), the account and the +// link, tab-separated. The mark has a field of its own so a search can +// leave it out (fzf --nth), and ^prod finds names starting with prod. The +// name is coloured from the theme, // with the provider's icon (as in the TUI: --icons, LAZYSECRETS_ICONS, // ui.icons); the link never is. // @@ -44,7 +45,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t fs := flag.NewFlagSet("list", flag.ContinueOnError) cached := fs.Bool("cached", false, "only what's cached, and repos: load nothing from Azure or Databricks") maxAge := fs.Duration("max-age", 2*time.Minute, "load cached lists older than this again") - format := fs.String("format", "text", "text: kind, name, error, account and link, tab-separated; json: an object per line") + format := fs.String("format", "text", "text: kind, mark, name, error, account and link, tab-separated; json: an object per line") color := fs.String("color", "always", "colour the names (text): always or never; fzf needs --ansi") iconsOn := fs.Bool("icons", withIcons, "put the provider's Nerd Font icon before names (text)") stars := fs.Bool("stars", true, "put ★ before starred names, in the theme's favouriteColor (text)") @@ -77,13 +78,12 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t switch *format { case "text": write = func(e listing.Entry) error { + colored := *color == "always" + mark := markOf(e, theme, iconSet, *stars, colored) + name := nameOf(e, theme, colored) + why := errorOf(e, theme, colored) // A tab or line break in a name would break the line up. - name := nameOf(e, theme, iconSet, *color == "always") - if *stars { - name = starOf(e, theme, *color == "always") + name - } - why := errorOf(e, theme, *color == "always") - _, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\n", e.Kind, oneLine.Replace(name), oneLine.Replace(why), e.Account, e.Link) + _, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\t%s\n", e.Kind, mark, oneLine.Replace(name), oneLine.Replace(why), e.Account, e.Link) return err } case "json": @@ -138,18 +138,6 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t var oneLine = strings.NewReplacer("\t", " ", "\r\n", " ", "\n", " ", "\r", " ") -// starOf is ★ for a starred entry, as the TUI marks them, and spaces -// the same width for the rest, so names line up. -func starOf(e listing.Entry, theme styles.Styles, color bool) string { - switch { - case !e.Starred: - return " " - case color: - return theme.Favourite.Render("★") + " " - } - return "★ " -} - // errorOf is why a list couldn't be read, or "": with colour, a red ✗ // before it, as the TUI marks lists it can't read. func errorOf(e listing.Entry, theme styles.Styles, color bool) string { @@ -159,22 +147,10 @@ func errorOf(e listing.Entry, theme styles.Styles, color bool) string { return theme.Error.Render("✗ " + e.Error) } -// nameOf is an entry's name for the text format: the provider's icon, -// the path with the item's own name standing out, and the account. -func nameOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, color bool) string { - render := func(st lipgloss.Style, s string) string { - if !color || s == "" { - return s - } - return st.Render(s) - } - kind := provider.Kind("") - for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} { - if strings.EqualFold(string(k), e.Provider) { - kind = k - } - } - icon := iconSet.Prefix(kind) +// nameOf is an entry's name for the text format: the path with the +// item's own name standing out, and the account. +func nameOf(e listing.Entry, theme styles.Styles, color bool) string { + render := renderer(color) var name string if n := len(e.Path); n > 0 { if n > 1 { @@ -185,5 +161,39 @@ func nameOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, color bool) if e.AccountLabel != "" { name += render(theme.Muted, " · "+e.AccountLabel) } - return render(theme.Kind(kind), strings.TrimSuffix(icon, " ")) + strings.Repeat(" ", min(len(icon), 1)) + name + return name +} + +// markOf is what goes before the name, in a field of its own so a search +// leaves it out: ★ for a starred entry (as the TUI marks them; a space +// as wide for the rest, so names line up), and the provider's icon. +func markOf(e listing.Entry, theme styles.Styles, iconSet icons.Set, stars, color bool) string { + render := renderer(color) + var parts []string + if stars { + star := " " + if e.Starred { + star = render(theme.Favourite, "★") + } + parts = append(parts, star) + } + kind := provider.Kind("") + for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} { + if strings.EqualFold(string(k), e.Provider) { + kind = k + } + } + if icon := strings.TrimSpace(iconSet.Prefix(kind)); icon != "" { + parts = append(parts, render(theme.Kind(kind), icon)) + } + return strings.Join(parts, " ") +} + +func renderer(color bool) func(lipgloss.Style, string) string { + return func(st lipgloss.Style, s string) string { + if !color || s == "" { + return s + } + return st.Render(s) + } } diff --git a/cmd/lazysecrets/list_test.go b/cmd/lazysecrets/list_test.go index 85aac78..09a8818 100644 --- a/cmd/lazysecrets/list_test.go +++ b/cmd/lazysecrets/list_test.go @@ -11,22 +11,39 @@ import ( func TestListNames(t *testing.T) { e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-app-prod", "db-password"}, AccountLabel: "alice"} - if got := nameOf(e, styles.Default(), icons.None, false); got != "platform-prod / kv-app-prod / db-password · alice" { + if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-app-prod / db-password · alice" { t.Errorf("plain: %q", got) } - got := nameOf(e, styles.Default(), icons.NerdFont, true) - if !strings.HasPrefix(got, "\x1b[") || !strings.Contains(got, icons.NerdFont.Azure) { - t.Errorf("no colour or icon: %q", got) - } // The item's own name isn't muted with its path. - if !strings.Contains(got, "\x1b[mdb-password") { + if got := nameOf(e, styles.Default(), true); !strings.Contains(got, "\x1b[mdb-password") { t.Errorf("name not set apart: %q", got) } } -func TestListErrorNames(t *testing.T) { +func TestListMarks(t *testing.T) { + az := icons.NerdFont.Azure + for _, c := range []struct { + e listing.Entry + stars, color bool + set icons.Set + want string + }{ + {listing.Entry{Provider: "azure", Starred: true}, true, false, icons.NerdFont, "★ " + az}, + {listing.Entry{Provider: "azure"}, true, false, icons.NerdFont, " " + az}, // as wide as ★ + {listing.Entry{Provider: "azure", Starred: true}, false, false, icons.NerdFont, az}, + {listing.Entry{Provider: "azure", Starred: true}, true, false, icons.None, "★"}, + {listing.Entry{Provider: "azure", Starred: true}, false, false, icons.None, ""}, + {listing.Entry{Provider: "azure", Starred: true}, true, true, icons.None, styles.Default().Favourite.Render("★")}, + } { + if got := markOf(c.e, styles.Default(), c.set, c.stars, c.color); got != c.want { + t.Errorf("markOf(starred %v, stars %v, colour %v) = %q, want %q", c.e.Starred, c.stars, c.color, got, c.want) + } + } +} + +func TestListErrors(t *testing.T) { e := listing.Entry{Provider: "azure", Path: []string{"platform-prod", "kv-restricted"}, Error: "403 Forbidden"} - if got := nameOf(e, styles.Default(), icons.NerdFont, false); got != icons.NerdFont.Azure+" platform-prod / kv-restricted" { + if got := nameOf(e, styles.Default(), false); got != "platform-prod / kv-restricted" { t.Errorf("the name has more: %q", got) } if got := errorOf(e, styles.Default(), false); got != "403 Forbidden" { @@ -39,15 +56,3 @@ func TestListErrorNames(t *testing.T) { t.Errorf("no error: %q", got) } } - -func TestListStars(t *testing.T) { - if got := starOf(listing.Entry{Starred: true}, styles.Default(), false); got != "★ " { - t.Errorf("starred: %q", got) - } - if got := starOf(listing.Entry{}, styles.Default(), true); got != " " { - t.Errorf("not starred: %q, want spaces as wide as ★", got) - } - if got := starOf(listing.Entry{Starred: true}, styles.Default(), true); got != styles.Default().Favourite.Render("★")+" " { - t.Errorf("not in the favourite colour: %q", got) - } -}