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
This commit was merged in pull request #12.
This commit is contained in:
1 parent
56d3d25c9c
commit
9b0329e9da
8 files changed
+98
-6
No files matched your search
@@ -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
|
||||||
@@ -49,6 +49,8 @@ commit_preprocessors = [
|
|||||||
# A merge commit (not a squash) says "Merge pull request 'feat: …' (#4)
|
# A merge commit (not a squash) says "Merge pull request 'feat: …' (#4)
|
||||||
# from branch into main": keep the title and number.
|
# from branch into main": keep the title and number.
|
||||||
{ pattern = "^Merge pull request '(.+)' \\((#[0-9]+)\\) from .*", replace = "${1} (${2})" },
|
{ 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
|
# Titles may be written "[feat] x", "[feat]: x" or "feat:x" (see
|
||||||
# pr-title.yml); all become "feat: x".
|
# 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}: " },
|
{ pattern = "^\\[(feat|fix|perf|refactor|docs|test|ci|chore|build|style|revert)(\\([^)]*\\))?(!?)\\](!?):?\\s*", replace = "${1}${2}${3}${4}: " },
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
|
|
||||||
tea "charm.land/bubbletea/v2"
|
tea "charm.land/bubbletea/v2"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
"git.kebcloud.no/keb/lazysecrets/internal/config"
|
||||||
"git.kebcloud.no/keb/lazysecrets/internal/instance"
|
"git.kebcloud.no/keb/lazysecrets/internal/instance"
|
||||||
@@ -371,6 +372,7 @@ Flags:
|
|||||||
CacheFile: cacheFile,
|
CacheFile: cacheFile,
|
||||||
ConfigFiles: configFiles(*configPath),
|
ConfigFiles: configFiles(*configPath),
|
||||||
StatePath: statePath,
|
StatePath: statePath,
|
||||||
|
Changelog: lazysecrets.Changelog,
|
||||||
Dir: placeDir,
|
Dir: placeDir,
|
||||||
Open: open,
|
Open: open,
|
||||||
Styles: &theme,
|
Styles: &theme,
|
||||||
|
|||||||
+4
-1
@@ -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
|
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
|
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
|
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 |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ type Options struct {
|
|||||||
// and keeps its state, for the status pane.
|
// and keeps its state, for the status pane.
|
||||||
ConfigFiles []string
|
ConfigFiles []string
|
||||||
StatePath string
|
StatePath string
|
||||||
|
// Changelog is CHANGELOG.md, for the status pane.
|
||||||
|
Changelog string
|
||||||
// CacheFile keeps what was loaded between runs; "" keeps it for this
|
// CacheFile keeps what was loaded between runs; "" keeps it for this
|
||||||
// run only.
|
// run only.
|
||||||
CacheFile string
|
CacheFile string
|
||||||
@@ -185,6 +187,7 @@ type Model struct {
|
|||||||
configFiles []string
|
configFiles []string
|
||||||
statePath string
|
statePath string
|
||||||
statusW int // the status pane's width: the left column's, zoomed or not
|
statusW int // the status pane's width: the left column's, zoomed or not
|
||||||
|
changelog []panes.Field
|
||||||
cacheDirty bool
|
cacheDirty bool
|
||||||
sourcesAt map[string]time.Time
|
sourcesAt map[string]time.Time
|
||||||
placeDir string
|
placeDir string
|
||||||
@@ -448,7 +451,7 @@ func New(providers []provider.Provider, opts Options) Model {
|
|||||||
m.spinning = true
|
m.spinning = true
|
||||||
|
|
||||||
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
|
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 != "" {
|
if m.cachePath != "" {
|
||||||
m.loadCacheFile()
|
m.loadCacheFile()
|
||||||
if len(m.sourceResults) > 0 {
|
if len(m.sourceResults) > 0 {
|
||||||
|
|||||||
@@ -65,8 +65,17 @@ func (m Model) statusFields() []panes.Field {
|
|||||||
out = append(out, f)
|
out = append(out, f)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
add(panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")})
|
// A release's rows open its release page.
|
||||||
add(panes.Field{Label: "update", Value: m.updateStatus(), Info: m.newer == nil})
|
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})
|
add(panes.Field{Label: "license", Value: "GPL-3.0-or-later · © 2026 Karl-Einar Bergstrøm", Info: true})
|
||||||
|
|
||||||
for i, p := range m.providers {
|
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: "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: "source", Value: repo, URL: update.DefaultRepo})
|
||||||
add(panes.Field{Label: "issues", Value: repo + "/issues", URL: update.DefaultRepo + "/issues"})
|
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
|
// 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))
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestChangelogFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
md := "# Changelog\n\nEvery release of lazysecrets, newest first.\n\n## v0.2.0 – 2026-10-12\n\n### Added\n\n" +
|
||||||
|
"- Search ([#11](https://x/pulls/11)) ([65dc2d7](https://x/commit/65dc2d7))\n\n### Fixed\n\n- A fix (#3)\n\n" +
|
||||||
|
"## v0.1.0 – 2026-10-10\n\nHello world 👋\n\n<!-- generated by git-cliff (cliff.toml) -->\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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -50,9 +50,12 @@ type Release struct {
|
|||||||
// every release ends with.
|
// every release ends with.
|
||||||
func (r Release) Notes() string {
|
func (r Release) Notes() string {
|
||||||
notes, _, _ := strings.Cut(r.Body, "## Install")
|
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,
|
// mdLink is a Markdown link, [text](url): the notes link PRs and commits,
|
||||||
// and a terminal shows them as their text.
|
// and a terminal shows them as their text.
|
||||||
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
var mdLink = regexp.MustCompile(`\[([^\]]+)\]\((https?://[^)\s]+)\)`)
|
||||||
|
|||||||
Reference in new issue
Block a user