From 411068ba43314b088525c0ab0aa82d23becf8de1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karl-Einar=20Bergstr=C3=B8m?= Date: Sun, 11 Oct 2026 10:49:39 +0200 Subject: [PATCH] feat: the right pane describes what's selected in the pane you're in Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/keys.md | 7 + internal/ui/app/app.go | 19 ++- internal/ui/app/app_test.go | 45 ++++++- internal/ui/app/favourites_test.go | 5 +- internal/ui/app/fields.go | 142 +++++++++++++++++++-- internal/ui/app/notes_test.go | 2 +- internal/ui/app/testdata/TestGolden.golden | 38 +++--- internal/ui/panes/detail.go | 11 -- internal/ui/panes/fields.go | 30 +++-- 9 files changed, 232 insertions(+), 67 deletions(-) diff --git a/docs/keys.md b/docs/keys.md index 2058e77..74090f0 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -105,6 +105,13 @@ anything loads. ## Layout +The pane on the right describes what's selected in the pane you're in: the +account or subscription in Sources, the vault in Vaults, the secret in +Secrets. Going into it (`4`, or `tab` from an account's row) keeps it on +that; there `j`/`k`, `/` and `y` work on its rows, and `shift+tab` goes +back. A value you reveal stays in view whichever pane you're in, until +it's hidden. + | Key | Action | |---|---| | `ctrl+←`/`ctrl+→` or `<`/`>` | move the divider between the left panes and the detail pane | diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index 900eed8..2c2df6e 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -163,9 +163,11 @@ type Model struct { clip Clipboard clipClear time.Duration openURL func(string) error - // fields is the fourth pane for anything but a secret (now: an - // account), with each account's groups as they load (fields.go). + // fields is the fourth pane for anything but a secret, with each + // account's groups as they load; lastLeft is the left pane it + // describes while the fourth has focus (fields.go). fields panes.Fields + lastLeft pane groups map[string]groupsState getenv func(string) string nvimOpen func(server, path string, line int) error // edit.go @@ -731,7 +733,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.waiting = 0 m.askHandlerSetup() m.keys.Login.SetEnabled(m.loginFor() != nil) - m.detail.SetMessage(m.selectedProblem()) + if m.focus != paneDetail { + m.lastLeft = m.focus + } cmds = append(cmds, m.syncFields()) if m.anyLoading() && !m.spinning { m.spinning = true @@ -799,13 +803,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() { + if r, ok := m.sources.Selected(); ok && m.focus == paneContainers && r.kind == rowGroup { 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 + if m.focus == paneDetail { + m.focus = m.lastLeft // back to what it describes + } else { + m.focus = (m.focus + paneCount - 1) % paneCount } case key.Matches(msg, k.Pane1): m.focus = paneSources diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index 6263ea3..88d4590 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -185,8 +185,9 @@ func TestIcons(t *testing.T) { if !strings.Contains(screen, azure+" Azure") || !strings.Contains(screen, dbx+" Databricks") { t.Errorf("icons missing:\n%s", screen) } - if !strings.Contains(screen, "source "+azure+" Azure / platform-prod") { - t.Errorf("detail source line missing icon:\n%s", screen) + d.keys("3") + if !strings.Contains(d.screen(), "source "+azure+" Azure / platform-prod") { + t.Errorf("detail source line missing icon:\n%s", d.screen()) } } @@ -400,6 +401,7 @@ func (describing) Describe(_ context.Context, o provider.Object) (provider.Objec func TestDescribeUpdatesDetail(t *testing.T) { t.Parallel() d := newDriver(t, describing{fake.Azure(0)}) + d.keys("3") // the fourth pane follows focus: the secret if !strings.Contains(d.screen(), "described db-password") { t.Fatalf("describe result not shown:\n%s", d.screen()) } @@ -487,6 +489,7 @@ func TestVersions(t *testing.T) { func TestVersionHistoryInDetail(t *testing.T) { t.Parallel() d := newDriver(t) + d.keys("3") // the fourth pane follows focus: the secret screen := d.screen() for _, want := range []string{ "versions 3 · v to browse", @@ -594,7 +597,7 @@ func TestPermissionErrorShownInPane(t *testing.T) { if !strings.Contains(d.screen(), "403 Forbidden") { t.Error("error not rendered") } - if !strings.Contains(d.screen(), "Select a secret") { + if !strings.Contains(d.screen(), "name kv-restricted") { t.Error("detail should show the container summary") } } @@ -1037,7 +1040,7 @@ func TestSignInMessageOnceThenInDetails(t *testing.T) { t.Parallel() d := newDriverOpts(t, Options{CollapseGroups: false}, fake.Azure(0), signedOut{fake.Azure(0)}) hint := "Azure · admin: sign-in required" - if !strings.Contains(lastLines(d.screen(), 2), hint) { + if !strings.Contains(lastLines(d.screen(), 2), hint) { // the footer: one line t.Fatalf("not said at start:\n%s", d.screen()) } d.keys("j") @@ -1050,7 +1053,7 @@ func TestSignInMessageOnceThenInDetails(t *testing.T) { if r, _ := d.m.sources.Selected(); r.provider != "Azure:admin@contoso.com/t1" { t.Fatalf("selected %+v", r) } - if !strings.Contains(d.screen(), hint) || !strings.Contains(d.screen(), "press L to run az login --tenant t1") { + if scr := d.screen(); !strings.Contains(scr, "problem") || !strings.Contains(scr, hint) || !strings.Contains(scr, "press L to run az login") { t.Errorf("not in Details:\n%s", d.screen()) } if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" { @@ -1154,3 +1157,35 @@ func TestAccountPane(t *testing.T) { t.Errorf("back to %v, want Sources", d.m.focus) } } + +func TestDetailsFollowFocus(t *testing.T) { + t.Parallel() + d := newDriver(t) + d.keys("3") // a secret + if !strings.Contains(d.screen(), "version 3f9c2a") { + t.Fatalf("secret not shown:\n%s", d.screen()) + } + d.keys("2") // its vault + if scr := d.screen(); !strings.Contains(scr, "name kv-app-prod") || strings.Contains(scr, "version 3f9c2a") { + t.Errorf("vault not described:\n%s", scr) + } + d.keys("1") // its subscription + if scr := d.screen(); !strings.Contains(scr, "name platform-prod") || !strings.Contains(scr, "vaults 3") { + t.Errorf("subscription not described:\n%s", scr) + } + // Into the fourth pane: it keeps describing the subscription, and + // shift+tab goes back there. + d.keys("4") + if !strings.Contains(d.screen(), "name platform-prod") { + t.Errorf("lost the subscription:\n%s", d.screen()) + } + d.keys("shift+tab") + if d.m.focus != paneSources { + t.Errorf("back to %v", d.m.focus) + } + // A value revealed from a left pane stays in view until hidden. + d.keys("space") + if !strings.Contains(d.screen(), "correct-horse-battery-staple") { + t.Errorf("value not shown:\n%s", d.screen()) + } +} diff --git a/internal/ui/app/favourites_test.go b/internal/ui/app/favourites_test.go index d1ce13a..9c1dd84 100644 --- a/internal/ui/app/favourites_test.go +++ b/internal/ui/app/favourites_test.go @@ -421,8 +421,9 @@ func TestStarIsPerAccount(t *testing.T) { d.keys("n") d.typeText("shared note") d.keys("enter") - if n := strings.Count(d.screen(), "shared note"); n != 2 { - t.Errorf("note shown %d times, want 2:\n%s", n, d.screen()) + // On both accounts' rows, and in the selected one's details. + if n := strings.Count(d.screen(), "shared note"); n != 3 { + t.Errorf("note shown %d times, want 3:\n%s", n, d.screen()) } d.keys("F") var labels []string diff --git a/internal/ui/app/fields.go b/internal/ui/app/fields.go index a85c58a..4fb35ba 100644 --- a/internal/ui/app/fields.go +++ b/internal/ui/app/fields.go @@ -7,6 +7,7 @@ import ( "strings" tea "charm.land/bubbletea/v2" + "github.com/charmbracelet/x/ansi" "git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes" @@ -28,21 +29,67 @@ type groupsMsg struct { err error } -// syncFields fills the Fields pane for what's selected, loading an -// account's groups the first time. +// detailOf is the pane the fourth pane describes: the one in focus, or, +// with the fourth in focus, the one it was entered from. +func (m Model) detailOf() pane { + if m.focus != paneDetail { + return m.focus + } + return m.lastLeft +} + +// syncFields shows the Fields pane for what the focused pane has selected, +// unless that's a secret (or a vault with the Secrets pane in focus), which +// the secret pane shows, as it does a revealed value whatever the focus. +// An account's groups load the first time. func (m *Model) syncFields() tea.Cmd { - acct, ok := m.selectedAccount() - if !ok { - m.fields.Set("", nil) + if m.detail.State() != panes.ValueHidden { + // A value revealed (or being fetched) from any pane stays in + // view until it's hidden. + m.fields.Hide() return nil } - r, _ := m.sources.Selected() + switch m.detailOf() { + case paneSources: + r, ok := m.sources.Selected() + switch { + case !ok: + m.fields.Show("", nil) + case m.selectedProblem() != "": + m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem())) + case r.kind == rowGroup: + return m.showAccount(r) + case r.kind == rowSource: + m.fields.Show(r.source.Name, m.sourceFields(r.source)) + default: + m.fields.Show("", nil) + } + case paneContainers: + if c, ok := m.containers.Selected(); ok { + m.fields.Show(c.Name, m.containerFields(c)) + } else { + m.fields.Show("", nil) + } + default: + m.fields.Hide() + } + return nil +} + +// showAccount shows a provider's account, loading its groups the first +// time. +func (m *Model) showAccount(r sourceRow) tea.Cmd { + p := m.byID[r.provider] var fields []panes.Field - for _, f := range acct.Fields { - fields = append(fields, panes.Field{Label: f.Label, Value: f.Value}) + if a, ok := p.(provider.Accounts); ok { + if acct, ok := a.Account(); ok { + 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 { + if g, ok := p.(provider.AccountGroups); ok { st, seen := m.groups[r.provider] if !seen { st = groupsState{loading: true} @@ -57,10 +104,85 @@ func (m *Model) syncFields() tea.Cmd { } fields = append(fields, groupFields(st)...) } - m.fields.Set(m.byID[r.provider].Label(), fields) + m.fields.Show(p.Label(), fields) return cmd } +// sourceFields describe a subscription, workspace or repo. +func (m Model) sourceFields(src provider.Source) []panes.Field { + var out []panes.Field + add := func(label, value string) { + if value != "" { + out = append(out, panes.Field{Label: label, Value: value}) + } + } + // Azure's detail is "subscription ": the ID row says it. + id, detail := "ID", "" + switch src.Kind { + case provider.KindDatabricks: + id, detail = "profile", "host" + case provider.KindRepo: + id, detail = "path", "remote" + } + add("name", src.Name) + add("note", m.store.Node(sourceKey(src)).Note) + if src.ID != src.Name { + add(id, src.ID) + } + if detail != "" { + add(detail, src.Detail) + } + if src.ProviderLabel != string(src.Kind) { + add("account", src.ProviderLabel) + } + add("web", src.URL) + if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok { + label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind] + add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value))) + } + return out +} + +// containerFields describe a vault, scope or file. +func (m Model) containerFields(c provider.Container) []panes.Field { + var out []panes.Field + add := func(label, value string) { + if value != "" { + out = append(out, panes.Field{Label: label, Value: value}) + } + } + add("name", c.Name) + add("note", m.store.Node(storeKey(c)).Note) + // The vault's URI, the scope's backend, the file's format. + switch c.Source.Kind { + case provider.KindAzure: + add("URI", c.Detail) + case provider.KindDatabricks: + add("backend", c.Detail) + case provider.KindRepo: + add("format", c.Detail) + } + add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name) + add("web", c.URL) + for _, t := range c.ObjectTypes() { + if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok { + add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value))) + } + } + return out +} + +// problemFields show why an account has nothing to show, wrapped to the +// pane, in red. +func (m Model) problemFields(problem string) []panes.Field { + var out []panes.Field + for _, l := range strings.Split(ansi.Wrap(problem, max(20, m.fields.Width()-16), " "), "\n") { + out = append(out, panes.Field{Value: l, Info: true, Err: true}) + } + out[0].Label = "problem" + return out +} + // 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") { diff --git a/internal/ui/app/notes_test.go b/internal/ui/app/notes_test.go index e80f2ad..1c3a850 100644 --- a/internal/ui/app/notes_test.go +++ b/internal/ui/app/notes_test.go @@ -42,7 +42,7 @@ func TestNotesOnRows(t *testing.T) { d.note("main prod") screen := d.screen() - for _, want := range []string{"stripe live", "app secrets", "main prod", "note stripe live"} { + for _, want := range []string{"stripe live", "app secrets", "main prod", "note main prod"} { if !strings.Contains(screen, want) { t.Errorf("missing %q:\n%s", want, screen) } diff --git a/internal/ui/app/testdata/TestGolden.golden b/internal/ui/app/testdata/TestGolden.golden index 1e4db37..75d3cd7 100644 --- a/internal/ui/app/testdata/TestGolden.golden +++ b/internal/ui/app/testdata/TestGolden.golden @@ -1,23 +1,23 @@ -╭─ [1] Sources - Favourites ────╮╭─ Secret ────────────────────────────────────────────────────────╮ -│ ▾ Azure (2) ││ name db-password │ -│ platform-prod ││ vault kv-app-prod │ -│ platform-dev ││ source Azure / platform-prod │ -│ ▾ Databricks (2) ││ version 3f9c2a │ -│ prod ││ enabled yes │ -╰────────────────────── 2 of 6 ─╯│ updated 2026-09-01 09:30 │ -╭─ [2] Vaults ──────────────────╮│ expires 2027-09-01 09:30 │ -│ kv-app-prod ││ content text/plain │ -│ kv-shared-prod ││ tags env=prod, owner=data │ -│ kv-restricted ││ ref @Microsoft.KeyVault(SecretUri=https://kv-app-prod.vau│ -│ ││ lt.azure.net/secrets/db-password) │ +╭─ [1] Sources - Favourites ────╮╭─ platform-prod ─────────────────────────────────────────────────╮ +│ ▾ Azure (2) ││ name platform-prod │ +│ platform-prod ││ ID sub-prod │ +│ platform-dev ││ web https://portal.azure.com/#resource/subscriptions/sub…│ +│ ▾ Databricks (2) ││ vaults 3 │ +│ prod ││ │ +╰────────────────────── 2 of 6 ─╯│ │ +╭─ [2] Vaults ──────────────────╮│ │ +│ kv-app-prod ││ │ +│ kv-shared-prod ││ │ +│ kv-restricted ││ │ +│ ││ │ +│ ││ │ +│ ││ │ │ ││ │ -│ ││ value •••••••• │ -│ ││ space to reveal · y to copy │ ╰────────────────────── 1 of 3 ─╯│ │ -╭─ [3] Secrets - Keys - Certs ──╮│ versions 3 · v to browse │ -│ db-password ││ ▸ 3f9c2a 2026-09-01 current │ -│ api-key ││ 91d3e0 2026-03-01 expired │ -│ legacy-token (disabled) ││ 0a77c4 2025-09-01 disabled │ +╭─ [3] Secrets - Keys - Certs ──╮│ │ +│ db-password ││ │ +│ api-key ││ │ +│ legacy-token (disabled) ││ │ │ ││ │ │ ││ │ │ ││ │ @@ -26,5 +26,5 @@ │ ││ │ │ ││ │ │ ││ │ -╰────────────────────── 1 of 3 ─╯╰─────────────────────────────────────────────────────────────────╯ +╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯ enter open • ctrl+s search • s copy link • S share… • f star • F favourites • … • ? help • q quit \ No newline at end of file diff --git a/internal/ui/panes/detail.go b/internal/ui/panes/detail.go index 2379fee..cd2a695 100644 --- a/internal/ui/panes/detail.go +++ b/internal/ui/panes/detail.go @@ -46,7 +46,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 offset int w, h int @@ -72,10 +71,6 @@ func (d *Detail) Clear() { *d = Detail{Now: d.Now, Icons: d.Icons, RevealKey: d.RevealKey, VersionsKey: d.VersionsKey, CopyKey: d.CopyKey, w: d.w, h: d.h} // drops the note too } -// SetMessage sets what's shown instead of the hint when nothing is -// selected, such as why an account has nothing to show; "" for the hint. -func (d *Detail) SetMessage(s string) { d.message = s } - // SetDescribing marks extra metadata as being fetched. func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil } @@ -191,12 +186,6 @@ func (d Detail) lines(st styles.Styles) []string { muted := st.Muted.Render if d.container == nil { - 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)) - } - return out - } return []string{muted(" Select a vault or scope to browse its secrets.")} } c := *d.container diff --git a/internal/ui/panes/fields.go b/internal/ui/panes/fields.go index 0859c46..eaa6f45 100644 --- a/internal/ui/panes/fields.go +++ b/internal/ui/panes/fields.go @@ -15,13 +15,14 @@ import ( // have Detail, for revealing and versions. type Fields struct { List[Field] + shown bool } // Field is a row: a value under a label. Info rows (loading, errors) -// can't be copied. +// can't be copied; Err ones are errors. type Field struct { Label, Value string - Info bool + Info, Err bool } func NewFields() Fields { @@ -32,30 +33,35 @@ func NewFields() Fields { FilterExtra: func(f Field) string { return f.Label }, Render: func(st styles.Styles, f Field, width int) string { value := f.Value - if f.Info { + switch { + case f.Err: + value = st.Error.Render(value) + case 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} + return Fields{List: 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) { +// Show shows fields under title, instead of the secret pane. Another +// title (another account or vault) starts over, without a filter. +func (s *Fields) Show(title string, fields []Field) { if title != s.Title { s.Reset() s.Title = title } - if len(fields) > 0 || len(s.Items()) > 0 { - s.SetItems(fields) - } + s.shown = true + s.SetItems(fields) } -// Shown reports whether there's anything to show. -func (s Fields) Shown() bool { return len(s.Items()) > 0 } +// Hide gives the fourth pane back to the secret pane. +func (s *Fields) Hide() { s.shown = false } + +// Shown reports whether the fourth pane shows Fields. +func (s Fields) Shown() bool { return s.shown } // Selected is the field under the cursor, unless it's an info row. func (s Fields) Selected() (Field, bool) { -- 2.54.0