feat: this session's errors in the status pane, newest first
CI / Checks (pull_request) Successful in 22s
PR title / Conventional title (pull_request) Successful in 1s

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 14:33:23 +02:00
1 parent ece44daadb
commit b8cd217e24
6 files changed
+120 -13

No files matched your search

+17 -3
View File
@@ -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))
}
}
+22
View File
@@ -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)
}
}
+64
View File
@@ -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
}
+1 -2
View File
@@ -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)
}
}
+1
View File
@@ -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 := ""