feat: H shows what's hidden; Spring keys that don't look secret are hidden
Sources, files and secrets get a Hidden flag. The repo provider marks Spring keys that match neither pattern, files with only those, and repos with only such files, instead of leaving them out. - Hidden items are left out of the panes, Go to and lazysecrets list; a pane's counter says how many it hides. - H (lists.toggleHidden) shows them, dimmed whole in mutedColor; list --hidden lists them, json marks them "hidden". - A revealed empty value shows "∅ no value (an empty string)", muted, instead of a blank line.
This commit is contained in:
19 files changed
+323
-74
No files matched your search
@@ -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),
|
||||||
|
|||||||
@@ -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`) |
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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"},
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user