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

+55 -7
View File
@@ -56,6 +56,7 @@ const (
slotDescribe // extra metadata for the selected object
slotCopy // fetch a value straight to the clipboard
slotVersions // count the selected object's versions
slotDetails // a vault's, scope's or file's details
slotCount
)
@@ -171,12 +172,11 @@ type Model struct {
clip Clipboard
clipClear time.Duration
openURL func(string) error
// fields is the fourth pane for anything but a secret, with each
// account's groups as they load; lastLeft is the left pane it
// describes while the fourth has focus (fields.go).
// fields is the fourth pane for anything but a secret; lastLeft is
// the left pane it describes while the fourth has focus (fields.go).
fields panes.Fields
lastLeft pane
groups map[string]groupsState
details map[string]detailsState // by containerKey
getenv func(string) string
nvimOpen func(server, path string, line int) error // edit.go
now func() time.Time
@@ -375,7 +375,7 @@ func New(providers []provider.Provider, opts Options) Model {
getenv: os.Getenv,
nvimOpen: nvimOpen,
cache: newCache(),
groups: map[string]groupsState{},
details: map[string]detailsState{},
fields: panes.NewFields(),
}
if m.clip == nil {
@@ -456,6 +456,8 @@ func New(providers []provider.Provider, opts Options) Model {
m.loadCacheFile()
if len(m.sourceResults) > 0 {
m.rebuildSources()
m.sources.Loading = true // reloading what the file had
m.sources.Age = m.cachedAge(m.oldestSources())
}
}
if m.placeDir != "" && opts.Open == nil {
@@ -585,8 +587,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case persistedMsg:
m.handlePersisted(msg)
case groupsMsg:
m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err}
case detailsMsg:
if msg.req == m.reqID[slotDetails] {
m.done(slotDetails)
}
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
delete(m.details, msg.key) // tried again when shown again
} else {
m.details[msg.key] = detailsState{attrs: msg.attrs, err: msg.err}
}
case crawlMsg:
cmds = append(cmds, m.handleCrawl(msg))
@@ -821,6 +830,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
if r, ok := m.sources.Selected(); ok && m.focus == paneContainers && r.kind == rowGroup {
m.focus = paneDetail // an account's row: nothing between
}
case key.Matches(msg, k.Details):
m.focus = paneDetail // what's selected, on the right
case key.Matches(msg, k.PrevPane):
if m.focus == paneDetail {
m.focus = m.lastLeft // back to what it describes
@@ -841,6 +852,12 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
m.focus = paneDetail // its rows are on the right
case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup():
// folded or unfolded a provider group
case m.focus == paneDetail && key.Matches(msg, k.Enter) && m.fieldLink() != "":
r, err := ref.Parse(m.fieldLink())
if err == nil {
f, _ := m.fields.Selected()
m.startNav(r, actGo, f.Row)
}
case key.Matches(msg, k.Enter):
if m.focus < paneDetail {
m.focus++
@@ -1032,6 +1049,7 @@ func (m *Model) refresh() tea.Cmd {
case paneContainers:
if src, ok := m.selectedSource(); ok {
m.containers.Refresh()
m.details = map[string]detailsState{}
return m.loadContainers(src, m.selSource)
}
case paneSecrets:
@@ -1154,6 +1172,7 @@ func (m *Model) showContainers(src provider.Source, key string) tea.Cmd {
return nil
}
m.containers.Refresh()
m.containers.Age = m.cachedAge(e.at)
return m.loadContainers(src, key)
}
@@ -1170,6 +1189,7 @@ func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
return nil
}
m.secrets.Refresh()
m.secrets.Age = m.cachedAge(e.at)
return m.loadObjects(c, m.objType, key)
}
@@ -1299,6 +1319,26 @@ func (m *Model) detailStale() bool {
return hasSecret || shown != m.containers.SelectedID()
}
// cachedAge is how old a list loaded at at is, if it's older than
// cacheTTL: the panes say it while it reloads.
func (m Model) cachedAge(at time.Time) string {
if d := m.now().Sub(at); d >= cacheTTL {
return ago(d)
}
return ""
}
// oldestSources is when the oldest of the cached Sources lists loaded.
func (m Model) oldestSources() time.Time {
var oldest time.Time
for _, at := range m.sourcesAt {
if oldest.IsZero() || at.Before(oldest) {
oldest = at
}
}
return oldest
}
func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
if msg.gen != m.sourcesGen {
return nil
@@ -1738,6 +1778,14 @@ func (m Model) problemText(providerID, err string) string {
return err
}
// fieldLink is the link of the selected Fields row, if it has one.
func (m Model) fieldLink() string {
if f, ok := m.fields.Selected(); ok && m.fields.Shown() {
return f.Link
}
return ""
}
// loginFor is the sign-in L runs: the selected account's, if it needs
// one, else the last one asked for.
func (m Model) loginFor() *provider.AuthError {
+85 -26
View File
@@ -758,7 +758,9 @@ func TestFocusKeys(t *testing.T) {
}{
{"tab", paneContainers}, {"enter", paneSecrets}, {"enter", paneDetail},
{"enter", paneDetail}, {"esc", paneSecrets}, {"shift+tab", paneContainers},
{"4", paneDetail}, {"l", paneSources}, {"h", paneDetail},
{"4", paneDetail}, {"tab", paneSources}, {"shift+tab", paneDetail},
// l: what's selected, on the right; h: back to it.
{"2", paneContainers}, {"l", paneDetail}, {"h", paneContainers}, {"h", paneSources}, {"l", paneDetail},
} {
d.keys(tc.key)
if d.m.focus != tc.want {
@@ -1104,25 +1106,16 @@ func TestAccountInDetails(t *testing.T) {
}
}
// grouped is an account in two groups.
type grouped struct{ described }
func (grouped) Groups(context.Context) ([]string, error) {
return []string{"Platform team", "Vault readers"}, nil
}
func TestAccountPane(t *testing.T) {
t.Parallel()
d := newDriver(t, grouped{described{fake.Azure(0)}})
d.keys("g", "tab") // the account's row, then straight to its pane
d := newDriver(t, described{fake.Azure(0)})
d.keys("g", "l") // the account's row, then its pane
if d.m.focus != paneDetail {
t.Fatalf("focus %v, want the fourth pane", d.m.focus)
}
scr := d.screen()
for _, want := range []string{"Alice Example", "groups 2", "Platform team", "Vault readers"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
if !strings.Contains(scr, "Alice Example") {
t.Errorf("account not shown:\n%s", scr)
}
bar := lastLine(scr)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
@@ -1132,28 +1125,20 @@ func TestAccountPane(t *testing.T) {
if got := d.clip().content; got != "oid-123" {
t.Errorf("y on the object ID copied %q", got)
}
d.keys("G", "y")
if got := d.clip().content; got != "Vault readers" {
t.Errorf("y on a group copied %q", got)
}
// / filters it, labels too.
d.keys("/")
d.typeText("vault")
d.typeText("object")
d.keys("enter")
if scr := d.screen(); !strings.Contains(scr, "Vault readers") || strings.Contains(scr, "Platform team") {
if scr := d.screen(); !strings.Contains(scr, "oid-123") || strings.Contains(scr, "Alice Example") {
t.Errorf("filtered:\n%s", scr)
}
d.keys("y")
if got := d.clip().content; got != "Vault readers" {
t.Errorf("y in the filtered pane copied %q", got)
}
d.keys("esc")
if !strings.Contains(d.screen(), "Platform team") {
if !strings.Contains(d.screen(), "Alice Example") {
t.Errorf("filter not cleared:\n%s", d.screen())
}
d.keys("shift+tab")
d.keys("h")
if d.m.focus != paneSources {
t.Errorf("back to %v, want Sources", d.m.focus)
}
@@ -1236,3 +1221,77 @@ func TestStatusPane(t *testing.T) {
t.Errorf("back to %v", d.m.focus)
}
}
// empty is an account with nothing in it, like Databricks without profiles.
type empty struct{ provider.Provider }
func (empty) Sources(context.Context) ([]provider.Source, error) { return nil, nil }
func TestGetStarted(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{}, signedOut{fake.Azure(0)}, empty{fake.Databricks(0)})
scr := d.screen()
for _, want := range []string{"get started", "Azure az login", "L runs it here",
"databricks auth login --host", "lazysecrets --demo"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
}
d.keys("l", "j")
if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" {
t.Errorf("L signs in to %+v", a)
}
d.keys("y")
if got := d.clip().content; got != "az login" {
t.Errorf("y copied %q", got)
}
// Once an account has something, an empty one says what it needs.
d = newDriverOpts(t, Options{}, fake.Azure(0), empty{fake.Databricks(0)})
d.m.sources.SelectWhere(func(r sourceRow) bool { return r.kind == rowGroup && r.provider == fake.Databricks(0).ID() })
d.send(nil)
if scr := d.screen(); strings.Contains(scr, "get started") || !strings.Contains(scr, "databricks auth login") {
t.Errorf("empty Databricks:\n%s", scr)
}
}
// detailed is an account whose vaults have details.
type detailed struct{ provider.Provider }
func (detailed) Details(_ context.Context, c provider.Container) ([]provider.Attr, error) {
return []provider.Attr{{Label: "access", Value: "Azure roles (RBAC)"}, {Label: "git", Value: "tracked", Warn: true}}, nil
}
func TestContainerDetails(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{}, detailed{fake.Azure(0)})
d.keys("2")
if scr := d.screen(); !strings.Contains(scr, "access Azure roles (RBAC)") || !strings.Contains(scr, "URI") {
t.Errorf("details:\n%s", scr)
}
if st := d.m.details[d.m.containers.SelectedID()]; st.loading || len(st.attrs) != 2 {
t.Errorf("state %+v", st)
}
}
func TestExpiringInStatus(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{ExpiryWarning: 30 * 24 * time.Hour}, fake.Azure(0))
d.keys("3", "]", "]") // load kv-app-prod's certificates
if !strings.Contains(d.screen(), "⚠ 1 expiring") {
t.Errorf("status line:\n%s", d.screen())
}
d.keys("0")
scr := d.screen()
for _, want := range []string{"expiring ✗ expired old-tls · kv-app-prod", "in 1 of 3 vaults (ctrl+a in search loads all)"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
}
d.keys("3", "[", "[", "0", "enter") // elsewhere, then to it from the status pane
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "expiring" })
d.keys("enter")
if _, v, o := d.selected(); v != "kv-app-prod" || o != "old-tls" {
t.Errorf("enter went to %s/%s\n%s", v, o, d.screen())
}
}
+75
View File
@@ -0,0 +1,75 @@
package app
import (
"cmp"
"fmt"
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// The status pane lists what has expired or will soon, across every vault
// loaded (opened, searched, or from the cache): expiry dates come with
// the lists, so this makes no requests. enter on one goes to it.
type expiringItem struct {
o provider.Object
row string // its source's row key, to go to it
}
// expiringItems are the loaded objects that have expired or will within
// the warning period, soonest first.
func (m Model) expiringItems() []expiringItem {
var out []expiringItem
for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources {
row := sourceRowKey(p.ID(), src.ID)
for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row})
}
}
}
}
}
}
slices.SortFunc(out, func(a, b expiringItem) int {
return cmp.Or(a.o.Expires.Compare(b.o.Expires), cmp.Compare(a.o.Name, b.o.Name))
})
return out
}
// expiringFields are the status pane's rows for them, and how much of
// everything they cover.
func (m Model) expiringFields() []panes.Field {
items := m.expiringItems()
var out []panes.Field
for _, it := range items {
state, left := m.expiry(it.o)
mark := "⚠ " + daysLeft(left)
if state == expired {
mark = "✗ expired"
}
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
if r, err := ref.ForObject(it.o, ""); err == nil {
f.Link = r.String()
}
out = append(out, f)
}
cov := m.coverageOf(provider.KindAzure)
switch {
case cov.vaults == 0 && len(items) == 0:
return nil
case !cov.complete():
out = append(out, panes.Field{Value: fmt.Sprintf("in %d of %d vaults", cov.vaultsLoaded, cov.vaults) +
withKey(m.keys.SearchLoadAll, " (%s in search loads all)"), Info: true})
case len(items) == 0:
out = append(out, panes.Field{Value: fmt.Sprintf("nothing within %d days", int(m.expiryWarning.Hours()/24)), Info: true})
}
out[0].Label = "expiring"
return out
}
+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)
}
+66
View File
@@ -0,0 +1,66 @@
package app
import (
"errors"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// When no account has anything to show, as on a first run, the right
// pane says how to get started: the sign-in for each kind of account
// (y copies it, L runs az login), and the demo.
// nothingFound reports whether every account has loaded, with nothing.
func (m Model) nothingFound() bool {
if m.sourcesPending > 0 {
return false
}
for _, p := range m.providers {
if len(m.sourceResults[p.ID()].sources) > 0 {
return false
}
}
return true
}
// getStartedFields are the rows saying how to get started.
func (m Model) getStartedFields() []panes.Field {
out := []panes.Field{{Value: "Nothing to show yet: sign in where your secrets are.", Info: true}}
seen := map[provider.Kind]bool{}
for _, p := range m.providers {
if seen[p.Kind()] {
continue
}
seen[p.Kind()] = true
f := emptyHint(p.Kind())
var auth *provider.AuthError
switch err := m.sourceResults[p.ID()].err; {
case errors.As(err, &auth) && len(auth.Login) > 0:
f.Provider = p.ID() // L signs in
out = append(out, f, panes.Field{Value: withKey(m.keys.Login, "%s runs it here"), Info: true})
case err != nil:
out = append(out, f, panes.Field{Value: err.Error(), Info: true, Err: true})
default:
out = append(out, f)
}
}
return append(out,
panes.Field{Label: "try", Value: "lazysecrets --demo"},
panes.Field{Value: "built-in sample data, to look around", Info: true},
panes.Field{Label: "docs", Value: "getting started", URL: update.DefaultRepo + "#get-started"},
)
}
// emptyHint is how an account of kind gets something to show.
func emptyHint(kind provider.Kind) panes.Field {
switch kind {
case provider.KindDatabricks:
return panes.Field{Label: "Databricks", Value: "databricks auth login --host https://<workspace>"}
case provider.KindRepo:
return panes.Field{Label: "repos", Value: "SOPS and .env files in your git repos",
URL: update.DefaultRepo + "/src/branch/main/docs/repos.md"}
}
return panes.Field{Label: string(kind), Value: "az login"}
}
+26
View File
@@ -39,6 +39,9 @@ func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
// Next run, with a backend too slow to answer during the test: the
// lists show from the file, and the cursor is where it was.
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
if strings.Contains(d2.screen(), "cached") {
t.Errorf("a fresh cache said its age:\n%s", d2.screen())
}
d2.keys("2")
if _, v, _ := d2.selected(); v != vault {
t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen())
@@ -49,6 +52,29 @@ func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
}
}
// Lists from an older cache say how old they are while they reload.
func TestCacheFileSaysItsAge(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "cache.json")
d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0))
d.keys("3")
at := time.Now().Add(-3 * time.Hour)
for k, e := range d.m.cache.containers {
d.m.cache.containers[k] = entry[[]provider.Container]{e.value, at}
}
for id := range d.m.sourcesAt {
d.m.sourcesAt[id] = at
}
if err := d.m.Persist(); err != nil {
t.Fatal(err)
}
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
d2.keys("2")
if n := strings.Count(d2.screen(), "cached 3h ago"); n != 2 {
t.Errorf("Sources and Vaults should say they're from 3h ago (%d do):\n%s", n, d2.screen())
}
}
func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "cache.json")
+5
View File
@@ -38,6 +38,9 @@ func (m Model) statusLine() string {
failing++
}
}
if n := len(m.expiringItems()); n > 0 {
parts = append(parts, m.st.Warning.Render(fmt.Sprintf("⚠ %d expiring", n)))
}
if accounts > 0 {
s := plural(accounts, "account")
if failing > 0 {
@@ -88,6 +91,8 @@ func (m Model) statusFields() []panes.Field {
add(f)
}
out = append(out, m.expiringFields()...)
for i, path := range m.configFiles {
label := ""
if i == 0 {
+4 -3
View File
@@ -36,7 +36,7 @@ const (
type Map struct {
// lists
Up, Down, Top, Bottom key.Binding
NextPane, PrevPane key.Binding
NextPane, PrevPane, Details key.Binding
Pane0, Pane1, Pane2, Pane3, Pane4 key.Binding
NextTab, PrevTab key.Binding
Enter, Back, Filter key.Binding
@@ -87,7 +87,8 @@ func (m *Map) Actions() []Action {
a(Lists, "top", &m.Top, "top", "move", "Jump to the top of the focused list."),
a(Lists, "bottom", &m.Bottom, "bottom", "move", "Jump to the bottom of the focused list."),
a(Lists, "nextPane", &m.NextPane, "next pane", "move", "Focus the next pane: Sources, Vaults, Secrets, detail."),
a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane."),
a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane; from the detail pane, the one it describes."),
a(Lists, "details", &m.Details, "details", "move", "Focus the detail pane on the right, on what's selected: a source, vault, file or secret."),
a(Lists, "pane1", &m.Pane1, "Sources pane", "move", "Focus the Sources pane."),
a(Lists, "pane2", &m.Pane2, "Vaults pane", "move", "Focus the Vaults (or Scopes) pane."),
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
@@ -178,7 +179,7 @@ func (m *Map) Actions() []Action {
var defaults = map[string][]string{
"lists.up": {"k", "up", "ctrl+k", "ctrl+p"}, "lists.down": {"j", "down", "ctrl+j", "ctrl+n"},
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
"lists.nextPane": {"tab", "l", "right"}, "lists.prevPane": {"shift+tab", "h", "left"},
"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.enter": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
+4 -1
View File
@@ -20,12 +20,15 @@ type Fields struct {
// Field is a row: a value under a label. Info rows (loading, errors)
// can't be copied; Err ones are errors. A row can also be opened: URL in
// the browser (o), Path in the editor (e), or Provider signed in to (L).
// the browser (o), Path in the editor (e), Provider signed in to (L), or
// Link gone to (enter).
type Field struct {
Label, Value string
Info, Err bool
URL, Path, Provider string
// Link is a lazysecrets link enter goes to, in the source of row Row.
Link, Row string
}
func NewFields() Fields {
+27 -1
View File
@@ -34,6 +34,9 @@ type List[T any] struct {
Empty string // shown when loaded with no items
Loading bool
Err error
// Age says how old the items are ("2h ago") while they reload, by
// the counter.
Age string
item Item[T]
items []T
@@ -88,6 +91,7 @@ func (l *List[T]) SetError(err error) {
func (l *List[T]) Refresh() {
l.Loading = true
l.Err = nil
l.Age = ""
}
func (l *List[T]) SetLoading() {
@@ -330,7 +334,7 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
case l.Loading && len(l.items) == 0:
body = append(body, " "+spin+" loading…")
case l.Err != nil:
for _, line := range strings.Split(ansi.Wrap(l.Err.Error(), inner-1, " -/"), "\n") {
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:
@@ -369,6 +373,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.Loading && l.Age != "" && len(l.items) > 0 {
counter = "cached " + l.Age + " · " + counter
}
return BoxCounted(st, title, "", counter, body, l.w, l.h, focused)
}
@@ -386,3 +393,22 @@ func (l *List[T]) scroll() {
}
l.offset = max(0, min(l.offset, len(l.visible)-rows))
}
// wrapLines wraps text to width, line by line; a "• " item's further
// lines line up under its text.
func wrapLines(text string, width int) []string {
var out []string
for _, line := range strings.Split(text, "\n") {
indent := ""
if strings.HasPrefix(line, "• ") {
indent = " "
}
for i, l := range strings.Split(ansi.Wrap(line, max(1, width-len(indent)), " -/"), "\n") {
if i > 0 {
l = indent + l
}
out = append(out, l)
}
}
return out
}