feat: a status pane, like lazygit's: version, updates, accounts, files and links
CI / Checks (pull_request) Successful in 21s
PR title / Conventional title (pull_request) Successful in 2s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-11 11:07:36 +02:00
1 parent 1c8d69d957
commit 036f32d9ac
16 files changed
+379 -90

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 {
+48 -1
View File
@@ -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)
}
}
+3
View File
@@ -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
View File
@@ -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
}
+2
View File
@@ -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
View File
@@ -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
+15 -8
View File
@@ -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]())
}
+5 -6
View File
@@ -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)
}
}
+143
View File
@@ -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
View File
@@ -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 ──────────────────╮│ │
-12
View File
@@ -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 ""
}
+2 -2
View File
@@ -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.