feat: keep what was loaded between runs, and start where you left off (#2)

Starting lazysecrets again now shows what you had loaded at once, and puts you back where you were.

- **Cache file** `~/.cache/lazysecrets/cache.json` (0600, written atomically): the Sources lists, vaults/scopes, secrets' metadata, Describe results and each list's cursor. **Never values**, and never repos (they're on disk anyway). Loaded on start; lists older than 2 minutes reload in the background as before. Saved every 30s when changed, and on quit.
- **Housekeeping:** entries not loaded for 30 days are dropped, as is everything from accounts no longer signed in (every cache key starts with the account ID). The file has a format version; another version is ignored and replaced. `cache: false` turns it off; `--demo` never uses it.
- **Sources are cached too:** Azure lists subscriptions over the network, so without this the Sources pane would still wait.
- **Go to** searches everything cached straight away, and its background loading now also refreshes lists from an earlier run instead of skipping them.
- **Where you left off:** the state file remembers the selected source, vault or secret per folder (the `--dir` or working directory, 100 most recent folders), and the next start in that folder goes back there quietly (no error if it's gone). Starting on a link skips it.

Docs: `cache:` in config.md, a Cache section, the place in State, and the README Privacy section now mentions the cache file.

Tests: cache round trip with a backend too slow to answer (lists and cursor come from the file), no value in the file, file mode 0600, old and signed-out entries dropped, back where it was left, not for another folder or a link.

This touches `app.go` near PR #1's changes (Model fields, the Update switch), so whichever merges second needs a small conflict fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
keb committed 2026-10-11 09:53:32 +02:00
1 parent 17d2705e12
commit 10dc747f0f
11 files changed
+512 -12

No files matched your search

+40
View File
@@ -69,6 +69,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
@@ -160,6 +166,13 @@ type Model struct {
getenv func(string) string
nvimOpen func(server, path string, line int) error // edit.go
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
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.
@@ -415,6 +428,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
}
@@ -438,6 +462,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 != "" || m.placeDir != "" {
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
}
if m.fetchOnStart {
cmds = append(cmds, m.fetchLatest(false))
}
@@ -530,6 +557,12 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case editDoneMsg:
cmds = append(cmds, m.handleEditDone(msg))
case persistTickMsg:
cmds = append(cmds, m.persistTick())
case persistedMsg:
m.handlePersisted(msg)
case crawlMsg:
cmds = append(cmds, m.handleCrawl(msg))
@@ -560,6 +593,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))
}
@@ -580,6 +614,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 {
@@ -609,6 +644,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)
}
@@ -622,6 +658,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)
}
@@ -1213,6 +1250,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 {