Files
vault-tui/internal/ui/format.go
T
f.weber ae30ba1240 feat(vault-tui): implement KV client and service for managing secrets
- 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.
2026-08-14 11:09:03 +02:00

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)
}