From db8ce90ee7a1e1940063b15fa5fd57c7ed7ecd66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Tue, 13 Oct 2026 14:50:59 +0200 Subject: [PATCH] fix: the details pane follows its secret's list when it reloads A cached list is shown at once and reloaded in the background; the selected secret's details stayed as first shown, old web page included, until another secret was selected. When the reloaded list has it changed, the details pane takes the new listing (a value shown stays shown) and describes it afresh: its cached details came from the old one. Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/app/app.go | 2 ++ internal/ui/app/cache.go | 32 ++++++++++++++++++++++++++++++++ internal/ui/app/cache_test.go | 31 +++++++++++++++++++++++++++++++ internal/ui/panes/detail.go | 10 ++++++++++ 4 files changed, 75 insertions(+) diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index c410c57..e023152 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -679,12 +679,14 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.bound != nil { msg.key = m.rebind(*msg.bound) } + before := m.cache.objects[msg.key].value m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()} m.cacheDirty = true if m.versionsOf != nil { m.setVersions(msg.items) } else { m.secrets.SetItems(m.starredFirst(msg.items)) + cmds = append(cmds, m.refreshShown(before)) } } diff --git a/internal/ui/app/cache.go b/internal/ui/app/cache.go index 3bfe3ab..2de8090 100644 --- a/internal/ui/app/cache.go +++ b/internal/ui/app/cache.go @@ -1,8 +1,12 @@ package app import ( + "maps" + "slices" "time" + tea "charm.land/bubbletea/v2" + "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -48,3 +52,31 @@ func (c cache) rememberCursor(parent, child string) { c.cursor[parent] = child } } + +// refreshShown shows the selected secret's newer metadata when its list +// reloaded with it changed from before (a cached list refreshed in the +// background), and describes it afresh: its cached details came from +// the old listing. +func (m *Model) refreshShown(before []provider.Object) tea.Cmd { + s, ok := m.secrets.Selected() + if !ok || m.secrets.SelectedID() != m.selSecret { + return nil // a new selection is shown as usual + } + i := slices.IndexFunc(before, func(o provider.Object) bool { return o.ID == s.ID }) + if i < 0 || sameListing(before[i], s) { + return nil + } + m.detail.Refresh(s) + delete(m.cache.details, detailKey(s)) + return m.describe(s) +} + +// 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 { + enabled := func(e *bool) bool { return e == nil || *e } + return a.Name == b.Name && a.Version == b.Version && enabled(a.Enabled) == enabled(b.Enabled) && + a.Updated.Equal(b.Updated) && a.Expires.Equal(b.Expires) && a.ContentType == b.ContentType && + maps.Equal(a.Tags, b.Tags) && a.Reference == b.Reference && a.URL == b.URL && + slices.Equal(a.Attrs, b.Attrs) +} diff --git a/internal/ui/app/cache_test.go b/internal/ui/app/cache_test.go index 5047d06..f9b1840 100644 --- a/internal/ui/app/cache_test.go +++ b/internal/ui/app/cache_test.go @@ -2,6 +2,7 @@ package app import ( "context" + "slices" "testing" "time" @@ -159,3 +160,33 @@ func TestMovingOnDropsUnsettledLoads(t *testing.T) { t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before) } } + +func TestReloadedListRefreshesShownSecret(t *testing.T) { + t.Parallel() + d, _, clock := newCachingDriver(t) + d.keys("3") // kv-app-prod's secrets, db-password selected + shown, _ := d.m.detail.Secret() + // What an older lazysecrets listed and cached for it. + const old = "https://portal.example/old" + key := d.m.selContainer + e := d.m.cache.objects[key] + items := slices.Clone(e.value) + for i := range items { + if items[i].ID == shown.ID { + items[i].URL = old + } + } + d.m.cache.objects[key] = entry[[]provider.Object]{items, e.at} + stale := shown + stale.URL = old + d.m.cache.details[detailKey(shown)] = entry[provider.Object]{stale, *clock} + + // Away and back once the list is stale: it's shown from the cache, + // then reloaded, and the details pane follows. + d.keys("1", "j") + *clock = clock.Add(cacheTTL + time.Second) + d.keys("k", "3") + if got, _ := d.m.detail.Secret(); got.ID != shown.ID || got.URL == old { + t.Errorf("shown %s with %q after the reload", got.Name, got.URL) + } +} diff --git a/internal/ui/panes/detail.go b/internal/ui/panes/detail.go index 4876daf..5c1e782 100644 --- a/internal/ui/panes/detail.go +++ b/internal/ui/panes/detail.go @@ -60,6 +60,16 @@ func (d *Detail) ShowSecret(s provider.Object) { d.container = &s.Container } +// Refresh shows s's newer metadata if s is the secret shown, keeping +// its account and any value shown or loading. +func (d *Detail) Refresh(s provider.Object) { + if d.secret == nil || d.secret.ID != s.ID { + return + } + s.Container = d.secret.Container + d.secret = &s +} + // ShowContainer shows a container summary when no secret is selected. func (d *Detail) ShowContainer(c provider.Container) { d.Clear()