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
+372 -83

No files matched your search

+21
View File
@@ -369,6 +369,8 @@ Flags:
Version: versionString(), Version: versionString(),
Store: st, Store: st,
CacheFile: cacheFile, CacheFile: cacheFile,
ConfigFiles: configFiles(*configPath),
StatePath: statePath,
Dir: placeDir, Dir: placeDir,
Open: open, Open: open,
Styles: &theme, Styles: &theme,
@@ -381,3 +383,22 @@ Flags:
} }
return err 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
}
+6
View File
@@ -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 back. A value you reveal stays in view whichever pane you're in, until
it's hidden. 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 | | Key | Action |
|---|---| |---|---|
| `ctrl+←`/`ctrl+→` or `<`/`>` | move the divider between the left panes and the detail pane | | `ctrl+←`/`ctrl+→` or `<`/`>` | move the divider between the left panes and the detail pane |
+61 -19
View File
@@ -18,7 +18,6 @@ import (
"charm.land/bubbles/v2/spinner" "charm.land/bubbles/v2/spinner"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2" "charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/browser" "git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/clipboard" "git.kebcloud.no/keb/lazysecrets/internal/clipboard"
@@ -40,6 +39,9 @@ const (
paneContainers paneContainers
paneSecrets paneSecrets
paneDetail paneDetail
// paneStatus is on top of the left panes; after the right pane in
// the tab order (status.go).
paneStatus
paneCount paneCount
) )
@@ -69,6 +71,10 @@ type Options struct {
ClipboardClear time.Duration ClipboardClear time.Duration
// OpenURL opens a web page; defaults to the system browser. // OpenURL opens a web page; defaults to the system browser.
OpenURL func(url string) error 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 // CacheFile keeps what was loaded between runs; "" keeps it for this
// run only. // run only.
CacheFile string CacheFile string
@@ -175,6 +181,10 @@ type Model struct {
// The cache file, whether the cache changed since it was saved, and // The cache file, whether the cache changed since it was saved, and
// when each provider's Sources list was loaded (persist.go). // when each provider's Sources list was loaded (persist.go).
cachePath string 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 cacheDirty bool
sourcesAt map[string]time.Time sourcesAt map[string]time.Time
placeDir string placeDir string
@@ -438,6 +448,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
if m.cachePath != "" { if m.cachePath != "" {
m.loadCacheFile() m.loadCacheFile()
if len(m.sourceResults) > 0 { 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): case key.Matches(msg, k.Bottom):
m.jump(false) m.jump(false)
case key.Matches(msg, k.NextPane): 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 { if r, ok := m.sources.Selected(); ok && m.focus == paneContainers && r.kind == rowGroup {
m.focus = paneDetail // an account's row: nothing between 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 { if m.focus == paneDetail {
m.focus = m.lastLeft // back to what it describes m.focus = m.lastLeft // back to what it describes
} else { } else {
m.focus = (m.focus + paneCount - 1) % paneCount m.focus = (m.focus + paneStatus - 1) % paneStatus
} }
case key.Matches(msg, k.Pane1): case key.Matches(msg, k.Pane1):
m.focus = paneSources m.focus = paneSources
@@ -820,6 +832,10 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
m.focus = paneSecrets m.focus = paneSecrets
case key.Matches(msg, k.Pane4): case key.Matches(msg, k.Pane4):
m.focus = paneDetail 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(): case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup():
// folded or unfolded a provider group // folded or unfolded a provider group
case key.Matches(msg, k.Enter): 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): case key.Matches(msg, k.Back):
if f := m.focusedFilter(); f != nil && f.HasFilter() { if f := m.focusedFilter(); f != nil && f.HasFilter() {
f.ClearFilter() f.ClearFilter()
} else if m.versionsOf != nil && m.focus >= paneSecrets { } else if m.versionsOf != nil && (m.focus == paneSecrets || m.focus == paneDetail) {
m.closeVersions() m.closeVersions()
} else if m.focus == paneStatus {
m.focus = paneSources
} else if m.focus > paneSources { } else if m.focus > paneSources {
m.focus-- 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 // copyValue copies the selected object's value, fetching it without
// revealing it if it isn't shown already. // revealing it if it isn't shown already.
func (m *Model) copyValue() tea.Cmd { func (m *Model) copyValue() tea.Cmd {
o, ok := m.detail.Secret() if m.focus == paneDetail && m.fields.Shown() {
if !ok { 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 { if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources {
return m.copyText(a.CopyLabel, a.Copy, false) return m.copyText(a.CopyLabel, a.Copy, false)
} }
if f, ok := m.fields.Selected(); ok && m.focus == paneDetail && m.fields.Shown() { o, ok := m.detail.Secret()
return m.copyText(fieldName(f), f.Value, false) if !ok {
}
return nil return nil
} }
if v, ok := m.detail.Value(); ok { if v, ok := m.detail.Value(); ok {
@@ -1544,6 +1565,25 @@ func (m *Model) cancelClipClear() {
func (m *Model) openSelected() tea.Cmd { func (m *Model) openSelected() tea.Cmd {
var name, url string var name, url string
var used func() 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 { switch m.focus {
case paneSources: case paneSources:
s, _ := m.selectedSource() s, _ := m.selectedSource()
@@ -1555,9 +1595,11 @@ func (m *Model) openSelected() tea.Cmd {
o, _ := m.detail.Secret() o, _ := m.detail.Secret()
name, url, used = o.Name, o.URL, func() { m.usedObject(o) } name, url, used = o.Name, o.URL, func() { m.usedObject(o) }
} }
if name == "" { return name, url, used
return nil }
}
// openPage opens url in the browser, or copies it over SSH.
func (m *Model) openPage(name, url string, used func()) tea.Cmd {
if url == "" { if url == "" {
m.flash = warning("no web page for " + name) m.flash = warning("no web page for " + name)
return nil return nil
@@ -1617,7 +1659,7 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() { } else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else { } 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 var lines []string
if !m.clipDeadline.IsZero() { if !m.clipDeadline.IsZero() {
@@ -1627,12 +1669,6 @@ func (m Model) footerView() string {
if m.flash.text != "" { if m.flash.text != "" {
lines = append(lines, m.flashLine(m.flash)) 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") 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 // loginFor is the sign-in L runs: the selected account's, if it needs
// one, else the last one asked for. // one, else the last one asked for.
func (m Model) loginFor() *provider.AuthError { 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 { if r, ok := m.sources.Selected(); ok {
var auth *provider.AuthError var auth *provider.AuthError
if res := m.sourceResults[r.provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 { 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"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake" "git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys" "git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
) )
func TestMain(m *testing.M) { func TestMain(m *testing.M) {
@@ -57,7 +58,7 @@ func newDriverOpts(t *testing.T, opts Options, providers ...provider.Provider) *
m := New(providers, opts) m := New(providers, opts)
m.detail.Now = func() time.Time { return time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) } 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 := &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()) d.run(m.Init())
return d return d
} }
@@ -1189,3 +1190,49 @@ func TestDetailsFollowFocus(t *testing.T) {
t.Errorf("value not shown:\n%s", d.screen()) 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. // commandsHere are the custom commands for the focused pane.
func (m *Model) commandsHere() []commands.Command { func (m *Model) commandsHere() []commands.Command {
if m.focus == paneStatus {
return nil
}
var out []commands.Command var out []commands.Command
for _, c := range m.commands { for _, c := range m.commands {
if c.Context == commands.Global || c.Context == paneContext(m.focus) { 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. // edit opens the selected file, or the file of the selected secret.
func (m *Model) edit() tea.Cmd { 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 c provider.Container
var o *provider.Object var o *provider.Object
switch m.focus { switch m.focus {
@@ -81,30 +90,7 @@ func (m *Model) edit() tea.Cmd {
m.flash = flash{} m.flash = flash{}
return tea.ExecProcess(cmd, finished) return tea.ExecProcess(cmd, finished)
} }
if server := m.getenv("NVIM"); server != "" { return m.openInEditor(f, finished)
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
} }
// plusLine are editors that open at a line with +N before the file. // 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 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: default:
m.fields.Show("", nil) m.fields.Show("", nil)
} }
case paneStatus:
m.fields.Show("lazysecrets", m.statusFields())
case paneContainers: case paneContainers:
if c, ok := m.containers.Selected(); ok { if c, ok := m.containers.Selected(); ok {
m.fields.Show(c.Name, m.containerFields(c)) m.fields.Show(c.Name, m.containerFields(c))
+11 -3
View File
@@ -36,7 +36,8 @@ type contextHelp struct {
k keys.Map k keys.Map
focus pane focus pane
commands bool // custom commands apply here 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 { func (c contextHelp) ShortHelp() []key.Binding {
@@ -45,8 +46,15 @@ func (c contextHelp) ShortHelp() []key.Binding {
if k.Login.Enabled() { if k.Login.Enabled() {
b = append(b, k.Login) 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) return append(b, k.Up, k.Down, copyField, k.Filter, k.Help, k.Quit)
} }
switch c.focus { switch c.focus {
@@ -65,7 +73,7 @@ func (c contextHelp) ShortHelp() []key.Binding {
func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.ShortHelp()} } 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 // showKeybindings opens the ? menu, like lazygit's: every binding with
// what it does, the focused pane's first, filterable by key, description // 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 { if m.focus > paneSecrets {
return false return false
} }
avail := m.leftAvail() avail := m.listAvail()
h := m.leftHeights(avail) h := m.leftHeights(avail)
i := int(m.focus) i := int(m.focus)
j := i + 1 j := i + 1
@@ -128,6 +128,9 @@ func (m *Model) saveLayout() {
func (m Model) leftAvail() int { return m.h - lipgloss.Height(m.footerView()) } 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) { func (m *Model) cycleZoom(delta int) {
m.zoom = (m.zoom + zoom(delta) + zoomCount) % zoomCount 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 m.help.SetWidth(max(0, m.w-1)) // one column in, like messages
avail := m.leftAvail() avail := m.leftAvail()
leftW, h := m.leftWidth(), m.leftHeights(avail) leftW, h := m.leftWidth(), m.leftHeights(avail-statusH)
switch m.zoom { switch m.zoom {
case zoomHalf: case zoomHalf:
if m.focus == paneDetail { if m.focus == paneDetail || m.focus == paneStatus {
leftW = minLeftW leftW = minLeftW
} else { } else {
leftW = max(m.leftWidth(), m.w/2) leftW = max(m.leftWidth(), m.w/2)
h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH} h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH}
h[m.focus] = avail h[m.focus] = avail - statusH
h = fitHeights(h, avail, tinyPaneH) h = fitHeights(h, avail-statusH, tinyPaneH)
} }
case zoomFull: case zoomFull:
// Only the focused pane is drawn; it gets the whole screen. // Only the focused pane is drawn; it gets the whole screen.
switch m.focus { switch m.focus {
case paneDetail: case paneDetail, paneStatus:
leftW = 0 leftW = 0
default: default:
leftW = m.w leftW = m.w
@@ -162,6 +165,7 @@ func (m *Model) layout() {
} }
} }
m.statusW = leftW
m.sources.SetSize(leftW, h[0]) m.sources.SetSize(leftW, h[0])
m.containers.SetSize(leftW, h[1]) m.containers.SetSize(leftW, h[1])
m.secrets.SetSize(leftW, h[2]) 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 { func (m Model) panesView() string {
spin := m.spin.View() 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.sources.View(m.st, m.active(paneSources), spin) },
func() string { return m.containers.View(m.st, m.active(paneContainers), 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) }, 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.zoom == zoomFull {
if m.focus == paneStatus {
return views[paneDetail]() // its rows
}
return views[m.focus]() 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]()) 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() t.Parallel()
d := newDriverOpts(t, Options{Version: "0.1.0"}) d := newDriverOpts(t, Options{Version: "0.1.0"})
bar := lastLine(d.screen()) if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
if !strings.HasSuffix(bar, "v0.1.0 ") || ansi.StringWidth(bar) != 100 { t.Errorf("no version in the status pane:\n%s", d.screen())
t.Errorf("help line %q (%d wide)", bar, ansi.StringWidth(bar))
} }
if !strings.Contains(bar, "? help • q quit") { if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
t.Errorf("help squeezed out by the version: %q", bar) 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 ─────────────────────────────────────────────────╮ ╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮
│ ▾ Azure (2) ││ name platform-prod │ │ 2 accounts ││ name platform-prod │
│ platform-prod ││ ID sub-prod │ ╰───────────────────────────────╯│ ID sub-prod │
│ platform-dev ││ web https://portal.azure.com/#resource/subscriptions/sub…│ ╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│
│ ▾ Databricks (2) ││ vaults 3 │ │ ▾ Azure (2) ││ vaults 3 │
│ platform-prod ││ │
│ platform-dev ││ │
│ ▾ Databricks (2) ││ │
│ prod ││ │ │ prod ││ │
╰────────────────────── 2 of 6 ─╯│ │ ╰────────────────────── 2 of 6 ─╯│ │
╭─ [2] Vaults ──────────────────╮│ │ ╭─ [2] Vaults ──────────────────╮│ │
-12
View File
@@ -155,15 +155,3 @@ func (m *Model) handleInstalled(msg installedMsg) {
m.updated = msg.release.Tag m.updated = msg.release.Tag
m.flash = done("updated to lazysecrets " + msg.release.Tag + " · restart lazysecrets to use it") 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 { if f.asked != 1 {
t.Fatalf("asked %d times at startup", f.asked) t.Fatalf("asked %d times at startup", f.asked)
} }
if !strings.Contains(d.screen(), "v0.1.1 ↑ v0.2.0 U") { 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 footer:\n%s", d.screen()) t.Errorf("no update in the status pane:\n%s", scr)
} }
// U: the changelog, without the install notes; u installs. // U: the changelog, without the install notes; u installs.
+3 -2
View File
@@ -37,7 +37,7 @@ type Map struct {
// lists // lists
Up, Down, Top, Bottom key.Binding Up, Down, Top, Bottom key.Binding
NextPane, PrevPane key.Binding NextPane, PrevPane key.Binding
Pane1, Pane2, Pane3, Pane4 key.Binding Pane0, Pane1, Pane2, Pane3, Pane4 key.Binding
NextTab, PrevTab key.Binding NextTab, PrevTab key.Binding
Enter, Back, Filter key.Binding Enter, Back, Filter key.Binding
Reveal, Copy, CopyRef, Open, Edit 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, "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, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail 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, "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."), 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.up": {"k", "up", "ctrl+k", "ctrl+p"}, "lists.down": {"j", "down", "ctrl+j", "ctrl+n"},
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"}, "lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
"lists.nextPane": {"tab", "l", "right"}, "lists.prevPane": {"shift+tab", "h", "left"}, "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.enter": {"enter"}, "lists.back": {"esc", "backspace"},
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"}, "lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"}, "lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
+4 -1
View File
@@ -19,10 +19,13 @@ type Fields struct {
} }
// Field is a row: a value under a label. Info rows (loading, errors) // 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 { type Field struct {
Label, Value string Label, Value string
Info, Err bool Info, Err bool
URL, Path, Provider string
} }
func NewFields() Fields { func NewFields() Fields {