feat: the right pane describes what's selected in the pane you're in #9

Merged
keb merged 1 commits from feat/details-follow-focus into main 2026-10-11 10:59:40 +02:00
9 changed files with 228 additions and 63 deletions
Showing only changes of commit 411068ba43 - Show all commits

No files matched your search

+7
View File
@@ -105,6 +105,13 @@ anything loads.
## Layout ## 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 | | Key | Action |
|---|---| |---|---|
| `ctrl+←`/`ctrl+→` or `<`/`>` | move the divider between the left panes and the detail pane | | `ctrl+←`/`ctrl+→` or `<`/`>` | move the divider between the left panes and the detail pane |
+11 -6
View File
@@ -163,9 +163,11 @@ type Model struct {
clip Clipboard clip Clipboard
clipClear time.Duration clipClear time.Duration
openURL func(string) error openURL func(string) error
// fields is the fourth pane for anything but a secret (now: an // fields is the fourth pane for anything but a secret, with each
// account), with each account's groups as they load (fields.go). // account's groups as they load; lastLeft is the left pane it
// describes while the fourth has focus (fields.go).
fields panes.Fields fields panes.Fields
lastLeft pane
groups map[string]groupsState groups map[string]groupsState
getenv func(string) string getenv func(string) string
nvimOpen func(server, path string, line int) error // edit.go 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.waiting = 0
m.askHandlerSetup() m.askHandlerSetup()
m.keys.Login.SetEnabled(m.loginFor() != nil) 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()) cmds = append(cmds, m.syncFields())
if m.anyLoading() && !m.spinning { if m.anyLoading() && !m.spinning {
m.spinning = true m.spinning = true
@@ -799,13 +803,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() { if r, ok := m.sources.Selected(); ok && m.focus == paneContainers && r.kind == rowGroup {
m.focus = paneDetail // an account's row: nothing between m.focus = paneDetail // an account's row: nothing between
} }
case key.Matches(msg, k.PrevPane): case key.Matches(msg, k.PrevPane):
if m.focus == paneDetail {
m.focus = m.lastLeft // back to what it describes
} else {
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
+40 -5
View File
@@ -185,8 +185,9 @@ func TestIcons(t *testing.T) {
if !strings.Contains(screen, azure+" Azure") || !strings.Contains(screen, dbx+" Databricks") { if !strings.Contains(screen, azure+" Azure") || !strings.Contains(screen, dbx+" Databricks") {
t.Errorf("icons missing:\n%s", screen) t.Errorf("icons missing:\n%s", screen)
} }
if !strings.Contains(screen, "source "+azure+" Azure / platform-prod") { d.keys("3")
t.Errorf("detail source line missing icon:\n%s", screen) 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) { func TestDescribeUpdatesDetail(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t, describing{fake.Azure(0)}) 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") { if !strings.Contains(d.screen(), "described db-password") {
t.Fatalf("describe result not shown:\n%s", d.screen()) t.Fatalf("describe result not shown:\n%s", d.screen())
} }
@@ -487,6 +489,7 @@ func TestVersions(t *testing.T) {
func TestVersionHistoryInDetail(t *testing.T) { func TestVersionHistoryInDetail(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriver(t) d := newDriver(t)
d.keys("3") // the fourth pane follows focus: the secret
screen := d.screen() screen := d.screen()
for _, want := range []string{ for _, want := range []string{
"versions 3 · v to browse", "versions 3 · v to browse",
@@ -594,7 +597,7 @@ func TestPermissionErrorShownInPane(t *testing.T) {
if !strings.Contains(d.screen(), "403 Forbidden") { if !strings.Contains(d.screen(), "403 Forbidden") {
t.Error("error not rendered") 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") t.Error("detail should show the container summary")
} }
} }
@@ -1037,7 +1040,7 @@ func TestSignInMessageOnceThenInDetails(t *testing.T) {
t.Parallel() t.Parallel()
d := newDriverOpts(t, Options{CollapseGroups: false}, fake.Azure(0), signedOut{fake.Azure(0)}) d := newDriverOpts(t, Options{CollapseGroups: false}, fake.Azure(0), signedOut{fake.Azure(0)})
hint := "Azure · admin: sign-in required" 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()) t.Fatalf("not said at start:\n%s", d.screen())
} }
d.keys("j") 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" { if r, _ := d.m.sources.Selected(); r.provider != "Azure:admin@contoso.com/t1" {
t.Fatalf("selected %+v", r) 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()) t.Errorf("not in Details:\n%s", d.screen())
} }
if a := d.m.loginFor(); a == nil || a.Account != "Azure · admin" { 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) 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())
}
}
+3 -2
View File
@@ -421,8 +421,9 @@ func TestStarIsPerAccount(t *testing.T) {
d.keys("n") d.keys("n")
d.typeText("shared note") d.typeText("shared note")
d.keys("enter") d.keys("enter")
if n := strings.Count(d.screen(), "shared note"); n != 2 { // On both accounts' rows, and in the selected one's details.
t.Errorf("note shown %d times, want 2:\n%s", n, d.screen()) 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") d.keys("F")
var labels []string var labels []string
+130 -8
View File
@@ -7,6 +7,7 @@ import (
"strings" "strings"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/provider" "git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes" "git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
@@ -28,21 +29,67 @@ type groupsMsg struct {
err error err error
} }
// syncFields fills the Fields pane for what's selected, loading an // detailOf is the pane the fourth pane describes: the one in focus, or,
// account's groups the first time. // 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 { func (m *Model) syncFields() tea.Cmd {
acct, ok := m.selectedAccount() if m.detail.State() != panes.ValueHidden {
if !ok { // A value revealed (or being fetched) from any pane stays in
m.fields.Set("", nil) // view until it's hidden.
m.fields.Hide()
return nil 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 var fields []panes.Field
if a, ok := p.(provider.Accounts); ok {
if acct, ok := a.Account(); ok {
for _, f := range acct.Fields { for _, f := range acct.Fields {
fields = append(fields, panes.Field{Label: f.Label, Value: f.Value}) fields = append(fields, panes.Field{Label: f.Label, Value: f.Value})
} }
}
}
var cmd tea.Cmd 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] st, seen := m.groups[r.provider]
if !seen { if !seen {
st = groupsState{loading: true} st = groupsState{loading: true}
@@ -57,10 +104,85 @@ func (m *Model) syncFields() tea.Cmd {
} }
fields = append(fields, groupFields(st)...) fields = append(fields, groupFields(st)...)
} }
m.fields.Set(m.byID[r.provider].Label(), fields) m.fields.Show(p.Label(), fields)
return cmd 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 <ID>": 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. // fieldName names a copied field: its label, or for a group, the group.
func fieldName(f panes.Field) string { func fieldName(f panes.Field) string {
if f.Label == "" || strings.HasPrefix(f.Label, "groups") { if f.Label == "" || strings.HasPrefix(f.Label, "groups") {
+1 -1
View File
@@ -42,7 +42,7 @@ func TestNotesOnRows(t *testing.T) {
d.note("main prod") d.note("main prod")
screen := d.screen() 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) { if !strings.Contains(screen, want) {
t.Errorf("missing %q:\n%s", want, screen) t.Errorf("missing %q:\n%s", want, screen)
} }
+19 -19
View File
@@ -1,23 +1,23 @@
╭─ [1] Sources - Favourites ────╮╭─ Secret ────────────────────────────────────────────────────────╮ ╭─ [1] Sources - Favourites ────╮╭─ platform-prod ─────────────────────────────────────────────────╮
│ ▾ Azure (2) ││ name db-password │ │ ▾ Azure (2) ││ name platform-prod │
│ platform-prod ││ vault kv-app-prod │ │ platform-prod ││ ID sub-prod │
│ platform-dev ││ source Azure / platform-prod │ │ platform-dev ││ web https://portal.azure.com/#resource/subscriptions/sub…│
│ ▾ Databricks (2) ││ version 3f9c2a │ │ ▾ Databricks (2) ││ vaults 3 │
│ prod ││ enabled yes │ │ prod ││ │
╰────────────────────── 2 of 6 ─╯│ updated 2026-09-01 09:30 │ ╰────────────────────── 2 of 6 ─╯│ │
╭─ [2] Vaults ──────────────────╮│ expires 2027-09-01 09:30 │ ╭─ [2] Vaults ──────────────────╮│ │
│ kv-app-prod ││ content text/plain │ │ kv-app-prod ││ │
│ kv-shared-prod ││ tags env=prod, owner=data │ │ kv-shared-prod ││ │
│ kv-restricted ││ ref @Microsoft.KeyVault(SecretUri=https://kv-app-prod.vau│ │ kv-restricted ││ │
│ ││ lt.azure.net/secrets/db-password) │ │ ││ │
│ ││ │
│ ││ │
│ ││ │ │ ││ │
│ ││ value •••••••• │
│ ││ space to reveal · y to copy │
╰────────────────────── 1 of 3 ─╯│ │ ╰────────────────────── 1 of 3 ─╯│ │
╭─ [3] Secrets - Keys - Certs ──╮│ versions 3 · v to browse │ ╭─ [3] Secrets - Keys - Certs ──╮│ │
│ db-password ││ ▸ 3f9c2a 2026-09-01 current │ │ db-password ││ │
│ api-key ││ 91d3e0 2026-03-01 expired │ │ api-key ││ │
│ legacy-token (disabled) ││ 0a77c4 2025-09-01 disabled │ │ 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 enter open • ctrl+s search • s copy link • S share… • f star • F favourites • … • ? help • q quit
-11
View File
@@ -46,7 +46,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
offset int offset int
w, h 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 *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. // 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 }
@@ -191,12 +186,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 != "" {
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.")} return []string{muted(" Select a vault or scope to browse its secrets.")}
} }
c := *d.container c := *d.container
+17 -11
View File
@@ -15,13 +15,14 @@ import (
// have Detail, for revealing and versions. // have Detail, for revealing and versions.
type Fields struct { type Fields struct {
List[Field] List[Field]
shown bool
} }
// 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)
// can't be copied. // can't be copied; Err ones are errors.
type Field struct { type Field struct {
Label, Value string Label, Value string
Info bool Info, Err bool
} }
func NewFields() Fields { func NewFields() Fields {
@@ -32,30 +33,35 @@ func NewFields() Fields {
FilterExtra: func(f Field) string { return f.Label }, FilterExtra: func(f Field) string { return f.Label },
Render: func(st styles.Styles, f Field, width int) string { Render: func(st styles.Styles, f Field, width int) string {
value := f.Value value := f.Value
if f.Info { switch {
case f.Err:
value = st.Error.Render(value)
case f.Info:
value = st.Muted.Render(value) value = st.Muted.Render(value)
} }
return ansi.Truncate(st.Label.Render(fmt.Sprintf("%-10s", f.Label))+" "+value, width, "…") return ansi.Truncate(st.Label.Render(fmt.Sprintf("%-10s", f.Label))+" "+value, width, "…")
}, },
}) })
l.Empty = "nothing to show" l.Empty = "nothing to show"
return Fields{l} return Fields{List: l}
} }
// Set shows fields under title. Another title (another account or vault) // Show shows fields under title, instead of the secret pane. Another
// starts over, without a filter. // title (another account or vault) starts over, without a filter.
func (s *Fields) Set(title string, fields []Field) { func (s *Fields) Show(title string, fields []Field) {
if title != s.Title { if title != s.Title {
s.Reset() s.Reset()
s.Title = title s.Title = title
} }
if len(fields) > 0 || len(s.Items()) > 0 { s.shown = true
s.SetItems(fields) s.SetItems(fields)
}
} }
// Shown reports whether there's anything to show. // Hide gives the fourth pane back to the secret pane.
func (s Fields) Shown() bool { return len(s.Items()) > 0 } 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. // Selected is the field under the cursor, unless it's an info row.
func (s Fields) Selected() (Field, bool) { func (s Fields) Selected() (Field, bool) {