Hello world 👋
Release / Build and publish (push) Successful in 22s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-10 23:58:25 +02:00
commit 6fab41703e
111 files changed
+22988

No files matched your search

+71
View File
@@ -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
}
+374
View File
@@ -0,0 +1,374 @@
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 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, 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.
keyHint := func(k, what string) string {
if k == "" {
return ""
}
return "(" + k + " to " + what + ")"
}
label, hidden := "value", st.Masked.Render("••••••••")+muted(" "+keyHint(d.RevealKey, "reveal"))
switch s.Type {
case provider.TypeKey:
label, hidden = "public", muted(keyHint(d.RevealKey, "show public key"))
case provider.TypeCertificate:
label, hidden = "cert", muted(keyHint(d.RevealKey, "show certificate"))
}
out = append(out, "")
switch d.state {
case ValueHidden:
field(label, hidden)
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:
var hints []string
if d.RevealKey != "" {
hints = append(hints, d.RevealKey+" to hide")
}
if d.hidesIn != "" {
hints = append(hints, "hides in "+d.hidesIn)
}
line := " " + st.Label.Render(label)
if len(hints) > 0 {
line += muted(" (" + strings.Join(hints, " · ") + ")")
}
out = append(out, line)
for _, line := range strings.Split(d.value.Reveal(), "\n") {
for _, w := range strings.Split(ansi.Hardwrap(line, inner, true), "\n") {
out = append(out, " "+st.Value.Render(w))
}
}
}
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
}
marker := " "
if d.secret.Version != "" && v.Version == d.secret.Version {
marker = "▸ " // the version shown above
}
out = append(out, " "+marker+VersionLine(st, v, d.Now(), current))
}
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") }
+53
View File
@@ -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
}
+388
View File
@@ -0,0 +1,388 @@
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
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
}
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--
}
return max(0, r)
}
// 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())
}
switch {
case l.Loading && len(l.items) == 0:
body = append(body, " "+spin+" loading…")
case l.Err != nil:
for _, line := range strings.Split(ansi.Wrap(l.Err.Error(), inner-1, " -/"), "\n") {
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))
}
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))
}
+179
View File
@@ -0,0 +1,179 @@
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")
}
}