Files
lazysecrets/internal/ui/app/sources.go
T
kebandClaude Opus 5.5 430875f200
Release / Build and publish (push) Successful in 23s
Hello world 👋
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-10 22:34:22 +02:00

289 lines
9.0 KiB
Go

package app
import (
"cmp"
"fmt"
"slices"
"strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// The Sources pane either groups sources under a foldable header per
// provider, or lists them flat with the provider on each row.
type rowKind int
const (
rowGroup rowKind = iota
rowSource
rowLinks // vaults opened from links that no account could locate
)
type sourceRow struct {
kind rowKind
group provider.Kind // colour and icon
provider string // provider ID: one group per instance (account)
label string // group: provider label, e.g. "Azure · keb"
account string // flat source: account part of the label, if any
status string // group: source count, "…" while loading, "!" on error
collapsed bool // group only
flat bool // source only: shown without a group header
top bool // source only: the repo lazysecrets started in, on top
source provider.Source
}
// sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in
// either; provider IDs contain "/" (Azure:user/tenant).
func sourceRowKey(providerID, sourceID string) string { return providerID + "|" + sourceID }
func splitSourceRow(row string) (providerID, sourceID string) {
providerID, sourceID, _ = strings.Cut(row, "|")
return providerID, sourceID
}
func sourceRowID(r sourceRow) string {
switch r.kind {
case rowGroup:
return "group:" + r.provider
case rowLinks:
return linksRowID
}
return sourceRowKey(r.provider, r.source.ID)
}
func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] {
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
ID: sourceRowID,
// Headers have no filter text, so filtering shows just sources.
Filter: func(r sourceRow) string {
if r.kind != rowSource {
return ""
}
return r.source.Name
},
// So "data" finds Databricks workspaces and an account name finds
// its subscriptions.
FilterExtra: func(r sourceRow) string {
if r.kind != rowSource {
return ""
}
return cmp.Or(r.source.ProviderLabel, string(r.group)) + " " + mem.Node(sourceKey(r.source)).Note
},
Render: func(st styles.Styles, r sourceRow, width int) string {
node := mem.Node(sourceKey(r.source))
kind := st.Kind(r.group)
starred := ""
if r.kind == rowSource && node.StarredFor(r.source.Provider) {
starred = st.Favourite.Render(star)
}
switch {
case r.kind == rowLinks:
return st.Notice.Render("↗ Opened links") + " " + st.Muted.Render(fmt.Sprintf("(%s)", r.status))
case r.kind == rowSource && r.top:
return withNote(st, kind.Render(iconSet.Prefix(r.group)+"Repo · ")+starred+kind.Render(r.source.Name), node.Note, width)
case r.kind == rowSource && r.flat:
line := kind.Render(iconSet.Prefix(r.group)+fmt.Sprintf("%-10s", r.group)) + " " + starred + r.source.Name
if r.account != "" {
line += st.Muted.Render(" · " + r.account)
}
return withNote(st, line+here(st, r.source), node.Note, width)
case r.kind == rowSource:
// The provider's colour, so long groups stay recognisable.
return withNote(st, " "+starred+kind.Render(r.source.Name), node.Note, width)
}
arrow := "▾"
if r.collapsed {
arrow = "▸"
}
status := st.Muted.Render(fmt.Sprintf("(%s)", r.status))
if r.status == "!" {
status = st.Error.Render("(!)")
}
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
},
})
}
// rebuildSources lays out the Sources pane from the per-provider results.
func (m *Model) rebuildSources() {
// While filtering, headers drop out, so rows show their provider and
// account like the flat view (and folded groups are searched too).
flat := m.flatSources || m.sources.HasFilter() || m.sourcesFavs
m.sourcesFiltered = m.sources.HasFilter()
// Tabs: everything, or just the starred ones ([ and ]).
m.sources.Title, m.sources.Tabs = "[1]", []string{"Sources", "Favourites"}
m.sources.Tab, m.sources.Empty = 0, "no sources found"
if m.sourcesFavs {
m.sources.Tab, m.sources.Empty = 1, "nothing starred"+withKey(m.keys.Favourite, ": %s on a subscription or workspace stars it")
}
var rows []sourceRow
if n := len(m.linked); n > 0 && !m.sourcesFavs {
rows = append(rows, sourceRow{kind: rowLinks, status: fmt.Sprint(n)})
}
// The repo lazysecrets started in gets a row of its own on top, so it
// opens on it; flat, it sorts first instead.
if src, ok := m.currentSource(); ok && !flat {
rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src})
}
for _, p := range m.providers {
kind, id := p.Kind(), p.ID()
account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ")
if account == p.Label() {
account = ""
}
r, done := m.sourceResults[id]
sources := r.sources
if !flat {
sources = slices.DeleteFunc(slices.Clone(sources), func(s provider.Source) bool { return s.Here })
}
if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) {
continue // repos show once some are found to have secret files
}
status := "…"
switch {
case done && r.err != nil:
status = "!"
case done:
status = fmt.Sprint(len(sources))
}
if !flat {
rows = append(rows, sourceRow{
kind: rowGroup, group: kind, provider: id, label: p.Label(),
status: status, collapsed: m.collapsed[id],
})
if m.collapsed[id] {
continue
}
}
for _, s := range m.starredSourcesFirst(sources) {
if m.sourcesFavs && !m.store.Node(sourceKey(s)).StarredFor(s.Provider) {
continue
}
rows = append(rows, sourceRow{
kind: rowSource, group: kind, provider: id, account: account,
flat: flat, source: s,
})
}
}
if flat {
// Flat: starred sources go to the very top, across providers.
slices.SortStableFunc(rows, func(a, b sourceRow) int {
return starRank(m.store, a) - starRank(m.store, b)
})
}
m.sources.SetItems(rows)
// Until the user moves, keep the cursor on the first source in
// provider order rather than a header, so the other panes fill in on
// their own. Providers answer in any order, so this can change as
// results arrive.
if !m.sourcesTouched {
m.sources.Top() // scroll back up so the first header shows
for _, r := range rows {
if r.kind == rowSource {
m.sources.Select(sourceRowID(r))
break
}
}
}
}
// currentSource is the source lazysecrets started in: from its
// provider's list once that's in, else as found on its own beforehand.
func (m Model) currentSource() (provider.Source, bool) {
for _, p := range m.providers {
r, done := m.sourceResults[p.ID()]
if !done {
if m.current != nil && m.current.Provider == p.ID() {
return *m.current, true
}
continue
}
for _, src := range r.sources {
if src.Here {
return src, true
}
}
}
return provider.Source{}, false
}
// starredSourcesFirst moves starred sources to the front, keeping the
// order otherwise. It doesn't modify sources.
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
out := slices.Clone(sources)
rank := func(s provider.Source) int {
return boolRank(m.store.Node(sourceKey(s)).StarredFor(s.Provider))
}
slices.SortStableFunc(out, func(a, b provider.Source) int { return rank(a) - rank(b) })
return out
}
// starRank orders the Favourites row, then starred sources, then the rest.
func starRank(mem *store.Store, r sourceRow) int {
switch {
case r.kind == rowLinks:
return 0
case r.kind == rowSource && r.source.Here:
return 1
case r.kind == rowSource && mem.Node(sourceKey(r.source)).StarredFor(r.source.Provider):
return 2
}
return 3
}
func boolRank(starred bool) int {
if starred {
return 0
}
return 1
}
// selectedSource is the source under the cursor; false on a header.
func (m Model) selectedSource() (provider.Source, bool) {
r, ok := m.sources.Selected()
if !ok || r.kind != rowSource {
return provider.Source{}, false
}
return r.source, true
}
// onLinks reports whether the cursor is on the Opened links row.
func (m Model) onLinks() bool {
return m.sources.SelectedID() == linksRowID
}
// toggleSourcesView switches between grouped and flat, keeping the cursor
// on the same source.
func (m *Model) toggleSourcesView() {
m.flatSources = !m.flatSources
m.rebuildSources()
}
// toggleGroup folds or unfolds the group under the cursor. It reports
// whether the cursor was on a group header.
func (m *Model) toggleGroup() bool {
r, ok := m.sources.Selected()
if !ok || r.kind != rowGroup {
return false
}
m.collapsed[r.provider] = !m.collapsed[r.provider]
m.rebuildSources()
m.sources.Select(sourceRowID(r))
return true
}
// here labels the source lazysecrets was started in, in the flat list
// (grouped, it has the "Repo · name" row on top).
func here(st styles.Styles, s provider.Source) string {
if s.Here {
return st.Muted.Render(" · here")
}
return ""
}