Files
lazysecrets/internal/ui/app/search.go
T
kebandClaude Opus 5.5 331e53fb49
CI / Checks (pull_request) Successful in 24s
PR title / Conventional title (pull_request) Successful in 1s
feat: Go to scopes are grouped like the Sources pane, an account at a time
The scope list follows the Sources pane: All, then each account (or
Databricks, or Repos) with its sources under it, in place of All Azure,
All Databricks and All repos. An account scope searches and loads only
that account. ctrl+s on a group header narrows to its group.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-12 11:51:29 +02:00

401 lines
12 KiB
Go

package app
import (
"cmp"
"context"
"fmt"
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
// ctrl+s searches everything loaded: subscriptions and workspaces, vaults
// and scopes, and the secrets, keys and certificates of every vault opened
// this session. Azure has no search across vaults, so ctrl+a in the popup
// loads the rest in the background, a few requests at a time; results
// appear as lists arrive, and the panes use them too.
const crawlWorkers = 4
// crawl loads every vault's secrets for search.
type crawl struct {
running bool
queue []crawlJob
active int
pending map[string]bool // cache keys of lists queued or loading
failed map[string]bool // cache keys of lists that couldn't be read
}
type crawlJob struct {
src *provider.Source // list its vaults or scopes
container *provider.Container // list its secrets
row string // the source's row key (containers cache key)
force bool // reload what's loaded already
}
type crawlMsg struct {
job crawlJob
containers []provider.Container
objects []provider.Object
err error
}
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// searchEntries lists everything loaded: the sources (with how much of
// each is loaded) and the vaults, scopes and secrets in them, each with
// its source's row key as group.
func (m *Model) searchEntries() (sources, items []option) {
type entry struct {
depth int
sort string
opt option
}
var es []entry
// key is the store key, so ctrl+f stars the entry; mark shows how
// much is loaded, reload (ctrl+r) loads it again.
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) {
n := m.store.Node(key)
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
es = append(es, entry{depth, sort,
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
}
for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources {
src.Provider, src.ProviderLabel = p.ID(), p.Label()
row := sourceRowKey(p.ID(), src.ID)
add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
m.nav = &nav{sourceRow: row, sourceOnly: true}
return nil
}, func(m *Model) tea.Cmd { return m.loadSource(src) })
for _, c := range m.cache.containers[row].value {
r, err := ref.ForContainer(c, provider.TypeSecret)
if err != nil {
continue
}
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) }
add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
m.startNav(r, actGo, row)
return nil
}, reload)
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
r, err := ref.ForObject(o, "")
if err != nil {
continue
}
suffix := accountOf(src)
if t != provider.TypeSecret {
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
suffix = strings.Trim(typ+" · "+suffix, " ·")
}
add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
m.startNav(r, actGo, row)
return nil
}, reload)
}
}
}
}
}
slices.SortStableFunc(es, func(a, b entry) int { return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort)) })
for _, e := range es {
if e.depth == 0 {
sources = append(sources, e.opt)
} else {
items = append(items, e.opt)
}
}
return sources, items
}
// coverage is how much of everything search can see: sources whose
// vaults are listed, vaults whose secrets are loaded, and secrets. Lists
// that couldn't be read count as done.
type coverage struct {
sources, sourcesListed int
vaults, vaultsLoaded int
secrets, unreadable int
}
func (c coverage) complete() bool {
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
}
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
// providerFilter picks the providers a search or a load covers; nil is
// all of them.
type providerFilter func(provider.Provider) bool
func ofKind(k provider.Kind) providerFilter {
return func(p provider.Provider) bool { return p.Kind() == k }
}
func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id }
}
// coverageOf is searchCoverage for the providers in.
func (m *Model) coverageOf(in providerFilter) coverage {
var c coverage
for _, p := range m.providers {
if in != nil && !in(p) {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
row := sourceRowKey(p.ID(), src.ID)
c.sources++
e, ok := m.cache.containers[row]
if ok || m.crawl.failed[row] {
c.sourcesListed++
}
if m.crawl.failed[row] {
c.unreadable++
}
for _, v := range e.value {
c.vaults++
key := containerKey(v) + "|" + string(provider.TypeSecret)
if objs, ok := m.cache.objects[key]; ok {
c.vaultsLoaded++
c.secrets += len(objs.value)
} else if m.crawl.failed[key] {
c.vaultsLoaded++
c.unreadable++
}
}
}
}
return c
}
// searchLines says what the search covers.
func (m *Model) searchLines() []string {
c := m.searchCoverage()
switch {
case m.crawl.running:
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
len(m.crawl.queue)+m.crawl.active))}
case c.complete():
s := fmt.Sprintf("Searching everything: %s, %s, %s",
plural(c.sources, "source"), plural(c.vaults, "vault"), plural(c.secrets, "secret"))
if c.unreadable > 0 {
s += fmt.Sprintf(" (%s unreadable)", plural(c.unreadable, "list"))
}
return []string{m.st.Success.Render("✓") + " " + m.st.Notice.Render(s)}
}
s := fmt.Sprintf("Only what's loaded: %d/%d vaults", c.vaultsLoaded, c.vaults)
if c.sourcesListed < c.sources {
s += fmt.Sprintf(", %d/%d sources listed", c.sourcesListed, c.sources)
}
return []string{m.st.Warning.Render("⚠ " + s + "." + withKey(m.loadAllKey(), " %s loads the rest."))}
}
func plural(n int, word string) string {
if n == 1 {
return "1 " + word
}
return fmt.Sprintf("%d %ss", n, word)
}
// startCrawl loads every unlisted subscription's vaults and every vault's
// secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
// startCrawlOf is startCrawl for the providers in.
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
for _, p := range m.providers {
if in != nil && !in(p) {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
src.Provider, src.ProviderLabel = p.ID(), p.Label()
m.queueSource(src, false)
}
}
return m.crawlStart()
}
// loadSource loads (again) a subscription's vaults and their secrets.
func (m *Model) loadSource(src provider.Source) tea.Cmd {
m.queueSource(src, true)
return m.crawlStart()
}
// loadContainer loads (again) a vault's secrets.
func (m *Model) loadContainer(c provider.Container) tea.Cmd {
m.queueContainer(c, sourceRowKey(c.Source.Provider, c.Source.ID), true)
return m.crawlStart()
}
// crawlStart runs what's queued, and shows it in the popup.
func (m *Model) crawlStart() tea.Cmd {
cmd := m.crawlNext()
m.refreshSearch()
return cmd
}
// queueSource queues listing src's vaults or, with them listed lately
// and not force, loading their secrets. Lists from an earlier run (the
// cache file) are listed again, so searching brings them up to date.
func (m *Model) queueSource(src provider.Source, force bool) {
row := sourceRowKey(src.Provider, src.ID)
if e, ok := m.cache.containers[row]; ok && e.fresh(m.now()) && !force {
for _, c := range e.value {
m.queueContainer(c, row, false)
}
return
}
m.enqueue(crawlJob{src: &src, row: row, force: force}, row)
}
// queueContainer queues loading c's secrets, unless loaded lately and not
// force.
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
key := containerKey(c) + "|" + string(provider.TypeSecret)
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
return
}
m.enqueue(crawlJob{container: &c, row: row, force: force}, key)
}
func (m *Model) enqueue(job crawlJob, key string) {
if m.crawl.pending == nil {
m.crawl.pending, m.crawl.failed = map[string]bool{}, map[string]bool{}
}
if m.crawl.pending[key] {
return // queued or loading already
}
m.crawl.pending[key] = true
m.crawl.queue = append(m.crawl.queue, job)
}
// crawlNext starts jobs up to crawlWorkers at a time.
func (m *Model) crawlNext() tea.Cmd {
var cmds []tea.Cmd
for m.crawl.active < crawlWorkers && len(m.crawl.queue) > 0 {
job := m.crawl.queue[0]
m.crawl.queue = m.crawl.queue[1:]
m.crawl.active++
cmds = append(cmds, m.crawlCmd(job))
}
m.crawl.running = m.crawl.active > 0 || len(m.crawl.queue) > 0
return tea.Batch(cmds...)
}
func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
timeout := m.timeout
if job.src != nil {
p := m.providerFor(*job.src)
src := *job.src
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
items, err := p.Containers(ctx, src)
for i := range items {
items[i].Source = src
}
return crawlMsg{job: job, containers: items, err: err}
}
}
c := *job.container
p := m.providerFor(c.Source)
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
items, err := p.Objects(ctx, c, provider.TypeSecret)
for i := range items {
items[i].Container = c
}
return crawlMsg{job: job, objects: items, err: err}
}
}
func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
m.crawl.active--
key := msg.job.row
if msg.job.container != nil {
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
}
delete(m.crawl.pending, key)
switch {
case msg.err != nil:
m.crawl.failed[key] = true
case msg.job.src != nil:
delete(m.crawl.failed, key)
m.cache.containers[key] = entry[[]provider.Container]{msg.containers, m.now()}
m.cacheDirty = true
for _, c := range msg.containers {
m.queueContainer(c, key, msg.job.force)
}
default:
delete(m.crawl.failed, key)
m.cache.objects[key] = entry[[]provider.Object]{msg.objects, m.now()}
m.cacheDirty = true
}
cmd := m.crawlNext()
m.refreshSearch()
return cmd
}
// sourceMark shows how much of a subscription or workspace is loaded:
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
func (m *Model) sourceMark(row string) string {
if m.crawl.pending[row] {
return m.st.Muted.Render("◌")
}
if m.crawl.failed[row] {
return m.st.Error.Render("✗")
}
e, ok := m.cache.containers[row]
if !ok {
return m.st.Muted.Render("○")
}
loaded, loading := 0, false
for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
switch {
case loading:
return m.st.Muted.Render("◌")
case loaded == len(e.value):
return m.st.Success.Render("●")
case loaded > 0:
return m.st.Warning.Render("◐")
}
return m.st.Muted.Render("○")
}
// vaultMark marks a vault in search only when its secrets can't be read
// (✗) or are loading (◌): whether a subscription is loaded says whether
// its vaults are searchable.
func (m *Model) vaultMark(c provider.Container) string {
key := containerKey(c) + "|" + string(provider.TypeSecret)
switch {
case m.crawl.pending[key]:
return m.st.Muted.Render("◌")
case m.crawl.failed[key]:
return m.st.Error.Render("✗")
}
return " "
}
// providerOf is the provider ID in a source row key.
func providerOf(row string) string {
p, _ := splitSourceRow(row)
return p
}