feat(vault-tui): implement KV client and service for managing secrets
- Added internal/vault/client.go for creating a Vault client with configuration settings. - Introduced internal/vault/errors.go to classify Vault API errors for better UI handling. - Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations. - Implemented internal/vault/mounts.go to list and describe secret engine mounts. - Developed internal/vault/service.go to provide a unified entry point for Vault operations. - Added internal/vault/kv_test.go for comprehensive testing of KV operations. - Introduced internal/ui/toast.go for transient notifications in the UI. - Added renovate.json for dependency management and updates.
This commit is contained in:
@@ -0,0 +1,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
|
||||
}
|
||||
Reference in New Issue
Block a user