feat: link each changelog entry to its PR and commit #11
No files matched your search
+4
-1
@@ -24,7 +24,8 @@ Hello world 👋
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | striptags | trim }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.breaking %}**Breaking:** {% endif %}{{ commit.message | split(pat="\n") | first | upper_first | trim }}\
|
||||
- {% if commit.breaking %}**Breaking:** {% endif %}{{ commit.message | split(pat="\n") | first | upper_first | trim }} \
|
||||
([{{ commit.id | truncate(length=7, end="") }}](https://git.kebcloud.no/keb/lazysecrets/commit/{{ commit.id }}))\
|
||||
{% endfor %}
|
||||
{% endfor %}\n
|
||||
"""
|
||||
@@ -33,6 +34,8 @@ trim = true
|
||||
# Runs on each part (header, every release) separately.
|
||||
postprocessors = [
|
||||
{ pattern = "\n{3,}", replace = "\n\n" },
|
||||
# Squash commits end in the PR's number: link it.
|
||||
{ pattern = "\\(#([0-9]+)\\)", replace = "([#${1}](https://git.kebcloud.no/keb/lazysecrets/pulls/${1}))" },
|
||||
]
|
||||
|
||||
[git]
|
||||
|
||||
@@ -369,6 +369,8 @@ Flags:
|
||||
Version: versionString(),
|
||||
Store: st,
|
||||
CacheFile: cacheFile,
|
||||
ConfigFiles: configFiles(*configPath),
|
||||
StatePath: statePath,
|
||||
Dir: placeDir,
|
||||
Open: open,
|
||||
Styles: &theme,
|
||||
@@ -381,3 +383,22 @@ Flags:
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// configFiles are the config files given, for the status pane: those
|
||||
// that exist, or the first if none does (where to create one).
|
||||
func configFiles(list string) []string {
|
||||
var all, found []string
|
||||
for _, f := range strings.Split(list, ",") {
|
||||
if f = strings.TrimSpace(f); f == "" {
|
||||
continue
|
||||
}
|
||||
all = append(all, f)
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
found = append(found, f)
|
||||
}
|
||||
}
|
||||
if len(found) == 0 && len(all) > 0 {
|
||||
return all[:1]
|
||||
}
|
||||
return found
|
||||
}
|
||||
@@ -112,6 +112,12 @@ that; there `j`/`k`, `/` and `y` work on its rows, and `shift+tab` goes
|
||||
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
|
||||
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.
|
||||
|
||||
| Key | Action |
|
||||
|---|---|
|
||||
| `ctrl+←`/`ctrl+→` or `<`/`>` | move the divider between the left panes and the detail pane |
|
||||
|
||||
+67
-25
@@ -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 {
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
@@ -57,7 +58,7 @@ func newDriverOpts(t *testing.T, opts Options, providers ...provider.Provider) *
|
||||
m := New(providers, opts)
|
||||
m.detail.Now = func() time.Time { return time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) }
|
||||
d := &driver{t: t, m: m}
|
||||
d.send(tea.WindowSizeMsg{Width: 100, Height: 30})
|
||||
d.send(tea.WindowSizeMsg{Width: 100, Height: 33}) // 30 for the main panes, 3 for status
|
||||
d.run(m.Init())
|
||||
return d
|
||||
}
|
||||
@@ -1189,3 +1190,49 @@ func TestDetailsFollowFocus(t *testing.T) {
|
||||
t.Errorf("value not shown:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusPane(t *testing.T) {
|
||||
t.Parallel()
|
||||
var opened []string
|
||||
d := newDriverOpts(t, Options{Version: "0.1.0", ConfigFiles: []string{"/cfg/config.yaml"}, StatePath: "/state/state.json",
|
||||
OpenURL: func(u string) error { opened = append(opened, u); return nil }},
|
||||
fake.Azure(0), signedOut{fake.Azure(0)})
|
||||
if !strings.Contains(d.screen(), "2 accounts · 1 need sign-in") {
|
||||
t.Errorf("status line:\n%s", d.screen())
|
||||
}
|
||||
d.keys("0")
|
||||
if d.m.focus != paneStatus {
|
||||
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",
|
||||
"/cfg/config.yaml", "/state/state.json", "lazysecrets/…/docs"} {
|
||||
if !strings.Contains(scr, want) {
|
||||
t.Errorf("missing %q:\n%s", want, scr)
|
||||
}
|
||||
}
|
||||
// Into its rows: L signs in to the failing account, y copies a path,
|
||||
// o opens a link.
|
||||
d.keys("enter")
|
||||
if d.m.focus != paneDetail {
|
||||
t.Fatalf("enter: focus %v", d.m.focus)
|
||||
}
|
||||
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Provider == "Azure:admin@contoso.com/t1" })
|
||||
if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" {
|
||||
t.Errorf("L signs in to %+v", a)
|
||||
}
|
||||
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "config" })
|
||||
d.keys("y")
|
||||
if got := d.clip().content; got != "/cfg/config.yaml" {
|
||||
t.Errorf("y copied %q", got)
|
||||
}
|
||||
d.m.fields.SelectWhere(func(f panes.Field) bool { return f.Label == "docs" })
|
||||
d.keys("o")
|
||||
if len(opened) != 1 || !strings.HasSuffix(opened[0], "/src/branch/main/docs") {
|
||||
t.Errorf("o opened %q", opened)
|
||||
}
|
||||
d.keys("shift+tab")
|
||||
if d.m.focus != paneStatus {
|
||||
t.Errorf("back to %v", d.m.focus)
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,9 @@ func paneContext(p pane) string {
|
||||
|
||||
// commandsHere are the custom commands for the focused pane.
|
||||
func (m *Model) commandsHere() []commands.Command {
|
||||
if m.focus == paneStatus {
|
||||
return nil
|
||||
}
|
||||
var out []commands.Command
|
||||
for _, c := range m.commands {
|
||||
if c.Context == commands.Global || c.Context == paneContext(m.focus) {
|
||||
|
||||
+40
-24
@@ -32,6 +32,15 @@ const sopsUnchanged = 200
|
||||
|
||||
// edit opens the selected file, or the file of the selected secret.
|
||||
func (m *Model) edit() tea.Cmd {
|
||||
if m.focus == paneDetail && m.fields.Shown() {
|
||||
f, ok := m.fields.Selected()
|
||||
if !ok || f.Path == "" {
|
||||
m.flash = warning("nothing to open in an editor here")
|
||||
return nil
|
||||
}
|
||||
name := filepath.Base(f.Path)
|
||||
return m.openInEditor(provider.File{Path: f.Path}, func(err error) tea.Msg { return editDoneMsg{name: name, err: err} })
|
||||
}
|
||||
var c provider.Container
|
||||
var o *provider.Object
|
||||
switch m.focus {
|
||||
@@ -81,30 +90,7 @@ func (m *Model) edit() tea.Cmd {
|
||||
m.flash = flash{}
|
||||
return tea.ExecProcess(cmd, finished)
|
||||
}
|
||||
if server := m.getenv("NVIM"); server != "" {
|
||||
open := m.nvimOpen
|
||||
return func() tea.Msg {
|
||||
if err := open(server, f.Path, f.Line); err != nil {
|
||||
return finished(err)
|
||||
}
|
||||
return tea.QuitMsg{}
|
||||
}
|
||||
}
|
||||
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
|
||||
// Through the shell, so EDITOR can carry arguments ("code -w").
|
||||
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
|
||||
m.flash = flash{}
|
||||
return tea.ExecProcess(exec.Command("/bin/sh", args...), finished)
|
||||
}
|
||||
switch err := m.openURL(f.Path); {
|
||||
case errors.Is(err, browser.ErrRemote):
|
||||
m.flash = warning("set $EDITOR to open files over SSH")
|
||||
case err != nil:
|
||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
||||
default:
|
||||
m.flash = done("opened " + name)
|
||||
}
|
||||
return nil
|
||||
return m.openInEditor(f, finished)
|
||||
}
|
||||
|
||||
// plusLine are editors that open at a line with +N before the file.
|
||||
@@ -206,3 +192,33 @@ func nvimOpen(server, path string, line int) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// openInEditor opens f: in the nvim lazysecrets runs in, quitting; else
|
||||
// in $VISUAL or $EDITOR, coming back after; else in the default app.
|
||||
func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.Cmd {
|
||||
name := filepath.Base(f.Path)
|
||||
if server := m.getenv("NVIM"); server != "" {
|
||||
open := m.nvimOpen
|
||||
return func() tea.Msg {
|
||||
if err := open(server, f.Path, f.Line); err != nil {
|
||||
return finished(err)
|
||||
}
|
||||
return tea.QuitMsg{}
|
||||
}
|
||||
}
|
||||
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
|
||||
// Through the shell, so EDITOR can carry arguments ("code -w").
|
||||
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
|
||||
m.flash = flash{}
|
||||
return tea.ExecProcess(exec.Command("/bin/sh", args...), finished)
|
||||
}
|
||||
switch err := m.openURL(f.Path); {
|
||||
case errors.Is(err, browser.ErrRemote):
|
||||
m.flash = warning("set $EDITOR to open files over SSH")
|
||||
case err != nil:
|
||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
||||
default:
|
||||
m.flash = done("opened " + name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -64,6 +64,8 @@ func (m *Model) syncFields() tea.Cmd {
|
||||
default:
|
||||
m.fields.Show("", nil)
|
||||
}
|
||||
case paneStatus:
|
||||
m.fields.Show("lazysecrets", m.statusFields())
|
||||
case paneContainers:
|
||||
if c, ok := m.containers.Selected(); ok {
|
||||
m.fields.Show(c.Name, m.containerFields(c))
|
||||
|
||||
+12
-4
@@ -36,7 +36,8 @@ type contextHelp struct {
|
||||
k keys.Map
|
||||
focus pane
|
||||
commands bool // custom commands apply here
|
||||
account bool // the fourth pane describes an account (fields.go)
|
||||
account bool // the fourth pane shows Fields (fields.go)
|
||||
of pane // what the fourth pane describes
|
||||
}
|
||||
|
||||
func (c contextHelp) ShortHelp() []key.Binding {
|
||||
@@ -45,8 +46,15 @@ func (c contextHelp) ShortHelp() []key.Binding {
|
||||
if k.Login.Enabled() {
|
||||
b = append(b, k.Login)
|
||||
}
|
||||
if c.focus == paneDetail && c.account {
|
||||
copyField := key.NewBinding(key.WithKeys(k.Copy.Keys()...), key.WithHelp(k.Copy.Help().Key, "copy"))
|
||||
copyField := key.NewBinding(key.WithKeys(k.Copy.Keys()...), key.WithHelp(k.Copy.Help().Key, "copy"))
|
||||
switch {
|
||||
case c.focus == paneStatus:
|
||||
details := key.NewBinding(key.WithKeys(k.Enter.Keys()...), key.WithHelp(k.Enter.Help().Key, "details"))
|
||||
return append(b, details, k.Update, k.Help, k.Quit)
|
||||
case c.focus == paneDetail && c.account && c.of == paneStatus:
|
||||
open := key.NewBinding(key.WithKeys(k.Open.Keys()...), key.WithHelp(k.Open.Help().Key, "open link"))
|
||||
return append(b, k.Up, k.Down, copyField, open, k.Edit, k.Filter, k.Help, k.Quit)
|
||||
case c.focus == paneDetail && c.account:
|
||||
return append(b, k.Up, k.Down, copyField, k.Filter, k.Help, k.Quit)
|
||||
}
|
||||
switch c.focus {
|
||||
@@ -65,7 +73,7 @@ func (c contextHelp) ShortHelp() []key.Binding {
|
||||
|
||||
func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.ShortHelp()} }
|
||||
|
||||
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail"}
|
||||
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
|
||||
|
||||
// showKeybindings opens the ? menu, like lazygit's: every binding with
|
||||
// what it does, the focused pane's first, filterable by key, description
|
||||
|
||||
@@ -101,7 +101,7 @@ func (m *Model) resizeFocused(delta int) bool {
|
||||
if m.focus > paneSecrets {
|
||||
return false
|
||||
}
|
||||
avail := m.leftAvail()
|
||||
avail := m.listAvail()
|
||||
h := m.leftHeights(avail)
|
||||
i := int(m.focus)
|
||||
j := i + 1
|
||||
@@ -128,6 +128,9 @@ func (m *Model) saveLayout() {
|
||||
|
||||
func (m Model) leftAvail() int { return m.h - lipgloss.Height(m.footerView()) }
|
||||
|
||||
// listAvail is the rows the three list panes share, under the status pane.
|
||||
func (m Model) listAvail() int { return m.leftAvail() - statusH }
|
||||
|
||||
func (m *Model) cycleZoom(delta int) {
|
||||
m.zoom = (m.zoom + zoom(delta) + zoomCount) % zoomCount
|
||||
}
|
||||
@@ -138,22 +141,22 @@ func (m *Model) layout() {
|
||||
}
|
||||
m.help.SetWidth(max(0, m.w-1)) // one column in, like messages
|
||||
avail := m.leftAvail()
|
||||
leftW, h := m.leftWidth(), m.leftHeights(avail)
|
||||
leftW, h := m.leftWidth(), m.leftHeights(avail-statusH)
|
||||
|
||||
switch m.zoom {
|
||||
case zoomHalf:
|
||||
if m.focus == paneDetail {
|
||||
if m.focus == paneDetail || m.focus == paneStatus {
|
||||
leftW = minLeftW
|
||||
} else {
|
||||
leftW = max(m.leftWidth(), m.w/2)
|
||||
h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH}
|
||||
h[m.focus] = avail
|
||||
h = fitHeights(h, avail, tinyPaneH)
|
||||
h[m.focus] = avail - statusH
|
||||
h = fitHeights(h, avail-statusH, tinyPaneH)
|
||||
}
|
||||
case zoomFull:
|
||||
// Only the focused pane is drawn; it gets the whole screen.
|
||||
switch m.focus {
|
||||
case paneDetail:
|
||||
case paneDetail, paneStatus:
|
||||
leftW = 0
|
||||
default:
|
||||
leftW = m.w
|
||||
@@ -162,6 +165,7 @@ func (m *Model) layout() {
|
||||
}
|
||||
}
|
||||
|
||||
m.statusW = leftW
|
||||
m.sources.SetSize(leftW, h[0])
|
||||
m.containers.SetSize(leftW, h[1])
|
||||
m.secrets.SetSize(leftW, h[2])
|
||||
@@ -176,7 +180,7 @@ func (m Model) active(p pane) bool { return m.focus == p && !m.termBlurred }
|
||||
|
||||
func (m Model) panesView() string {
|
||||
spin := m.spin.View()
|
||||
views := [paneCount]func() string{
|
||||
views := [paneStatus]func() string{
|
||||
func() string { return m.sources.View(m.st, m.active(paneSources), spin) },
|
||||
func() string { return m.containers.View(m.st, m.active(paneContainers), spin) },
|
||||
func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) },
|
||||
@@ -188,8 +192,11 @@ func (m Model) panesView() string {
|
||||
},
|
||||
}
|
||||
if m.zoom == zoomFull {
|
||||
if m.focus == paneStatus {
|
||||
return views[paneDetail]() // its rows
|
||||
}
|
||||
return views[m.focus]()
|
||||
}
|
||||
left := lipgloss.JoinVertical(lipgloss.Left, views[0](), views[1](), views[2]())
|
||||
left := lipgloss.JoinVertical(lipgloss.Left, m.statusView(), views[0](), views[1](), views[2]())
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, views[3]())
|
||||
}
|
||||
@@ -258,14 +258,13 @@ func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionBottomRight(t *testing.T) {
|
||||
func TestVersionInStatus(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{Version: "0.1.0"})
|
||||
bar := lastLine(d.screen())
|
||||
if !strings.HasSuffix(bar, "v0.1.0 ") || ansi.StringWidth(bar) != 100 {
|
||||
t.Errorf("help line %q (%d wide)", bar, ansi.StringWidth(bar))
|
||||
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
|
||||
t.Errorf("no version in the status pane:\n%s", d.screen())
|
||||
}
|
||||
if !strings.Contains(bar, "? help • q quit") {
|
||||
t.Errorf("help squeezed out by the version: %q", bar)
|
||||
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
|
||||
t.Errorf("help line %q", bar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
add(panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")})
|
||||
add(panes.Field{Label: "update", Value: m.updateStatus(), Info: m.newer == nil})
|
||||
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"})
|
||||
return out
|
||||
}
|
||||
|
||||
// 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))
|
||||
}
|
||||
+8
-5
@@ -1,8 +1,11 @@
|
||||
╭─ [1] Sources - Favourites ────╮╭─ platform-prod ─────────────────────────────────────────────────╮
|
||||
│ ▾ Azure (2) ││ name platform-prod │
|
||||
│ platform-prod ││ ID sub-prod │
|
||||
│ platform-dev ││ web https://portal.azure.com/#resource/subscriptions/sub…│
|
||||
│ ▾ Databricks (2) ││ vaults 3 │
|
||||
╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮
|
||||
│ 2 accounts ││ name platform-prod │
|
||||
╰───────────────────────────────╯│ ID sub-prod │
|
||||
╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│
|
||||
│ ▾ Azure (2) ││ vaults 3 │
|
||||
│ platform-prod ││ │
|
||||
│ platform-dev ││ │
|
||||
│ ▾ Databricks (2) ││ │
|
||||
│ prod ││ │
|
||||
╰────────────────────── 2 of 6 ─╯│ │
|
||||
╭─ [2] Vaults ──────────────────╮│ │
|
||||
|
||||
@@ -155,15 +155,3 @@ func (m *Model) handleInstalled(msg installedMsg) {
|
||||
m.updated = msg.release.Tag
|
||||
m.flash = done("updated to lazysecrets " + msg.release.Tag + " · restart lazysecrets to use it")
|
||||
}
|
||||
|
||||
// updateLabel is the version's suffix in the footer: a newer release to
|
||||
// update to, or the one installed and waiting for a restart.
|
||||
func (m Model) updateLabel() string {
|
||||
switch {
|
||||
case m.updated != "":
|
||||
return " → " + m.updated + " (restart)"
|
||||
case m.newer != nil:
|
||||
return " ↑ " + m.newer.Tag + withKey(m.keys.Update, " %s")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -47,8 +47,8 @@ func TestUpdate(t *testing.T) {
|
||||
if f.asked != 1 {
|
||||
t.Fatalf("asked %d times at startup", f.asked)
|
||||
}
|
||||
if !strings.Contains(d.screen(), "v0.1.1 ↑ v0.2.0 U") {
|
||||
t.Errorf("no update in the footer:\n%s", d.screen())
|
||||
if scr := d.screen(); !strings.Contains(scr, "[0] lazysecrets v0.1.1") || !strings.Contains(scr, "↑ v0.2.0 U") {
|
||||
t.Errorf("no update in the status pane:\n%s", scr)
|
||||
}
|
||||
|
||||
// U: the changelog, without the install notes; u installs.
|
||||
|
||||
@@ -37,7 +37,7 @@ type Map struct {
|
||||
// lists
|
||||
Up, Down, Top, Bottom key.Binding
|
||||
NextPane, PrevPane key.Binding
|
||||
Pane1, Pane2, Pane3, Pane4 key.Binding
|
||||
Pane0, Pane1, Pane2, Pane3, Pane4 key.Binding
|
||||
NextTab, PrevTab key.Binding
|
||||
Enter, Back, Filter key.Binding
|
||||
Reveal, Copy, CopyRef, Open, Edit key.Binding
|
||||
@@ -92,6 +92,7 @@ func (m *Map) Actions() []Action {
|
||||
a(Lists, "pane2", &m.Pane2, "Vaults pane", "move", "Focus the Vaults (or Scopes) pane."),
|
||||
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
|
||||
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
|
||||
a(Lists, "pane0", &m.Pane0, "status", "move", "Focus the status pane: version and updates, accounts, config files, links and the changelog, on the right."),
|
||||
a(Lists, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
|
||||
a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
|
||||
|
||||
@@ -178,7 +179,7 @@ var defaults = map[string][]string{
|
||||
"lists.up": {"k", "up", "ctrl+k", "ctrl+p"}, "lists.down": {"j", "down", "ctrl+j", "ctrl+n"},
|
||||
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
|
||||
"lists.nextPane": {"tab", "l", "right"}, "lists.prevPane": {"shift+tab", "h", "left"},
|
||||
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"},
|
||||
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"}, "lists.pane0": {"0"},
|
||||
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
|
||||
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
|
||||
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
|
||||
|
||||
@@ -19,10 +19,13 @@ type Fields struct {
|
||||
}
|
||||
|
||||
// Field is a row: a value under a label. Info rows (loading, errors)
|
||||
// can't be copied; Err ones are errors.
|
||||
// can't be copied; Err ones are errors. A row can also be opened: URL in
|
||||
// the browser (o), Path in the editor (e), or Provider signed in to (L).
|
||||
type Field struct {
|
||||
Label, Value string
|
||||
Info, Err bool
|
||||
|
||||
URL, Path, Provider string
|
||||
}
|
||||
|
||||
func NewFields() Fields {
|
||||
|
||||
@@ -22,6 +22,7 @@ import (
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -49,9 +50,13 @@ type Release struct {
|
||||
// every release ends with.
|
||||
func (r Release) Notes() string {
|
||||
notes, _, _ := strings.Cut(r.Body, "## Install")
|
||||
return strings.TrimSpace(notes)
|
||||
return mdLink.ReplaceAllString(strings.TrimSpace(notes), "$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]+)\)`)
|
||||
|
||||
// Client talks to the release server.
|
||||
type Client struct {
|
||||
API string // the repository's API, DefaultAPI when empty
|
||||
|
||||
@@ -123,3 +123,10 @@ func TestInstallRefusesATamperedArchive(t *testing.T) {
|
||||
t.Error("replaced anyway")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotesShowLinksAsText(t *testing.T) {
|
||||
r := Release{Body: "### Added\n\n- Search ([#11](https://git.example.com/pulls/11)) ([65dc2d7](https://git.example.com/commit/65dc2d7abc))\n\n## Install\n\ncurl …"}
|
||||
if got, want := r.Notes(), "### Added\n\n- Search (#11) (65dc2d7)"; got != want {
|
||||
t.Errorf("notes %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user