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
13 changed files with 404 additions and 37 deletions

No files matched your search

+8 -5
View File
@@ -13,12 +13,15 @@ is listed under each, since their permissions can differ.
A service principal is used instead when `AZURE_CLIENT_ID`, A service principal is used instead when `AZURE_CLIENT_ID`,
`AZURE_TENANT_ID` and a secret or certificate are set in the environment. `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 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 (built-in ones by name) and its subscriptions, read from az's account list
list and the token lazysecrets already has, so nothing is fetched for it. and the token lazysecrets already has. Below them are the groups the
`y` there copies the object ID, the one access policies and role account is in, nested ones too: those come from Microsoft Graph, once a
assignments ask for. 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 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`
+1 -1
View File
@@ -48,7 +48,7 @@ match case exactly.
| Key | Action | | Key | Action |
|---|---| |---|---|
| `space` | reveal / hide the selected value (public material for keys and certificates) | | `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}}`) | | `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 | | `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) | | `o` | open the selected item in the browser (over SSH: copy the link) |
+72 -5
View File
@@ -1,10 +1,15 @@
package azure package azure
import ( import (
"context"
"encoding/json"
"fmt" "fmt"
"net/http"
"slices" "slices"
"strings" "strings"
"github.com/Azure/azure-sdk-for-go/sdk/azcore/policy"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -40,11 +45,8 @@ func (p *Provider) Account() (provider.Account, bool) {
user += " (service principal)" user += " (service principal)"
} }
add("user", user) add("user", user)
tenant := a.tenant add("tenant", a.tenantName)
if a.tenantName != "" { add("tenant ID", a.tenant)
tenant = a.tenantName + " · " + a.tenant
}
add("tenant", tenant)
if oid := str("oid"); oid != "" { if oid := str("oid"); oid != "" {
add("object ID", oid) add("object ID", oid)
out.Copy, out.CopyLabel = oid, "object ID" out.Copy, out.CopyLabel = oid, "object ID"
@@ -158,3 +160,68 @@ func directoryRoles(wids any) string {
slices.Sort(names) slices.Sort(names)
return strings.Join(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
}
+49 -2
View File
@@ -1,7 +1,17 @@
package azure package azure
import ( import (
"context"
"errors"
"io"
"net/http"
"slices"
"strings"
"testing" "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" "git.kebcloud.no/keb/lazysecrets/internal/provider"
) )
@@ -25,7 +35,7 @@ func TestAccount(t *testing.T) {
got[f.Label] = f.Value got[f.Label] = f.Value
} }
want := map[string]string{ 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", "object ID": "11111111-2222-3333-4444-555555555555", "sign-in": "MFA · password",
"roles": "Global Administrator, role aaaaaaaa", "subs": "3 · default platform", "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. // Before any token: what az's list says.
claims = nil claims = nil
a, _ = p.Account() a, _ = p.Account()
if len(a.Fields) != 3 || a.Copy != "" { if len(a.Fields) != 4 || a.Copy != "" {
t.Errorf("without a token: %+v", a) t.Errorf("without a token: %+v", a)
} }
if _, ok := (&Provider{}).Account(); ok { 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)
}
}
+7 -5
View File
@@ -9,6 +9,7 @@ import (
"crypto/x509" "crypto/x509"
"errors" "errors"
"fmt" "fmt"
"net/http"
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
@@ -36,11 +37,12 @@ var objectTypes = []provider.ObjectType{provider.TypeSecret, provider.TypeKey, p
type Provider struct { type Provider struct {
id, label string // "" for the single-account defaults id, label string // "" for the single-account defaults
cred azcore.TokenCredential cred azcore.TokenCredential
opts options opts options
account *accountDetails // nil for a service principal from AZURE_* account *accountDetails // nil for a service principal from AZURE_*
subs *armsubscriptions.Client graphClient *http.Client // for Groups; nil for the default
graph *armresourcegraph.Client subs *armsubscriptions.Client
graph *armresourcegraph.Client
mu sync.Mutex mu sync.Mutex
vaults map[string]*vaultClients // by vault URL vaults map[string]*vaultClients // by vault URL
+7
View File
@@ -198,3 +198,10 @@ type Account struct {
// Copy is what y copies on the provider's row, named CopyLabel. // Copy is what y copies on the provider's row, named CopyLabel.
Copy, CopyLabel string 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)
}
+36 -7
View File
@@ -163,9 +163,13 @@ type Model struct {
clip Clipboard clip Clipboard
clipClear time.Duration clipClear time.Duration
openURL func(string) error openURL func(string) error
getenv func(string) string // fields is the fourth pane for anything but a secret (now: an
nvimOpen func(server, path string, line int) error // edit.go // account), with each account's groups as they load (fields.go).
now func() time.Time 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 // The cache file, whether the cache changed since it was saved, and
// when each provider's Sources list was loaded (persist.go). // when each provider's Sources list was loaded (persist.go).
cachePath string cachePath string
@@ -356,6 +360,8 @@ func New(providers []provider.Provider, opts Options) Model {
getenv: os.Getenv, getenv: os.Getenv,
nvimOpen: nvimOpen, nvimOpen: nvimOpen,
cache: newCache(), cache: newCache(),
groups: map[string]groupsState{},
fields: panes.NewFields(),
} }
if m.clip == nil { if m.clip == nil {
m.clip = clipboard.New() m.clip = clipboard.New()
@@ -563,6 +569,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
case persistedMsg: case persistedMsg:
m.handlePersisted(msg) m.handlePersisted(msg)
case groupsMsg:
m.groups[msg.provider] = groupsState{names: msg.names, err: msg.err}
case crawlMsg: case crawlMsg:
cmds = append(cmds, m.handleCrawl(msg)) cmds = append(cmds, m.handleCrawl(msg))
@@ -723,8 +732,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.askHandlerSetup() m.askHandlerSetup()
m.keys.Login.SetEnabled(m.loginFor() != nil) m.keys.Login.SetEnabled(m.loginFor() != nil)
m.detail.SetMessage(m.selectedProblem()) m.detail.SetMessage(m.selectedProblem())
acct, _ := m.selectedAccount() cmds = append(cmds, m.syncFields())
m.detail.SetAccount(acct.Fields)
if m.anyLoading() && !m.spinning { if m.anyLoading() && !m.spinning {
m.spinning = true m.spinning = true
cmds = append(cmds, m.spin.Tick) 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) m.jump(false)
case key.Matches(msg, k.NextPane): case key.Matches(msg, k.NextPane):
m.focus = (m.focus + 1) % paneCount 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): case key.Matches(msg, k.PrevPane):
m.focus = (m.focus + paneCount - 1) % paneCount m.focus = (m.focus + paneCount - 1) % paneCount
if m.focus == paneSecrets && m.fields.Shown() {
m.focus = paneSources
}
case key.Matches(msg, k.Pane1): case key.Matches(msg, k.Pane1):
m.focus = paneSources m.focus = paneSources
case key.Matches(msg, k.Pane2): case key.Matches(msg, k.Pane2):
@@ -914,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.fields.Shown() {
return &m.fields
}
} }
return nil return nil
} }
@@ -928,7 +946,11 @@ func (m *Model) move(delta int) {
case paneSecrets: case paneSecrets:
m.secrets.Move(delta) m.secrets.Move(delta)
case paneDetail: 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() m.secrets.Top()
case m.focus == paneSecrets: case m.focus == paneSecrets:
m.secrets.Bottom() 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: case m.focus == paneDetail && top:
m.detail.ScrollTop() m.detail.ScrollTop()
case m.focus == paneDetail: 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 { if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources {
return m.copyText(a.CopyLabel, a.Copy, false) 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 return nil
} }
if v, ok := m.detail.Value(); ok { if v, ok := m.detail.Value(); ok {
@@ -1583,7 +1612,7 @@ func (m Model) footerView() string {
} else if l != nil && l.Filtering() { } else if l != nil && l.Filtering() {
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2) help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
} else { } 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 var lines []string
if !m.clipDeadline.IsZero() { if !m.clipDeadline.IsZero() {
+55
View File
@@ -1099,3 +1099,58 @@ func TestAccountInDetails(t *testing.T) {
t.Errorf("still shown on a subscription:\n%s", d.screen()) 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)
}
}
+90
View File
@@ -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
}
+5
View File
@@ -36,6 +36,7 @@ type contextHelp struct {
k keys.Map k keys.Map
focus pane focus pane
commands bool // custom commands apply here commands bool // custom commands apply here
account bool // the fourth pane describes an account (fields.go)
} }
func (c contextHelp) ShortHelp() []key.Binding { func (c contextHelp) ShortHelp() []key.Binding {
@@ -44,6 +45,10 @@ func (c contextHelp) ShortHelp() []key.Binding {
if k.Login.Enabled() { if k.Login.Enabled() {
b = append(b, k.Login) 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 { switch c.focus {
case paneSources: case paneSources:
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.Filter) b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.Filter)
+7 -1
View File
@@ -166,6 +166,7 @@ func (m *Model) layout() {
m.containers.SetSize(leftW, h[1]) m.containers.SetSize(leftW, h[1])
m.secrets.SetSize(leftW, h[2]) m.secrets.SetSize(leftW, h[2])
m.detail.SetSize(m.w-leftW, avail) 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 // 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.sources.View(m.st, m.active(paneSources), spin) },
func() string { return m.containers.View(m.st, m.active(paneContainers), 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.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 { if m.zoom == zoomFull {
return views[m.focus]() return views[m.focus]()
-11
View File
@@ -47,7 +47,6 @@ type Detail struct {
versions []provider.Object // newest first; nil while unknown versions []provider.Object // newest first; nil while unknown
note string // the user's note on what's shown note string // the user's note on what's shown
message string // shown instead of the hint when nothing is selected: an error 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 offset int
w, h 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. // selected, such as why an account has nothing to show; "" for the hint.
func (d *Detail) SetMessage(s string) { d.message = s } 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. // SetDescribing marks extra metadata as being fetched.
func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil } 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 muted := st.Muted.Render
if d.container == nil { 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 != "" { if d.message != "" {
for _, l := range strings.Split(ansi.Hardwrap(d.message, max(1, inner-2), true), "\n") { for _, l := range strings.Split(ansi.Hardwrap(d.message, max(1, inner-2), true), "\n") {
out = append(out, " "+st.Error.Render(l)) out = append(out, " "+st.Error.Render(l))
+67
View File
@@ -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
}