diff --git a/cmd/lazysecrets/list.go b/cmd/lazysecrets/list.go index 5a787b9..6ed3421 100644 --- a/cmd/lazysecrets/list.go +++ b/cmd/lazysecrets/list.go @@ -19,6 +19,7 @@ import ( "git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/listing" "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/store" "git.kebcloud.no/keb/lazysecrets/internal/ui/icons" "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" ) @@ -39,13 +40,14 @@ import ( // follows as it loads. A list that can't be read is a line too, as it // fails: the vault or source, then ✗ and why in the theme's error colour. // They're in the output rather than on stderr, which would draw over fzf. -func listCmd(providers []provider.Provider, cachePath string, timeout func(provider.Kind) time.Duration, theme styles.Styles, withIcons bool, args []string, out io.Writer) error { +func listCmd(providers []provider.Provider, st *store.Store, cachePath string, timeout func(provider.Kind) time.Duration, theme styles.Styles, withIcons bool, args []string, out io.Writer) error { 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") 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)") + starIcon := fs.Bool("starred-icon", false, "put ★ before starred names, in the theme's favouriteColor (text)") kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)") showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")") if err := fs.Parse(args); err != nil { @@ -77,6 +79,9 @@ func listCmd(providers []provider.Provider, cachePath string, timeout func(provi write = func(e listing.Entry) error { // A tab or line break in a name would break the line up. name := nameOf(e, theme, iconSet, *color == "always") + if *starIcon { + 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) return err @@ -110,6 +115,10 @@ func listCmd(providers []provider.Provider, cachePath string, timeout func(provi } f = listing.Run(context.Background(), listing.Options{ Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout, + Mark: func(link, account string) (bool, bool) { + n := st.Node(link) + return n.StarredFor(account), !n.LastUsed.IsZero() + }, }, emit) if cachePath != "" && !*cached { byID := map[string]provider.Provider{} @@ -129,6 +138,18 @@ func listCmd(providers []provider.Provider, cachePath string, timeout func(provi 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 { diff --git a/cmd/lazysecrets/list_test.go b/cmd/lazysecrets/list_test.go index 6694b82..85aac78 100644 --- a/cmd/lazysecrets/list_test.go +++ b/cmd/lazysecrets/list_test.go @@ -39,3 +39,15 @@ 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) + } +} diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 223cd85..302faed 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -385,7 +385,7 @@ Flags: if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) { cachePath = filepath.Join(d, "cache.json") } - return listCmd(providers, cachePath, timeoutFor, theme, *icons, listArgs, os.Stdout) + return listCmd(providers, st, cachePath, timeoutFor, theme, *icons, listArgs, os.Stdout) } if shareArgs != nil { // The files from a repo, the key from whichever provider it's diff --git a/internal/listing/listing.go b/internal/listing/listing.go index 8d9fe7f..41fed54 100644 --- a/internal/listing/listing.go +++ b/internal/listing/listing.go @@ -39,6 +39,10 @@ type Entry struct { Account string `json:"account"` // AccountLabel names the az account, when there are several. AccountLabel string `json:"accountLabel,omitempty"` + // Starred is starred for this account; Recent is used lately (Recent, + // R, in the TUI). + Starred bool `json:"starred"` + Recent bool `json:"recent"` // Error says why a source's vaults or a vault's secrets couldn't be // listed. The entry is then that source or vault, again, or with an // account's sources unreadable, the account without a link. @@ -56,7 +60,10 @@ type Options struct { // Kinds are the kinds of entry to list (Kinds' names); none is all. // Lists only needed for other kinds aren't loaded: sources and vaults // alone don't load any secrets. Lists that fail are always listed. - Kinds []string + Kinds []string + // Mark tells whether the entry with link is starred for account, and + // used lately; nil marks nothing. + Mark func(link, account string) (starred, recent bool) Timeout func(provider.Kind) time.Duration Now func() time.Time } @@ -217,6 +224,9 @@ func (l *lister) put(p provider.Provider, e Entry) { return } e.Account = p.ID() + if l.o.Mark != nil && e.Link != "" { + e.Starred, e.Recent = l.o.Mark(e.Link, e.Account) + } id := p.ID() + "\x00" + e.Link + "\x00" + e.Error if l.stopped != nil || l.seen[id] { return diff --git a/internal/listing/listing_test.go b/internal/listing/listing_test.go index 9908d24..8945d45 100644 --- a/internal/listing/listing_test.go +++ b/internal/listing/listing_test.go @@ -223,3 +223,31 @@ func TestErrorsAreOneLine(t *testing.T) { } } } + +func TestListMarks(t *testing.T) { + t.Parallel() + az := fake.Azure(0) + var starredLink string + got, _, _ := run(t, Options{Providers: []provider.Provider{az}, MaxAge: time.Minute, Kinds: []string{"secret"}, + Mark: func(link, account string) (bool, bool) { + if starredLink == "" && strings.HasSuffix(link, "/db-password") { + starredLink = link + } + return link == starredLink && account == az.ID(), strings.HasSuffix(link, "/api-key") + }}) + var starred, recent int + for _, e := range got { + if e.Starred { + starred++ + if e.Link != starredLink { + t.Errorf("%s starred", e.Link) + } + } + if e.Recent { + recent++ + } + } + if starred != 1 || recent == 0 { + t.Errorf("%d starred, %d recent", starred, recent) + } +}