Files
lazysecrets/internal/ui/app/app.go
T
Karl-Einar Bergstrøm 4377eb98bf feat: share env files as encrypted links (#54)
Share `.env` files as an encrypted `lazysecrets://share/…` link. The files are encrypted with a secret's value, so anyone who can read that secret can open the link.

**Sharing:** `S` → `f` on a repo (all its plain env files), an env file, or a key in one (its file).
- **The key** is `share.key` from the repo's `.lazysecrets.yaml`, else from the config. It can be a `lazysecrets://` link, a Key Vault URL or an `@Microsoft.KeyVault(...)` reference.
- **Without `share.key`,** a picker built on Go to opens, titled "Share .env", with "Pick a secret to encrypt with" in its own border. It lists only secrets, leaving out values from plain `.env` files. Pasting a reference picks it. Picking shares at once, and the picker stays open until the link is copied. If the secret can't be used, the reason shows in red in the picker: it can't be read, or it doesn't meet `share.keyRules` (which rule failed is named). `esc` cancels.
- **`share.keyRules`** (defaults `minLength: 32`, `minClasses: 2`; never under 16) applies when sharing, not when opening.
- **On Azure,** the link pins the key's version, so rotating the key doesn't break old links, and disabling a version revokes them.
- **The command line:** `lazysecrets share [--key <secret>] <env files>`.

**Opening** works by paste, `ctrl+o`, the clipboard offer, the link handler or `lazysecrets <link>`.
1. It first says which repo the link is from, how many files it holds and which key it uses, and decrypts only after `o`.
2. It saves into the repo's clone, found by its remote, showing which files are new, which would replace one there, and which git doesn't ignore.
3. Without a clone, it asks for a folder and saves straight away. The prompt stays open, with the reason in red, until the files are written.
4. If decryption fails, a popup says why: the first line of the reason, `r` for the whole message, `g` to go to the key's secret. A key that has changed is named by where it lives: a Databricks or SOPS value changed in place, an Azure current version that isn't the one the link was made with, or a pinned version, meaning the link may be damaged.

**Format** (`internal/share`): a version byte, then a JSON header with the key, the repo and the file count, then an age file (scrypt 2^15, with the key's value as passphrase) holding the files as deflated JSON, all as unpadded base64url. Links are capped at 16 KB. A future format gets a new version, and old ones stay readable.

**Safety:**
- A link can only write env files, inside the folder: never in `.git`, never through a symlink.
- New files are created with mode 0600.
- Opening accepts an scrypt work factor of at most 2^20, and unpacks at most 1 MB.

**Along the way:**
- Opening a link to a secret ends on its details, as `enter` would.
- `o` on an Azure secret opens its portal page, and in the versions view, that version's page.
- The details pane follows its secret's list when it reloads in the background. Before, it kept what it showed first, including an old portal address.
- `ref.Read` is shared by `get` and sharing, with Azure `VersionedValue` for pinning.
- `provider.Contents` lets the demo repos share their env files. `--demo` has a share key (`kv-shared-prod / env-share`), so the whole flow works there.
- New dependency: `filippo.io/age`.

Tested end to end in the TUI and the demo, the CLI, and the `share` package. In a real vault: version pinning, opening into a clone, and revocation by disabling the version.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Reviewed-on: #54
2026-10-13 14:55:22 +02:00

1865 lines
54 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
// 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
// ShareKey is the config's share.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 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
shareKey string // the config's share.key
shareRules share.Rules // and share.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: 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.shareKey, m.shareRules = opts.ShareKey, opts.ShareRules
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(), 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 {
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.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
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)
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 {
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()
}