diff --git a/docs/azure.md b/docs/azure.md index f82dfa7..85a9e91 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -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` diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index f12cc31..f92fd90 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -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 } diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index ef688fd..7c1a2d6 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -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) diff --git a/internal/ui/app/help.go b/internal/ui/app/help.go index 2fc9efb..71760ce 100644 --- a/internal/ui/app/help.go +++ b/internal/ui/app/help.go @@ -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: diff --git a/internal/ui/app/layout.go b/internal/ui/app/layout.go index c1a3990..b796653 100644 --- a/internal/ui/app/layout.go +++ b/internal/ui/app/layout.go @@ -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) }, diff --git a/internal/ui/app/source_detail.go b/internal/ui/app/source_detail.go index 93aa27d..01e60f6 100644 --- a/internal/ui/app/source_detail.go +++ b/internal/ui/app/source_detail.go @@ -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 } diff --git a/internal/ui/panes/source_detail.go b/internal/ui/panes/source_detail.go index f18ed0d..00d39fd 100644 --- a/internal/ui/panes/source_detail.go +++ b/internal/ui/panes/source_detail.go @@ -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 }