290 lines
9.1 KiB
Go
290 lines
9.1 KiB
Go
package app
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"github.com/charmbracelet/x/ansi"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/cachefile"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/expiry"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ref"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/store"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
|
)
|
|
|
|
// What lazysecrets remembers about subscriptions, vaults and secrets —
|
|
// stars, notes — lives in the store, one record per item keyed by its
|
|
// lazysecrets:// link. Keys leave out the account, so a star or note on a
|
|
// subscription shows under every account that sees it.
|
|
|
|
const star = "★ "
|
|
|
|
// recentMark marks items used lately (Recent), with ui.recentIcon.
|
|
const recentMark = "◷ "
|
|
|
|
// marks is what goes before a list row's name: ★ when it's starred for
|
|
// account and, with recent, ◷ when it was used lately.
|
|
func marks(st styles.Styles, n store.Node, account string, recent bool, now time.Time) string {
|
|
var s string
|
|
if n.StarredFor(account) {
|
|
s = st.Favourite.Render(star)
|
|
}
|
|
if recent && n.UsedLately(now) {
|
|
s += st.Muted.Render(recentMark)
|
|
}
|
|
return s
|
|
}
|
|
|
|
func sourceKey(s provider.Source) string {
|
|
if r, err := ref.ForSource(s); err == nil {
|
|
return r.String()
|
|
}
|
|
return "source:" + string(s.Kind) + "/" + s.ID
|
|
}
|
|
|
|
func storeKey(c provider.Container) string {
|
|
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
|
|
return r.String()
|
|
}
|
|
return "container:" + c.ID
|
|
}
|
|
|
|
func objectKey(o provider.Object) string {
|
|
if r, err := ref.ForObject(o, ""); err == nil {
|
|
return r.String()
|
|
}
|
|
return "object:" + o.ID
|
|
}
|
|
|
|
// remember records context with a change, so lists of remembered items
|
|
// can show and jump to them later.
|
|
func (m *Model) remember(key, name string, path []string, src provider.Source, change func(*store.Node)) error {
|
|
ctx := withContext(name, path, src)
|
|
return m.store.Update(key, func(n *store.Node) {
|
|
ctx(n)
|
|
change(n)
|
|
})
|
|
}
|
|
|
|
// withContext sets a record's context: where the item is, and the account
|
|
// that read it. A vault opened from a link has no known subscription;
|
|
// that keeps the one already recorded.
|
|
func withContext(name string, path []string, src provider.Source) func(*store.Node) {
|
|
return func(n *store.Node) {
|
|
n.Name = name
|
|
if src.ID != "" || n.Source == "" {
|
|
n.Path, n.Source = path, src.ID
|
|
}
|
|
if src.Provider != "" {
|
|
n.Account = src.Provider
|
|
}
|
|
}
|
|
}
|
|
|
|
// refreshContext updates a remembered item's context after reaching it,
|
|
// e.g. the account that could read it. Items without a record are left
|
|
// alone, so browsing doesn't write the file.
|
|
func (m *Model) refreshContext(key, name string, path []string, src provider.Source) {
|
|
if m.store.Has(key) && src.Provider != "" {
|
|
_ = m.remember(key, name, path, src, func(*store.Node) {})
|
|
}
|
|
}
|
|
|
|
// containerRender draws a vault or scope, starred or not. In the
|
|
// Opened links list it also names the source, since they come from many.
|
|
func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Container, int) string {
|
|
st, iconSet := m.store, m.icons
|
|
return func(sty styles.Styles, c provider.Container, width int) string {
|
|
n := st.Node(storeKey(c))
|
|
line := c.Name
|
|
if c.Source.Kind == provider.KindRepo {
|
|
// A secret file: a lock for an encrypted one, a leaf
|
|
// for Spring config, a file in the warning colour for plain
|
|
// text, and its type after it ("sops · age", "env · plain text").
|
|
icon, style := iconSet.Encrypted, sty.KindRepo
|
|
switch {
|
|
case strings.HasPrefix(c.Detail, "spring"):
|
|
icon, style = iconSet.Spring, sty.Spring
|
|
case !strings.HasPrefix(c.Detail, "sops"):
|
|
icon, style = iconSet.PlainFile, sty.Warning
|
|
}
|
|
if icon != "" {
|
|
line = style.Render(icon) + " " + line
|
|
}
|
|
line += sty.Muted.Render(" " + c.Detail)
|
|
}
|
|
line = dimIf(sty, c.Hidden, marks(sty, n, c.Source.Provider, m.recentIcon, m.now())+line)
|
|
if withSource {
|
|
src := c.Source.Name
|
|
if account := accountOf(c.Source); account != "" {
|
|
src = account + " / " + src
|
|
}
|
|
line += sty.Muted.Render(" · " + src)
|
|
}
|
|
return withNote(sty, line, n.Note, width)
|
|
}
|
|
}
|
|
|
|
func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
|
|
st := m.store
|
|
return func(sty styles.Styles, o provider.Object, width int) string {
|
|
n := st.Node(objectKey(o))
|
|
line := o.Name
|
|
if o.Enabled != nil && !*o.Enabled {
|
|
line = sty.Muted.Render(o.Name + " (disabled)")
|
|
}
|
|
line = marks(sty, n, o.Container.Source.Provider, m.recentIcon, m.now()) + line
|
|
if o.Detail != "" {
|
|
line += sty.Muted.Render(" " + o.Detail)
|
|
}
|
|
line = dimIf(sty, o.Hidden, line)
|
|
switch state, left := m.expiry(o); state {
|
|
case expiry.Expired:
|
|
line += " " + sty.Error.Render("✗ expired")
|
|
case expiry.Soon:
|
|
line += " " + sty.Warning.Render("⚠ "+expiry.Left(left))
|
|
}
|
|
return withNote(sty, line, n.Note, width)
|
|
}
|
|
}
|
|
|
|
// dimIf draws a hidden item's row (shown with H) in the muted colour,
|
|
// whole, so it stands apart from what's listed by the provider's rules.
|
|
func dimIf(sty styles.Styles, hidden bool, line string) string {
|
|
if !hidden {
|
|
return line
|
|
}
|
|
return sty.Muted.Render(ansi.Strip(line))
|
|
}
|
|
|
|
// starredFirst moves starred items to the front, keeping the order
|
|
// otherwise. It doesn't modify items, which may be cached.
|
|
func starredFirst[T any](items []T, starred func(T) bool) []T {
|
|
rank := func(x T) int {
|
|
if starred(x) {
|
|
return 0
|
|
}
|
|
return 1
|
|
}
|
|
out := slices.Clone(items)
|
|
slices.SortStableFunc(out, func(a, b T) int { return rank(a) - rank(b) })
|
|
return out
|
|
}
|
|
|
|
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
|
|
return starredFirst(sources, func(s provider.Source) bool {
|
|
return m.store.Node(sourceKey(s)).StarredFor(s.Provider)
|
|
})
|
|
}
|
|
|
|
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
|
|
return starredFirst(items, func(c provider.Container) bool {
|
|
return m.store.Node(storeKey(c)).StarredFor(c.Source.Provider)
|
|
})
|
|
}
|
|
|
|
func (m Model) starredObjectsFirst(items []provider.Object) []provider.Object {
|
|
return starredFirst(items, func(o provider.Object) bool {
|
|
return m.store.Node(objectKey(o)).StarredFor(o.Container.Source.Provider)
|
|
})
|
|
}
|
|
|
|
// showLinkedContainers lists the vaults opened from links.
|
|
func (m *Model) showLinkedContainers() {
|
|
m.containers.Title, m.containers.Empty = "[2] Opened links", "no links opened"
|
|
m.containers.SetRender(m.containerRender(true))
|
|
m.containers.SetItems(m.linked)
|
|
m.containers.Select(m.cache.cursor[linksRowID])
|
|
}
|
|
|
|
// rebind records the account that served a vault opened from a link. The
|
|
// container's key includes the account, so the selection moves to the
|
|
// new key without reloading. It returns the new key for the object list.
|
|
func (m *Model) rebind(c provider.Container) string {
|
|
m.refreshContext(storeKey(c), c.Name, []string{c.Source.Name}, c.Source)
|
|
for i := range m.linked {
|
|
if m.linked[i].ID == c.ID {
|
|
m.linked[i].Source = c.Source
|
|
}
|
|
}
|
|
key := cachefile.ObjectsKey(c, m.objType)
|
|
if m.onLinks() {
|
|
m.containers.SetItems(m.linked)
|
|
m.containers.Select(containerKey(c))
|
|
m.selContainer = key
|
|
}
|
|
return key
|
|
}
|
|
|
|
// resortStarred puts starred items first again in every pane, after
|
|
// stars changed elsewhere (the favourites list).
|
|
func (m *Model) resortStarred() {
|
|
m.rebuildSources()
|
|
if !m.onLinks() {
|
|
m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
|
|
}
|
|
if m.versionsOf == nil {
|
|
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
|
|
}
|
|
}
|
|
|
|
// toggleFavourite stars or unstars the selected subscription (Sources),
|
|
// vault or scope (Vaults) or object (Secrets and detail panes).
|
|
func (m *Model) toggleFavourite() tea.Cmd {
|
|
var name string
|
|
var on bool
|
|
// A star is the item through the account showing it.
|
|
var err error
|
|
switch m.focus {
|
|
case paneSources:
|
|
src, ok := m.selectedSource()
|
|
if !ok {
|
|
m.flash = warning("select a subscription or workspace to star")
|
|
return nil
|
|
}
|
|
name = src.Name
|
|
toggle := func(n *store.Node) { on = n.ToggleStar(src.Provider) }
|
|
if err = m.remember(sourceKey(src), src.Name, nil, src, toggle); err == nil {
|
|
m.rebuildSources() // re-sorts; the cursor follows the source
|
|
}
|
|
case paneContainers:
|
|
c, ok := m.containers.Selected()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
name = c.Name
|
|
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
|
|
if err = m.remember(storeKey(c), c.Name, []string{c.Source.Name}, c.Source, toggle); err == nil {
|
|
m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
|
|
}
|
|
default:
|
|
if m.versionsOf != nil {
|
|
m.flash = warning("star the item from the list rather than a version")
|
|
return nil
|
|
}
|
|
o, ok := m.detail.Secret()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
name = o.Name
|
|
c := o.Container
|
|
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
|
|
if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil {
|
|
m.secrets.SetItems(m.starredObjectsFirst(m.secrets.Items()))
|
|
}
|
|
}
|
|
switch {
|
|
case err != nil:
|
|
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
|
|
case on:
|
|
m.flash = done("starred " + name)
|
|
default:
|
|
m.flash = done("unstarred " + name)
|
|
}
|
|
return nil
|
|
}
|