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