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,280 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/list"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/vault"
|
||||
)
|
||||
|
||||
func (m *Model) loadMounts() tea.Cmd {
|
||||
m.loading = true
|
||||
return func() tea.Msg {
|
||||
mounts, err := m.svc.Mounts(m.ctx)
|
||||
sort.Slice(mounts, func(i, j int) bool { return mounts[i].Path < mounts[j].Path })
|
||||
return mountsMsg{mounts: mounts, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) loadListing(mount vault.Mount, path string) tea.Cmd {
|
||||
m.loading = true
|
||||
return func() tea.Msg {
|
||||
l, err := m.svc.KV.List(m.ctx, mount, path)
|
||||
l.Path = path
|
||||
return listMsg{listing: l, mount: mount, path: path, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// listPaneOuter is the outer (border-inclusive) size of the panel the list
|
||||
// renders in: the full body on sizeTiny/sizeCompact, or the left column
|
||||
// once a side detail panel appears at sizeWide.
|
||||
func (m *Model) listPaneOuter() (w, h int) {
|
||||
if m.lay.class == sizeWide {
|
||||
return m.lay.mainW, m.lay.bodyH
|
||||
}
|
||||
return m.lay.bodyW, m.lay.bodyH
|
||||
}
|
||||
|
||||
// resizeList re-applies the current layout's dimensions to the list — the
|
||||
// single place list.SetSize is called, so every WindowSizeMsg and every
|
||||
// list (re)build stays in sync with the same math.
|
||||
func (m *Model) resizeList() {
|
||||
if !m.listReady {
|
||||
return
|
||||
}
|
||||
ow, oh := m.listPaneOuter()
|
||||
iw, ih := panelInner(ow, oh)
|
||||
ih-- // panel title line
|
||||
if ih < 3 {
|
||||
ih = 3
|
||||
}
|
||||
if iw < 10 {
|
||||
iw = 10
|
||||
}
|
||||
m.list.SetSize(iw, ih)
|
||||
}
|
||||
|
||||
// setListItems fills the list with items, building it on first use and
|
||||
// otherwise updating in place — SetItems (not a fresh list.New) is what
|
||||
// lets a refresh survive with the active filter and, via listSelect,
|
||||
// the selected row intact.
|
||||
func (m *Model) setListItems(items []entryItem) tea.Cmd {
|
||||
litems := make([]list.Item, len(items))
|
||||
for i, it := range items {
|
||||
litems[i] = it
|
||||
}
|
||||
|
||||
if !m.listReady {
|
||||
ow, oh := m.listPaneOuter()
|
||||
iw, ih := panelInner(ow, oh)
|
||||
ih--
|
||||
if ih < 3 {
|
||||
ih = 3
|
||||
}
|
||||
if iw < 10 {
|
||||
iw = 10
|
||||
}
|
||||
m.list = list.New(litems, entryDelegate{styles: m.styles}, iw, ih)
|
||||
m.list.SetShowStatusBar(false)
|
||||
m.list.SetShowTitle(false)
|
||||
m.list.SetShowHelp(false)
|
||||
m.list.DisableQuitKeybindings()
|
||||
m.list.Styles = m.styles.ListStyles()
|
||||
m.listReady = true
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := m.list.SetItems(litems)
|
||||
restored := false
|
||||
if m.listSelect != "" {
|
||||
for i, it := range items {
|
||||
if it.label == m.listSelect {
|
||||
m.list.Select(i)
|
||||
restored = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !restored {
|
||||
// SetItems doesn't touch the cursor, so switching to a shorter list
|
||||
// (e.g. drilling into a directory) can otherwise leave it pointing
|
||||
// past the end, and SelectedItem starts returning nil.
|
||||
m.list.Select(0)
|
||||
}
|
||||
m.resizeList()
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (m *Model) updateBrowser(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case mountsMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return errMsg{err: msg.err} }
|
||||
}
|
||||
cmd := m.setListItems(mountItems(msg.mounts))
|
||||
return m, cmd
|
||||
|
||||
case listMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return errMsg{err: msg.err} }
|
||||
}
|
||||
cmd := m.setListItems(listingItems(msg.mount, msg.listing))
|
||||
return m, cmd
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
// While the list's own filter editor has focus, every keystroke is
|
||||
// text input for the filter — none of our single-letter global
|
||||
// bindings (h for Back, l for Enter, ...) may intercept it, or
|
||||
// typing e.g. "testpath" would pop the screen on its embedded "h".
|
||||
if !m.listReady || m.list.SettingFilter() {
|
||||
break
|
||||
}
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Back):
|
||||
return m.popBrowser()
|
||||
case key.Matches(msg, m.keys.Refresh):
|
||||
return m, m.reloadTop()
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
return m.openSelected()
|
||||
case key.Matches(msg, m.keys.New):
|
||||
if !m.a.Settings.ReadOnly && m.top() == scrBrowser {
|
||||
return m.newSecret()
|
||||
}
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
}
|
||||
if !m.listReady {
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.list, cmd = m.list.Update(msg)
|
||||
if it, ok := m.list.SelectedItem().(entryItem); ok {
|
||||
m.listSelect = it.label
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) reloadTop() tea.Cmd {
|
||||
f := m.currentFrame()
|
||||
switch f.scr {
|
||||
case scrMounts:
|
||||
return m.loadMounts()
|
||||
case scrBrowser:
|
||||
return m.loadListing(f.mount, f.path)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) popBrowser() (tea.Model, tea.Cmd) {
|
||||
if len(m.stack) <= 1 {
|
||||
return m, nil
|
||||
}
|
||||
m.pop()
|
||||
m.listSelect = ""
|
||||
return m, m.reloadTop()
|
||||
}
|
||||
|
||||
func (m *Model) openSelected() (tea.Model, tea.Cmd) {
|
||||
sel, ok := m.list.SelectedItem().(entryItem)
|
||||
if !ok {
|
||||
return m, nil
|
||||
}
|
||||
if sel.unsupported {
|
||||
return m, m.notify(toastWarn, "%s (%s) is not a supported engine", sel.label, sel.mount.Type)
|
||||
}
|
||||
m.listSelect = ""
|
||||
if sel.isMount {
|
||||
m.push(frame{scr: scrBrowser, mount: sel.mount, path: ""})
|
||||
return m, m.loadListing(sel.mount, "")
|
||||
}
|
||||
if sel.isDir {
|
||||
m.push(frame{scr: scrBrowser, mount: sel.mount, path: sel.relPath})
|
||||
return m, m.loadListing(sel.mount, sel.relPath)
|
||||
}
|
||||
m.push(frame{scr: scrSecret, mount: sel.mount, path: sel.relPath})
|
||||
return m, m.loadSecret(sel.mount, sel.relPath, 0)
|
||||
}
|
||||
|
||||
// detailPreview is the sizeWide side panel's content: whatever we already
|
||||
// know about the highlighted row, without issuing another Vault read.
|
||||
func (m *Model) detailPreview() string {
|
||||
sel, ok := m.list.SelectedItem().(entryItem)
|
||||
if !ok {
|
||||
return m.styles.EmptyState.Render("nothing selected")
|
||||
}
|
||||
var lines []string
|
||||
switch {
|
||||
case sel.isMount:
|
||||
lines = append(lines, m.styles.PanelTitle.Render(sel.mount.Path))
|
||||
lines = append(lines, m.styles.KeyCell.Render("engine")+" "+m.styles.ValueCell.Render(engineLabel(sel.mount)))
|
||||
if sel.mount.Description != "" {
|
||||
lines = append(lines, "", sel.mount.Description)
|
||||
}
|
||||
if sel.mount.Local {
|
||||
lines = append(lines, "", m.styles.Badge.Render("local"))
|
||||
}
|
||||
if sel.unsupported {
|
||||
lines = append(lines, "", m.styles.WarnText.Render(symWarn+" unsupported engine: "+sel.mount.Type))
|
||||
}
|
||||
case sel.isDir:
|
||||
lines = append(lines, m.styles.PanelTitle.Render(sel.mount.Path+sel.relPath))
|
||||
lines = append(lines, m.styles.ValueCell.Render("directory — press enter to browse"))
|
||||
default:
|
||||
lines = append(lines, m.styles.PanelTitle.Render(sel.mount.Path+sel.relPath))
|
||||
lines = append(lines, m.styles.ValueCell.Render("secret — press enter to open"))
|
||||
}
|
||||
return lipgloss.JoinVertical(lipgloss.Left, lines...)
|
||||
}
|
||||
|
||||
func emptyBrowserMessage(f frame, readOnly bool) string {
|
||||
if f.scr == scrMounts {
|
||||
return "no mounts visible to this token"
|
||||
}
|
||||
if readOnly {
|
||||
return "nothing here yet"
|
||||
}
|
||||
return "nothing here yet — press n to create a secret"
|
||||
}
|
||||
|
||||
func (m *Model) viewBrowser() string {
|
||||
f := m.currentFrame()
|
||||
title := "Mounts"
|
||||
if f.scr == scrBrowser {
|
||||
title = f.mount.Path + f.path
|
||||
}
|
||||
count := 0
|
||||
if m.listReady {
|
||||
count = len(m.list.Items())
|
||||
}
|
||||
header := m.styles.PanelTitle.Render(title) + " " + m.styles.PanelSubtle.Render(fmt.Sprintf("%d item(s)", count))
|
||||
|
||||
var body string
|
||||
switch {
|
||||
case m.loading || !m.listReady:
|
||||
body = m.spin.View() + " loading…"
|
||||
case count == 0:
|
||||
body = m.styles.EmptyState.Render(emptyBrowserMessage(f, m.a.Settings.ReadOnly))
|
||||
default:
|
||||
body = m.list.View()
|
||||
}
|
||||
mainContent := lipgloss.JoinVertical(lipgloss.Left, header, body)
|
||||
|
||||
ow, oh := m.listPaneOuter()
|
||||
panel := renderPanel(m.styles.PanelActive, ow, oh, mainContent)
|
||||
|
||||
if m.lay.class != sizeWide || !m.listReady || count == 0 {
|
||||
return panel
|
||||
}
|
||||
side := renderPanel(m.styles.SidePanel, m.lay.sideW, m.lay.bodyH, m.detailPreview())
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, panel, " ", side)
|
||||
}
|
||||
Reference in New Issue
Block a user