From 4d6db97d54e8dd7365047b6442bb2a28545ca750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 13:35:11 +0200 Subject: [PATCH] feat: a list from the cache says how old it is while it reloads Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/config.md | 5 +++-- internal/ui/app/app.go | 24 ++++++++++++++++++++++++ internal/ui/app/persist_test.go | 26 ++++++++++++++++++++++++++ internal/ui/panes/list.go | 7 +++++++ 4 files changed, 60 insertions(+), 2 deletions(-) diff --git a/docs/config.md b/docs/config.md index 5fc5344..c44a577 100644 --- a/docs/config.md +++ b/docs/config.md @@ -114,8 +114,9 @@ What lazysecrets has loaded is kept in you: the Sources lists, vaults and scopes, and secrets' names and metadata (tags, dates, references), never values. On the next start they show at once, and anything older than two minutes reloads in the background when -you go to it, or when Go to searches it. Revealing and copying always -fetch the value live. +you go to it, or when Go to searches it. While it does, the pane's +bottom border says how old what it shows is (`cached 3h ago`). Revealing +and copying always fetch the value live. Lists not loaded for 30 days are dropped, and so is everything from an account you're no longer signed in to. Repos aren't cached: their files diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 7b0880c..b4b68cb 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -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 { @@ -1154,6 +1156,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 +1173,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 +1303,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 diff --git a/internal/ui/app/persist_test.go b/internal/ui/app/persist_test.go index 26cc9dc..e1c1b6c 100644 --- a/internal/ui/app/persist_test.go +++ b/internal/ui/app/persist_test.go @@ -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") diff --git a/internal/ui/panes/list.go b/internal/ui/panes/list.go index 21252b2..aa685fd 100644 --- a/internal/ui/panes/list.go +++ b/internal/ui/panes/list.go @@ -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() { @@ -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) }