From 9b0329e9da2e491e5a8adcdd16ecc72f89613316 Mon Sep 17 00:00:00 2001 From: keb Date: Sun, 11 Oct 2026 13:12:51 +0200 Subject: [PATCH] feat: the changelog in the status pane, built in (#12) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Reviewed-on: https://git.kebcloud.no/keb/lazysecrets/pulls/12 --- changelog.go | 10 ++++++++ cliff.toml | 2 ++ cmd/lazysecrets/main.go | 2 ++ docs/keys.md | 5 +++- internal/ui/app/app.go | 5 +++- internal/ui/app/status.go | 45 +++++++++++++++++++++++++++++++--- internal/ui/app/status_test.go | 30 +++++++++++++++++++++++ internal/update/update.go | 5 +++- 8 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 changelog.go create mode 100644 internal/ui/app/status_test.go diff --git a/changelog.go b/changelog.go new file mode 100644 index 0000000..915cd66 --- /dev/null +++ b/changelog.go @@ -0,0 +1,10 @@ +// Package lazysecrets holds what the module's root has for the app: the +// changelog, embedded so the status pane can show it offline. +package lazysecrets + +import _ "embed" + +// Changelog is CHANGELOG.md as of the build: every release up to it. +// +//go:embed CHANGELOG.md +var Changelog string diff --git a/cliff.toml b/cliff.toml index 1978ee8..e82f71c 100644 --- a/cliff.toml +++ b/cliff.toml @@ -49,6 +49,8 @@ commit_preprocessors = [ # A merge commit (not a squash) says "Merge pull request 'feat: …' (#4) # from branch into main": keep the title and number. { pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" }, + # #11 was squash-merged with #10 (the status pane) in it: name both. + { pattern = "^feat: link each changelog entry to its PR and commit \\(#11\\)", replace = "feat: a status pane (0) with version, updates, accounts, files and links; changelog entries link to their PR and commit (#11)" }, # Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see # pr-title.yml); all become "feat: x". { pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " }, diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 3142ea5..571de9e 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -16,6 +16,7 @@ import ( tea "charm.land/bubbletea/v2" + "git.kebcloud.no/keb/lazysecrets" "git.kebcloud.no/keb/lazysecrets/internal/commands" "git.kebcloud.no/keb/lazysecrets/internal/config" "git.kebcloud.no/keb/lazysecrets/internal/instance" @@ -371,6 +372,7 @@ Flags: CacheFile: cacheFile, ConfigFiles: configFiles(*configPath), StatePath: statePath, + Changelog: lazysecrets.Changelog, Dir: placeDir, Open: open, Styles: &theme, diff --git a/docs/keys.md b/docs/keys.md index 00554e5..893d179 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -116,7 +116,10 @@ On top, the status pane (`0`) shows the version, a newer release and how your accounts are doing. Focused, the right pane lists more: 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 and issues (`o` opens one). `y` copies any of them. +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 8efcdf6..7b0880c 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -75,6 +75,8 @@ type Options struct { // and keeps its state, for the status pane. ConfigFiles []string StatePath string + // Changelog is CHANGELOG.md, for the status pane. + Changelog string // CacheFile keeps what was loaded between runs; "" keeps it for this // run only. CacheFile string @@ -185,6 +187,7 @@ type Model struct { configFiles []string statePath string statusW int // the status pane's width: the left column's, zoomed or not + changelog []panes.Field cacheDirty bool sourcesAt map[string]time.Time placeDir string @@ -448,7 +451,7 @@ func New(providers []provider.Provider, opts Options) Model { m.spinning = true m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{} - m.configFiles, m.statePath = opts.ConfigFiles, opts.StatePath + m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog) if m.cachePath != "" { m.loadCacheFile() if len(m.sourceResults) > 0 { diff --git a/internal/ui/app/status.go b/internal/ui/app/status.go index e1427f7..02628fa 100644 --- a/internal/ui/app/status.go +++ b/internal/ui/app/status.go @@ -65,8 +65,17 @@ func (m Model) statusFields() []panes.Field { out = append(out, f) } } - add(panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")}) - add(panes.Field{Label: "update", Value: m.updateStatus(), Info: m.newer == nil}) + // 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 { @@ -94,7 +103,9 @@ func (m Model) statusFields() []panes.Field { 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"}) - return out + 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 @@ -141,3 +152,31 @@ func ago(d time.Duration) string { } 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, "\n" + got := changelogFields(md) + want := []panes.Field{ + {Label: "v0.2.0", Value: "2026-10-12", Info: true}, + {Value: "added: Search (#11) (65dc2d7)"}, + {Value: "fixed: A fix (#3)"}, + {Label: "v0.1.0", Value: "2026-10-10", Info: true}, + {Value: "Hello world 👋"}, + } + if len(got) != len(want) { + t.Fatalf("got %+v", got) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("row %d: %+v, want %+v", i, got[i], want[i]) + } + } +} diff --git a/internal/update/update.go b/internal/update/update.go index d0e251e..4d1a9e3 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -50,9 +50,12 @@ type Release struct { // every release ends with. func (r Release) Notes() string { notes, _, _ := strings.Cut(r.Body, "## Install") - return mdLink.ReplaceAllString(strings.TrimSpace(notes), "$1") + return PlainLinks(strings.TrimSpace(notes)) } +// PlainLinks shows Markdown links as their text, for a terminal. +func PlainLinks(s string) string { return mdLink.ReplaceAllString(s, "$1") } + // mdLink is a Markdown link, [text](url): the notes link PRs and commits, // and a terminal shows them as their text. var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)