A cached list is shown at once and reloaded in the background; the selected secret's details stayed as first shown, old web page included, until another secret was selected. When the reloaded list has it changed, the details pane takes the new listing (a value shown stays shown) and describes it afresh: its cached details came from the old one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
405 lines
12 KiB
Go
405 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"
|
|
|
|
// 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") }
|