feat: link each changelog entry to its PR and commit (#11)

Like Immich's and RomM's release notes: every changelog entry now links to its pull request and its commit, in `CHANGELOG.md` and in the release notes on Gitea:

```
- Search with fzf's scoring and query syntax ([#11](…/pulls/11)) ([65dc2d7](…/commit/65dc2d7…))
```

- `cliff.toml`: the entry template adds the short commit hash as a link; a postprocessor turns the squash commit's `(#N)` into a link to the PR.
- The app shows release notes as text (`U`, and the status pane's changelog to come), so `Release.Notes()` turns Markdown links back into their text: `Search (#11) (65dc2d7)`.

Checked with `git cliff --unreleased` on main: every entry since v0.1.0 gets both links. Test for the link stripping.

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

---------

Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
Reviewed-on: #11
This commit was merged in pull request #11.
This commit is contained in:
keb committed 2026-10-11 11:20:19 +02:00
1 parent 1c8d69d957
commit 56d3d25c9c
19 files changed
+396 -92

No files matched your search

+67 -25
View File
@@ -18,7 +18,6 @@ import (
"charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/clipboard"
@@ -40,6 +39,9 @@ const (
paneContainers
paneSecrets
paneDetail
// paneStatus is on top of the left panes; after the right pane in
// the tab order (status.go).
paneStatus
paneCount
)
@@ -69,6 +71,10 @@ type Options struct {
ClipboardClear time.Duration
// OpenURL opens a web page; defaults to the system browser.
OpenURL func(url string) error
// ConfigFiles and StatePath are where lazysecrets reads its config
// and keeps its state, for the status pane.
ConfigFiles []string
StatePath string
// CacheFile keeps what was loaded between runs; "" keeps it for this
// run only.
CacheFile string
@@ -174,11 +180,15 @@ type Model struct {
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
cacheDirty bool
sourcesAt map[string]time.Time
placeDir string
savedPlace string // the link last saved as the place
cachePath string
// configFiles and statePath are for the status pane (status.go).
configFiles []string
statePath string
statusW int // the status pane's width: the left column's, zoomed or not
cacheDirty bool
sourcesAt map[string]time.Time
placeDir string
savedPlace string // the link last saved as the place
// A pending clear of a copied value, counted down in the footer.
// clipToken identifies the latest copy so older ticks are ignored.
@@ -438,6 +448,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
if m.cachePath != "" {
m.loadCacheFile()
if len(m.sourceResults) > 0 {
@@ -802,7 +813,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Bottom):
m.jump(false)
case key.Matches(msg, k.NextPane):
m.focus = (m.focus + 1) % paneCount
// The four main panes, round; the status pane (0) leads into them.
m.focus = (m.focus + 1) % paneStatus
if r, ok := m.sources.Selected(); ok && m.focus == paneContainers && r.kind == rowGroup {
m.focus = paneDetail // an account's row: nothing between
}
@@ -810,7 +822,7 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
if m.focus == paneDetail {
m.focus = m.lastLeft // back to what it describes
} else {
m.focus = (m.focus + paneCount - 1) % paneCount
m.focus = (m.focus + paneStatus - 1) % paneStatus
}
case key.Matches(msg, k.Pane1):
m.focus = paneSources
@@ -820,6 +832,10 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
m.focus = paneSecrets
case key.Matches(msg, k.Pane4):
m.focus = paneDetail
case key.Matches(msg, k.Pane0):
m.focus = paneStatus
case m.focus == paneStatus && key.Matches(msg, k.Enter):
m.focus = paneDetail // its rows are on the right
case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup():
// folded or unfolded a provider group
case key.Matches(msg, k.Enter):
@@ -877,8 +893,10 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
case key.Matches(msg, k.Back):
if f := m.focusedFilter(); f != nil && f.HasFilter() {
f.ClearFilter()
} else if m.versionsOf != nil && m.focus >= paneSecrets {
} else if m.versionsOf != nil && (m.focus == paneSecrets || m.focus == paneDetail) {
m.closeVersions()
} else if m.focus == paneStatus {
m.focus = paneSources
} else if m.focus > paneSources {
m.focus--
}
@@ -1484,14 +1502,17 @@ func (m *Model) describe(o provider.Object) tea.Cmd {
// copyValue copies the selected object's value, fetching it without
// revealing it if it isn't shown already.
func (m *Model) copyValue() tea.Cmd {
if m.focus == paneDetail && m.fields.Shown() {
if f, ok := m.fields.Selected(); ok {
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false)
}
return nil
}
if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources {
return m.copyText(a.CopyLabel, a.Copy, false)
}
o, ok := m.detail.Secret()
if !ok {
if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources {
return m.copyText(a.CopyLabel, a.Copy, false)
}
if f, ok := m.fields.Selected(); ok && m.focus == paneDetail && m.fields.Shown() {
return m.copyText(fieldName(f), f.Value, false)
}
return nil
}
if v, ok := m.detail.Value(); ok {
@@ -1544,6 +1565,25 @@ func (m *Model) cancelClipClear() {
func (m *Model) openSelected() tea.Cmd {
var name, url string
var used func()
switch {
case m.focus == paneDetail && m.fields.Shown():
f, _ := m.fields.Selected()
if f.URL == "" {
m.flash = warning("nothing to open in the browser here")
return nil
}
name, url, used = f.Label, f.URL, func() {}
default:
name, url, used = m.openTarget()
}
if name == "" {
return nil
}
return m.openPage(name, url, used)
}
// openTarget is the focused pane's selected item, for the browser.
func (m *Model) openTarget() (name, url string, used func()) {
switch m.focus {
case paneSources:
s, _ := m.selectedSource()
@@ -1555,9 +1595,11 @@ func (m *Model) openSelected() tea.Cmd {
o, _ := m.detail.Secret()
name, url, used = o.Name, o.URL, func() { m.usedObject(o) }
}
if name == "" {
return nil
}
return name, url, used
}
// openPage opens url in the browser, or copies it over SSH.
func (m *Model) openPage(name, url string, used func()) tea.Cmd {
if url == "" {
m.flash = warning("no web page for " + name)
return nil
@@ -1617,7 +1659,7 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else {
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0, account: m.fields.Shown()}.ShortHelp(), 2)
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown()}.ShortHelp(), 2)
}
var lines []string
if !m.clipDeadline.IsZero() {
@@ -1627,12 +1669,6 @@ func (m Model) footerView() string {
if m.flash.text != "" {
lines = append(lines, m.flashLine(m.flash))
}
// The version at the right end, one column in, like lazygit.
if v := m.versionLabel(); v != "" {
up := m.updateLabel() // a newer release, in colour
gap := max(1, m.w-1-ansi.StringWidth(help)-ansi.StringWidth(v+up)-1)
help += strings.Repeat(" ", gap) + m.st.Muted.Render(v) + m.st.Notice.Render(up) + " "
}
return strings.Join(append(lines, " "+help), "\n")
}
@@ -1702,6 +1738,12 @@ func (m Model) problemText(providerID, err string) string {
// loginFor is the sign-in L runs: the selected account's, if it needs
// one, else the last one asked for.
func (m Model) loginFor() *provider.AuthError {
if f, ok := m.fields.Selected(); ok && m.focus == paneDetail && m.fields.Shown() && f.Provider != "" {
var auth *provider.AuthError
if res := m.sourceResults[f.Provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 {
return auth
}
}
if r, ok := m.sources.Selected(); ok {
var auth *provider.AuthError
if res := m.sourceResults[r.provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 {