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

281 lines
7.5 KiB
Go

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)
}