From 1d8a7d2842a2c54a4ce856f56f430ae108934090 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 14:30:28 +0200 Subject: [PATCH 1/3] refactor: no access list for Databricks scopes: it was the one request of its own Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/databricks.md | 4 -- .../provider/databricks/databricks_test.go | 24 ----------- internal/provider/databricks/details.go | 43 ------------------- internal/provider/provider.go | 5 +-- internal/ui/app/app.go | 2 +- internal/ui/app/fields.go | 2 +- 6 files changed, 4 insertions(+), 76 deletions(-) delete mode 100644 internal/provider/databricks/details.go diff --git a/docs/databricks.md b/docs/databricks.md index 30ddcaf..e60c703 100644 --- a/docs/databricks.md +++ b/docs/databricks.md @@ -17,10 +17,6 @@ When Databricks refuses, the pane says what to check: access to the workspace and READ on the scope, or, when the workspace's IP access list refused, the network you're on. -On a scope, the right pane shows who has access to it and how much -(manage, write, read). Databricks shows that only to the scope's -managers; to others it says so. - Hide profiles you don't want in the [config](config.md): ```yaml diff --git a/internal/provider/databricks/databricks_test.go b/internal/provider/databricks/databricks_test.go index 89909c5..10caba9 100644 --- a/internal/provider/databricks/databricks_test.go +++ b/internal/provider/databricks/databricks_test.go @@ -140,15 +140,8 @@ func workspaceAPI(t *testing.T, status int) *httptest.Server { map[string]any{"key": "storage-key", "last_updated_timestamp": 1767225600000}, map[string]any{"key": "client-secret"}, }}) - case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "app": - write(w, 200, map[string]any{"items": []any{ - map[string]any{"principal": "users", "permission": "READ"}, - map[string]any{"principal": "admins", "permission": "MANAGE"}, - }}) case r.URL.Path == "/api/2.0/secrets/list" && q.Get("scope") == "locked": write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have READ permission on scope locked"}) - case r.URL.Path == "/api/2.0/secrets/acls/list" && q.Get("scope") == "locked": - write(w, 403, map[string]any{"error_code": "PERMISSION_DENIED", "message": "User does not have MANAGE permission"}) case r.URL.Path == "/api/2.0/secrets/get" && q.Get("scope") == "app": write(w, 200, map[string]any{"key": q.Get("key"), "value": base64.StdEncoding.EncodeToString([]byte("value-of-" + q.Get("key")))}) @@ -301,23 +294,6 @@ func TestClassify(t *testing.T) { } } -func TestScopeDetails(t *testing.T) { - p := newTestProvider(t, workspaceAPI(t, 0), nil) - src := provider.Source{ID: "prod"} - attrs, err := p.Details(context.Background(), provider.Container{Source: src, Name: "app"}) - if err != nil { - t.Fatal(err) - } - want := []provider.Attr{{Label: "access", Value: "admins · manage"}, {Value: "users · read"}} - if !reflect.DeepEqual(attrs, want) { - t.Errorf("app: %+v", attrs) - } - attrs, err = p.Details(context.Background(), provider.Container{Source: src, Name: "locked"}) - if err != nil || len(attrs) != 1 || !strings.Contains(attrs[0].Value, "managers") { - t.Errorf("locked: %+v, %v", attrs, err) - } -} - func TestDeniedSaysWhatToCheck(t *testing.T) { p := newTestProvider(t, workspaceAPI(t, 0), nil) _, err := p.Objects(context.Background(), provider.Container{Source: provider.Source{ID: "prod"}, Name: "locked"}, provider.TypeSecret) diff --git a/internal/provider/databricks/details.go b/internal/provider/databricks/details.go deleted file mode 100644 index dac37a9..0000000 --- a/internal/provider/databricks/details.go +++ /dev/null @@ -1,43 +0,0 @@ -package databricks - -import ( - "cmp" - "context" - "errors" - "slices" - "strings" - - "github.com/databricks/databricks-sdk-go/apierr" - "github.com/databricks/databricks-sdk-go/service/workspace" - - "git.kebcloud.no/keb/lazysecrets/internal/provider" -) - -// Details are who has access to a scope, and how much. Only the scope's -// managers may see that; to others it says so. -func (p *Provider) Details(ctx context.Context, c provider.Container) ([]provider.Attr, error) { - w, pr, err := p.client(c.Source.ID) - if err != nil { - return nil, err - } - acls, err := w.ListAclsAll(ctx, workspace.ListAclsRequest{Scope: c.Name}) - var api *apierr.APIError - switch { - case errors.As(err, &api) && api.StatusCode == 403: - return []provider.Attr{{Label: "access", Value: "only the scope's managers can see who has it"}}, nil - case err != nil: - return nil, classify(pr, err) - case len(acls) == 0: - return []provider.Attr{{Label: "access", Value: "workspace admins only"}}, nil - } - rank := map[workspace.AclPermission]int{workspace.AclPermissionManage: 0, workspace.AclPermissionWrite: 1, workspace.AclPermissionRead: 2} - slices.SortFunc(acls, func(a, b workspace.AclItem) int { - return cmp.Or(cmp.Compare(rank[a.Permission], rank[b.Permission]), cmp.Compare(a.Principal, b.Principal)) - }) - out := make([]provider.Attr, len(acls)) - for i, a := range acls { - out[i] = provider.Attr{Value: a.Principal + " · " + strings.ToLower(string(a.Permission))} - } - out[0].Label = "access" - return out, nil -} diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 2b3ae9f..e5136d4 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -221,9 +221,8 @@ func (d *Denied) Error() string { func (d *Denied) Unwrap() error { return d.Err } // ContainerDetails is implemented by providers that can say more about a -// container than its listing does: a vault's settings, who has access to -// a scope, how a file is encrypted and what git knows of it. It may go -// over the network; ErrUnsupported means there's nothing more for c. +// container than its listing does: how a file is encrypted and what git +// knows of it. ErrUnsupported means there's nothing more for c. type ContainerDetails interface { Details(ctx context.Context, c Container) ([]Attr, error) } diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 4604886..344da81 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -56,7 +56,7 @@ const ( slotDescribe // extra metadata for the selected object slotCopy // fetch a value straight to the clipboard slotVersions // count the selected object's versions - slotDetails // a vault's, scope's or file's details + slotDetails // a file's details slotCount ) diff --git a/internal/ui/app/fields.go b/internal/ui/app/fields.go index d06f0b6..1aa7e63 100644 --- a/internal/ui/app/fields.go +++ b/internal/ui/app/fields.go @@ -15,7 +15,7 @@ import ( // The fourth pane shows Fields for anything but a secret. On a provider's // row: its account, from what the provider knows locally. -// A vault's, scope's or file's details beyond its listing load once a +// A file's details beyond its listing load once a // session, when the cursor rests on it, and again with refresh. They're // extra: moving on cancels the load, and search never waits for them. type detailsState struct { -- 2.54.0 From ece44daadb639dbd0715431e3317fea5c2ee552f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 14:31:46 +0200 Subject: [PATCH 2/3] feat: revealed JSON is shown formatted, and PEM certificates as what they say Co-Authored-By: Claude Opus 5.5 (1M context) --- internal/ui/panes/detail.go | 9 +++- internal/ui/panes/valueview.go | 79 +++++++++++++++++++++++++++++ internal/ui/panes/valueview_test.go | 60 ++++++++++++++++++++++ 3 files changed, 147 insertions(+), 1 deletion(-) create mode 100644 internal/ui/panes/valueview.go create mode 100644 internal/ui/panes/valueview_test.go diff --git a/internal/ui/panes/detail.go b/internal/ui/panes/detail.go index cd2a695..416df35 100644 --- a/internal/ui/panes/detail.go +++ b/internal/ui/panes/detail.go @@ -313,7 +313,8 @@ func (d Detail) lines(st styles.Styles) []string { case ValueShown: // Where the dots were, continuing under itself; the keys below. first := true - for _, line := range strings.Split(d.value.Reveal(), "\n") { + lines, note := valueView(d.value.Reveal()) + for _, line := range lines { for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") { if first { out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w)) @@ -324,6 +325,12 @@ func (d Detail) lines(st styles.Styles) []string { } } var more []string + if note != "" && d.CopyKey != "" { + note += ": " + d.CopyKey + " copies it as stored" + } + if note != "" { + more = append(more, note) + } if d.hidesIn != "" { more = append(more, "hides in "+d.hidesIn) } diff --git a/internal/ui/panes/valueview.go b/internal/ui/panes/valueview.go new file mode 100644 index 0000000..beae4d3 --- /dev/null +++ b/internal/ui/panes/valueview.go @@ -0,0 +1,79 @@ +package panes + +import ( + "bytes" + "crypto/sha1" + "crypto/x509" + "encoding/json" + "encoding/pem" + "fmt" + "strings" + "time" +) + +// A revealed value is shown as it reads best: JSON indented, and PEM +// certificates as what they say (subject, issuer, dates, names) rather +// than base64, with any other PEM block, a private key say, named but not +// shown. Anything else is shown as it is. y copies it as stored. + +// valueView is how to show v, and what's been done to it ("" when it's +// shown as it is). +func valueView(v string) (lines []string, note string) { + if lines, ok := pemView(v); ok { + return lines, "certificate" + } + if t := strings.TrimSpace(v); (strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[")) && json.Valid([]byte(t)) { + var b bytes.Buffer + if json.Indent(&b, []byte(t), "", " ") == nil { + return strings.Split(b.String(), "\n"), "JSON, formatted" + } + } + return strings.Split(v, "\n"), "" +} + +// pemView describes the PEM blocks in v, if it's PEM with a certificate. +func pemView(v string) ([]string, bool) { + rest := []byte(strings.TrimSpace(v)) + var out []string + certs := 0 + for len(rest) > 0 { + block, after := pem.Decode(rest) + if block == nil { + return nil, false // text that isn't PEM: show it all + } + rest = bytes.TrimSpace(after) + if len(out) > 0 { + out = append(out, "") + } + if block.Type != "CERTIFICATE" { + out = append(out, block.Type+" (not shown)") + continue + } + cert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, false + } + certs++ + out = append(out, certLines(cert)...) + } + return out, certs > 0 +} + +func certLines(c *x509.Certificate) []string { + day := func(t time.Time) string { return t.UTC().Format("2006-01-02") } + out := []string{ + "subject " + c.Subject.String(), + "issuer " + c.Issuer.String(), + "valid " + day(c.NotBefore) + " to " + day(c.NotAfter), + } + names := append([]string{}, c.DNSNames...) + for _, ip := range c.IPAddresses { + names = append(names, ip.String()) + } + names = append(names, c.EmailAddresses...) + if len(names) > 0 { + out = append(out, "names "+strings.Join(names, ", ")) + } + sum := sha1.Sum(c.Raw) // what Azure and Windows call the thumbprint + return append(out, "SHA-1 "+fmt.Sprintf("%X", sum[:])) +} diff --git a/internal/ui/panes/valueview_test.go b/internal/ui/panes/valueview_test.go new file mode 100644 index 0000000..1f2e05b --- /dev/null +++ b/internal/ui/panes/valueview_test.go @@ -0,0 +1,60 @@ +package panes + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "slices" + "strings" + "testing" + "time" +) + +func TestValueViewJSON(t *testing.T) { + lines, note := valueView(`{"user":"app","port":5432,"tags":["a"]}`) + want := []string{"{", ` "user": "app",`, ` "port": 5432,`, ` "tags": [`, ` "a"`, " ]", "}"} + if !slices.Equal(lines, want) || note != "JSON, formatted" { + t.Errorf("lines %q, note %q", lines, note) + } + for _, v := range []string{"plain value", "{not json", "42", "line one\nline two"} { + if lines, note := valueView(v); note != "" || strings.Join(lines, "\n") != v { + t.Errorf("%q shown as %q (%q)", v, lines, note) + } + } +} + +func TestValueViewCertificate(t *testing.T) { + key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + tmpl := &x509.Certificate{ + SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "app.example.com"}, + DNSNames: []string{"app.example.com", "www.app.example.com"}, + NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC), + } + der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + keyDER, _ := x509.MarshalECPrivateKey(key) + bundle := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) + + string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})) + + lines, note := valueView(bundle) + text := strings.Join(lines, "\n") + for _, want := range []string{"subject CN=app.example.com", "valid 2026-01-01 to 2027-01-01", + "names app.example.com, www.app.example.com", "SHA-1 ", "EC PRIVATE KEY (not shown)"} { + if !strings.Contains(text, want) { + t.Errorf("missing %q:\n%s", want, text) + } + } + if note != "certificate" || strings.Contains(text, "BEGIN") { + t.Errorf("note %q, or PEM shown:\n%s", note, text) + } + // A private key alone isn't a certificate: shown as it is. + if _, note := valueView(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))); note != "" { + t.Errorf("a key alone: note %q", note) + } +} -- 2.54.0 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 3/3] 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 := "" -- 2.54.0