diff --git a/cliff.toml b/cliff.toml index 7b3e699..1978ee8 100644 --- a/cliff.toml +++ b/cliff.toml @@ -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] diff --git a/cmd/lazysecrets/main.go b/cmd/lazysecrets/main.go index 3117843..3142ea5 100644 --- a/cmd/lazysecrets/main.go +++ b/cmd/lazysecrets/main.go @@ -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 +} diff --git a/docs/keys.md b/docs/keys.md index 74090f0..00554e5 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -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 | diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 2c2df6e..8efcdf6 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -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 { diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index 88d4590..e526a91 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -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) + } +} diff --git a/internal/ui/app/commands.go b/internal/ui/app/commands.go index 4caeca2..6631aca 100644 --- a/internal/ui/app/commands.go +++ b/internal/ui/app/commands.go @@ -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) { diff --git a/internal/ui/app/edit.go b/internal/ui/app/edit.go index 83be600..420b38c 100644 --- a/internal/ui/app/edit.go +++ b/internal/ui/app/edit.go @@ -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 +} diff --git a/internal/ui/app/fields.go b/internal/ui/app/fields.go index 4fb35ba..6a0f253 100644 --- a/internal/ui/app/fields.go +++ b/internal/ui/app/fields.go @@ -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)) diff --git a/internal/ui/app/help.go b/internal/ui/app/help.go index 641ae9f..0d02159 100644 --- a/internal/ui/app/help.go +++ b/internal/ui/app/help.go @@ -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 diff --git a/internal/ui/app/layout.go b/internal/ui/app/layout.go index 9f1a8ff..fbdd711 100644 --- a/internal/ui/app/layout.go +++ b/internal/ui/app/layout.go @@ -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]()) } diff --git a/internal/ui/app/layout_test.go b/internal/ui/app/layout_test.go index c5a67de..229dda8 100644 --- a/internal/ui/app/layout_test.go +++ b/internal/ui/app/layout_test.go @@ -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) } } diff --git a/internal/ui/app/status.go b/internal/ui/app/status.go new file mode 100644 index 0000000..e1427f7 --- /dev/null +++ b/internal/ui/app/status.go @@ -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)) +} diff --git a/internal/ui/app/testdata/TestGolden.golden b/internal/ui/app/testdata/TestGolden.golden index 75d3cd7..99b7ff4 100644 --- a/internal/ui/app/testdata/TestGolden.golden +++ b/internal/ui/app/testdata/TestGolden.golden @@ -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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎโ”‚ โ”‚ diff --git a/internal/ui/app/update.go b/internal/ui/app/update.go index 7379e37..37b019c 100644 --- a/internal/ui/app/update.go +++ b/internal/ui/app/update.go @@ -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 "" -} diff --git a/internal/ui/app/update_test.go b/internal/ui/app/update_test.go index 6f75ced..08b7c55 100644 --- a/internal/ui/app/update_test.go +++ b/internal/ui/app/update_test.go @@ -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. diff --git a/internal/ui/keys/keys.go b/internal/ui/keys/keys.go index f8663a2..1e81dc3 100644 --- a/internal/ui/keys/keys.go +++ b/internal/ui/keys/keys.go @@ -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"}, diff --git a/internal/ui/panes/fields.go b/internal/ui/panes/fields.go index eaa6f45..7dd65b6 100644 --- a/internal/ui/panes/fields.go +++ b/internal/ui/panes/fields.go @@ -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 { diff --git a/internal/update/update.go b/internal/update/update.go index 8c62532..d0e251e 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -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 diff --git a/internal/update/update_test.go b/internal/update/update_test.go index 61d9f2d..496e916 100644 --- a/internal/update/update_test.go +++ b/internal/update/update_test.go @@ -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) + } +}