Files
lazysecrets/internal/ui/app/app.go
T

1916 lines
56 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/share"
"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
// Timeouts override Timeout for the providers of a kind.
Timeouts map[provider.Kind]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
// Warning shows when the window opens: what lazysecrets skipped
// instead of failing to start.
Warning 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
// ShareKey is the config's sharing.key: the secret that encrypts shared
// env files, for repos whose .lazysecrets.yaml names none.
ShareKey string
// ShareRules are what a key's value must be to share with it.
ShareRules share.Rules
// 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 on its own at startup, at most
// once per UpdateEvery (0 for a day).
Updater *Updater
UpdateCheck bool
UpdateEvery time.Duration
// 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
// RecentIcon marks items used lately with ◷ in the lists
// (ui.recentIcon).
RecentIcon bool
// Open is a reference to open at startup (lazysecrets open <ref>).
Open *ref.Ref
// OpenAction, if set, is what to do with Open, rather than asking:
// go to it, and maybe copy or show its value (OpenActions). It ends in
// the list, like Go to, rather than on the details.
OpenAction string
// 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 timeouts
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
updateEvery time.Duration
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
recentIcon bool // mark items used lately (◷)
openAction string // and what to do with it, or ask
shareKey string // the config's sharing.key
shareRules share.Rules // and sharing.keyRules
// 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: timeouts{def: opts.Timeout, byKind: opts.Timeouts},
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.recentIcon = opts.RecentIcon
m.sources = newSourcesList(iconSet, m.store, m.recentIcon)
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, m.updateEvery = opts.Updater, opts.UpdateCheck, opts.UpdateEvery
if m.updateEvery == 0 {
m.updateEvery = updateEvery
}
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, m.openAction = opts.Open, opts.OpenAction
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.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
m.configFiles, m.statePath, m.changelog = opts.ConfigFiles, opts.StatePath, changelogFields(opts.Changelog)
if opts.Warning != "" {
m.flash = warning(opts.Warning)
}
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(), tea.RequestBackgroundColor}
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 {
msg := openRefMsg{ref: *m.open, action: m.openAction}
cmds = append(cmds, func() tea.Msg { return msg })
}
return tea.Batch(cmds...)
}
// openRefMsg starts opening a reference from outside the key handler:
// asking what to do with it, or doing action (OpenActions).
type openRefMsg struct {
ref ref.Ref
action string
}
// OpenActions are what lazysecrets open --action takes, the Open link
// menu's choices that go there.
var OpenActions = map[string]linkAction{"go": actGo, "copy": actCopy, "show": actShow}
// 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{ref: r} }
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tea.BackgroundColorMsg:
m.st = m.st.OnBackground(msg.Color, msg.IsDark())
return m, nil
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
if act, ok := OpenActions[msg.action]; ok && !msg.ref.IsShare() {
cmds = append(cmds, m.findRef(msg.ref, act))
} else {
cmds = append(cmds, m.offerRef("Open link", msg.ref, false))
}
case resolvedMsg:
m.handleResolved(msg)
case shareFilesMsg:
cmds = append(cmds, m.handleShareFiles(msg))
case sealedMsg:
cmds = append(cmds, m.handleSealed(msg))
case shareOpenedMsg:
cmds = append(cmds, m.handleShareOpened(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)
}
before := m.cache.objects[msg.key].value
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))
cmds = append(cmds, m.refreshShown(before))
}
}
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, o.Container.Source.Kind)
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, o.Container.Source.Kind)
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()
}
// timeouts bound each provider request: its kind's, else the default.
type timeouts struct {
def time.Duration
byKind map[provider.Kind]time.Duration
}
// of is how long a request to a provider of kind k may take.
func (t timeouts) of(k provider.Kind) time.Duration {
if d, ok := t.byKind[k]; ok {
return d
}
return t.def
}
// begin starts a cancellable request for pane p to a provider of kind k,
// cancelling any in flight.
func (m *Model) begin(p slot, k provider.Kind) (context.Context, int) {
m.cancelReq(p)
m.seq++
ctx, cancel := context.WithTimeout(context.Background(), m.timeout.of(k))
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.of(p.Kind()))
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.of(p.Kind()))
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, src.Kind)
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, c.Source.Kind)
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, s.Container.Source.Kind)
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, o.Container.Source.Kind)
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, o.Container.Source.Kind)
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 {
revealed := m.detail.State() == panes.ValueShown
help = m.helpBar(contextHelp{k: m.keys, focus: m.focus, of: m.detailOf(), commands: len(m.commandsHere()) > 0, account: m.fields.Shown(), revealed: &revealed}.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()
}