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,192 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// screenHelp is the shape every keys.KeyMap.XxxHelp() screen-help value
|
||||
// satisfies; declared here (rather than importing the concrete type, which
|
||||
// is unexported in package keys) so viewFooter can take any of them.
|
||||
type screenHelp interface {
|
||||
ShortHelp() []key.Binding
|
||||
FullHelp() [][]key.Binding
|
||||
}
|
||||
|
||||
var _ help.KeyMap = screenHelp(nil) // screenHelp is exactly help.KeyMap
|
||||
|
||||
// breadcrumbSegments returns one path element per frame on the stack, plus
|
||||
// the leading profile name — the pieces viewHeader joins with symChevronR
|
||||
// and, on a narrow terminal, elides from the middle.
|
||||
func (m *Model) breadcrumbSegments() []string {
|
||||
segs := []string{m.a.Settings.Profile}
|
||||
for _, f := range m.stack {
|
||||
switch f.scr {
|
||||
case scrMounts:
|
||||
segs = append(segs, "mounts")
|
||||
case scrBrowser, scrSecret:
|
||||
segs = append(segs, f.mount.Path+f.path)
|
||||
case scrEditor:
|
||||
segs = append(segs, "edit")
|
||||
case scrVersions:
|
||||
segs = append(segs, "versions")
|
||||
}
|
||||
}
|
||||
return segs
|
||||
}
|
||||
|
||||
// renderBreadcrumb joins breadcrumbSegments with " ▸ ", eliding from the
|
||||
// middle (keeping the profile name and the current location legible) when
|
||||
// the full trail doesn't fit in avail cells.
|
||||
func (m *Model) renderBreadcrumb(avail int) string {
|
||||
segs := m.breadcrumbSegments()
|
||||
sep := m.styles.CrumbSep.Render(" " + symChevronR + " ")
|
||||
join := func(ss []string) string {
|
||||
styled := make([]string, len(ss))
|
||||
last := len(ss) - 1
|
||||
for i, s := range ss {
|
||||
if i == last {
|
||||
styled[i] = m.styles.CrumbActive.Render(s)
|
||||
} else {
|
||||
styled[i] = m.styles.Crumb.Render(s)
|
||||
}
|
||||
}
|
||||
out := styled[0]
|
||||
for _, s := range styled[1:] {
|
||||
out += sep + s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
full := join(segs)
|
||||
if lipgloss.Width(full) <= avail || len(segs) <= 2 {
|
||||
if lipgloss.Width(full) <= avail {
|
||||
return full
|
||||
}
|
||||
return m.styles.CrumbActive.Render(truncate(segs[len(segs)-1], avail))
|
||||
}
|
||||
|
||||
elided := append([]string{segs[0], symEllipsis}, segs[len(segs)-1])
|
||||
out := join(elided)
|
||||
if lipgloss.Width(out) <= avail {
|
||||
return out
|
||||
}
|
||||
return m.styles.CrumbActive.Render(truncate(segs[len(segs)-1], avail))
|
||||
}
|
||||
|
||||
// ttlPill renders the token-TTL pill, colored by how close it is to
|
||||
// Settings.TTLWarnBelow.
|
||||
func (m *Model) ttlPill() string {
|
||||
if m.tokenInfo == nil || m.tokenInfo.ExpireTime == nil {
|
||||
return ""
|
||||
}
|
||||
ttl := m.tokenInfo.TTL
|
||||
text := "ttl " + ttlString(ttl)
|
||||
warnAt := m.a.Settings.TTLWarnBelow
|
||||
switch {
|
||||
case ttl <= 0:
|
||||
return m.styles.PillDanger.Render(text)
|
||||
case warnAt > 0 && ttl <= warnAt/4:
|
||||
return m.styles.PillDanger.Render(text)
|
||||
case warnAt > 0 && ttl <= warnAt:
|
||||
return m.styles.PillWarn.Render(text)
|
||||
default:
|
||||
return m.styles.Pill.Render(text)
|
||||
}
|
||||
}
|
||||
|
||||
// viewHeader renders the single-line top bar: app badge + breadcrumb on
|
||||
// the left, status pills on the right. Collapses to a bare breadcrumb on
|
||||
// sizeTiny, where there's no room for a badge.
|
||||
func (m *Model) viewHeader() string {
|
||||
var right []string
|
||||
if m.a.Settings.ReadOnly {
|
||||
right = append(right, m.styles.PillWarn.Render("READ-ONLY"))
|
||||
}
|
||||
if m.a.Settings.Namespace != "" && m.lay.class != sizeTiny {
|
||||
right = append(right, m.styles.Pill.Render("ns:"+m.a.Settings.Namespace))
|
||||
}
|
||||
if p := m.ttlPill(); p != "" {
|
||||
right = append(right, p)
|
||||
}
|
||||
if m.loading {
|
||||
right = append(right, m.spin.View())
|
||||
}
|
||||
rightStr := lipgloss.JoinHorizontal(lipgloss.Center, right...)
|
||||
rightW := lipgloss.Width(rightStr)
|
||||
if rightStr != "" {
|
||||
rightStr = " " + rightStr
|
||||
rightW++
|
||||
}
|
||||
|
||||
badge := ""
|
||||
if m.lay.class != sizeTiny {
|
||||
badge = m.styles.AppBadge.Render("▮ VAULT") + " "
|
||||
}
|
||||
badgeW := lipgloss.Width(badge)
|
||||
|
||||
// HeaderBar carries its own Padding(0,1) — a style's declared Width sets
|
||||
// the pre-padding content width, so the bar renders at contentW+2. Budget
|
||||
// for that here rather than sizing to the full bar width, or the result
|
||||
// overflows the terminal by 2 cells and wraps.
|
||||
barW := m.lay.w - 2
|
||||
if barW < 1 {
|
||||
barW = 1
|
||||
}
|
||||
|
||||
avail := barW - badgeW - rightW
|
||||
if avail < 4 {
|
||||
avail = 4
|
||||
}
|
||||
crumb := m.renderBreadcrumb(avail)
|
||||
crumbW := lipgloss.Width(crumb)
|
||||
|
||||
pad := barW - badgeW - crumbW - rightW
|
||||
if pad < 0 {
|
||||
pad = 0
|
||||
}
|
||||
line := badge + crumb + lipgloss.NewStyle().Width(pad).Render("") + rightStr
|
||||
return m.styles.HeaderBar.Render(fit(line, barW, 1))
|
||||
}
|
||||
|
||||
// viewFooter renders the footer: a screen-specific key hint line plus a
|
||||
// status line (profile/address, or the current toast) — one line total on
|
||||
// sizeTiny, two otherwise.
|
||||
func (m *Model) viewFooter(sh screenHelp) string {
|
||||
status := fmt.Sprintf("%s @ %s", m.a.Settings.Profile, m.a.Settings.Address)
|
||||
if toastStr := m.toastView(); toastStr != "" {
|
||||
status = toastStr
|
||||
} else {
|
||||
status = m.styles.StatusBar.Render(status)
|
||||
}
|
||||
|
||||
// Footer carries Padding(0,1) too — same barW budgeting as viewHeader.
|
||||
barW := m.lay.w - 2
|
||||
if barW < 1 {
|
||||
barW = 1
|
||||
}
|
||||
m.help.SetWidth(barW)
|
||||
|
||||
if m.lay.class == sizeTiny {
|
||||
line := status
|
||||
if m.toast == nil {
|
||||
line = m.styles.Footer.Render(m.help.ShortHelpView(sh.ShortHelp()))
|
||||
}
|
||||
return m.styles.Footer.Render(fit(line, barW, 1))
|
||||
}
|
||||
|
||||
helpLine := m.styles.Footer.Render(fit(m.help.ShortHelpView(sh.ShortHelp()), barW, 1))
|
||||
statusLine := m.styles.Footer.Render(fit(status, barW, 1))
|
||||
return lipgloss.JoinVertical(lipgloss.Left, helpLine, statusLine)
|
||||
}
|
||||
|
||||
// viewTooSmall replaces the entire UI below layout.tooSmall's threshold.
|
||||
func (m *Model) viewTooSmall() string {
|
||||
msg := fmt.Sprintf("terminal too small\n%dx%d — need at least %dx%d", m.lay.w, m.lay.h, minWidth, minHeight)
|
||||
box := m.styles.Modal.Render(msg)
|
||||
return lipgloss.Place(m.lay.w, m.lay.h, lipgloss.Center, lipgloss.Center, box,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(m.styles.Bg)))
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func (m *Model) updateConfirm(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
c := m.confirm
|
||||
if c.typeToConf != "" {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
m.confirm = nil
|
||||
return m, nil
|
||||
case "enter":
|
||||
if c.input == c.typeToConf {
|
||||
cmd := c.onConfirm()
|
||||
m.confirm = nil
|
||||
return m, cmd
|
||||
}
|
||||
return m, nil
|
||||
case "backspace":
|
||||
if len(c.input) > 0 {
|
||||
c.input = c.input[:len(c.input)-1]
|
||||
}
|
||||
return m, nil
|
||||
default:
|
||||
if len(msg.Text) > 0 {
|
||||
c.input += msg.Text
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
switch msg.String() {
|
||||
case "y", "enter":
|
||||
cmd := c.onConfirm()
|
||||
m.confirm = nil
|
||||
return m, cmd
|
||||
case "n", "esc":
|
||||
m.confirm = nil
|
||||
return m, nil
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) viewConfirm() string {
|
||||
c := m.confirm
|
||||
style := m.styles.Modal
|
||||
if c.danger {
|
||||
style = m.styles.ModalDanger
|
||||
}
|
||||
w := m.modalWrapWidth()
|
||||
lines := []string{m.styles.ModalTitle.Render(c.title), lipgloss.Wrap(c.body, w, ""), ""}
|
||||
if c.typeToConf != "" {
|
||||
lines = append(lines, "Type "+c.typeToConf+" to confirm:", "> "+c.input)
|
||||
} else {
|
||||
lines = append(lines, m.styles.Help.Render("y/enter confirm • n/esc cancel"))
|
||||
}
|
||||
return style.Render(lipgloss.JoinVertical(lipgloss.Left, lines...))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/vault"
|
||||
)
|
||||
|
||||
// Symbols used throughout the TUI. Deliberately plain, single-width
|
||||
// Unicode box-drawing/geometric shapes — no Nerd Font glyphs, no emoji —
|
||||
// so rendering never depends on a font the user's terminal might not have.
|
||||
const (
|
||||
symMount = "▣"
|
||||
symDir = "▸"
|
||||
symSecret = "▪"
|
||||
symUnsupported = "▨"
|
||||
symCursor = "┃"
|
||||
symCurrent = "●"
|
||||
symDeleted = "◐"
|
||||
symDestroyed = "✕"
|
||||
symOK = "✓"
|
||||
symWarn = "⚠"
|
||||
symErr = "✕"
|
||||
symLocked = "●"
|
||||
symUnlocked = "○"
|
||||
symChevronR = "▸"
|
||||
symEllipsis = "…"
|
||||
)
|
||||
|
||||
// truncate clips s to at most w cells wide, replacing the tail with an
|
||||
// ellipsis when it doesn't fit — unlike fit/MaxWidth, which hard-crop
|
||||
// without any indication that content was cut off.
|
||||
func truncate(s string, w int) string {
|
||||
if w <= 0 {
|
||||
return ""
|
||||
}
|
||||
if lipgloss.Width(s) <= w {
|
||||
return s
|
||||
}
|
||||
if w == 1 {
|
||||
return symEllipsis
|
||||
}
|
||||
runes := []rune(s)
|
||||
// Binary-search-free linear trim: widths are small (labels/paths), and
|
||||
// wide runes are rare enough that a byte-at-a-time trim is plenty fast.
|
||||
for i := len(runes); i > 0; i-- {
|
||||
cand := string(runes[:i]) + symEllipsis
|
||||
if lipgloss.Width(cand) <= w {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
return symEllipsis
|
||||
}
|
||||
|
||||
// padRight pads s with spaces to exactly w cells, or truncates it if it's
|
||||
// already wider — for building fixed-width table cells inline (as opposed
|
||||
// to fit, which pads/crops a whole multi-line block).
|
||||
func padRight(s string, w int) string {
|
||||
if w <= 0 {
|
||||
return ""
|
||||
}
|
||||
lw := lipgloss.Width(s)
|
||||
if lw > w {
|
||||
return truncate(s, w)
|
||||
}
|
||||
return s + strings.Repeat(" ", w-lw)
|
||||
}
|
||||
|
||||
// padLeft right-aligns s within w cells.
|
||||
func padLeft(s string, w int) string {
|
||||
if w <= 0 {
|
||||
return ""
|
||||
}
|
||||
lw := lipgloss.Width(s)
|
||||
if lw >= w {
|
||||
return truncate(s, w)
|
||||
}
|
||||
return strings.Repeat(" ", w-lw) + s
|
||||
}
|
||||
|
||||
// humanTime renders an RFC3339 timestamp (as returned by Vault's KV
|
||||
// metadata) as a short relative time, falling back to the raw string if it
|
||||
// doesn't parse.
|
||||
func humanTime(rfc3339 string) string {
|
||||
if rfc3339 == "" {
|
||||
return ""
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, rfc3339)
|
||||
if err != nil {
|
||||
return rfc3339
|
||||
}
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < 0:
|
||||
return t.Format("2006-01-02 15:04")
|
||||
case d < time.Minute:
|
||||
return "just now"
|
||||
case d < time.Hour:
|
||||
return fmt.Sprintf("%dm ago", int(d/time.Minute))
|
||||
case d < 24*time.Hour:
|
||||
return fmt.Sprintf("%dh ago", int(d/time.Hour))
|
||||
case d < 30*24*time.Hour:
|
||||
return fmt.Sprintf("%dd ago", int(d/(24*time.Hour)))
|
||||
default:
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
|
||||
// ttlString renders a duration the way a status pill wants it: compact,
|
||||
// never more than two units ("42m", "1h05m", "3d02h"), never negative.
|
||||
func ttlString(d time.Duration) string {
|
||||
if d <= 0 {
|
||||
return "expired"
|
||||
}
|
||||
d = d.Round(time.Second)
|
||||
days := d / (24 * time.Hour)
|
||||
d -= days * 24 * time.Hour
|
||||
hours := d / time.Hour
|
||||
d -= hours * time.Hour
|
||||
mins := d / time.Minute
|
||||
d -= mins * time.Minute
|
||||
secs := d / time.Second
|
||||
|
||||
switch {
|
||||
case days > 0:
|
||||
return fmt.Sprintf("%dd%02dh", days, hours)
|
||||
case hours > 0:
|
||||
return fmt.Sprintf("%dh%02dm", hours, mins)
|
||||
case mins > 0:
|
||||
return fmt.Sprintf("%dm%02ds", mins, secs)
|
||||
default:
|
||||
return fmt.Sprintf("%ds", secs)
|
||||
}
|
||||
}
|
||||
|
||||
// engineLabel is the short badge text for a mount's engine kind.
|
||||
func engineLabel(m vault.Mount) string {
|
||||
switch m.Kind {
|
||||
case vault.EngineKVv2:
|
||||
return "kv v2"
|
||||
case vault.EngineKVv1:
|
||||
return "kv v1"
|
||||
default:
|
||||
return m.Type
|
||||
}
|
||||
}
|
||||
|
||||
// maskValue replaces v with repeated mask characters, capped at a fixed
|
||||
// display width so long secrets don't stretch the layout.
|
||||
func maskValue(v, char string) string {
|
||||
if char == "" {
|
||||
char = "•"
|
||||
}
|
||||
n := lipgloss.Width(v)
|
||||
if n > 12 {
|
||||
n = 12
|
||||
}
|
||||
if n == 0 {
|
||||
n = 1
|
||||
}
|
||||
return strings.Repeat(char, n)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package keys
|
||||
|
||||
import "charm.land/bubbles/v2/key"
|
||||
|
||||
// screenHelp implements bubbles/help's Model.KeyMap with a fixed
|
||||
// short/full binding set — one instance per screen, built fresh from the
|
||||
// live *KeyMap so a `keys:` rebind or DisableWrites (read-only mode) is
|
||||
// reflected without the screen needing to know about it.
|
||||
type screenHelp struct {
|
||||
short []key.Binding
|
||||
full [][]key.Binding
|
||||
}
|
||||
|
||||
func (h screenHelp) ShortHelp() []key.Binding { return h.short }
|
||||
func (h screenHelp) FullHelp() [][]key.Binding { return h.full }
|
||||
|
||||
// BrowserHelp is the footer for the mounts/listing screens.
|
||||
func (k *KeyMap) BrowserHelp() screenHelp {
|
||||
return screenHelp{
|
||||
short: []key.Binding{k.Up, k.Down, k.Enter, k.Back, k.Filter, k.New, k.Help, k.Quit},
|
||||
full: [][]key.Binding{
|
||||
{k.Up, k.Down, k.Top, k.Bottom, k.Enter, k.Back},
|
||||
{k.Filter, k.Refresh, k.New, k.Profiles, k.Logout},
|
||||
{k.Help, k.Quit, k.ForceQuit},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SecretHelp is the footer for the secret detail screen.
|
||||
func (k *KeyMap) SecretHelp() screenHelp {
|
||||
return screenHelp{
|
||||
short: []key.Binding{k.Up, k.Down, k.ToggleMask, k.CopyValue, k.Edit, k.Back, k.Help},
|
||||
full: [][]key.Binding{
|
||||
{k.Up, k.Down, k.Back, k.Versions},
|
||||
{k.ToggleMask, k.ToggleMaskAll, k.CopyValue},
|
||||
{k.Edit, k.Delete, k.Destroy},
|
||||
{k.Help, k.Quit, k.ForceQuit},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// EditorHelp is the footer for the create/edit-secret screen.
|
||||
func (k *KeyMap) EditorHelp() screenHelp {
|
||||
return screenHelp{
|
||||
short: []key.Binding{k.Save, k.AddField, k.DeleteField, k.Cancel},
|
||||
full: [][]key.Binding{
|
||||
{k.Save, k.AddField, k.DeleteField, k.Cancel},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// VersionsHelp is the footer for the KV v2 version-history screen.
|
||||
func (k *KeyMap) VersionsHelp() screenHelp {
|
||||
return screenHelp{
|
||||
short: []key.Binding{k.Up, k.Down, k.Enter, k.Rollback, k.Undelete, k.Back},
|
||||
full: [][]key.Binding{
|
||||
{k.Up, k.Down, k.Enter, k.Back},
|
||||
{k.Rollback, k.Undelete},
|
||||
{k.Help, k.Quit, k.ForceQuit},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AuthHelp is the footer for the auth-method picker phase (the form and
|
||||
// waiting phases use their own fixed, non-rebindable hints since tab/
|
||||
// enter/esc there are text-navigation, not KeyMap actions).
|
||||
func (k *KeyMap) AuthHelp() screenHelp {
|
||||
return screenHelp{
|
||||
short: []key.Binding{k.Up, k.Down, k.Enter, k.Quit},
|
||||
full: [][]key.Binding{
|
||||
{k.Up, k.Down, k.Enter, k.Quit, k.ForceQuit},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package keys defines the TUI's keybindings and how config.File's `keys:`
|
||||
// section can rebind them.
|
||||
package keys
|
||||
|
||||
import "charm.land/bubbles/v2/key"
|
||||
|
||||
// KeyMap holds every action the TUI's root Update dispatches on. Screens
|
||||
// consult the same KeyMap so a rebind in config.yaml takes effect
|
||||
// everywhere at once.
|
||||
type KeyMap struct {
|
||||
Up, Down key.Binding
|
||||
Top, Bottom key.Binding
|
||||
Enter, Back key.Binding
|
||||
Refresh key.Binding
|
||||
Filter key.Binding
|
||||
Palette key.Binding
|
||||
ToggleMask key.Binding
|
||||
ToggleMaskAll key.Binding
|
||||
CopyValue key.Binding
|
||||
Versions key.Binding
|
||||
Rollback key.Binding
|
||||
Undelete key.Binding
|
||||
New, Edit key.Binding
|
||||
Save, Cancel key.Binding
|
||||
AddField key.Binding
|
||||
DeleteField key.Binding
|
||||
Delete, Destroy key.Binding
|
||||
Profiles key.Binding
|
||||
Logout key.Binding
|
||||
Help, Quit key.Binding
|
||||
ForceQuit key.Binding
|
||||
}
|
||||
|
||||
// Default returns the built-in vim-flavoured keymap.
|
||||
func Default() *KeyMap {
|
||||
return &KeyMap{
|
||||
Up: key.NewBinding(key.WithKeys("k", "up"), key.WithHelp("k/↑", "up")),
|
||||
Down: key.NewBinding(key.WithKeys("j", "down"), key.WithHelp("j/↓", "down")),
|
||||
Top: key.NewBinding(key.WithKeys("g", "home"), key.WithHelp("g", "top")),
|
||||
Bottom: key.NewBinding(key.WithKeys("G", "end"), key.WithHelp("G", "bottom")),
|
||||
Enter: key.NewBinding(key.WithKeys("enter", "l"), key.WithHelp("↵", "open")),
|
||||
Back: key.NewBinding(key.WithKeys("esc", "h"), key.WithHelp("esc", "back")),
|
||||
Refresh: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "refresh")),
|
||||
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||
Palette: key.NewBinding(key.WithKeys(":"), key.WithHelp(":", "goto path")),
|
||||
ToggleMask: key.NewBinding(key.WithKeys("s"), key.WithHelp("s", "show/hide value")),
|
||||
ToggleMaskAll: key.NewBinding(key.WithKeys("S"), key.WithHelp("S", "show/hide all")),
|
||||
CopyValue: key.NewBinding(key.WithKeys("y"), key.WithHelp("y", "copy value")),
|
||||
Versions: key.NewBinding(key.WithKeys("V"), key.WithHelp("V", "versions")),
|
||||
Rollback: key.NewBinding(key.WithKeys("R"), key.WithHelp("R", "rollback to this version")),
|
||||
Undelete: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "undelete this version")),
|
||||
New: key.NewBinding(key.WithKeys("n"), key.WithHelp("n", "new secret")),
|
||||
Edit: key.NewBinding(key.WithKeys("e"), key.WithHelp("e", "edit")),
|
||||
Save: key.NewBinding(key.WithKeys("ctrl+s"), key.WithHelp("^s", "save")),
|
||||
Cancel: key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "cancel")),
|
||||
AddField: key.NewBinding(key.WithKeys("ctrl+a"), key.WithHelp("^a", "add field")),
|
||||
DeleteField: key.NewBinding(key.WithKeys("ctrl+x"), key.WithHelp("^x", "remove field")),
|
||||
Delete: key.NewBinding(key.WithKeys("d"), key.WithHelp("d", "delete")),
|
||||
Destroy: key.NewBinding(key.WithKeys("D"), key.WithHelp("D", "destroy")),
|
||||
Profiles: key.NewBinding(key.WithKeys("p"), key.WithHelp("p", "switch profile")),
|
||||
Logout: key.NewBinding(key.WithKeys("ctrl+l"), key.WithHelp("^l", "logout")),
|
||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||
Quit: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit")),
|
||||
ForceQuit: key.NewBinding(key.WithKeys("ctrl+c"), key.WithHelp("^c", "quit")),
|
||||
}
|
||||
}
|
||||
|
||||
func (k *KeyMap) registry() map[string]*key.Binding {
|
||||
return map[string]*key.Binding{
|
||||
"up": &k.Up, "down": &k.Down, "top": &k.Top, "bottom": &k.Bottom,
|
||||
"enter": &k.Enter, "back": &k.Back, "refresh": &k.Refresh, "filter": &k.Filter,
|
||||
"palette": &k.Palette, "toggle_mask": &k.ToggleMask, "toggle_mask_all": &k.ToggleMaskAll,
|
||||
"copy_value": &k.CopyValue, "versions": &k.Versions, "rollback": &k.Rollback,
|
||||
"undelete": &k.Undelete, "new": &k.New, "edit": &k.Edit, "save": &k.Save,
|
||||
"cancel": &k.Cancel, "add_field": &k.AddField, "delete_field": &k.DeleteField,
|
||||
"delete": &k.Delete, "destroy": &k.Destroy, "profiles": &k.Profiles, "logout": &k.Logout,
|
||||
"help": &k.Help, "quit": &k.Quit, "force_quit": &k.ForceQuit,
|
||||
}
|
||||
}
|
||||
|
||||
// Apply overrides the defaults from config.File's `keys:` map. An unknown
|
||||
// action name is a startup error, not a silently ignored typo.
|
||||
func (k *KeyMap) Apply(cfg map[string][]string) error {
|
||||
reg := k.registry()
|
||||
for action, keyStrs := range cfg {
|
||||
b, ok := reg[action]
|
||||
if !ok {
|
||||
return unknownActionError(action)
|
||||
}
|
||||
*b = key.NewBinding(key.WithKeys(keyStrs...), key.WithHelp(b.Help().Key, b.Help().Desc))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type unknownActionError string
|
||||
|
||||
func (e unknownActionError) Error() string {
|
||||
return "unknown keybinding action " + string(e) + " in config `keys:` section"
|
||||
}
|
||||
|
||||
// DisableWrites turns off every write-capable binding — the UI-side half of
|
||||
// read-only enforcement (the real guard is vault.KV.ReadOnly).
|
||||
func (k *KeyMap) DisableWrites() {
|
||||
for _, b := range []*key.Binding{
|
||||
&k.New, &k.Edit, &k.Save, &k.AddField, &k.DeleteField,
|
||||
&k.Delete, &k.Destroy, &k.Rollback, &k.Undelete,
|
||||
} {
|
||||
b.SetEnabled(false)
|
||||
}
|
||||
}
|
||||
|
||||
// ShortHelp/FullHelp implement help.KeyMap for the global bindings and
|
||||
// back the "?" help overlay (screen_help.go). Individual screens' footers
|
||||
// use the narrower, screen-specific views in help.go instead.
|
||||
func (k *KeyMap) ShortHelp() []key.Binding {
|
||||
return []key.Binding{k.Up, k.Down, k.Enter, k.Back, k.Help, k.Quit}
|
||||
}
|
||||
|
||||
func (k *KeyMap) FullHelp() [][]key.Binding {
|
||||
return [][]key.Binding{
|
||||
{k.Up, k.Down, k.Top, k.Bottom, k.Enter, k.Back},
|
||||
{k.Refresh, k.Filter, k.Palette, k.ToggleMask, k.ToggleMaskAll, k.CopyValue},
|
||||
{k.Versions, k.Rollback, k.Undelete, k.New, k.Edit, k.Save},
|
||||
{k.Cancel, k.AddField, k.DeleteField, k.Delete, k.Destroy},
|
||||
{k.Profiles, k.Logout, k.Help, k.Quit, k.ForceQuit},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// sizeClass buckets the terminal width into the breakpoints every screen's
|
||||
// view function reads to decide how much chrome it can afford.
|
||||
type sizeClass int
|
||||
|
||||
const (
|
||||
sizeTiny sizeClass = iota // < tinyWidth columns: single column, minimal chrome
|
||||
sizeCompact // < wideWidth columns: single column, full chrome
|
||||
sizeWide // >= wideWidth columns: room for a side panel
|
||||
)
|
||||
|
||||
const (
|
||||
minWidth, minHeight = 44, 12 // below this, render the "too small" notice instead
|
||||
tinyWidth = 60
|
||||
wideWidth = 100
|
||||
|
||||
// Panel border(1)+padding(0,1) on each side: 2 cols border, 2 cols
|
||||
// padding, 2 rows border, 0 rows padding. Every screen that renders
|
||||
// inside styles.Panel/PanelActive/SidePanel must size its content to
|
||||
// panelInner of the outer box it's about to be wrapped in.
|
||||
panelBorderW, panelBorderH = 2, 2
|
||||
panelPadW, panelPadH = 2, 0
|
||||
)
|
||||
|
||||
// layout is the single source of truth for how the terminal's cells are
|
||||
// divided, recomputed once per tea.WindowSizeMsg and read by every view
|
||||
// function instead of each screen re-deriving its own magic numbers.
|
||||
type layout struct {
|
||||
w, h int
|
||||
class sizeClass
|
||||
tooSmall bool
|
||||
|
||||
headerH, footerH int
|
||||
bodyW, bodyH int // full-width body area below the header, above the footer
|
||||
|
||||
// Two-column split of bodyW, valid only when class == sizeWide and a
|
||||
// screen opts into it (mainW+sideW+1 == bodyW; the extra column is a
|
||||
// one-cell gutter between the two panels).
|
||||
mainW, sideW int
|
||||
}
|
||||
|
||||
func computeLayout(w, h int) layout {
|
||||
l := layout{w: w, h: h}
|
||||
if w < minWidth || h < minHeight {
|
||||
l.tooSmall = true
|
||||
return l
|
||||
}
|
||||
|
||||
switch {
|
||||
case w < tinyWidth:
|
||||
l.class = sizeTiny
|
||||
case w < wideWidth:
|
||||
l.class = sizeCompact
|
||||
default:
|
||||
l.class = sizeWide
|
||||
}
|
||||
|
||||
l.headerH = 1
|
||||
l.footerH = 2
|
||||
if l.class == sizeTiny {
|
||||
l.footerH = 1
|
||||
}
|
||||
l.bodyH = h - l.headerH - l.footerH
|
||||
if l.bodyH < 3 {
|
||||
l.bodyH = 3
|
||||
}
|
||||
l.bodyW = w
|
||||
|
||||
if l.class == sizeWide {
|
||||
l.sideW = l.bodyW * 2 / 5
|
||||
if l.sideW > 48 {
|
||||
l.sideW = 48
|
||||
}
|
||||
l.mainW = l.bodyW - l.sideW - 1
|
||||
} else {
|
||||
l.mainW = l.bodyW
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
// panelInner returns the content area available inside a bordered,
|
||||
// (0,1)-padded panel of the given outer size — what a screen must wrap its
|
||||
// text to before handing it to styles.Panel/PanelActive/SidePanel.Render.
|
||||
func panelInner(outerW, outerH int) (w, h int) {
|
||||
w = outerW - panelBorderW - panelPadW
|
||||
h = outerH - panelBorderH - panelPadH
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// fit pads/truncates s to exactly h lines of at most w cells each, so a
|
||||
// panel's rendered size never depends on its content — no reflow, no
|
||||
// leftover scrollback in the alt-screen buffer.
|
||||
func fit(s string, w, h int) string {
|
||||
if w < 1 {
|
||||
w = 1
|
||||
}
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
lines := strings.Split(s, "\n")
|
||||
out := make([]string, h)
|
||||
for i := 0; i < h; i++ {
|
||||
if i >= len(lines) {
|
||||
out[i] = ""
|
||||
continue
|
||||
}
|
||||
line := lines[i]
|
||||
lw := lipgloss.Width(line)
|
||||
switch {
|
||||
case lw > w:
|
||||
out[i] = lipgloss.NewStyle().MaxWidth(w).Render(line)
|
||||
case lw < w:
|
||||
out[i] = line + strings.Repeat(" ", w-lw)
|
||||
default:
|
||||
out[i] = line
|
||||
}
|
||||
}
|
||||
return strings.Join(out, "\n")
|
||||
}
|
||||
|
||||
// renderPanel wraps content (already fit to panelInner(outerW, outerH)) in
|
||||
// style, sized so the rendered result is exactly outerW x outerH.
|
||||
//
|
||||
// Deliberately does NOT call style.Width()/Height(): lipgloss.Style.Render
|
||||
// treats a set Width as a word-wrap target of width-minus-border-minus-
|
||||
// padding and re-wraps anything wider, which corrupts content we've
|
||||
// already sized to the exact cell grid ourselves via fit(). Handing it
|
||||
// pre-fit, uniform-width content and leaving Width/Height unset gets the
|
||||
// same result (border+padding add their fixed, known cost on top) without
|
||||
// that reprocessing.
|
||||
func renderPanel(style lipgloss.Style, outerW, outerH int, content string) string {
|
||||
iw, ih := panelInner(outerW, outerH)
|
||||
return style.Render(fit(content, iw, ih))
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/key"
|
||||
"charm.land/bubbles/v2/list"
|
||||
"charm.land/bubbles/v2/spinner"
|
||||
"charm.land/bubbles/v2/viewport"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/auth"
|
||||
"git.morlana.online/f.weber/vault-tui/internal/cli"
|
||||
"git.morlana.online/f.weber/vault-tui/internal/token"
|
||||
"git.morlana.online/f.weber/vault-tui/internal/ui/keys"
|
||||
"git.morlana.online/f.weber/vault-tui/internal/ui/theme"
|
||||
"git.morlana.online/f.weber/vault-tui/internal/vault"
|
||||
)
|
||||
|
||||
type screen int
|
||||
|
||||
const (
|
||||
scrAuth screen = iota
|
||||
scrMounts
|
||||
scrBrowser
|
||||
scrSecret
|
||||
scrEditor
|
||||
scrVersions
|
||||
)
|
||||
|
||||
// frame is one entry in the navigation stack. Not every field is used by
|
||||
// every screen kind; see the per-screen handlers in the other files in
|
||||
// this package.
|
||||
type frame struct {
|
||||
scr screen
|
||||
mount vault.Mount
|
||||
path string // relative path: browser's current dir, or the secret path
|
||||
}
|
||||
|
||||
// confirmSpec describes a pending destructive action, rendered as a
|
||||
// centered overlay (see overlay.go) rather than appended below the
|
||||
// current screen.
|
||||
type confirmSpec struct {
|
||||
title string
|
||||
body string
|
||||
danger bool
|
||||
typeToConf string // if non-empty, the user must type this text to confirm
|
||||
input string
|
||||
onConfirm func() tea.Cmd
|
||||
}
|
||||
|
||||
// authState holds everything the auth screen needs across its three
|
||||
// sub-phases (method picker -> credential form -> waiting on a
|
||||
// multi-step login such as OIDC or Okta).
|
||||
type authState struct {
|
||||
phase int // 0=picker 1=form 2=waiting
|
||||
methods []auth.Method
|
||||
cursor int
|
||||
method auth.Method
|
||||
mount string
|
||||
inputs []textInputField
|
||||
focus int
|
||||
events chan auth.Event
|
||||
cancel context.CancelFunc
|
||||
authURL string
|
||||
status []string
|
||||
err error
|
||||
}
|
||||
|
||||
type textInputField struct {
|
||||
field auth.Field
|
||||
input textInput
|
||||
}
|
||||
|
||||
// profileState drives the profile-switch overlay (screen_profile.go).
|
||||
type profileState struct {
|
||||
open bool
|
||||
names []string
|
||||
cursor int
|
||||
}
|
||||
|
||||
type Model struct {
|
||||
ctx context.Context
|
||||
a *cli.App
|
||||
svc *vault.Service
|
||||
|
||||
styles *theme.Styles
|
||||
keys *keys.KeyMap
|
||||
help help.Model
|
||||
spin spinner.Model
|
||||
|
||||
lay layout
|
||||
loading bool
|
||||
|
||||
toast *toast
|
||||
toastSeq int
|
||||
|
||||
stack []frame
|
||||
auth authState
|
||||
|
||||
list list.Model
|
||||
listReady bool
|
||||
listSelect string // remembered selected item's label, restored after a reload
|
||||
|
||||
secret *vault.Secret
|
||||
secretMask map[string]bool
|
||||
secretKeys []string // sorted cache of secret.Data's keys, rebuilt on load
|
||||
secretCursor int
|
||||
secretVP viewport.Model
|
||||
clipSeq int
|
||||
|
||||
editName textInput
|
||||
editKeys []textInput
|
||||
editVals []textInput
|
||||
editFocus int
|
||||
editIsNew bool
|
||||
editMount vault.Mount
|
||||
editPath string
|
||||
editSecret *vault.Secret
|
||||
editSnap string // serialized snapshot at open, for the unsaved-changes guard
|
||||
editConfirm bool // pending "discard unsaved changes?" on esc
|
||||
|
||||
versions []vault.VersionMeta
|
||||
versionsCursor int
|
||||
|
||||
confirm *confirmSpec
|
||||
profile profileState
|
||||
helpOpen bool
|
||||
|
||||
tokenInfo *token.Info
|
||||
quitting bool
|
||||
}
|
||||
|
||||
func newModel(ctx context.Context, a *cli.App) (*Model, error) {
|
||||
isDark := theme.IsDark(a.File.Theme, a.Settings.Appearance)
|
||||
mono := theme.NoColor(a.Settings.NoColor)
|
||||
styles := theme.Build(a.File.Theme, isDark, mono)
|
||||
|
||||
km := keys.Default()
|
||||
if err := km.Apply(a.File.Keys); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.Settings.ReadOnly {
|
||||
km.DisableWrites()
|
||||
}
|
||||
|
||||
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
|
||||
hp := help.New()
|
||||
hp.Styles = styles.HelpStyles()
|
||||
|
||||
m := &Model{
|
||||
ctx: ctx,
|
||||
a: a,
|
||||
svc: a.Service(),
|
||||
styles: styles,
|
||||
keys: km,
|
||||
help: hp,
|
||||
spin: sp,
|
||||
secretMask: map[string]bool{},
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) Init() tea.Cmd {
|
||||
return tea.Batch(m.startAuth(), m.spin.Tick, tickCmd())
|
||||
}
|
||||
|
||||
func tickCmd() tea.Cmd {
|
||||
return tea.Tick(30*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) })
|
||||
}
|
||||
|
||||
func (m *Model) top() screen {
|
||||
if len(m.stack) == 0 {
|
||||
return scrAuth
|
||||
}
|
||||
return m.stack[len(m.stack)-1].scr
|
||||
}
|
||||
|
||||
func (m *Model) push(f frame) { m.stack = append(m.stack, f) }
|
||||
|
||||
func (m *Model) pop() (frame, bool) {
|
||||
if len(m.stack) == 0 {
|
||||
return frame{}, false
|
||||
}
|
||||
f := m.stack[len(m.stack)-1]
|
||||
m.stack = m.stack[:len(m.stack)-1]
|
||||
return f, true
|
||||
}
|
||||
|
||||
func (m *Model) currentFrame() frame {
|
||||
if len(m.stack) == 0 {
|
||||
return frame{scr: scrMounts}
|
||||
}
|
||||
return m.stack[len(m.stack)-1]
|
||||
}
|
||||
|
||||
// overlayActive reports whether anything should render on top of the
|
||||
// current screen instead of routing input to it.
|
||||
func (m *Model) overlayActive() bool {
|
||||
return m.confirm != nil || m.profile.open || m.helpOpen
|
||||
}
|
||||
|
||||
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.WindowSizeMsg:
|
||||
m.lay = computeLayout(msg.Width, msg.Height)
|
||||
m.help.SetWidth(m.lay.w - 2)
|
||||
m.resizeList()
|
||||
m.resizeSecretViewport()
|
||||
return m, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
if key.Matches(msg, m.keys.ForceQuit) {
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
if m.confirm != nil {
|
||||
return m.updateConfirm(msg)
|
||||
}
|
||||
if m.profile.open {
|
||||
return m.updateProfilePicker(msg)
|
||||
}
|
||||
if m.helpOpen {
|
||||
return m.updateHelp(msg)
|
||||
}
|
||||
if !m.textInputActive() {
|
||||
if key.Matches(msg, m.keys.Help) {
|
||||
m.helpOpen = true
|
||||
return m, nil
|
||||
}
|
||||
if key.Matches(msg, m.keys.Profiles) && m.top() != scrAuth && len(m.a.File.Profiles) > 1 {
|
||||
return m.openProfilePicker()
|
||||
}
|
||||
if key.Matches(msg, m.keys.Logout) && m.top() != scrAuth {
|
||||
return m.confirmLogout()
|
||||
}
|
||||
}
|
||||
|
||||
case tickMsg:
|
||||
cmds := []tea.Cmd{tickCmd()}
|
||||
if m.a.Client.Token() != "" {
|
||||
cmds = append(cmds, m.refreshTokenInfo())
|
||||
}
|
||||
return m, tea.Batch(cmds...)
|
||||
|
||||
case tokenInfoMsg:
|
||||
if msg.err == nil {
|
||||
m.tokenInfo = msg.info
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case toastExpireMsg:
|
||||
m.clearExpiredToast(msg)
|
||||
return m, nil
|
||||
|
||||
case logoutDoneMsg:
|
||||
if msg.err != nil {
|
||||
return m, m.notify(toastErr, "logout: %v", msg.err)
|
||||
}
|
||||
m.a.Client.ClearToken()
|
||||
m.tokenInfo = nil
|
||||
return m, tea.Batch(m.notify(toastSuccess, "logged out"), m.startAuth())
|
||||
|
||||
case errMsg:
|
||||
m.loading = false
|
||||
return m, m.notify(toastErr, "%v", msg.err)
|
||||
|
||||
case statusMsg:
|
||||
return m, m.notify(toastInfo, "%s", msg.text)
|
||||
|
||||
case spinner.TickMsg:
|
||||
var cmd tea.Cmd
|
||||
m.spin, cmd = m.spin.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
switch m.top() {
|
||||
case scrAuth:
|
||||
return m.updateAuth(msg)
|
||||
case scrMounts, scrBrowser:
|
||||
return m.updateBrowser(msg)
|
||||
case scrSecret:
|
||||
return m.updateSecret(msg)
|
||||
case scrEditor:
|
||||
return m.updateEditor(msg)
|
||||
case scrVersions:
|
||||
return m.updateVersions(msg)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) View() tea.View {
|
||||
if m.lay.tooSmall {
|
||||
v := tea.NewView(m.viewTooSmall())
|
||||
v.AltScreen = true
|
||||
v.WindowTitle = "vault-tui"
|
||||
return v
|
||||
}
|
||||
|
||||
var body string
|
||||
var sh screenHelp
|
||||
switch m.top() {
|
||||
case scrAuth:
|
||||
body, sh = m.viewAuth(), m.keys.AuthHelp()
|
||||
case scrMounts, scrBrowser:
|
||||
body, sh = m.viewBrowser(), m.keys.BrowserHelp()
|
||||
case scrSecret:
|
||||
body, sh = m.viewSecret(), m.keys.SecretHelp()
|
||||
case scrEditor:
|
||||
body, sh = m.viewEditor(), m.keys.EditorHelp()
|
||||
case scrVersions:
|
||||
body, sh = m.viewVersions(), m.keys.VersionsHelp()
|
||||
}
|
||||
|
||||
content := lipgloss.JoinVertical(lipgloss.Left, m.viewHeader(), fit(body, m.lay.w, m.lay.bodyH), m.viewFooter(sh))
|
||||
|
||||
if m.confirm != nil {
|
||||
content = m.overlay(content, m.viewConfirm())
|
||||
}
|
||||
if m.profile.open {
|
||||
content = m.overlay(content, m.viewProfilePicker())
|
||||
}
|
||||
if m.helpOpen {
|
||||
content = m.overlay(content, m.viewHelp())
|
||||
}
|
||||
|
||||
v := tea.NewView(content)
|
||||
v.AltScreen = true
|
||||
v.WindowTitle = "vault-tui — " + m.a.Settings.Profile
|
||||
return v
|
||||
}
|
||||
|
||||
func (m *Model) refreshTokenInfo() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
info, err := token.Lookup(m.ctx, m.a.Client)
|
||||
return tokenInfoMsg{info: info, err: err}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/auth"
|
||||
"git.morlana.online/f.weber/vault-tui/internal/token"
|
||||
"git.morlana.online/f.weber/vault-tui/internal/vault"
|
||||
)
|
||||
|
||||
// Every Vault/auth call the TUI makes is issued as a tea.Cmd and reported
|
||||
// back as one of these typed messages — Update never blocks on I/O itself.
|
||||
|
||||
type mountsMsg struct {
|
||||
mounts []vault.Mount
|
||||
err error
|
||||
}
|
||||
|
||||
type listMsg struct {
|
||||
listing vault.Listing
|
||||
mount vault.Mount
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
type secretMsg struct {
|
||||
secret *vault.Secret
|
||||
mount vault.Mount
|
||||
path string
|
||||
err error
|
||||
}
|
||||
|
||||
type versionsMsg struct {
|
||||
versions []vault.VersionMeta
|
||||
err error
|
||||
}
|
||||
|
||||
type writeAckMsg struct {
|
||||
ack *vault.WriteAck
|
||||
err error
|
||||
}
|
||||
|
||||
type deleteAckMsg struct {
|
||||
ack *vault.DeleteAck
|
||||
err error
|
||||
}
|
||||
|
||||
type rollbackAckMsg struct {
|
||||
ack *vault.WriteAck
|
||||
err error
|
||||
}
|
||||
|
||||
type undeleteAckMsg struct {
|
||||
ack *vault.DeleteAck
|
||||
err error
|
||||
}
|
||||
|
||||
type profileSwitchMsg struct {
|
||||
profile string
|
||||
err error
|
||||
}
|
||||
|
||||
type logoutDoneMsg struct{ err error }
|
||||
|
||||
// clipboardClearMsg fires Settings.ClipboardClear after a copy, so a
|
||||
// secret value doesn't linger in the system clipboard indefinitely.
|
||||
type clipboardClearMsg struct{ seq int }
|
||||
|
||||
type tokenInfoMsg struct {
|
||||
info *token.Info
|
||||
err error
|
||||
}
|
||||
|
||||
// loginEventMsg/loginDoneMsg drive the OIDC/Okta progress pump: Login runs
|
||||
// in a goroutine and reports each auth.Event plus a final result, exactly
|
||||
// mirroring the channel-pump pattern used by internal/cli's drainEvents.
|
||||
type loginEventMsg auth.Event
|
||||
|
||||
type loginDoneMsg struct {
|
||||
result *auth.Result
|
||||
err error
|
||||
storeWarn string // non-empty if login succeeded but token persistence failed
|
||||
}
|
||||
|
||||
type errMsg struct{ err error }
|
||||
|
||||
type statusMsg struct{ text string }
|
||||
|
||||
// tickMsg drives the token-TTL countdown in the status bar.
|
||||
type tickMsg time.Time
|
||||
@@ -0,0 +1,43 @@
|
||||
package ui
|
||||
|
||||
import "charm.land/lipgloss/v2"
|
||||
|
||||
// overlay composites box on top of base, centered over the current
|
||||
// terminal size, using lipgloss/v2's real layer compositor. This is what
|
||||
// makes confirm dialogs, the help screen, and the profile picker float
|
||||
// over the current screen instead of being appended below it.
|
||||
func (m *Model) overlay(base, box string) string {
|
||||
bw, bh := lipgloss.Size(box)
|
||||
x := (m.lay.w - bw) / 2
|
||||
y := (m.lay.h - bh) / 2
|
||||
if x < 0 {
|
||||
x = 0
|
||||
}
|
||||
if y < 0 {
|
||||
y = 0
|
||||
}
|
||||
return lipgloss.NewCompositor(
|
||||
lipgloss.NewLayer(base),
|
||||
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
|
||||
).Render()
|
||||
}
|
||||
|
||||
const (
|
||||
modalMaxContentWidth = 64
|
||||
modalFrameWidth = 10 // border(2) + padding(4, from Padding(1,2)) + a little breathing room
|
||||
modalMinContentWidth = 24
|
||||
)
|
||||
|
||||
// modalWrapWidth is the text-wrap width a screen should use before handing
|
||||
// content to styles.Modal/ModalDanger — keeps the box from ever exceeding
|
||||
// the terminal or the design's max modal width.
|
||||
func (m *Model) modalWrapWidth() int {
|
||||
w := m.lay.w - modalFrameWidth
|
||||
if w > modalMaxContentWidth {
|
||||
w = modalMaxContentWidth
|
||||
}
|
||||
if w < modalMinContentWidth {
|
||||
w = modalMinContentWidth
|
||||
}
|
||||
return w
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Package ui is vault-tui's Bubbletea v2 terminal UI. It talks to Vault
|
||||
// exclusively through internal/vault.Service and internal/auth.Method —
|
||||
// never hashicorp/vault/api directly — which is what keeps this package
|
||||
// swappable/testable independently of the headless CLI in internal/cli
|
||||
// (which builds the very same Service/Method values from the same
|
||||
// resolved config.Settings).
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/cli"
|
||||
)
|
||||
|
||||
// Run launches the TUI. Wired into internal/cli's "ui" command (and its
|
||||
// DefaultCommand) via cli.SetUIRunner in cmd/vault-tui/main.go, so that
|
||||
// internal/cli itself never has to import a TUI toolkit.
|
||||
func Run(ctx context.Context, a *cli.App) error {
|
||||
m, err := newModel(ctx, a)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := tea.NewProgram(m, tea.WithContext(ctx))
|
||||
_, err = p.Run()
|
||||
return err
|
||||
}
|
||||
|
||||
func init() {
|
||||
cli.SetUIRunner(func(ctx context.Context, a *cli.App) error {
|
||||
if err := Run(ctx, a); err != nil {
|
||||
return fmt.Errorf("ui: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,394 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/auth"
|
||||
)
|
||||
|
||||
// startAuth (re)enters the auth screen: build the method list (prioritising
|
||||
// the profile's configured default, if any) and reset to the picker phase.
|
||||
// Called from Init and again whenever a token turns out to be missing or
|
||||
// invalid (see tokenInfoMsg handling and ensureLoggedIn's own error path).
|
||||
func (m *Model) startAuth() tea.Cmd {
|
||||
methods := auth.Default().All()
|
||||
cursor := 0
|
||||
for i, meth := range methods {
|
||||
if meth.Name() == m.a.Settings.Auth.Method {
|
||||
cursor = i
|
||||
}
|
||||
}
|
||||
m.auth = authState{phase: 0, methods: methods, cursor: cursor}
|
||||
m.stack = nil
|
||||
|
||||
return m.tryExistingToken()
|
||||
}
|
||||
|
||||
// tryExistingToken attempts the flag/env/store token resolution the
|
||||
// headless CLI also uses (internal/cli.App.EnsureLoggedIn) before falling
|
||||
// back to an interactive login — this is what makes VAULT_TOKEN and a
|
||||
// pre-existing `vault login` session "just work" without visiting the auth
|
||||
// screen at all.
|
||||
func (m *Model) tryExistingToken() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
_, err := m.a.EnsureLoggedIn(m.ctx, "")
|
||||
if err != nil {
|
||||
return statusMsg{text: "no saved token — pick an auth method"}
|
||||
}
|
||||
return loginDoneMsg{result: &auth.Result{Token: m.a.Client.Token()}}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) updateAuth(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case loginDoneMsg:
|
||||
if msg.err != nil {
|
||||
m.auth.err = msg.err
|
||||
m.auth.phase = 0
|
||||
return m, nil
|
||||
}
|
||||
if msg.result != nil && msg.result.Token != "" && m.a.Client.Token() == "" {
|
||||
m.a.Client.SetToken(msg.result.Token)
|
||||
}
|
||||
m.push(frame{scr: scrMounts})
|
||||
cmd := m.loadMounts()
|
||||
if msg.storeWarn != "" {
|
||||
cmd = tea.Batch(cmd, m.notify(toastWarn, "logged in, but could not save token: %s", msg.storeWarn))
|
||||
}
|
||||
return m, cmd
|
||||
|
||||
case loginEventMsg:
|
||||
switch msg.Kind {
|
||||
case auth.EventOpenURL:
|
||||
m.auth.authURL = msg.URL
|
||||
default:
|
||||
m.auth.status = append(m.auth.status, msg.Message)
|
||||
}
|
||||
return m, waitForAuthEvent(m.auth.events)
|
||||
}
|
||||
|
||||
switch m.auth.phase {
|
||||
case 0:
|
||||
if km, ok := msg.(tea.KeyPressMsg); ok {
|
||||
return m.updateAuthPicker(km)
|
||||
}
|
||||
case 1:
|
||||
return m.updateAuthForm(msg)
|
||||
case 2:
|
||||
if km, ok := msg.(tea.KeyPressMsg); ok {
|
||||
return m.updateAuthWaiting(km)
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) updateAuthPicker(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
if m.auth.cursor < len(m.auth.methods)-1 {
|
||||
m.auth.cursor++
|
||||
}
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
if m.auth.cursor > 0 {
|
||||
m.auth.cursor--
|
||||
}
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
return m.chooseAuthMethod(m.auth.methods[m.auth.cursor])
|
||||
case key.Matches(msg, m.keys.Quit), key.Matches(msg, m.keys.ForceQuit):
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) chooseAuthMethod(meth auth.Method) (tea.Model, tea.Cmd) {
|
||||
m.auth.method = meth
|
||||
m.auth.mount = m.a.Settings.Auth.Mount
|
||||
if m.auth.mount == "" || m.a.Settings.Auth.Method != meth.Name() {
|
||||
m.auth.mount = meth.DefaultMount()
|
||||
}
|
||||
|
||||
creds := auth.Prefill(meth, auth.PrefillSource{ConfigArgs: m.a.Settings.Auth.Params})
|
||||
if len(auth.Missing(meth, creds)) == 0 {
|
||||
return m.beginLogin(meth, creds)
|
||||
}
|
||||
|
||||
m.auth.inputs = nil
|
||||
for _, f := range meth.Fields() {
|
||||
ti := m.newTextInput(fieldSpec{label: f.Label, secret: f.Kind == auth.FieldSecret, value: creds.Get(f.Name)})
|
||||
m.auth.inputs = append(m.auth.inputs, textInputField{field: f, input: ti})
|
||||
}
|
||||
m.auth.focus = 0
|
||||
var cmd tea.Cmd
|
||||
if len(m.auth.inputs) > 0 {
|
||||
cmd = m.auth.inputs[0].input.Focus()
|
||||
}
|
||||
m.auth.phase = 1
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// updateAuthForm handles the credential-form phase. It only special-cases
|
||||
// key presses that are form navigation (esc/tab/enter); everything else —
|
||||
// including non-key messages like a textinput's own cursor-blink tick — is
|
||||
// forwarded straight to the focused field's Update.
|
||||
func (m *Model) updateAuthForm(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
if km, ok := msg.(tea.KeyPressMsg); ok {
|
||||
switch km.String() {
|
||||
case "esc":
|
||||
m.auth.phase = 0
|
||||
return m, nil
|
||||
case "tab", "down":
|
||||
return m, m.moveAuthFocus(1)
|
||||
case "shift+tab", "up":
|
||||
return m, m.moveAuthFocus(-1)
|
||||
case "enter":
|
||||
if m.auth.focus < len(m.auth.inputs)-1 {
|
||||
return m, m.moveAuthFocus(1)
|
||||
}
|
||||
creds := auth.Credentials{}
|
||||
for _, f := range m.auth.inputs {
|
||||
creds[f.field.Name] = f.input.Value()
|
||||
}
|
||||
if err := auth.Validate(m.auth.method, creds); err != nil {
|
||||
m.auth.err = err
|
||||
return m, nil
|
||||
}
|
||||
return m.beginLogin(m.auth.method, creds)
|
||||
}
|
||||
}
|
||||
if len(m.auth.inputs) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
var cmd tea.Cmd
|
||||
m.auth.inputs[m.auth.focus].input, cmd = m.auth.inputs[m.auth.focus].input.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) moveAuthFocus(delta int) tea.Cmd {
|
||||
if len(m.auth.inputs) == 0 {
|
||||
return nil
|
||||
}
|
||||
m.auth.inputs[m.auth.focus].input.Blur()
|
||||
m.auth.focus = (m.auth.focus + delta + len(m.auth.inputs)) % len(m.auth.inputs)
|
||||
return m.auth.inputs[m.auth.focus].input.Focus()
|
||||
}
|
||||
|
||||
func (m *Model) beginLogin(meth auth.Method, creds auth.Credentials) (tea.Model, tea.Cmd) {
|
||||
m.auth.phase = 2
|
||||
m.auth.err = nil
|
||||
m.auth.status = nil
|
||||
m.auth.authURL = ""
|
||||
|
||||
ctx, cancel := context.WithCancel(m.ctx)
|
||||
m.auth.cancel = cancel
|
||||
events := make(chan auth.Event, 16)
|
||||
m.auth.events = events
|
||||
|
||||
req := auth.Request{Mount: m.auth.mount, Namespace: m.a.Settings.Namespace, Creds: creds, Events: events}
|
||||
run := func() tea.Msg {
|
||||
defer close(events)
|
||||
sec, err := meth.Login(ctx, m.a.Client, req)
|
||||
if err != nil {
|
||||
return loginDoneMsg{err: err}
|
||||
}
|
||||
res, err := auth.NewResult(sec, m.a.Settings.Namespace)
|
||||
if err != nil {
|
||||
return loginDoneMsg{err: err}
|
||||
}
|
||||
if serr := m.a.Store.Store(ctx, res.Token); serr != nil {
|
||||
return loginDoneMsg{result: res, storeWarn: serr.Error()}
|
||||
}
|
||||
return loginDoneMsg{result: res}
|
||||
}
|
||||
return m, tea.Batch(run, waitForAuthEvent(events))
|
||||
}
|
||||
|
||||
func waitForAuthEvent(events <-chan auth.Event) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
e, ok := <-events
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return loginEventMsg(e)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) updateAuthWaiting(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
if m.auth.cancel != nil {
|
||||
m.auth.cancel()
|
||||
}
|
||||
m.auth.phase = 0
|
||||
return m, nil
|
||||
case "y":
|
||||
return m, copyToClipboard(m.auth.authURL)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func copyToClipboard(s string) tea.Cmd {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return tea.SetClipboard(s)
|
||||
}
|
||||
|
||||
// authCardWidth is the inner content width every auth-phase card wraps
|
||||
// text to, capped so the card never dominates a very wide terminal.
|
||||
func (m *Model) authCardWidth() int {
|
||||
w := m.lay.bodyW - 12
|
||||
if w > 56 {
|
||||
w = 56
|
||||
}
|
||||
if w < 24 {
|
||||
w = 24
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// authCard centers content (already wrapped to authCardWidth) in a bordered
|
||||
// card over the auth screen's body area — the "modern app" look asked for,
|
||||
// replacing the old flush-left, unbounded-width panel.
|
||||
func (m *Model) authCard(content string) string {
|
||||
box := m.styles.PanelActive.Render(content)
|
||||
return lipgloss.Place(m.lay.bodyW, m.lay.bodyH, lipgloss.Center, lipgloss.Center, box,
|
||||
lipgloss.WithWhitespaceStyle(lipgloss.NewStyle().Background(m.styles.Bg)))
|
||||
}
|
||||
|
||||
func (m *Model) viewAuth() string {
|
||||
switch m.auth.phase {
|
||||
case 1:
|
||||
return m.viewAuthForm()
|
||||
case 2:
|
||||
return m.viewAuthWaiting()
|
||||
default:
|
||||
return m.viewAuthPicker()
|
||||
}
|
||||
}
|
||||
|
||||
// methodDescription returns a one-line hint for a method: its
|
||||
// auth.Describable text if implemented, else a small built-in fallback so
|
||||
// the picker never shows a bare name with nothing else to go on.
|
||||
func methodDescription(meth auth.Method) string {
|
||||
if d, ok := meth.(auth.Describable); ok {
|
||||
return d.Description()
|
||||
}
|
||||
switch meth.Name() {
|
||||
case "token":
|
||||
return "use an existing Vault token"
|
||||
case "oidc":
|
||||
return "sign in via your browser"
|
||||
case "userpass":
|
||||
return "username and password"
|
||||
case "ldap":
|
||||
return "LDAP directory credentials"
|
||||
case "okta":
|
||||
return "Okta username/password, with MFA"
|
||||
case "radius":
|
||||
return "RADIUS username/password"
|
||||
case "approle":
|
||||
return "role ID and secret ID"
|
||||
case "github":
|
||||
return "a GitHub personal access token"
|
||||
case "jwt":
|
||||
return "a role and a signed JWT"
|
||||
case "kubernetes":
|
||||
return "the pod's projected service-account token"
|
||||
case "cert":
|
||||
return "a TLS client certificate"
|
||||
case "aws", "azure", "gcp":
|
||||
return "cloud-native machine identity"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) viewAuthPicker() string {
|
||||
w := m.authCardWidth()
|
||||
lines := []string{m.styles.ModalTitle.Render("Sign in to Vault"), ""}
|
||||
for i, meth := range m.auth.methods {
|
||||
name := meth.DisplayName()
|
||||
desc := methodDescription(meth)
|
||||
if i == m.auth.cursor {
|
||||
lines = append(lines, m.styles.RowSelected.Render(padRight(symChevronR+" "+name, w)))
|
||||
} else {
|
||||
line := " " + name
|
||||
if desc != "" {
|
||||
line = padRight(line, 18) + m.styles.PanelSubtle.Render(desc)
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
}
|
||||
|
||||
if unavail := auth.Default().UnavailableAll(); len(unavail) > 0 {
|
||||
names := make([]string, 0, len(unavail))
|
||||
for n := range unavail {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
lines = append(lines, "")
|
||||
for _, n := range names {
|
||||
lines = append(lines, m.styles.RowDim.Render(fmt.Sprintf(" %s — %s", n, unavail[n])))
|
||||
}
|
||||
}
|
||||
|
||||
if m.auth.err != nil {
|
||||
lines = append(lines, "", m.styles.ErrorText.Render(lipgloss.Wrap(m.auth.err.Error(), w, "")))
|
||||
}
|
||||
lines = append(lines, "", m.styles.Help.Render("↑/↓ move • enter select • q quit"))
|
||||
return m.authCard(lipgloss.JoinVertical(lipgloss.Left, lines...))
|
||||
}
|
||||
|
||||
func (m *Model) viewAuthForm() string {
|
||||
w := m.authCardWidth()
|
||||
lines := []string{m.styles.ModalTitle.Render(m.auth.method.DisplayName()), ""}
|
||||
for i, f := range m.auth.inputs {
|
||||
label := f.field.Label
|
||||
if f.field.Required {
|
||||
label += " *"
|
||||
}
|
||||
if i == m.auth.focus {
|
||||
label = m.styles.CrumbActive.Render(symChevronR + " " + label)
|
||||
} else {
|
||||
label = " " + label
|
||||
}
|
||||
f.input.SetWidth(w - 2)
|
||||
lines = append(lines, label, " "+f.input.View())
|
||||
if f.field.Help != "" {
|
||||
lines = append(lines, m.styles.EmptyState.Render(" "+f.field.Help))
|
||||
}
|
||||
lines = append(lines, "")
|
||||
}
|
||||
if m.auth.err != nil {
|
||||
lines = append(lines, m.styles.ErrorText.Render(lipgloss.Wrap(m.auth.err.Error(), w, "")), "")
|
||||
}
|
||||
lines = append(lines, m.styles.Help.Render("tab/shift+tab move • enter next/submit • esc back"))
|
||||
return m.authCard(lipgloss.JoinVertical(lipgloss.Left, lines...))
|
||||
}
|
||||
|
||||
func (m *Model) viewAuthWaiting() string {
|
||||
w := m.authCardWidth()
|
||||
lines := []string{m.styles.ModalTitle.Render("Signing in via " + m.auth.method.DisplayName()), ""}
|
||||
if m.auth.authURL != "" {
|
||||
lines = append(lines,
|
||||
"Open this URL if your browser didn't launch automatically:", "",
|
||||
m.styles.InputFocused.Render(lipgloss.Wrap(m.auth.authURL, w, "")), "",
|
||||
m.styles.Help.Render("y copy URL • esc cancel"))
|
||||
} else {
|
||||
lines = append(lines, m.spin.View()+" waiting…")
|
||||
}
|
||||
for _, s := range m.auth.status {
|
||||
lines = append(lines, m.styles.PanelSubtle.Render(lipgloss.Wrap(s, w, "")))
|
||||
}
|
||||
if m.auth.err != nil {
|
||||
lines = append(lines, "", m.styles.ErrorText.Render(lipgloss.Wrap(m.auth.err.Error(), w, "")))
|
||||
}
|
||||
return m.authCard(lipgloss.JoinVertical(lipgloss.Left, lines...))
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/vault"
|
||||
)
|
||||
|
||||
// --- editor: create/update a KV secret -----------------------------------
|
||||
|
||||
func (m *Model) newSecret() (tea.Model, tea.Cmd) {
|
||||
f := m.currentFrame()
|
||||
m.editMount, m.editPath, m.editIsNew, m.editSecret = f.mount, f.path, true, nil
|
||||
m.editName = m.newTextInput(fieldSpec{label: "secret name"})
|
||||
cmd := m.editName.Focus()
|
||||
m.editKeys = []textInput{m.newTextInput(fieldSpec{label: "key"})}
|
||||
m.editVals = []textInput{m.newTextInput(fieldSpec{label: "value"})}
|
||||
m.editFocus = -1 // -1 == the name field
|
||||
m.editSnap = m.editSnapshot()
|
||||
m.push(frame{scr: scrEditor, mount: f.mount, path: f.path})
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) editSelected() (tea.Model, tea.Cmd) {
|
||||
if m.secret == nil {
|
||||
return m, nil
|
||||
}
|
||||
f := m.currentFrame()
|
||||
m.editMount, m.editPath, m.editIsNew, m.editSecret = f.mount, f.path, false, m.secret
|
||||
m.editKeys, m.editVals = nil, nil
|
||||
for _, k := range sortedKeys(m.secret.Data) {
|
||||
m.editKeys = append(m.editKeys, m.textInputWithValue("key", k))
|
||||
m.editVals = append(m.editVals, m.textInputWithValue("value", fmt.Sprint(m.secret.Data[k])))
|
||||
}
|
||||
if len(m.editKeys) == 0 {
|
||||
m.editKeys = []textInput{m.newTextInput(fieldSpec{label: "key"})}
|
||||
m.editVals = []textInput{m.newTextInput(fieldSpec{label: "value"})}
|
||||
}
|
||||
m.editFocus = 0
|
||||
cmd := m.editKeys[0].Focus()
|
||||
m.editSnap = m.editSnapshot()
|
||||
m.push(frame{scr: scrEditor, mount: f.mount, path: f.path})
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
// editSnapshot serializes the editor's current field values so esc can
|
||||
// tell whether anything actually changed before asking to discard.
|
||||
func (m *Model) editSnapshot() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(m.editName.Value())
|
||||
for i := range m.editKeys {
|
||||
b.WriteByte(0)
|
||||
b.WriteString(m.editKeys[i].Value())
|
||||
b.WriteByte('=')
|
||||
b.WriteString(m.editVals[i].Value())
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func (m *Model) updateEditor(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case writeAckMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return errMsg{err: msg.err} }
|
||||
}
|
||||
toastCmd := m.notify(toastSuccess, "saved (version %d)", msg.ack.Version)
|
||||
savedPath := msg.ack.Path
|
||||
m.pop()
|
||||
if m.top() == scrSecret {
|
||||
return m, tea.Batch(toastCmd, m.loadSecret(m.editMount, savedPath, 0))
|
||||
}
|
||||
return m, tea.Batch(toastCmd, m.reloadTop())
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch msg.String() {
|
||||
case "esc":
|
||||
if m.editSnapshot() != m.editSnap {
|
||||
m.confirm = &confirmSpec{
|
||||
title: "Discard changes?",
|
||||
body: "Unsaved edits to this secret will be lost.",
|
||||
danger: true,
|
||||
onConfirm: func() tea.Cmd {
|
||||
m.pop()
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
m.pop()
|
||||
return m, nil
|
||||
case "ctrl+a":
|
||||
m.editKeys = append(m.editKeys, m.newTextInput(fieldSpec{label: "key"}))
|
||||
m.editVals = append(m.editVals, m.newTextInput(fieldSpec{label: "value"}))
|
||||
return m, nil
|
||||
case "ctrl+x":
|
||||
return m, m.removeCurrentField()
|
||||
case "ctrl+s":
|
||||
return m.saveEditor()
|
||||
case "tab":
|
||||
return m, m.moveEditFocus(1)
|
||||
case "shift+tab":
|
||||
return m, m.moveEditFocus(-1)
|
||||
}
|
||||
return m.updateEditorInput(msg)
|
||||
}
|
||||
return m.updateEditorInput(msg)
|
||||
}
|
||||
|
||||
// focus encoding: -1 = name field (new-secret only), 2*i = key[i], 2*i+1 = val[i]
|
||||
func (m *Model) moveEditFocus(delta int) tea.Cmd {
|
||||
maxIdx := len(m.editKeys)*2 - 1
|
||||
m.blurEdit()
|
||||
if m.editIsNew {
|
||||
m.editFocus += delta
|
||||
if m.editFocus < -1 {
|
||||
m.editFocus = maxIdx
|
||||
} else if m.editFocus > maxIdx {
|
||||
m.editFocus = -1
|
||||
}
|
||||
} else {
|
||||
if maxIdx < 0 {
|
||||
return nil
|
||||
}
|
||||
m.editFocus = (m.editFocus + delta + maxIdx + 1) % (maxIdx + 1)
|
||||
}
|
||||
return m.focusEdit()
|
||||
}
|
||||
|
||||
func (m *Model) removeCurrentField() tea.Cmd {
|
||||
if m.editFocus < 0 || len(m.editKeys) == 0 {
|
||||
return nil
|
||||
}
|
||||
i := m.editFocus / 2
|
||||
if len(m.editKeys) <= 1 {
|
||||
m.editKeys[0] = m.newTextInput(fieldSpec{label: "key"})
|
||||
m.editVals[0] = m.newTextInput(fieldSpec{label: "value"})
|
||||
m.editFocus = 0
|
||||
return m.editKeys[0].Focus()
|
||||
}
|
||||
m.editKeys = append(m.editKeys[:i], m.editKeys[i+1:]...)
|
||||
m.editVals = append(m.editVals[:i], m.editVals[i+1:]...)
|
||||
if m.editFocus >= len(m.editKeys)*2 {
|
||||
m.editFocus = len(m.editKeys)*2 - 1
|
||||
}
|
||||
return m.focusEdit()
|
||||
}
|
||||
|
||||
func (m *Model) blurEdit() {
|
||||
if m.editFocus == -1 {
|
||||
m.editName.Blur()
|
||||
return
|
||||
}
|
||||
i, isKey := m.editFocus/2, m.editFocus%2 == 0
|
||||
if i >= len(m.editKeys) {
|
||||
return
|
||||
}
|
||||
if isKey {
|
||||
m.editKeys[i].Blur()
|
||||
} else {
|
||||
m.editVals[i].Blur()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) focusEdit() tea.Cmd {
|
||||
if m.editFocus == -1 {
|
||||
return m.editName.Focus()
|
||||
}
|
||||
i, isKey := m.editFocus/2, m.editFocus%2 == 0
|
||||
if i >= len(m.editKeys) {
|
||||
return nil
|
||||
}
|
||||
if isKey {
|
||||
return m.editKeys[i].Focus()
|
||||
}
|
||||
return m.editVals[i].Focus()
|
||||
}
|
||||
|
||||
// updateEditorInput forwards msg to whichever field is focused — every
|
||||
// message type, not just key presses, so a textinput's own async commands
|
||||
// (cursor blink, paste) actually reach it instead of being dropped by the
|
||||
// screen dispatch above.
|
||||
func (m *Model) updateEditorInput(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
if m.editFocus == -1 {
|
||||
m.editName, cmd = m.editName.Update(msg)
|
||||
return m, cmd
|
||||
}
|
||||
if len(m.editKeys) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
i, isKey := m.editFocus/2, m.editFocus%2 == 0
|
||||
if i >= len(m.editKeys) {
|
||||
return m, nil
|
||||
}
|
||||
if isKey {
|
||||
m.editKeys[i], cmd = m.editKeys[i].Update(msg)
|
||||
} else {
|
||||
m.editVals[i], cmd = m.editVals[i].Update(msg)
|
||||
}
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m *Model) saveEditor() (tea.Model, tea.Cmd) {
|
||||
data := map[string]interface{}{}
|
||||
for i := range m.editKeys {
|
||||
k := m.editKeys[i].Value()
|
||||
if k == "" {
|
||||
continue
|
||||
}
|
||||
data[k] = m.editVals[i].Value()
|
||||
}
|
||||
path := m.editPath
|
||||
if m.editIsNew {
|
||||
name := m.editName.Value()
|
||||
if name == "" {
|
||||
return m, m.notify(toastWarn, "secret name is required")
|
||||
}
|
||||
path = m.editPath + name
|
||||
}
|
||||
|
||||
useCAS := m.a.Settings.RequireCAS && m.editMount.Kind == vault.EngineKVv2
|
||||
cas := 0
|
||||
if m.editSecret != nil {
|
||||
cas = m.editSecret.Version
|
||||
}
|
||||
|
||||
m.loading = true
|
||||
mount := m.editMount
|
||||
// Deliberately NOT mutating m.editPath here: on a failed write (CAS
|
||||
// mismatch, permission denied, ...) the editor screen stays open so the
|
||||
// user can retry, and a premature mutation would double-concatenate the
|
||||
// name on a second "new secret" save attempt. writeAckMsg's handler uses
|
||||
// ack.Path — the path Write actually succeeded at — instead.
|
||||
return m, func() tea.Msg {
|
||||
ack, err := m.svc.KV.Write(m.ctx, mount, path, data, useCAS, cas)
|
||||
return writeAckMsg{ack: ack, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
// editFieldWidths splits the panel's inner width into key/value columns —
|
||||
// side by side from sizeCompact up, stacked on sizeTiny.
|
||||
func (m *Model) editFieldWidths(iw int) (keyColW, valColW int) {
|
||||
if m.lay.class == sizeTiny {
|
||||
return iw, iw
|
||||
}
|
||||
keyColW = iw / 3
|
||||
if keyColW < 16 {
|
||||
keyColW = 16
|
||||
}
|
||||
valColW = iw - keyColW - 2
|
||||
if valColW < 12 {
|
||||
valColW = 12
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (m *Model) viewEditor() string {
|
||||
ow, oh := m.lay.bodyW, m.lay.bodyH
|
||||
iw, _ := panelInner(ow, oh)
|
||||
tiny := m.lay.class == sizeTiny
|
||||
keyColW, valColW := m.editFieldWidths(iw)
|
||||
|
||||
rows := []string{m.styles.PanelTitle.Render("Edit " + m.editMount.Path + m.editPath), ""}
|
||||
|
||||
if m.editIsNew {
|
||||
label := " name"
|
||||
if m.editFocus == -1 {
|
||||
label = m.styles.CrumbActive.Render(symChevronR + " name")
|
||||
}
|
||||
m.editName.SetWidth(max(iw-2, 8))
|
||||
rows = append(rows, label, " "+m.editName.View(), "")
|
||||
}
|
||||
|
||||
const lbl = 8 // widest label ("▸ value") is 7 cells; +1 keeps a gap before the input's own prompt
|
||||
for i := range m.editKeys {
|
||||
kFocused, vFocused := m.editFocus == 2*i, m.editFocus == 2*i+1
|
||||
kLabel, vLabel := padRight("key", lbl), padRight("value", lbl)
|
||||
if kFocused {
|
||||
kLabel = m.styles.CrumbActive.Render(padRight(symChevronR+" key", lbl))
|
||||
}
|
||||
if vFocused {
|
||||
vLabel = m.styles.CrumbActive.Render(padRight(symChevronR+" value", lbl))
|
||||
}
|
||||
m.editKeys[i].SetWidth(max(keyColW-lbl-1, 6))
|
||||
m.editVals[i].SetWidth(max(valColW-lbl-1, 6))
|
||||
|
||||
keyCell := kLabel + m.editKeys[i].View()
|
||||
valCell := vLabel + m.editVals[i].View()
|
||||
if tiny {
|
||||
rows = append(rows, keyCell, valCell, "")
|
||||
} else {
|
||||
rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, padRight(keyCell, keyColW+lbl+2), valCell))
|
||||
}
|
||||
}
|
||||
if !tiny {
|
||||
rows = append(rows, "")
|
||||
}
|
||||
|
||||
return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, rows...))
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// updateHelp handles the "?" full-keybinding overlay — any key closes it,
|
||||
// since it's purely informational.
|
||||
func (m *Model) updateHelp(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
m.helpOpen = false
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) viewHelp() string {
|
||||
w := m.lay.w - 12
|
||||
if w > 72 {
|
||||
w = 72
|
||||
}
|
||||
if w < 30 {
|
||||
w = 30
|
||||
}
|
||||
m.help.SetWidth(w)
|
||||
body := m.help.FullHelpView(m.keys.FullHelp())
|
||||
lines := []string{m.styles.ModalTitle.Render("Keybindings"), "", body, "", m.styles.Help.Render("press any key to close")}
|
||||
return m.styles.Modal.Render(lipgloss.JoinVertical(lipgloss.Left, lines...))
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"sort"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/ui/keys"
|
||||
)
|
||||
|
||||
// textInputActive reports whether a free-text field currently owns the
|
||||
// keyboard, so global single-letter bindings (help "?", profiles "p", ...)
|
||||
// don't get eaten while the user is typing a value/filter that happens to
|
||||
// contain that letter.
|
||||
func (m *Model) textInputActive() bool {
|
||||
if m.top() == scrEditor {
|
||||
return true
|
||||
}
|
||||
if m.top() == scrAuth && m.auth.phase == 1 {
|
||||
return true
|
||||
}
|
||||
if (m.top() == scrMounts || m.top() == scrBrowser) && m.listReady && m.list.SettingFilter() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Model) openProfilePicker() (tea.Model, tea.Cmd) {
|
||||
names := make([]string, 0, len(m.a.File.Profiles))
|
||||
for n := range m.a.File.Profiles {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
cursor := 0
|
||||
for i, n := range names {
|
||||
if n == m.a.Settings.Profile {
|
||||
cursor = i
|
||||
}
|
||||
}
|
||||
m.profile = profileState{open: true, names: names, cursor: cursor}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) updateProfilePicker(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
if m.profile.cursor > 0 {
|
||||
m.profile.cursor--
|
||||
}
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
if m.profile.cursor < len(m.profile.names)-1 {
|
||||
m.profile.cursor++
|
||||
}
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
return m.switchToSelectedProfile()
|
||||
case key.Matches(msg, m.keys.Back), key.Matches(msg, m.keys.Quit):
|
||||
m.profile.open = false
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) switchToSelectedProfile() (tea.Model, tea.Cmd) {
|
||||
name := m.profile.names[m.profile.cursor]
|
||||
m.profile.open = false
|
||||
if name == m.a.Settings.Profile {
|
||||
return m, nil
|
||||
}
|
||||
if err := m.a.SwitchProfile(name); err != nil {
|
||||
return m, m.notify(toastErr, "switch profile: %v", err)
|
||||
}
|
||||
|
||||
m.svc = m.a.Service()
|
||||
km := keys.Default()
|
||||
_ = km.Apply(m.a.File.Keys) // already validated once at startup; config hasn't changed
|
||||
if m.a.Settings.ReadOnly {
|
||||
km.DisableWrites()
|
||||
}
|
||||
m.keys = km
|
||||
m.tokenInfo = nil
|
||||
m.listSelect = ""
|
||||
return m, m.startAuth()
|
||||
}
|
||||
|
||||
func (m *Model) confirmLogout() (tea.Model, tea.Cmd) {
|
||||
profile := m.a.Settings.Profile
|
||||
action := func() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
return logoutDoneMsg{err: m.a.Store.Erase(m.ctx)}
|
||||
}
|
||||
}
|
||||
m.confirm = &confirmSpec{
|
||||
title: "Log out?",
|
||||
body: "Forgets the saved token for profile " + profile + ". This does not revoke it in Vault.",
|
||||
onConfirm: action,
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) viewProfilePicker() string {
|
||||
lines := []string{m.styles.ModalTitle.Render("Switch profile"), ""}
|
||||
for i, n := range m.profile.names {
|
||||
marker := " "
|
||||
if n == m.a.Settings.Profile {
|
||||
marker = m.styles.SuccessText.Render(symCurrent) + " "
|
||||
}
|
||||
line := marker + n
|
||||
if i == m.profile.cursor {
|
||||
line = m.styles.RowSelected.Render(padRight(marker+n, 28))
|
||||
}
|
||||
lines = append(lines, line)
|
||||
}
|
||||
lines = append(lines, "", m.styles.Help.Render("↑/↓ move • enter switch • esc cancel"))
|
||||
return m.styles.Modal.Render(lipgloss.JoinVertical(lipgloss.Left, lines...))
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/vault"
|
||||
)
|
||||
|
||||
func (m *Model) loadSecret(mount vault.Mount, path string, version int) tea.Cmd {
|
||||
m.loading = true
|
||||
return func() tea.Msg {
|
||||
sec, err := m.svc.KV.Read(m.ctx, mount, path, version)
|
||||
return secretMsg{secret: sec, mount: mount, path: path, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func sortedKeys(data map[string]interface{}) []string {
|
||||
keys := make([]string, 0, len(data))
|
||||
for k := range data {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
func (m *Model) resizeSecretViewport() {
|
||||
if m.secret == nil {
|
||||
return
|
||||
}
|
||||
iw, ih := panelInner(m.lay.bodyW, m.lay.bodyH)
|
||||
ih -= 2 // title + metadata line
|
||||
if ih < 1 {
|
||||
ih = 1
|
||||
}
|
||||
m.secretVP.SetWidth(iw)
|
||||
m.secretVP.SetHeight(ih)
|
||||
}
|
||||
|
||||
func (m *Model) updateSecret(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case secretMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return errMsg{err: msg.err} }
|
||||
}
|
||||
if msg.secret == nil {
|
||||
m.pop()
|
||||
return m, m.notify(toastWarn, "%s%s: secret not found (already deleted?)", msg.mount.Path, msg.path)
|
||||
}
|
||||
m.secret = msg.secret
|
||||
m.secretKeys = sortedKeys(msg.secret.Data)
|
||||
m.secretMask = make(map[string]bool, len(m.secretKeys))
|
||||
for _, k := range m.secretKeys {
|
||||
m.secretMask[k] = !m.a.Settings.MaskValues
|
||||
}
|
||||
if m.secretCursor >= len(m.secretKeys) {
|
||||
m.secretCursor = 0
|
||||
}
|
||||
m.resizeSecretViewport()
|
||||
return m, nil
|
||||
|
||||
case deleteAckMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return errMsg{err: msg.err} }
|
||||
}
|
||||
m.secret = nil
|
||||
_, popCmd := m.popBrowser()
|
||||
return m, tea.Batch(m.notify(toastSuccess, "%v: %s", msg.ack.Op, msg.ack.Path), popCmd)
|
||||
|
||||
case clipboardClearMsg:
|
||||
if msg.seq == m.clipSeq {
|
||||
return m, tea.SetClipboard("")
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Back):
|
||||
m.secret = nil
|
||||
return m.popBrowser()
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
// Safe to bind bare "q" here (unlike the editor/auth-form
|
||||
// screens): the secret detail screen owns no text input.
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
if m.secretCursor > 0 {
|
||||
m.secretCursor--
|
||||
}
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
if m.secretCursor < len(m.secretKeys)-1 {
|
||||
m.secretCursor++
|
||||
}
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.ToggleMask):
|
||||
m.toggleRowMask()
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.ToggleMaskAll):
|
||||
m.toggleAllMask()
|
||||
return m, nil
|
||||
case key.Matches(msg, m.keys.Versions):
|
||||
return m.openVersions()
|
||||
case key.Matches(msg, m.keys.Edit):
|
||||
if !m.a.Settings.ReadOnly {
|
||||
return m.editSelected()
|
||||
}
|
||||
case key.Matches(msg, m.keys.Delete):
|
||||
if !m.a.Settings.ReadOnly {
|
||||
return m.confirmDelete(false)
|
||||
}
|
||||
case key.Matches(msg, m.keys.Destroy):
|
||||
if !m.a.Settings.ReadOnly {
|
||||
return m.confirmDelete(true)
|
||||
}
|
||||
case key.Matches(msg, m.keys.CopyValue):
|
||||
return m, m.copySelectedValue()
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) toggleRowMask() {
|
||||
if len(m.secretKeys) == 0 {
|
||||
return
|
||||
}
|
||||
k := m.secretKeys[m.secretCursor]
|
||||
m.secretMask[k] = !m.secretMask[k]
|
||||
}
|
||||
|
||||
func (m *Model) toggleAllMask() {
|
||||
any := false
|
||||
for _, k := range m.secretKeys {
|
||||
if m.secretMask[k] {
|
||||
any = true
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, k := range m.secretKeys {
|
||||
m.secretMask[k] = !any
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) copySelectedValue() tea.Cmd {
|
||||
if m.secret == nil || len(m.secretKeys) == 0 {
|
||||
return nil
|
||||
}
|
||||
k := m.secretKeys[m.secretCursor]
|
||||
v := fmt.Sprint(m.secret.Data[k])
|
||||
cmds := []tea.Cmd{tea.SetClipboard(v), m.notify(toastSuccess, "copied %s", k)}
|
||||
if d := m.a.Settings.ClipboardClear; d > 0 {
|
||||
m.clipSeq++
|
||||
seq := m.clipSeq
|
||||
cmds = append(cmds, tea.Tick(d, func(time.Time) tea.Msg { return clipboardClearMsg{seq: seq} }))
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
// confirmTypedName decides whether a destructive confirmation requires the
|
||||
// user to type the path back (irreversible actions) or just y/enter.
|
||||
func confirmTypedName(danger bool, path string) string {
|
||||
if danger {
|
||||
return path
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *Model) confirmDelete(destroy bool) (tea.Model, tea.Cmd) {
|
||||
f := m.currentFrame()
|
||||
mount, path := f.mount, f.path
|
||||
op := vault.OpSoftDelete
|
||||
danger := destroy
|
||||
title := "Delete " + path + "?"
|
||||
body := "This soft-deletes the current version. It can be undeleted."
|
||||
var versions []int // nil => current version, for the ops that support that shorthand
|
||||
if mount.Kind != vault.EngineKVv2 {
|
||||
op = vault.OpDeleteV1
|
||||
danger = true
|
||||
body = "This permanently deletes the secret. This cannot be undone."
|
||||
} else if destroy {
|
||||
op = vault.OpDestroy
|
||||
title = "Destroy " + path + "?"
|
||||
body = "This permanently destroys the current version's data. This cannot be undone."
|
||||
// Unlike soft-delete, Vault's destroy endpoint has no "current
|
||||
// version" shorthand — it always requires explicit version
|
||||
// numbers — so the version being viewed must be passed along.
|
||||
if m.secret != nil {
|
||||
versions = []int{m.secret.Version}
|
||||
}
|
||||
}
|
||||
|
||||
action := func() tea.Cmd {
|
||||
m.loading = true
|
||||
return func() tea.Msg {
|
||||
ack, err := m.svc.KV.Delete(m.ctx, mount, path, op, versions)
|
||||
return deleteAckMsg{ack: ack, err: err}
|
||||
}
|
||||
}
|
||||
if !m.a.Settings.ConfirmDestructive {
|
||||
return m, action()
|
||||
}
|
||||
m.confirm = &confirmSpec{
|
||||
title: title, body: body, danger: danger,
|
||||
typeToConf: confirmTypedName(danger, path),
|
||||
onConfirm: action,
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// secretMetaLine renders the version/status line shown between the title
|
||||
// and the key/value table.
|
||||
func (m *Model) secretMetaLine() string {
|
||||
if m.secret == nil || m.secret.Meta == nil {
|
||||
return ""
|
||||
}
|
||||
meta := m.secret.Meta
|
||||
parts := []string{fmt.Sprintf("version %d", m.secret.Version), "created " + humanTime(meta.CreatedTime)}
|
||||
if meta.Destroyed {
|
||||
parts = append(parts, m.styles.ErrorText.Render(symDestroyed+" destroyed"))
|
||||
} else if meta.DeletionTime != "" {
|
||||
parts = append(parts, m.styles.WarnText.Render(symDeleted+" deleted"))
|
||||
}
|
||||
return m.styles.PanelSubtle.Render(strings.Join(parts, " • "))
|
||||
}
|
||||
|
||||
// secretRows renders the key/value table fed to secretVP, one row per
|
||||
// sorted key with the selected row highlighted full-width.
|
||||
func (m *Model) secretRows(width int) string {
|
||||
if len(m.secretKeys) == 0 {
|
||||
return m.styles.EmptyState.Render("(no data)")
|
||||
}
|
||||
keyW := 0
|
||||
for _, k := range m.secretKeys {
|
||||
if w := lipgloss.Width(k); w > keyW {
|
||||
keyW = w
|
||||
}
|
||||
}
|
||||
if keyW > width/3 {
|
||||
keyW = width / 3
|
||||
}
|
||||
if keyW < 4 {
|
||||
keyW = 4
|
||||
}
|
||||
valW := width - keyW - 2
|
||||
if valW < 4 {
|
||||
valW = 4
|
||||
}
|
||||
|
||||
lines := make([]string, len(m.secretKeys))
|
||||
for i, k := range m.secretKeys {
|
||||
v := fmt.Sprint(m.secret.Data[k])
|
||||
if !m.secretMask[k] {
|
||||
v = maskValue(v, m.styles.MaskChar)
|
||||
}
|
||||
row := padRight(truncate(k, keyW), keyW) + " " + truncate(v, valW)
|
||||
if i == m.secretCursor {
|
||||
lines[i] = m.styles.RowSelected.Render(padRight(row, width))
|
||||
} else {
|
||||
lines[i] = m.styles.KeyCell.Render(padRight(truncate(k, keyW), keyW)) + " " + m.styles.ValueCell.Render(truncate(v, valW))
|
||||
}
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
func (m *Model) viewSecret() string {
|
||||
f := m.currentFrame()
|
||||
ow, oh := m.lay.bodyW, m.lay.bodyH
|
||||
title := m.styles.PanelTitle.Render(f.mount.Path + f.path)
|
||||
|
||||
if m.loading || m.secret == nil {
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, title, m.spin.View()+" loading…")
|
||||
return renderPanel(m.styles.PanelActive, ow, oh, body)
|
||||
}
|
||||
|
||||
iw, _ := panelInner(ow, oh)
|
||||
m.resizeSecretViewport()
|
||||
m.secretVP.SetContent(m.secretRows(iw))
|
||||
m.secretVP.EnsureVisible(m.secretCursor, 0, 0)
|
||||
|
||||
lines := []string{title}
|
||||
if meta := m.secretMetaLine(); meta != "" {
|
||||
lines = append(lines, meta)
|
||||
}
|
||||
lines = append(lines, m.secretVP.View())
|
||||
return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, lines...))
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/vault"
|
||||
)
|
||||
|
||||
func (m *Model) loadVersions(mount vault.Mount, path string) tea.Cmd {
|
||||
m.loading = true
|
||||
return func() tea.Msg {
|
||||
vs, err := m.svc.KV.Versions(m.ctx, mount, path)
|
||||
return versionsMsg{versions: vs, err: err}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Model) openVersions() (tea.Model, tea.Cmd) {
|
||||
f := m.currentFrame()
|
||||
if f.mount.Kind != vault.EngineKVv2 {
|
||||
return m, m.notify(toastWarn, "version history is only available for KV v2")
|
||||
}
|
||||
m.versionsCursor = 0
|
||||
m.push(frame{scr: scrVersions, mount: f.mount, path: f.path})
|
||||
return m, m.loadVersions(f.mount, f.path)
|
||||
}
|
||||
|
||||
func (m *Model) updateVersions(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case versionsMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return errMsg{err: msg.err} }
|
||||
}
|
||||
m.versions = msg.versions
|
||||
if m.versionsCursor >= len(m.versions) {
|
||||
m.versionsCursor = 0
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case rollbackAckMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return errMsg{err: msg.err} }
|
||||
}
|
||||
f := m.currentFrame()
|
||||
return m, tea.Batch(
|
||||
m.notify(toastSuccess, "rolled back — created version %d", msg.ack.Version),
|
||||
m.loadVersions(f.mount, f.path),
|
||||
)
|
||||
|
||||
case undeleteAckMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
return m, func() tea.Msg { return errMsg{err: msg.err} }
|
||||
}
|
||||
f := m.currentFrame()
|
||||
return m, tea.Batch(m.notify(toastSuccess, "undeleted"), m.loadVersions(f.mount, f.path))
|
||||
|
||||
case tea.KeyPressMsg:
|
||||
switch {
|
||||
case key.Matches(msg, m.keys.Back):
|
||||
m.pop()
|
||||
return m, m.reloadSecretAfterVersions()
|
||||
case key.Matches(msg, m.keys.Quit):
|
||||
m.quitting = true
|
||||
return m, tea.Quit
|
||||
case key.Matches(msg, m.keys.Up):
|
||||
if m.versionsCursor > 0 {
|
||||
m.versionsCursor--
|
||||
}
|
||||
case key.Matches(msg, m.keys.Down):
|
||||
if m.versionsCursor < len(m.versions)-1 {
|
||||
m.versionsCursor++
|
||||
}
|
||||
case key.Matches(msg, m.keys.Enter):
|
||||
return m.viewSelectedVersion()
|
||||
case key.Matches(msg, m.keys.Rollback):
|
||||
if !m.a.Settings.ReadOnly {
|
||||
return m.confirmRollback()
|
||||
}
|
||||
case key.Matches(msg, m.keys.Undelete):
|
||||
if !m.a.Settings.ReadOnly {
|
||||
return m.confirmUndelete()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) reloadSecretAfterVersions() tea.Cmd {
|
||||
f := m.currentFrame()
|
||||
if f.scr == scrSecret {
|
||||
return m.loadSecret(f.mount, f.path, 0)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *Model) viewSelectedVersion() (tea.Model, tea.Cmd) {
|
||||
if len(m.versions) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
v := m.versions[m.versionsCursor]
|
||||
f := m.currentFrame()
|
||||
m.push(frame{scr: scrSecret, mount: f.mount, path: f.path})
|
||||
return m, m.loadSecret(f.mount, f.path, v.Version)
|
||||
}
|
||||
|
||||
func (m *Model) confirmRollback() (tea.Model, tea.Cmd) {
|
||||
if len(m.versions) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
v := m.versions[m.versionsCursor]
|
||||
f := m.currentFrame()
|
||||
mount, path := f.mount, f.path
|
||||
action := func() tea.Cmd {
|
||||
m.loading = true
|
||||
return func() tea.Msg {
|
||||
ack, err := m.svc.KV.Rollback(m.ctx, mount, path, v.Version)
|
||||
return rollbackAckMsg{ack: ack, err: err}
|
||||
}
|
||||
}
|
||||
if !m.a.Settings.ConfirmDestructive {
|
||||
return m, action()
|
||||
}
|
||||
m.confirm = &confirmSpec{
|
||||
title: fmt.Sprintf("Roll back to version %d?", v.Version),
|
||||
body: "Creates a new version with version " + strconv.Itoa(v.Version) + "'s data. No existing version is deleted.",
|
||||
onConfirm: action,
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) confirmUndelete() (tea.Model, tea.Cmd) {
|
||||
if len(m.versions) == 0 {
|
||||
return m, nil
|
||||
}
|
||||
v := m.versions[m.versionsCursor]
|
||||
if v.DeletionTime == "" && !v.Destroyed {
|
||||
return m, m.notify(toastInfo, "version %d is not deleted", v.Version)
|
||||
}
|
||||
if v.Destroyed {
|
||||
return m, m.notify(toastWarn, "version %d was destroyed — it cannot be undeleted", v.Version)
|
||||
}
|
||||
f := m.currentFrame()
|
||||
mount, path := f.mount, f.path
|
||||
action := func() tea.Cmd {
|
||||
m.loading = true
|
||||
return func() tea.Msg {
|
||||
ack, err := m.svc.KV.Delete(m.ctx, mount, path, vault.OpUndelete, []int{v.Version})
|
||||
return undeleteAckMsg{ack: ack, err: err}
|
||||
}
|
||||
}
|
||||
if !m.a.Settings.ConfirmDestructive {
|
||||
return m, action()
|
||||
}
|
||||
m.confirm = &confirmSpec{
|
||||
title: fmt.Sprintf("Undelete version %d?", v.Version),
|
||||
body: "Restores this version so it can be read again.",
|
||||
onConfirm: action,
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Model) viewVersions() string {
|
||||
f := m.currentFrame()
|
||||
ow, oh := m.lay.bodyW, m.lay.bodyH
|
||||
iw, ih := panelInner(ow, oh)
|
||||
title := m.styles.PanelTitle.Render("Versions of " + f.mount.Path + f.path)
|
||||
|
||||
if m.loading {
|
||||
return renderPanel(m.styles.PanelActive, ow, oh, lipgloss.JoinVertical(lipgloss.Left, title, m.spin.View()+" loading…"))
|
||||
}
|
||||
if len(m.versions) == 0 {
|
||||
return renderPanel(m.styles.PanelActive, ow, oh,
|
||||
lipgloss.JoinVertical(lipgloss.Left, title, "", m.styles.EmptyState.Render("no version history")))
|
||||
}
|
||||
|
||||
header := m.styles.TableHeader.Render(padRight("", 3) + padRight("version", 10) + padRight("created", 22) + "status")
|
||||
rowsH := ih - 2 // title + header
|
||||
if rowsH < 1 {
|
||||
rowsH = 1
|
||||
}
|
||||
top := m.versionsCursor - rowsH/2
|
||||
if top < 0 {
|
||||
top = 0
|
||||
}
|
||||
if top > len(m.versions)-rowsH {
|
||||
top = len(m.versions) - rowsH
|
||||
}
|
||||
if top < 0 {
|
||||
top = 0
|
||||
}
|
||||
bottom := min(top+rowsH, len(m.versions))
|
||||
|
||||
rows := make([]string, 0, bottom-top)
|
||||
for i := top; i < bottom; i++ {
|
||||
v := m.versions[i]
|
||||
state := m.styles.SuccessText.Render(symOK + " active")
|
||||
if v.Destroyed {
|
||||
state = m.styles.ErrorText.Render(symDestroyed + " destroyed")
|
||||
} else if v.DeletionTime != "" {
|
||||
state = m.styles.WarnText.Render(symDeleted + " deleted")
|
||||
}
|
||||
marker := " "
|
||||
if i == 0 {
|
||||
marker = m.styles.SuccessText.Render(symCurrent) + " "
|
||||
}
|
||||
line := marker + padRight(fmt.Sprintf("v%d", v.Version), 9) + padRight(humanTime(v.CreatedTime), 22) + state
|
||||
if i == m.versionsCursor {
|
||||
line = m.styles.RowSelected.Render(padRight(line, iw))
|
||||
}
|
||||
rows = append(rows, line)
|
||||
}
|
||||
|
||||
body := lipgloss.JoinVertical(lipgloss.Left, title, header, lipgloss.JoinVertical(lipgloss.Left, rows...))
|
||||
return renderPanel(m.styles.PanelActive, ow, oh, body)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package ui
|
||||
|
||||
import "charm.land/bubbles/v2/textinput"
|
||||
|
||||
// textInput is a local alias purely to keep the many field declarations in
|
||||
// model.go from repeating the full package-qualified name.
|
||||
type textInput = textinput.Model
|
||||
|
||||
// newTextInput is a method (not a free function) so every input picks up
|
||||
// the current theme's colors via styles.TextInputStyles — otherwise inputs
|
||||
// would render in bubbles' hard-coded default palette regardless of the
|
||||
// user's theme/appearance settings.
|
||||
func (m *Model) newTextInput(f fieldSpec) textInput {
|
||||
ti := textinput.New()
|
||||
ti.Placeholder = f.label
|
||||
ti.SetStyles(m.styles.TextInputStyles())
|
||||
if f.secret {
|
||||
ti.EchoMode = textinput.EchoPassword
|
||||
ti.EchoCharacter = '•'
|
||||
}
|
||||
ti.SetValue(f.value)
|
||||
return ti
|
||||
}
|
||||
|
||||
func (m *Model) textInputWithValue(label, val string) textInput {
|
||||
return m.newTextInput(fieldSpec{label: label, value: val})
|
||||
}
|
||||
|
||||
// fieldSpec is the minimal shape newTextInput needs; kept separate from
|
||||
// auth.Field so this file has no dependency on the auth package.
|
||||
type fieldSpec struct {
|
||||
label string
|
||||
secret bool
|
||||
value string
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
// Package theme turns config.Theme (the YAML-facing schema) into concrete
|
||||
// lipgloss.Style values the TUI renders with.
|
||||
package theme
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"os"
|
||||
|
||||
"charm.land/bubbles/v2/help"
|
||||
"charm.land/bubbles/v2/list"
|
||||
"charm.land/bubbles/v2/textinput"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/term"
|
||||
|
||||
"git.morlana.online/f.weber/vault-tui/internal/config"
|
||||
)
|
||||
|
||||
// Styles is the materialised set of lipgloss styles and colors the whole
|
||||
// TUI renders with. Built once at startup from config.Theme plus a
|
||||
// light/dark decision, and passed down by value (styles are cheap,
|
||||
// immutable value types) to every screen.
|
||||
type Styles struct {
|
||||
// Raw colors, exposed for the few call sites that need to compose a
|
||||
// style lipgloss.Style alone can't express (e.g. list.Styles).
|
||||
Bg color.Color
|
||||
Surface color.Color
|
||||
SurfaceRaised color.Color
|
||||
Overlay color.Color
|
||||
Text color.Color
|
||||
TextMuted color.Color
|
||||
TextFaint color.Color
|
||||
TextInverted color.Color
|
||||
Border color.Color
|
||||
BorderSubtle color.Color
|
||||
BorderActive color.Color
|
||||
Header color.Color
|
||||
Selection color.Color
|
||||
SelectionBg color.Color
|
||||
Accent color.Color
|
||||
Error color.Color
|
||||
Warning color.Color
|
||||
Success color.Color
|
||||
Info color.Color
|
||||
Muted color.Color
|
||||
Masked color.Color
|
||||
Directory color.Color
|
||||
Leaf color.Color
|
||||
|
||||
MaskChar string
|
||||
Mono bool // true when colors are suppressed (--no-color / NO_COLOR)
|
||||
|
||||
// Chrome
|
||||
HeaderBar lipgloss.Style
|
||||
AppBadge lipgloss.Style
|
||||
Crumb lipgloss.Style
|
||||
CrumbSep lipgloss.Style
|
||||
CrumbActive lipgloss.Style
|
||||
Pill lipgloss.Style
|
||||
PillOK lipgloss.Style
|
||||
PillWarn lipgloss.Style
|
||||
PillDanger lipgloss.Style
|
||||
Footer lipgloss.Style
|
||||
StatusBar lipgloss.Style
|
||||
ReadOnly lipgloss.Style
|
||||
|
||||
// Panels
|
||||
Panel lipgloss.Style
|
||||
PanelActive lipgloss.Style
|
||||
PanelTitle lipgloss.Style
|
||||
PanelSubtle lipgloss.Style
|
||||
SidePanel lipgloss.Style
|
||||
EmptyState lipgloss.Style
|
||||
|
||||
// Rows / tables
|
||||
Row lipgloss.Style
|
||||
RowSelected lipgloss.Style
|
||||
RowDim lipgloss.Style
|
||||
Badge lipgloss.Style
|
||||
TableHeader lipgloss.Style
|
||||
KeyCell lipgloss.Style
|
||||
ValueCell lipgloss.Style
|
||||
|
||||
// Text roles
|
||||
Title lipgloss.Style
|
||||
Help lipgloss.Style
|
||||
ErrorText lipgloss.Style
|
||||
WarnText lipgloss.Style
|
||||
SuccessText lipgloss.Style
|
||||
MaskedText lipgloss.Style
|
||||
DirText lipgloss.Style
|
||||
LeafText lipgloss.Style
|
||||
Selected lipgloss.Style
|
||||
|
||||
// Overlays
|
||||
Modal lipgloss.Style
|
||||
ModalDanger lipgloss.Style
|
||||
ModalTitle lipgloss.Style
|
||||
Backdrop lipgloss.Style
|
||||
|
||||
// Toasts
|
||||
ToastInfo lipgloss.Style
|
||||
ToastSuccess lipgloss.Style
|
||||
ToastWarn lipgloss.Style
|
||||
ToastError lipgloss.Style
|
||||
|
||||
// Inputs
|
||||
InputFocused lipgloss.Style
|
||||
InputBlurred lipgloss.Style
|
||||
|
||||
isDark bool
|
||||
}
|
||||
|
||||
// defaultPalette is the built-in fallback: an adaptive light/dark set for
|
||||
// every named color config.Theme.Colors can override.
|
||||
func defaultPalette(dark bool) map[string]string {
|
||||
if dark {
|
||||
return map[string]string{
|
||||
"bg": "#0E1015", "surface": "#151821", "surface_raised": "#1C2130", "overlay": "#20263A",
|
||||
"text": "#E4E7F1", "text_muted": "#9AA2B8", "text_faint": "#5B6272", "text_inverted": "#0E1015",
|
||||
"border": "#2A3040", "border_subtle": "#1C2130", "border_active": "#A78BFA", "header": "#9BA3C4",
|
||||
"selection": "#0E1015", "selection_bg": "#A78BFA", "accent": "#A78BFA",
|
||||
"error": "#F87171", "warning": "#FBBF24", "success": "#4ADE80", "info": "#6EC1FF",
|
||||
"muted": "#9AA2B8", "masked": "#5B6272", "directory": "#6EC1FF", "leaf": "#E4E7F1",
|
||||
}
|
||||
}
|
||||
return map[string]string{
|
||||
"bg": "#FFFFFF", "surface": "#F6F7FB", "surface_raised": "#EDEFF7", "overlay": "#E3E6F3",
|
||||
"text": "#1F2430", "text_muted": "#6C7086", "text_faint": "#9AA0AE", "text_inverted": "#FFFFFF",
|
||||
"border": "#C9CCD6", "border_subtle": "#DEE1EA", "border_active": "#5B34D6", "header": "#3A3F58",
|
||||
"selection": "#FFFFFF", "selection_bg": "#5B34D6", "accent": "#5B34D6",
|
||||
"error": "#B3261E", "warning": "#8A6100", "success": "#1B6B3A", "info": "#0B5FA5",
|
||||
"muted": "#6C7086", "masked": "#9AA0AE", "directory": "#0B5FA5", "leaf": "#1F2430",
|
||||
}
|
||||
}
|
||||
|
||||
// IsDark decides light vs. dark once at startup, synchronously — a
|
||||
// deliberate simplification versus wiring bubbletea's async
|
||||
// BackgroundColorMsg round trip, since the terminal's background rarely
|
||||
// changes mid-session. cfg.Appearance can force it.
|
||||
func IsDark(cfg config.Theme, appearance string) bool {
|
||||
switch appearance {
|
||||
case "dark":
|
||||
return true
|
||||
case "light":
|
||||
return false
|
||||
default:
|
||||
return lipgloss.HasDarkBackground(term.File(os.Stdin), term.File(os.Stdout))
|
||||
}
|
||||
}
|
||||
|
||||
// NoColor reports whether ANSI color should be suppressed: the NO_COLOR
|
||||
// convention (https://no-color.org) or an explicit request.
|
||||
func NoColor(explicit bool) bool {
|
||||
if explicit {
|
||||
return true
|
||||
}
|
||||
_, set := os.LookupEnv("NO_COLOR")
|
||||
return set
|
||||
}
|
||||
|
||||
func colorOf(cfg config.Theme, defaults map[string]string, dark bool, key string) color.Color {
|
||||
if c, ok := cfg.Colors[key]; ok {
|
||||
if dark && c.Dark != "" {
|
||||
return lipgloss.Color(c.Dark)
|
||||
}
|
||||
if !dark && c.Light != "" {
|
||||
return lipgloss.Color(c.Light)
|
||||
}
|
||||
}
|
||||
return lipgloss.Color(defaults[key])
|
||||
}
|
||||
|
||||
// Build materialises Styles from cfg and the light/dark decision. When mono
|
||||
// is true (NO_COLOR / --no-color), every color collapses to the terminal's
|
||||
// default foreground and hierarchy is carried by weight/reverse instead.
|
||||
func Build(cfg config.Theme, dark bool, mono bool) *Styles {
|
||||
def := defaultPalette(dark)
|
||||
c := func(key string) color.Color { return colorOf(cfg, def, dark, key) }
|
||||
|
||||
s := &Styles{isDark: dark, Mono: mono}
|
||||
s.Bg, s.Surface, s.SurfaceRaised, s.Overlay = c("bg"), c("surface"), c("surface_raised"), c("overlay")
|
||||
s.Text, s.TextMuted, s.TextFaint, s.TextInverted = c("text"), c("text_muted"), c("text_faint"), c("text_inverted")
|
||||
s.Border, s.BorderSubtle, s.BorderActive, s.Header = c("border"), c("border_subtle"), c("border_active"), c("header")
|
||||
s.Selection, s.SelectionBg, s.Accent = c("selection"), c("selection_bg"), c("accent")
|
||||
s.Error, s.Warning, s.Success, s.Info = c("error"), c("warning"), c("success"), c("info")
|
||||
s.Muted, s.Masked, s.Directory, s.Leaf = c("muted"), c("masked"), c("directory"), c("leaf")
|
||||
|
||||
s.MaskChar = cfg.MaskChar
|
||||
if s.MaskChar == "" {
|
||||
s.MaskChar = "•"
|
||||
}
|
||||
|
||||
if mono {
|
||||
s.flatten()
|
||||
}
|
||||
|
||||
border := borderFor(cfg.BorderStyle)
|
||||
pillFG := s.TextInverted
|
||||
|
||||
s.HeaderBar = lipgloss.NewStyle().Foreground(s.Text).Background(s.Surface).Padding(0, 1)
|
||||
s.AppBadge = lipgloss.NewStyle().Foreground(pillFG).Background(s.Accent).Bold(true).Padding(0, 1)
|
||||
s.Crumb = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.Surface)
|
||||
s.CrumbSep = lipgloss.NewStyle().Foreground(s.TextFaint).Background(s.Surface)
|
||||
s.CrumbActive = lipgloss.NewStyle().Foreground(s.Text).Background(s.Surface).Bold(true)
|
||||
s.Pill = lipgloss.NewStyle().Foreground(s.Text).Background(s.SurfaceRaised).Padding(0, 1)
|
||||
s.PillOK = s.Pill.Foreground(pillFG).Background(s.Success)
|
||||
s.PillWarn = s.Pill.Foreground(pillFG).Background(s.Warning)
|
||||
s.PillDanger = s.Pill.Foreground(pillFG).Background(s.Error)
|
||||
s.Footer = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.Surface).Padding(0, 1)
|
||||
s.StatusBar = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.Surface)
|
||||
s.ReadOnly = s.PillWarn.Bold(true)
|
||||
|
||||
s.Panel = lipgloss.NewStyle().Border(border).BorderForeground(s.Border).Padding(0, 1)
|
||||
s.PanelActive = s.Panel.BorderForeground(s.BorderActive)
|
||||
s.PanelTitle = lipgloss.NewStyle().Foreground(s.Accent).Bold(true)
|
||||
s.PanelSubtle = lipgloss.NewStyle().Foreground(s.TextMuted)
|
||||
s.SidePanel = lipgloss.NewStyle().Border(border).BorderForeground(s.BorderSubtle).Padding(0, 1)
|
||||
s.EmptyState = lipgloss.NewStyle().Foreground(s.TextFaint).Italic(true)
|
||||
|
||||
s.Row = lipgloss.NewStyle().Foreground(s.Text)
|
||||
s.RowSelected = lipgloss.NewStyle().Foreground(s.Selection).Background(s.SelectionBg).Bold(true)
|
||||
s.RowDim = lipgloss.NewStyle().Foreground(s.TextFaint)
|
||||
s.Badge = lipgloss.NewStyle().Foreground(s.TextMuted).Background(s.SurfaceRaised).Padding(0, 1)
|
||||
s.TableHeader = lipgloss.NewStyle().Foreground(s.TextMuted).Bold(true)
|
||||
s.KeyCell = lipgloss.NewStyle().Foreground(s.Text).Bold(true)
|
||||
s.ValueCell = lipgloss.NewStyle().Foreground(s.TextMuted)
|
||||
|
||||
s.Title = lipgloss.NewStyle().Foreground(s.Accent).Bold(true)
|
||||
s.Help = lipgloss.NewStyle().Foreground(s.TextMuted)
|
||||
s.ErrorText = lipgloss.NewStyle().Foreground(s.Error).Bold(true)
|
||||
s.WarnText = lipgloss.NewStyle().Foreground(s.Warning)
|
||||
s.SuccessText = lipgloss.NewStyle().Foreground(s.Success)
|
||||
s.MaskedText = lipgloss.NewStyle().Foreground(s.Masked)
|
||||
s.DirText = lipgloss.NewStyle().Foreground(s.Directory)
|
||||
s.LeafText = lipgloss.NewStyle().Foreground(s.Leaf)
|
||||
s.Selected = s.RowSelected
|
||||
|
||||
s.Modal = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(s.Accent).
|
||||
Background(s.Overlay).Foreground(s.Text).Padding(1, 2)
|
||||
s.ModalDanger = s.Modal.BorderForeground(s.Error)
|
||||
s.ModalTitle = lipgloss.NewStyle().Foreground(s.Accent).Bold(true)
|
||||
s.Backdrop = lipgloss.NewStyle().Foreground(s.TextFaint)
|
||||
|
||||
s.ToastInfo = lipgloss.NewStyle().Foreground(pillFG).Background(s.Info).Padding(0, 1)
|
||||
s.ToastSuccess = lipgloss.NewStyle().Foreground(pillFG).Background(s.Success).Padding(0, 1)
|
||||
s.ToastWarn = lipgloss.NewStyle().Foreground(pillFG).Background(s.Warning).Padding(0, 1)
|
||||
s.ToastError = lipgloss.NewStyle().Foreground(pillFG).Background(s.Error).Padding(0, 1)
|
||||
|
||||
s.InputFocused = lipgloss.NewStyle().Foreground(s.Accent)
|
||||
s.InputBlurred = lipgloss.NewStyle().Foreground(s.TextMuted)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// flatten drops every color to the terminal's default foreground (mono
|
||||
// mode). Called before styles are built so every lipgloss.Style below
|
||||
// inherits plain colors and relies on Bold/Italic/Reverse for hierarchy.
|
||||
func (s *Styles) flatten() {
|
||||
none := lipgloss.Color("")
|
||||
*s = Styles{
|
||||
isDark: s.isDark, Mono: true, MaskChar: s.MaskChar,
|
||||
Bg: none, Surface: none, SurfaceRaised: none, Overlay: none,
|
||||
Text: none, TextMuted: none, TextFaint: none, TextInverted: none,
|
||||
Border: none, BorderSubtle: none, BorderActive: none, Header: none,
|
||||
Selection: none, SelectionBg: none, Accent: none,
|
||||
Error: none, Warning: none, Success: none, Info: none,
|
||||
Muted: none, Masked: none, Directory: none, Leaf: none,
|
||||
}
|
||||
}
|
||||
|
||||
func borderFor(style string) lipgloss.Border {
|
||||
switch style {
|
||||
case "normal":
|
||||
return lipgloss.NormalBorder()
|
||||
case "thick":
|
||||
return lipgloss.ThickBorder()
|
||||
case "double":
|
||||
return lipgloss.DoubleBorder()
|
||||
case "hidden":
|
||||
return lipgloss.HiddenBorder()
|
||||
case "ascii":
|
||||
return lipgloss.Border{Top: "-", Bottom: "-", Left: "|", Right: "|", TopLeft: "+", TopRight: "+", BottomLeft: "+", BottomRight: "+"}
|
||||
default:
|
||||
return lipgloss.RoundedBorder()
|
||||
}
|
||||
}
|
||||
|
||||
// HelpStyles adapts bubbles/help's palette to the theme.
|
||||
func (s *Styles) HelpStyles() help.Styles {
|
||||
hs := help.DefaultStyles(s.isDark)
|
||||
hs.ShortKey = lipgloss.NewStyle().Foreground(s.Accent)
|
||||
hs.ShortDesc = lipgloss.NewStyle().Foreground(s.TextMuted)
|
||||
hs.ShortSeparator = lipgloss.NewStyle().Foreground(s.TextFaint)
|
||||
hs.FullKey = hs.ShortKey
|
||||
hs.FullDesc = hs.ShortDesc
|
||||
hs.FullSeparator = hs.ShortSeparator
|
||||
hs.Ellipsis = lipgloss.NewStyle().Foreground(s.TextFaint)
|
||||
return hs
|
||||
}
|
||||
|
||||
// TextInputStyles adapts bubbles/textinput's palette to the theme.
|
||||
func (s *Styles) TextInputStyles() textinput.Styles {
|
||||
ti := textinput.DefaultStyles(s.isDark)
|
||||
ti.Focused.Text = lipgloss.NewStyle().Foreground(s.Text)
|
||||
ti.Focused.Prompt = lipgloss.NewStyle().Foreground(s.Accent)
|
||||
ti.Focused.Placeholder = lipgloss.NewStyle().Foreground(s.TextFaint)
|
||||
ti.Blurred.Text = lipgloss.NewStyle().Foreground(s.TextMuted)
|
||||
ti.Blurred.Prompt = lipgloss.NewStyle().Foreground(s.TextFaint)
|
||||
ti.Blurred.Placeholder = lipgloss.NewStyle().Foreground(s.TextFaint)
|
||||
ti.Cursor.Color = s.Accent
|
||||
return ti
|
||||
}
|
||||
|
||||
// ListStyles adapts bubbles/list's chrome (filter prompt/cursor, "no
|
||||
// items", help line) to the theme. The list's per-row rendering is handled
|
||||
// entirely by our own list.ItemDelegate, not by this.
|
||||
func (s *Styles) ListStyles() list.Styles {
|
||||
ls := list.DefaultStyles(s.isDark)
|
||||
ls.Filter = s.TextInputStyles()
|
||||
ls.NoItems = s.EmptyState
|
||||
ls.StatusEmpty = s.EmptyState
|
||||
ls.HelpStyle = s.Help
|
||||
ls.StatusBarActiveFilter = lipgloss.NewStyle().Foreground(s.Text)
|
||||
ls.StatusBarFilterCount = lipgloss.NewStyle().Foreground(s.TextFaint)
|
||||
ls.PaginationStyle = lipgloss.NewStyle().Foreground(s.TextFaint)
|
||||
ls.DefaultFilterCharacterMatch = lipgloss.NewStyle().Foreground(s.Accent).Underline(true)
|
||||
return ls
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
// toastKind selects which of the theme's toast styles a notification uses.
|
||||
type toastKind int
|
||||
|
||||
const (
|
||||
toastInfo toastKind = iota
|
||||
toastSuccess
|
||||
toastWarn
|
||||
toastErr
|
||||
)
|
||||
|
||||
// toast is a transient status/error message shown in the footer. Replaces
|
||||
// the old Model.statusText/errText, which never expired once set.
|
||||
type toast struct {
|
||||
kind toastKind
|
||||
text string
|
||||
seq int
|
||||
}
|
||||
|
||||
// toastExpireMsg clears the toast identified by seq — guarded so a
|
||||
// newer toast issued while an older one's timer is still running can't be
|
||||
// clobbered by the older timer firing after it.
|
||||
type toastExpireMsg struct{ seq int }
|
||||
|
||||
// notify replaces the current toast and returns the tea.Cmd that expires
|
||||
// it. Errors linger noticeably longer than routine status updates.
|
||||
func (m *Model) notify(kind toastKind, format string, args ...any) tea.Cmd {
|
||||
m.toastSeq++
|
||||
seq := m.toastSeq
|
||||
m.toast = &toast{kind: kind, text: fmt.Sprintf(format, args...), seq: seq}
|
||||
|
||||
d := 4 * time.Second
|
||||
if kind == toastErr {
|
||||
d = 8 * time.Second
|
||||
}
|
||||
return tea.Tick(d, func(time.Time) tea.Msg { return toastExpireMsg{seq: seq} })
|
||||
}
|
||||
|
||||
func (m *Model) clearExpiredToast(msg toastExpireMsg) {
|
||||
if m.toast != nil && m.toast.seq == msg.seq {
|
||||
m.toast = nil
|
||||
}
|
||||
}
|
||||
|
||||
// toastView renders the current toast, or "" if there is none.
|
||||
func (m *Model) toastView() string {
|
||||
if m.toast == nil {
|
||||
return ""
|
||||
}
|
||||
switch m.toast.kind {
|
||||
case toastSuccess:
|
||||
return m.styles.ToastSuccess.Render(symOK + " " + m.toast.text)
|
||||
case toastWarn:
|
||||
return m.styles.ToastWarn.Render(symWarn + " " + m.toast.text)
|
||||
case toastErr:
|
||||
return m.styles.ToastError.Render(symErr + " " + m.toast.text)
|
||||
default:
|
||||
return m.styles.ToastInfo.Render(m.toast.text)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user