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.
This commit is contained in:
2026-08-14 11:09:03 +02:00
commit ae30ba1240
85 changed files with 9413 additions and 0 deletions
+192
View File
@@ -0,0 +1,192 @@
package ui
import (
"fmt"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
"charm.land/lipgloss/v2"
)
// screenHelp is the shape every keys.KeyMap.XxxHelp() screen-help value
// satisfies; declared here (rather than importing the concrete type, which
// is unexported in package keys) so viewFooter can take any of them.
type screenHelp interface {
ShortHelp() []key.Binding
FullHelp() [][]key.Binding
}
var _ help.KeyMap = screenHelp(nil) // screenHelp is exactly help.KeyMap
// breadcrumbSegments returns one path element per frame on the stack, plus
// the leading profile name — the pieces viewHeader joins with symChevronR
// and, on a narrow terminal, elides from the middle.
func (m *Model) breadcrumbSegments() []string {
segs := []string{m.a.Settings.Profile}
for _, f := range m.stack {
switch f.scr {
case scrMounts:
segs = append(segs, "mounts")
case scrBrowser, scrSecret:
segs = append(segs, f.mount.Path+f.path)
case scrEditor:
segs = append(segs, "edit")
case scrVersions:
segs = append(segs, "versions")
}
}
return segs
}
// renderBreadcrumb joins breadcrumbSegments with " ▸ ", eliding from the
// middle (keeping the profile name and the current location legible) when
// the full trail doesn't fit in avail cells.
func (m *Model) renderBreadcrumb(avail int) string {
segs := m.breadcrumbSegments()
sep := m.styles.CrumbSep.Render(" " + symChevronR + " ")
join := func(ss []string) string {
styled := make([]string, len(ss))
last := len(ss) - 1
for i, s := range ss {
if i == last {
styled[i] = m.styles.CrumbActive.Render(s)
} else {
styled[i] = m.styles.Crumb.Render(s)
}
}
out := styled[0]
for _, s := range styled[1:] {
out += sep + s
}
return out
}
full := join(segs)
if lipgloss.Width(full) <= avail || len(segs) <= 2 {
if lipgloss.Width(full) <= avail {
return full
}
return m.styles.CrumbActive.Render(truncate(segs[len(segs)-1], avail))
}
elided := append([]string{segs[0], symEllipsis}, segs[len(segs)-1])
out := join(elided)
if lipgloss.Width(out) <= avail {
return out
}
return m.styles.CrumbActive.Render(truncate(segs[len(segs)-1], avail))
}
// ttlPill renders the token-TTL pill, colored by how close it is to
// Settings.TTLWarnBelow.
func (m *Model) ttlPill() string {
if m.tokenInfo == nil || m.tokenInfo.ExpireTime == nil {
return ""
}
ttl := m.tokenInfo.TTL
text := "ttl " + ttlString(ttl)
warnAt := m.a.Settings.TTLWarnBelow
switch {
case ttl <= 0:
return m.styles.PillDanger.Render(text)
case warnAt > 0 && ttl <= warnAt/4:
return m.styles.PillDanger.Render(text)
case warnAt > 0 && ttl <= warnAt:
return m.styles.PillWarn.Render(text)
default:
return m.styles.Pill.Render(text)
}
}
// viewHeader renders the single-line top bar: app badge + breadcrumb on
// the left, status pills on the right. Collapses to a bare breadcrumb on
// sizeTiny, where there's no room for a badge.
func (m *Model) viewHeader() string {
var right []string
if m.a.Settings.ReadOnly {
right = append(right, m.styles.PillWarn.Render("READ-ONLY"))
}
if m.a.Settings.Namespace != "" && m.lay.class != sizeTiny {
right = append(right, m.styles.Pill.Render("ns:"+m.a.Settings.Namespace))
}
if p := m.ttlPill(); p != "" {
right = append(right, p)
}
if m.loading {
right = append(right, m.spin.View())
}
rightStr := lipgloss.JoinHorizontal(lipgloss.Center, right...)
rightW := lipgloss.Width(rightStr)
if rightStr != "" {
rightStr = " " + rightStr
rightW++
}
badge := ""
if m.lay.class != sizeTiny {
badge = m.styles.AppBadge.Render("▮ VAULT") + " "
}
badgeW := lipgloss.Width(badge)
// HeaderBar carries its own Padding(0,1) — a style's declared Width sets
// the pre-padding content width, so the bar renders at contentW+2. Budget
// for that here rather than sizing to the full bar width, or the result
// overflows the terminal by 2 cells and wraps.
barW := m.lay.w - 2
if barW < 1 {
barW = 1
}
avail := barW - badgeW - rightW
if avail < 4 {
avail = 4
}
crumb := m.renderBreadcrumb(avail)
crumbW := lipgloss.Width(crumb)
pad := barW - badgeW - crumbW - rightW
if pad < 0 {
pad = 0
}
line := badge + crumb + lipgloss.NewStyle().Width(pad).Render("") + rightStr
return m.styles.HeaderBar.Render(fit(line, barW, 1))
}
// viewFooter renders the footer: a screen-specific key hint line plus a
// status line (profile/address, or the current toast) — one line total on
// sizeTiny, two otherwise.
func (m *Model) viewFooter(sh screenHelp) string {
status := fmt.Sprintf("%s @ %s", m.a.Settings.Profile, m.a.Settings.Address)
if toastStr := m.toastView(); toastStr != "" {
status = toastStr
} else {
status = m.styles.StatusBar.Render(status)
}
// Footer carries Padding(0,1) too — same barW budgeting as viewHeader.
barW := m.lay.w - 2
if barW < 1 {
barW = 1
}
m.help.SetWidth(barW)
if m.lay.class == sizeTiny {
line := status
if m.toast == nil {
line = m.styles.Footer.Render(m.help.ShortHelpView(sh.ShortHelp()))
}
return m.styles.Footer.Render(fit(line, barW, 1))
}
helpLine := m.styles.Footer.Render(fit(m.help.ShortHelpView(sh.ShortHelp()), barW, 1))
statusLine := m.styles.Footer.Render(fit(status, barW, 1))
return lipgloss.JoinVertical(lipgloss.Left, helpLine, statusLine)
}
// viewTooSmall replaces the entire UI below layout.tooSmall's threshold.
func (m *Model) viewTooSmall() string {
msg := fmt.Sprintf("terminal too small\n%dx%d — need at least %dx%d", m.lay.w, m.lay.h, minWidth, minHeight)
box := m.styles.Modal.Render(msg)
return lipgloss.Place(m.lay.w, m.lay.h, lipgloss.Center, lipgloss.Center, box,
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(m.styles.Bg)))
}