- 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.
151 lines
4.2 KiB
Go
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
|
|
}
|