200 lines
5.8 KiB
Go
200 lines
5.8 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
"charm.land/bubbles/v2/help"
|
|
"charm.land/bubbles/v2/key"
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"git.morlana.online/f.weber/vault-tui/internal/cli"
|
|
)
|
|
|
|
// 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 {
|
|
b := cli.Build()
|
|
ver := b.Version
|
|
if b.Prerelease {
|
|
ver += " (prerelease)"
|
|
}
|
|
status := fmt.Sprintf("%s @ %s · %s", m.a.Settings.Profile, m.a.Settings.Address, ver)
|
|
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)))
|
|
}
|