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:
2026-08-14 11:09:03 +02:00
commit ae30ba1240
85 changed files with 9413 additions and 0 deletions
+192
View File
@@ -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)))
}
+60
View File
@@ -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...))
}
+150
View File
@@ -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
}
+166
View File
@@ -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)
}
+74
View File
@@ -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},
},
}
}
+127
View File
@@ -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},
}
}
+147
View File
@@ -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))
}
+341
View File
@@ -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}
}
}
+90
View File
@@ -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
+43
View File
@@ -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
}
+38
View File
@@ -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
})
}
+394
View File
@@ -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...))
}
+280
View File
@@ -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)
}
+305
View File
@@ -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...))
}
+27
View File
@@ -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...))
}
+116
View File
@@ -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...))
}
+294
View File
@@ -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...))
}
+222
View File
@@ -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)
}
+35
View File
@@ -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
}
+328
View File
@@ -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
}
+68
View File
@@ -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)
}
}