Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1840 lines
53 KiB
Go
1840 lines
53 KiB
Go
// Package app is the root Bubble Tea model: layout, focus, key routing and
|
|
// asynchronous loading from providers.
|
|
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"image/color"
|
|
"math"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"charm.land/bubbles/v2/help"
|
|
"charm.land/bubbles/v2/key"
|
|
"charm.land/bubbles/v2/spinner"
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
|
|
"git.kebcloud.no/keb/lazysecrets/internal/browser"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/clipboard"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/commands"
|
|
"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/icons"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
|
"git.kebcloud.no/keb/lazysecrets/internal/update"
|
|
)
|
|
|
|
type pane int
|
|
|
|
const (
|
|
paneSources pane = iota
|
|
paneContainers
|
|
paneSecrets
|
|
paneDetail
|
|
// paneStatus is on top of the left panes; after the right pane in
|
|
// the tab order (status.go).
|
|
paneStatus
|
|
paneCount
|
|
)
|
|
|
|
// slot identifies a kind of request; starting one cancels the previous
|
|
// request in the same slot.
|
|
type slot int
|
|
|
|
const (
|
|
slotContainers slot = iota
|
|
slotObjects
|
|
slotValue // reveal in the detail pane
|
|
slotDescribe // extra metadata for the selected object
|
|
slotCopy // fetch a value straight to the clipboard
|
|
slotVersions // count the selected object's versions
|
|
slotDetails // a file's details
|
|
slotCount
|
|
)
|
|
|
|
// Options configures the app.
|
|
type Options struct {
|
|
// Timeout bounds every provider call. Defaults to 30s.
|
|
Timeout time.Duration
|
|
// Icons shows Nerd Font glyphs next to backends.
|
|
Icons bool
|
|
// Clipboard defaults to the system clipboard, or OSC52 over SSH.
|
|
Clipboard Clipboard
|
|
// ClipboardClear clears copied values after this long; 0 disables.
|
|
ClipboardClear time.Duration
|
|
// OpenURL opens a web page; defaults to the system browser.
|
|
OpenURL func(url string) error
|
|
// ConfigFiles and StatePath are where lazysecrets reads its config
|
|
// and keeps its state, for the status pane.
|
|
ConfigFiles []string
|
|
StatePath string
|
|
// Changelog is CHANGELOG.md, for the status pane.
|
|
Changelog string
|
|
// CacheFile keeps what was loaded between runs; "" keeps it for this
|
|
// run only.
|
|
CacheFile string
|
|
// Dir is the folder lazysecrets runs in: it goes back to where it was
|
|
// left there, unless it was started on a link. "" doesn't.
|
|
Dir string
|
|
// FlatSources lists sources without provider groups.
|
|
FlatSources bool
|
|
// CollapseGroups starts with the Sources pane's groups folded, when
|
|
// there are several.
|
|
CollapseGroups bool
|
|
// Store keeps stars, notes and the layout; defaults to an unsaved one.
|
|
Store *store.Store
|
|
// LoadDelay is how long the cursor must rest on a row before its
|
|
// contents load (settled); 0 loads at once.
|
|
LoadDelay time.Duration
|
|
// ExpiryWarning marks items expiring within this long; 0 marks only
|
|
// expired ones.
|
|
ExpiryWarning time.Duration
|
|
// RevealTimeout hides a revealed value after this long; 0 never.
|
|
RevealTimeout time.Duration
|
|
// HideOnBlur hides a revealed value when the terminal loses focus.
|
|
HideOnBlur bool
|
|
// OnActive is called on key presses and when the terminal gains
|
|
// focus, so the link handler can pick the instance last used.
|
|
OnActive func()
|
|
// Version is shown at the bottom right ("0.1.0"); empty shows none.
|
|
Version string
|
|
// Commands are the custom commands from the config (commands.go).
|
|
Commands []commands.Command
|
|
// Keys, if set, replaces the default keymap (keys.Map.Apply).
|
|
Keys *keys.Map
|
|
// Updater, if set, finds and installs newer releases (U); with
|
|
// UpdateCheck, lazysecrets also looks once a day on its own.
|
|
Updater *Updater
|
|
UpdateCheck bool
|
|
// HandlerSetup, if set, offers to set up the link handler at startup.
|
|
HandlerSetup *HandlerSetup
|
|
// NoHistory stops counting uses of items for the Recent list.
|
|
NoHistory bool
|
|
// Open is a reference to open at startup (lazysecrets open <ref>).
|
|
Open *ref.Ref
|
|
// Styles, if set, replaces the default theme (styles.FromTheme), and
|
|
// DefaultFg the terminal's default text colour.
|
|
Styles *styles.Styles
|
|
DefaultFg color.Color
|
|
}
|
|
|
|
// Clipboard is implemented by clipboard.Clipboard.
|
|
type Clipboard interface {
|
|
Write(s string) tea.Cmd
|
|
Clear(fingerprint [32]byte) tea.Cmd
|
|
// Read returns the clipboard's text, or an error where it can't be
|
|
// read (over SSH).
|
|
Read() (string, error)
|
|
}
|
|
|
|
type Model struct {
|
|
providers []provider.Provider
|
|
byID map[string]provider.Provider
|
|
timeout time.Duration
|
|
|
|
keys keys.Map
|
|
st styles.Styles
|
|
help help.Model
|
|
spin spinner.Model
|
|
|
|
sources panes.List[sourceRow]
|
|
containers panes.List[provider.Container]
|
|
secrets panes.List[provider.Object]
|
|
detail panes.Detail
|
|
|
|
focus pane
|
|
termBlurred bool // the terminal (or tmux pane) doesn't have focus
|
|
w, h int
|
|
split int // columns the user moved the divider from its default
|
|
heights [3]int // rows added to each left pane's default height
|
|
zoom zoom
|
|
spinning bool
|
|
status string // sticky errors from loading sources
|
|
flash flash // transient message, cleared on the next key press
|
|
|
|
// auth is set when a backend needs the user to sign in again.
|
|
auth *provider.AuthError
|
|
|
|
defaultFg color.Color // from the theme; nil keeps the terminal's
|
|
store *store.Store
|
|
icons icons.Set
|
|
|
|
cache cache
|
|
|
|
clip Clipboard
|
|
clipClear time.Duration
|
|
openURL func(string) error
|
|
// fields is the fourth pane for anything but a secret; lastLeft is
|
|
// the left pane it describes while the fourth has focus (fields.go).
|
|
fields panes.Fields
|
|
lastLeft pane
|
|
details map[string]detailsState // by containerKey
|
|
// errLog is this session's errors, newest first; loggedFlash the
|
|
// footer's text when last looked at (errlog.go).
|
|
errLog []loggedErr
|
|
loggedFlash string
|
|
getenv func(string) string
|
|
nvimOpen func(server, path string, line int) error // edit.go
|
|
now func() time.Time
|
|
// The cache file, whether the cache changed since it was saved, and
|
|
// when each provider's Sources list was loaded (persist.go).
|
|
cachePath string
|
|
// configFiles and statePath are for the status pane (status.go).
|
|
configFiles []string
|
|
statePath string
|
|
statusW int // the status pane's width: the left column's, zoomed or not
|
|
changelog []panes.Field
|
|
cacheDirty bool
|
|
sourcesAt map[string]time.Time
|
|
placeDir string
|
|
savedPlace string // the link last saved as the place
|
|
|
|
// A pending clear of a copied value, counted down in the footer.
|
|
// clipToken identifies the latest copy so older ticks are ignored.
|
|
clipToken int
|
|
clipFP [32]byte // fingerprint of the copied value
|
|
clipLabel string
|
|
clipDeadline time.Time // zero when nothing is pending
|
|
|
|
collapsed map[string]bool // folded groups in the Sources pane, by provider ID
|
|
// current is the source lazysecrets started in, as soon as its
|
|
// provider finds it (provider.Current), before Sources lists them all.
|
|
current *provider.Source
|
|
flatSources bool // Sources pane without provider groups
|
|
sourcesFavs bool // Sources pane on its Favourites tab
|
|
history bool // count uses of items (usage.go)
|
|
onActive func()
|
|
|
|
// A revealed value hides at revealDeadline; revealToken drops ticks
|
|
// of an earlier reveal.
|
|
revealTimeout time.Duration
|
|
expiryWarning time.Duration
|
|
hideOnBlur bool
|
|
revealDeadline time.Time
|
|
revealToken int
|
|
|
|
commands []commands.Command // custom commands (commands.go)
|
|
version string // shown at the bottom right
|
|
crawl crawl // loading every vault for search (search.go)
|
|
search *searchView // the search popup, when open (searchview.go)
|
|
|
|
handlerSetup *HandlerSetup
|
|
|
|
updater *Updater
|
|
updateCheck bool
|
|
fetchOnStart bool // look for a newer release in Init
|
|
newer *update.Release // a newer release to offer
|
|
updated string // the release installed, waiting for a restart
|
|
askHandler bool // the link handler popup is still to show
|
|
|
|
// Loads the selection starts after a key press wait settle first, and
|
|
// are dropped if the cursor moves on meanwhile, so holding j doesn't
|
|
// send a request per row. waiting is settle while handling a key.
|
|
settle, waiting time.Duration
|
|
sourcesFiltered bool // the Sources rows were built for a filter
|
|
sourcesTouched bool // the user has moved in the Sources pane
|
|
|
|
// Sources stream in per provider; gen discards results from a
|
|
// superseded load.
|
|
sourcesGen int
|
|
sourcesPending int
|
|
sourceResults map[string]sourcesMsg // by provider ID
|
|
|
|
// At most one request in flight per slot. Responses whose id doesn't
|
|
// match are stale and dropped.
|
|
seq int
|
|
reqID [slotCount]int
|
|
cancel [slotCount]context.CancelFunc
|
|
|
|
// Last selection seen, to detect changes after each update.
|
|
selSource, selContainer, selSecret string
|
|
|
|
// objType is the active tab of the Secrets pane. wantType is the tab the
|
|
// user last picked, restored when moving to a container that has it.
|
|
objType, wantType provider.ObjectType
|
|
|
|
// versionsOf is set while the Secrets pane lists an object's versions.
|
|
versionsOf *provider.Object
|
|
|
|
nav *nav // opening a reference, in progress
|
|
pendingCmd tea.Cmd // a command started by advanceNav
|
|
prompt *modal // popup that has the keyboard
|
|
linked []provider.Container // vaults opened from links, this session
|
|
open *ref.Ref // reference to open at startup
|
|
|
|
// A lazysecrets:// link found on the clipboard, offered in the footer.
|
|
dismissedLink string // the last link offered: not offered again
|
|
clipChecked time.Time
|
|
}
|
|
|
|
// currentMsg is the source lazysecrets started in (provider.Current).
|
|
type currentMsg struct {
|
|
gen int
|
|
source provider.Source
|
|
}
|
|
|
|
type sourcesMsg struct {
|
|
gen int
|
|
provider string // provider ID
|
|
sources []provider.Source
|
|
err error
|
|
}
|
|
|
|
type containersMsg struct {
|
|
req int
|
|
key string // cache key
|
|
items []provider.Container
|
|
err error
|
|
}
|
|
|
|
type secretsMsg struct {
|
|
req int
|
|
key string // cache key
|
|
// bound is set when the container's account was found by trying each
|
|
// candidate; it is the container as served by the one that worked.
|
|
bound *provider.Container
|
|
items []provider.Object
|
|
err error
|
|
}
|
|
|
|
type valueMsg struct {
|
|
req int
|
|
obj provider.Object
|
|
value provider.SecretValue
|
|
err error
|
|
}
|
|
|
|
type describeMsg struct {
|
|
req int
|
|
key string // cache key
|
|
obj provider.Object
|
|
err error
|
|
}
|
|
|
|
type copyMsg struct {
|
|
req int
|
|
obj provider.Object
|
|
name string
|
|
value provider.SecretValue
|
|
err error
|
|
}
|
|
|
|
type clipTickMsg struct{ token int }
|
|
|
|
// revealTickMsg counts down a revealed value's display (reveal.go).
|
|
type revealTickMsg struct{ token int }
|
|
|
|
type versionCountMsg struct {
|
|
req int
|
|
key string // cache key, shared with the versions view
|
|
id string // object ID
|
|
items []provider.Object
|
|
err error
|
|
}
|
|
|
|
type loginDoneMsg struct{ err error }
|
|
|
|
type flash struct {
|
|
text string
|
|
isErr bool // a failure: "✗ copy failed: …"
|
|
ok bool // a confirmation: "✓ copied api-key"
|
|
warn bool // can't be done here: "⚠ select a vault to share"
|
|
}
|
|
|
|
// done is a confirmation flash, "✓ copied api-key".
|
|
func done(text string) flash { return flash{text: text, ok: true} }
|
|
|
|
// warning says what can't be done here, and why.
|
|
func warning(text string) flash { return flash{text: text, warn: true} }
|
|
|
|
func New(providers []provider.Provider, opts Options) Model {
|
|
if opts.Timeout <= 0 {
|
|
opts.Timeout = 30 * time.Second
|
|
}
|
|
iconSet := icons.None
|
|
if opts.Icons {
|
|
iconSet = icons.NerdFont
|
|
}
|
|
m := Model{
|
|
providers: providers,
|
|
byID: map[string]provider.Provider{},
|
|
timeout: opts.Timeout,
|
|
keys: keys.Default(),
|
|
st: styles.Default(),
|
|
spin: spinner.New(spinner.WithSpinner(spinner.Dot)),
|
|
detail: panes.NewDetail(iconSet),
|
|
icons: iconSet,
|
|
sourceResults: map[string]sourcesMsg{},
|
|
clip: opts.Clipboard,
|
|
clipClear: opts.ClipboardClear,
|
|
now: time.Now,
|
|
getenv: os.Getenv,
|
|
nvimOpen: nvimOpen,
|
|
cache: newCache(),
|
|
details: map[string]detailsState{},
|
|
fields: panes.NewFields(),
|
|
}
|
|
if m.clip == nil {
|
|
m.clip = clipboard.New()
|
|
}
|
|
m.openURL = opts.OpenURL
|
|
if m.openURL == nil {
|
|
m.openURL = browser.Open
|
|
}
|
|
for _, p := range providers {
|
|
m.byID[p.ID()] = p
|
|
}
|
|
|
|
// Before the lists: their render functions capture the store.
|
|
m.store = opts.Store
|
|
if m.store == nil {
|
|
m.store, _ = store.Load("")
|
|
}
|
|
m.sources = newSourcesList(iconSet, m.store)
|
|
m.sources.Empty = "no sources found"
|
|
m.collapsed = map[string]bool{}
|
|
m.flatSources = opts.FlatSources
|
|
if opts.CollapseGroups && len(providers) > 1 {
|
|
for _, p := range providers {
|
|
m.collapsed[p.ID()] = true
|
|
}
|
|
}
|
|
m.history = !opts.NoHistory
|
|
m.onActive = opts.OnActive
|
|
m.commands = opts.Commands
|
|
m.version = opts.Version
|
|
if opts.Keys != nil {
|
|
m.keys = *opts.Keys
|
|
}
|
|
m.detail.RevealKey, m.detail.VersionsKey, m.detail.CopyKey = keys.Name(m.keys.Reveal), keys.Name(m.keys.Versions), keys.Name(m.keys.Copy)
|
|
m.revealTimeout, m.hideOnBlur = opts.RevealTimeout, opts.HideOnBlur
|
|
m.expiryWarning = opts.ExpiryWarning
|
|
m.handlerSetup = opts.HandlerSetup
|
|
m.updater, m.updateCheck = opts.Updater, opts.UpdateCheck
|
|
m.startUpdateCheck()
|
|
m.askHandler = opts.HandlerSetup != nil && !m.store.Flag(flagNoHandlerPrompt)
|
|
m.settle = opts.LoadDelay
|
|
if opts.Styles != nil {
|
|
m.st = *opts.Styles
|
|
}
|
|
m.defaultFg = opts.DefaultFg
|
|
m.help = newHelp(m.st)
|
|
m.open = opts.Open
|
|
saved := m.store.Layout()
|
|
m.split, m.heights = saved.Split, saved.Heights
|
|
|
|
m.containers = panes.NewList("[2] Vaults", panes.Item[provider.Container]{
|
|
ID: containerKey,
|
|
Filter: func(c provider.Container) string { return c.Name },
|
|
FilterExtra: func(c provider.Container) string { return m.store.Node(storeKey(c)).Note },
|
|
Render: m.containerRender(false),
|
|
})
|
|
|
|
m.secrets = panes.NewList("[3] Secrets", panes.Item[provider.Object]{
|
|
// Versions of one object share its ID.
|
|
ID: func(s provider.Object) string { return s.ID + "@" + s.Version },
|
|
Filter: func(s provider.Object) string { return s.Name },
|
|
// Typing "expir" finds what has expired or soon will.
|
|
FilterExtra: func(o provider.Object) string { return m.store.Node(objectKey(o)).Note + " " + m.expiryWord(o) },
|
|
Render: m.objectRender(),
|
|
})
|
|
m.secrets.Empty = "no secrets"
|
|
m.objType, m.wantType = provider.TypeSecret, provider.TypeSecret
|
|
|
|
m.sources.SetLoading()
|
|
m.sourcesGen = 1
|
|
m.sourcesPending = len(providers)
|
|
m.spinning = true
|
|
|
|
m.cachePath, m.placeDir, m.sourcesAt = opts.CacheFile, opts.Dir, map[string]time.Time{}
|
|
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
|
|
if m.cachePath != "" {
|
|
m.loadCacheFile()
|
|
if len(m.sourceResults) > 0 {
|
|
m.rebuildSources()
|
|
m.sources.Loading = true // reloading what the file had
|
|
m.sources.Age = m.cachedAge(m.oldestSources())
|
|
}
|
|
}
|
|
if m.placeDir != "" && opts.Open == nil {
|
|
m.restorePlace()
|
|
}
|
|
return m
|
|
}
|
|
|
|
// renderVersion draws a version row: short ID, date and state. current is
|
|
// the newest version's ID.
|
|
func renderVersion(now time.Time, current string) func(styles.Styles, provider.Object, int) string {
|
|
return func(st styles.Styles, s provider.Object, _ int) string {
|
|
return panes.VersionLine(st, s, now, current)
|
|
}
|
|
}
|
|
|
|
// setVersions shows a version list, marking the newest as current.
|
|
func (m *Model) setVersions(items []provider.Object) {
|
|
current := ""
|
|
if len(items) > 0 {
|
|
current = items[0].Version
|
|
}
|
|
m.secrets.SetRender(renderVersion(m.now(), current))
|
|
m.secrets.SetItems(items)
|
|
}
|
|
|
|
func (m Model) Init() tea.Cmd {
|
|
cmds := []tea.Cmd{m.sourcesCmd(), m.spin.Tick, m.checkClipboard()}
|
|
if m.cachePath != "" || m.placeDir != "" {
|
|
cmds = append(cmds, tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} }))
|
|
}
|
|
if m.fetchOnStart {
|
|
cmds = append(cmds, m.fetchLatest(false))
|
|
}
|
|
if m.open != nil {
|
|
r := *m.open
|
|
cmds = append(cmds, func() tea.Msg { return openRefMsg{r} })
|
|
}
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
// openRefMsg starts opening a reference from outside the key handler.
|
|
type openRefMsg struct{ ref ref.Ref }
|
|
|
|
// OpenLink is a message offering r in the Open link popup, for links
|
|
// handed over by the link handler (Program.Send).
|
|
func OpenLink(r ref.Ref) tea.Msg { return openRefMsg{r} }
|
|
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
var cmds []tea.Cmd
|
|
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.w, m.h = msg.Width, msg.Height
|
|
|
|
case spinner.TickMsg:
|
|
if !m.anyLoading() {
|
|
m.spinning = false
|
|
return m, nil
|
|
}
|
|
var cmd tea.Cmd
|
|
m.spin, cmd = m.spin.Update(msg)
|
|
return m, cmd
|
|
|
|
case tea.KeyPressMsg:
|
|
m.touched()
|
|
m.flash = flash{}
|
|
m.nav = nil // the user took over
|
|
cmds = append(cmds, m.maybeCheckClipboard())
|
|
cmd, quit := m.handleKey(msg)
|
|
if quit {
|
|
return m, tea.Quit
|
|
}
|
|
cmds = append(cmds, cmd)
|
|
if m.nav == nil { // jumps and links go straight there
|
|
m.waiting = m.settle
|
|
}
|
|
|
|
case sourcesMsg:
|
|
cmds = append(cmds, m.handleSources(msg))
|
|
|
|
case updateMsg:
|
|
cmds = append(cmds, m.handleUpdate(msg))
|
|
|
|
case installedMsg:
|
|
m.handleInstalled(msg)
|
|
|
|
case currentMsg:
|
|
if msg.gen == m.sourcesGen {
|
|
m.current = &msg.source
|
|
m.rebuildSources()
|
|
m.queueSource(msg.source, false)
|
|
cmds = append(cmds, m.crawlStart())
|
|
}
|
|
|
|
case handlerInstalledMsg:
|
|
m.handlerInstalled(msg)
|
|
|
|
case openRefMsg:
|
|
m.deferHandlerSetup() // the link first
|
|
cmds = append(cmds, m.offerRef("Open link", msg.ref, false))
|
|
|
|
case resolvedMsg:
|
|
m.handleResolved(msg)
|
|
|
|
case tea.PasteMsg:
|
|
cmds = append(cmds, m.handlePaste(msg))
|
|
|
|
case tea.BlurMsg:
|
|
m.termBlurred = true
|
|
if m.hideOnBlur && m.detail.State() == panes.ValueShown {
|
|
m.hideValue()
|
|
}
|
|
|
|
case commandValueMsg:
|
|
cmds = append(cmds, m.handleCommandValue(msg))
|
|
|
|
case commandDoneMsg:
|
|
m.handleCommandDone(msg)
|
|
|
|
case editDoneMsg:
|
|
cmds = append(cmds, m.handleEditDone(msg))
|
|
|
|
case persistTickMsg:
|
|
cmds = append(cmds, m.persistTick())
|
|
|
|
case persistedMsg:
|
|
m.handlePersisted(msg)
|
|
|
|
case detailsMsg:
|
|
if msg.req == m.reqID[slotDetails] {
|
|
m.done(slotDetails)
|
|
}
|
|
if errors.Is(msg.err, context.Canceled) || errors.Is(msg.err, context.DeadlineExceeded) {
|
|
delete(m.details, msg.key) // tried again when shown again
|
|
} else {
|
|
m.details[msg.key] = detailsState{attrs: msg.attrs, err: msg.err}
|
|
}
|
|
|
|
case crawlMsg:
|
|
cmds = append(cmds, m.handleCrawl(msg))
|
|
|
|
case revealTickMsg:
|
|
cmds = append(cmds, m.revealTick(msg))
|
|
|
|
case tea.FocusMsg:
|
|
m.touched()
|
|
m.termBlurred = false
|
|
// Back from another app, maybe with a link copied there.
|
|
m.clipChecked = m.now()
|
|
cmds = append(cmds, m.checkClipboard())
|
|
|
|
case openPromptFillMsg:
|
|
m.fillOpenPrompt(string(msg))
|
|
|
|
case clipboardTextMsg:
|
|
m.offerClipboardLink(string(msg))
|
|
|
|
case containersMsg:
|
|
if msg.req != m.reqID[slotContainers] {
|
|
break
|
|
}
|
|
m.done(slotContainers)
|
|
if msg.err != nil {
|
|
m.noteErr(msg.err)
|
|
if src, ok := m.selectedSource(); ok {
|
|
m.logErrOf(src.Name, msg.err)
|
|
}
|
|
delete(m.cache.containers, msg.key)
|
|
m.containers.SetError(msg.err)
|
|
} else {
|
|
m.cache.containers[msg.key] = entry[[]provider.Container]{msg.items, m.now()}
|
|
m.cacheDirty = true
|
|
m.containers.SetItems(m.starredContainersFirst(msg.items))
|
|
}
|
|
|
|
case secretsMsg:
|
|
if msg.req != m.reqID[slotObjects] {
|
|
break
|
|
}
|
|
m.done(slotObjects)
|
|
if errors.Is(msg.err, provider.ErrUnsupported) && m.versionsOf != nil {
|
|
m.flash = warning(fmt.Sprintf("versions aren't supported for %s", m.versionsOf.Container.Source.Kind))
|
|
m.closeVersions()
|
|
} else if msg.err != nil {
|
|
m.noteErr(msg.err)
|
|
if c, ok := m.containers.Selected(); ok {
|
|
m.logErrOf(c.Name, msg.err)
|
|
}
|
|
delete(m.cache.objects, msg.key)
|
|
m.secrets.SetError(msg.err)
|
|
} else {
|
|
if msg.bound != nil {
|
|
msg.key = m.rebind(*msg.bound)
|
|
}
|
|
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
|
m.cacheDirty = true
|
|
if m.versionsOf != nil {
|
|
m.setVersions(msg.items)
|
|
} else {
|
|
m.secrets.SetItems(m.starredFirst(msg.items))
|
|
}
|
|
}
|
|
|
|
case valueMsg:
|
|
if msg.req != m.reqID[slotValue] {
|
|
break
|
|
}
|
|
m.done(slotValue)
|
|
if msg.err != nil {
|
|
m.noteErr(msg.err)
|
|
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
|
m.detail.SetValueError(msg.err)
|
|
} else {
|
|
m.detail.SetValue(msg.value)
|
|
m.usedObject(msg.obj)
|
|
cmds = append(cmds, m.startRevealTimer())
|
|
}
|
|
|
|
case versionCountMsg:
|
|
if msg.req != m.reqID[slotVersions] {
|
|
break
|
|
}
|
|
m.done(slotVersions)
|
|
// Errors and unsupported providers just leave the count out.
|
|
if msg.err == nil {
|
|
m.cache.objects[msg.key] = entry[[]provider.Object]{msg.items, m.now()}
|
|
m.cacheDirty = true
|
|
m.detail.SetVersions(msg.id, msg.items)
|
|
}
|
|
|
|
case describeMsg:
|
|
if msg.req != m.reqID[slotDescribe] {
|
|
break
|
|
}
|
|
m.done(slotDescribe)
|
|
if msg.err != nil {
|
|
m.noteErr(msg.err)
|
|
m.logErrOf(msg.obj.Container.Name+"/"+msg.obj.Name, msg.err)
|
|
m.detail.SetDescribeError(msg.err)
|
|
} else {
|
|
m.cache.details[msg.key] = entry[provider.Object]{msg.obj, m.now()}
|
|
m.cacheDirty = true
|
|
m.detail.SetDescribed(msg.obj)
|
|
}
|
|
|
|
case copyMsg:
|
|
if msg.req != m.reqID[slotCopy] {
|
|
break
|
|
}
|
|
m.done(slotCopy)
|
|
if msg.err != nil {
|
|
m.noteErr(msg.err)
|
|
m.flash = flash{text: "copy failed: " + msg.err.Error(), isErr: true}
|
|
} else {
|
|
cmds = append(cmds, m.copyText(msg.name, msg.value.Reveal(), true))
|
|
if msg.obj.Name != "" {
|
|
m.usedObject(msg.obj)
|
|
}
|
|
}
|
|
|
|
case clipTickMsg:
|
|
if msg.token != m.clipToken || m.clipDeadline.IsZero() {
|
|
break
|
|
}
|
|
if m.now().Before(m.clipDeadline) {
|
|
cmds = append(cmds, m.clipTick())
|
|
break
|
|
}
|
|
cmds = append(cmds, m.clip.Clear(m.clipFP))
|
|
m.cancelClipClear()
|
|
m.flash = done("clipboard cleared")
|
|
|
|
case clipboard.ErrorMsg:
|
|
m.flash = flash{text: "clipboard: " + msg.Err.Error(), isErr: true}
|
|
|
|
case loginDoneMsg:
|
|
if msg.err != nil {
|
|
m.flash = flash{text: "sign-in failed: " + msg.err.Error(), isErr: true}
|
|
break
|
|
}
|
|
m.auth = nil
|
|
m.cache = newCache()
|
|
m.flash = done("signed in; reloading")
|
|
m.sources.Refresh()
|
|
cmds = append(cmds, m.loadSources())
|
|
m.selSource = "\x00" // force the selection cascade to reload everything
|
|
}
|
|
|
|
cmds = append(cmds, m.syncSelection())
|
|
// Opening a link steps through panes as lists load; lists served from
|
|
// the cache load at once, so keep going while it makes progress.
|
|
for range 8 {
|
|
if !m.advanceNav() {
|
|
break
|
|
}
|
|
cmds = append(cmds, m.pendingCmd, m.syncSelection())
|
|
m.pendingCmd = nil
|
|
}
|
|
if m.sources.HasFilter() != m.sourcesFiltered {
|
|
m.rebuildSources() // switch between grouped and flat-while-filtering rows
|
|
cmds = append(cmds, m.syncSelection())
|
|
}
|
|
m.waiting = 0
|
|
m.askHandlerSetup()
|
|
m.keys.Login.SetEnabled(m.loginFor() != nil)
|
|
if m.focus != paneDetail {
|
|
m.lastLeft = m.focus
|
|
}
|
|
m.logFlash()
|
|
cmds = append(cmds, m.syncFields())
|
|
if m.anyLoading() && !m.spinning {
|
|
m.spinning = true
|
|
cmds = append(cmds, m.spin.Tick)
|
|
}
|
|
m.layout()
|
|
return m, tea.Batch(cmds...)
|
|
}
|
|
|
|
// handleKey returns quit=true when the program should exit.
|
|
func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
|
if msg.String() == "ctrl+c" {
|
|
return nil, true
|
|
}
|
|
if m.prompt != nil {
|
|
return m.handleModal(msg), false
|
|
}
|
|
if m.search != nil {
|
|
return m.handleSearch(msg), false
|
|
}
|
|
if l := m.focusedFilter(); l != nil && l.Filtering() {
|
|
switch {
|
|
case key.Matches(msg, m.keys.AcceptFilter):
|
|
l.AcceptFilter()
|
|
case key.Matches(msg, m.keys.ClearFilter):
|
|
l.ClearFilter()
|
|
case key.Matches(msg, m.keys.FilterUp):
|
|
m.move(-1)
|
|
case key.Matches(msg, m.keys.FilterDown):
|
|
m.move(1)
|
|
case key.Matches(msg, m.keys.FilterCopy):
|
|
return m.copyValue(), false
|
|
case key.Matches(msg, m.keys.FilterReveal):
|
|
return m.toggleReveal(), false
|
|
case key.Matches(msg, m.keys.FilterStar):
|
|
return m.toggleFavourite(), false
|
|
default:
|
|
return l.UpdateFilter(msg), false
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
if c, ok := m.commandForKey(msg); ok {
|
|
return m.startCommand(c), false
|
|
}
|
|
k := m.keys
|
|
switch {
|
|
case key.Matches(msg, k.Commands):
|
|
return m.showCommands(), false
|
|
case key.Matches(msg, k.Quit):
|
|
return nil, true
|
|
case key.Matches(msg, k.Help):
|
|
return m.showKeybindings(), false
|
|
case key.Matches(msg, k.Up):
|
|
m.move(-1)
|
|
case key.Matches(msg, k.Down):
|
|
m.move(1)
|
|
case key.Matches(msg, k.FilterCopy):
|
|
return m.copyValue(), false
|
|
case key.Matches(msg, k.FilterReveal):
|
|
return m.toggleReveal(), false
|
|
case key.Matches(msg, k.Top):
|
|
m.jump(true)
|
|
case key.Matches(msg, k.Bottom):
|
|
m.jump(false)
|
|
case key.Matches(msg, k.NextPane):
|
|
// The four main panes, round; the status pane (0) leads into them.
|
|
m.focus = (m.focus + 1) % paneStatus
|
|
if r, ok := m.sources.Selected(); ok && m.focus == paneContainers && r.kind == rowGroup {
|
|
m.focus = paneDetail // an account's row: nothing between
|
|
}
|
|
case key.Matches(msg, k.Details):
|
|
m.focus = paneDetail // what's selected, on the right
|
|
case key.Matches(msg, k.PrevPane):
|
|
if m.focus == paneDetail {
|
|
m.focus = m.lastLeft // back to what it describes
|
|
} else {
|
|
m.focus = (m.focus + paneStatus - 1) % paneStatus
|
|
}
|
|
case key.Matches(msg, k.Pane1):
|
|
m.focus = paneSources
|
|
case key.Matches(msg, k.Pane2):
|
|
m.focus = paneContainers
|
|
case key.Matches(msg, k.Pane3):
|
|
m.focus = paneSecrets
|
|
case key.Matches(msg, k.Pane4):
|
|
m.focus = paneDetail
|
|
case key.Matches(msg, k.Pane0):
|
|
m.focus = paneStatus
|
|
case m.focus == paneStatus && key.Matches(msg, k.Enter):
|
|
m.focus = paneDetail // its rows are on the right
|
|
case m.focus == paneSources && (key.Matches(msg, k.Enter) || key.Matches(msg, k.Reveal)) && m.toggleGroup():
|
|
// folded or unfolded a provider group
|
|
case m.focus == paneDetail && key.Matches(msg, k.Enter) && m.fieldLink() != "":
|
|
r, err := ref.Parse(m.fieldLink())
|
|
if err == nil {
|
|
f, _ := m.fields.Selected()
|
|
m.startNav(r, actGo, f.Row)
|
|
}
|
|
case key.Matches(msg, k.Enter):
|
|
if m.focus < paneDetail {
|
|
m.focus++
|
|
}
|
|
case key.Matches(msg, k.Share):
|
|
return m.share(false), false
|
|
case key.Matches(msg, k.ShareMenu):
|
|
return m.share(true), false
|
|
case key.Matches(msg, k.OpenRef):
|
|
return m.startOpenPrompt(), false
|
|
case key.Matches(msg, k.Note):
|
|
return m.editNote(), false
|
|
case key.Matches(msg, k.Favourites):
|
|
return m.showFavourites(), false
|
|
case key.Matches(msg, k.Search):
|
|
return m.showSearch(), false
|
|
case key.Matches(msg, k.Recent):
|
|
return m.showRecent(), false
|
|
case key.Matches(msg, k.Favourite):
|
|
return m.toggleFavourite(), false
|
|
case key.Matches(msg, k.SplitRight):
|
|
m.moveSplit(splitStep)
|
|
m.saveLayout()
|
|
case key.Matches(msg, k.SplitLeft):
|
|
m.moveSplit(-splitStep)
|
|
m.saveLayout()
|
|
case key.Matches(msg, k.SplitReset):
|
|
m.split, m.heights, m.zoom = 0, [3]int{}, zoomNormal
|
|
m.saveLayout()
|
|
case key.Matches(msg, k.Taller), key.Matches(msg, k.Shorter):
|
|
delta := heightStep
|
|
if key.Matches(msg, k.Shorter) {
|
|
delta = -heightStep
|
|
}
|
|
if m.resizeFocused(delta) {
|
|
m.saveLayout()
|
|
} else {
|
|
m.flash = warning("focus a left pane to change its height")
|
|
}
|
|
case key.Matches(msg, k.ZoomIn):
|
|
m.cycleZoom(1)
|
|
case key.Matches(msg, k.ZoomOut):
|
|
m.cycleZoom(-1)
|
|
case key.Matches(msg, k.SourcesView):
|
|
m.toggleSourcesView()
|
|
case key.Matches(msg, k.Versions):
|
|
if m.versionsOf != nil {
|
|
m.closeVersions()
|
|
} else if o, ok := m.detail.Secret(); ok {
|
|
m.versionsOf = &o
|
|
m.focus = paneSecrets
|
|
}
|
|
case key.Matches(msg, k.Back):
|
|
if f := m.focusedFilter(); f != nil && f.HasFilter() {
|
|
f.ClearFilter()
|
|
} else if m.versionsOf != nil && (m.focus == paneSecrets || m.focus == paneDetail) {
|
|
m.closeVersions()
|
|
} else if m.focus == paneStatus {
|
|
m.focus = paneSources
|
|
} else if m.focus > paneSources {
|
|
m.focus--
|
|
}
|
|
case key.Matches(msg, k.Filter):
|
|
if l := m.focusedFilter(); l != nil {
|
|
return l.StartFilter(), false
|
|
}
|
|
case key.Matches(msg, k.NextTab):
|
|
m.switchTab(1)
|
|
case key.Matches(msg, k.PrevTab):
|
|
m.switchTab(-1)
|
|
case key.Matches(msg, k.Reveal):
|
|
return m.toggleReveal(), false
|
|
case key.Matches(msg, k.Copy):
|
|
return m.copyValue(), false
|
|
case key.Matches(msg, k.CopyRef):
|
|
if o, ok := m.detail.Secret(); ok && o.Reference != "" {
|
|
return m.copyText(o.Name+" reference", o.Reference, false), false
|
|
}
|
|
case key.Matches(msg, k.Open):
|
|
return m.openSelected(), false
|
|
case key.Matches(msg, k.Edit):
|
|
return m.edit(), false
|
|
case key.Matches(msg, k.Login):
|
|
if auth := m.loginFor(); auth != nil {
|
|
c := exec.Command(auth.Login[0], auth.Login[1:]...)
|
|
return tea.ExecProcess(c, func(err error) tea.Msg { return loginDoneMsg{err: err} }), false
|
|
}
|
|
case key.Matches(msg, k.Refresh):
|
|
return m.refresh(), false
|
|
case key.Matches(msg, k.Update):
|
|
return m.update(), false
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// filterable is the part of panes.List the app needs regardless of item type.
|
|
type filterable interface {
|
|
Filtering() bool
|
|
HasFilter() bool
|
|
StartFilter() tea.Cmd
|
|
AcceptFilter()
|
|
ClearFilter()
|
|
UpdateFilter(tea.Msg) tea.Cmd
|
|
}
|
|
|
|
func (m *Model) focusedFilter() filterable {
|
|
switch m.focus {
|
|
case paneSources:
|
|
return &m.sources
|
|
case paneContainers:
|
|
return &m.containers
|
|
case paneSecrets:
|
|
return &m.secrets
|
|
case paneDetail:
|
|
if m.fields.Shown() {
|
|
return &m.fields
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *Model) move(delta int) {
|
|
switch m.focus {
|
|
case paneSources:
|
|
m.sourcesTouched = true
|
|
m.sources.Move(delta)
|
|
case paneContainers:
|
|
m.containers.Move(delta)
|
|
case paneSecrets:
|
|
m.secrets.Move(delta)
|
|
case paneDetail:
|
|
if m.fields.Shown() {
|
|
m.fields.Move(delta)
|
|
} else {
|
|
m.detail.Scroll(delta)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Model) jump(top bool) {
|
|
switch {
|
|
case m.focus == paneSources && top:
|
|
m.sourcesTouched = true
|
|
m.sources.Top()
|
|
case m.focus == paneSources:
|
|
m.sourcesTouched = true
|
|
m.sources.Bottom()
|
|
case m.focus == paneContainers && top:
|
|
m.containers.Top()
|
|
case m.focus == paneContainers:
|
|
m.containers.Bottom()
|
|
case m.focus == paneSecrets && top:
|
|
m.secrets.Top()
|
|
case m.focus == paneSecrets:
|
|
m.secrets.Bottom()
|
|
case m.focus == paneDetail && m.fields.Shown() && top:
|
|
m.fields.Top()
|
|
case m.focus == paneDetail && m.fields.Shown():
|
|
m.fields.Bottom()
|
|
case m.focus == paneDetail && top:
|
|
m.detail.ScrollTop()
|
|
case m.focus == paneDetail:
|
|
m.detail.ScrollBottom()
|
|
}
|
|
}
|
|
|
|
func (m *Model) toggleReveal() tea.Cmd {
|
|
s, ok := m.detail.Secret()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
switch m.detail.State() {
|
|
case panes.ValueShown:
|
|
m.hideValue()
|
|
return nil
|
|
case panes.ValueLoading:
|
|
m.cancelReq(slotValue)
|
|
m.detail.Hide()
|
|
return nil
|
|
}
|
|
return m.fetchValue(s)
|
|
}
|
|
|
|
func (m *Model) refresh() tea.Cmd {
|
|
switch m.focus {
|
|
case paneSources:
|
|
m.sources.Refresh()
|
|
return m.loadSources()
|
|
case paneContainers:
|
|
if src, ok := m.selectedSource(); ok {
|
|
m.containers.Refresh()
|
|
m.details = map[string]detailsState{}
|
|
return m.loadContainers(src, m.selSource)
|
|
}
|
|
case paneSecrets:
|
|
if c, ok := m.containers.Selected(); ok {
|
|
m.secrets.Refresh()
|
|
return m.loadObjects(c, m.objType, m.selContainer)
|
|
}
|
|
case paneDetail:
|
|
if s, ok := m.detail.Secret(); ok && m.detail.State() != panes.ValueHidden {
|
|
return m.fetchValue(s)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// syncSelection cascades selection changes down the hierarchy: a new source
|
|
// loads its containers, a new container loads its secrets, a new secret
|
|
// resets the detail pane. It runs after every update.
|
|
func (m *Model) syncSelection() tea.Cmd {
|
|
var cmds []tea.Cmd
|
|
|
|
if id := m.sources.SelectedID(); id != m.selSource {
|
|
m.cache.rememberCursor(m.selSource, m.containers.SelectedID())
|
|
m.selSource = id
|
|
m.cancelReq(slotContainers)
|
|
m.containers.Reset()
|
|
// Named after the level for the selected row's provider, also on
|
|
// a group header.
|
|
m.containers.Title, m.containers.Empty = "[2] Vaults", "select a subscription"
|
|
if r, ok := m.sources.Selected(); ok {
|
|
switch r.group {
|
|
case provider.KindDatabricks:
|
|
m.containers.Title, m.containers.Empty = "[2] Scopes", "select a workspace"
|
|
case provider.KindRepo:
|
|
m.containers.Title, m.containers.Empty = "[2] Files", "select a repo"
|
|
}
|
|
}
|
|
m.containers.SetRender(m.containerRender(false))
|
|
if m.onLinks() {
|
|
m.showLinkedContainers()
|
|
} else if src, ok := m.selectedSource(); ok {
|
|
m.containers.Title = "[2] Vaults"
|
|
m.containers.Empty = "no vaults found"
|
|
switch src.Kind {
|
|
case provider.KindDatabricks:
|
|
m.containers.Title = "[2] Scopes"
|
|
m.containers.Empty = "no secret scopes"
|
|
case provider.KindRepo:
|
|
m.containers.Title = "[2] Files"
|
|
m.containers.Empty = "no SOPS or env files here (see docs/repos.md)"
|
|
}
|
|
cmds = append(cmds, m.showContainers(src, id))
|
|
}
|
|
}
|
|
|
|
// The active tab is part of the key, so switching tabs reloads too.
|
|
c, hasContainer := m.containers.Selected()
|
|
listKey := m.containers.SelectedID()
|
|
if hasContainer {
|
|
m.syncTabs(c)
|
|
listKey += "|" + string(m.objType)
|
|
// Moving to another vault or tab leaves the versions view.
|
|
if v := m.versionsOf; v != nil && (containerKey(v.Container) != containerKey(c) || v.Type != m.objType) {
|
|
m.closeVersions()
|
|
}
|
|
if v := m.versionsOf; v != nil {
|
|
listKey = versionsKey(*v)
|
|
m.secrets.Title = "[3] Versions of " + v.Name
|
|
m.secrets.Tabs = nil
|
|
m.secrets.Empty = "no versions"
|
|
}
|
|
} else {
|
|
m.secrets.Tabs = nil
|
|
m.secrets.Title = "[3] Secrets"
|
|
m.secrets.Empty = "select a vault or scope"
|
|
}
|
|
if listKey != m.selContainer {
|
|
m.cache.rememberCursor(m.selContainer, m.secrets.SelectedID())
|
|
m.selContainer = listKey
|
|
m.cancelReq(slotObjects)
|
|
m.secrets.Reset()
|
|
switch {
|
|
case hasContainer && m.versionsOf != nil:
|
|
cmds = append(cmds, m.showVersions(*m.versionsOf, listKey))
|
|
case hasContainer:
|
|
m.secrets.SetRender(m.objectRender())
|
|
cmds = append(cmds, m.showObjects(c, listKey))
|
|
}
|
|
}
|
|
|
|
if id := m.secrets.SelectedID(); id != m.selSecret || (id == "" && m.detailStale()) {
|
|
m.selSecret = id
|
|
m.cancelReq(slotValue)
|
|
m.cancelReq(slotDescribe)
|
|
m.cancelReq(slotVersions)
|
|
if s, ok := m.secrets.Selected(); ok {
|
|
m.detail.ShowSecret(s)
|
|
cmds = append(cmds, m.describe(s), m.countVersions(s))
|
|
} else if c, ok := m.containers.Selected(); ok {
|
|
m.detail.ShowContainer(c)
|
|
} else {
|
|
m.detail.Clear()
|
|
}
|
|
m.refreshDetailNote()
|
|
}
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
// showContainers shows src's containers from the cache, loading them if
|
|
// they are missing or stale.
|
|
func (m *Model) showContainers(src provider.Source, key string) tea.Cmd {
|
|
e, ok := m.cache.containers[key]
|
|
if !ok {
|
|
m.containers.SetLoading()
|
|
return m.loadContainers(src, key)
|
|
}
|
|
m.containers.SetItems(m.starredContainersFirst(e.value))
|
|
m.containers.Select(m.cache.cursor[key])
|
|
if e.fresh(m.now()) {
|
|
return nil
|
|
}
|
|
m.containers.Refresh()
|
|
m.containers.Age = m.cachedAge(e.at)
|
|
return m.loadContainers(src, key)
|
|
}
|
|
|
|
// showObjects is showContainers for the Secrets pane.
|
|
func (m *Model) showObjects(c provider.Container, key string) tea.Cmd {
|
|
e, ok := m.cache.objects[key]
|
|
if !ok {
|
|
m.secrets.SetLoading()
|
|
return m.loadObjects(c, m.objType, key)
|
|
}
|
|
m.secrets.SetItems(m.starredFirst(e.value))
|
|
m.secrets.Select(m.cache.cursor[key])
|
|
if e.fresh(m.now()) {
|
|
return nil
|
|
}
|
|
m.secrets.Refresh()
|
|
m.secrets.Age = m.cachedAge(e.at)
|
|
return m.loadObjects(c, m.objType, key)
|
|
}
|
|
|
|
// showVersions lists o's versions, newest first. They are cached like
|
|
// object lists.
|
|
func (m *Model) showVersions(o provider.Object, key string) tea.Cmd {
|
|
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) {
|
|
m.setVersions(e.value)
|
|
m.secrets.Select(m.cache.cursor[key])
|
|
return nil
|
|
}
|
|
m.secrets.SetLoading()
|
|
p := m.providerFor(o.Container.Source)
|
|
ctx, req := m.begin(slotObjects)
|
|
return func() tea.Msg {
|
|
items, err := p.Versions(ctx, o)
|
|
for i := range items {
|
|
items[i].Container = o.Container
|
|
}
|
|
return secretsMsg{req: req, key: key, items: items, err: err}
|
|
}
|
|
}
|
|
|
|
// versionsKey caches an object's versions; the versions view and the
|
|
// version count share it.
|
|
func versionsKey(o provider.Object) string {
|
|
return containerKey(o.Container) + "|" + string(o.Type) + "|versions|" + o.ID
|
|
}
|
|
|
|
// countVersions fetches the selected object's versions in the background
|
|
// so the detail pane can list them.
|
|
func (m *Model) countVersions(o provider.Object) tea.Cmd {
|
|
if m.versionsOf != nil {
|
|
return nil // the list already shows them
|
|
}
|
|
key := versionsKey(o)
|
|
if e, ok := m.cache.objects[key]; ok {
|
|
m.detail.SetVersions(o.ID, e.value)
|
|
if e.fresh(m.now()) {
|
|
return nil
|
|
}
|
|
}
|
|
p := m.providerFor(o.Container.Source)
|
|
ctx, req := m.begin(slotVersions)
|
|
return m.settled(ctx, func() tea.Msg {
|
|
items, err := p.Versions(ctx, o)
|
|
for i := range items {
|
|
items[i].Container = o.Container
|
|
}
|
|
return versionCountMsg{req: req, key: key, id: o.ID, items: items, err: err}
|
|
})
|
|
}
|
|
|
|
// closeVersions returns the Secrets pane to the object list, with the
|
|
// cursor on the object whose versions were shown.
|
|
func (m *Model) closeVersions() {
|
|
m.versionsOf = nil
|
|
}
|
|
|
|
// syncTabs sets the Secrets pane tabs from the container's object types,
|
|
// keeping the user's preferred type when the container has it.
|
|
func (m *Model) syncTabs(c provider.Container) {
|
|
types := c.ObjectTypes()
|
|
m.objType = types[0]
|
|
m.secrets.Tabs = nil
|
|
for i, t := range types {
|
|
if t == m.wantType {
|
|
m.objType = t
|
|
m.secrets.Tab = i
|
|
}
|
|
m.secrets.Tabs = append(m.secrets.Tabs, tabLabels[t])
|
|
}
|
|
if m.objType != m.wantType {
|
|
m.secrets.Tab = 0
|
|
}
|
|
m.secrets.Title = "[3] " + tabLabels[m.objType]
|
|
if len(types) > 1 {
|
|
m.secrets.Title = "[3]"
|
|
}
|
|
m.secrets.Empty = "no " + strings.ToLower(pluralLabels[m.objType])
|
|
}
|
|
|
|
var (
|
|
tabLabels = map[provider.ObjectType]string{
|
|
provider.TypeSecret: "Secrets", provider.TypeKey: "Keys", provider.TypeCertificate: "Certs",
|
|
}
|
|
pluralLabels = map[provider.ObjectType]string{
|
|
provider.TypeSecret: "Secrets", provider.TypeKey: "Keys", provider.TypeCertificate: "Certificates",
|
|
}
|
|
)
|
|
|
|
// switchTab moves the Secrets pane to the next (delta=1) or previous
|
|
// (delta=-1) object type of the selected container.
|
|
func (m *Model) switchTab(delta int) {
|
|
if m.focus == paneSources {
|
|
m.sourcesFavs = !m.sourcesFavs // two tabs: either way flips
|
|
m.sourcesTouched = false // onto the first source of the tab
|
|
m.rebuildSources()
|
|
return
|
|
}
|
|
c, ok := m.containers.Selected()
|
|
if !ok {
|
|
return
|
|
}
|
|
types := c.ObjectTypes()
|
|
if len(types) < 2 {
|
|
return
|
|
}
|
|
i := 0
|
|
for j, t := range types {
|
|
if t == m.objType {
|
|
i = j
|
|
}
|
|
}
|
|
m.wantType = types[(i+delta+len(types))%len(types)]
|
|
}
|
|
|
|
// detailStale reports whether the detail pane shows a container other than
|
|
// the selected one, which happens when the container changes while no
|
|
// secret is selected.
|
|
func (m *Model) detailStale() bool {
|
|
_, hasSecret := m.detail.Secret()
|
|
shown := ""
|
|
if c, ok := m.detail.Container(); ok {
|
|
shown = containerKey(c)
|
|
}
|
|
return hasSecret || shown != m.containers.SelectedID()
|
|
}
|
|
|
|
// cachedAge is how old a list loaded at at is, if it's older than
|
|
// cacheTTL: the panes say it while it reloads.
|
|
func (m Model) cachedAge(at time.Time) string {
|
|
if d := m.now().Sub(at); d >= cacheTTL {
|
|
return ago(d)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// oldestSources is when the oldest of the cached Sources lists loaded.
|
|
func (m Model) oldestSources() time.Time {
|
|
var oldest time.Time
|
|
for _, at := range m.sourcesAt {
|
|
if oldest.IsZero() || at.Before(oldest) {
|
|
oldest = at
|
|
}
|
|
}
|
|
return oldest
|
|
}
|
|
|
|
func (m *Model) handleSources(msg sourcesMsg) tea.Cmd {
|
|
if msg.gen != m.sourcesGen {
|
|
return nil
|
|
}
|
|
m.sourcesPending--
|
|
m.sourceResults[msg.provider] = msg
|
|
if msg.err == nil {
|
|
m.sourcesAt[msg.provider], m.cacheDirty = m.now(), true
|
|
}
|
|
|
|
var errs []string
|
|
for _, p := range m.providers {
|
|
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
|
|
m.noteErr(r.err)
|
|
m.logErrOf(p.Label(), r.err)
|
|
errs = append(errs, fmt.Sprintf("%s: %v", p.Label(), r.err))
|
|
}
|
|
}
|
|
// Said once, when they change; after that the Details pane says it
|
|
// for the selected account.
|
|
if status := strings.Join(errs, " · "); status != m.status {
|
|
m.status = status
|
|
if status != "" {
|
|
m.flash = flash{text: m.problemText(msg.provider, status), isErr: true}
|
|
}
|
|
}
|
|
m.rebuildSources()
|
|
m.sources.Loading = m.sourcesPending > 0
|
|
return m.autoLoad(msg)
|
|
}
|
|
|
|
// autoLoad loads local sources (repos) whole as they're found: their
|
|
// files are on disk, so listing every file's keys is cheap, and Go to
|
|
// can search them without asking.
|
|
func (m *Model) autoLoad(msg sourcesMsg) tea.Cmd {
|
|
p, ok := m.byID[msg.provider]
|
|
if !ok || msg.err != nil || p.Kind() != provider.KindRepo {
|
|
return nil
|
|
}
|
|
for _, src := range msg.sources {
|
|
m.queueSource(src, false)
|
|
}
|
|
return m.crawlStart()
|
|
}
|
|
|
|
// begin starts a cancellable request for pane p, cancelling any in flight.
|
|
func (m *Model) begin(p slot) (context.Context, int) {
|
|
m.cancelReq(p)
|
|
m.seq++
|
|
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
|
|
m.reqID[p], m.cancel[p] = m.seq, cancel
|
|
return ctx, m.seq
|
|
}
|
|
|
|
func (m *Model) cancelReq(p slot) {
|
|
if m.cancel[p] != nil {
|
|
m.cancel[p]()
|
|
}
|
|
m.reqID[p], m.cancel[p] = 0, nil
|
|
}
|
|
|
|
func (m *Model) touched() {
|
|
if m.onActive != nil {
|
|
m.onActive()
|
|
}
|
|
}
|
|
|
|
// settled runs load after the wait for the cursor to settle, if a key
|
|
// press started it; a request cancelled meanwhile (the cursor moved on)
|
|
// is never sent.
|
|
func (m *Model) settled(ctx context.Context, load tea.Cmd) tea.Cmd {
|
|
wait := m.waiting
|
|
if wait <= 0 {
|
|
return load
|
|
}
|
|
return func() tea.Msg {
|
|
t := time.NewTimer(wait)
|
|
defer t.Stop()
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil
|
|
case <-t.C:
|
|
return load()
|
|
}
|
|
}
|
|
}
|
|
|
|
// done releases the context of a completed request.
|
|
func (m *Model) done(p slot) { m.cancelReq(p) }
|
|
|
|
// loadSources starts a new generation of source loads, superseding any in
|
|
// flight.
|
|
func (m *Model) loadSources() tea.Cmd {
|
|
m.sourcesGen++
|
|
m.sourcesPending = len(m.providers)
|
|
m.sourceResults = map[string]sourcesMsg{}
|
|
return m.sourcesCmd()
|
|
}
|
|
|
|
func (m Model) sourcesCmd() tea.Cmd {
|
|
gen := m.sourcesGen
|
|
var cmds []tea.Cmd
|
|
for _, p := range m.providers {
|
|
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
|
|
cmds = append(cmds, func() tea.Msg {
|
|
defer cancel()
|
|
s, err := p.Sources(ctx)
|
|
for i := range s {
|
|
s[i].Provider, s[i].ProviderLabel = p.ID(), p.Label()
|
|
}
|
|
return sourcesMsg{gen: gen, provider: p.ID(), sources: s, err: err}
|
|
})
|
|
// The source it started in, quickly, on its own.
|
|
if c, ok := p.(provider.Current); ok {
|
|
ctx, cancel := context.WithTimeout(context.Background(), m.timeout)
|
|
cmds = append(cmds, func() tea.Msg {
|
|
defer cancel()
|
|
src, ok := c.Current(ctx)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
src.Provider, src.ProviderLabel = p.ID(), p.Label()
|
|
return currentMsg{gen: gen, source: src}
|
|
})
|
|
}
|
|
}
|
|
return tea.Batch(cmds...)
|
|
}
|
|
|
|
func (m *Model) loadContainers(src provider.Source, key string) tea.Cmd {
|
|
p := m.providerFor(src)
|
|
ctx, req := m.begin(slotContainers)
|
|
return m.settled(ctx, func() tea.Msg {
|
|
items, err := p.Containers(ctx, src)
|
|
// Carry the source as given, with its provider ID, so later
|
|
// requests reach the same account.
|
|
for i := range items {
|
|
items[i].Source = src
|
|
}
|
|
return containersMsg{req: req, key: key, items: items, err: err}
|
|
})
|
|
}
|
|
|
|
// loadObjects lists a container's objects. If the container's account
|
|
// isn't known (an old favourite), each account that can see its source is
|
|
// tried in turn, and the first that succeeds is reported in bound so the
|
|
// favourite can remember it.
|
|
func (m *Model) loadObjects(c provider.Container, t provider.ObjectType, key string) tea.Cmd {
|
|
cands := m.candidates(c.Source)
|
|
known := len(cands) == 1 && cands[0].ID() == c.Source.Provider
|
|
ctx, req := m.begin(slotObjects)
|
|
return m.settled(ctx, func() tea.Msg {
|
|
var errs []error
|
|
for _, p := range cands {
|
|
cc := c
|
|
cc.Source = boundTo(c.Source, p)
|
|
items, err := p.Objects(ctx, cc, t)
|
|
if err == nil {
|
|
for i := range items {
|
|
items[i].Container = cc
|
|
}
|
|
msg := secretsMsg{req: req, key: key, items: items}
|
|
if !known {
|
|
msg.bound = &cc
|
|
}
|
|
return msg
|
|
}
|
|
if ctx.Err() != nil || len(cands) == 1 {
|
|
return secretsMsg{req: req, key: key, err: err}
|
|
}
|
|
errs = append(errs, fmt.Errorf("%s: %w", p.Label(), err))
|
|
}
|
|
return secretsMsg{req: req, key: key, err: errors.Join(errs...)}
|
|
})
|
|
}
|
|
|
|
func (m *Model) fetchValue(s provider.Object) tea.Cmd {
|
|
p := m.providerFor(s.Container.Source)
|
|
ctx, req := m.begin(slotValue)
|
|
m.detail.SetLoading()
|
|
return func() tea.Msg {
|
|
v, err := p.Value(ctx, s)
|
|
return valueMsg{req: req, obj: s, value: v, err: err}
|
|
}
|
|
}
|
|
|
|
func (m *Model) describe(o provider.Object) tea.Cmd {
|
|
key := detailKey(o)
|
|
if e, ok := m.cache.details[key]; ok {
|
|
m.detail.SetDescribed(e.value)
|
|
if e.fresh(m.now()) {
|
|
return nil
|
|
}
|
|
} else {
|
|
m.detail.SetDescribing()
|
|
}
|
|
p := m.providerFor(o.Container.Source)
|
|
ctx, req := m.begin(slotDescribe)
|
|
return m.settled(ctx, func() tea.Msg {
|
|
obj, err := p.Describe(ctx, o)
|
|
return describeMsg{req: req, key: key, obj: obj, err: err}
|
|
})
|
|
}
|
|
|
|
// copyValue copies the selected object's value, fetching it without
|
|
// revealing it if it isn't shown already.
|
|
func (m *Model) copyValue() tea.Cmd {
|
|
if m.focus == paneDetail && m.fields.Shown() {
|
|
if f, ok := m.fields.Selected(); ok {
|
|
return m.copyText(fieldName(f), cmpOr(f.URL, f.Value), false)
|
|
}
|
|
return nil
|
|
}
|
|
if a, ok := m.selectedAccount(); ok && a.Copy != "" && m.focus == paneSources {
|
|
return m.copyText(a.CopyLabel, a.Copy, false)
|
|
}
|
|
o, ok := m.detail.Secret()
|
|
if !ok {
|
|
return nil
|
|
}
|
|
if v, ok := m.detail.Value(); ok {
|
|
m.usedObject(o)
|
|
return m.copyText(o.Name, v.Reveal(), true)
|
|
}
|
|
p := m.providerFor(o.Container.Source)
|
|
ctx, req := m.begin(slotCopy)
|
|
m.flash = flash{text: "copying " + o.Name + "…"}
|
|
return func() tea.Msg {
|
|
v, err := p.Value(ctx, o)
|
|
return copyMsg{req: req, obj: o, name: o.Name, value: v, err: err}
|
|
}
|
|
}
|
|
|
|
// copyText puts text on the clipboard. Sensitive text is cleared again
|
|
// after the configured delay.
|
|
func (m *Model) copyText(label, text string, sensitive bool) tea.Cmd {
|
|
cmds := []tea.Cmd{m.clip.Write(text)}
|
|
// This copy replaces whatever a pending clear was waiting for.
|
|
m.cancelClipClear()
|
|
if !sensitive || m.clipClear <= 0 {
|
|
m.flash = done("copied " + label)
|
|
return tea.Batch(cmds...)
|
|
}
|
|
m.flash = flash{} // the countdown line takes over from "copying…"
|
|
m.clipFP = clipboard.Fingerprint(text)
|
|
m.clipLabel = label
|
|
m.clipDeadline = m.now().Add(m.clipClear)
|
|
return tea.Batch(append(cmds, m.clipTick())...)
|
|
}
|
|
|
|
// clipTick fires when the countdown's displayed second next changes.
|
|
func (m *Model) clipTick() tea.Cmd {
|
|
wait := m.clipDeadline.Sub(m.now()) % time.Second
|
|
if wait <= 0 {
|
|
wait = time.Second
|
|
}
|
|
token := m.clipToken
|
|
return tea.Tick(wait, func(time.Time) tea.Msg { return clipTickMsg{token: token} })
|
|
}
|
|
|
|
func (m *Model) cancelClipClear() {
|
|
m.clipToken++
|
|
m.clipFP, m.clipLabel, m.clipDeadline = [32]byte{}, "", time.Time{}
|
|
}
|
|
|
|
// openSelected opens the focused pane's selected item in the browser. Over
|
|
// SSH it copies the URL instead.
|
|
func (m *Model) openSelected() tea.Cmd {
|
|
var name, url string
|
|
var used func()
|
|
switch {
|
|
case m.focus == paneDetail && m.fields.Shown():
|
|
f, _ := m.fields.Selected()
|
|
if f.URL == "" {
|
|
m.flash = warning("nothing to open in the browser here")
|
|
return nil
|
|
}
|
|
name, url, used = f.Label, f.URL, func() {}
|
|
default:
|
|
name, url, used = m.openTarget()
|
|
}
|
|
if name == "" {
|
|
return nil
|
|
}
|
|
return m.openPage(name, url, used)
|
|
}
|
|
|
|
// openTarget is the focused pane's selected item, for the browser.
|
|
func (m *Model) openTarget() (name, url string, used func()) {
|
|
switch m.focus {
|
|
case paneSources:
|
|
s, _ := m.selectedSource()
|
|
name, url, used = s.Name, s.URL, func() { m.usedSource(s) }
|
|
case paneContainers:
|
|
c, _ := m.containers.Selected()
|
|
name, url, used = c.Name, c.URL, func() { m.usedContainer(c) }
|
|
default:
|
|
o, _ := m.detail.Secret()
|
|
name, url, used = o.Name, o.URL, func() { m.usedObject(o) }
|
|
}
|
|
return name, url, used
|
|
}
|
|
|
|
// openPage opens url in the browser, or copies it over SSH.
|
|
func (m *Model) openPage(name, url string, used func()) tea.Cmd {
|
|
if url == "" {
|
|
m.flash = warning("no web page for " + name)
|
|
return nil
|
|
}
|
|
err := m.openURL(url)
|
|
if err == nil || errors.Is(err, browser.ErrRemote) {
|
|
used()
|
|
}
|
|
switch {
|
|
case errors.Is(err, browser.ErrRemote):
|
|
cmd := m.copyText("link to "+name, url, false)
|
|
m.flash.text += " (can't open a browser over SSH)"
|
|
return cmd
|
|
case err != nil:
|
|
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
|
default:
|
|
m.flash = done("opened " + name + " in the browser")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// noteErr remembers authentication failures so the UI can offer sign-in,
|
|
// saying so once for each account.
|
|
func (m *Model) noteErr(err error) {
|
|
var auth *provider.AuthError
|
|
if errors.As(err, &auth) {
|
|
if m.auth == nil || m.auth.Who() != auth.Who() {
|
|
m.flash = flash{text: m.authText(auth), isErr: true}
|
|
}
|
|
m.auth = auth
|
|
}
|
|
}
|
|
|
|
// authText says which account needs signing in, and how.
|
|
func (m Model) authText(auth *provider.AuthError) string {
|
|
if len(auth.Login) == 0 {
|
|
return fmt.Sprintf("%s: sign-in required: %v", auth.Who(), auth.Err)
|
|
}
|
|
return fmt.Sprintf("%s: sign-in required (not signed in or session expired)%s",
|
|
auth.Who(), withKey(m.keys.Login, " · press %s to run "+strings.Join(auth.Login, " ")))
|
|
}
|
|
|
|
func (m Model) anyLoading() bool {
|
|
return m.sources.Loading || m.containers.Loading || m.secrets.Loading ||
|
|
m.detail.State() == panes.ValueLoading
|
|
}
|
|
|
|
const (
|
|
minWidth = 50
|
|
minHeight = 12
|
|
)
|
|
|
|
func (m Model) footerView() string {
|
|
var help string
|
|
if l := m.focusedFilter(); m.search != nil || m.prompt != nil {
|
|
help = m.helpBar(m.popupKeys(), 1) // the popup's keys, ? help last
|
|
} else if l != nil && l.Filtering() {
|
|
help = m.helpBar(keys.FilterHelp{Map: m.keys}.ShortHelp(), 2)
|
|
} else {
|
|
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown()}.ShortHelp(), 2)
|
|
}
|
|
var lines []string
|
|
if !m.clipDeadline.IsZero() {
|
|
left := max(0, int(math.Ceil(m.clipDeadline.Sub(m.now()).Seconds())))
|
|
lines = append(lines, m.flashLine(done(fmt.Sprintf("copied %s · clipboard clears in %ds", m.clipLabel, left))))
|
|
}
|
|
if m.flash.text != "" {
|
|
lines = append(lines, m.flashLine(m.flash))
|
|
}
|
|
return strings.Join(append(lines, " "+help), "\n")
|
|
}
|
|
|
|
// flashLine draws a message: errors in red, confirmations after a ✓.
|
|
func (m Model) flashLine(f flash) string {
|
|
// One line here: a longer message (sops' errors) keeps its first, and
|
|
// says where the rest is.
|
|
if first, _, multi := strings.Cut(f.text, "\n"); multi {
|
|
f.text = first + " … (the whole message: " + withKey(m.keys.Reveal, "%s on the secret") + ")"
|
|
}
|
|
var s string
|
|
switch {
|
|
case f.isErr:
|
|
s = m.st.Error.Render("✗ " + f.text)
|
|
case f.warn:
|
|
s = m.st.Warning.Render("⚠ " + f.text)
|
|
case f.ok:
|
|
s = m.st.Success.Render("✓") + " " + m.st.Notice.Render(f.text)
|
|
default:
|
|
s = m.st.Notice.Render(f.text)
|
|
}
|
|
return lipgloss.NewStyle().MaxWidth(m.w).Render(" " + s) // off the edge
|
|
}
|
|
|
|
func (m Model) View() tea.View {
|
|
var content string
|
|
if m.w < minWidth || m.h < minHeight {
|
|
content = fmt.Sprintf("Terminal too small (%dx%d); need at least %dx%d.", m.w, m.h, minWidth, minHeight)
|
|
} else {
|
|
content = m.panesView() + "\n" + m.footerView()
|
|
if m.prompt != nil || m.search != nil {
|
|
content = m.overlay(content)
|
|
}
|
|
}
|
|
v := tea.NewView(content)
|
|
v.AltScreen = true
|
|
v.ForegroundColor = m.defaultFg
|
|
v.ReportFocus = true // to look for links on the clipboard
|
|
v.WindowTitle = "lazysecrets"
|
|
return v
|
|
}
|
|
|
|
// selectedProblem is why the selected Sources row's account has nothing
|
|
// to show, if it failed: for the Details pane.
|
|
func (m Model) selectedProblem() string {
|
|
r, ok := m.sources.Selected()
|
|
if !ok || (r.kind != rowGroup && r.kind != rowSource) {
|
|
return ""
|
|
}
|
|
res, done := m.sourceResults[r.provider]
|
|
if !done || res.err == nil {
|
|
return ""
|
|
}
|
|
return m.problemText(r.provider, res.err.Error())
|
|
}
|
|
|
|
// problemText says what went wrong for a provider: for a sign-in, which
|
|
// account and how to sign in again; else err as given.
|
|
func (m Model) problemText(providerID, err string) string {
|
|
var auth *provider.AuthError
|
|
if r, ok := m.sourceResults[providerID]; ok && errors.As(r.err, &auth) {
|
|
return m.authText(auth)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// fieldLink is the link of the selected Fields row, if it has one.
|
|
func (m Model) fieldLink() string {
|
|
if f, ok := m.fields.Selected(); ok && m.fields.Shown() {
|
|
return f.Link
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// loginFor is the sign-in L runs: the selected account's, if it needs
|
|
// one, else the last one asked for.
|
|
func (m Model) loginFor() *provider.AuthError {
|
|
if f, ok := m.fields.Selected(); ok && m.focus == paneDetail && m.fields.Shown() && f.Provider != "" {
|
|
var auth *provider.AuthError
|
|
if res := m.sourceResults[f.Provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 {
|
|
return auth
|
|
}
|
|
}
|
|
if r, ok := m.sources.Selected(); ok {
|
|
var auth *provider.AuthError
|
|
if res := m.sourceResults[r.provider]; errors.As(res.err, &auth) && len(auth.Login) > 0 {
|
|
return auth
|
|
}
|
|
}
|
|
if m.auth != nil && len(m.auth.Login) > 0 {
|
|
return m.auth
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// selectedAccount describes the account of the selected provider row, if
|
|
// its provider can.
|
|
func (m Model) selectedAccount() (provider.Account, bool) {
|
|
r, ok := m.sources.Selected()
|
|
if !ok || r.kind != rowGroup {
|
|
return provider.Account{}, false
|
|
}
|
|
if res := m.sourceResults[r.provider]; res.err != nil {
|
|
return provider.Account{}, false // the problem shows instead
|
|
}
|
|
a, ok := m.byID[r.provider].(provider.Accounts)
|
|
if !ok {
|
|
return provider.Account{}, false
|
|
}
|
|
return a.Account()
|
|
}
|