feat: H shows what's hidden; Spring keys that don't look secret are hidden
CI / Checks (pull_request) Successful in 39s
PR title / Conventional title (pull_request) Successful in 1s

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:
keb committed 2026-10-15 13:43:37 +02:00
1 parent 17da9eaa32
commit dbe4fbeb5f
19 files changed
+323 -74

No files matched your search

+15 -4
View File
@@ -56,6 +56,9 @@ type Entry struct {
// web page (portal, workspace), if any.
Reference string `json:"reference,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
// listed. The entry is then that source or vault, again, or with an
// account's sources unreadable, the account without a link.
@@ -70,6 +73,8 @@ type Options struct {
MaxAge time.Duration
// Cached lists only what's cached (and repos): nothing is loaded.
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.
// Lists only needed for other kinds aren't loaded: sources and vaults
// 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.
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()
l.put(p, sourceEntry(src))
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) {
for _, c := range sorted(cs, func(c provider.Container) string { return c.Name }) {
if c.Hidden && !l.o.Hidden {
continue
}
c.Source = src
l.put(p, containerEntry(c))
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 }) {
o.Container = c
r, err := ref.ForObject(o, "")
if err != nil {
if err != nil || (o.Hidden && !l.o.Hidden) {
continue
}
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),
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 {
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 {
e.Link = r.String()
}
@@ -239,7 +250,7 @@ func sourceEntry(src provider.Source) 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 {
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
// repo): listed first, starred or not, and labelled.
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.
@@ -109,7 +113,8 @@ type Container struct {
Types []ObjectType
URL string // web page for the container, if any
// 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.
@@ -134,6 +139,7 @@ type Object struct {
ID string
Name string
Detail string // shown after the name, e.g. "jasypt" for how its value is kept
Hidden bool `json:",omitempty"` // see Source.Hidden
Version string
Enabled *bool
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
}
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
}
src.Hidden = err == nil && allHidden(files)
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 {
return provider.Source{
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) {
files, err := p.Containers(ctx, srcs[i])
keep[i] = err != nil || len(files) > 0
srcs[i].Hidden = err == nil && allHidden(files)
})
var out []provider.Source
for i, src := range srcs {
@@ -327,6 +335,7 @@ func (p *Provider) Containers(ctx context.Context, src provider.Source) ([]provi
c.Detail = provider.DetailPlainEnv
case IsSpring(f):
c.Detail = "spring · " + springFormat(f)
c.Hidden = p.springHidden(r.root, path)
default:
return
}
+19 -6
View File
@@ -9,6 +9,7 @@ import (
"path"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"unicode"
@@ -331,7 +332,22 @@ func unescapeProperty(s string) 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) {
keys, err := parseSpring(c.ID, b)
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)}
var out []provider.Object
for _, k := range keys {
if !filter.secret(k) {
continue
}
state, detail := provider.Attr{Label: "encrypted", Value: "no: plain text in the file"}, ""
switch {
case k.reference():
@@ -356,7 +369,7 @@ func (p *Provider) springObjects(c provider.Container, b []byte) ([]provider.Obj
}
out = append(out, provider.Object{
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
@@ -406,6 +419,6 @@ func (p *Provider) springDetails(c provider.Container) (attrs []provider.Attr, p
}
return []provider.Attr{
{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
}
+37 -5
View File
@@ -177,11 +177,15 @@ app:
}
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
for _, k := range keys {
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) {
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.
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)
}
if d := details(p, local); !d["git"].Warn {
@@ -208,12 +212,12 @@ app:
// The config's patterns, and the repo's over them.
all := []string{".*"}
p = New(Settings{Dir: root, Spring: SpringPatterns{Keys: &all}})
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(keys) != 5 {
t.Errorf("every key: %d", len(keys))
if keys, _ := p.Objects(ctx, yml, provider.TypeSecret); len(shown(keys)) != 5 {
t.Errorf("every key: %d", len(shown(keys)))
}
lazysecrets := filepath.Join(root, ".lazysecrets.yaml")
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)
}
os.WriteFile(lazysecrets, []byte("spring:\n keys: [\"(\"]\n"), 0o644)
@@ -221,3 +225,31 @@ app:
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.
current *provider.Source
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
history bool // count uses of items (usage.go)
onActive func()
@@ -469,6 +470,7 @@ func New(providers []provider.Provider, opts Options) Model {
Filter: func(c provider.Container) string { return c.Name },
FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note },
Render: m.containerRender(false),
Hidden: func(c provider.Container) bool { return c.Hidden },
})
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.
FilterExtra: func(o provider.Object) string { return m.store.Node(objectKey(o)).Note + " " + m.expiryWord(o) },
Render: m.objectRender(),
Hidden: func(o provider.Object) bool { return o.Hidden },
})
m.secrets.Empty = "no secrets"
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)
case key.Matches(msg, k.SourcesView):
m.toggleSourcesView()
case key.Matches(msg, k.ToggleHidden):
m.toggleHidden()
case key.Matches(msg, k.Versions):
if m.versionsOf != nil {
m.closeVersions()
+31
View File
@@ -1344,3 +1344,34 @@ func TestErrorLog(t *testing.T) {
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"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"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 = 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 {
src := c.Source.Name
if account := accountOf(c.Source); account != "" {
@@ -140,6 +141,7 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
if o.Detail != "" {
line += sty.Muted.Render(" " + o.Detail)
}
line = dimIf(sty, o.Hidden, line)
switch state, left := m.expiry(o); state {
case expiry.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
// otherwise. It doesn't modify items, which may be cached.
func starredFirst[T any](items []T, starred func(T) bool) []T {
+20 -5
View File
@@ -8,6 +8,7 @@ import (
"strings"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"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}})
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 _, src := range m.sourceResults[p.ID()].sources {
if skip(src.Hidden) {
continue
}
src.Provider, src.ProviderLabel = p.ID(), p.Label()
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}
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 {
if skip(c.Hidden) {
continue
}
r, err := ref.ForContainer(c, provider.TypeSecret)
if err != nil {
continue
}
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)
return nil
}, reload)
}, reload))
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
r, err := ref.ForObject(o, "")
if err != nil {
if err != nil || skip(o.Hidden) {
continue
}
suffix := strings.Trim(o.Detail+" · "+accountOf(src), " ·")
@@ -100,6 +114,7 @@ func (m *Model) searchEntries() (sources, items []option) {
return nil
}, reload)
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] {
render := renderSourceRow(iconSet, mem, recent)
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
ID: sourceRowID,
// 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
},
Render: 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
return dimIf(st, r.kind == rowSource && r.source.Hidden, render(st, r, width))
},
})
}
// 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.
func (m *Model) rebuildSources() {
// 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
// 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})
}
for _, p := range m.providers {
kind, id := p.Kind(), p.ID()
account := accountOf(provider.Source{Kind: kind, ProviderLabel: p.Label()})
r, done := m.sourceResults[id]
sources := r.sources
if !flat {
sources = slices.DeleteFunc(slices.Clone(sources), func(s provider.Source) bool { return s.Here })
}
sources := slices.DeleteFunc(slices.Clone(r.sources), func(s provider.Source) bool {
return (s.Here && !flat) || (s.Hidden && !m.showHidden)
})
if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) {
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
}
// 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
// on the same source.
func (m *Model) toggleSourcesView() {
+3 -1
View File
@@ -45,6 +45,7 @@ type Map struct {
Recent key.Binding
Note, Share, ShareMenu, OpenRef key.Binding
Search, SourcesView, Commands key.Binding
ToggleHidden key.Binding
SplitLeft, SplitRight, SplitReset key.Binding
Taller, Shorter, ZoomIn, ZoomOut 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, "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, "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, "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."),
@@ -185,7 +187,7 @@ var defaults = map[string][]string{
"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.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.commands": {"x"},
"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:
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.
first := true
lines, note := valueView(d.value.Reveal())
+20
View File
@@ -3,6 +3,12 @@ package panes
import (
"strings"
"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) {
@@ -15,3 +21,17 @@ func TestWrappedValueKeepsItsStyle(t *testing.T) {
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).
FilterExtra func(T) 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.
@@ -53,6 +56,9 @@ type List[T any] struct {
input textinput.Model
filtering bool
showHidden bool
hidden int // items left out for being hidden
}
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)
}
// 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.
func (l *List[T]) Reset() {
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
// item with ID keep if it is still visible.
func (l *List[T]) applyFilter(keep string) {
l.visible, l.hits = l.visible[:0], nil
if q := l.input.Value(); q == "" {
for i := range l.items {
l.visible = append(l.visible, i)
l.visible, l.hits, l.hidden = l.visible[:0], nil, 0
shown := make([]int, 0, len(l.items))
for i, v := range l.items {
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 {
// The row's own text is the name; FilterExtra (a note, a provider)
// is context, matched too but ranked lower.
items := make([]match.Item, len(l.items))
for i, v := range l.items {
items := make([]match.Item, len(shown))
for j, i := range shown {
v := l.items[i]
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 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)
}
}
l.visible = append(l.visible, h.Index)
l.visible = append(l.visible, shown[h.Index])
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) {
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"))
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:
body = append(body, st.Muted.Render(" "+l.Empty))
default:
@@ -376,6 +397,9 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
if len(l.visible) > 0 {
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 {
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)
}
}
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)
}
}