Files
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

151 lines
4.2 KiB
Go

package ui
import (
"fmt"
"io"
"charm.land/bubbles/v2/list"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"git.morlana.online/f.weber/vault-tui/internal/ui/theme"
"git.morlana.online/f.weber/vault-tui/internal/vault"
)
// entryItem implements list.Item for every row shown in the mounts and
// browser screens: a secret mount, a "directory" (a KV path prefix), or a
// leaf secret.
type entryItem struct {
label string // display text, e.g. "secret/" or "team/prod/"
desc string
mount vault.Mount // set for mount rows and (copied through) for dir/leaf rows
relPath string // path relative to mount, "" at mount root
isMount bool
isDir bool
unsupported bool
}
func (e entryItem) FilterValue() string { return e.label }
func mountItems(mounts []vault.Mount) []entryItem {
items := make([]entryItem, 0, len(mounts))
for _, mnt := range mounts {
desc := mnt.Type
if mnt.Description != "" {
desc = fmt.Sprintf("%s — %s", mnt.Type, mnt.Description)
}
items = append(items, entryItem{
label: mnt.Path, desc: desc, mount: mnt, isMount: true, unsupported: !mnt.Supported(),
})
}
return items
}
func listingItems(mount vault.Mount, listing vault.Listing) []entryItem {
items := make([]entryItem, 0, len(listing.Dirs)+len(listing.Leaves))
for _, d := range listing.Dirs {
items = append(items, entryItem{label: d, desc: "directory", mount: mount, relPath: listing.Path + d, isDir: true})
}
for _, l := range listing.Leaves {
items = append(items, entryItem{label: l, desc: "secret", mount: mount, relPath: listing.Path + l})
}
return items
}
// entryDelegate is a from-scratch list.ItemDelegate: a cursor bar, a type
// symbol, the label (with filter matches highlighted), and a right-aligned
// badge — replacing bubbles/list's DefaultDelegate, which has no notion of
// our entry types or theme.
type entryDelegate struct {
styles *theme.Styles
}
func (d entryDelegate) Height() int { return 1 }
func (d entryDelegate) Spacing() int { return 0 }
func (d entryDelegate) Update(tea.Msg, *list.Model) tea.Cmd { return nil }
func (d entryDelegate) Render(w io.Writer, lm list.Model, index int, item list.Item) {
e, ok := item.(entryItem)
if !ok {
return
}
width := lm.Width()
if width <= 0 {
return
}
selected := index == lm.Index() && lm.FilterState() != list.Filtering
sym, badge := symSecret, "secret"
switch {
case e.isMount:
sym, badge = symMount, engineLabel(e.mount)
case e.isDir:
sym, badge = symDir, "dir"
case e.unsupported:
sym, badge = symUnsupported, e.mount.Type+" (unsupported)"
}
cursor := " "
if selected {
cursor = d.styles.Title.Render(symCursor) + " "
}
cursorW := lipgloss.Width(cursor)
badgeStr := d.styles.Badge.Render(badge)
if selected {
badgeStr = d.styles.RowSelected.Render(" " + badge + " ")
}
badgeW := lipgloss.Width(badgeStr)
labelW := width - cursorW - badgeW - 2
if labelW < 4 {
labelW = 4
}
base := d.rowStyle(e, selected)
label := truncate(sym+" "+e.label, labelW)
highlighted := false
if lm.FilterState() == list.Filtering || lm.FilterState() == list.FilterApplied {
if matches := lm.MatchesForItem(index); len(matches) > 0 {
unmatched := base.Inline(true)
matched := unmatched.Underline(true)
label = lipgloss.StyleRunes(label, shiftMatches(matches, 2), matched, unmatched)
highlighted = true
}
}
if !highlighted {
label = base.Render(label)
}
pad := width - cursorW - lipgloss.Width(label) - badgeW
if pad < 1 {
pad = 1
}
fmt.Fprint(w, cursor+label+lipgloss.NewStyle().Width(pad-1).Render("")+" "+badgeStr)
}
// rowStyle picks the base row style for an entry, accounting for
// selection and the dimmed "unsupported engine" case.
func (d *entryDelegate) rowStyle(e entryItem, selected bool) lipgloss.Style {
switch {
case selected:
return d.styles.RowSelected
case e.unsupported:
return d.styles.RowDim
case e.isDir:
return d.styles.DirText
default:
return d.styles.Row
}
}
// shiftMatches offsets filter-match rune indices by the width of the
// symbol+space prefix Render prepends to the label before highlighting.
func shiftMatches(matches []int, offset int) []int {
out := make([]int, len(matches))
for i, m := range matches {
out[i] = m + offset
}
return out
}