Files
lazysecrets/internal/ui/panes/detail.go
T
keb dbb0eb2bbd fix: a wrapped value in the details keeps its colour on every line (#69)
A long value in the details pane (a ref, a note, a vault's detail, an error) was coloured, then wrapped: the colour's reset only came on the last line, and each line is drawn on its own, so the lines after the first lost the colour. Each wrapped line now starts with the styles still on at the end of the one before.

Reviewed-on: #69
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-14 21:23:53 +02:00

423 lines
12 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
}
// Refresh shows s's newer metadata if s is the secret shown, keeping
// its account and any value shown or loading.
func (d *Detail) Refresh(s provider.Object) {
if d.secret == nil || d.secret.ID != s.ID {
return
}
s.Container = d.secret.Container
d.secret = &s
}
// 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"
// carryStyle starts each wrapped line with the styles still on at the end
// of the line before: a styled value wrapped in the middle (a long ref)
// would go on unstyled, as each line is drawn on its own.
func carryStyle(lines []string) []string {
var on string
for i, line := range lines {
lines[i] = on + line
for _, code := range sgr.FindAllString(line, -1) {
if code == "\x1b[m" || code == "\x1b[0m" {
on = ""
} else {
on += code
}
}
}
return lines
}
// 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 := carryStyle(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") }