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:
@@ -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)))
|
||||
}
|
||||
Reference in New Issue
Block a user