feat: an account pane on Azure account rows: fields and groups to move through and copy
CI / Checks (pull_request) Failing after 11s
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 10:17:22 +02:00
1 parent f6c4f5f6f6
commit b37caa5a25
13 files changed
+396 -27

No files matched your search

+28 -4
View File
@@ -163,6 +163,10 @@ type Model struct {
clip Clipboard
clipClear time.Duration
openURL func(string) error
// srcDetail is the fourth pane on a provider's row, with the groups
// of each provider's account as they load (source_detail.go).
srcDetail panes.SourceDetail
groups map[string]groupsState
getenv func(string) string
nvimOpen func(server, path string, line int) error // edit.go
now func() time.Time
@@ -356,6 +360,7 @@ func New(providers []provider.Provider, opts Options) Model {
getenv: os.Getenv,
nvimOpen: nvimOpen,
cache: newCache(),
groups: map[string]groupsState{},
}
if m.clip == nil {
m.clip = clipboard.New()
@@ -563,6 +568,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case persistedMsg:
m.handlePersisted(msg)
case groupsMsg:
m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err}
case crawlMsg:
cmds = append(cmds, m.handleCrawl(msg))
@@ -723,8 +731,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.askHandlerSetup()
m.keys.Login.SetEnabled(m.loginFor() != nil)
m.detail.SetMessage(m.selectedProblem())
acct, _ := m.selectedAccount()
m.detail.SetAccount(acct.Fields)
cmds = append(cmds, m.syncSourceDetail())
if m.anyLoading() && !m.spinning {
m.spinning = true
cmds = append(cmds, m.spin.Tick)
@@ -791,8 +798,14 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
m.jump(false)
case key.Matches(msg, k.NextPane):
m.focus = (m.focus + 1) % paneCount
if m.focus == paneContainers && m.srcDetail.Shown() {
m.focus = paneDetail // an account's row: nothing between
}
case key.Matches(msg, k.PrevPane):
m.focus = (m.focus + paneCount - 1) % paneCount
if m.focus == paneSecrets && m.srcDetail.Shown() {
m.focus = paneSources
}
case key.Matches(msg, k.Pane1):
m.focus = paneSources
case key.Matches(msg, k.Pane2):
@@ -928,7 +941,11 @@ func (m *Model) move(delta int) {
case paneSecrets:
m.secrets.Move(delta)
case paneDetail:
m.detail.Scroll(delta)
if m.srcDetail.Shown() {
m.srcDetail.Move(delta)
} else {
m.detail.Scroll(delta)
}
}
}
@@ -948,6 +965,10 @@ func (m *Model) jump(top bool) {
m.secrets.Top()
case m.focus == paneSecrets:
m.secrets.Bottom()
case m.focus == paneDetail && m.srcDetail.Shown() && top:
m.srcDetail.Top()
case m.focus == paneDetail && m.srcDetail.Shown():
m.srcDetail.Bottom()
case m.focus == paneDetail && top:
m.detail.ScrollTop()
case m.focus == paneDetail:
@@ -1458,6 +1479,9 @@ func (m *Model) copyValue() tea.Cmd {
if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources {
return m.copyText(a.CopyLabel, a.Copy, false)
}
if f, ok := m.srcDetail.Selected(); ok && m.focus == paneDetail && m.srcDetail.Shown() {
return m.copyText(fieldName(f), f.Value, false)
}
return nil
}
if v, ok := m.detail.Value(); ok {
@@ -1583,7 +1607,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}.ShortHelp(), 2)
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0, account: m.srcDetail.Shown()}.ShortHelp(), 2)
}
var lines []string
if !m.clipDeadline.IsZero() {
+38
View File
@@ -1099,3 +1099,41 @@ func TestAccountInDetails(t *testing.T) {
t.Errorf("still shown on a subscription:\n%s", d.screen())
}
}
// grouped is an account in two groups.
type grouped struct{ described }
func (grouped) Groups(context.Context) ([]string, error) {
return []string{"Platform team", "Vault readers"}, nil
}
func TestAccountPane(t *testing.T) {
t.Parallel()
d := newDriver(t, grouped{described{fake.Azure(0)}})
d.keys("g", "tab") // the account's row, then straight to its pane
if d.m.focus != paneDetail {
t.Fatalf("focus %v, want the fourth pane", d.m.focus)
}
scr := d.screen()
for _, want := range []string{"Account", "Alice Example", "groups 2", "Platform team", "Vault readers"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
}
bar := lastLine(scr)
if !strings.Contains(bar, "copy") || strings.Contains(bar, "reveal") || strings.Contains(bar, "versions") {
t.Errorf("help bar: %q", bar)
}
d.keys("j", "y")
if got := d.clip().content; got != "oid-123" {
t.Errorf("y on the object ID copied %q", got)
}
d.keys("G", "y")
if got := d.clip().content; got != "Vault readers" {
t.Errorf("y on a group copied %q", got)
}
d.keys("shift+tab")
if d.m.focus != paneSources {
t.Errorf("back to %v, want Sources", d.m.focus)
}
}
+5
View File
@@ -36,6 +36,7 @@ type contextHelp struct {
k keys.Map
focus pane
commands bool // custom commands apply here
account bool // the fourth pane describes an account (source_detail.go)
}
func (c contextHelp) ShortHelp() []key.Binding {
@@ -44,6 +45,10 @@ 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"))
return append(b, k.Up, k.Down, copyField, k.Help, k.Quit)
}
switch c.focus {
case paneSources:
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.Filter)
+7 -1
View File
@@ -166,6 +166,7 @@ func (m *Model) layout() {
m.containers.SetSize(leftW, h[1])
m.secrets.SetSize(leftW, h[2])
m.detail.SetSize(m.w-leftW, avail)
m.srcDetail.SetSize(m.w-leftW, avail)
}
// active reports whether p is drawn focused: it has focus and the terminal
@@ -179,7 +180,12 @@ func (m Model) panesView() 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) },
func() string { return m.detail.View(m.st, m.active(paneDetail), spin) },
func() string {
if m.srcDetail.Shown() {
return m.srcDetail.View(m.st, m.active(paneDetail))
}
return m.detail.View(m.st, m.active(paneDetail), spin)
},
}
if m.zoom == zoomFull {
return views[m.focus]()
+90
View File
@@ -0,0 +1,90 @@
package app
import (
"context"
"errors"
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// On a provider's row, the fourth pane describes its account: the fields
// the provider knows locally, then the groups the account is in, loaded
// once a session when first shown.
type groupsState struct {
names []string
err error
loading bool
}
type groupsMsg struct {
provider string
names []string
err error
}
// syncSourceDetail fills the pane for the selected row, loading the
// account's groups the first time.
func (m *Model) syncSourceDetail() tea.Cmd {
acct, ok := m.selectedAccount()
if !ok {
m.srcDetail.Set("", nil)
return nil
}
r, _ := m.sources.Selected()
var fields []panes.Field
for _, f := range acct.Fields {
fields = append(fields, panes.Field{Label: f.Label, Value: f.Value})
}
var cmd tea.Cmd
if g, ok := m.byID[r.provider].(provider.AccountGroups); ok {
st, seen := m.groups[r.provider]
if !seen {
st = groupsState{loading: true}
m.groups[r.provider] = st
id, timeout := r.provider, m.timeout
cmd = func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
names, err := g.Groups(ctx)
return groupsMsg{provider: id, names: names, err: err}
}
}
fields = append(fields, groupFields(st)...)
}
m.srcDetail.Set("Account", fields)
return cmd
}
// fieldName names a copied field: its label, or for a group, the group.
func fieldName(f panes.Field) string {
if f.Label == "" || strings.HasPrefix(f.Label, "groups") {
return "group " + f.Value
}
return f.Label
}
// groupFields are the groups' rows: labelled on the first.
func groupFields(st groupsState) []panes.Field {
switch {
case st.loading:
return []panes.Field{{Label: "groups", Value: "loading…", Info: true}}
case errors.Is(st.err, provider.ErrUnsupported):
return nil
case st.err != nil:
return []panes.Field{{Label: "groups", Value: "couldn't list them: " + st.err.Error(), Info: true}}
case len(st.names) == 0:
return []panes.Field{{Label: "groups", Value: "none", Info: true}}
}
out := make([]panes.Field, len(st.names))
for i, n := range st.names {
out[i] = panes.Field{Value: n}
}
out[0].Label = fmt.Sprintf("groups %d", len(st.names))
return out
}