Files
lazysecrets/internal/ui/app/jump.go
T
keb 39454d47a8 feat: lazysecrets list prints everything lazysecrets can find, for fzf and editor pickers (#70)
`lazysecrets list` prints every source, vault and secret lazysecrets can find, one per line, for fzf, editor pickers and scripts: what Go to searches, without the TUI. What's cached prints at once; what's missing or older than `--max-age` loads in the background and streams in. It stops when the reader goes (a pick in fzf), saving what it loaded.

- Text: kind, mark (✗/⚠, ★, ◷ and the provider icon), name, status (why it couldn't be read, or expired / expires in 12d), note, account and link, tab-separated; kind first and account and link last, so new fields go between. Coloured from the theme; `--color never`, `--icons=false`, `--stars=false`, `--recent`, `--notes=false`, `--expiry=false`.
- `--format json`: an object per line, with starred, recent, lastUsed and uses, expires, enabled, note, reference and url too.
- `--kind` lists only some kinds, and doesn't load lists only other kinds need; `--cached` loads nothing.
- `lazysecrets open --action go|copy|show` skips the "what to do" menu and ends in the list, like Go to; `open` and `get` take `--account`, the account list printed, rather than asking which.
- The cache file moved to `internal/cachefile`: saves merge with the file under a lock, so list and the TUI don't undo each other.
- `ui.recentIcon` marks items used lately with ◷ in the TUI's lists too; Recent now means the last 90 days.
- --demo has stars, notes, recent use, and expired, expiring and disabled secrets.
- docs/integration.md documents get, list and an fzf picker; docs/links.md is docs/sharing.md.

Reviewed-on: #70
Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com>
2026-10-14 21:30:44 +02:00

224 lines
6.7 KiB
Go

package app
import (
"cmp"
"maps"
"slices"
"strings"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// The Favourites and Recent scopes of the Go to popup list what's
// remembered in the store, so they work before anything has loaded.
// storeEntries lists starred items (sources first, then vaults, then
// secrets) and recently used ones (newest first), as Go to entries.
// Entries read as paths: "󰠅 platform-prod / kv-app-prod / api-key · dev",
// the account last, and only when there are several.
func (m *Model) storeEntries() (favs, recent []option) {
type entry struct {
depth int // 0 source, 1 vault, 2 secret: sources list first
sort string
opt option
}
nodes := m.store.Nodes()
keys := slices.Sorted(maps.Keys(nodes))
// entries lists jumps to the item through each of accounts; nil
// means through whichever accounts list a source, else the account
// that last read the item.
entries := func(prefix, key string, n store.Node, accounts []string) []entry {
r, err := ref.Parse(key)
if err != nil {
return nil
}
var out []entry
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
account := ""
if p, ok := m.byID[acct]; ok {
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
}
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
out = append(out, entry{depth, sort, option{
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
plain: m.inPlainEnv(r),
run: func(m *Model) tea.Cmd {
m.used(key)
return run(m)
}}})
}
if r.IsSource() {
for _, p := range m.providers {
if p.Kind() != r.Kind || (accounts != nil && !slices.Contains(accounts, p.ID())) {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if !sourceMatches(r, src) {
continue
}
row := sourceRowKey(p.ID(), src.ID)
add([]string{src.Name}, p.ID(), 0, func(m *Model) tea.Cmd {
m.nav = &nav{sourceRow: row, sourceOnly: true}
return nil
})
}
}
return out
}
if accounts == nil {
accounts = []string{n.Account}
}
depth := 1
if !r.IsContainer() {
depth = 2
}
path := slices.Clone(n.Path)
if len(path) == 0 && !r.IsContainer() {
path = []string{r.ContainerName()} // no context recorded
}
for _, acct := range accounts {
via := n
via.Account = acct
add(append(slices.Clone(path), cmp.Or(n.Name, r.Name, r.ContainerName())), acct, depth,
func(m *Model) tea.Cmd { return m.jumpTo(r, via) })
}
return out
}
var starred []entry
for _, k := range keys {
if n := nodes[k]; len(n.StarredBy) > 0 {
starred = append(starred, entries("fav|", k, n, n.StarredBy)...)
}
}
slices.SortFunc(starred, func(a, b entry) int {
return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort))
})
for _, e := range starred {
favs = append(favs, e.opt)
}
if m.history {
var used []string
for _, k := range keys {
if nodes[k].UsedLately(m.now()) {
used = append(used, k)
}
}
slices.SortStableFunc(used, func(a, b string) int { return nodes[b].LastUsed.Compare(nodes[a].LastUsed) })
for _, k := range used[:min(len(used), recentMax)] {
for _, e := range entries("recent|", k, nodes[k], nil) {
recent = append(recent, e.opt)
}
}
}
return favs, recent
}
// recentMax is how many recently used items Recent lists.
const recentMax = 20
// pathLabel draws a jump list entry: the provider's icon and the path in
// its colour, then the account.
func (m Model) pathLabel(kind provider.Kind, path []string, account string) string {
style := m.st.Kind(kind)
parts := make([]string, len(path))
for i, p := range path {
parts[i] = style.Render(p)
}
label := style.Render(m.icons.Prefix(kind)) + strings.Join(parts, m.st.Muted.Render(" / "))
if account != "" {
label += m.st.Muted.Render(" · " + account)
}
return label
}
// inPlainEnv reports whether r is a key in a plain env file, as far as
// the files loaded tell.
func (m Model) inPlainEnv(r ref.Ref) bool {
if r.Kind != provider.KindRepo || r.Name == "" {
return false
}
file := r
file.Name, file.Type = "", ""
for _, e := range m.cache.containers {
for _, c := range e.value {
if c.Detail == provider.DetailPlainEnv && storeKey(c) == file.String() {
return true
}
}
}
return false
}
// repoLabel draws a repo, or a file in it, as everywhere else: the repo
// icon and colour, "app / apps/api/.env".
func (m Model) repoLabel(repo string, file ...string) string {
return m.pathLabel(provider.KindRepo, append([]string{repo}, file...), "")
}
// sourceMatches reports whether src is the subscription or workspace r
// links to.
func sourceMatches(r ref.Ref, src provider.Source) bool {
if r.Kind != provider.KindAzure { // by host: workspaces, repos
return strings.EqualFold(strings.TrimSuffix(src.Detail, "/"), r.Host)
}
return src.ID == r.Subscription
}
// accountOf is the account part of a source's provider label ("alice" in
// "Azure · alice"), or "" when there's a single account.
func accountOf(src provider.Source) string {
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
if !ok {
return ""
}
return account
}
// jumpTo goes to a remembered vault or secret. With the account that last
// read it signed in, it goes straight there; otherwise it uses the
// accounts that list its subscription, asking if there are several, or
// finds it like a link. Reaching it updates the remembered account.
func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
if n.Source == "" && !r.IsContainer() {
// A secret without context: its vault's record may have it.
parent := r
parent.Type, parent.Name, parent.Version = "", "", ""
if p := m.store.Node(parent.String()); p.Source != "" {
n.Source, n.Account = p.Source, cmp.Or(n.Account, p.Account)
}
}
if _, ok := m.byID[n.Account]; ok && n.Source != "" {
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
return nil
}
var choices []choice
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if src.ID == n.Source && n.Source != "" {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
switch len(choices) {
case 0:
return m.openRef(r, actGo)
case 1:
m.startNav(r, actGo, choices[0].sourceRow)
default:
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices, false)
}
return nil
}