feat: l for details; details for vaults, scopes and files; getting started; the age of cached lists (#17)

- `l` goes to the details of what is selected; `enter` still drills in
- vaults, scopes and files show their details (vault settings from the listing, no extra requests)
- with nothing to show, the right pane says how to get started
- a list from the cache says how old it is while it reloads
- what is expiring, in the status pane and `lazysecrets expiring`
- when access is refused, what to check, for Azure and Databricks
- no group list in an account's details, and no Microsoft Graph call for it

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit was merged in pull request #17.
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-11 14:24:08 +02:00
1 parent 9b0329e9da
commit 09bacd8438
32 files changed
+1321 -254

No files matched your search

+63 -55
View File
@@ -1,7 +1,6 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
@@ -14,19 +13,22 @@ import (
)
// The fourth pane shows Fields for anything but a secret. On a provider's
// row: its account, from what the provider knows locally, then the
// groups the account is in, loaded once a session when first shown.
// row: its account, from what the provider knows locally.
type groupsState struct {
names []string
// A vault's, scope's or file's details beyond its listing load once a
// session, when the cursor rests on it, and again with refresh. They're
// extra: moving on cancels the load, and search never waits for them.
type detailsState struct {
attrs []provider.Attr
err error
loading bool
}
type groupsMsg struct {
provider string
names []string
err error
type detailsMsg struct {
req int
key string
attrs []provider.Attr
err error
}
// detailOf is the pane the fourth pane describes: the one in focus, or,
@@ -41,7 +43,6 @@ func (m Model) detailOf() pane {
// syncFields shows the Fields pane for what the focused pane has selected,
// unless that's a secret (or a vault with the Secrets pane in focus), which
// the secret pane shows, as it does a revealed value whatever the focus.
// An account's groups load the first time.
func (m *Model) syncFields() tea.Cmd {
if m.detail.State() != panes.ValueHidden {
// A value revealed (or being fetched) from any pane stays in
@@ -53,12 +54,14 @@ func (m *Model) syncFields() tea.Cmd {
case paneSources:
r, ok := m.sources.Selected()
switch {
case m.nothingFound():
m.fields.Show("get started", m.getStartedFields())
case !ok:
m.fields.Show("", nil)
case m.selectedProblem() != "":
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup:
return m.showAccount(r)
m.showAccount(r)
case r.kind == rowSource:
m.fields.Show(r.source.Name, m.sourceFields(r.source))
default:
@@ -68,7 +71,9 @@ func (m *Model) syncFields() tea.Cmd {
m.fields.Show("lazysecrets", m.statusFields())
case paneContainers:
if c, ok := m.containers.Selected(); ok {
cmd := m.loadDetails(c)
m.fields.Show(c.Name, m.containerFields(c))
return cmd
} else {
m.fields.Show("", nil)
}
@@ -78,9 +83,8 @@ func (m *Model) syncFields() tea.Cmd {
return nil
}
// showAccount shows a provider's account, loading its groups the first
// time.
func (m *Model) showAccount(r sourceRow) tea.Cmd {
// showAccount shows a provider's account.
func (m *Model) showAccount(r sourceRow) {
p := m.byID[r.provider]
var fields []panes.Field
if a, ok := p.(provider.Accounts); ok {
@@ -90,24 +94,11 @@ func (m *Model) showAccount(r sourceRow) tea.Cmd {
}
}
}
var cmd tea.Cmd
if g, ok := p.(provider.AccountGroups); ok {
st, seen := m.groups[r.provider]
if !seen {
st = groupsState{loading: true}
m.groups[r.provider] = st
id, timeout := r.provider, m.timeout
cmd = func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
names, err := g.Groups(ctx)
return groupsMsg{provider: id, names: names, err: err}
}
}
fields = append(fields, groupFields(st)...)
if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 {
hint := emptyHint(p.Kind())
fields = append(fields, panes.Field{Label: "nothing", Value: "found: try", Info: true}, panes.Field{Value: hint.Value, URL: hint.URL})
}
m.fields.Show(p.Label(), fields)
return cmd
}
// sourceFields describe a subscription, workspace or repo.
@@ -171,9 +162,48 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
}
}
for _, a := range c.Attrs {
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
}
switch st := m.details[containerKey(c)]; {
case st.loading:
out = append(out, panes.Field{Label: "details", Value: "loading…", Info: true})
case errors.Is(st.err, provider.ErrUnsupported):
case st.err != nil:
out = append(out, panes.Field{Label: "details", Value: "couldn't load them: " + st.err.Error(), Info: true, Err: true})
}
for _, a := range m.details[containerKey(c)].attrs {
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
}
return out
}
// loadDetails loads c's details, if its provider has them and they
// aren't loaded yet.
func (m *Model) loadDetails(c provider.Container) tea.Cmd {
d, ok := m.providerFor(c.Source).(provider.ContainerDetails)
key := containerKey(c)
if _, seen := m.details[key]; !ok || seen {
return nil
}
// One at a time: the previous one, if it's still loading, is
// cancelled and forgotten, to load when it's shown again.
for k, st := range m.details {
if st.loading {
delete(m.details, k)
}
}
m.details[key] = detailsState{loading: true}
ctx, req := m.begin(slotDetails)
return m.settled(ctx, func() tea.Msg {
attrs, err := d.Details(ctx, c)
if ctx.Err() != nil {
err = ctx.Err()
}
return detailsMsg{req: req, key: key, attrs: attrs, err: err}
})
}
// problemFields show why an account has nothing to show, wrapped to the
// pane, in red.
func (m Model) problemFields(problem string) []panes.Field {
@@ -185,30 +215,8 @@ func (m Model) problemFields(problem string) []panes.Field {
return out
}
// fieldName names a copied field: its label, or for a group, the group.
// fieldName names a copied field: its label, or a row of several under
// one label (a tag), its value.
func fieldName(f panes.Field) string {
if f.Label == "" || strings.HasPrefix(f.Label, "groups") {
return "group " + f.Value
}
return f.Label
}
// groupFields are the groups' rows: labelled on the first.
func groupFields(st groupsState) []panes.Field {
switch {
case st.loading:
return []panes.Field{{Label: "groups", Value: "loading…", Info: true}}
case errors.Is(st.err, provider.ErrUnsupported):
return nil
case st.err != nil:
return []panes.Field{{Label: "groups", Value: "couldn't list them: " + st.err.Error(), Info: true}}
case len(st.names) == 0:
return []panes.Field{{Label: "groups", Value: "none", Info: true}}
}
out := make([]panes.Field, len(st.names))
for i, n := range st.names {
out[i] = panes.Field{Value: n}
}
out[0].Label = fmt.Sprintf("groups %d", len(st.names))
return out
return cmpOr(f.Label, f.Value)
}