feat: H shows what's hidden; Spring keys that don't look secret are hidden (#81)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
This commit was merged in pull request #81.
This commit is contained in:
1 parent
17da9eaa32
commit
913b3db6a9
19 files changed
+323
-74
No files matched your search
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user