diff --git a/cmd/lazysecrets/list.go b/cmd/lazysecrets/list.go index d85933c..02f1b7e 100644 --- a/cmd/lazysecrets/list.go +++ b/cmd/lazysecrets/list.go @@ -41,7 +41,7 @@ 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, st *store.Store, 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, withRecent 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") @@ -49,6 +49,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t 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)") + recent := fs.Bool("recent", withRecent, "put ◷ before names used lately, in mutedColor (text; default ui.recentIcon)") 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 { @@ -79,7 +80,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t case "text": write = func(e listing.Entry) error { colored := *color == "always" - mark := markOf(e, theme, iconSet, *stars, colored) + mark := markOf(e, theme, iconSet, *stars, *recent, colored) name := nameOf(e, theme, colored) why := errorOf(e, theme, colored) // A tab or line break in a name would break the line up. @@ -165,17 +166,23 @@ func nameOf(e listing.Entry, theme styles.Styles, color bool) string { } // 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 { +// leaves it out: ★ for a starred entry and ◷ for one used lately (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, recent, color bool) string { render := renderer(color) var parts []string - if stars { - star := " " - if e.Starred { - star = render(theme.Favourite, "★") + mark := func(on bool, st lipgloss.Style, s string) { + if !on { + s = " " } - parts = append(parts, star) + parts = append(parts, render(st, s)) + } + if stars { + mark(e.Starred, theme.Favourite, "★") + } + if recent { + mark(e.Recent, theme.Muted, "◷") } kind := provider.Kind("") for _, k := range []provider.Kind{provider.KindAzure, provider.KindDatabricks, provider.KindRepo} { diff --git a/cmd/lazysecrets/list_test.go b/cmd/lazysecrets/list_test.go index 09a8818..bed768a 100644 --- a/cmd/lazysecrets/list_test.go +++ b/cmd/lazysecrets/list_test.go @@ -35,12 +35,28 @@ func TestListMarks(t *testing.T) { {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 { + if got := markOf(c.e, styles.Default(), c.set, c.stars, false, 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 TestListRecentMark(t *testing.T) { + az := icons.NerdFont.Azure + for _, c := range []struct { + e listing.Entry + want string + }{ + {listing.Entry{Provider: "azure", Starred: true, Recent: true}, "★ ◷ " + az}, + {listing.Entry{Provider: "azure", Recent: true}, " ◷ " + az}, + {listing.Entry{Provider: "azure", Starred: true}, "★ " + az}, // as wide as ◷ + } { + if got := markOf(c.e, styles.Default(), icons.NerdFont, true, true, false); got != c.want { + t.Errorf("markOf(starred %v, recent %v) = %q, want %q", c.e.Starred, c.e.Recent, 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(), false); got != "platform-prod / kv-restricted" { diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 5c01981..a4c086f 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -298,6 +298,8 @@ Flags: } } + recentIcon := cfg.UI.RecentIcon != nil && *cfg.UI.RecentIcon + // Stars, notes and the layout. Demo mode keeps them in memory, so // sample vaults never end up in the real file. stateDir, err := config.StateDir() @@ -387,7 +389,7 @@ Flags: if d, err := config.CacheDir(); err == nil && !*demo && config.On(cfg.Storage.Cache) { cachePath = filepath.Join(d, "cache.json") } - return listCmd(providers, st, cachePath, timeoutFor, theme, *icons, listArgs, os.Stdout) + return listCmd(providers, st, cachePath, timeoutFor, theme, *icons, recentIcon, listArgs, os.Stdout) } if shareArgs != nil { // The files from a repo, the key from whichever provider it's @@ -478,6 +480,7 @@ Flags: ShareRules: shareRules(cfg), Open: open, OpenAction: openAction, + RecentIcon: recentIcon, Styles: &theme, DefaultFg: defaultFg, }) diff --git a/docs/config.md b/docs/config.md index 70fd2f2..008a8c2 100644 --- a/docs/config.md +++ b/docs/config.md @@ -9,6 +9,7 @@ it isn't there yet); changes apply when lazysecrets starts again. ```yaml ui: icons: true # Nerd Font icons (v3+); also --icons=false or LAZYSECRETS_ICONS=0 + recentIcon: false # mark items used lately (Recent, R) with ◷, here and in lazysecrets list sourcesView: grouped # or flat; ` toggles it collapseGroups: true # start with the Sources pane's groups folded; enter or space unfolds one loadDelay: 150ms # how long the cursor rests on a row before it loads; 0s loads at once diff --git a/internal/config/config.go b/internal/config/config.go index d2844c2..11c7da3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -44,6 +44,9 @@ type Config struct { type UI struct { Icons *bool `yaml:"icons"` + // RecentIcon marks items used lately (Recent, R) with ◷, in the lists + // and in lazysecrets list. Defaults to false. + RecentIcon *bool `yaml:"recentIcon"` // SourcesView is "grouped" (default) or "flat"; ` + "`" + ` toggles it. SourcesView string `yaml:"sourcesView"` // CollapseGroups starts the Sources pane with its groups folded; diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index da6abdc..5447318 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -131,6 +131,9 @@ type Options struct { HandlerSetup *HandlerSetup // NoHistory stops counting uses of items for the Recent list. NoHistory bool + // RecentIcon marks items used lately with ◷ in the lists + // (ui.recentIcon). + RecentIcon bool // Open is a reference to open at startup (lazysecrets open ). Open *ref.Ref // OpenAction, if set, is what to do with Open, rather than asking: @@ -287,6 +290,7 @@ type Model struct { prompt *modal // popup that has the keyboard linked []provider.Container // vaults opened from links, this session open *ref.Ref // reference to open at startup + recentIcon bool // mark items used lately (◷) openAction string // and what to do with it, or ask shareKey string // the config's sharing.key @@ -420,7 +424,8 @@ func New(providers []provider.Provider, opts Options) Model { if m.store == nil { m.store, _ = store.Load("") } - m.sources = newSourcesList(iconSet, m.store) + m.recentIcon = opts.RecentIcon + m.sources = newSourcesList(iconSet, m.store, m.recentIcon) m.sources.Empty = "no sources found" m.collapsed = map[string]bool{} m.flatSources = opts.FlatSources diff --git a/internal/ui/app/favourites.go b/internal/ui/app/favourites.go index 9faf2d8..e9f207b 100644 --- a/internal/ui/app/favourites.go +++ b/internal/ui/app/favourites.go @@ -19,6 +19,22 @@ import ( const star = "★ " +// recentMark marks items used lately (Recent), with ui.recentIcon. +const recentMark = "◷ " + +// marks is what goes before a list row's name: ★ when it's starred for +// account and, with recent, ◷ when it was used lately. +func marks(st styles.Styles, n store.Node, account string, recent bool) string { + var s string + if n.StarredFor(account) { + s = st.Favourite.Render(star) + } + if recent && !n.LastUsed.IsZero() { + s += st.Muted.Render(recentMark) + } + return s +} + func sourceKey(s provider.Source) string { if r, err := ref.ForSource(s); err == nil { return r.String() @@ -94,9 +110,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con } line += sty.Muted.Render(" " + c.Detail) } - if n.StarredFor(c.Source.Provider) { - line = sty.Favourite.Render(star) + line - } + line = marks(sty, n, c.Source.Provider, m.recentIcon) + line if withSource { src := c.Source.Name if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok { @@ -116,9 +130,7 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string { if o.Enabled != nil && !*o.Enabled { line = sty.Muted.Render(o.Name + " (disabled)") } - if n.StarredFor(o.Container.Source.Provider) { - line = sty.Favourite.Render(star) + line - } + line = marks(sty, n, o.Container.Source.Provider, m.recentIcon) + line switch state, left := m.expiry(o); state { case expired: line += " " + sty.Error.Render("✗ expired") diff --git a/internal/ui/app/favourites_test.go b/internal/ui/app/favourites_test.go index 9c1dd84..fd16afc 100644 --- a/internal/ui/app/favourites_test.go +++ b/internal/ui/app/favourites_test.go @@ -6,6 +6,7 @@ import ( "slices" "strings" "testing" + "time" tea "charm.land/bubbletea/v2" "charm.land/lipgloss/v2" @@ -434,3 +435,20 @@ func TestStarIsPerAccount(t *testing.T) { t.Errorf("favourites should list alice's star only: %q", labels) } } + +func TestRecentIcon(t *testing.T) { + t.Parallel() + mem, _ := store.Load("") + _ = mem.Use("lazysecrets://azure/"+kv+"/secrets/api-key", time.Now(), func(*store.Node) {}) + d := newDriverOpts(t, Options{Store: mem, RecentIcon: true}) + d.keys("3") + scr := d.screen() + if !strings.Contains(scr, "◷ api-key") || strings.Contains(scr, "◷ db-password") { + t.Errorf("want ◷ on api-key only:\n%s", scr) + } + off := newDriverOpts(t, Options{Store: mem}) + off.keys("3") + if strings.Contains(off.screen(), "◷") { + t.Errorf("◷ without ui.recentIcon:\n%s", off.screen()) + } +} diff --git a/internal/ui/app/sources.go b/internal/ui/app/sources.go index 45c38f9..4e45209 100644 --- a/internal/ui/app/sources.go +++ b/internal/ui/app/sources.go @@ -57,7 +57,7 @@ func sourceRowID(r sourceRow) string { return sourceRowKey(r.provider, r.source.ID) } -func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] { +func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] { return panes.NewList("[1] Sources", panes.Item[sourceRow]{ ID: sourceRowID, // Headers have no filter text, so filtering shows just sources. @@ -79,8 +79,8 @@ func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] { node := mem.Node(sourceKey(r.source)) kind := st.Kind(r.group) starred := "" - if r.kind == rowSource && node.StarredFor(r.source.Provider) { - starred = st.Favourite.Render(star) + if r.kind == rowSource { + starred = marks(st, node, r.source.Provider, recent) } switch { case r.kind == rowLinks: