Files
lazysecrets/internal/ui/app/status.go
T
keb 9b0329e9da feat: the changelog in the status pane, built in (#12)
The status pane's rows (`0`, then `enter`) end with the changelog: every release up to the running one, newest first.

```
v0.2.0     2026-10-12
           added: Search with fzf's scoring and query syntax (#11) (65dc2d7)
           fixed: …
v0.1.0     2026-10-10
           Hello world 👋
```

- `CHANGELOG.md` is embedded at build time by a small root package (`changelog.go`, `package lazysecrets`, `//go:embed`), so it needs no network and matches the binary. `release.sh` updates the file before tagging, so a release's binary includes its own entry. `go install …@latest` gets it too, since the module includes the file.
- Entries show as text: links become `(#11) (65dc2d7)` (`update.PlainLinks`, shared with the `U` notes). `/` filters them like any Fields pane (`/fixed`, `/search`), and `y` copies one.
- `cliff.toml`: #11 was squash-merged with #10 (the status pane) in it, so a preprocessor rewrites its entry to name both. Otherwise the status pane would be missing from the changelog.

Test for the parsing: versions and dates, kinds, links as text, a release's own words, and the header and footer left out.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #12
2026-10-11 13:12:51 +02:00

183 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package app
import (
"errors"
"fmt"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// The status pane, like lazygit's: a line on top of the left panes with
// the version, a newer release and how the accounts are doing. Focused
// (0), the right pane has the rest: updates, each account, the files
// lazysecrets uses, links, and the changelog.
// statusH is the status pane's height: one line in a box.
const statusH = 3
// statusLine is what the status pane says while it isn't focused.
func (m Model) statusLine() string {
var parts []string
switch {
case m.updated != "":
parts = append(parts, m.st.Notice.Render("→ "+m.updated+" (restart)"))
case m.newer != nil:
parts = append(parts, m.st.Notice.Render("↑ "+m.newer.Tag+withKey(m.keys.Update, " %s")))
}
accounts, failing := 0, 0
for _, p := range m.providers {
if p.Kind() == provider.KindRepo {
continue
}
accounts++
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
failing++
}
}
if accounts > 0 {
s := plural(accounts, "account")
if failing > 0 {
s += " · " + m.st.Error.Render(fmt.Sprintf("%d need sign-in", failing))
}
parts = append(parts, s)
}
return " " + strings.Join(parts, m.st.Muted.Render(" · "))
}
func (m Model) statusView() string {
title := "[0] lazysecrets"
if v := m.versionLabel(); v != "" {
title += " " + v
}
return panes.Box(m.st, panes.TitleStyle(m.st, m.active(paneStatus)).Render(title), "",
[]string{m.statusLine()}, m.statusW, statusH, m.active(paneStatus))
}
// statusFields are the status pane's rows on the right.
func (m Model) statusFields() []panes.Field {
var out []panes.Field
add := func(f panes.Field) {
if f.Value != "" {
out = append(out, f)
}
}
// A release's rows open its release page.
version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")}
if strings.HasPrefix(version.Value, "v") {
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
}
add(version)
upd := panes.Field{Label: "update", Value: m.updateStatus(), Info: m.newer == nil}
if m.newer != nil {
upd.URL = update.DefaultRepo + "/releases/tag/" + m.newer.Tag
}
add(upd)
add(panes.Field{Label: "license", Value: "GPL-3.0-or-later · © 2026 Karl-Einar Bergstrøm", Info: true})
for i, p := range m.providers {
f := panes.Field{Value: p.Label() + " · " + m.accountStatus(p), Provider: p.ID()}
if i == 0 {
f.Label = "accounts"
}
var auth *provider.AuthError
f.Err = errors.As(m.sourceResults[p.ID()].err, &auth)
add(f)
}
for i, path := range m.configFiles {
label := ""
if i == 0 {
label = "config"
}
add(panes.Field{Label: label, Value: path, Path: path})
}
add(panes.Field{Label: "state", Value: m.statePath, Path: m.statePath})
add(panes.Field{Label: "cache", Value: m.cachePath, Path: m.cachePath})
// Short, without https://; y copies and o opens the whole URL.
repo := strings.TrimPrefix(update.DefaultRepo, "https://")
add(panes.Field{Label: "docs", Value: repo + "/…/docs", URL: update.DefaultRepo + "/src/branch/main/docs"})
add(panes.Field{Label: "source", Value: repo, URL: update.DefaultRepo})
add(panes.Field{Label: "issues", Value: repo + "/issues", URL: update.DefaultRepo + "/issues"})
add(panes.Field{Label: "releases", Value: repo + "/releases", URL: update.DefaultRepo + "/releases"})
add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"})
return append(out, m.changelog...)
}
// updateStatus says whether a newer release is out, and when it was
// last checked.
func (m Model) updateStatus() string {
switch {
case m.updated != "":
return "updated to " + m.updated + ": restart to use it"
case m.newer != nil:
return m.newer.Tag + " is out" + withKey(m.keys.Update, " · %s updates")
}
at := m.store.UpdateCheck().At
if at.IsZero() {
return ""
}
return "up to date · checked " + ago(m.now().Sub(at))
}
// accountStatus says whether an account's sources loaded.
func (m Model) accountStatus(p provider.Provider) string {
r, done := m.sourceResults[p.ID()]
var auth *provider.AuthError
switch {
case !done:
return "loading…"
case errors.As(r.err, &auth) && len(auth.Login) > 0:
return "sign-in required" + withKey(m.keys.Login, " · %s signs in")
case r.err != nil:
return r.err.Error()
}
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
return plural(len(r.sources), cmpOr(noun, "source"))
}
// ago is a duration as people say it: "just now", "5m ago", "2h ago".
func ago(d time.Duration) string {
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
}
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
// changelogFields are CHANGELOG.md as rows: each release's version and
// date, then its entries, marked by what kind they are.
func changelogFields(md string) []panes.Field {
var out []panes.Field
kind := ""
for _, line := range strings.Split(md, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "## "):
version, date, _ := strings.Cut(strings.TrimPrefix(line, "## "), " – ")
out = append(out, panes.Field{Label: version, Value: date, Info: true})
kind = ""
case strings.HasPrefix(line, "### "):
kind = strings.ToLower(strings.TrimPrefix(line, "### "))
case strings.HasPrefix(line, "- ") && len(out) > 0:
text := update.PlainLinks(strings.TrimPrefix(line, "- "))
if kind != "" {
text = kind + ": " + text
}
out = append(out, panes.Field{Value: text})
case line != "" && len(out) > 0 && !strings.HasPrefix(line, "<!--"):
// A release's own words ("Hello world 👋").
out = append(out, panes.Field{Value: update.PlainLinks(line)})
}
}
return out
}