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()