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