Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
5987787f38
130 files changed
+26897
No files matched your search
@@ -0,0 +1,71 @@
|
||||
// Package panes contains the lazygit-style bordered panes.
|
||||
package panes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
// Box draws a rounded border of exactly w×h cells with the title embedded in
|
||||
// the top edge and an optional footer in the bottom edge. The title is
|
||||
// rendered as given, so callers style it (see TitleStyle). Body lines are
|
||||
// truncated or padded to fit; extra lines are dropped.
|
||||
func Box(st styles.Styles, title, footer string, body []string, w, h int, focused bool) string {
|
||||
return BoxCounted(st, title, footer, "", body, w, h, focused)
|
||||
}
|
||||
|
||||
// BoxCounted is Box with a counter like "3 of 12" at the right end of the
|
||||
// bottom edge, where lazygit puts it.
|
||||
func BoxCounted(st styles.Styles, title, footer, counter string, body []string, w, h int, focused bool) string {
|
||||
if w < 4 || h < 2 {
|
||||
return ""
|
||||
}
|
||||
border := st.BorderBlurred
|
||||
if focused {
|
||||
border = st.BorderFocused
|
||||
}
|
||||
plain := func(s ...string) string { return strings.Join(s, "") }
|
||||
inner := w - 2
|
||||
|
||||
edge := func(left, right, label string, styled func(...string) string) string {
|
||||
if label == "" {
|
||||
return border.Render(left + strings.Repeat("─", inner) + right)
|
||||
}
|
||||
label = ansi.Truncate(" "+label+" ", inner-1, "…")
|
||||
fill := inner - 1 - ansi.StringWidth(label)
|
||||
return border.Render(left+"─") + styled(label) + border.Render(strings.Repeat("─", fill)+right)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(edge("╭", "╮", title, plain))
|
||||
side := border.Render("│")
|
||||
for i := range h - 2 {
|
||||
line := ""
|
||||
if i < len(body) {
|
||||
line = ansi.Truncate(body[i], inner, "…")
|
||||
}
|
||||
b.WriteString("\n" + side + line + strings.Repeat(" ", inner-ansi.StringWidth(line)) + side)
|
||||
}
|
||||
bottom := edge("╰", "╯", footer, border.Render)
|
||||
if counter != "" {
|
||||
// Overwrite the end of the bottom edge: "… 3 of 12 ─╯".
|
||||
label := " " + counter + " "
|
||||
if keep := w - 2 - ansi.StringWidth(label); keep > 2 {
|
||||
bottom = ansi.Truncate(bottom, keep, "") + border.Render(label+"─╯")
|
||||
}
|
||||
}
|
||||
b.WriteString("\n" + bottom)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TitleStyle returns the style for a pane title.
|
||||
func TitleStyle(st styles.Styles, focused bool) lipgloss.Style {
|
||||
if focused {
|
||||
return st.TitleFocused
|
||||
}
|
||||
return st.TitleBlurred
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
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") }
|
||||
@@ -0,0 +1,79 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
// Fields is the fourth pane for anything but a secret (an account, and
|
||||
// what describes vaults, scopes and files): labelled values, one per row,
|
||||
// a label on the first of several values, to move through, filter and
|
||||
// copy from. It's a List, so / filters it like the other panes. Secrets
|
||||
// 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; Err ones are errors. A row can also be opened: URL in
|
||||
// the browser (o), Path in the editor (e), Provider signed in to (L), or
|
||||
// Link gone to (enter).
|
||||
type Field struct {
|
||||
Label, Value string
|
||||
Info, Err bool
|
||||
|
||||
URL, Path, Provider string
|
||||
// Link is a lazysecrets link enter goes to, in the source of row Row.
|
||||
Link, Row string
|
||||
}
|
||||
|
||||
func NewFields() Fields {
|
||||
l := NewList("", Item[Field]{
|
||||
ID: func(f Field) string { return f.Label + "\x00" + f.Value },
|
||||
Filter: func(f Field) string { return f.Value },
|
||||
// "/tenant" finds the tenant's rows.
|
||||
FilterExtra: func(f Field) string { return f.Label },
|
||||
Render: func(st styles.Styles, f Field, width int) string {
|
||||
value := f.Value
|
||||
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{List: l}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
s.shown = true
|
||||
s.SetItems(fields)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
f, ok := s.List.Selected()
|
||||
if !ok || f.Info {
|
||||
return Field{}, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// sgr matches SGR escape sequences.
|
||||
var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
|
||||
|
||||
// Highlight draws a cursor row: line, padded to width, on style's
|
||||
// background, keeping the colours line already has (a provider's colour,
|
||||
// a dimmed note), like LazyVim's and lazygit's selected lines; bold
|
||||
// carries over too. A reverse style is applied to the plain text instead,
|
||||
// since reversing coloured text would scramble it.
|
||||
func Highlight(line string, style lipgloss.Style, width int) string {
|
||||
pad := strings.Repeat(" ", max(0, width-ansi.StringWidth(line)))
|
||||
if style.GetReverse() {
|
||||
return style.Render(ansi.Strip(line) + pad)
|
||||
}
|
||||
var s ansi.Style
|
||||
if bg := style.GetBackground(); !isNoColor(bg) { // never nil: NoColor when unset
|
||||
s = s.BackgroundColor(bg)
|
||||
}
|
||||
if style.GetBold() {
|
||||
s = s.Bold()
|
||||
}
|
||||
if len(s) == 0 {
|
||||
return line + pad
|
||||
}
|
||||
on := s.String()
|
||||
// Every reset in line (a bare reset, one starting with 0, or a
|
||||
// background reset) would end the highlight: put it back after each.
|
||||
line = sgr.ReplaceAllStringFunc(line, func(seq string) string {
|
||||
params := sgr.FindStringSubmatch(seq)[1]
|
||||
for _, p := range strings.Split(params, ";") {
|
||||
if p == "" || p == "0" || p == "49" {
|
||||
return seq + on
|
||||
}
|
||||
}
|
||||
return seq
|
||||
})
|
||||
return on + line + pad + ansi.ResetStyle
|
||||
}
|
||||
|
||||
func isNoColor(c color.Color) bool {
|
||||
_, ok := c.(lipgloss.NoColor)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,449 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/match"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
// Item adapts a value for display in a List.
|
||||
type Item[T any] struct {
|
||||
ID func(T) string // stable identity, used to detect selection changes
|
||||
Filter func(T) string // text matched by the filter, and highlighted
|
||||
// FilterExtra, if set, is matched too but not shown highlighted, for
|
||||
// text the row doesn't show (e.g. the provider of a grouped source).
|
||||
FilterExtra func(T) string
|
||||
Render func(st styles.Styles, v T, width int) string
|
||||
}
|
||||
|
||||
// List is a scrollable, filterable list pane with loading and error states.
|
||||
type List[T any] struct {
|
||||
Title string
|
||||
// Tabs, when there are several, are shown after the title; Tab is the
|
||||
// active one.
|
||||
Tabs []string
|
||||
Tab int
|
||||
Empty string // shown when loaded with no items
|
||||
Loading bool
|
||||
Err error
|
||||
// Age says how old the items are ("2h ago") while they reload, by
|
||||
// the counter.
|
||||
Age string
|
||||
// Header lines (a logo) go above the items while the cursor is in
|
||||
// the rows under it, the pane has room and there's no filter; else
|
||||
// HeaderShort, a line of plain text, does.
|
||||
Header []string
|
||||
HeaderShort string
|
||||
|
||||
item Item[T]
|
||||
items []T
|
||||
visible []int // indices into items that match the filter, best first
|
||||
hits [][]int // per visible item: matched rune indices in its Filter text
|
||||
cursor int // index into visible
|
||||
offset int // first visible row
|
||||
w, h int // outer size including border
|
||||
|
||||
input textinput.Model
|
||||
filtering bool
|
||||
}
|
||||
|
||||
func NewList[T any](title string, item Item[T]) List[T] {
|
||||
in := textinput.New()
|
||||
in.Prompt = "/"
|
||||
return List[T]{Title: title, Empty: "nothing here", item: item, input: in}
|
||||
}
|
||||
|
||||
// SetRender changes how items are drawn, e.g. when the pane switches to
|
||||
// showing versions.
|
||||
func (l *List[T]) SetRender(render func(st styles.Styles, v T, width int) string) {
|
||||
l.item.Render = render
|
||||
}
|
||||
|
||||
// SetItems replaces the contents, keeping the cursor on the same item if it
|
||||
// is still present.
|
||||
func (l *List[T]) SetItems(items []T) {
|
||||
prev := l.SelectedID()
|
||||
l.items = items
|
||||
l.Loading = false
|
||||
l.Err = nil
|
||||
l.applyFilter(prev)
|
||||
}
|
||||
|
||||
// Reset clears items, error, filter and cursor.
|
||||
func (l *List[T]) Reset() {
|
||||
l.items, l.visible = nil, nil
|
||||
l.cursor, l.offset = 0, 0
|
||||
l.Loading, l.Err = false, nil
|
||||
l.filtering = false
|
||||
l.input.Reset()
|
||||
}
|
||||
|
||||
func (l *List[T]) SetError(err error) {
|
||||
l.Reset()
|
||||
l.Err = err
|
||||
}
|
||||
|
||||
// Refresh marks the pane as loading while keeping the current items and
|
||||
// selection on screen until SetItems replaces them.
|
||||
func (l *List[T]) Refresh() {
|
||||
l.Loading = true
|
||||
l.Err = nil
|
||||
l.Age = ""
|
||||
}
|
||||
|
||||
func (l *List[T]) SetLoading() {
|
||||
l.Reset()
|
||||
l.Loading = true
|
||||
}
|
||||
|
||||
func (l List[T]) Selected() (T, bool) {
|
||||
if l.cursor < 0 || l.cursor >= len(l.visible) {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
return l.items[l.visible[l.cursor]], true
|
||||
}
|
||||
|
||||
// SelectedID is "" when nothing is selected.
|
||||
func (l List[T]) SelectedID() string {
|
||||
v, ok := l.Selected()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return l.item.ID(v)
|
||||
}
|
||||
|
||||
func (l List[T]) Len() int { return len(l.visible) }
|
||||
|
||||
// Width is the pane's outer width.
|
||||
func (l List[T]) Width() int { return l.w }
|
||||
|
||||
// Items returns all items, ignoring the filter.
|
||||
func (l List[T]) Items() []T { return l.items }
|
||||
|
||||
// Select moves the cursor to the item with the given ID, if visible, and
|
||||
// reports whether it was.
|
||||
func (l *List[T]) Select(id string) bool {
|
||||
return l.SelectWhere(func(v T) bool { return l.item.ID(v) == id })
|
||||
}
|
||||
|
||||
// SelectWhere moves the cursor to the first visible item matching f.
|
||||
func (l *List[T]) SelectWhere(f func(T) bool) bool {
|
||||
for i, idx := range l.visible {
|
||||
if f(l.items[idx]) {
|
||||
l.cursor = i
|
||||
l.scroll()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (l *List[T]) Move(delta int) {
|
||||
l.cursor = max(0, min(l.cursor+delta, len(l.visible)-1))
|
||||
l.scroll()
|
||||
}
|
||||
|
||||
func (l *List[T]) Top() { l.Move(-len(l.visible)) }
|
||||
func (l *List[T]) Bottom() { l.Move(len(l.visible)) }
|
||||
|
||||
// SetSize sets the outer size of the pane, including its border.
|
||||
func (l *List[T]) SetSize(w, h int) {
|
||||
l.w, l.h = w, h
|
||||
l.input.SetWidth(max(1, w-4))
|
||||
l.scroll()
|
||||
}
|
||||
|
||||
// rows is the number of item rows that fit in the pane.
|
||||
func (l List[T]) rows() int {
|
||||
r := l.h - 2
|
||||
if l.filtering {
|
||||
r--
|
||||
}
|
||||
r -= len(l.header())
|
||||
return max(0, r)
|
||||
}
|
||||
|
||||
// headerMinRows is how many item rows Header leaves at least.
|
||||
const headerMinRows = 5
|
||||
|
||||
// header is the lines above the items: Header and a blank line, if it
|
||||
// fits, leaves headerMinRows and the cursor is in the rows under it;
|
||||
// else HeaderShort.
|
||||
func (l List[T]) header() []string {
|
||||
short := []string{}
|
||||
if l.HeaderShort != "" {
|
||||
short = []string{l.HeaderShort}
|
||||
}
|
||||
if len(l.Header) == 0 || l.filtering || l.input.Value() != "" {
|
||||
return short
|
||||
}
|
||||
for _, line := range l.Header {
|
||||
if ansi.StringWidth(line) > l.w-3 {
|
||||
return short
|
||||
}
|
||||
}
|
||||
below := l.h - 2 - len(l.Header) - 1
|
||||
if below < headerMinRows || l.cursor >= below {
|
||||
return short
|
||||
}
|
||||
return append(l.Header[:len(l.Header):len(l.Header)], "")
|
||||
}
|
||||
|
||||
// Filtering reports whether the filter input has focus.
|
||||
func (l List[T]) Filtering() bool { return l.filtering }
|
||||
|
||||
// HasFilter reports whether a filter query is applied.
|
||||
func (l List[T]) HasFilter() bool { return l.input.Value() != "" }
|
||||
|
||||
func (l *List[T]) StartFilter() tea.Cmd {
|
||||
l.filtering = true
|
||||
l.scroll()
|
||||
return l.input.Focus()
|
||||
}
|
||||
|
||||
// AcceptFilter leaves filter mode, keeping the query applied.
|
||||
func (l *List[T]) AcceptFilter() {
|
||||
l.filtering = false
|
||||
l.input.Blur()
|
||||
}
|
||||
|
||||
// ClearFilter leaves filter mode and removes the query.
|
||||
func (l *List[T]) ClearFilter() {
|
||||
l.filtering = false
|
||||
l.input.Blur()
|
||||
prev := l.SelectedID()
|
||||
l.input.Reset()
|
||||
l.applyFilter(prev)
|
||||
}
|
||||
|
||||
// UpdateFilter forwards a message to the filter input and re-filters.
|
||||
func (l *List[T]) UpdateFilter(msg tea.Msg) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
prev := l.SelectedID()
|
||||
l.input, cmd = l.input.Update(msg)
|
||||
l.applyFilter(prev)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// applyFilter recomputes the visible items and puts the cursor back on the
|
||||
// item with ID keep if it is still visible.
|
||||
func (l *List[T]) applyFilter(keep string) {
|
||||
l.visible, l.hits = l.visible[:0], nil
|
||||
if q := l.input.Value(); q == "" {
|
||||
for i := range l.items {
|
||||
l.visible = append(l.visible, i)
|
||||
}
|
||||
} else {
|
||||
// The row's own text is the name; FilterExtra (a note, a provider)
|
||||
// is context, matched too but ranked lower.
|
||||
items := make([]match.Item, len(l.items))
|
||||
for i, v := range l.items {
|
||||
text := l.item.Filter(v)
|
||||
items[i] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)}
|
||||
if l.item.FilterExtra != nil {
|
||||
if extra := l.item.FilterExtra(v); extra != "" {
|
||||
items[i].Text += " " + extra
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, h := range match.Rank(match.Parse(q), items) {
|
||||
var hits []int // the ones in the text the row shows
|
||||
for _, p := range h.Pos {
|
||||
if p < items[h.Index].NameEnd {
|
||||
hits = append(hits, p)
|
||||
}
|
||||
}
|
||||
l.visible = append(l.visible, h.Index)
|
||||
l.hits = append(l.hits, hits)
|
||||
}
|
||||
}
|
||||
l.cursor = max(0, min(l.cursor, len(l.visible)-1))
|
||||
if keep != "" {
|
||||
l.Select(keep)
|
||||
}
|
||||
l.scroll()
|
||||
}
|
||||
|
||||
// MarkMatches styles the characters at hits (rune indexes in the text
|
||||
// shown, without styling) in a styled string, keeping its own colours.
|
||||
func MarkMatches(styled string, hits []int, style lipgloss.Style) string {
|
||||
if len(hits) == 0 {
|
||||
return styled
|
||||
}
|
||||
return lipgloss.StyleRanges(styled, matchRanges(ansi.Strip(styled), hits, style)...)
|
||||
}
|
||||
|
||||
// matchRanges turns matched rune indexes in plain into ranges of screen
|
||||
// cells, merging neighbours, for lipgloss.StyleRanges.
|
||||
func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Range {
|
||||
runes := []rune(plain)
|
||||
cell := func(i int) int { return ansi.StringWidth(string(runes[:min(i, len(runes))])) }
|
||||
var out []lipgloss.Range
|
||||
for i := 0; i < len(hits); {
|
||||
j := i
|
||||
for j+1 < len(hits) && hits[j+1] == hits[j]+1 {
|
||||
j++
|
||||
}
|
||||
if hits[i] < len(runes) {
|
||||
out = append(out, lipgloss.NewRange(cell(hits[i]), cell(hits[j]+1), style))
|
||||
}
|
||||
i = j + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RuneIndexes converts byte offsets in s to rune offsets.
|
||||
func RuneIndexes(s string, bytes []int) []int {
|
||||
out := make([]int, 0, len(bytes))
|
||||
for _, b := range bytes {
|
||||
out = append(out, utf8.RuneCountInString(s[:b]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// highlight returns the rune indexes to highlight in a row's plain text:
|
||||
// the filter hits, shifted to where the Filter text appears in the row.
|
||||
// It returns nil if the row doesn't contain the Filter text verbatim.
|
||||
func (l List[T]) highlight(i int, plain string) []int {
|
||||
if i >= len(l.hits) {
|
||||
return nil
|
||||
}
|
||||
off := strings.Index(plain, l.item.Filter(l.items[l.visible[i]]))
|
||||
if off < 0 {
|
||||
return nil
|
||||
}
|
||||
shift := utf8.RuneCountInString(plain[:off])
|
||||
out := make([]int, len(l.hits[i]))
|
||||
for j, h := range l.hits[i] {
|
||||
out[j] = h + shift
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// View renders the pane at the size given to SetSize. spin is the current
|
||||
// spinner frame.
|
||||
func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
|
||||
rows := l.rows()
|
||||
inner := max(0, l.w-2)
|
||||
|
||||
ts := TitleStyle(st, focused)
|
||||
title := ts.Render(l.Title)
|
||||
if len(l.Tabs) > 1 {
|
||||
for i, tab := range l.Tabs {
|
||||
sep := " - "
|
||||
if i == 0 {
|
||||
sep = " "
|
||||
}
|
||||
if i == l.Tab {
|
||||
title += ts.Render(sep) + ts.Underline(true).Render(tab)
|
||||
} else {
|
||||
title += ts.Render(sep) + st.Muted.Render(tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
if q := l.input.Value(); q != "" && !l.filtering {
|
||||
title += ts.Render(" /" + q)
|
||||
}
|
||||
if l.Loading && len(l.items) > 0 {
|
||||
title += ts.Render(" " + spin)
|
||||
}
|
||||
|
||||
var body []string
|
||||
if l.filtering {
|
||||
body = append(body, l.input.View())
|
||||
}
|
||||
for _, line := range l.header() {
|
||||
body = append(body, st.Notice.Render(" "+line))
|
||||
}
|
||||
|
||||
switch {
|
||||
case l.Loading && len(l.items) == 0:
|
||||
body = append(body, " "+spin+" loading…")
|
||||
case l.Err != nil:
|
||||
for _, line := range wrapLines(l.Err.Error(), inner-1) {
|
||||
body = append(body, st.Error.Render(" "+line))
|
||||
}
|
||||
case len(l.visible) == 0 && len(l.items) > 0:
|
||||
body = append(body, st.Muted.Render(" no matches"))
|
||||
case len(l.visible) == 0:
|
||||
body = append(body, st.Muted.Render(" "+l.Empty))
|
||||
default:
|
||||
for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ {
|
||||
line := l.item.Render(st, l.items[l.visible[i]], inner-1)
|
||||
plain := ansi.Strip(line)
|
||||
hits := l.highlight(i, plain)
|
||||
if i == l.cursor {
|
||||
base := st.SelectedBlurred
|
||||
if focused {
|
||||
base = st.Selected
|
||||
}
|
||||
text := " " + line
|
||||
if len(hits) > 0 {
|
||||
for j := range hits {
|
||||
hits[j]++ // leading space
|
||||
}
|
||||
text = lipgloss.StyleRanges(text, matchRanges(" "+plain, hits, st.Match)...)
|
||||
}
|
||||
line = Highlight(ansi.Truncate(text, inner, "…"), base, inner)
|
||||
} else if len(hits) > 0 {
|
||||
// Style just the matches, keeping the row's own colours.
|
||||
line = " " + lipgloss.StyleRanges(line, matchRanges(plain, hits, st.Match)...)
|
||||
} else {
|
||||
line = " " + line
|
||||
}
|
||||
body = append(body, line)
|
||||
}
|
||||
}
|
||||
|
||||
counter := ""
|
||||
if len(l.visible) > 0 {
|
||||
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
|
||||
}
|
||||
if l.Loading && l.Age != "" && len(l.items) > 0 {
|
||||
counter = "cached " + l.Age + " · " + counter
|
||||
}
|
||||
return BoxCounted(st, title, "", counter, body, l.w, l.h, focused)
|
||||
}
|
||||
|
||||
// scroll keeps the cursor within the visible window.
|
||||
func (l *List[T]) scroll() {
|
||||
rows := l.rows()
|
||||
if rows <= 0 {
|
||||
return
|
||||
}
|
||||
if l.cursor < l.offset {
|
||||
l.offset = l.cursor
|
||||
}
|
||||
if l.cursor >= l.offset+rows {
|
||||
l.offset = l.cursor - rows + 1
|
||||
}
|
||||
l.offset = max(0, min(l.offset, len(l.visible)-rows))
|
||||
}
|
||||
|
||||
// wrapLines wraps text to width, line by line; a "• " item's further
|
||||
// lines line up under its text.
|
||||
func wrapLines(text string, width int) []string {
|
||||
var out []string
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
indent := ""
|
||||
if strings.HasPrefix(line, "• ") {
|
||||
indent = " "
|
||||
}
|
||||
for i, l := range strings.Split(ansi.Wrap(line, max(1, width-len(indent)), " -/"), "\n") {
|
||||
if i > 0 {
|
||||
l = indent + l
|
||||
}
|
||||
out = append(out, l)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
func newStringList(n int) List[string] {
|
||||
l := NewList("Items", Item[string]{
|
||||
ID: func(s string) string { return s },
|
||||
Filter: func(s string) string { return s },
|
||||
Render: func(_ styles.Styles, s string, _ int) string { return s },
|
||||
})
|
||||
var items []string
|
||||
for i := range n {
|
||||
items = append(items, fmt.Sprintf("item-%02d", i))
|
||||
}
|
||||
l.SetItems(items)
|
||||
l.SetSize(20, 7) // 5 rows
|
||||
return l
|
||||
}
|
||||
|
||||
func TestListScrollKeepsCursorVisible(t *testing.T) {
|
||||
l := newStringList(20)
|
||||
l.Move(9)
|
||||
view := ansi.Strip(l.View(styles.Default(), true, ""))
|
||||
if !strings.Contains(view, "item-09") || strings.Contains(view, "item-04") {
|
||||
t.Fatalf("cursor row not scrolled into view:\n%s", view)
|
||||
}
|
||||
// Moving up within the window must not scroll.
|
||||
l.Move(-2)
|
||||
view = ansi.Strip(l.View(styles.Default(), true, ""))
|
||||
if !strings.Contains(view, "item-05") {
|
||||
t.Fatalf("window jumped when moving up inside it:\n%s", view)
|
||||
}
|
||||
l.Top()
|
||||
if l.SelectedID() != "item-00" || l.offset != 0 {
|
||||
t.Fatalf("Top: selected %q offset %d", l.SelectedID(), l.offset)
|
||||
}
|
||||
l.Bottom()
|
||||
if l.SelectedID() != "item-19" || l.offset != 15 {
|
||||
t.Fatalf("Bottom: selected %q offset %d", l.SelectedID(), l.offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSetItemsKeepsSelection(t *testing.T) {
|
||||
l := newStringList(5)
|
||||
l.Move(3)
|
||||
l.SetItems([]string{"new", "item-03", "item-04"})
|
||||
if l.SelectedID() != "item-03" {
|
||||
t.Fatalf("selected = %q", l.SelectedID())
|
||||
}
|
||||
l.SetItems([]string{"other"})
|
||||
if l.SelectedID() != "other" {
|
||||
t.Fatalf("selected = %q after item vanished", l.SelectedID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoxExactSize(t *testing.T) {
|
||||
out := Box(styles.Default(), "a very long title that will not fit", "footer", []string{"x", strings.Repeat("y", 50)}, 12, 5, true)
|
||||
lines := strings.Split(out, "\n")
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("height = %d", len(lines))
|
||||
}
|
||||
for _, line := range lines {
|
||||
if w := ansi.StringWidth(line); w != 12 {
|
||||
t.Errorf("width %d: %q", w, ansi.Strip(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func filterList(t *testing.T, items []string, query string) List[string] {
|
||||
t.Helper()
|
||||
l := NewList("Items", Item[string]{
|
||||
ID: func(s string) string { return s },
|
||||
Filter: func(s string) string { return s },
|
||||
Render: func(_ styles.Styles, s string, _ int) string { return s },
|
||||
})
|
||||
l.SetItems(items)
|
||||
l.SetSize(40, 10)
|
||||
l.StartFilter()
|
||||
for _, r := range query {
|
||||
l.UpdateFilter(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func visibleNames(l List[string]) []string {
|
||||
var out []string
|
||||
for _, i := range l.visible {
|
||||
out = append(out, l.items[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFuzzyFilterRanksAndSkips(t *testing.T) {
|
||||
l := filterList(t, []string{"api-key", "db-password", "storage-key", "dbx-pat"}, "dbpw")
|
||||
if got := visibleNames(l); !reflect.DeepEqual(got, []string{"db-password"}) {
|
||||
t.Errorf("dbpw matched %v", got)
|
||||
}
|
||||
l = filterList(t, []string{"monkey", "key-vault", "api-key"}, "key")
|
||||
if got := visibleNames(l); got[0] != "key-vault" || len(got) != 3 {
|
||||
t.Errorf("prefix match not ranked first: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterHighlightsMatches(t *testing.T) {
|
||||
l := filterList(t, []string{"db-password", "api-key"}, "dpw")
|
||||
l.AcceptFilter()
|
||||
view := l.View(styles.Default(), false, "")
|
||||
// d, p and w are styled; with ANSI stripped the text is unchanged.
|
||||
if !strings.Contains(ansi.Strip(view), "db-password") {
|
||||
t.Fatalf("row missing:\n%s", ansi.Strip(view))
|
||||
}
|
||||
if hits := l.highlight(0, "db-password"); !reflect.DeepEqual(hits, []int{0, 3, 7}) {
|
||||
t.Errorf("hits = %v", hits)
|
||||
}
|
||||
// Rows that show the Filter text at an offset get shifted hits.
|
||||
if hits := l.highlight(0, " db-password (disabled)"); !reflect.DeepEqual(hits, []int{2, 5, 9}) {
|
||||
t.Errorf("shifted hits = %v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterKeepsRowColours(t *testing.T) {
|
||||
blue := lipgloss.NewStyle().Foreground(lipgloss.Blue)
|
||||
match := styles.Default().Match
|
||||
|
||||
if got := matchRanges("platform-dev", []int{9, 10, 11}, match); len(got) != 1 || got[0].Start != 9 || got[0].End != 12 {
|
||||
t.Errorf("ranges = %+v, want one range 9-12", got)
|
||||
}
|
||||
|
||||
// Only the matches change style; the rest keeps the row's colour.
|
||||
styled := " " + blue.Render("platform-dev")
|
||||
out := lipgloss.StyleRanges(styled, matchRanges(" platform-dev", []int{11, 12, 13}, match)...)
|
||||
if ansi.Strip(out) != " platform-dev" {
|
||||
t.Fatalf("text changed: %q", ansi.Strip(out))
|
||||
}
|
||||
if !strings.Contains(out, "\x1b[34m") {
|
||||
t.Errorf("unmatched text lost its colour: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCounterInBottomRight(t *testing.T) {
|
||||
out := BoxCounted(styles.Default(), "T", "footer", "3 of 12", []string{"x"}, 30, 3, true)
|
||||
bottom := ansi.Strip(strings.Split(out, "\n")[2])
|
||||
if !strings.HasSuffix(bottom, " 3 of 12 ─╯") || !strings.HasPrefix(bottom, "╰─ footer ") || ansi.StringWidth(bottom) != 30 {
|
||||
t.Errorf("bottom edge = %q", bottom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightKeepsColours(t *testing.T) {
|
||||
blue := lipgloss.NewStyle().Foreground(lipgloss.Blue)
|
||||
sel := lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true)
|
||||
row := " " + blue.Render("platform-prod") + lipgloss.NewStyle().Faint(true).Render(" note")
|
||||
out := Highlight(row, sel, 30)
|
||||
if ansi.StringWidth(out) != 30 || ansi.Strip(out) != ansi.Strip(row)+strings.Repeat(" ", 30-ansi.StringWidth(row)) {
|
||||
t.Fatalf("text or width changed: %q", ansi.Strip(out))
|
||||
}
|
||||
if !strings.Contains(out, "\x1b[34m") {
|
||||
t.Error("row lost its blue")
|
||||
}
|
||||
// The background is re-applied after every reset, so it spans the row.
|
||||
resets := strings.Count(out, "\x1b[m") + strings.Count(out, "\x1b[0m")
|
||||
if bgs := strings.Count(out, "\x1b[100;1m"); bgs < resets {
|
||||
t.Errorf("background not re-applied after each reset: %d resets, %d backgrounds\n%q", resets, bgs, out)
|
||||
}
|
||||
// Reverse styles fall back to plain text.
|
||||
if got := Highlight(row, lipgloss.NewStyle().Reverse(true), 30); strings.Contains(got, "34m") {
|
||||
t.Error("reverse kept the colours")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListHeader(t *testing.T) {
|
||||
l := NewList("t", Item[string]{
|
||||
ID: func(s string) string { return s },
|
||||
Filter: func(s string) string { return s },
|
||||
Render: func(_ styles.Styles, s string, _ int) string { return s },
|
||||
})
|
||||
l.Header, l.HeaderShort = []string{"ART1", "ART2"}, "short"
|
||||
l.SetItems([]string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j"})
|
||||
l.SetSize(30, 2+2+1+6) // the art, a blank line and 6 rows
|
||||
st := styles.Default()
|
||||
if v := l.View(st, true, ""); !strings.Contains(v, "ART1") || strings.Contains(v, "short") {
|
||||
t.Errorf("at the top, the art:\n%s", v)
|
||||
}
|
||||
l.Move(6) // below the rows under the art
|
||||
if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") || !strings.Contains(v, " g") {
|
||||
t.Errorf("further down, the text:\n%s", v)
|
||||
}
|
||||
l.Top()
|
||||
l.SetSize(30, 8) // too short for the art and 5 rows
|
||||
if v := l.View(st, true, ""); strings.Contains(v, "ART1") || !strings.Contains(v, "short") {
|
||||
t.Errorf("no room, the text:\n%s", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A revealed value is shown as it reads best: JSON indented, and PEM
|
||||
// certificates as what they say (subject, issuer, dates, names) rather
|
||||
// than base64, with any other PEM block, a private key say, named but not
|
||||
// shown. Anything else is shown as it is. y copies it as stored.
|
||||
|
||||
// valueView is how to show v, and what's been done to it ("" when it's
|
||||
// shown as it is).
|
||||
func valueView(v string) (lines []string, note string) {
|
||||
if lines, ok := pemView(v); ok {
|
||||
return lines, "certificate"
|
||||
}
|
||||
if t := strings.TrimSpace(v); (strings.HasPrefix(t, "{") || strings.HasPrefix(t, "[")) && json.Valid([]byte(t)) {
|
||||
var b bytes.Buffer
|
||||
if json.Indent(&b, []byte(t), "", " ") == nil {
|
||||
return strings.Split(b.String(), "\n"), "JSON, formatted"
|
||||
}
|
||||
}
|
||||
return strings.Split(v, "\n"), ""
|
||||
}
|
||||
|
||||
// pemView describes the PEM blocks in v, if it's PEM with a certificate.
|
||||
func pemView(v string) ([]string, bool) {
|
||||
rest := []byte(strings.TrimSpace(v))
|
||||
var out []string
|
||||
certs := 0
|
||||
for len(rest) > 0 {
|
||||
block, after := pem.Decode(rest)
|
||||
if block == nil {
|
||||
return nil, false // text that isn't PEM: show it all
|
||||
}
|
||||
rest = bytes.TrimSpace(after)
|
||||
if len(out) > 0 {
|
||||
out = append(out, "")
|
||||
}
|
||||
if block.Type != "CERTIFICATE" {
|
||||
out = append(out, block.Type+" (not shown)")
|
||||
continue
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
certs++
|
||||
out = append(out, certLines(cert)...)
|
||||
}
|
||||
return out, certs > 0
|
||||
}
|
||||
|
||||
func certLines(c *x509.Certificate) []string {
|
||||
day := func(t time.Time) string { return t.UTC().Format("2006-01-02") }
|
||||
out := []string{
|
||||
"subject " + c.Subject.String(),
|
||||
"issuer " + c.Issuer.String(),
|
||||
"valid " + day(c.NotBefore) + " to " + day(c.NotAfter),
|
||||
}
|
||||
names := append([]string{}, c.DNSNames...)
|
||||
for _, ip := range c.IPAddresses {
|
||||
names = append(names, ip.String())
|
||||
}
|
||||
names = append(names, c.EmailAddresses...)
|
||||
if len(names) > 0 {
|
||||
out = append(out, "names "+strings.Join(names, ", "))
|
||||
}
|
||||
sum := sha1.Sum(c.Raw) // what Azure and Windows call the thumbprint
|
||||
return append(out, "SHA-1 "+fmt.Sprintf("%X", sum[:]))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"math/big"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestValueViewJSON(t *testing.T) {
|
||||
lines, note := valueView(`{"user":"app","port":5432,"tags":["a"]}`)
|
||||
want := []string{"{", ` "user": "app",`, ` "port": 5432,`, ` "tags": [`, ` "a"`, " ]", "}"}
|
||||
if !slices.Equal(lines, want) || note != "JSON, formatted" {
|
||||
t.Errorf("lines %q, note %q", lines, note)
|
||||
}
|
||||
for _, v := range []string{"plain value", "{not json", "42", "line one\nline two"} {
|
||||
if lines, note := valueView(v); note != "" || strings.Join(lines, "\n") != v {
|
||||
t.Errorf("%q shown as %q (%q)", v, lines, note)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestValueViewCertificate(t *testing.T) {
|
||||
key, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
tmpl := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "app.example.com"},
|
||||
DNSNames: []string{"app.example.com", "www.app.example.com"},
|
||||
NotBefore: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), NotAfter: time.Date(2027, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keyDER, _ := x509.MarshalECPrivateKey(key)
|
||||
bundle := string(pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})) +
|
||||
string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))
|
||||
|
||||
lines, note := valueView(bundle)
|
||||
text := strings.Join(lines, "\n")
|
||||
for _, want := range []string{"subject CN=app.example.com", "valid 2026-01-01 to 2027-01-01",
|
||||
"names app.example.com, www.app.example.com", "SHA-1 ", "EC PRIVATE KEY (not shown)"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Errorf("missing %q:\n%s", want, text)
|
||||
}
|
||||
}
|
||||
if note != "certificate" || strings.Contains(text, "BEGIN") {
|
||||
t.Errorf("note %q, or PEM shown:\n%s", note, text)
|
||||
}
|
||||
// A private key alone isn't a certificate: shown as it is.
|
||||
if _, note := valueView(string(pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}))); note != "" {
|
||||
t.Errorf("a key alone: note %q", note)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user