diff --git a/docs/azure.md b/docs/azure.md index 75bc2b4..85a9e91 100644 --- a/docs/azure.md +++ b/docs/azure.md @@ -13,12 +13,15 @@ is listed under each, since their permissions can differ. A service principal is used instead when `AZURE_CLIENT_ID`, `AZURE_TENANT_ID` and a secret or certificate are set in the environment. -On an account's group row, the Details pane shows who it signs in as: name, +On an account's group row, the fourth pane shows who it signs in as: name, user, tenant, object ID, whether the session used MFA, directory roles -(built-in ones by name) and its subscriptions. It's read from az's account -list and the token lazysecrets already has, so nothing is fetched for it. -`y` there copies the object ID, the one access policies and role -assignments ask for. +(built-in ones by name) and its subscriptions, read from az's account list +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, `/` 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/docs/keys.md b/docs/keys.md index 5bf842f..2058e77 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -48,7 +48,7 @@ match case exactly. | Key | Action | |---|---| | `space` | reveal / hide the selected value (public material for keys and certificates) | -| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID | +| `y` | copy the value without revealing it; cleared from the clipboard after 30s. On an Azure account's row: its object ID; in its pane, the selected field or group | | `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `o` | open the selected item in the browser (over SSH: copy the link) | diff --git a/internal/provider/azure/account.go b/internal/provider/azure/account.go index 7371203..690234e 100644 --- a/internal/provider/azure/account.go +++ b/internal/provider/azure/account.go @@ -1,10 +1,15 @@ package azure import ( + "context" + "encoding/json" "fmt" + "net/http" "slices" "strings" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -40,11 +45,8 @@ func (p *Provider) Account() (provider.Account, bool) { user += " (service principal)" } add("user", user) - tenant := a.tenant - if a.tenantName != "" { - tenant = a.tenantName + " · " + a.tenant - } - add("tenant", tenant) + add("tenant", a.tenantName) + add("tenant ID", a.tenant) if oid := str("oid"); oid != "" { add("object ID", oid) out.Copy, out.CopyLabel = oid, "object ID" @@ -158,3 +160,68 @@ func directoryRoles(wids any) string { slices.Sort(names) return strings.Join(names, ", ") } + +// graphGroups lists the signed-in user's groups, nested ones too, from +// Microsoft Graph with az's token for it. +const graphGroups = "https://graph.microsoft.com/v1.0/me/transitiveMemberOf?$select=displayName&$top=999" + +var _ provider.AccountGroups = (*Provider)(nil) + +// Groups lists the groups the account is a member of, by name. +func (p *Provider) Groups(ctx context.Context) ([]string, error) { + if p.account == nil || (p.account.cloud != "" && p.account.cloud != "AzureCloud") { + return nil, provider.ErrUnsupported + } + tok, err := p.cred.GetToken(ctx, policy.TokenRequestOptions{Scopes: []string{"https://graph.microsoft.com/.default"}}) + if err != nil { + return nil, err + } + var names []string + for next := graphGroups; next != ""; { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, next, nil) + if err != nil { + return nil, err + } + req.Header.Set("Authorization", "Bearer "+tok.Token) + resp, err := p.http().Do(req) + if err != nil { + return nil, err + } + var page struct { + Value []struct { + Type string `json:"@odata.type"` + Name string `json:"displayName"` + } `json:"value"` + Next string `json:"@odata.nextLink"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + err = json.NewDecoder(resp.Body).Decode(&page) + resp.Body.Close() + switch { + case err != nil: + return nil, fmt.Errorf("listing groups from Microsoft Graph: %w", err) + case page.Error != nil: + return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", page.Error.Message) + case resp.StatusCode != http.StatusOK: + return nil, fmt.Errorf("listing groups from Microsoft Graph: %s", resp.Status) + } + for _, v := range page.Value { + if v.Type == "#microsoft.graph.group" && v.Name != "" { + names = append(names, v.Name) + } + } + next = page.Next + } + slices.SortFunc(names, func(a, b string) int { return strings.Compare(strings.ToLower(a), strings.ToLower(b)) }) + return names, nil +} + +// http is the client for Microsoft Graph; tests replace it. +func (p *Provider) http() *http.Client { + if p.graphClient != nil { + return p.graphClient + } + return http.DefaultClient +} diff --git a/internal/provider/azure/account_test.go b/internal/provider/azure/account_test.go index c82e32f..a951ce8 100644 --- a/internal/provider/azure/account_test.go +++ b/internal/provider/azure/account_test.go @@ -1,7 +1,17 @@ package azure import ( + "context" + "errors" + "io" + "net/http" + "slices" + "strings" "testing" + "time" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" "git.kebcloud.no/keb/lazysecrets/internal/provider" ) @@ -25,7 +35,7 @@ func TestAccount(t *testing.T) { got[f.Label] = f.Value } want := map[string]string{ - "name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso · t1", + "name": "Alice Example", "user": "alice@contoso.com", "tenant": "Contoso", "tenant ID": "t1", "object ID": "11111111-2222-3333-4444-555555555555", "sign-in": "MFA · password", "roles": "Global Administrator, role aaaaaaaa", "subs": "3 · default platform", } @@ -44,7 +54,7 @@ func TestAccount(t *testing.T) { // Before any token: what az's list says. claims = nil a, _ = p.Account() - if len(a.Fields) != 3 || a.Copy != "" { + if len(a.Fields) != 4 || a.Copy != "" { t.Errorf("without a token: %+v", a) } if _, ok := (&Provider{}).Account(); ok { @@ -65,3 +75,40 @@ func TestSignInMethods(t *testing.T) { } } } + +type staticToken struct{} + +func (staticToken) GetToken(context.Context, policy.TokenRequestOptions) (azcore.AccessToken, error) { + return azcore.AccessToken{Token: "tok", ExpiresOn: time.Now().Add(time.Hour)}, nil +} + +type graphPages map[string]string // by URL + +func (g graphPages) RoundTrip(r *http.Request) (*http.Response, error) { + body, ok := g[r.URL.String()] + if !ok || r.Header.Get("Authorization") != "Bearer tok" { + return &http.Response{StatusCode: 404, Status: "404 Not Found", Body: io.NopCloser(strings.NewReader(`{}`))}, nil + } + return &http.Response{StatusCode: 200, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil +} + +func TestGroups(t *testing.T) { + next := "https://graph.microsoft.com/v1.0/next" + p := &Provider{ + cred: staticToken{}, + account: &accountDetails{account: account{user: "alice@contoso.com"}}, + graphClient: &http.Client{Transport: graphPages{ + graphGroups: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"vault readers"}, + {"@odata.type":"#microsoft.graph.directoryRole","displayName":"Global Reader"}],"@odata.nextLink":"` + next + `"}`, + next: `{"value":[{"@odata.type":"#microsoft.graph.group","displayName":"Platform team"}]}`, + }}, + } + got, err := p.Groups(context.Background()) + if err != nil || !slices.Equal(got, []string{"Platform team", "vault readers"}) { + t.Errorf("groups %q, %v", got, err) + } + p.account.cloud = "AzureChinaCloud" + if _, err := p.Groups(context.Background()); !errors.Is(err, provider.ErrUnsupported) { + t.Errorf("other cloud: %v", err) + } +} diff --git a/internal/provider/azure/azure.go b/internal/provider/azure/azure.go index 1d50999..b7b0629 100644 --- a/internal/provider/azure/azure.go +++ b/internal/provider/azure/azure.go @@ -9,6 +9,7 @@ import ( "crypto/x509" "errors" "fmt" + "net/http" "regexp" "slices" "strings" @@ -36,11 +37,12 @@ var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, p type Provider struct { id, label string // "" for the single-account defaults - cred azcore.TokenCredential - opts options - account *accountDetails // nil for a service principal from AZURE_* - subs *armsubscriptions.Client - graph *armresourcegraph.Client + cred azcore.TokenCredential + opts options + account *accountDetails // nil for a service principal from AZURE_* + graphClient *http.Client // for Groups; nil for the default + subs *armsubscriptions.Client + graph *armresourcegraph.Client mu sync.Mutex vaults map[string]*vaultClients // by vault URL diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 0857baf..2cce540 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -198,3 +198,10 @@ type Account struct { // Copy is what y copies on the provider's row, named CopyLabel. Copy, CopyLabel string } + +// AccountGroups is implemented by providers that can list the groups +// their account is a member of, directly or through other groups. It +// goes over the network. +type AccountGroups interface { + Groups(ctx context.Context) ([]string, error) +} diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 2e1eee2..fc44701 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -163,9 +163,13 @@ type Model struct { clip Clipboard clipClear time.Duration openURL func(string) error - getenv func(string) string - nvimOpen func(server, path string, line int) error // edit.go - now func() time.Time + // fields is the fourth pane for anything but a secret (now: an + // account), with each account's groups as they load (fields.go). + fields panes.Fields + groups map[string]groupsState + getenv func(string) string + nvimOpen func(server, path string, line int) error // edit.go + now func() time.Time // The cache file, whether the cache changed since it was saved, and // when each provider's Sources list was loaded (persist.go). cachePath string @@ -356,6 +360,8 @@ func New(providers []provider.Provider, opts Options) Model { getenv: os.Getenv, nvimOpen: nvimOpen, cache: newCache(), + groups: map[string]groupsState{}, + fields: panes.NewFields(), } if m.clip == nil { m.clip = clipboard.New() @@ -563,6 +569,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 +732,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.syncFields()) if m.anyLoading() && !m.spinning { m.spinning = true cmds = append(cmds, m.spin.Tick) @@ -791,8 +799,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.fields.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.fields.Shown() { + m.focus = paneSources + } case key.Matches(msg, k.Pane1): m.focus = paneSources case key.Matches(msg, k.Pane2): @@ -914,6 +928,10 @@ func (m *Model) focusedFilter() filterable { return &m.containers case paneSecrets: return &m.secrets + case paneDetail: + if m.fields.Shown() { + return &m.fields + } } return nil } @@ -928,7 +946,11 @@ func (m *Model) move(delta int) { case paneSecrets: m.secrets.Move(delta) case paneDetail: - m.detail.Scroll(delta) + if m.fields.Shown() { + m.fields.Move(delta) + } else { + m.detail.Scroll(delta) + } } } @@ -948,6 +970,10 @@ func (m *Model) jump(top bool) { m.secrets.Top() case m.focus == paneSecrets: m.secrets.Bottom() + case m.focus == paneDetail && m.fields.Shown() && top: + m.fields.Top() + case m.focus == paneDetail && m.fields.Shown(): + m.fields.Bottom() case m.focus == paneDetail && top: m.detail.ScrollTop() case m.focus == paneDetail: @@ -1458,6 +1484,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.fields.Selected(); ok && m.focus == paneDetail && m.fields.Shown() { + return m.copyText(fieldName(f), f.Value, false) + } return nil } if v, ok := m.detail.Value(); ok { @@ -1583,7 +1612,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.fields.Shown()}.ShortHelp(), 2) } var lines []string if !m.clipDeadline.IsZero() { diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index d952d6b..7c1a2d6 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -1099,3 +1099,58 @@ 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{"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) + } + + // / 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/fields.go b/internal/ui/app/fields.go new file mode 100644 index 0000000..a85c58a --- /dev/null +++ b/internal/ui/app/fields.go @@ -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" +) + +// The fourth pane shows Fields for anything but a secret. On a provider's +// row: its account, from what 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 +} + +// syncFields fills the Fields pane for what's selected, loading an +// account's groups the first time. +func (m *Model) syncFields() tea.Cmd { + acct, ok := m.selectedAccount() + if !ok { + m.fields.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.fields.Set(m.byID[r.provider].Label(), 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 +} diff --git a/internal/ui/app/help.go b/internal/ui/app/help.go index 872443f..641ae9f 100644 --- a/internal/ui/app/help.go +++ b/internal/ui/app/help.go @@ -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 (fields.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.Filter, 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) diff --git a/internal/ui/app/layout.go b/internal/ui/app/layout.go index 94e628a..9f1a8ff 100644 --- a/internal/ui/app/layout.go +++ b/internal/ui/app/layout.go @@ -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.fields.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.fields.Shown() { + return m.fields.View(m.st, m.active(paneDetail), spin) + } + return m.detail.View(m.st, m.active(paneDetail), spin) + }, } if m.zoom == zoomFull { return views[m.focus]() diff --git a/internal/ui/panes/detail.go b/internal/ui/panes/detail.go index dbdab8c..f1f2e15 100644 --- a/internal/ui/panes/detail.go +++ b/internal/ui/panes/detail.go @@ -47,7 +47,6 @@ type Detail struct { versions []provider.Object // newest first; nil while unknown note string // the user's note on what's shown message string // shown instead of the hint when nothing is selected: an error - account []provider.Attr // shown instead of the hint on a provider's row: who it signs in as offset int w, h int @@ -77,10 +76,6 @@ func (d *Detail) Clear() { // selected, such as why an account has nothing to show; "" for the hint. func (d *Detail) SetMessage(s string) { d.message = s } -// SetAccount sets the account shown on a provider's row when nothing is -// selected; nil for the hint. -func (d *Detail) SetAccount(fields []provider.Attr) { d.account = fields } - // SetDescribing marks extra metadata as being fetched. func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil } @@ -196,12 +191,6 @@ func (d Detail) lines(st styles.Styles) []string { muted := st.Muted.Render if d.container == nil { - if d.message == "" && len(d.account) > 0 { - for _, a := range d.account { - field(a.Label, a.Value) - } - return out - } if d.message != "" { for _, l := range strings.Split(ansi.Hardwrap(d.message, max(1, inner-2), true), "\n") { out = append(out, " "+st.Error.Render(l)) diff --git a/internal/ui/panes/fields.go b/internal/ui/panes/fields.go new file mode 100644 index 0000000..0859c46 --- /dev/null +++ b/internal/ui/panes/fields.go @@ -0,0 +1,67 @@ +package panes + +import ( + "fmt" + + "github.com/charmbracelet/x/ansi" + + "git.kebcloud.no/keb/lazysecrets/internal/ui/styles" +) + +// Fields is the fourth pane for anything but a secret (an account, and +// what describes vaults, scopes and files): labelled values, one per row, +// a label on the first of several values, to move through, filter and +// copy from. It's a List, so / filters it like the other panes. Secrets +// have Detail, for revealing and versions. +type Fields struct { + List[Field] +} + +// Field is a row: a value under a label. Info rows (loading, errors) +// can't be copied. +type Field struct { + Label, Value string + Info bool +} + +func NewFields() Fields { + 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 to show" + return Fields{l} +} + +// Set shows fields under title. Another title (another account or vault) +// starts over, without a filter. +func (s *Fields) 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) + } +} + +// Shown reports whether there's anything to show. +func (s Fields) Shown() bool { return len(s.Items()) > 0 } + +// Selected is the field under the cursor, unless it's an info row. +func (s Fields) Selected() (Field, bool) { + f, ok := s.List.Selected() + if !ok || f.Info { + return Field{}, false + } + return f, true +}