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
+64 -65

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
} }
+36 -59
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]{
func (s *SourceDetail) Set(title string, fields []Field) { ID: func(f Field) string { return f.Label + "\x00" + f.Value },
if title != s.title { Filter: func(f Field) string { return f.Value },
s.sel = 0 // "/tenant" finds the tenant's rows.
} FilterExtra: func(f Field) string { return f.Label },
s.title, s.fields = title, fields Render: func(st styles.Styles, f Field, width int) string {
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) }
// 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 {
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 value := f.Value
if f.Info { if f.Info {
value = st.Muted.Render(value) value = st.Muted.Render(value)
} }
line := " " + st.Label.Render(fmt.Sprintf("%-10s", f.Label)) + " " + value return ansi.Truncate(st.Label.Render(fmt.Sprintf("%-10s", f.Label))+" "+value, width, "…")
line = ansi.Truncate(line, inner, "…") },
if i+offset == s.sel { })
pad := strings.Repeat(" ", max(0, inner-ansi.StringWidth(line))) l.Empty = "nothing known about this account"
if focused { return SourceDetail{l}
line = st.Selected.Render(ansi.Strip(line) + pad) }
} else {
line = st.SelectedBlurred.Render(ansi.Strip(line) + pad) // 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.Reset()
s.Title = title
}
if len(fields) > 0 || len(s.Items()) > 0 {
s.SetItems(fields)
} }
} }
body = append(body, line)
// Shown reports whether there's anything to show.
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) {
f, ok := s.List.Selected()
if !ok || f.Info {
return Field{}, false
} }
footer := "" return f, true
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)
} }