fix: review findings for lazysecrets list: recent means the last 90 days, cache saves lock and keep deletions and the TUI's cursors, failed lists aren't retried, one expiry rule
CI / Checks (pull_request) Successful in 40s
PR title / Conventional title (pull_request) Successful in 2s

This commit is contained in:
keb committed 2026-10-14 21:24:01 +02:00
1 parent ea58530068
commit db4d64012d
23 files changed
+333 -107

No files matched your search

+8 -5
View File
@@ -213,9 +213,12 @@ type Model struct {
statusW int // the status pane's width: the left column's, zoomed or not
changelog []panes.Field
cacheDirty bool
sourcesAt map[string]time.Time
placeDir string
savedPlace string // the link last saved as the place
// cacheDropped are cache keys deleted since the last save, so saving
// deletes them from the file too.
cacheDropped map[string]bool
sourcesAt map[string]time.Time
placeDir string
savedPlace string // the link last saved as the place
// A pending clear of a copied value, counted down in the footer.
// clipToken identifies the latest copy so older ticks are ignored.
@@ -688,7 +691,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if src, ok := m.selectedSource(); ok {
m.logErrOf(src.Name, msg.err)
}
delete(m.cache.containers, msg.key)
uncache(&m, m.cache.containers, msg.key)
m.containers.SetError(msg.err)
} else {
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
@@ -709,7 +712,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if c, ok := m.containers.Selected(); ok {
m.logErrOf(c.Name, msg.err)
}
delete(m.cache.objects, msg.key)
uncache(&m, m.cache.objects, msg.key)
m.secrets.SetError(msg.err)
} else {
if msg.bound != nil {
+14 -1
View File
@@ -67,10 +67,23 @@ func (m *Model) refreshShown(before []provider.Object) tea.Cmd {
return nil
}
m.detail.Refresh(s)
delete(m.cache.details, detailKey(s))
uncache(m, m.cache.details, detailKey(s))
return m.describe(s)
}
// uncache deletes key from a cache, and, at the next save, from the file.
func uncache[T any](m *Model, c map[string]entry[T], key string) {
if _, ok := c[key]; !ok {
return
}
delete(c, key)
if m.cacheDropped == nil {
m.cacheDropped = map[string]bool{}
}
m.cacheDropped[key] = true
m.cacheDirty = true
}
// sameListing reports whether a and b list the same: what a list
// says of an object, not what describing it adds.
func sameListing(a, b provider.Object) bool {
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -28,7 +29,7 @@ func (m Model) expiringItems() []expiringItem {
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 {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row})
}
+8 -22
View File
@@ -1,9 +1,10 @@
package app
import (
"fmt"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
@@ -11,28 +12,18 @@ import (
// list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState int
type expiryState = expiry.State
const (
notExpiring expiryState = iota
expiresSoon
expired
notExpiring = expiry.None
expiresSoon = expiry.Soon
expired = expiry.Expired
)
// expiry tells whether o has expired or will within the warning period,
// and how long it has left.
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
if o.Expires.IsZero() {
return notExpiring, 0
}
left := o.Expires.Sub(m.now())
switch {
case left <= 0:
return expired, 0
case left <= m.expiryWarning:
return expiresSoon, left
}
return notExpiring, left
return expiry.Of(o.Expires, m.now(), m.expiryWarning)
}
// expiryWord is matched by the filter: "expired" or "expiring".
@@ -47,9 +38,4 @@ func (m Model) expiryWord(o provider.Object) string {
}
// daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string {
if d < 24*time.Hour {
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
func daysLeft(d time.Duration) string { return expiry.Left(d) }
+7 -5
View File
@@ -3,9 +3,11 @@ package app
import (
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
@@ -24,12 +26,12 @@ const recentMark = "◷ "
// marks is what goes before a list row's name: ★ when it's starred for
// account and, with recent, ◷ when it was used lately.
func marks(st styles.Styles, n store.Node, account string, recent bool) string {
func marks(st styles.Styles, n store.Node, account string, recent bool, now time.Time) string {
var s string
if n.StarredFor(account) {
s = st.Favourite.Render(star)
}
if recent && !n.LastUsed.IsZero() {
if recent && n.UsedLately(now) {
s += st.Muted.Render(recentMark)
}
return s
@@ -110,7 +112,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) + line
line = marks(sty, n, c.Source.Provider, m.recentIcon, m.now()) + line
if withSource {
src := c.Source.Name
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
@@ -130,7 +132,7 @@ func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
if o.Enabled != nil && !*o.Enabled {
line = sty.Muted.Render(o.Name + " (disabled)")
}
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon) + line
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
switch state, left := m.expiry(o); state {
case expired:
line += " " + sty.Error.Render("✗ expired")
@@ -179,7 +181,7 @@ func (m *Model) rebind(c provider.Container) string {
m.linked[i].Source = c.Source
}
}
key := containerKey(c) + "|" + string(m.objType)
key := cachefile.ObjectsKey(c, m.objType)
if m.onLinks() {
m.containers.SetItems(m.linked)
m.containers.Select(containerKey(c))
+2 -1
View File
@@ -8,6 +8,7 @@ import (
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
@@ -160,7 +161,7 @@ func (m Model) containerFields(c provider.Container) []panes.Field {
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL)
for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok {
if e, ok := m.cache.objects[cachefile.ObjectsKey(c, t)]; ok {
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
}
}
+1 -1
View File
@@ -107,7 +107,7 @@ func (m *Model) storeEntries() (favs, recent []option) {
if m.history {
var used []string
for _, k := range keys {
if !nodes[k].LastUsed.IsZero() {
if nodes[k].UsedLately(m.now()) {
used = append(used, k)
}
}
+3 -9
View File
@@ -33,14 +33,7 @@ type persistedMsg struct{ err error }
// isn't a repo: every key starts with the provider ID.
func (m *Model) keepCached(key string) bool { return m.keepProvider(cachefile.ProviderOf(key)) }
func (m *Model) keepProvider(id string) bool { return keepIn(m.byID)(id) }
func keepIn(byID map[string]provider.Provider) func(id string) bool {
return func(id string) bool {
p, ok := byID[id]
return ok && p.Kind() != provider.KindRepo
}
}
func (m *Model) keepProvider(id string) bool { return cachefile.Keep(m.providers)(id) }
// loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more.
@@ -79,6 +72,7 @@ func (m *Model) loadCacheFile() {
// cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cachefile.File {
f := cachefile.New()
f.Dropped, m.cacheDropped = m.cacheDropped, nil
for id, r := range m.sourceResults {
if r.err == nil && m.keepProvider(id) {
f.Sources[id] = cachefile.Stamped[[]provider.Source]{At: m.sourcesAt[id], Value: r.sources}
@@ -110,7 +104,7 @@ func (m *Model) cacheSnapshot() cachefile.File {
// saveCache saves f, merged with the file (lazysecrets list may have
// loaded more since), from any goroutine.
func (m *Model) saveCache(f cachefile.File) func() error {
path, keep, now := m.cachePath, keepIn(m.byID), m.now()
path, keep, now := m.cachePath, cachefile.Keep(m.providers), m.now()
return func() error { return cachefile.Save(path, f, keep, now) }
}
+7 -6
View File
@@ -10,6 +10,7 @@ import (
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
@@ -89,7 +90,7 @@ func (m *Model) searchEntries() (sources, items []option) {
return nil
}, reload)
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
for _, o := range m.cache.objects[cachefile.ObjectsKey(c, t)].value {
r, err := ref.ForObject(o, "")
if err != nil {
continue
@@ -166,7 +167,7 @@ func (m *Model) coverageOf(in providerFilter) coverage {
}
for _, v := range e.value {
c.vaults++
key := containerKey(v) + "|" + string(provider.TypeSecret)
key := cachefile.ObjectsKey(v, provider.TypeSecret)
if objs, ok := m.cache.objects[key]; ok {
c.vaultsLoaded++
c.secrets += len(objs.value)
@@ -263,7 +264,7 @@ func (m *Model) queueSource(src provider.Source, force bool) {
// queueContainer queues loading c's secrets, unless loaded lately and not
// force.
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
key := containerKey(c) + "|" + string(provider.TypeSecret)
key := cachefile.ObjectsKey(c, provider.TypeSecret)
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
return
}
@@ -327,7 +328,7 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
m.crawl.active--
key := msg.job.row
if msg.job.container != nil {
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
key = cachefile.ObjectsKey(*msg.job.container, provider.TypeSecret)
}
delete(m.crawl.pending, key)
switch {
@@ -365,7 +366,7 @@ func (m *Model) sourceMark(row string) string {
}
loaded, loading := 0, false
for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret)
key := cachefile.ObjectsKey(c, provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
@@ -386,7 +387,7 @@ func (m *Model) sourceMark(row string) string {
// (✗) or are loading (◌): whether a subscription is loaded says whether
// its vaults are searchable.
func (m *Model) vaultMark(c provider.Container) string {
key := containerKey(c) + "|" + string(provider.TypeSecret)
key := cachefile.ObjectsKey(c, provider.TypeSecret)
switch {
case m.crawl.pending[key]:
return m.st.Muted.Render("◌")
+2 -1
View File
@@ -12,6 +12,7 @@ import (
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
@@ -617,7 +618,7 @@ func (m Model) loadHint() string {
}
loaded := 0
for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret)
key := cachefile.ObjectsKey(c, provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
+2 -1
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"slices"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
@@ -80,7 +81,7 @@ func newSourcesList(iconSet icons.Set, mem *store.Store, recent bool) panes.List
kind := st.Kind(r.group)
starred := ""
if r.kind == rowSource {
starred = marks(st, node, r.source.Provider, recent)
starred = marks(st, node, r.source.Provider, recent, time.Now())
}
switch {
case r.kind == rowLinks: