3 Commits
Author SHA1 Message Date
kebandClaude Opus 5.5 66faafb9b9 feat: the logo on top of the status pane's rows
CI / Checks (pull_request) Successful in 24s
PR title / Conventional title (pull_request) Failing after 1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 13:26:06 +02:00
kebandClaude Opus 5.5 c129f22f5b feat: releases and changelog links in the status pane; the version opens its release
CI / Checks (pull_request) Successful in 25s
PR title / Conventional title (pull_request) Successful in 2s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 13:09:49 +02:00
kebandClaude Opus 5.5 dc856677cc feat: the changelog in the status pane, built in
CI / Checks (pull_request) Successful in 24s
PR title / Conventional title (pull_request) Successful in 1s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-11 11:23:20 +02:00
11 changed files with 147 additions and 8 deletions

No files matched your search

+10
View File
@@ -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
+2
View File
@@ -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}: " },
+2
View File
@@ -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,
+6 -2
View File
@@ -113,10 +113,14 @@ back. A value you reveal stays in view whichever pane you're in, until
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: the version
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 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 |
|---|---|
+4 -1
View File
@@ -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 {
+6 -1
View File
@@ -1205,7 +1205,7 @@ func TestStatusPane(t *testing.T) {
t.Fatalf("focus %v", d.m.focus)
}
scr := d.screen()
for _, want := range []string{"version v0.1.0", "GPL-3.0-or-later", "Azure · 2 subscriptions", "Azure · admin · sign-in required",
for _, want := range []string{logo[2], "version v0.1.0", "GPL-3.0-or-later", "Azure · 2 subscriptions", "Azure · admin · sign-in required",
"/cfg/config.yaml", "/state/state.json", "lazysecrets/…/docs"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
@@ -1231,6 +1231,11 @@ func TestStatusPane(t *testing.T) {
if len(opened) != 1 || !strings.HasSuffix(opened[0], "/src/branch/main/docs") {
t.Errorf("o opened %q", opened)
}
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "version" })
d.keys("o")
if len(opened) != 2 || !strings.HasSuffix(opened[1], "/releases/tag/v0.1.0") {
t.Errorf("o on the version opened %q", opened)
}
d.keys("shift+tab")
if d.m.focus != paneStatus {
t.Errorf("back to %v", d.m.focus)
+2
View File
@@ -49,6 +49,7 @@ func (m *Model) syncFields() tea.Cmd {
m.fields.Hide()
return nil
}
m.fields.Header = nil
switch m.detailOf() {
case paneSources:
r, ok := m.sources.Selected()
@@ -65,6 +66,7 @@ func (m *Model) syncFields() tea.Cmd {
m.fields.Show("", nil)
}
case paneStatus:
m.fields.Header = logo
m.fields.Show("lazysecrets", m.statusFields())
case paneContainers:
if c, ok := m.containers.Selected(); ok {
+52 -3
View File
@@ -16,6 +16,16 @@ import (
// (0), the right pane has the rest: updates, each account, the files
// lazysecrets uses, links, and the changelog.
// logo heads the status pane's rows.
var logo = []string{
` _ _`,
`| | __ _ _____ _ ___ ___ ___ _ __ ___| |_ ___`,
`| |/ _` + "`" + ` |_ / | | / __|/ _ \/ __| '__/ _ \ __/ __|`,
`| | (_| |/ /| |_| \__ \ __/ (__| | | __/ |_\__ \`,
`|_|\__,_/___|\__, |___/\___|\___|_| \___|\__|___/`,
` |___/`,
}
// statusH is the status pane's height: one line in a box.
const statusH = 3
@@ -65,8 +75,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 +113,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 +162,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, "<!--"):
// A release's own words ("Hello world 👋").
out = append(out, panes.Field{Value: update.PlainLinks(line)})
}
}
return out
}
+30
View File
@@ -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])
}
}
}
+29
View File
@@ -34,6 +34,9 @@ type List[T any] struct {
Empty string // shown when loaded with no items
Loading bool
Err error
// Header lines (a logo) go above the items, when the pane has room
// for them and there's no filter.
Header []string
item Item[T]
items []T
@@ -159,9 +162,29 @@ func (l List[T]) rows() int {
if l.filtering {
r--
}
if l.headerShown() {
r -= len(l.Header) + 1
}
return max(0, r)
}
// headerMinRows is how many item rows the header leaves at least.
const headerMinRows = 5
// headerShown reports whether Header fits: as wide as the pane allows,
// leaving headerMinRows, a blank line under it, and no filter.
func (l List[T]) headerShown() bool {
if len(l.Header) == 0 || l.filtering || l.input.Value() != "" {
return false
}
for _, line := range l.Header {
if ansi.StringWidth(line) > l.w-3 {
return false
}
}
return l.h-2-len(l.Header)-1 >= headerMinRows
}
// Filtering reports whether the filter input has focus.
func (l List[T]) Filtering() bool { return l.filtering }
@@ -325,6 +348,12 @@ func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
if l.filtering {
body = append(body, l.input.View())
}
if l.headerShown() {
for _, line := range l.Header {
body = append(body, st.Notice.Render(" "+line))
}
body = append(body, "")
}
switch {
case l.Loading && len(l.items) == 0:
+4 -1
View File
@@ -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]+)\)`)