feat: a Fields pane for anything but a secret, first for Azure accounts: fields and groups to move through, filter and copy #7

Merged
keb merged 5 commits from feat/source-detail into main 2026-10-11 10:37:40 +02:00
7 changed files with 61 additions and 62 deletions
Showing only changes of commit 0a80073aad - Show all commits

No files matched your search

+2 -2
View File
@@ -20,8 +20,8 @@ and the token lazysecrets already has. Below them are the groups the
account is in, nested ones too: those come from Microsoft Graph, once a account is in, nested ones too: those come from Microsoft Graph, once a
session, the first time the pane shows them. `y` on the row copies the session, the first time the pane shows them. `y` on the row copies the
object ID, the one access policies and role assignments ask for; `tab` object ID, the one access policies and role assignments ask for; `tab`
goes into the pane, where `j`/`k` pick any field or group and `y` copies goes into the pane, where `j`/`k` pick any field or group, `/` filters
it. them (`/tenant`, `/platform`) and `y` copies the selected one.
If an account's az login has expired, the status line says so once, and the If an account's az login has expired, the status line says so once, and the
Details pane on that account's rows says it until you sign in: press `L` Details pane on that account's rows says it until you sign in: press `L`
+5
View File
@@ -361,6 +361,7 @@ func New(providers []provider.Provider, opts Options) Model {
nvimOpen: nvimOpen, nvimOpen: nvimOpen,
cache: newCache(), cache: newCache(),
groups: map[string]groupsState{}, groups: map[string]groupsState{},
srcDetail: panes.NewSourceDetail(),
} }
if m.clip == nil { if m.clip == nil {
m.clip = clipboard.New() m.clip = clipboard.New()
@@ -927,6 +928,10 @@ func (m *Model) focusedFilter() filterable {
return &m.containers return &m.containers
case paneSecrets: case paneSecrets:
return &m.secrets return &m.secrets
case paneDetail:
if m.srcDetail.Shown() {
return &m.srcDetail
}
} }
return nil return nil
} }
+18 -1
View File
@@ -1115,7 +1115,7 @@ func TestAccountPane(t *testing.T) {
t.Fatalf("focus %v, want the fourth pane", d.m.focus) t.Fatalf("focus %v, want the fourth pane", d.m.focus)
} }
scr := d.screen() scr := d.screen()
for _, want := range []string{"Account", "Alice Example", "groups 2", "Platform team", "Vault readers"} { for _, want := range []string{"Alice Example", "groups 2", "Platform team", "Vault readers"} {
if !strings.Contains(scr, want) { if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr) t.Errorf("missing %q:\n%s", want, scr)
} }
@@ -1132,6 +1132,23 @@ func TestAccountPane(t *testing.T) {
if got := d.clip().content; got != "Vault readers" { if got := d.clip().content; got != "Vault readers" {
t.Errorf("y on a group copied %q", got) t.Errorf("y on a group copied %q", got)
} }
// / filters it, labels too.
d.keys("/")
d.typeText("vault")
d.keys("enter")
if scr := d.screen(); !strings.Contains(scr, "Vault readers") || strings.Contains(scr, "Platform team") {
t.Errorf("filtered:\n%s", scr)
}
d.keys("y")
if got := d.clip().content; got != "Vault readers" {
t.Errorf("y in the filtered pane copied %q", got)
}
d.keys("esc")
if !strings.Contains(d.screen(), "Platform team") {
t.Errorf("filter not cleared:\n%s", d.screen())
}
d.keys("shift+tab") d.keys("shift+tab")
if d.m.focus != paneSources { if d.m.focus != paneSources {
t.Errorf("back to %v, want Sources", d.m.focus) t.Errorf("back to %v, want Sources", d.m.focus)
+1 -1
View File
@@ -47,7 +47,7 @@ func (c contextHelp) ShortHelp() []key.Binding {
} }
if c.focus == paneDetail && c.account { 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"))
return append(b, k.Up, k.Down, copyField, k.Help, k.Quit) return append(b, k.Up, k.Down, copyField, k.Filter, k.Help, k.Quit)
} }
switch c.focus { switch c.focus {
case paneSources: case paneSources:
+1 -1
View File
@@ -182,7 +182,7 @@ func (m Model) panesView() string {
func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) }, func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) },
func() string { func() string {
if m.srcDetail.Shown() { if m.srcDetail.Shown() {
return m.srcDetail.View(m.st, m.active(paneDetail)) return m.srcDetail.View(m.st, m.active(paneDetail), spin)
} }
return m.detail.View(m.st, m.active(paneDetail), spin) return m.detail.View(m.st, m.active(paneDetail), spin)
}, },
+1 -1
View File
@@ -57,7 +57,7 @@ func (m *Model) syncSourceDetail() tea.Cmd {
} }
fields = append(fields, groupFields(st)...) fields = append(fields, groupFields(st)...)
} }
m.srcDetail.Set("Account", fields) m.srcDetail.Set(m.byID[r.provider].Label(), fields)
return cmd return cmd
} }
+33 -56
View File
@@ -2,7 +2,6 @@ package panes
import ( import (
"fmt" "fmt"
"strings"
"github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/ansi"
@@ -10,12 +9,10 @@ import (
) )
// SourceDetail is the fourth pane on a provider's row: what's known about // SourceDetail is the fourth pane on a provider's row: what's known about
// its account, one field per row, to move through and copy from. // its account, one field per row, to move through, filter and copy from.
// It's a List, so / filters it like the other panes.
type SourceDetail struct { type SourceDetail struct {
title string List[Field]
fields []Field
sel int
w, h int
} }
// 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)
@@ -25,64 +22,44 @@ type Field struct {
Info bool Info bool
} }
// Set shows fields under title, keeping the cursor where it was for the func NewSourceDetail() SourceDetail {
// same title. l := NewList("", Item[Field]{
ID: func(f Field) string { return f.Label + "\x00" + f.Value },
Filter: func(f Field) string { return f.Value },
// "/tenant" finds the tenant's rows.
FilterExtra: func(f Field) string { return f.Label },
Render: func(st styles.Styles, f Field, width int) string {
value := f.Value
if f.Info {
value = st.Muted.Render(value)
}
return ansi.Truncate(st.Label.Render(fmt.Sprintf("%-10s", f.Label))+" "+value, width, "…")
},
})
l.Empty = "nothing known about this account"
return SourceDetail{l}
}
// Set shows fields under title. Another title (another account) starts
// over, without a filter.
func (s *SourceDetail) Set(title string, fields []Field) { func (s *SourceDetail) Set(title string, fields []Field) {
if title != s.title { if title != s.Title {
s.sel = 0 s.Reset()
s.Title = title
}
if len(fields) > 0 || len(s.Items()) > 0 {
s.SetItems(fields)
} }
s.title, s.fields = title, fields
s.sel = max(0, min(s.sel, len(fields)-1))
} }
// Shown reports whether there's anything to show. // Shown reports whether there's anything to show.
func (s SourceDetail) Shown() bool { return len(s.fields) > 0 } func (s SourceDetail) Shown() bool { return len(s.Items()) > 0 }
func (s *SourceDetail) SetSize(w, h int) { s.w, s.h = w, h }
func (s *SourceDetail) Move(delta int) { s.sel = max(0, min(s.sel+delta, len(s.fields)-1)) }
func (s *SourceDetail) Top() { s.sel = 0 }
func (s *SourceDetail) Bottom() { s.sel = max(0, len(s.fields)-1) }
// Selected is the field under the cursor, unless it's an info row. // Selected is the field under the cursor, unless it's an info row.
func (s SourceDetail) Selected() (Field, bool) { func (s SourceDetail) Selected() (Field, bool) {
if s.sel >= len(s.fields) || s.fields[s.sel].Info { f, ok := s.List.Selected()
if !ok || f.Info {
return Field{}, false return Field{}, false
} }
return s.fields[s.sel], true return f, true
}
func (s SourceDetail) View(st styles.Styles, focused bool) string {
inner := max(1, s.w-2)
rows := max(0, s.h-2)
offset := 0
if s.sel >= rows { // keep the cursor in view
offset = s.sel - rows + 1
}
var body []string
for i, f := range s.fields[min(offset, len(s.fields)):] {
if i >= rows {
break
}
value := f.Value
if f.Info {
value = st.Muted.Render(value)
}
line := " " + st.Label.Render(fmt.Sprintf("%-10s", f.Label)) + " " + value
line = ansi.Truncate(line, inner, "…")
if i+offset == s.sel {
pad := strings.Repeat(" ", max(0, inner-ansi.StringWidth(line)))
if focused {
line = st.Selected.Render(ansi.Strip(line) + pad)
} else {
line = st.SelectedBlurred.Render(ansi.Strip(line) + pad)
}
}
body = append(body, line)
}
footer := ""
if len(s.fields) > rows {
footer = fmt.Sprintf("%d of %d", s.sel+1, len(s.fields))
}
return Box(st, TitleStyle(st, focused).Render(s.title), footer, body, s.w, s.h, focused)
} }