feat: keep what was loaded between runs, and start where you left off

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-11 09:32:55 +02:00
1 parent 6fab41703e
commit d6c4b33d1d
11 files changed
+506 -13

No files matched your search

+9
View File
@@ -40,6 +40,9 @@ type Config struct {
// background; defaults to true. U and lazysecrets update work either
// way.
UpdateCheck *bool `yaml:"updateCheck"`
// Cache keeps what was loaded (never values) between runs, so lists
// show at once and refresh in the background; defaults to true.
Cache *bool `yaml:"cache"`
// CollapseGroups starts the Sources pane with its groups folded;
// defaults to true.
CollapseGroups *bool `yaml:"collapseGroups"`
@@ -119,6 +122,12 @@ func Dir() (string, error) {
return xdgDir("XDG_CONFIG_HOME", ".config")
}
// CacheDir is $XDG_CACHE_HOME/lazysecrets, defaulting to
// ~/.cache/lazysecrets. It holds what was loaded, to show it at once.
func CacheDir() (string, error) {
return xdgDir("XDG_CACHE_HOME", ".cache")
}
// StateDir is $XDG_STATE_HOME/lazysecrets, defaulting to
// ~/.local/state/lazysecrets. It holds data the app writes, like favourites.
func StateDir() (string, error) {
+41
View File
@@ -69,6 +69,33 @@ func (s *Store) SetUpdateCheck(c UpdateCheck) error {
return s.save(func(d *file) { d.Update = c })
}
// Place is where lazysecrets was left when it last ran in dir.
func (s *Store) Place(dir string) (Place, bool) {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.data.Places[dir]
return p, ok
}
// SetPlace remembers where lazysecrets was left in dir.
func (s *Store) SetPlace(dir string, p Place) error {
return s.save(func(d *file) {
if d.Places == nil {
d.Places = map[string]Place{}
}
d.Places[dir] = p
for len(d.Places) > maxPlaces {
oldest := ""
for k, v := range d.Places {
if oldest == "" || v.At.Before(d.Places[oldest].At) {
oldest = k
}
}
delete(d.Places, oldest)
}
})
}
// Layout is the user's pane sizing, as offsets from the default layout so
// it adapts to the terminal size.
type Layout struct {
@@ -84,8 +111,22 @@ type file struct {
Flags map[string]bool `json:"flags,omitempty"`
// Update is the last check for a newer release.
Update UpdateCheck `json:"update,omitzero"`
// Places is where lazysecrets was left, by the folder it ran in.
Places map[string]Place `json:"places,omitempty"`
}
// Place is what was selected when lazysecrets quit: an item's link, and
// the account and source it was read through.
type Place struct {
Link string `json:"link"`
Account string `json:"account"`
Source string `json:"source"`
At time.Time `json:"at"`
}
// maxPlaces bounds Places: the folders used most recently are kept.
const maxPlaces = 100
// UpdateCheck is the last look for a newer release: when, and what the
// latest was (its tag, changelog and page), so the next start within a
// day shows it without asking again.
+40 -1
View File
@@ -68,6 +68,12 @@ type Options struct {
ClipboardClear time.Duration
// OpenURL opens a web page; defaults to the system browser.
OpenURL func(url string) error
// CacheFile keeps what was loaded between runs; "" keeps it for this
// run only.
CacheFile string
// Dir is the folder lazysecrets runs in: it goes back to where it was
// left there, unless it was started on a link. "" doesn't.
Dir string
// FlatSources lists sources without provider groups.
FlatSources bool
// CollapseGroups starts with the Sources pane's groups folded, when
@@ -156,7 +162,13 @@ type Model struct {
clip Clipboard
clipClear time.Duration
openURL func(string) error
now func() time.Time
// The cache file, whether the cache changed since it was saved, and
// when each provider's Sources list was loaded (persist.go).
cachePath string
cacheDirty bool
sourcesAt map[string]time.Time
placeDir string
now func() time.Time
// A pending clear of a copied value, counted down in the footer.
// clipToken identifies the latest copy so older ticks are ignored.
@@ -410,6 +422,17 @@ func New(providers []provider.Provider, opts Options) Model {
m.sourcesGen = 1
m.sourcesPending = len(providers)
m.spinning = true
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
if m.cachePath != "" {
m.loadCacheFile()
if len(m.sourceResults) > 0 {
m.rebuildSources()
}
}
if m.placeDir != "" && opts.Open == nil {
m.restorePlace()
}
return m
}
@@ -433,6 +456,9 @@ func (m *Model) setVersions(items []provider.Object) {
func (m Model) Init() tea.Cmd {
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()}
if m.cachePath != "" {
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
}
if m.fetchOnStart {
cmds = append(cmds, m.fetchLatest(false))
}
@@ -522,6 +548,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case commandDoneMsg:
m.handleCommandDone(msg)
case persistTickMsg:
cmds = append(cmds, m.persistTick())
case persistedMsg:
m.handlePersisted(msg)
case crawlMsg:
cmds = append(cmds, m.handleCrawl(msg))
@@ -552,6 +584,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.containers.SetError(msg.err)
} else {
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
m.cacheDirty = true
m.containers.SetItems(m.starredContainersFirst(msg.items))
}
@@ -572,6 +605,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
msg.key = m.rebind(*msg.bound)
}
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
m.cacheDirty = true
if m.versionsOf != nil {
m.setVersions(msg.items)
} else {
@@ -601,6 +635,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Errors and unsupported providers just leave the count out.
if msg.err == nil {
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
m.cacheDirty = true
m.detail.SetVersions(msg.id, msg.items)
}
@@ -614,6 +649,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.detail.SetDescribeError(msg.err)
} else {
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
m.cacheDirty = true
m.detail.SetDescribed(msg.obj)
}
@@ -1203,6 +1239,9 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
}
m.sourcesPending--
m.sourceResults[msg.provider] = msg
if msg.err == nil {
m.sourcesAt[msg.provider], m.cacheDirty = m.now(), true
}
var errs []string
for _, p := range m.providers {
+2 -2
View File
@@ -10,8 +10,8 @@ import (
// entries are still shown immediately, then reloaded in the background.
const cacheTTL = 2 * time.Minute
// cache holds metadata for this session only. It never holds secret values
// or errors, and is never written to disk.
// cache holds metadata: never secret values or errors. It's kept between
// runs in the cache file (persist.go).
type cache struct {
containers map[string]entry[[]provider.Container] // by source key
objects map[string]entry[[]provider.Object] // by container key (ID|type)
+6 -1
View File
@@ -43,6 +43,9 @@ type nav struct {
sourceRow string // Sources row to select
stage navStage
// quiet gives up without a word: going back to where lazysecrets
// was left, which may be gone.
quiet bool
}
type resolvedMsg struct {
@@ -460,7 +463,9 @@ func (m *Model) addLinked(c provider.Container) {
}
func (m *Model) navFail(format string, a ...any) {
m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true}
if !m.nav.quiet {
m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true}
}
m.nav = nil
}
+243
View File
@@ -0,0 +1,243 @@
package app
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// What was loaded is kept between runs in a cache file (Options.CacheFile):
// the Sources lists, vaults, secrets' metadata and where the cursor was in
// each. Starting again shows it at once, and lists older than cacheTTL
// reload in the background as usual. Values are never in it; neither are
// repos, whose files are read from disk anyway.
//
// And where lazysecrets was left, per folder it ran in, goes in the store,
// to go back there on the next start.
// cacheFileVersion is the file's format; a file of another version is
// ignored, and replaced on the next save.
const cacheFileVersion = 1
// cacheMaxAge drops what hasn't been loaded again for this long.
const cacheMaxAge = 30 * 24 * time.Hour
// persistEvery is how often a changed cache is saved while running, so
// little is lost if lazysecrets is killed rather than quit.
const persistEvery = 30 * time.Second
type cacheFile struct {
Version int `json:"version"`
Sources map[string]stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
Containers map[string]stamped[[]provider.Container] `json:"containers,omitempty"`
Objects map[string]stamped[[]provider.Object] `json:"objects,omitempty"`
Details map[string]stamped[provider.Object] `json:"details,omitempty"`
Cursor map[string]string `json:"cursor,omitempty"`
}
type stamped[T any] struct {
At time.Time `json:"at"`
Value T `json:"value"`
}
type persistTickMsg struct{}
type persistedMsg struct{ err error }
// keepCached reports whether a cache key's account still exists and
// isn't a repo: every key starts with the provider ID.
func (m *Model) keepCached(key string) bool {
id, _ := splitSourceRow(key)
p, ok := m.byID[id]
return ok && p.Kind() != provider.KindRepo
}
// loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more.
func (m *Model) loadCacheFile() {
b, err := os.ReadFile(m.cachePath)
if err != nil {
return
}
var f cacheFile
if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion {
return
}
now := m.now()
recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge }
for id, e := range f.Sources {
if m.keepCached(id+"|") && recent(e.At) {
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
m.sourcesAt[id] = e.At
}
}
for k, e := range f.Containers {
if m.keepCached(k) && recent(e.At) {
m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
}
}
for k, e := range f.Objects {
if m.keepCached(k) && recent(e.At) {
m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At}
}
}
for k, e := range f.Details {
if m.keepCached(k) && recent(e.At) {
m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
}
}
for k, v := range f.Cursor {
if m.keepCached(k) {
m.cache.cursor[k] = v
}
}
}
// cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cacheFile {
f := cacheFile{
Version: cacheFileVersion,
Sources: map[string]stamped[[]provider.Source]{},
Containers: map[string]stamped[[]provider.Container]{},
Objects: map[string]stamped[[]provider.Object]{},
Details: map[string]stamped[provider.Object]{},
Cursor: map[string]string{},
}
for id, r := range m.sourceResults {
if r.err == nil && m.keepCached(id+"|") {
f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources}
}
}
for k, e := range m.cache.containers {
if m.keepCached(k) {
f.Containers[k] = stamped[[]provider.Container]{e.at, e.value}
}
}
for k, e := range m.cache.objects {
if m.keepCached(k) {
f.Objects[k] = stamped[[]provider.Object]{e.at, e.value}
}
}
for k, e := range m.cache.details {
if m.keepCached(k) {
f.Details[k] = stamped[provider.Object]{e.at, e.value}
}
}
for k, v := range m.cache.cursor {
if m.keepCached(k) {
f.Cursor[k] = v
}
}
return f
}
// writeCacheFile writes f atomically, readable by the user only.
func writeCacheFile(path string, f cacheFile) error {
b, err := json.Marshal(f)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
// persistTick saves the cache in the background when it changed.
func (m *Model) persistTick() tea.Cmd {
next := tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })
if !m.cacheDirty {
return next
}
m.cacheDirty = false
f, path := m.cacheSnapshot(), m.cachePath
return tea.Batch(next, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} })
}
// Persist saves the cache and where lazysecrets was left; run it after
// the program ends.
func (m Model) Persist() error {
var errs []error
if m.cachePath != "" {
errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot()))
}
if m.placeDir != "" {
if p, ok := m.place(); ok {
errs = append(errs, m.store.SetPlace(m.placeDir, p))
}
}
return errors.Join(errs...)
}
// place is the selected item, as deep as the focus.
func (m *Model) place() (store.Place, bool) {
src, ok := m.selectedSource()
if !ok || m.nav != nil {
return store.Place{}, false
}
var r ref.Ref
var err error
switch c, cok := m.containers.Selected(); {
case m.focus == paneSources || !cok:
r, err = ref.ForSource(src)
case m.focus == paneContainers:
r, err = ref.ForContainer(c, m.objType)
default:
o, ook := m.detail.Secret()
if !ook {
r, err = ref.ForContainer(c, m.objType)
} else {
r, err = ref.ForObject(o, "")
}
}
if err != nil {
return store.Place{}, false
}
return store.Place{Link: r.String(), Account: src.Provider, Source: src.ID, At: m.now()}, true
}
// restorePlace goes back to where lazysecrets was left in dir, if that
// account is still signed in.
func (m *Model) restorePlace() {
p, ok := m.store.Place(m.placeDir)
if !ok {
return
}
if _, ok := m.byID[p.Account]; !ok {
return
}
r, err := ref.Parse(p.Link)
if err != nil {
return
}
m.startNav(r, actGo, sourceRowKey(p.Account, p.Source))
m.nav.sourceOnly = r.IsSource()
m.nav.quiet = true
}
func (m *Model) handlePersisted(msg persistedMsg) {
if msg.err != nil {
m.noteErr(fmt.Errorf("saving the cache: %w", msg.err))
}
}
+110
View File
@@ -0,0 +1,110 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "lazysecrets", "cache.json")
d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0))
d.keys("3", "space")
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
t.Fatalf("value not revealed:\n%s", d.screen())
}
src, vault, secret := d.selected()
if err := d.m.Persist(); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "correct-horse") {
t.Error("a value is in the cache file")
}
if fi, _ := os.Stat(file); fi.Mode().Perm() != 0o600 {
t.Errorf("cache file mode %v", fi.Mode().Perm())
}
// 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))
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())
}
d2.keys("3")
if s, v, o := d2.selected(); s != src || v != vault || o != secret {
t.Errorf("selected %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen())
}
}
func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "cache.json")
az := fake.Azure(0)
now := time.Now()
f := cacheFile{
Version: cacheFileVersion,
Containers: map[string]stamped[[]provider.Container]{
az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}},
az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}},
"Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}},
},
}
if err := writeCacheFile(file, f); err != nil {
t.Fatal(err)
}
d := newDriverOpts(t, Options{CacheFile: file}, az)
c := d.m.cache.containers
if _, ok := c[az.ID()+"|recent"]; !ok {
t.Error("dropped a recent entry")
}
if _, ok := c[az.ID()+"|old"]; ok {
t.Error("kept an entry older than cacheMaxAge")
}
if _, ok := c["Azure:gone@x/t|recent"]; ok {
t.Error("kept an entry of an account that isn't signed in")
}
}
func TestBackWhereItWasLeft(t *testing.T) {
t.Parallel()
st, _ := store.Load("")
dir := "/work/project"
d := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0))
d.keys("3", "j", "j")
src, vault, secret := d.selected()
if err := d.m.Persist(); err != nil {
t.Fatal(err)
}
if p, ok := st.Place(dir); !ok || !strings.Contains(p.Link, secret) {
t.Fatalf("place %+v", p)
}
d2 := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0))
if s, v, o := d2.selected(); s != src || v != vault || o != secret {
t.Errorf("back at %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen())
}
// Another folder starts as usual; so does opening a link.
d3 := newDriverOpts(t, Options{Store: st, Dir: "/elsewhere"}, fake.Azure(0))
if _, _, o := d3.selected(); o == secret {
t.Errorf("another folder went to %s too", o)
}
r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password")
d4 := newDriverOpts(t, Options{Store: st, Dir: dir, Open: &r}, fake.Azure(0))
if d4.m.nav != nil && d4.m.nav.quiet {
t.Error("a link was overridden by where it was left")
}
}
+9 -5
View File
@@ -232,11 +232,12 @@ func (m *Model) crawlStart() tea.Cmd {
return cmd
}
// queueSource queues listing src's vaults or, with them listed and not
// force, loading their secrets.
// queueSource queues listing src's vaults or, with them listed lately
// and not force, loading their secrets. Lists from an earlier run (the
// cache file) are listed again, so searching brings them up to date.
func (m *Model) queueSource(src provider.Source, force bool) {
row := sourceRowKey(src.Provider, src.ID)
if e, ok := m.cache.containers[row]; ok && !force {
if e, ok := m.cache.containers[row]; ok && e.fresh(m.now()) && !force {
for _, c := range e.value {
m.queueContainer(c, row, false)
}
@@ -245,10 +246,11 @@ func (m *Model) queueSource(src provider.Source, force bool) {
m.enqueue(crawlJob{src: &src, row: row, force: force}, row)
}
// queueContainer queues loading c's secrets, unless loaded and not force.
// 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)
if _, ok := m.cache.objects[key]; ok && !force {
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
return
}
m.enqueue(crawlJob{container: &c, row: row, force: force}, key)
@@ -319,12 +321,14 @@ func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
case msg.job.src != nil:
delete(m.crawl.failed, key)
m.cache.containers[key] = entry[[]provider.Container]{msg.containers, m.now()}
m.cacheDirty = true
for _, c := range msg.containers {
m.queueContainer(c, key, msg.job.force)
}
default:
delete(m.crawl.failed, key)
m.cache.objects[key] = entry[[]provider.Object]{msg.objects, m.now()}
m.cacheDirty = true
}
cmd := m.crawlNext()
m.refreshSearch()