Files
f.weber ae30ba1240 feat(vault-tui): implement KV client and service for managing secrets
- Added internal/vault/client.go for creating a Vault client with configuration settings.
- Introduced internal/vault/errors.go to classify Vault API errors for better UI handling.
- Created internal/vault/kv.go to manage KV secrets, including listing, reading, writing, and deleting operations.
- Implemented internal/vault/mounts.go to list and describe secret engine mounts.
- Developed internal/vault/service.go to provide a unified entry point for Vault operations.
- Added internal/vault/kv_test.go for comprehensive testing of KV operations.
- Introduced internal/ui/toast.go for transient notifications in the UI.
- Added renovate.json for dependency management and updates.
2026-08-14 11:09:03 +02:00

148 lines
3.9 KiB
Go

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