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 }