From b8cd217e2417dfa22cb46e6557f8a3068b9c6d09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 14:33:23 +0200 Subject: [PATCH] feat: this session's errors in the status pane, newest first Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/keys.md | 23 ++++++++----- internal/ui/app/app.go | 20 ++++++++++-- internal/ui/app/app_test.go | 22 +++++++++++++ internal/ui/app/errlog.go | 64 +++++++++++++++++++++++++++++++++++++ internal/ui/app/persist.go | 3 +- internal/ui/app/status.go | 1 + 6 files changed, 120 insertions(+), 13 deletions(-) create mode 100644 internal/ui/app/errlog.go diff --git a/docs/keys.md b/docs/keys.md index 3d4185a..8f9d538 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -49,7 +49,7 @@ match case exactly. | Key | Action | |---|---| | `space` | reveal / hide the selected value (public material for keys and certificates) | -| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field or group | +| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field | | `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `o` | open the selected item in the browser (over SSH: copy the link) | @@ -60,6 +60,11 @@ match case exactly. | `L` | sign in again (shown when a login has expired) | | `U` | a newer lazysecrets: its changelog, and update in place (restart to use it); or look for one | +A revealed value is shown as it reads best: JSON indented, and PEM +certificates as their subject, issuer, dates, names and thumbprint, with +any other PEM block (a private key) named but not shown. `y` still copies +it exactly as stored. + A revealed value hides again after `revealTimeout` (60s) and when the terminal loses focus (`hideOnBlur`); see [config](config.md). @@ -114,13 +119,15 @@ it's hidden. On top, the status pane (`0`) shows the version, a newer release and how your accounts are doing. Focused, the right pane lists more under the -logo (when there's room): the version -and updates (`U`), each account (`L` on one that needs it signs in), the -config, state and cache files (`e` opens one in your editor), and links -to the docs, source, issues, releases and changelog (`o` opens one; on -the version or update, its release), then the changelog of -every release up to yours, built in so it needs no network: `/` filters -it (`/fixed`, `/search`). `y` copies any row. +logo (when there's room): the version and updates (`U`), each account +(`L` on one that needs it signs in), what's expiring (`enter` goes to +it), this session's errors, newest first (what a pane showed, or the +footer flashed before you moved on; not kept after quitting), the config, +state and cache files (`e` opens one in your editor), and links to the +docs, source, issues, releases and changelog (`o` opens one; on the +version or update, its release), then the changelog of every release up +to yours, built in so it needs no network: `/` filters it (`/fixed`, +`/search`). `y` copies any row. | Key | Action | |---|---| diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 344da81..16712ae 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -177,9 +177,13 @@ type Model struct { fields panes.Fields lastLeft pane details map[string]detailsState // by containerKey - getenv func(string) string - nvimOpen func(server, path string, line int) error // edit.go - now func() time.Time + // errLog is this session's errors, newest first; loggedFlash the + // footer's text when last looked at (errlog.go). + errLog []loggedErr + loggedFlash string + 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 @@ -623,6 +627,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.done(slotContainers) if msg.err != nil { m.noteErr(msg.err) + if src, ok := m.selectedSource(); ok { + m.logErrOf(src.Name, msg.err) + } delete(m.cache.containers, msg.key) m.containers.SetError(msg.err) } else { @@ -641,6 +648,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.closeVersions() } else if msg.err != nil { m.noteErr(msg.err) + if c, ok := m.containers.Selected(); ok { + m.logErrOf(c.Name, msg.err) + } delete(m.cache.objects, msg.key) m.secrets.SetError(msg.err) } else { @@ -663,6 +673,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.done(slotValue) if msg.err != nil { m.noteErr(msg.err) + m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.detail.SetValueError(msg.err) } else { m.detail.SetValue(msg.value) @@ -689,6 +700,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.done(slotDescribe) if msg.err != nil { m.noteErr(msg.err) + m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err) m.detail.SetDescribeError(msg.err) } else { m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()} @@ -759,6 +771,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.focus != paneDetail { m.lastLeft = m.focus } + m.logFlash() cmds = append(cmds, m.syncFields()) if m.anyLoading() && !m.spinning { m.spinning = true @@ -1353,6 +1366,7 @@ func (m *Model) handleSources(msg sourcesMsg) tea.Cmd { for _, p := range m.providers { if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil { m.noteErr(r.err) + m.logErrOf(p.Label(), r.err) errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err)) } } diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index 56bd1b2..bc98cac 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1300,3 +1300,25 @@ func TestExpiringInStatus(t *testing.T) { t.Errorf("enter went to %s/%s\n%s", v, o, d.screen()) } } + +func TestErrorLog(t *testing.T) { + t.Parallel() + d := newDriverOpts(t, Options{}, fake.Azure(0)) + d.keys("2", "j", "j", "3") // kv-restricted: its secrets can't be listed + d.m.flash = flash{text: "copy failed: no clipboard", isErr: true} + d.send(nil) + d.send(nil) // still showing: logged once + d.keys("0") + scr := d.screen() + if !strings.Contains(scr, "errors") || !strings.Contains(scr, "copy failed: no clipboard") || !strings.Contains(scr, "kv-restricted: 403 Forbidden") { + t.Errorf("errors not listed:\n%s", scr) + } + if n := len(d.m.errLog); n != 2 || !strings.HasPrefix(d.m.errLog[0].text, "copy failed") { + t.Errorf("log %+v", d.m.errLog) + } + d.m.logErr("403 Forbidden: again") // a new one, and an old one again: on top + d.m.logErr(d.m.errLog[2].text) + if len(d.m.errLog) != 3 || d.m.errLog[0].text == "403 Forbidden: again" { + t.Errorf("log %+v", d.m.errLog) + } +} diff --git a/internal/ui/app/errlog.go b/internal/ui/app/errlog.go new file mode 100644 index 0000000..9d108b3 --- /dev/null +++ b/internal/ui/app/errlog.go @@ -0,0 +1,64 @@ +package app + +import ( + "errors" + "slices" + "strings" + "time" + + "git.kebcloud.no/keb/lazysecrets/internal/provider" + "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" +) + +// The status pane lists this session's errors, newest first: what a pane +// showed (a list that couldn't load) and what the footer flashed (a copy +// that failed), which go when you move on. Only the first line of each, +// and never kept after quitting. + +// maxErrors is how many errors are kept. +const maxErrors = 20 + +type loggedErr struct { + at time.Time + text string +} + +// logErr records text; an error already there moves to the top, with +// the time it happened again. +func (m *Model) logErr(text string) { + text, _, _ = strings.Cut(strings.TrimSpace(text), "\n") + if text == "" { + return + } + m.errLog = slices.DeleteFunc(m.errLog, func(e loggedErr) bool { return e.text == text }) + m.errLog = append([]loggedErr{{m.now(), text}}, m.errLog[:min(len(m.errLog), maxErrors-1)]...) +} + +// logErrOf records err, about what, except a sign-in, which the footer +// and the account's details say. +func (m *Model) logErrOf(what string, err error) { + var auth *provider.AuthError + if !errors.As(err, &auth) { + m.logErr(what + ": " + err.Error()) + } +} + +// logFlash records an error the footer is showing, once. +func (m *Model) logFlash() { + if m.flash.isErr && m.flash.text != m.loggedFlash { + m.logErr(m.flash.text) + } + m.loggedFlash = m.flash.text +} + +// errorFields are the status pane's rows for them. +func (m Model) errorFields() []panes.Field { + out := make([]panes.Field, len(m.errLog)) + for i, e := range m.errLog { + out[i] = panes.Field{Value: e.at.Format("15:04") + " " + e.text, Err: true} + } + if len(out) > 0 { + out[0].Label = "errors" + } + return out +} diff --git a/internal/ui/app/persist.go b/internal/ui/app/persist.go index 78f3a6f..659637d 100644 --- a/internal/ui/app/persist.go +++ b/internal/ui/app/persist.go @@ -3,7 +3,6 @@ package app import ( "encoding/json" "errors" - "fmt" "os" "path/filepath" "time" @@ -244,6 +243,6 @@ func (m *Model) restorePlace() { func (m *Model) handlePersisted(msg persistedMsg) { if msg.err != nil { - m.noteErr(fmt.Errorf("saving the cache or place: %w", msg.err)) + m.logErrOf("saving the cache or place", msg.err) } } diff --git a/internal/ui/app/status.go b/internal/ui/app/status.go index 4e2de7f..7925d01 100644 --- a/internal/ui/app/status.go +++ b/internal/ui/app/status.go @@ -102,6 +102,7 @@ func (m Model) statusFields() []panes.Field { } out = append(out, m.expiringFields()...) + out = append(out, m.errorFields()...) for i, path := range m.configFiles { label := ""