Files
lazysecrets/internal/ui/panes/detail.go
T
kebandClaude Opus 5.5 c728033436
CI / Checks (pull_request) Successful in 25s
PR title / Conventional title (pull_request) Successful in 2s
fix: no cursor on the versions in a secret's details: they're read, not moved in
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 08:56:29 +02:00

395 lines
11 KiB
Go

package panes
import (
"cmp"
"fmt"
"maps"
"slices"
"strings"
"time"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
type ValueState int
const (
ValueHidden ValueState = iota
ValueLoading
ValueShown
ValueFailed
)
// Detail shows metadata for the selected secret (or container) and, on
// request, its value.
type Detail struct {
Now func() time.Time
Icons icons.Set
// RevealKey and VersionsKey are the keys hints mention ("space to
// reveal"); a hint is left out when its key is "" (unbound).
RevealKey, VersionsKey, CopyKey string
secret *provider.Object
container *provider.Container
state ValueState
value provider.SecretValue
err error
hidesIn string // when a shown value hides by itself, if it does
describing bool
describeErr error
versions []provider.Object // newest first; nil while unknown
note string // the user's note on what's shown
offset int
w, h int
}
func NewDetail(iconSet icons.Set) Detail { return Detail{Now: time.Now, Icons: iconSet} }
// ShowSecret selects a secret and forgets any previously fetched value.
func (d *Detail) ShowSecret(s provider.Object) {
d.Clear()
d.secret = &s
d.container = &s.Container
}
// ShowContainer shows a container summary when no secret is selected.
func (d *Detail) ShowContainer(c provider.Container) {
d.Clear()
d.container = &c
}
// Clear drops the selection and any fetched value.
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
}
// SetDescribing marks extra metadata as being fetched.
func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil }
// SetDescribed replaces the shown object's metadata, keeping the value
// state. It ignores objects other than the one shown.
func (d *Detail) SetDescribed(o provider.Object) {
if d.secret == nil || d.secret.ID != o.ID {
return
}
// Keep the container (and so the account) the object was shown with;
// value requests are routed by it.
o.Container = d.secret.Container
d.secret = &o
d.describing = false
}
// SetNote shows the user's note on the shown object or container.
func (d *Detail) SetNote(note string) { d.note = note }
// SetVersions records the shown object's versions, newest first. It
// ignores objects other than the one shown.
func (d *Detail) SetVersions(id string, versions []provider.Object) {
if d.secret != nil && d.secret.ID == id {
d.versions = versions
}
}
func (d *Detail) SetDescribeError(err error) { d.describing, d.describeErr = false, err }
func (d Detail) Secret() (provider.Object, bool) {
if d.secret == nil {
return provider.Object{}, false
}
return *d.secret, true
}
// Container is the container shown, if any.
func (d Detail) Container() (provider.Container, bool) {
if d.container == nil {
return provider.Container{}, false
}
return *d.container, true
}
// Value returns the value if it is shown.
func (d Detail) Value() (provider.SecretValue, bool) {
return d.value, d.state == ValueShown
}
func (d Detail) State() ValueState { return d.state }
func (d *Detail) SetLoading() { d.state, d.err = ValueLoading, nil }
func (d *Detail) SetValue(v provider.SecretValue) { d.state, d.value, d.err = ValueShown, v, nil }
func (d *Detail) SetValueError(err error) { d.state, d.err = ValueFailed, err }
// Hide masks the value again and drops it from memory.
func (d *Detail) Hide() {
d.state, d.value, d.err, d.hidesIn = ValueHidden, provider.SecretValue{}, nil, ""
}
// SetHidesIn shows when a revealed value hides by itself, e.g. "42s".
func (d *Detail) SetHidesIn(s string) { d.hidesIn = s }
func (d *Detail) SetSize(w, h int) {
d.w, d.h = w, h
d.Scroll(0)
}
func (d *Detail) Scroll(delta int) {
total := len(d.lines(styles.Styles{}))
d.offset = max(0, min(d.offset+delta, total-(d.h-2)))
}
func (d *Detail) ScrollTop() { d.offset = 0 }
func (d *Detail) ScrollBottom() { d.Scroll(1 << 30) }
func (d Detail) View(st styles.Styles, focused bool, spin string) string {
lines := d.lines(st)
if spin != "" && d.state == ValueLoading {
for i, l := range lines {
lines[i] = strings.Replace(l, spinnerPlaceholder, spin, 1)
}
}
rows := max(0, d.h-2)
footer := ""
if len(lines) > rows {
footer = fmt.Sprintf("%d-%d of %d", d.offset+1, min(d.offset+rows, len(lines)), len(lines))
}
title := "Details"
if d.secret != nil {
title = string(d.secret.Type)
}
return Box(st, TitleStyle(st, focused).Render(title), footer, lines[min(d.offset, len(lines)):], d.w, d.h, focused)
}
const spinnerPlaceholder = "\x00spin\x00"
// lines renders the body, wrapped to the pane width.
func (d Detail) lines(st styles.Styles) []string {
inner := max(1, d.w-3)
var out []string
field := func(label, value string) {
prefix := " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
indent := strings.Repeat(" ", 12)
wrapped := strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n")
out = append(out, prefix+wrapped[0])
for _, w := range wrapped[1:] {
out = append(out, indent+w)
}
}
muted := st.Muted.Render
if d.container == nil {
return []string{muted(" Select a vault or scope to browse its secrets.")}
}
c := *d.container
containerLabel := map[provider.Kind]string{provider.KindDatabricks: "scope", provider.KindRepo: "file"}[c.Source.Kind]
if containerLabel == "" {
containerLabel = "vault"
}
if d.secret == nil {
field(containerLabel, c.Name)
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
if c.Detail != "" {
field("", muted(c.Detail))
}
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
out = append(out, "", muted(" Select a secret to see its details."))
return out
}
s := *d.secret
field("name", s.Name)
if d.note != "" {
field("note", st.Favourite.Render(d.note))
}
field(containerLabel, c.Name)
if c.Source.Kind != provider.KindAzure && c.Detail != "" {
field("", muted(c.Detail))
}
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
if s.Version != "" {
field("version", s.Version)
}
if s.Enabled != nil {
if *s.Enabled {
field("enabled", "yes")
} else {
field("enabled", st.Error.Render("no"))
}
}
if !s.Updated.IsZero() {
field("updated", formatTime(s.Updated))
}
if !s.Expires.IsZero() {
exp := formatTime(s.Expires)
if s.Expires.Before(d.Now()) {
exp = st.Error.Render(exp + " (expired)")
}
field("expires", exp)
}
if s.ContentType != "" {
field("content", s.ContentType)
}
for _, a := range s.Attrs {
field(a.Label, a.Value)
}
if len(s.Tags) > 0 {
var tags []string
for _, k := range slices.Sorted(maps.Keys(s.Tags)) {
tags = append(tags, k+"="+s.Tags[k])
}
field("tags", strings.Join(tags, ", "))
}
if s.Reference != "" {
field("ref", muted(s.Reference))
}
switch {
case d.describing:
out = append(out, muted(" loading details…"))
case d.describeErr != nil:
field("details", st.Error.Render(d.describeErr.Error()))
}
// Secrets are masked; keys and certificates only expose public material.
// What the keys do with the value, on the line under it.
hints := func(reveal string, more ...string) string {
var h []string
if d.RevealKey != "" {
h = append(h, d.RevealKey+" to "+reveal)
}
if d.CopyKey != "" {
h = append(h, d.CopyKey+" to copy")
}
return muted(strings.Join(append(h, more...), " · "))
}
label, hidden, reveal := "value", st.Masked.Render("••••••••"), "reveal"
switch s.Type {
case provider.TypeKey:
label, hidden, reveal = "public", "", "show the public key"
case provider.TypeCertificate:
label, hidden, reveal = "cert", "", "show the certificate"
}
indent := strings.Repeat(" ", 12)
out = append(out, "")
switch d.state {
case ValueHidden:
if hidden != "" {
field(label, hidden)
out = append(out, indent+hints(reveal))
} else {
field(label, hints(reveal))
}
case ValueLoading:
field(label, spinnerPlaceholder+" fetching…")
case ValueFailed:
// Errors can run over several lines (sops lists every key it
// tried): all of them, each wrapped at spaces and styled alone,
// so lines aren't padded to the longest.
for i, line := range strings.Split(strings.TrimRight(d.err.Error(), "\n"), "\n") {
// Keep a line's own indent, on each part it wraps into.
text := strings.TrimLeft(line, " ")
lead := strings.Repeat(" ", len(line)-len(text))
for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") {
w = lead + w
if i == 0 && j == 0 {
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w))
} else {
out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w))
}
}
}
case ValueShown:
// Where the dots were, continuing under itself; the keys below.
first := true
lines, note := valueView(d.value.Reveal())
for _, line := range lines {
for _, w := range strings.Split(ansi.Hardwrap(line, max(1, inner-11), true), "\n") {
if first {
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Value.Render(w))
first = false
} else {
out = append(out, indent+st.Value.Render(w))
}
}
}
var more []string
if note != "" && d.CopyKey != "" {
note += ": " + d.CopyKey + " copies it as stored"
}
if note != "" {
more = append(more, note)
}
if d.hidesIn != "" {
more = append(more, "hides in "+d.hidesIn)
}
out = append(out, indent+hints("hide", more...))
}
return append(out, d.versionLines(st)...)
}
// maxVersionLines caps the version history shown in the detail pane; the
// versions view (v) shows them all.
const maxVersionLines = 10
func (d Detail) versionLines(st styles.Styles) []string {
if len(d.versions) == 0 {
return nil
}
muted := st.Muted.Render
head := fmt.Sprintf("%d", len(d.versions))
if len(d.versions) > 1 {
head = st.Notice.Render(head)
if d.VersionsKey != "" {
head += muted(" · " + d.VersionsKey + " to browse")
}
}
out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head}
current := d.versions[0].Version
for i, v := range d.versions {
if i == maxVersionLines {
out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i)))
break
}
// A list to read, not to move in (v browses them): no cursor. The
// version shown above, when it isn't the current one, is named.
line := " " + VersionLine(st, v, d.Now(), current)
if d.secret.Version != "" && v.Version == d.secret.Version && v.Version != current {
line += muted(" · shown above")
}
out = append(out, line)
}
return out
}
// VersionLine draws a version as "id date state".
func VersionLine(st styles.Styles, v provider.Object, now time.Time, current string) string {
id := v.Version
if len(id) > 8 {
id = id[:8]
}
line := fmt.Sprintf("%-8s %s", id, v.Updated.Local().Format("2006-01-02"))
switch {
case v.Enabled != nil && !*v.Enabled:
return st.Muted.Render(line + " disabled")
case !v.Expires.IsZero() && v.Expires.Before(now):
return st.Muted.Render(line + " expired")
case v.Version == current:
return line + st.Notice.Render(" current")
}
return line
}
func formatTime(t time.Time) string { return t.Local().Format("2006-01-02 15:04") }