feat: / filters the account pane
CI / Checks (pull_request) Successful in 25s
PR title / Conventional title (pull_request) Successful in 1s

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:28:59 +02:00
1 parent 3913989cd1
commit 0a80073aad
7 files changed
+61 -62

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
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`
goes into the pane, where `j`/`k` pick any field or group and `y` copies
it.
goes into the pane, where `j`/`k` pick any field or group, `/` filters
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
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,
cache: newCache(),
groups: map[string]groupsState{},
srcDetail: panes.NewSourceDetail(),
}
if m.clip == nil {
m.clip = clipboard.New()
@@ -927,6 +928,10 @@ func (m *Model) focusedFilter() filterable {
return &m.containers
case paneSecrets:
return &m.secrets
case paneDetail:
if m.srcDetail.Shown() {
return &m.srcDetail
}
}
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)
}
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) {
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" {
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")
if d.m.focus != paneSources {
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 {
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 {
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 {
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)
},
+1 -1
View File
@@ -57,7 +57,7 @@ func (m *Model) syncSourceDetail() tea.Cmd {
}
fields = append(fields, groupFields(st)...)
}
m.srcDetail.Set("Account", fields)
m.srcDetail.Set(m.byID[r.provider].Label(), fields)
return cmd
}
+33 -56
View File
@@ -2,7 +2,6 @@ package panes
import (
"fmt"
"strings"
"github.com/charmbracelet/x/ansi"
@@ -10,12 +9,10 @@ import (
)
// 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 {
title string
fields []Field
sel int
w, h int
List[Field]
}
// Field is a row: a value under a label. Info rows (loading, errors)
@@ -25,64 +22,44 @@ type Field struct {
Info bool
}
// Set shows fields under title, keeping the cursor where it was for the
// same title.
func NewSourceDetail() SourceDetail {
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) {
if title != s.title {
s.sel = 0
if title != s.Title {
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.
func (s SourceDetail) Shown() bool { return len(s.fields) > 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) }
func (s SourceDetail) Shown() bool { return len(s.Items()) > 0 }
// Selected is the field under the cursor, unless it's an info row.
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 s.fields[s.sel], 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)
return f, true
}