- Added internal/vault/client.go for creating a Vault client with configuration settings. - Introduced internal/vault/errors.go to classify Vault API errors for better UI handling. - Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations. - Implemented internal/vault/mounts.go to list and describe secret engine mounts. - Developed internal/vault/service.go to provide a unified entry point for Vault operations. - Added internal/vault/kv_test.go for comprehensive testing of KV operations. - Introduced internal/ui/toast.go for transient notifications in the UI. - Added renovate.json for dependency management and updates.
167 lines
3.8 KiB
Go
167 lines
3.8 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"git.morlana.online/f.weber/vault-tui/internal/vault"
|
|
)
|
|
|
|
// Symbols used throughout the TUI. Deliberately plain, single-width
|
|
// Unicode box-drawing/geometric shapes — no Nerd Font glyphs, no emoji —
|
|
// so rendering never depends on a font the user's terminal might not have.
|
|
const (
|
|
symMount = "▣"
|
|
symDir = "▸"
|
|
symSecret = "▪"
|
|
symUnsupported = "▨"
|
|
symCursor = "┃"
|
|
symCurrent = "●"
|
|
symDeleted = "◐"
|
|
symDestroyed = "✕"
|
|
symOK = "✓"
|
|
symWarn = "⚠"
|
|
symErr = "✕"
|
|
symLocked = "●"
|
|
symUnlocked = "○"
|
|
symChevronR = "▸"
|
|
symEllipsis = "…"
|
|
)
|
|
|
|
// truncate clips s to at most w cells wide, replacing the tail with an
|
|
// ellipsis when it doesn't fit — unlike fit/MaxWidth, which hard-crop
|
|
// without any indication that content was cut off.
|
|
func truncate(s string, w int) string {
|
|
if w <= 0 {
|
|
return ""
|
|
}
|
|
if lipgloss.Width(s) <= w {
|
|
return s
|
|
}
|
|
if w == 1 {
|
|
return symEllipsis
|
|
}
|
|
runes := []rune(s)
|
|
// Binary-search-free linear trim: widths are small (labels/paths), and
|
|
// wide runes are rare enough that a byte-at-a-time trim is plenty fast.
|
|
for i := len(runes); i > 0; i-- {
|
|
cand := string(runes[:i]) + symEllipsis
|
|
if lipgloss.Width(cand) <= w {
|
|
return cand
|
|
}
|
|
}
|
|
return symEllipsis
|
|
}
|
|
|
|
// padRight pads s with spaces to exactly w cells, or truncates it if it's
|
|
// already wider — for building fixed-width table cells inline (as opposed
|
|
// to fit, which pads/crops a whole multi-line block).
|
|
func padRight(s string, w int) string {
|
|
if w <= 0 {
|
|
return ""
|
|
}
|
|
lw := lipgloss.Width(s)
|
|
if lw > w {
|
|
return truncate(s, w)
|
|
}
|
|
return s + strings.Repeat(" ", w-lw)
|
|
}
|
|
|
|
// padLeft right-aligns s within w cells.
|
|
func padLeft(s string, w int) string {
|
|
if w <= 0 {
|
|
return ""
|
|
}
|
|
lw := lipgloss.Width(s)
|
|
if lw >= w {
|
|
return truncate(s, w)
|
|
}
|
|
return strings.Repeat(" ", w-lw) + s
|
|
}
|
|
|
|
// humanTime renders an RFC3339 timestamp (as returned by Vault's KV
|
|
// metadata) as a short relative time, falling back to the raw string if it
|
|
// doesn't parse.
|
|
func humanTime(rfc3339 string) string {
|
|
if rfc3339 == "" {
|
|
return ""
|
|
}
|
|
t, err := time.Parse(time.RFC3339, rfc3339)
|
|
if err != nil {
|
|
return rfc3339
|
|
}
|
|
d := time.Since(t)
|
|
switch {
|
|
case d < 0:
|
|
return t.Format("2006-01-02 15:04")
|
|
case d < time.Minute:
|
|
return "just now"
|
|
case d < time.Hour:
|
|
return fmt.Sprintf("%dm ago", int(d/time.Minute))
|
|
case d < 24*time.Hour:
|
|
return fmt.Sprintf("%dh ago", int(d/time.Hour))
|
|
case d < 30*24*time.Hour:
|
|
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
|
|
default:
|
|
return t.Format("2006-01-02")
|
|
}
|
|
}
|
|
|
|
// ttlString renders a duration the way a status pill wants it: compact,
|
|
// never more than two units ("42m", "1h05m", "3d02h"), never negative.
|
|
func ttlString(d time.Duration) string {
|
|
if d <= 0 {
|
|
return "expired"
|
|
}
|
|
d = d.Round(time.Second)
|
|
days := d / (24 * time.Hour)
|
|
d -= days * 24 * time.Hour
|
|
hours := d / time.Hour
|
|
d -= hours * time.Hour
|
|
mins := d / time.Minute
|
|
d -= mins * time.Minute
|
|
secs := d / time.Second
|
|
|
|
switch {
|
|
case days > 0:
|
|
return fmt.Sprintf("%dd%02dh", days, hours)
|
|
case hours > 0:
|
|
return fmt.Sprintf("%dh%02dm", hours, mins)
|
|
case mins > 0:
|
|
return fmt.Sprintf("%dm%02ds", mins, secs)
|
|
default:
|
|
return fmt.Sprintf("%ds", secs)
|
|
}
|
|
}
|
|
|
|
// engineLabel is the short badge text for a mount's engine kind.
|
|
func engineLabel(m vault.Mount) string {
|
|
switch m.Kind {
|
|
case vault.EngineKVv2:
|
|
return "kv v2"
|
|
case vault.EngineKVv1:
|
|
return "kv v1"
|
|
default:
|
|
return m.Type
|
|
}
|
|
}
|
|
|
|
// maskValue replaces v with repeated mask characters, capped at a fixed
|
|
// display width so long secrets don't stretch the layout.
|
|
func maskValue(v, char string) string {
|
|
if char == "" {
|
|
char = "•"
|
|
}
|
|
n := lipgloss.Width(v)
|
|
if n > 12 {
|
|
n = 12
|
|
}
|
|
if n == 0 {
|
|
n = 1
|
|
}
|
|
return strings.Repeat(char, n)
|
|
}
|