feat: H shows what's hidden; Spring keys that don't look secret are hidden #81

Merged
keb merged 1 commits from feat/hidden into main 2026-10-15 13:44:30 +02:00
19 changed files with 323 additions and 74 deletions
Showing only changes of commit dbe4fbeb5f - Show all commits

No files matched your search

+2 -1
View File
@@ -57,6 +57,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)") expiry := fs.Bool("expiry", true, "mark and say which have expired (✗ expired) or expire within ui.expiryWarningDays (⚠ expires in 12d) (text)")
kinds := fs.String("kind", "", "only these kinds, comma-separated: "+strings.Join(listing.Kinds, ", ")+" (vault: also scopes and repo files)") 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\")") showErrors := fs.Bool("errors", true, "list the lists that can't be read, with why (text: ✗ lines; json: \"error\")")
hidden := fs.Bool("hidden", false, "list what's hidden too, as H does in the TUI: Spring config keys that don't look secret, and files and repos with only those (json: \"hidden\")")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return err return err
} }
@@ -129,7 +130,7 @@ func listCmd(providers []provider.Provider, st *store.Store, cachePath string, t
f = cachefile.Read(cachePath) f = cachefile.Read(cachePath)
} }
f, loaded := listing.Run(context.Background(), listing.Options{ f, loaded := listing.Run(context.Background(), listing.Options{
Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout, Providers: providers, Cache: f, MaxAge: *maxAge, Cached: *cached, Kinds: only, Timeout: timeout, Hidden: *hidden,
Mark: func(link, account string) listing.Marks { Mark: func(link, account string) listing.Marks {
n := st.Node(link) n := st.Node(link)
return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now), return listing.Marks{Starred: n.StarredFor(account), Recent: n.UsedLately(now),
+1
View File
@@ -51,6 +51,7 @@ so `^prod` finds names starting with prod.
| `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks | | `--cached` | only what's cached, at once: nothing is loaded from Azure or Databricks |
| `--max-age 2m` | load cached lists older than this again | | `--max-age 2m` | load cached lists older than this again |
| `--errors=false` | leave out the vaults and sources it couldn't read | | `--errors=false` | leave out the vaults and sources it couldn't read |
| `--hidden` | list what's [hidden](repos.md#spring-boot-config) too, as `H` shows it (json: `"hidden": true`) |
| `--color never` | no colours, for scripts | | `--color never` | no colours, for scripts |
| `--icons=false` | no provider icons | | `--icons=false` | no provider icons |
| `--stars=false` | no ★ before starred names (in `favouriteColor`) | | `--stars=false` | no ★ before starred names (in `favouriteColor`) |
+1
View File
@@ -20,6 +20,7 @@ The rule: a letter in the lists, ctrl+ that letter where you type a filter
| `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets | | `[` / `]` | previous / next tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets |
| `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars | | `/` | filter the focused list by name or note ([how it matches](#searching)); `enter` keeps it, `esc` clears it. While typing: `ctrl+j`/`ctrl+k` move, `ctrl+y` copies, `ctrl+r` reveals, `ctrl+f` stars |
| `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it | | `` ` `` | switch the Sources pane between grouped by provider and a flat list; `enter`/`space` on a provider folds it |
| `H` | show / hide what's hidden: [Spring keys](repos.md#spring-boot-config) that don't look secret, and files and repos with only those; shown dimmed |
| `r` | refresh the focused pane | | `r` | refresh the focused pane |
| `q`, `ctrl+c` | quit | | `q`, `ctrl+c` | quit |
+7 -4
View File
@@ -69,7 +69,8 @@ sharing:
`application.yml`, `application-prod.properties`, `bootstrap.yaml` and the `application.yml`, `application-prod.properties`, `bootstrap.yaml` and the
like, tracked by git (not those in a Helm chart's `templates/`). Most of like, tracked by git (not those in a Helm chart's `templates/`). Most of
their keys aren't secret, so only those that look like one are listed: their keys aren't secret, so only those that look like one are listed; the
rest are hidden:
- **keys** named like a secret: ending in `password`, `pwd`, `secret`, - **keys** named like a secret: ending in `password`, `pwd`, `secret`,
`token`, `key`, `credentials`, `salt`, `connection-string` and the `token`, `key`, `credentials`, `salt`, `connection-string` and the
@@ -78,9 +79,11 @@ their keys aren't secret, so only those that look like one are listed:
password (`jdbc:postgresql://app:pw@db/app`), or a connection string password (`jdbc:postgresql://app:pw@db/app`), or a connection string
with `AccountKey=`, `SharedAccessKey=` or `Password=`. with `AccountKey=`, `SharedAccessKey=` or `Password=`.
A file's details count the keys left out, and `e` opens it whole. A A file with none is hidden too, and a repo with only such files. `H` shows
`${DB_PASSWORD}` value is marked as a reference: the secret is set what's hidden, dimmed, in the panes and in Go to (`lazysecrets list
elsewhere. A tracked file is marked in red only when a listed key has its --hidden` lists it); a pane's counter says how many it hides. A file's
details count its hidden keys, and `e` opens it whole. A `${DB_PASSWORD}`
value is marked as a reference: the secret is set elsewhere. A tracked file is marked in red only when a listed key has its
value in plain text. value in plain text.
Each list of regexps replaces its default; `keys: [".*"]` lists every key: Each list of regexps replaces its default; `keys: [".*"]` lists every key:
+15 -4
View File
@@ -56,6 +56,9 @@ type Entry struct {
// web page (portal, workspace), if any. // web page (portal, workspace), if any.
Reference string `json:"reference,omitempty"` Reference string `json:"reference,omitempty"`
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
// Hidden is left out by its provider's rules (a Spring file's key
// that doesn't look secret), listed only with Options.Hidden.
Hidden bool `json:"hidden,omitempty"`
// Error says why a source's vaults or a vault's secrets couldn't be // 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 // listed. The entry is then that source or vault, again, or with an
// account's sources unreadable, the account without a link. // account's sources unreadable, the account without a link.
@@ -70,6 +73,8 @@ type Options struct {
MaxAge time.Duration MaxAge time.Duration
// Cached lists only what's cached (and repos): nothing is loaded. // Cached lists only what's cached (and repos): nothing is loaded.
Cached bool Cached bool
// Hidden lists what providers hide too, as H does in the TUI.
Hidden bool
// Kinds are the kinds of entry to list (Kinds' names); none is all. // 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 // Lists only needed for other kinds aren't loaded: sources and vaults
// alone don't load any secrets. Lists that fail are always listed. // alone don't load any secrets. Lists that fail are always listed.
@@ -180,6 +185,9 @@ func (l *lister) provider(p provider.Provider) {
// source emits src, and what's cached under it, and queues what's not. // source emits src, and what's cached under it, and queues what's not.
func (l *lister) source(p provider.Provider, src provider.Source) { func (l *lister) source(p provider.Provider, src provider.Source) {
if src.Hidden && !l.o.Hidden {
return
}
src.Provider, src.ProviderLabel = p.ID(), p.Label() src.Provider, src.ProviderLabel = p.ID(), p.Label()
l.put(p, sourceEntry(src)) l.put(p, sourceEntry(src))
row := cachefile.Row(p.ID(), src.ID) row := cachefile.Row(p.ID(), src.ID)
@@ -196,6 +204,9 @@ func (l *lister) source(p provider.Provider, src provider.Source) {
func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) { func (l *lister) containers(p provider.Provider, src provider.Source, cs []provider.Container) {
for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) { for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
if c.Hidden && !l.o.Hidden {
continue
}
c.Source = src c.Source = src
l.put(p, containerEntry(c)) l.put(p, containerEntry(c))
for _, t := range c.ObjectTypes() { for _, t := range c.ObjectTypes() {
@@ -221,17 +232,17 @@ func (l *lister) objects(p provider.Provider, c provider.Container, objs []provi
for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) { for _, o := range sorted(objs, func(o provider.Object) string { return o.Name }) {
o.Container = c o.Container = c
r, err := ref.ForObject(o, "") r, err := ref.ForObject(o, "")
if err != nil { if err != nil || (o.Hidden && !l.o.Hidden) {
continue continue
} }
l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind), l.put(p, Entry{Link: r.String(), Kind: strings.ToLower(string(o.Type)), Provider: kindOf(c.Source.Kind),
Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source), Path: []string{c.Source.Name, c.Name, o.Name}, AccountLabel: accountOf(c.Source),
Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL}) Expires: o.Expires, Enabled: o.Enabled, Reference: o.Reference, URL: o.URL, Hidden: o.Hidden})
} }
} }
func sourceEntry(src provider.Source) Entry { func sourceEntry(src provider.Source) Entry {
e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL} e := Entry{Kind: "source", Provider: kindOf(src.Kind), Path: []string{src.Name}, AccountLabel: accountOf(src), URL: src.URL, Hidden: src.Hidden}
if r, err := ref.ForSource(src); err == nil { if r, err := ref.ForSource(src); err == nil {
e.Link = r.String() e.Link = r.String()
} }
@@ -239,7 +250,7 @@ func sourceEntry(src provider.Source) Entry {
} }
func containerEntry(c provider.Container) Entry { func containerEntry(c provider.Container) Entry {
e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL} e := Entry{Kind: "vault", Provider: kindOf(c.Source.Kind), Path: []string{c.Source.Name, c.Name}, AccountLabel: accountOf(c.Source), URL: c.URL, Hidden: c.Hidden}
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil { if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
e.Link = r.String() e.Link = r.String()
} }
+7 -1
View File
@@ -73,6 +73,10 @@ type Source struct {
// Here marks the source lazysecrets was started in (the current git // Here marks the source lazysecrets was started in (the current git
// repo): listed first, starred or not, and labelled. // repo): listed first, starred or not, and labelled.
Here bool Here bool
// Hidden marks a source, container or object the provider's rules
// left out (a Spring file with no secret-looking keys): listed only
// while the app shows hidden ones.
Hidden bool `json:",omitempty"`
} }
// ObjectType is the kind of object stored in a container. // ObjectType is the kind of object stored in a container.
@@ -109,7 +113,8 @@ type Container struct {
Types []ObjectType Types []ObjectType
URL string // web page for the container, if any URL string // web page for the container, if any
// Attrs describe it further, from the listing (a vault's settings). // Attrs describe it further, from the listing (a vault's settings).
Attrs []Attr `json:",omitempty"` Attrs []Attr `json:",omitempty"`
Hidden bool `json:",omitempty"` // see Source.Hidden
} }
// ObjectTypes returns the container's object types, defaulting to secrets. // ObjectTypes returns the container's object types, defaulting to secrets.
@@ -134,6 +139,7 @@ type Object struct {
ID string ID string
Name string Name string
Detail string // shown after the name, e.g. "jasypt" for how its value is kept Detail string // shown after the name, e.g. "jasypt" for how its value is kept
Hidden bool `json:",omitempty"` // see Source.Hidden
Version string Version string
Enabled *bool Enabled *bool
Updated time.Time Updated time.Time
+10 -1
View File
@@ -190,12 +190,19 @@ func (p *Provider) Current(ctx context.Context) (provider.Source, bool) {
return provider.Source{}, false return provider.Source{}, false
} }
src := p.source(r) src := p.source(r)
if files, err := p.Containers(ctx, src); err == nil && len(files) == 0 { files, err := p.Containers(ctx, src)
if err == nil && len(files) == 0 {
return provider.Source{}, false return provider.Source{}, false
} }
src.Hidden = err == nil && allHidden(files)
return src, true return src, true
} }
// allHidden reports whether a repo's files are all hidden: the repo is too.
func allHidden(files []provider.Container) bool {
return !slices.ContainsFunc(files, func(c provider.Container) bool { return !c.Hidden })
}
func (p *Provider) source(r repoInfo) provider.Source { func (p *Provider) source(r repoInfo) provider.Source {
return provider.Source{ return provider.Source{
Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(), Kind: provider.KindRepo, Provider: p.ID(), ProviderLabel: p.Label(),
@@ -264,6 +271,7 @@ func (p *Provider) Sources(ctx context.Context) ([]provider.Source, error) {
parallel(len(srcs), func(i int) { parallel(len(srcs), func(i int) {
files, err := p.Containers(ctx, srcs[i]) files, err := p.Containers(ctx, srcs[i])
keep[i] = err != nil || len(files) > 0 keep[i] = err != nil || len(files) > 0
srcs[i].Hidden = err == nil && allHidden(files)
}) })
var out []provider.Source var out []provider.Source
for i, src := range srcs { for i, src := range srcs {
@@ -327,6 +335,7 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
c.Detail = provider.DetailPlainEnv c.Detail = provider.DetailPlainEnv
case IsSpring(f): case IsSpring(f):
c.Detail = "spring · " + springFormat(f) c.Detail = "spring · " + springFormat(f)
c.Hidden = p.springHidden(r.root, path)
default: default:
return return
} }
+19 -6
View File
@@ -9,6 +9,7 @@ import (
"path" "path"
"path/filepath" "path/filepath"
"regexp" "regexp"
"slices"
"strconv" "strconv"
"strings" "strings"
"unicode" "unicode"
@@ -331,7 +332,22 @@ func unescapeProperty(s string) string {
return b.String() return b.String()
} }
// springObjects lists the file's secret keys. // springHidden reports whether a Spring file has no secret keys. One that
// can't be read isn't hidden: its error is worth seeing.
func (p *Provider) springHidden(root, file string) bool {
keys, err := readSpring(file)
if err != nil {
return false
}
filter, err := p.springFilterFor(root)
if err != nil {
return false
}
return !slices.ContainsFunc(keys, filter.secret)
}
// springObjects lists the file's keys, those that don't look like secrets
// hidden.
func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Object, error) { func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Object, error) {
keys, err := parseSpring(c.ID, b) keys, err := parseSpring(c.ID, b)
if err != nil { if err != nil {
@@ -344,9 +360,6 @@ func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Obj
format := provider.Attr{Label: "format", Value: "spring " + springFormat(c.ID)} format := provider.Attr{Label: "format", Value: "spring " + springFormat(c.ID)}
var out []provider.Object var out []provider.Object
for _, k := range keys { for _, k := range keys {
if !filter.secret(k) {
continue
}
state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, "" state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, ""
switch { switch {
case k.reference(): case k.reference():
@@ -356,7 +369,7 @@ func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Obj
} }
out = append(out, provider.Object{ out = append(out, provider.Object{
Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail, Container: c, Type: provider.TypeSecret, ID: c.ID + "\x00" + k.name, Name: k.name, Detail: detail,
Attrs: []provider.Attr{format, state}, Hidden: !filter.secret(k), Attrs: []provider.Attr{format, state},
}) })
} }
return out, nil return out, nil
@@ -406,6 +419,6 @@ func (p *Provider) springDetails(c provider.Container) (attrs []provider.Attr, p
} }
return []provider.Attr{ return []provider.Attr{
{Label: "encryption", Value: jasyptShare(jasypt, len(keys))}, {Label: "encryption", Value: jasyptShare(jasypt, len(keys))},
{Label: "other keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)}, {Label: "hidden keys", Value: fmt.Sprintf("%d, not matched by the secret patterns", len(keys)-secrets)},
}, plain }, plain
} }
+37 -5
View File
@@ -177,11 +177,15 @@ app:
} }
local, yml := cs[0], cs[1] local, yml := cs[0], cs[1]
keys, _ := p.Objects(ctx, yml, provider.TypeSecret) listed, _ := p.Objects(ctx, yml, provider.TypeSecret)
keys := shown(listed)
var names []string var names []string
for _, k := range keys { for _, k := range keys {
names = append(names, strings.TrimSpace(k.Name+" "+k.Detail)) names = append(names, strings.TrimSpace(k.Name+" "+k.Detail))
} }
if len(listed) != 5 || !listed[0].Hidden || listed[0].Name != "server.port" {
t.Errorf("hidden keys: %+v", listed)
}
if want := []string{"spring.datasource.password reference", "jasypt.encryptor.password reference", "app.api-key jasypt"}; !slices.Equal(names, want) { if want := []string{"spring.datasource.password reference", "jasypt.encryptor.password reference", "app.api-key jasypt"}; !slices.Equal(names, want) {
t.Errorf("keys %v, want %v", names, want) t.Errorf("keys %v, want %v", names, want)
} }
@@ -198,7 +202,7 @@ app:
} }
// References and Jasypt aren't plain text in git; a password is. // References and Jasypt aren't plain text in git; a password is.
if d := details(p, yml); d["other keys"].Value != "2, not matched by the secret patterns" || d["git"].Warn || d["encryption"].Value != "jasypt: 1 of 5 values" { if d := details(p, yml); d["hidden keys"].Value != "2, not matched by the secret patterns" || d["git"].Warn || d["encryption"].Value != "jasypt: 1 of 5 values" {
t.Errorf("yml: %+v", d) t.Errorf("yml: %+v", d)
} }
if d := details(p, local); !d["git"].Warn { if d := details(p, local); !d["git"].Warn {
@@ -208,12 +212,12 @@ app:
// The config's patterns, and the repo's over them. // The config's patterns, and the repo's over them.
all := []string{".*"} all := []string{".*"}
p = New(Settings{Dir: root, Spring: SpringPatterns{Keys: &all}}) p = New(Settings{Dir: root, Spring: SpringPatterns{Keys: &all}})
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(keys) != 5 { if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 5 {
t.Errorf("every key: %d", len(keys)) t.Errorf("every key: %d", len(shown(keys)))
} }
lazysecrets := filepath.Join(root, ".lazysecrets.yaml") lazysecrets := filepath.Join(root, ".lazysecrets.yaml")
os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"url$\"]\n values: []\n"), 0o644) os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"url$\"]\n values: []\n"), 0o644)
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(keys) != 1 || keys[0].Name != "spring.datasource.url" { if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 1 || shown(keys)[0].Name != "spring.datasource.url" {
t.Errorf("repo's patterns: %v", keys) t.Errorf("repo's patterns: %v", keys)
} }
os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"(\"]\n"), 0o644) os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"(\"]\n"), 0o644)
@@ -221,3 +225,31 @@ app:
t.Errorf("bad pattern: %v", err) t.Errorf("bad pattern: %v", err)
} }
} }
func shown(objects []provider.Object) []provider.Object {
return slices.DeleteFunc(slices.Clone(objects), func(o provider.Object) bool { return o.Hidden })
}
func TestHiddenFiles(t *testing.T) {
root := gitRepo(t, map[string]string{
"a/application.yml": "server:\n port: 8080\n",
"b/application.yml": "spring.datasource.password: x\n",
})
ctx := context.Background()
p := New(Settings{Dir: root})
srcs, _ := p.Sources(ctx)
if len(srcs) != 1 || srcs[0].Hidden {
t.Fatalf("sources: %+v", srcs)
}
cs, _ := p.Containers(ctx, srcs[0])
if len(cs) != 2 || !cs[0].Hidden || cs[1].Hidden {
t.Errorf("files: %+v", cs)
}
// A repo with only hidden files is hidden too.
root = gitRepo(t, map[string]string{"application.yml": "server:\n port: 8080\n"})
srcs, _ = New(Settings{Dir: root}).Sources(ctx)
if len(srcs) != 1 || !srcs[0].Hidden {
t.Errorf("hidden repo: %+v", srcs)
}
}
+5
View File
@@ -232,6 +232,7 @@ type Model struct {
// provider finds it (provider.Current), before Sources lists them all. // provider finds it (provider.Current), before Sources lists them all.
current *provider.Source current *provider.Source
flatSources bool // Sources pane without provider groups flatSources bool // Sources pane without provider groups
showHidden bool // H: what providers hide is listed too, dimmed
sourcesFavs bool // Sources pane on its Favourites tab sourcesFavs bool // Sources pane on its Favourites tab
history bool // count uses of items (usage.go) history bool // count uses of items (usage.go)
onActive func() onActive func()
@@ -469,6 +470,7 @@ func New(providers []provider.Provider, opts Options) Model {
Filter: func(c provider.Container) string { return c.Name }, Filter: func(c provider.Container) string { return c.Name },
FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note }, FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note },
Render: m.containerRender(false), Render: m.containerRender(false),
Hidden: func(c provider.Container) bool { return c.Hidden },
}) })
m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{ m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{
@@ -478,6 +480,7 @@ func New(providers []provider.Provider, opts Options) Model {
// Typing "expir" finds what has expired or soon will. // Typing "expir" finds what has expired or soon will.
FilterExtra: func(o provider.Object) string { return m.store.Node(objectKey(o)).Note + " " + m.expiryWord(o) }, FilterExtra: func(o provider.Object) string { return m.store.Node(objectKey(o)).Note + " " + m.expiryWord(o) },
Render: m.objectRender(), Render: m.objectRender(),
Hidden: func(o provider.Object) bool { return o.Hidden },
}) })
m.secrets.Empty = "no secrets" m.secrets.Empty = "no secrets"
m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret
@@ -979,6 +982,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
m.cycleZoom(-1) m.cycleZoom(-1)
case key.Matches(msg, k.SourcesView): case key.Matches(msg, k.SourcesView):
m.toggleSourcesView() m.toggleSourcesView()
case key.Matches(msg, k.ToggleHidden):
m.toggleHidden()
case key.Matches(msg, k.Versions): case key.Matches(msg, k.Versions):
if m.versionsOf != nil { if m.versionsOf != nil {
m.closeVersions() m.closeVersions()
+31
View File
@@ -1344,3 +1344,34 @@ func TestErrorLog(t *testing.T) {
t.Errorf("log %+v", d.m.errLog) t.Errorf("log %+v", d.m.errLog)
} }
} }
// hiding marks the secret db-password hidden, as the repo provider does
// a Spring key that doesn't look secret.
type hiding struct{ provider.Provider }
func (h hiding) Objects(ctx context.Context, c provider.Container, t provider.ObjectType) ([]provider.Object, error) {
objs, err := h.Provider.Objects(ctx, c, t)
for i := range objs {
objs[i].Hidden = objs[i].Name == "db-password"
}
return objs, err
}
func TestToggleHidden(t *testing.T) {
t.Parallel()
d := newDriver(t, hiding{fake.Azure(0)})
if _, _, s := d.selected(); s == "db-password" || s == "" {
t.Fatalf("selected %q: the hidden one, or none", s)
}
if strings.Contains(d.screen(), "db-password") || !strings.Contains(d.screen(), "1 hidden") {
t.Fatalf("hidden one listed:\n%s", d.screen())
}
d.keys("H")
if !strings.Contains(d.screen(), "db-password") || !strings.Contains(d.screen(), "showing hidden") {
t.Fatalf("H doesn't show it:\n%s", d.screen())
}
d.keys("H")
if strings.Contains(d.screen(), "db-password") {
t.Fatalf("H again doesn't hide it:\n%s", d.screen())
}
}
+12 -1
View File
@@ -6,6 +6,7 @@ import (
"time" "time"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/expiry" "git.kebcloud.no/keb/lazysecrets/internal/expiry"
@@ -116,7 +117,7 @@ func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Con
} }
line += sty.Muted.Render(" " + c.Detail) line += sty.Muted.Render(" " + c.Detail)
} }
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line line = dimIf(sty, c.Hidden, marks(sty, n, c.Source.Provider, m.recentIcon, m.now())+line)
if withSource { if withSource {
src := c.Source.Name src := c.Source.Name
if account := accountOf(c.Source); account != "" { if account := accountOf(c.Source); account != "" {
@@ -140,6 +141,7 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
if o.Detail != "" { if o.Detail != "" {
line += sty.Muted.Render(" " + o.Detail) line += sty.Muted.Render(" " + o.Detail)
} }
line = dimIf(sty, o.Hidden, line)
switch state, left := m.expiry(o); state { switch state, left := m.expiry(o); state {
case expiry.Expired: case expiry.Expired:
line += " " + sty.Error.Render("✗ expired") line += " " + sty.Error.Render("✗ expired")
@@ -150,6 +152,15 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
} }
} }
// dimIf draws a hidden item's row (shown with H) in the muted colour,
// whole, so it stands apart from what's listed by the provider's rules.
func dimIf(sty styles.Styles, hidden bool, line string) string {
if !hidden {
return line
}
return sty.Muted.Render(ansi.Strip(line))
}
// starredFirst moves starred items to the front, keeping the order // starredFirst moves starred items to the front, keeping the order
// otherwise. It doesn't modify items, which may be cached. // otherwise. It doesn't modify items, which may be cached.
func starredFirst[T any](items []T, starred func(T) bool) []T { func starredFirst[T any](items []T, starred func(T) bool) []T {
+20 -5
View File
@@ -8,6 +8,7 @@ import (
"strings" "strings"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile" "git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -66,28 +67,41 @@ func (m *Model) searchEntries() (sources, items []option) {
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}}) mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
return &es[len(es)-1].opt return &es[len(es)-1].opt
} }
// What providers hide is left out, unless H shows it, dimmed.
skip := func(hidden bool) bool { return hidden && !m.showHidden }
dim := func(hidden bool, e *option) {
if hidden {
e.label = m.st.Muted.Render(ansi.Strip(e.label))
}
}
for _, p := range m.providers { for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources { for _, src := range m.sourceResults[p.ID()].sources {
if skip(src.Hidden) {
continue
}
src.Provider, src.ProviderLabel = p.ID(), p.Label() src.Provider, src.ProviderLabel = p.ID(), p.Label()
row := sourceRowKey(p.ID(), src.ID) row := sourceRowKey(p.ID(), src.ID)
add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd { dim(src.Hidden, add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
m.nav = &nav{sourceRow: row, sourceOnly: true} m.nav = &nav{sourceRow: row, sourceOnly: true}
return nil return nil
}, func(m *Model) tea.Cmd { return m.loadSource(src) }) }, func(m *Model) tea.Cmd { return m.loadSource(src) }))
for _, c := range m.cache.containers[row].value { for _, c := range m.cache.containers[row].value {
if skip(c.Hidden) {
continue
}
r, err := ref.ForContainer(c, provider.TypeSecret) r, err := ref.ForContainer(c, provider.TypeSecret)
if err != nil { if err != nil {
continue continue
} }
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) } reload := func(m *Model) tea.Cmd { return m.loadContainer(c) }
add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd { dim(c.Hidden, add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
m.startNav(r, actGo, row) m.startNav(r, actGo, row)
return nil return nil
}, reload) }, reload))
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} { for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value { for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
r, err := ref.ForObject(o, "") r, err := ref.ForObject(o, "")
if err != nil { if err != nil || skip(o.Hidden) {
continue continue
} }
suffix := strings.Trim(o.Detail+" · "+accountOf(src), " ·") suffix := strings.Trim(o.Detail+" · "+accountOf(src), " ·")
@@ -100,6 +114,7 @@ func (m *Model) searchEntries() (sources, items []option) {
return nil return nil
}, reload) }, reload)
e.plain = c.Detail == provider.DetailPlainEnv e.plain = c.Detail == provider.DetailPlainEnv
dim(o.Hidden, e)
} }
} }
} }
+55 -35
View File
@@ -59,6 +59,7 @@ func sourceRowID(r sourceRow) string {
} }
func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] { func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List[sourceRow] {
render := renderSourceRow(iconSet, mem, recent)
return panes.NewList("[1] Sources", panes.Item[sourceRow]{ return panes.NewList("[1] Sources", panes.Item[sourceRow]{
ID: sourceRowID, ID: sourceRowID,
// Headers have no filter text, so filtering shows just sources. // Headers have no filter text, so filtering shows just sources.
@@ -77,40 +78,48 @@ func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List
return cmp.Or(r.source.ProviderLabel, string(r.group)) + " " + mem.Node(sourceKey(r.source)).Note return cmp.Or(r.source.ProviderLabel, string(r.group)) + " " + mem.Node(sourceKey(r.source)).Note
}, },
Render: func(st styles.Styles, r sourceRow, width int) string { Render: func(st styles.Styles, r sourceRow, width int) string {
node := mem.Node(sourceKey(r.source)) return dimIf(st, r.kind == rowSource && r.source.Hidden, render(st, r, width))
kind := st.Kind(r.group)
starred := ""
if r.kind == rowSource {
starred = marks(st, node, r.source.Provider, recent, time.Now())
}
switch {
case r.kind == rowLinks:
return st.Notice.Render("↗ Opened links") + " " + st.Muted.Render(fmt.Sprintf("(%s)", r.status))
case r.kind == rowSource && r.top:
return withNote(st, kind.Render(iconSet.Prefix(r.group)+"Repo · ")+starred+kind.Render(r.source.Name), node.Note, width)
case r.kind == rowSource && r.flat:
line := kind.Render(iconSet.Prefix(r.group)+fmt.Sprintf("%-10s", r.group)) + " " + starred + r.source.Name
if r.account != "" {
line += st.Muted.Render(" · " + r.account)
}
return withNote(st, line+here(st, r.source), node.Note, width)
case r.kind == rowSource:
// The provider's colour, so long groups stay recognisable.
return withNote(st, " "+starred+kind.Render(r.source.Name), node.Note, width)
}
arrow := "▾"
if r.collapsed {
arrow = "▸"
}
status := st.Muted.Render(fmt.Sprintf("(%s)", r.status))
if r.status == "!" {
status = st.Error.Render("(!)")
}
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
}, },
}) })
} }
// renderSourceRow draws a source, a provider's header or the Opened links
// row.
func renderSourceRow(iconSet icons.Set, mem *store.Store, recent bool) func(styles.Styles, sourceRow, int) string {
return func(st styles.Styles, r sourceRow, width int) string {
node := mem.Node(sourceKey(r.source))
kind := st.Kind(r.group)
starred := ""
if r.kind == rowSource {
starred = marks(st, node, r.source.Provider, recent, time.Now())
}
switch {
case r.kind == rowLinks:
return st.Notice.Render("↗ Opened links") + " " + st.Muted.Render(fmt.Sprintf("(%s)", r.status))
case r.kind == rowSource && r.top:
return withNote(st, kind.Render(iconSet.Prefix(r.group)+"Repo · ")+starred+kind.Render(r.source.Name), node.Note, width)
case r.kind == rowSource && r.flat:
line := kind.Render(iconSet.Prefix(r.group)+fmt.Sprintf("%-10s", r.group)) + " " + starred + r.source.Name
if r.account != "" {
line += st.Muted.Render(" · " + r.account)
}
return withNote(st, line+here(st, r.source), node.Note, width)
case r.kind == rowSource:
// The provider's colour, so long groups stay recognisable.
return withNote(st, " "+starred+kind.Render(r.source.Name), node.Note, width)
}
arrow := "▾"
if r.collapsed {
arrow = "▸"
}
status := st.Muted.Render(fmt.Sprintf("(%s)", r.status))
if r.status == "!" {
status = st.Error.Render("(!)")
}
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
}
}
// rebuildSources lays out the Sources pane from the per-provider results. // rebuildSources lays out the Sources pane from the per-provider results.
func (m *Model) rebuildSources() { func (m *Model) rebuildSources() {
// While filtering, headers drop out, so rows show their provider and // While filtering, headers drop out, so rows show their provider and
@@ -129,17 +138,16 @@ func (m *Model) rebuildSources() {
} }
// The repo lazysecrets started in gets a row of its own on top, so it // The repo lazysecrets started in gets a row of its own on top, so it
// opens on it; flat, it sorts first instead. // opens on it; flat, it sorts first instead.
if src, ok := m.currentSource(); ok && !flat { if src, ok := m.currentSource(); ok && !flat && (!src.Hidden || m.showHidden) {
rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src}) rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src})
} }
for _, p := range m.providers { for _, p := range m.providers {
kind, id := p.Kind(), p.ID() kind, id := p.Kind(), p.ID()
account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()}) account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()})
r, done := m.sourceResults[id] r, done := m.sourceResults[id]
sources := r.sources sources := slices.DeleteFunc(slices.Clone(r.sources), func(s provider.Source) bool {
if !flat { return (s.Here && !flat) || (s.Hidden && !m.showHidden)
sources = slices.DeleteFunc(slices.Clone(sources), func(s provider.Source) bool { return s.Here }) })
}
if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) { if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) {
continue // repos show once some are found to have secret files continue // repos show once some are found to have secret files
} }
@@ -239,6 +247,18 @@ func (m Model) onLinks() bool {
return m.sources.SelectedID() == linksRowID return m.sources.SelectedID() == linksRowID
} }
// toggleHidden lists what providers hide, dimmed, or leaves it out again.
func (m *Model) toggleHidden() {
m.showHidden = !m.showHidden
m.containers.SetShowHidden(m.showHidden)
m.secrets.SetShowHidden(m.showHidden)
m.rebuildSources()
m.flash = flash{text: "hidden items hidden"}
if m.showHidden {
m.flash = flash{text: "showing hidden items, dimmed"}
}
}
// toggleSourcesView switches between grouped and flat, keeping the cursor // toggleSourcesView switches between grouped and flat, keeping the cursor
// on the same source. // on the same source.
func (m *Model) toggleSourcesView() { func (m *Model) toggleSourcesView() {
+3 -1
View File
@@ -45,6 +45,7 @@ type Map struct {
Recent key.Binding Recent key.Binding
Note, Share, ShareMenu, OpenRef key.Binding Note, Share, ShareMenu, OpenRef key.Binding
Search, SourcesView, Commands key.Binding Search, SourcesView, Commands key.Binding
ToggleHidden key.Binding
SplitLeft, SplitRight, SplitReset key.Binding SplitLeft, SplitRight, SplitReset key.Binding
Taller, Shorter, ZoomIn, ZoomOut key.Binding Taller, Shorter, ZoomIn, ZoomOut key.Binding
Login, Refresh, Help, Quit key.Binding Login, Refresh, Help, Quit key.Binding
@@ -102,6 +103,7 @@ func (m *Map) Actions() []Action {
a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."), a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."),
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."), 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, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
a(Lists, "toggleHidden", &m.ToggleHidden, "show/hide hidden", "lists", "Show or hide what's hidden: Spring config keys that don't look like secrets, and files and repos with only those. Shown, they're dimmed; Go to finds them too."),
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, "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, "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, "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, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
@@ -185,7 +187,7 @@ var defaults = map[string][]string{
"lists.nextPane": {"tab"}, "lists.prevPane": {"shift+tab", "h", "left"}, "lists.details": {"l", "right"}, "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.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
"lists.open": {"enter"}, "lists.back": {"esc", "backspace"}, "lists.open": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, "lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, "lists.toggleHidden": {"H"},
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.goTo": {"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.commands": {"x"},
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"}, "lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
+6
View File
@@ -335,6 +335,12 @@ func (d Detail) lines(st styles.Styles) []string {
} }
} }
case ValueShown: case ValueShown:
if d.value.Reveal() == "" {
// Not in the value's colour, nor a word a value could be.
field(label, st.Muted.Italic(true).Render("∅ no value (an empty string)"))
out = append(out, valueIndent+hints("hide"))
break
}
// Where the dots were, continuing under itself; the keys below. // Where the dots were, continuing under itself; the keys below.
first := true first := true
lines, note := valueView(d.value.Reveal()) lines, note := valueView(d.value.Reveal())
+20
View File
@@ -3,6 +3,12 @@ package panes
import ( import (
"strings" "strings"
"testing" "testing"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
) )
func TestWrappedValueKeepsItsStyle(t *testing.T) { func TestWrappedValueKeepsItsStyle(t *testing.T) {
@@ -15,3 +21,17 @@ func TestWrappedValueKeepsItsStyle(t *testing.T) {
t.Errorf("the style went on past its reset: %q", got[2]) t.Errorf("the style went on past its reset: %q", got[2])
} }
} }
func TestEmptyValue(t *testing.T) {
d := NewDetail(icons.None)
d.SetSize(80, 20)
d.ShowSecret(provider.Object{Name: "spring.datasource.password", Type: provider.TypeSecret})
d.SetValue(provider.NewSecretValue(nil))
if view := ansi.Strip(d.View(styles.Default(), true, "")); !strings.Contains(view, "∅ no value (an empty string)") {
t.Errorf("empty value:\n%s", view)
}
d.SetValue(provider.NewSecretValue([]byte("empty")))
if view := ansi.Strip(d.View(styles.Default(), true, "")); strings.Contains(view, "∅") || !strings.Contains(view, "empty") {
t.Errorf("the word empty:\n%s", view)
}
}
+34 -10
View File
@@ -22,6 +22,9 @@ type Item[T any] struct {
// text the row doesn't show (e.g. the provider of a grouped source). // text the row doesn't show (e.g. the provider of a grouped source).
FilterExtra func(T) string FilterExtra func(T) string
Render func(st styles.Styles, v T, width int) string Render func(st styles.Styles, v T, width int) string
// Hidden, if set, says which items are listed only while the list
// shows hidden ones (SetShowHidden).
Hidden func(T) bool
} }
// List is a scrollable, filterable list pane with loading and error states. // List is a scrollable, filterable list pane with loading and error states.
@@ -53,6 +56,9 @@ type List[T any] struct {
input textinput.Model input textinput.Model
filtering bool filtering bool
showHidden bool
hidden int // items left out for being hidden
} }
func NewList[T any](title string, item Item[T]) List[T] { func NewList[T any](title string, item Item[T]) List[T] {
@@ -77,6 +83,12 @@ func (l *List[T]) SetItems(items []T) {
l.applyFilter(prev) l.applyFilter(prev)
} }
// SetShowHidden lists hidden items too, or leaves them out again.
func (l *List[T]) SetShowHidden(on bool) {
l.showHidden = on
l.applyFilter(l.SelectedID())
}
// Reset clears items, error, filter and cursor. // Reset clears items, error, filter and cursor.
func (l *List[T]) Reset() { func (l *List[T]) Reset() {
l.items, l.visible = nil, nil l.items, l.visible = nil, nil
@@ -237,21 +249,28 @@ func (l *List[T]) UpdateFilter(msg tea.Msg) tea.Cmd {
// applyFilter recomputes the visible items and puts the cursor back on the // applyFilter recomputes the visible items and puts the cursor back on the
// item with ID keep if it is still visible. // item with ID keep if it is still visible.
func (l *List[T]) applyFilter(keep string) { func (l *List[T]) applyFilter(keep string) {
l.visible, l.hits = l.visible[:0], nil l.visible, l.hits, l.hidden = l.visible[:0], nil, 0
if q := l.input.Value(); q == "" { shown := make([]int, 0, len(l.items))
for i := range l.items { for i, v := range l.items {
l.visible = append(l.visible, i) if !l.showHidden && l.item.Hidden != nil && l.item.Hidden(v) {
l.hidden++
continue
} }
shown = append(shown, i)
}
if q := l.input.Value(); q == "" {
l.visible = append(l.visible, shown...)
} else { } else {
// The row's own text is the name; FilterExtra (a note, a provider) // The row's own text is the name; FilterExtra (a note, a provider)
// is context, matched too but ranked lower. // is context, matched too but ranked lower.
items := make([]match.Item, len(l.items)) items := make([]match.Item, len(shown))
for i, v := range l.items { for j, i := range shown {
v := l.items[i]
text := l.item.Filter(v) text := l.item.Filter(v)
items[i] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)} items[j] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)}
if l.item.FilterExtra != nil { if l.item.FilterExtra != nil {
if extra := l.item.FilterExtra(v); extra != "" { if extra := l.item.FilterExtra(v); extra != "" {
items[i].Text += " " + extra items[j].Text += " " + extra
} }
} }
} }
@@ -262,7 +281,7 @@ func (l *List[T]) applyFilter(keep string) {
hits = append(hits, p) hits = append(hits, p)
} }
} }
l.visible = append(l.visible, h.Index) l.visible = append(l.visible, shown[h.Index])
l.hits = append(l.hits, hits) l.hits = append(l.hits, hits)
} }
} }
@@ -353,8 +372,10 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
for _, line := range wrapLines(l.Err.Error(), inner-1) { for _, line := range wrapLines(l.Err.Error(), inner-1) {
body = append(body, st.Error.Render(" "+line)) body = append(body, st.Error.Render(" "+line))
} }
case len(l.visible) == 0 && len(l.items) > 0: case len(l.visible) == 0 && len(l.items) > l.hidden:
body = append(body, st.Muted.Render(" no matches")) body = append(body, st.Muted.Render(" no matches"))
case len(l.visible) == 0 && l.hidden > 0:
body = append(body, st.Muted.Render(fmt.Sprintf(" %s (%d hidden)", l.Empty, l.hidden)))
case len(l.visible) == 0: case len(l.visible) == 0:
body = append(body, st.Muted.Render(" "+l.Empty)) body = append(body, st.Muted.Render(" "+l.Empty))
default: default:
@@ -376,6 +397,9 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
if len(l.visible) > 0 { if len(l.visible) > 0 {
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible)) counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
} }
if l.hidden > 0 {
counter = strings.Trim(counter+fmt.Sprintf(" · %d hidden", l.hidden), " ·")
}
if l.Loading && l.Age != "" && len(l.items) > 0 { if l.Loading && l.Age != "" && len(l.items) > 0 {
counter = "cached " + l.Age + " · " + counter counter = "cached " + l.Age + " · " + counter
} }
+38
View File
@@ -201,3 +201,41 @@ func TestListHeader(t *testing.T) {
t.Errorf("no room, the text:\n%s", v) t.Errorf("no room, the text:\n%s", v)
} }
} }
func TestListHidden(t *testing.T) {
l := NewList("Items", Item[string]{
ID: func(s string) string { return s },
Filter: func(s string) string { return s },
Render: func(_ styles.Styles, s string, _ int) string { return s },
Hidden: func(s string) bool { return strings.HasPrefix(s, "hidden") },
})
l.SetSize(40, 7)
l.SetItems([]string{"shown-a", "hidden-b", "shown-c"})
view := ansi.Strip(l.View(styles.Default(), true, ""))
if l.Len() != 2 || strings.Contains(view, "hidden-b") || !strings.Contains(view, "1 hidden") {
t.Fatalf("hidden listed:\n%s", view)
}
l.Move(1)
l.SetShowHidden(true)
if l.Len() != 3 || l.SelectedID() != "shown-c" {
t.Fatalf("shown: %d, selected %q", l.Len(), l.SelectedID())
}
l.StartFilter()
l.input.SetValue("hid")
l.applyFilter("")
if l.Len() != 1 || l.SelectedID() != "hidden-b" {
t.Fatalf("filtered with hidden shown: %d %q", l.Len(), l.SelectedID())
}
l = NewList("Items", Item[string]{
ID: func(s string) string { return s }, Filter: func(s string) string { return s },
Render: func(_ styles.Styles, s string, _ int) string { return s },
Hidden: func(string) bool { return true },
})
l.Empty = "no secrets"
l.SetSize(40, 7)
l.SetItems([]string{"a", "b"})
if view := ansi.Strip(l.View(styles.Default(), true, "")); !strings.Contains(view, "no secrets (2 hidden)") {
t.Fatalf("all hidden:\n%s", view)
}
}