Hello world
Release / Build and publish (push) Successful in 37s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-12 15:33:18 +02:00
commit d1ae98e696
134 files changed
+27239

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+50
View File
@@ -0,0 +1,50 @@
package app
import (
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// cacheTTL is how long cached metadata is shown without reloading. Older
// entries are still shown immediately, then reloaded in the background.
const cacheTTL = 2 * time.Minute
// cache holds metadata: never secret values or errors. It's kept between
// runs in the cache file (persist.go).
type cache struct {
containers map[string]entry[[]provider.Container] // by source key
objects map[string]entry[[]provider.Object] // by container key (ID|type)
details map[string]entry[provider.Object] // Describe results by ID@version
// cursor remembers the selected child per parent key, so returning to
// a subscription or vault puts the cursor back where it was.
cursor map[string]string
}
type entry[T any] struct {
value T
at time.Time
}
func newCache() cache {
return cache{
containers: map[string]entry[[]provider.Container]{},
objects: map[string]entry[[]provider.Object]{},
details: map[string]entry[provider.Object]{},
cursor: map[string]string{},
}
}
func (e entry[T]) fresh(now time.Time) bool { return now.Sub(e.at) < cacheTTL }
// detailKey includes the account: the result carries its container, and
// later requests for the object are routed by it.
func detailKey(o provider.Object) string {
return containerKey(o.Container) + "|" + o.ID + "@" + o.Version
}
func (c cache) rememberCursor(parent, child string) {
if parent != "" && child != "" {
c.cursor[parent] = child
}
}
+161
View File
@@ -0,0 +1,161 @@
package app
import (
"context"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
// counting records how often lists and details are fetched.
type counting struct {
provider.Provider
containers, objects, describes, values, versions int
}
func (c *counting) Versions(ctx context.Context, o provider.Object) ([]provider.Object, error) {
c.versions++
return c.Provider.Versions(ctx, o)
}
func (c *counting) Containers(ctx context.Context, src provider.Source) ([]provider.Container, error) {
c.containers++
return c.Provider.Containers(ctx, src)
}
func (c *counting) Objects(ctx context.Context, ct provider.Container, t provider.ObjectType) ([]provider.Object, error) {
c.objects++
return c.Provider.Objects(ctx, ct, t)
}
func (c *counting) Describe(ctx context.Context, o provider.Object) (provider.Object, error) {
c.describes++
return c.Provider.Describe(ctx, o)
}
func (c *counting) Value(ctx context.Context, o provider.Object) (provider.SecretValue, error) {
c.values++
return c.Provider.Value(ctx, o)
}
func newCachingDriver(t *testing.T) (*driver, *counting, *time.Time) {
t.Helper()
p := &counting{Provider: fake.Azure(0)}
d := newDriver(t, p)
// Entries from the initial load were stamped with the real clock.
clock := time.Now()
d.m.now = func() time.Time { return clock }
return d, p, &clock
}
func TestCacheAvoidsRefetchAndRestoresCursor(t *testing.T) {
t.Parallel()
d, p, _ := newCachingDriver(t)
d.keys("2", "j", "3", "j") // kv-shared-prod... then back
d.keys("2", "k", "3", "j") // kv-app-prod, cursor to api-key
d.keys("1", "j", "k") // away to platform-dev and back
before := *p
d.keys("j", "k")
if p.containers != before.containers || p.objects != before.objects {
t.Errorf("fresh cache refetched: containers %d→%d, objects %d→%d",
before.containers, p.containers, before.objects, p.objects)
}
if _, c, s := d.selected(); c != "kv-app-prod" || s != "api-key" {
t.Errorf("cursor not restored: %s/%s", c, s)
}
}
func TestStaleCacheShownThenReloaded(t *testing.T) {
t.Parallel()
d, p, clock := newCachingDriver(t)
d.keys("j") // platform-dev
*clock = clock.Add(cacheTTL + time.Second)
before := p.containers
d.keys("k") // back to platform-prod: stale
if p.containers != before+1 {
t.Errorf("stale entry not reloaded: %d calls, want %d", p.containers, before+1)
}
if _, c, _ := d.selected(); c != "kv-app-prod" {
t.Errorf("selection lost across background reload: %q", c)
}
}
func TestRefreshBypassesCache(t *testing.T) {
t.Parallel()
d, p, _ := newCachingDriver(t)
before := p.objects
d.keys("3", "r")
if p.objects != before+1 {
t.Errorf("r did not reload: %d calls, want %d", p.objects, before+1)
}
}
func TestValuesAndErrorsNotCached(t *testing.T) {
t.Parallel()
d, p, _ := newCachingDriver(t)
d.keys("space", "space", "space")
if p.values != 2 {
t.Errorf("value fetched %d times for two reveals, want 2", p.values)
}
d.keys("2", "G") // kv-restricted: 403
if _, ok := d.m.cache.objects[d.m.selContainer]; ok {
t.Error("error result cached")
}
before := p.objects
d.keys("k", "j")
if p.objects != before+2 {
t.Errorf("failed list not retried: %d calls, want %d", p.objects, before+2)
}
}
func TestSignInDropsCache(t *testing.T) {
t.Parallel()
d, _, _ := newCachingDriver(t)
d.send(loginDoneMsg{})
// Everything is reloaded fresh, so only the current selection is cached.
if n := len(d.m.cache.containers); n != 1 {
t.Errorf("containers cached after sign-in = %d, want 1", n)
}
}
// runAll runs cmd and the commands it batches, ignoring their messages.
func runAll(cmd tea.Cmd) {
if cmd == nil {
return
}
if b, ok := cmd().(tea.BatchMsg); ok {
for _, c := range b {
runAll(c)
}
}
}
func TestMovingOnDropsUnsettledLoads(t *testing.T) {
t.Parallel()
c := &counting{Provider: fake.Azure(0)}
d := newDriverOpts(t, Options{}, c)
d.m.settle = 20 * time.Millisecond
before := c.containers
// j to platform-dev and straight back: its load never goes out.
m1, passed := d.m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
_, back := m1.Update(tea.KeyPressMsg{Code: 'k', Text: "k"})
runAll(back)
runAll(passed)
if c.containers != before {
t.Errorf("passing over a subscription loaded it: %d calls", c.containers-before)
}
// Stopping on it loads it once the cursor settles.
_, stop := d.m.Update(tea.KeyPressMsg{Code: 'j', Text: "j"})
runAll(stop)
if c.containers != before+1 {
t.Errorf("stopping on a subscription: %d calls, want 1", c.containers-before)
}
}
+321
View File
@@ -0,0 +1,321 @@
package app
import (
"context"
"fmt"
"os"
"os/exec"
"strings"
"time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
// Custom commands (customCommands in the config) run a shell command on
// the selected item: by their key in the pane they're for, or from the
// commands menu (x). Prompts ask for text first; commands that get the
// secret value ask before running. The value reaches the command on its
// input or in an environment variable, never on its command line.
// commandTimeout bounds commands whose output is captured; terminal ones
// run as long as the user likes.
const commandTimeout = 5 * time.Minute
type commandValueMsg struct {
cmd commands.Command
rendered string
value provider.SecretValue
err error
}
type commandDoneMsg struct {
cmd commands.Command
out string
err error
}
// paneContext is the custom command context of a pane.
func paneContext(p pane) string {
switch p {
case paneSources:
return commands.Sources
case paneContainers:
return commands.Vaults
}
return commands.Secrets
}
// commandsHere are the custom commands for the focused pane.
func (m *Model) commandsHere() []commands.Command {
if m.focus == paneStatus {
return nil
}
var out []commands.Command
for _, c := range m.commands {
if c.Context == commands.Global || c.Context == paneContext(m.focus) {
out = append(out, c)
}
}
return out
}
// commandForKey is the custom command a key runs here, if any.
func (m *Model) commandForKey(msg tea.KeyPressMsg) (commands.Command, bool) {
for _, c := range m.commandsHere() {
if c.Key != "" && c.Key == msg.String() {
return c, true
}
}
return commands.Command{}, false
}
// showCommands lists the custom commands for the focused pane.
func (m *Model) showCommands() tea.Cmd {
cmds := m.commandsHere()
if len(cmds) == 0 {
m.flash = warning("no custom commands for the " + strings.ToLower(paneNames[m.focus]) + " pane: see customCommands in the config")
return nil
}
var opts []option
for _, c := range cmds {
label := c.Description
if label == "" {
label = c.Command
}
if c.Key != "" {
label = m.st.Label.Render(fmt.Sprintf("%-8s", c.Key)) + label
}
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
}
return m.showFilterMenu("Commands", opts)
}
// startCommand runs c on the selection: prompts, then confirmation, then
// the command.
func (m *Model) startCommand(c commands.Command) tea.Cmd {
it, obj, ok := m.commandItem(c.Context)
if !ok {
m.flash = warning(map[string]string{
commands.Sources: "select a subscription or workspace to run " + c.Description + " on",
commands.Vaults: "select a vault or scope to run " + c.Description + " on",
commands.Secrets: "select a secret to run " + c.Description + " on",
}[c.Context])
return nil
}
it.Prompt = map[string]commands.Q{}
return m.askPrompt(c, it, obj, 0)
}
// askPrompt asks c's prompts in turn, then confirms or runs it.
func (m *Model) askPrompt(c commands.Command, it commands.Item, obj *provider.Object, i int) tea.Cmd {
if i == len(c.Prompts) {
return m.confirmCommand(c, it, obj)
}
p := c.Prompts[i]
in := newTextInput(p.Title+": ", 48)
in.SetValue(p.Default)
in.CursorEnd()
m.prompt = &modal{
title: cmpOr(c.Description, "Command"), input: &in,
submit: func(m *Model, text string) tea.Cmd {
it.Prompt[p.Name] = commands.Q(text)
return m.askPrompt(c, it, obj, i+1)
},
footer: footer(hint(m.keys.PopupSelect, "next"), hint(m.keys.PopupClose, "cancel")),
}
return m.prompt.input.Focus()
}
func (m *Model) confirmCommand(c commands.Command, it commands.Item, obj *provider.Object) tea.Cmd {
rendered, err := c.Render(it)
if err != nil {
m.flash = flash{text: c.Description + ": " + err.Error(), isErr: true}
return nil
}
if !c.Confirms() {
return m.runCommand(c, rendered, obj)
}
var lines []string
for _, l := range strings.Split(ansi.Wrap(rendered, 70, " "), "\n") {
lines = append(lines, m.st.Muted.Render(l))
}
switch {
case c.Value == "stdin":
lines = append(lines, "", "It gets the value of "+string(it.Name)+" on its input.")
case strings.HasPrefix(c.Value, "env:"):
lines = append(lines, "", "It gets the value of "+string(it.Name)+" in $"+strings.TrimPrefix(c.Value, "env:")+".")
}
m.showMenu("Run "+cmpOr(c.Description, "command")+"?", lines, []option{
{key: "y", label: "Run it", run: func(m *Model) tea.Cmd { return m.runCommand(c, rendered, obj) }},
})
return nil
}
// runCommand fetches the value if the command takes it, then runs it.
func (m *Model) runCommand(c commands.Command, rendered string, obj *provider.Object) tea.Cmd {
if c.Value == "" || obj == nil {
return m.execCommand(c, rendered, provider.SecretValue{})
}
o := *obj
p := m.providerFor(o.Container.Source)
m.flash = flash{text: "fetching " + o.Name + " for " + c.Description + "…"}
timeout := m.timeout
m.usedObject(o)
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
v, err := p.Value(ctx, o)
return commandValueMsg{cmd: c, rendered: rendered, value: v, err: err}
}
}
// execCommand runs the command as its output mode says.
func (m *Model) execCommand(c commands.Command, rendered string, value provider.SecretValue) tea.Cmd {
shell := cmpOr(os.Getenv("SHELL"), "/bin/sh")
setValue := func(cmd *exec.Cmd) {
switch {
case c.Value == "stdin":
cmd.Stdin = strings.NewReader(value.Reveal())
case strings.HasPrefix(c.Value, "env:"):
cmd.Env = append(os.Environ(), strings.TrimPrefix(c.Value, "env:")+"="+value.Reveal())
}
}
if c.Out() == commands.Terminal {
// Pause before coming back, reading the terminal itself: the
// command's input may be taken.
script := rendered + "\nprintf '\\n%s' 'Press enter to return to lazysecrets'; read -r _ </dev/tty"
cmd := exec.Command(shell, "-c", script)
setValue(cmd)
m.flash = flash{}
return tea.ExecProcess(cmd, func(err error) tea.Msg { return commandDoneMsg{cmd: c, err: err} })
}
m.flash = flash{text: "running " + cmpOr(c.Description, "command") + "…"}
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
cmd := exec.CommandContext(ctx, shell, "-c", rendered)
setValue(cmd)
out, err := cmd.CombinedOutput()
return commandDoneMsg{cmd: c, out: string(out), err: err}
}
}
func (m *Model) handleCommandValue(msg commandValueMsg) tea.Cmd {
if msg.err != nil {
m.noteErr(msg.err)
m.flash = flash{text: msg.cmd.Description + ": " + msg.err.Error(), isErr: true}
return nil
}
return m.execCommand(msg.cmd, msg.rendered, msg.value)
}
// maxOutputLines caps what a popup shows of a command's output.
const maxOutputLines = 30
func (m *Model) handleCommandDone(msg commandDoneMsg) {
name := cmpOr(msg.cmd.Description, "command")
if msg.cmd.Out() != commands.Popup || m.prompt != nil || m.search != nil {
switch {
case msg.err != nil:
out := strings.TrimSpace(msg.out)
if i := strings.LastIndexByte(out, '\n'); i >= 0 {
out = out[i+1:] // the last line, usually the error
}
m.flash = flash{text: strings.TrimSpace(name + " failed: " + msg.err.Error() + " " + out), isErr: true}
default:
m.flash = done(name)
}
return
}
lines := strings.Split(strings.TrimRight(ansi.Strip(msg.out), "\n"), "\n")
if len(lines) > maxOutputLines {
lines = append(lines[:maxOutputLines], m.st.Muted.Render(fmt.Sprintf("… %d more lines", len(lines)-maxOutputLines)))
}
title := "✓ " + name
if msg.err != nil {
title = "✗ " + name + ": " + msg.err.Error()
}
m.showMenu(title, lines, nil)
}
// commandItem fills a command's fields from the selection: the source,
// and the vault and secret as deep as the context goes (global: as deep
// as the focus). ok is false if the context's item isn't selected.
func (m *Model) commandItem(ctx string) (commands.Item, *provider.Object, bool) {
depth := map[string]int{commands.Sources: 0, commands.Vaults: 1, commands.Secrets: 2}[ctx]
if ctx == commands.Global {
depth = min(int(m.focus), 2)
}
var it commands.Item
src, ok := m.selectedSource()
if !ok && depth == 0 {
return it, nil, false
}
if ok {
it.Kind, it.Account = commands.Q(strings.ToLower(string(src.Kind))), commands.Q(accountOf(src))
it.Source, it.SourceID, it.URL = commands.Q(src.Name), commands.Q(src.ID), commands.Q(src.URL)
it.Note = commands.Q(m.store.Node(sourceKey(src)).Note)
if r, err := ref.ForSource(src); err == nil {
it.Link = commands.Q(r.String())
}
}
if depth == 0 {
return it, nil, true
}
c, ok := m.containers.Selected()
if !ok {
return it, nil, ctx == commands.Global
}
it.Vault, it.URL, it.Note = commands.Q(c.Name), commands.Q(c.URL), commands.Q(m.store.Node(storeKey(c)).Note)
if c.Source.Kind == provider.KindAzure {
it.VaultURL = commands.Q(c.Detail)
}
if r, err := ref.ForContainer(c, m.objType); err == nil {
it.Link = commands.Q(r.String())
}
if depth == 1 {
return it, nil, true
}
o, ok := m.detail.Secret()
if !ok {
return it, nil, ctx == commands.Global
}
version := ""
if m.versionsOf != nil {
version = o.Version // run from the versions view: that version
}
it.Name, it.Type, it.Version = commands.Q(o.Name), commands.Q(strings.ToLower(string(o.Type))), commands.Q(version)
it.Reference, it.URL, it.Note = commands.Q(o.Reference), commands.Q(o.URL), commands.Q(m.store.Node(objectKey(o)).Note)
if !o.Expires.IsZero() {
it.Expires = commands.Q(o.Expires.Format(time.RFC3339))
}
if r, err := ref.ForObject(o, version); err == nil {
it.Link = commands.Q(r.String())
}
if version != "" {
o.Version = version
} else {
o.Version = "" // the current value, like copying it
}
return it, &o, true
}
func newTextInput(prompt string, width int) textinput.Model {
in := textinput.New()
in.Prompt = prompt
in.SetWidth(width)
return in
}
func cmpOr(a, b string) string {
if a != "" {
return a
}
return b
}
+79
View File
@@ -0,0 +1,79 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/commands"
)
func commandDriver(t *testing.T, cmds ...commands.Command) *driver {
t.Setenv("SHELL", "/bin/sh")
d := newDriverOpts(t, Options{Commands: cmds})
d.wait = 5 * time.Second
return d
}
func TestCommandGetsValueOnInput(t *testing.T) {
out := filepath.Join(t.TempDir(), "out")
d := commandDriver(t, commands.Command{
Key: "K", Context: commands.Secrets, Description: "Save it",
Command: "cat > " + out + " && echo {{.Name}} {{.Prompt.ns}} >> " + out,
Value: "stdin", Prompts: []commands.Prompt{{Name: "ns", Title: "Namespace", Default: "team a"}},
})
d.keys("3", "K") // db-password
if !strings.Contains(d.screen(), "Namespace: team a") {
t.Fatalf("no prompt:\n%s", d.screen())
}
d.keys("enter")
scr := d.screen()
// The command as shown, unwrapped: where it wraps depends on the
// temporary path's length.
shown := ""
if d.m.prompt != nil {
shown = ansiStrip(strings.Join(d.m.prompt.lines, ""))
}
if !strings.Contains(scr, "Run Save it?") || !strings.Contains(shown, "db-password 'team a'") || !strings.Contains(shown, "on its input") {
t.Fatalf("no confirmation with the command %q:\n%s", shown, scr)
}
if strings.Contains(scr, "correct-horse") {
t.Fatal("value shown in the confirmation")
}
d.keys("y")
b, err := os.ReadFile(out)
if err != nil {
t.Fatalf("command didn't run: %v\n%s", err, d.screen())
}
if got := string(b); got != "correct-horse-battery-staple"+"db-password team a\n" {
t.Errorf("command got %q", got)
}
if !strings.Contains(d.screen(), "✓ Save it") {
t.Errorf("no confirmation message:\n%s", d.screen())
}
}
func TestCommandPopupAndMenu(t *testing.T) {
d := commandDriver(t,
commands.Command{Context: commands.Vaults, Description: "Show vault", Command: "echo vault={{.Vault}} url={{.VaultURL}}", Output: commands.Popup},
commands.Command{Context: commands.Secrets, Description: "Secrets only", Command: "true"},
)
d.keys("2", "x")
if !strings.Contains(d.screen(), "Show vault") || strings.Contains(d.screen(), "Secrets only") {
t.Fatalf("commands menu for the Vaults pane:\n%s", d.screen())
}
d.keys("enter")
if !strings.Contains(d.screen(), "vault=kv-app-prod url=https://kv-app-prod.vault.azure.net") {
t.Errorf("popup output:\n%s", d.screen())
}
}
func TestCommandNeedsItsSelection(t *testing.T) {
d := commandDriver(t, commands.Command{Key: "K", Context: commands.Secrets, Description: "Secret thing", Command: "true"})
d.keys("1", "K") // Sources pane: not a secrets command's context
if d.m.prompt != nil {
t.Error("ran from the wrong pane")
}
}
+224
View File
@@ -0,0 +1,224 @@
package app
import (
"cmp"
"errors"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Opening a repo's file (e): SOPS files with sops edit, which decrypts
// them for the editor and encrypts them again after. Other files, inside
// nvim: in that nvim, once lazysecrets has quit; else in $VISUAL or
// $EDITOR, coming back after; else in the system's default app. On a
// secret, editors that can open at a line start on its key.
type editDoneMsg struct {
c provider.Container
name string
err error
}
// sopsUnchanged is sops edit's exit code when the file wasn't changed.
const sopsUnchanged = 200
// edit opens the selected file, or the file of the selected secret.
func (m *Model) edit() tea.Cmd {
if m.focus == paneDetail && m.fields.Shown() {
f, ok := m.fields.Selected()
if !ok || f.Path == "" {
m.flash = warning("nothing to open in an editor here")
return nil
}
name := filepath.Base(f.Path)
return m.openInEditor(provider.File{Path: f.Path}, func(err error) tea.Msg { return editDoneMsg{name: name, err: err} })
}
var c provider.Container
var o *provider.Object
switch m.focus {
case paneSources:
m.flash = warning("select a file to open it")
return nil
case paneContainers:
sel, ok := m.containers.Selected()
if !ok {
return nil
}
c = sel
default:
sel, ok := m.detail.Secret()
if !ok {
return nil
}
c, o = sel.Container, &sel
}
files, ok := m.providerFor(c.Source).(provider.Files)
if !ok {
m.flash = warning("only SOPS and .env files in repos can be opened")
return nil
}
f, err := files.File(c, o)
if err != nil {
m.flash = flash{text: "open: " + err.Error(), isErr: true}
return nil
}
if o != nil {
m.usedObject(*o)
} else {
m.usedContainer(c)
}
name := filepath.Base(f.Path)
finished := func(err error) tea.Msg { return editDoneMsg{c: c, name: name, err: err} }
if f.Sops != "" {
cmd := exec.Command(f.Sops, "edit", f.Path)
cmd.Dir = filepath.Dir(f.Path) // sops looks for .sops.yaml from here
// sops starts SOPS_EDITOR (else EDITOR, else vim) on the decrypted
// copy, which keeps the file's lines: start it on the key.
editor := cmp.Or(m.getenv("SOPS_EDITOR"), m.getenv("EDITOR"), "vim")
if at, ok := editorAtLine(editor, f.Line); ok {
cmd.Env = append(os.Environ(), "SOPS_EDITOR="+at)
}
m.flash = flash{}
return tea.ExecProcess(cmd, finished)
}
return m.openInEditor(f, finished)
}
// plusLine are editors that open at a line with +N before the file.
var plusLine = map[string]bool{
"vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true,
"micro": true, "kak": true, "mg": true, "ne": true, "joe": true, "vis": true,
}
// editorAtLine is editor starting at line, for editors that take +N:
// sops adds the file after it.
func editorAtLine(editor string, line int) (string, bool) {
fields := strings.Fields(editor)
if line == 0 || len(fields) == 0 || !plusLine[filepath.Base(fields[0])] {
return "", false
}
return editor + " +" + strconv.Itoa(line), true
}
// editorArgs opens f at its line, for editors known to take one.
func editorArgs(editor string, f provider.File) []string {
fields := strings.Fields(editor)
if f.Line == 0 || len(fields) == 0 {
return []string{f.Path}
}
at := f.Path + ":" + strconv.Itoa(f.Line)
switch base := filepath.Base(fields[0]); {
case plusLine[base]:
return []string{"+" + strconv.Itoa(f.Line), f.Path}
case base == "hx" || base == "helix" || base == "subl" || base == "zed":
return []string{at}
case base == "code" || base == "codium" || base == "cursor" || base == "windsurf":
return []string{"-g", at}
}
return []string{f.Path}
}
// handleEditDone reports how editing went and reloads the file's secrets:
// their names, lines or values may have changed.
func (m *Model) handleEditDone(msg editDoneMsg) tea.Cmd {
var exit *exec.ExitError
switch {
case errors.As(msg.err, &exit) && exit.ExitCode() == sopsUnchanged:
m.flash = flash{text: msg.name + " unchanged"}
return nil
case errors.Is(msg.err, exec.ErrNotFound):
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error() + " (is sops installed?)", isErr: true}
return nil
case msg.err != nil:
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true}
return nil
}
m.flash = done("closed " + msg.name)
if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) {
m.hideValue()
m.secrets.Refresh()
return m.loadObjects(c, m.objType, m.selContainer)
}
return nil
}
// nvimLua opens a file in the nvim lazysecrets runs in. If lazysecrets is
// that terminal's own process (the nvim plugin, or :terminal lazysecrets),
// it waits for the terminal to close, so the file opens in the window
// below rather than in one about to close. Otherwise (a shell in a
// terminal) it opens it in the previous window now. The autocmd isn't
// the buffer's own: snacks wipes the buffer when the terminal closes,
// which would delete it before it runs.
const nvimLua = `local path, line, pid = ...
local function open()
local ok, err = pcall(vim.cmd, "edit " .. vim.fn.fnameescape(path))
if not ok then vim.notify("lazysecrets: " .. err, vim.log.levels.ERROR) return end
if line > 0 then pcall(vim.api.nvim_win_set_cursor, 0, { line, 0 }) vim.cmd("normal! zz") end
end
for _, b in ipairs(vim.api.nvim_list_bufs()) do
if vim.bo[b].buftype == "terminal" and vim.b[b].terminal_job_pid == pid then
vim.api.nvim_create_autocmd("TermClose", { callback = function(ev)
if ev.buf ~= b then return end
vim.defer_fn(open, 50)
return true
end })
return
end
end
pcall(vim.cmd, "wincmd p")
open()`
// nvimOpen asks the nvim at server to open path at line.
func nvimOpen(server, path string, line int) error {
q := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
lua := strings.ReplaceAll(nvimLua, "\n", " ")
expr := "luaeval('load(_A[1])(_A[2], _A[3], _A[4])', [" + q(lua) + ", " + q(path) + ", " +
strconv.Itoa(line) + ", " + strconv.Itoa(os.Getpid()) + "])"
out, err := exec.Command("nvim", "--server", server, "--remote-expr", expr).CombinedOutput()
if err != nil {
if msg := strings.TrimSpace(string(out)); msg != "" {
return errors.New(msg)
}
return err
}
return nil
}
// openInEditor opens f: in the nvim lazysecrets runs in, quitting; else
// in $VISUAL or $EDITOR, coming back after; else in the default app.
func (m *Model) openInEditor(f provider.File, finished func(error) tea.Msg) tea.Cmd {
name := filepath.Base(f.Path)
if server := m.getenv("NVIM"); server != "" {
open := m.nvimOpen
return func() tea.Msg {
if err := open(server, f.Path, f.Line); err != nil {
return finished(err)
}
return tea.QuitMsg{}
}
}
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
// Through the shell, so EDITOR can carry arguments ("code -w").
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
m.flash = flash{}
return tea.ExecProcess(exec.Command("/bin/sh", args...), finished)
}
switch err := m.openURL(f.Path); {
case errors.Is(err, browser.ErrRemote):
m.flash = warning("set $EDITOR to open files over SSH")
case err != nil:
m.flash = flash{text: "open: " + err.Error(), isErr: true}
default:
m.flash = done("opened " + name)
}
return nil
}
+64
View File
@@ -0,0 +1,64 @@
package app
import (
"errors"
"slices"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// The status pane lists this session's errors, newest first: what a pane
// showed (a list that couldn't load) and what the footer flashed (a copy
// that failed), which go when you move on. Only the first line of each,
// and never kept after quitting.
// maxErrors is how many errors are kept.
const maxErrors = 20
type loggedErr struct {
at time.Time
text string
}
// logErr records text; an error already there moves to the top, with
// the time it happened again.
func (m *Model) logErr(text string) {
text, _, _ = strings.Cut(strings.TrimSpace(text), "\n")
if text == "" {
return
}
m.errLog = slices.DeleteFunc(m.errLog, func(e loggedErr) bool { return e.text == text })
m.errLog = append([]loggedErr{{m.now(), text}}, m.errLog[:min(len(m.errLog), maxErrors-1)]...)
}
// logErrOf records err, about what, except a sign-in, which the footer
// and the account's details say.
func (m *Model) logErrOf(what string, err error) {
var auth *provider.AuthError
if !errors.As(err, &auth) {
m.logErr(what + ": " + err.Error())
}
}
// logFlash records an error the footer is showing, once.
func (m *Model) logFlash() {
if m.flash.isErr && m.flash.text != m.loggedFlash {
m.logErr(m.flash.text)
}
m.loggedFlash = m.flash.text
}
// errorFields are the status pane's rows for them.
func (m Model) errorFields() []panes.Field {
out := make([]panes.Field, len(m.errLog))
for i, e := range m.errLog {
out[i] = panes.Field{Value: e.at.Format("15:04") + " " + e.text, Err: true}
}
if len(out) > 0 {
out[0].Label = "errors"
}
return out
}
+75
View File
@@ -0,0 +1,75 @@
package app
import (
"cmp"
"fmt"
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// The status pane lists what has expired or will soon, across every vault
// loaded (opened, searched, or from the cache): expiry dates come with
// the lists, so this makes no requests. enter on one goes to it.
type expiringItem struct {
o provider.Object
row string // its source's row key, to go to it
}
// expiringItems are the loaded objects that have expired or will within
// the warning period, soonest first.
func (m Model) expiringItems() []expiringItem {
var out []expiringItem
for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources {
row := sourceRowKey(p.ID(), src.ID)
for _, c := range m.cache.containers[row].value {
for _, t := range c.ObjectTypes() {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
if state, _ := m.expiry(o); state != notExpiring {
out = append(out, expiringItem{o, row})
}
}
}
}
}
}
slices.SortFunc(out, func(a, b expiringItem) int {
return cmp.Or(a.o.Expires.Compare(b.o.Expires), cmp.Compare(a.o.Name, b.o.Name))
})
return out
}
// expiringFields are the status pane's rows for them, and how much of
// everything they cover.
func (m Model) expiringFields() []panes.Field {
items := m.expiringItems()
var out []panes.Field
for _, it := range items {
state, left := m.expiry(it.o)
mark := "⚠ " + daysLeft(left)
if state == expired {
mark = "✗ expired"
}
f := panes.Field{Value: mark + " " + it.o.Name + " · " + it.o.Container.Name, Err: state == expired, Row: it.row}
if r, err := ref.ForObject(it.o, ""); err == nil {
f.Link = r.String()
}
out = append(out, f)
}
cov := m.coverageOf(ofKind(provider.KindAzure))
switch {
case cov.vaults == 0 && len(items) == 0:
return nil
case !cov.complete():
out = append(out, panes.Field{Value: fmt.Sprintf("in %d of %d vaults", cov.vaultsLoaded, cov.vaults) +
withKey(m.keys.SearchLoadAll, " (%s in search loads all)"), Info: true})
case len(items) == 0:
out = append(out, panes.Field{Value: fmt.Sprintf("nothing within %d days", int(m.expiryWarning.Hours()/24)), Info: true})
}
out[0].Label = "expiring"
return out
}
+55
View File
@@ -0,0 +1,55 @@
package app
import (
"fmt"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// Secrets, keys and certificates with an expiry date are marked in the
// list once they have expired (✗ expired) or will within the configured
// warning period (⚠ 12d), and typing "expir" in the filter finds them.
type expiryState int
const (
notExpiring expiryState = iota
expiresSoon
expired
)
// expiry tells whether o has expired or will within the warning period,
// and how long it has left.
func (m Model) expiry(o provider.Object) (expiryState, time.Duration) {
if o.Expires.IsZero() {
return notExpiring, 0
}
left := o.Expires.Sub(m.now())
switch {
case left <= 0:
return expired, 0
case left <= m.expiryWarning:
return expiresSoon, left
}
return notExpiring, left
}
// expiryWord is matched by the filter: "expired" or "expiring".
func (m Model) expiryWord(o provider.Object) string {
switch state, _ := m.expiry(o); state {
case expired:
return "expired"
case expiresSoon:
return "expiring"
}
return ""
}
// daysLeft is a short "12d", or "5h" on the last day.
func daysLeft(d time.Duration) string {
if d < 24*time.Hour {
return fmt.Sprintf("%dh", max(1, int(d.Hours())))
}
return fmt.Sprintf("%dd", int(d.Hours()/24))
}
+245
View File
@@ -0,0 +1,245 @@
package app
import (
"slices"
"strings"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// What lazysecrets remembers about subscriptions, vaults and secrets —
// stars, notes — lives in the store, one record per item keyed by its
// lazysecrets:// link. Keys leave out the account, so a star or note on a
// subscription shows under every account that sees it.
const star = "★ "
func sourceKey(s provider.Source) string {
if r, err := ref.ForSource(s); err == nil {
return r.String()
}
return "source:" + string(s.Kind) + "/" + s.ID
}
func storeKey(c provider.Container) string {
if r, err := ref.ForContainer(c, provider.TypeSecret); err == nil {
return r.String()
}
return "container:" + c.ID
}
func objectKey(o provider.Object) string {
if r, err := ref.ForObject(o, ""); err == nil {
return r.String()
}
return "object:" + o.ID
}
// remember records context with a change, so lists of remembered items
// can show and jump to them later.
func (m *Model) remember(key, name string, path []string, src provider.Source, change func(*store.Node)) error {
ctx := withContext(name, path, src)
return m.store.Update(key, func(n *store.Node) {
ctx(n)
change(n)
})
}
// withContext sets a record's context: where the item is, and the account
// that read it. A vault opened from a link has no known subscription;
// that keeps the one already recorded.
func withContext(name string, path []string, src provider.Source) func(*store.Node) {
return func(n *store.Node) {
n.Name = name
if src.ID != "" || n.Source == "" {
n.Path, n.Source = path, src.ID
}
if src.Provider != "" {
n.Account = src.Provider
}
}
}
// refreshContext updates a remembered item's context after reaching it,
// e.g. the account that could read it. Items without a record are left
// alone, so browsing doesn't write the file.
func (m *Model) refreshContext(key, name string, path []string, src provider.Source) {
if m.store.Has(key) && src.Provider != "" {
_ = m.remember(key, name, path, src, func(*store.Node) {})
}
}
// containerRender draws a vault or scope, starred or not. In the
// Opened links list it also names the source, since they come from many.
func (m Model) containerRender(withSource bool) func(styles.Styles, provider.Container, int) string {
st, iconSet := m.store, m.icons
return func(sty styles.Styles, c provider.Container, width int) string {
n := st.Node(storeKey(c))
line := c.Name
if c.Source.Kind == provider.KindRepo {
// A secret file: a lock for an encrypted one, a file in the
// warning colour for plain text, and its type after it
// ("sops · age", "env · plain text").
icon, style := iconSet.Encrypted, sty.KindRepo
if !strings.HasPrefix(c.Detail, "sops") {
icon, style = iconSet.PlainFile, sty.Warning
}
if icon != "" {
line = style.Render(icon) + " " + line
}
line += sty.Muted.Render(" " + c.Detail)
}
if n.StarredFor(c.Source.Provider) {
line = sty.Favourite.Render(star) + line
}
if withSource {
src := c.Source.Name
if account, ok := strings.CutPrefix(c.Source.ProviderLabel, string(c.Source.Kind)+" · "); ok {
src = account + " / " + src
}
line += sty.Muted.Render(" · " + src)
}
return withNote(sty, line, n.Note, width)
}
}
func (m Model) objectRender() func(styles.Styles, provider.Object, int) string {
st := m.store
return func(sty styles.Styles, o provider.Object, width int) string {
n := st.Node(objectKey(o))
line := o.Name
if o.Enabled != nil && !*o.Enabled {
line = sty.Muted.Render(o.Name + " (disabled)")
}
if n.StarredFor(o.Container.Source.Provider) {
line = sty.Favourite.Render(star) + line
}
switch state, left := m.expiry(o); state {
case expired:
line += " " + sty.Error.Render("✗ expired")
case expiresSoon:
line += " " + sty.Warning.Render("⚠ "+daysLeft(left))
}
return withNote(sty, line, n.Note, width)
}
}
// starredContainersFirst moves starred vaults and scopes to the front,
// keeping the order otherwise. It doesn't modify items, which may be cached.
func (m Model) starredContainersFirst(items []provider.Container) []provider.Container {
out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b provider.Container) int {
return boolRank(m.store.Node(storeKey(a)).StarredFor(a.Source.Provider)) - boolRank(m.store.Node(storeKey(b)).StarredFor(b.Source.Provider))
})
return out
}
// starredFirst moves starred objects to the front, keeping the order
// otherwise. It doesn't modify items, which may be cached.
func (m Model) starredFirst(items []provider.Object) []provider.Object {
out := slices.Clone(items)
slices.SortStableFunc(out, func(a, b provider.Object) int {
return boolRank(m.store.Node(objectKey(a)).StarredFor(a.Container.Source.Provider)) - boolRank(m.store.Node(objectKey(b)).StarredFor(b.Container.Source.Provider))
})
return out
}
// showLinkedContainers lists the vaults opened from links.
func (m *Model) showLinkedContainers() {
m.containers.Title, m.containers.Empty = "[2] Opened links", "no links opened"
m.containers.SetRender(m.containerRender(true))
m.containers.SetItems(m.linked)
m.containers.Select(m.cache.cursor[linksRowID])
}
// rebind records the account that served a vault opened from a link. The
// container's key includes the account, so the selection moves to the
// new key without reloading. It returns the new key for the object list.
func (m *Model) rebind(c provider.Container) string {
m.refreshContext(storeKey(c), c.Name, []string{c.Source.Name}, c.Source)
for i := range m.linked {
if m.linked[i].ID == c.ID {
m.linked[i].Source = c.Source
}
}
key := containerKey(c) + "|" + string(m.objType)
if m.onLinks() {
m.containers.SetItems(m.linked)
m.containers.Select(containerKey(c))
m.selContainer = key
}
return key
}
// resortStarred puts starred items first again in every pane, after
// stars changed elsewhere (the favourites list).
func (m *Model) resortStarred() {
m.rebuildSources()
if !m.onLinks() {
m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
}
if m.versionsOf == nil {
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
}
}
// toggleFavourite stars or unstars the selected subscription (Sources),
// vault or scope (Vaults) or object (Secrets and detail panes).
func (m *Model) toggleFavourite() tea.Cmd {
var name string
var on bool
// A star is the item through the account showing it.
var err error
switch m.focus {
case paneSources:
src, ok := m.selectedSource()
if !ok {
m.flash = warning("select a subscription or workspace to star")
return nil
}
name = src.Name
toggle := func(n *store.Node) { on = n.ToggleStar(src.Provider) }
if err = m.remember(sourceKey(src), src.Name, nil, src, toggle); err == nil {
m.rebuildSources() // re-sorts; the cursor follows the source
}
case paneContainers:
c, ok := m.containers.Selected()
if !ok {
return nil
}
name = c.Name
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
if err = m.remember(storeKey(c), c.Name, []string{c.Source.Name}, c.Source, toggle); err == nil {
m.containers.SetItems(m.starredContainersFirst(m.containers.Items()))
}
default:
if m.versionsOf != nil {
m.flash = warning("star the item from the list rather than a version")
return nil
}
o, ok := m.detail.Secret()
if !ok {
return nil
}
name = o.Name
c := o.Container
toggle := func(n *store.Node) { on = n.ToggleStar(c.Source.Provider) }
if err = m.remember(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source, toggle); err == nil {
m.secrets.SetItems(m.starredFirst(m.secrets.Items()))
}
}
switch {
case err != nil:
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
case on:
m.flash = done("starred " + name)
default:
m.flash = done("unstarred " + name)
}
return nil
}
+436
View File
@@ -0,0 +1,436 @@
package app
import (
"context"
"path/filepath"
"slices"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// jump opens the favourites list, filters it and jumps to the first match.
func (d *driver) jump(filter string) {
d.t.Helper()
d.keys("F")
if d.m.search == nil || d.m.search.scope().id != scopeFavs {
d.t.Fatalf("F didn't open Go to on Favourites:\n%s", d.screen())
}
for _, r := range filter {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
d.keys("enter")
}
func TestStarVaultSortsFirstAndJumps(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("2", "j", "f") // star kv-shared-prod
if items := d.m.containers.Items(); items[0].Name != "kv-shared-prod" {
t.Errorf("starred vault not first: %s", items[0].Name)
}
if !strings.Contains(d.screen(), "★ kv-shared-prod") {
t.Errorf("no star:\n%s", d.screen())
}
if strings.Contains(d.screen(), "Favourites (") {
t.Error("the old Favourites row is back")
}
d.keys("1", "j") // elsewhere
d.keys("F")
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod") {
t.Fatalf("favourites list:\n%s", d.screen())
}
d.keys("esc")
d.jump("shared")
if src, c, _ := d.selected(); src != "platform-prod" || c != "kv-shared-prod" {
t.Errorf("jumped to %s/%s", src, c)
}
if d.m.focus != paneContainers {
t.Errorf("focus %d after jumping to a vault", d.m.focus)
}
}
func TestJumpToStarredSourceAndSecret(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "j", "f") // star api-key in platform-prod / kv-app-prod
d.keys("1", "j", "f") // star platform-dev (it moves to the top of its group)
d.keys("j", "j", "j") // down to Databricks prod
d.jump("platform-dev")
if src, _, _ := d.selected(); src != "platform-dev" || d.m.focus != paneContainers {
t.Errorf("source jump: %s, focus %d", src, d.m.focus)
}
d.jump("api")
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
t.Errorf("secret jump: %s/%s/%s", src, c, s)
}
}
func TestFavouritesListEmpty(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("F")
if d.m.search == nil || !strings.Contains(d.screen(), "nothing starred yet") {
t.Errorf("empty favourites:\n%s", d.screen())
}
}
func TestStarSecretSortsFirst(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "j", "f") // star api-key
if _, _, s := d.selected(); s != "api-key" {
t.Errorf("cursor left the starred item: %q", s)
}
rows := d.m.secrets.Items()
if rows[0].Name != "api-key" || !strings.Contains(d.screen(), "★ api-key") {
t.Errorf("starred secret not first: %v\n%s", rows[0].Name, d.screen())
}
// Still first after leaving and coming back (cached list).
d.keys("2", "j", "k")
if d.m.secrets.Items()[0].Name != "api-key" {
t.Error("starred secret not first after returning")
}
}
func TestNoStarringVersions(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("v", "f")
if d.m.store.Node(objectKey(*d.m.versionsOf)).StarredFor("Azure") || !strings.Contains(d.screen(), "rather than a version") {
t.Errorf("starring a version:\n%s", d.screen())
}
}
func TestFavouritesPersist(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "state.json")
load := func() *store.Store {
s, err := store.Load(path)
if err != nil {
t.Fatal(err)
}
return s
}
d := newDriverOpts(t, Options{Store: load()})
d.keys("2", "f", "3", "j", "f")
d2 := newDriverOpts(t, Options{Store: load()})
d2.keys("F")
if !strings.Contains(d2.screen(), "platform-prod / kv-app-prod") ||
!strings.Contains(d2.screen(), "platform-prod / kv-app-prod / api-key") {
t.Errorf("favourites not restored:\n%s", d2.screen())
}
d2.keys("esc")
if d2.m.secrets.Items()[0].Name != "api-key" {
t.Error("secret favourite not restored")
}
}
func TestStarSourceMovesItToTheTop(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("j", "f") // star platform-dev
if src, _, _ := d.selected(); src != "platform-dev" {
t.Errorf("cursor left the starred source: %q", src)
}
rows := d.m.sources.Items()
if rows[1].source.Name != "platform-dev" || !strings.Contains(d.screen(), "★ platform-dev") {
t.Errorf("not first in its group: %q\n%s", rows[1].source.Name, d.screen())
}
// Flat: starred sources come before every other provider's.
// platform-dev now leads its group: platform-prod, header, prod, dev.
d.keys("j", "j", "j", "j", "f", "`") // also star Databricks dev
var flat []string
for _, r := range d.m.sources.Items() {
flat = append(flat, r.source.Name)
}
if strings.Join(flat[:2], ",") != "platform-dev,dev" {
t.Errorf("flat order = %v", flat)
}
d.keys("`", "k") // grouped again; dev leads its group, so k is the header
d.keys("f")
if !strings.Contains(d.screen(), "select a subscription or workspace to star") {
t.Errorf("starring a header:\n%s", d.screen())
}
}
// denying refuses to list objects, like an account without access.
type denying struct{ *counting }
func (d denying) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) {
d.objects++
return nil, fake.ErrForbidden
}
// kvKey is kv-app-prod's store key, kvSharedKey kv-shared-prod's.
const (
kvKey = "lazysecrets://azure/kv-app-prod.vault.azure.net"
kvSharedKey = "lazysecrets://azure/kv-shared-prod.vault.azure.net"
)
// starredWithoutAccount is kv-app-prod starred with no account recorded,
// like one starred by an account that's no longer signed in.
func starredWithoutAccount(t *testing.T) *store.Store {
t.Helper()
s, _ := store.Load("")
if err := s.Update(kvKey, func(n *store.Node) {
n.Name, n.Path, n.Source, n.StarredBy = "kv-app-prod", []string{"platform-prod"}, "sub-prod", []string{"Azure:carol"}
}); err != nil {
t.Fatal(err)
}
return s
}
func TestOldFavouriteFindsTheAccountWithAccess(t *testing.T) {
t.Parallel()
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
mem := starredWithoutAccount(t)
d := newDriverOpts(t, Options{Store: mem}, denying{alice}, bob)
d.jump("kv-app") // both accounts see platform-prod: which one?
if !strings.Contains(d.screen(), "Several accounts can see kv-app-prod") {
t.Fatalf("no account choice:\n%s", d.screen())
}
d.keys("2") // bob
if _, c, s := d.selected(); c != "kv-app-prod" || s != "db-password" {
t.Fatalf("favourite not browsable: %q/%q\n%s", c, s, d.screen())
}
if got := mem.Node(kvKey).Account; got != "Azure:bob" {
t.Errorf("favourite bound to %q, want Azure:bob", got)
}
d.keys("R") // used through bob now
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod · bob") {
t.Errorf("recent should show the account:\n%s", d.screen())
}
d.keys("esc")
// Reads go through bob, and alice isn't asked again.
before := alice.objects
d.keys("space")
if bob.values != 1 || alice.values != 0 {
t.Errorf("reveal: alice %d, bob %d", alice.values, bob.values)
}
d.keys("2", "r")
if alice.objects != before {
t.Errorf("alice asked again after binding: %d → %d", before, alice.objects)
}
}
func TestOldFavouriteNoAccountHasAccess(t *testing.T) {
t.Parallel()
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
mem := starredWithoutAccount(t)
d := newDriverOpts(t, Options{Store: mem}, denying{alice}, denying{bob})
d.jump("kv-app")
d.keys("1") // alice: no access
if !strings.Contains(d.screen(), "403 Forbidden") {
t.Errorf("no access error:\n%s", d.screen())
}
if mem.Node(kvKey).Account != "" {
t.Error("favourite bound although no account worked")
}
}
func TestFavouritesPopupKeepsItsSize(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("j", "f", "2", "j", "f", "3", "j", "f") // a subscription, a vault, a secret
d.keys("F")
size := func() (int, int) {
box := d.m.searchViewBox()
return lipgloss.Width(box), lipgloss.Height(box)
}
w0, h0 := size()
for _, k := range []string{"down", "down", "up"} {
d.send(tea.KeyPressMsg{Code: map[string]rune{"down": tea.KeyDown, "up": tea.KeyUp}[k]})
if w, h := size(); w != w0 || h != h0 {
t.Fatalf("popup changed from %dx%d to %dx%d moving %s", w0, h0, w, h, k)
}
}
d.send(tea.KeyPressMsg{Code: 'z', Text: "z"}) // filter down to nothing
if w, h := size(); w != w0 || h != h0 {
t.Errorf("popup changed from %dx%d to %dx%d while filtering", w0, h0, w, h)
}
}
func TestStoreKeysAreLinks(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("f", "2", "f", "3", "j", "f")
var keys []string
for k := range d.m.store.Nodes() {
keys = append(keys, k)
}
slices.Sort(keys)
want := []string{
"lazysecrets://azure/kv-app-prod.vault.azure.net",
"lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key",
"lazysecrets://azure/subscriptions/sub-prod",
}
if !slices.Equal(keys, want) {
t.Errorf("keys = %v", keys)
}
n := d.m.store.Node(want[1])
if n.Name != "api-key" || n.Source != "sub-prod" || n.Account != "Azure" || !slices.Equal(n.Path, []string{"platform-prod", "kv-app-prod"}) {
t.Errorf("context = %+v", n)
}
}
func TestFavouritesShowNotes(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("2", "j", "f", "n") // star kv-shared-prod and note it
for _, r := range "shared certs" {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
d.keys("enter", "F")
line := ""
for l := range strings.SplitSeq(d.screen(), "\n") {
if strings.Contains(l, "kv-shared-prod") && strings.Contains(l, "│") && strings.Contains(l, "/") {
line = l
}
}
if !strings.Contains(line, "shared certs") {
t.Fatalf("no note in the favourites row %q:\n%s", line, d.screen())
}
for _, r := range "certs" { // the filter matches notes
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
if len(d.m.search.shown) != 1 {
t.Errorf("filtering by note: %d entries", len(d.m.search.shown))
}
}
func TestSecretWithoutContextJumpsThroughItsVault(t *testing.T) {
t.Parallel()
mem, _ := store.Load("")
_ = mem.Update(kvKey, func(n *store.Node) {
n.Name, n.Path, n.Source, n.Account, n.StarredBy = "kv-app-prod", []string{"platform-prod"}, "sub-prod", "Azure", []string{"Azure"}
})
_ = mem.Update(kvKey+"/secrets/api-key", func(n *store.Node) { n.StarredBy = []string{"Azure"} })
// counting hides Locate, like an account that can't find the vault
// in Resource Graph: opening it as a link would land in Opened links.
d := newDriverOpts(t, Options{Store: mem}, &counting{Provider: fake.Azure(0)})
d.keys("j") // elsewhere
d.jump("api-key")
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
t.Errorf("jumped to %s/%s/%s", src, c, s)
}
if strings.Contains(d.screen(), "Opened links") {
t.Errorf("went through Opened links:\n%s", d.screen())
}
}
func TestLinkedVaultKeepsKnownContext(t *testing.T) {
t.Parallel()
n := store.Node{Source: "sub-prod", Path: []string{"platform-prod", "kv-app-prod"}}
// A vault opened from a link: account known, subscription not.
withContext("api-key", []string{"kv-app-prod.vault.azure.net", "kv-app-prod"},
provider.Source{Kind: provider.KindAzure, Provider: "Azure:bob"})(&n)
if n.Source != "sub-prod" || n.Path[0] != "platform-prod" || n.Account != "Azure:bob" {
t.Errorf("context = %+v", n)
}
}
func TestToggleStarInGoTo(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("2", "j", "f") // star kv-shared-prod
d.keys("3", "j", "space", "2") // use smtp-password, unstarred
smtp := kvSharedKey + "/secrets/smtp-password"
ctrlF := tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl}
d.keys("R", "/")
d.typeText("smtp")
d.send(ctrlF) // star it from Recent, typing
if !d.m.store.Node(smtp).StarredFor("Azure") || !strings.Contains(d.screen(), "★ ") {
t.Fatalf("ctrl+f didn't star a recent item:\n%s", d.screen())
}
d.send(ctrlF) // and back
if d.m.store.Node(smtp).StarredFor("Azure") {
t.Error("second ctrl+f didn't unstar")
}
if d.m.search == nil || d.m.search.input.Value() != "smtp" || len(d.m.search.shown) == 0 {
t.Fatalf("entry or query gone after toggling:\n%s", d.screen())
}
// Unstarring a favourite keeps it listed until Go to opens again.
d.keys("esc", "esc", "F") // stop typing, close, open on Favourites
v := d.m.search
for v.res < len(v.shown)-1 && v.shown[v.res].star != kvSharedKey {
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
}
if v.shown[v.res].star != kvSharedKey {
t.Fatalf("selected %q", v.shown[v.res].star)
}
d.keys("f") // browsing: f
if d.m.store.Node(kvSharedKey).StarredFor("Azure") {
t.Error("favourite still starred")
}
if v.shown[v.res].star != kvSharedKey || v.shown[v.res].starred {
t.Error("unstarred favourite didn't stay in place, unstarred")
}
d.keys("esc")
if strings.Contains(d.screen(), "★ kv-shared-prod") {
t.Errorf("Vaults pane still shows the star:\n%s", d.screen())
}
}
func TestStarWhileFiltering(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
for _, r := range "api" {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
d.send(tea.KeyPressMsg{Code: 'f', Mod: tea.ModCtrl})
if !d.m.store.Node(kvKey + "/secrets/api-key").StarredFor("Azure") {
t.Errorf("ctrl+f while filtering didn't star:\n%s", d.screen())
}
}
func TestStarIsPerAccount(t *testing.T) {
t.Parallel()
alice := fake.Azure(0).As("Azure:alice", "Azure · alice")
bob := fake.Azure(0).As("Azure:bob", "Azure · bob")
d := newDriverOpts(t, Options{}, alice, bob)
// Rows: alice header, platform-prod, platform-dev, bob header, ….
d.keys("f") // alice's platform-prod
n := d.m.store.Node("lazysecrets://azure/subscriptions/sub-prod")
if !n.StarredFor("Azure:alice") || n.StarredFor("Azure:bob") {
t.Errorf("starred by %v, want alice only", n.StarredBy)
}
if c := strings.Count(d.screen(), "★ platform-prod"); c != 1 {
t.Errorf("star shown %d times, want once:\n%s", c, d.screen())
}
// A note is about the thing: it shows under both.
d.keys("n")
d.typeText("shared note")
d.keys("enter")
// On both accounts' rows, and in the selected one's details.
if n := strings.Count(d.screen(), "shared note"); n != 3 {
t.Errorf("note shown %d times, want 3:\n%s", n, d.screen())
}
d.keys("F")
var labels []string
for _, o := range d.m.search.shown {
labels = append(labels, ansiStrip(o.label))
}
if len(labels) != 1 || !strings.Contains(labels[0], "platform-prod · alice") {
t.Errorf("favourites should list alice's star only: %q", labels)
}
}
+224
View File
@@ -0,0 +1,224 @@
package app
import (
"errors"
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// The fourth pane shows Fields for anything but a secret. On a provider's
// row: its account, from what the provider knows locally.
// A file's details beyond its listing load once a
// session, when the cursor rests on it, and again with refresh. They're
// extra: moving on cancels the load, and search never waits for them.
type detailsState struct {
attrs []provider.Attr
err error
loading bool
}
type detailsMsg struct {
req int
key string
attrs []provider.Attr
err error
}
// detailOf is the pane the fourth pane describes: the one in focus, or,
// with the fourth in focus, the one it was entered from.
func (m Model) detailOf() pane {
if m.focus != paneDetail {
return m.focus
}
return m.lastLeft
}
// syncFields shows the Fields pane for what the focused pane has selected,
// unless that's a secret (or a vault with the Secrets pane in focus), which
// the secret pane shows, as it does a revealed value whatever the focus.
func (m *Model) syncFields() tea.Cmd {
if m.detail.State() != panes.ValueHidden {
// A value revealed (or being fetched) from any pane stays in
// view until it's hidden.
m.fields.Hide()
return nil
}
m.fields.Header, m.fields.HeaderShort = nil, ""
switch m.detailOf() {
case paneSources:
r, ok := m.sources.Selected()
switch {
case m.nothingFound():
m.fields.Show("get started", m.getStartedFields())
case !ok:
m.fields.Show("", nil)
case m.selectedProblem() != "":
m.fields.Show(cmpOr(r.label, r.source.Name), m.problemFields(m.selectedProblem()))
case r.kind == rowGroup:
m.showAccount(r)
case r.kind == rowSource:
m.fields.Show(r.source.Name, m.sourceFields(r.source))
default:
m.fields.Show("", nil)
}
case paneStatus:
m.fields.Header, m.fields.HeaderShort = logo, strings.TrimSpace("lazysecrets "+m.versionLabel())
m.fields.Show("lazysecrets", m.statusFields())
case paneContainers:
if c, ok := m.containers.Selected(); ok {
cmd := m.loadDetails(c)
m.fields.Show(c.Name, m.containerFields(c))
return cmd
} else {
m.fields.Show("", nil)
}
default:
m.fields.Hide()
}
return nil
}
// showAccount shows a provider's account.
func (m *Model) showAccount(r sourceRow) {
p := m.byID[r.provider]
var fields []panes.Field
if a, ok := p.(provider.Accounts); ok {
if acct, ok := a.Account(); ok {
for _, f := range acct.Fields {
fields = append(fields, panes.Field{Label: f.Label, Value: f.Value})
}
}
}
if res, done := m.sourceResults[r.provider]; done && res.err == nil && len(res.sources) == 0 {
hint := emptyHint(p.Kind())
fields = append(fields, panes.Field{Label: "nothing", Value: "found: try", Info: true}, panes.Field{Value: hint.Value, URL: hint.URL})
}
m.fields.Show(p.Label(), fields)
}
// sourceFields describe a subscription, workspace or repo.
func (m Model) sourceFields(src provider.Source) []panes.Field {
var out []panes.Field
add := func(label, value string) {
if value != "" {
out = append(out, panes.Field{Label: label, Value: value})
}
}
// Azure's detail is "subscription <ID>": the ID row says it.
id, detail := "ID", ""
switch src.Kind {
case provider.KindDatabricks:
id, detail = "profile", "host"
case provider.KindRepo:
id, detail = "path", "remote"
}
add("name", src.Name)
add("note", m.store.Node(sourceKey(src)).Note)
if src.ID != src.Name {
add(id, src.ID)
}
if detail != "" {
add(detail, src.Detail)
}
if src.ProviderLabel != string(src.Kind) {
add("account", src.ProviderLabel)
}
add("web", src.URL)
if e, ok := m.cache.containers[sourceRowKey(src.Provider, src.ID)]; ok {
label := map[provider.Kind]string{provider.KindDatabricks: "scopes", provider.KindRepo: "files"}[src.Kind]
add(cmpOr(label, "vaults"), fmt.Sprint(len(e.value)))
}
return out
}
// containerFields describe a vault, scope or file.
func (m Model) containerFields(c provider.Container) []panes.Field {
var out []panes.Field
add := func(label, value string) {
if value != "" {
out = append(out, panes.Field{Label: label, Value: value})
}
}
add("name", c.Name)
add("note", m.store.Node(storeKey(c)).Note)
// The vault's URI, the scope's backend, the file's format.
switch c.Source.Kind {
case provider.KindAzure:
add("URI", c.Detail)
case provider.KindDatabricks:
add("backend", c.Detail)
case provider.KindRepo:
add("format", c.Detail)
}
add("source", cmpOr(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
add("web", c.URL)
for _, t := range c.ObjectTypes() {
if e, ok := m.cache.objects[containerKey(c)+"|"+string(t)]; ok {
add(strings.ToLower(string(t))+"s", fmt.Sprint(len(e.value)))
}
}
for _, a := range c.Attrs {
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
}
switch st := m.details[containerKey(c)]; {
case st.loading:
out = append(out, panes.Field{Label: "details", Value: "loading…", Info: true})
case errors.Is(st.err, provider.ErrUnsupported):
case st.err != nil:
out = append(out, panes.Field{Label: "details", Value: "couldn't load them: " + st.err.Error(), Info: true, Err: true})
}
for _, a := range m.details[containerKey(c)].attrs {
out = append(out, panes.Field{Label: a.Label, Value: a.Value, Err: a.Warn})
}
return out
}
// loadDetails loads c's details, if its provider has them and they
// aren't loaded yet.
func (m *Model) loadDetails(c provider.Container) tea.Cmd {
d, ok := m.providerFor(c.Source).(provider.ContainerDetails)
key := containerKey(c)
if _, seen := m.details[key]; !ok || seen {
return nil
}
// One at a time: the previous one, if it's still loading, is
// cancelled and forgotten, to load when it's shown again.
for k, st := range m.details {
if st.loading {
delete(m.details, k)
}
}
m.details[key] = detailsState{loading: true}
ctx, req := m.begin(slotDetails)
return m.settled(ctx, func() tea.Msg {
attrs, err := d.Details(ctx, c)
if ctx.Err() != nil {
err = ctx.Err()
}
return detailsMsg{req: req, key: key, attrs: attrs, err: err}
})
}
// problemFields show why an account has nothing to show, wrapped to the
// pane, in red.
func (m Model) problemFields(problem string) []panes.Field {
var out []panes.Field
for _, l := range strings.Split(ansi.Wrap(problem, max(20, m.fields.Width()-16), " "), "\n") {
out = append(out, panes.Field{Value: l, Info: true, Err: true})
}
out[0].Label = "problem"
return out
}
// fieldName names a copied field: its label, or a row of several under
// one label (a tag), its value.
func fieldName(f panes.Field) string {
return cmpOr(f.Label, f.Value)
}
+66
View File
@@ -0,0 +1,66 @@
package app
import (
"errors"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// When no account has anything to show, as on a first run, the right
// pane says how to get started: the sign-in for each kind of account
// (y copies it, L runs az login), and the demo.
// nothingFound reports whether every account has loaded, with nothing.
func (m Model) nothingFound() bool {
if m.sourcesPending > 0 {
return false
}
for _, p := range m.providers {
if len(m.sourceResults[p.ID()].sources) > 0 {
return false
}
}
return true
}
// getStartedFields are the rows saying how to get started.
func (m Model) getStartedFields() []panes.Field {
out := []panes.Field{{Value: "Nothing to show yet: sign in where your secrets are.", Info: true}}
seen := map[provider.Kind]bool{}
for _, p := range m.providers {
if seen[p.Kind()] {
continue
}
seen[p.Kind()] = true
f := emptyHint(p.Kind())
var auth *provider.AuthError
switch err := m.sourceResults[p.ID()].err; {
case errors.As(err, &auth) && len(auth.Login) > 0:
f.Provider = p.ID() // L signs in
out = append(out, f, panes.Field{Value: withKey(m.keys.Login, "%s runs it here"), Info: true})
case err != nil:
out = append(out, f, panes.Field{Value: err.Error(), Info: true, Err: true})
default:
out = append(out, f)
}
}
return append(out,
panes.Field{Label: "try", Value: "lazysecrets --demo"},
panes.Field{Value: "built-in sample data, to look around", Info: true},
panes.Field{Label: "docs", Value: "getting started", URL: update.DefaultRepo + "#get-started"},
)
}
// emptyHint is how an account of kind gets something to show.
func emptyHint(kind provider.Kind) panes.Field {
switch kind {
case provider.KindDatabricks:
return panes.Field{Label: "Databricks", Value: "databricks auth login --host https://<workspace>"}
case provider.KindRepo:
return panes.Field{Label: "repos", Value: "SOPS and .env files in your git repos",
URL: update.DefaultRepo + "/src/branch/main/docs/repos.md"}
}
return panes.Field{Label: string(kind), Value: "az login"}
}
+74
View File
@@ -0,0 +1,74 @@
package app
import tea "charm.land/bubbletea/v2"
// At startup, if lazysecrets:// links don't open lazysecrets, a popup
// offers to set that up, once per start until the user says not to ask
// again; that choice is kept in the store.
// HandlerSetup offers to set up the link handler.
type HandlerSetup struct {
Reason string // why links don't open lazysecrets
// Install sets it up, returning what links open in ("kitty").
Install func() (string, error)
}
const (
flagNoHandlerPrompt = "noLinkHandlerPrompt"
setupTitle = "Open links in lazysecrets?"
)
// deferHandlerSetup puts the question back to show later, if a link
// popup is about to replace it.
func (m *Model) deferHandlerSetup() {
if m.prompt != nil && m.prompt.title == setupTitle {
m.askHandler = true
}
}
type handlerInstalledMsg struct {
opens string
err error
}
// askHandlerSetup shows the popup, once no other popup is in the way.
func (m *Model) askHandlerSetup() {
if !m.askHandler || m.prompt != nil || m.search != nil {
return
}
m.askHandler = false
setup := m.handlerSetup
m.prompt = &modal{
title: setupTitle,
lines: []string{
setup.Reason + ".",
m.st.Muted.Render("Set up, clicking a lazysecrets:// link (in Slack, a browser…) opens it here."),
},
options: []option{
{key: "i", label: "Set it up", run: func(*Model) tea.Cmd {
return func() tea.Msg {
opens, err := setup.Install()
return handlerInstalledMsg{opens, err}
}
}},
{key: "s", label: "Not now", tip: "Asks again next time lazysecrets starts.", run: func(*Model) tea.Cmd { return nil }},
{key: "n", label: "Don't ask again", tip: "lazysecrets handler install sets it up later.", run: func(m *Model) tea.Cmd {
if err := m.store.SetFlag(flagNoHandlerPrompt, true); err != nil {
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
}
return nil
}},
},
}
}
func (m *Model) handlerInstalled(msg handlerInstalledMsg) {
switch {
case msg.err != nil:
m.flash = flash{text: "setting up links: " + msg.err.Error(), isErr: true}
case msg.opens != "":
m.flash = done("lazysecrets:// links now open here, or in a new " + msg.opens + " window")
default:
m.flash = done("lazysecrets:// links now open in lazysecrets")
}
}
+71
View File
@@ -0,0 +1,71 @@
package app
import (
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
func setupOpts(mem *store.Store, installs *int) Options {
return Options{Store: mem, HandlerSetup: &HandlerSetup{
Reason: "lazysecrets:// links don't open lazysecrets yet",
Install: func() (string, error) { *installs++; return "kitty", nil },
}}
}
func TestHandlerSetupPopup(t *testing.T) {
t.Parallel()
mem, _ := store.Load("")
installs := 0
d := newDriverOpts(t, setupOpts(mem, &installs))
for _, want := range []string{"Open links in lazysecrets?", "links don't open lazysecrets yet", "i Set it up", "s Not now", "n Don't ask again"} {
if !strings.Contains(d.screen(), want) {
t.Fatalf("missing %q:\n%s", want, d.screen())
}
}
d.keys("i")
if installs != 1 || !strings.Contains(d.screen(), "links now open here, or in a new kitty window") {
t.Errorf("set up: %d installs\n%s", installs, d.screen())
}
// Not now: asked again next start.
d = newDriverOpts(t, setupOpts(mem, &installs))
d.keys("s")
if d.m.prompt != nil || mem.Flag(flagNoHandlerPrompt) {
t.Error("not now")
}
d = newDriverOpts(t, setupOpts(mem, &installs))
if d.m.prompt == nil {
t.Fatal("not asked again after not now")
}
// Don't ask again: remembered.
d.keys("n")
if !mem.Flag(flagNoHandlerPrompt) {
t.Error("don't ask again not stored")
}
d = newDriverOpts(t, setupOpts(mem, &installs))
if d.m.prompt != nil {
t.Errorf("asked after don't ask again:\n%s", d.screen())
}
}
// A link given at startup is offered first; the setup question waits.
func TestHandlerSetupWaitsForLink(t *testing.T) {
t.Parallel()
mem, _ := store.Load("")
installs := 0
opts := setupOpts(mem, &installs)
r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key")
opts.Open = &r
d := newDriverOpts(t, opts)
if !strings.Contains(d.screen(), "Open link") {
t.Fatalf("link not first:\n%s", d.screen())
}
d.keys("x")
if !strings.Contains(d.screen(), "Open links in lazysecrets?") {
t.Errorf("setup question not shown after the link:\n%s", d.screen())
}
}
+335
View File
@@ -0,0 +1,335 @@
package app
import (
"fmt"
"slices"
"strings"
"charm.land/bubbles/v2/help"
"charm.land/bubbles/v2/key"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// The help bar shows the keys that matter in the focused pane; ? lists all
// of them in a menu (showKeybindings).
// newHelp styles the help bar to read clearly: keys like labels elsewhere,
// descriptions in the normal text colour, only the separators dimmed. The
// bubbles defaults are grey on grey.
func newHelp(st styles.Styles) help.Model {
h := help.New()
h.Styles.ShortKey = st.Label
h.Styles.ShortDesc = lipgloss.NewStyle()
h.Styles.ShortSeparator = st.Muted
h.Styles.Ellipsis = st.Muted
h.Styles.FullKey, h.Styles.FullDesc, h.Styles.FullSeparator = h.Styles.ShortKey, h.Styles.ShortDesc, h.Styles.ShortSeparator
return h
}
// contextHelp is the help bar for the focused pane.
type contextHelp struct {
k keys.Map
focus pane
commands bool // custom commands apply here
account bool // the fourth pane shows Fields (fields.go)
of pane // what the fourth pane describes
// revealed, when set, says whether the selected value is shown, so
// the reveal key says just what it'll do: show or hide, the same
// width so the bar doesn't shift.
revealed *bool
}
func (c contextHelp) ShortHelp() []key.Binding {
k := c.k
var b []key.Binding
if k.Login.Enabled() {
b = append(b, k.Login)
}
copyField := key.NewBinding(key.WithKeys(k.Copy.Keys()...), key.WithHelp(k.Copy.Help().Key, "copy"))
switch {
case c.focus == paneStatus:
details := key.NewBinding(key.WithKeys(k.Enter.Keys()...), key.WithHelp(k.Enter.Help().Key, "details"))
return append(b, details, k.Update, k.Help, k.Quit)
case c.focus == paneDetail && c.account && c.of == paneStatus:
open := key.NewBinding(key.WithKeys(k.Open.Keys()...), key.WithHelp(k.Open.Help().Key, "open link"))
return append(b, k.Up, k.Down, copyField, open, k.Edit, k.Filter, k.Help, k.Quit)
case c.focus == paneDetail && c.account:
return append(b, k.Up, k.Down, copyField, k.Filter, k.Help, k.Quit)
}
switch c.focus {
case paneSources:
b = append(b, k.Enter, k.Search, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Filter)
case paneContainers:
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Open, k.Filter)
default:
reveal := k.Reveal
if c.revealed != nil {
reveal = key.NewBinding(key.WithKeys(k.Reveal.Keys()...), key.WithHelp(k.Reveal.Help().Key, map[bool]string{false: "show", true: "hide"}[*c.revealed]))
}
b = append(b, reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Filter)
}
if c.commands {
b = append(b, k.Commands)
}
return append(b, k.OpenRef, k.Help, k.Quit)
}
func (c contextHelp) FullHelp() [][]key.Binding { return [][]key.Binding{c.ShortHelp()} }
var paneNames = [paneCount]string{"Sources", "Vaults", "Secrets", "Detail", "Status"}
// showKeybindings opens the ? menu, like lazygit's: every binding with
// what it does, the focused pane's first, filterable by key, description
// and group. Enter runs the selected one.
func (m *Model) showKeybindings() tea.Cmd { return m.showKeybindingsFor("") }
// showKeybindingsFor opens the ? menu, like lazygit's: every binding with
// what it does, filterable by key, description and group; enter runs the
// selected one. Opened from the panes, it lists the focused pane's keys
// ("") first, then the rest; from a popup (keys.GoTo, keys.Menu), only
// the keys that work there, and it comes back to the popup when closed.
func (m *Model) showKeybindingsFor(ctx string) tea.Cmd {
// Where it was opened from, to come back to.
search, prompt := m.search, m.prompt
m.search = nil
actions := m.keys.Actions()
id := func(a keys.Action) string { return a.Context + "." + a.Name }
here := map[string]bool{}
var local []keys.Action
switch ctx {
case "":
for _, b := range (contextHelp{k: m.keys, focus: m.focus, commands: len(m.commandsHere()) > 0}).ShortHelp() {
for _, a := range actions {
if a.Context == keys.Lists && !here[id(a)] && slices.Equal(a.Binding.Keys(), b.Keys()) && a.Desc == b.Help().Desc {
here[id(a)] = true
local = append(local, a)
}
}
}
default:
for _, a := range actions {
if a.Context == ctx {
here[id(a)] = true
local = append(local, a)
}
}
}
var rest []keys.Action
for _, a := range actions {
if !here[id(a)] && a.Binding.Enabled() && !(a.Context == keys.Lists && a.Name == "help") {
rest = append(rest, a)
}
}
var opts []option
section := func(title string, list []keys.Action) {
opts = append(opts, option{label: title, header: true})
for _, a := range list {
h := a.Binding.Help()
if len(a.Binding.Keys()) == 0 {
continue // unbound
}
msg := keyPress(a.Binding.Keys()[0])
// Go to's typing keys need Go to typing: start it first.
typing := ctx == keys.GoTo && (a.Context == keys.Popup || a.Context == keys.Search)
label := m.st.Label.Render(fmt.Sprintf("%-14s", h.Key)) + fmt.Sprintf("%-26s", h.Desc) + m.st.Muted.Render(a.Group)
opts = append(opts, option{label: label, tip: a.Tip, run: func(m *Model) tea.Cmd {
send := func() tea.Msg { return msg }
if typing && m.search != nil && !m.search.typing {
// Typing starts now; its command only blinks the cursor.
return tea.Batch(m.setTyping(true), send)
}
return send
}})
}
}
title := map[string]string{"": paneNames[m.focus] + " pane", keys.GoTo: "Go to", keys.Menu: "This menu"}[ctx]
section(title, local)
if cmds := m.commandsHere(); ctx == "" && len(cmds) > 0 {
opts = append(opts, option{label: "Custom commands", header: true})
for _, c := range cmds {
label := m.st.Label.Render(fmt.Sprintf("%-14s", c.Key)) + fmt.Sprintf("%-26s", cmpOr(c.Description, "command")) + m.st.Muted.Render("custom")
opts = append(opts, option{label: label, tip: c.Command, run: func(m *Model) tea.Cmd { return m.startCommand(c) }})
}
}
switch ctx {
case "":
section("Global", rest)
case keys.GoTo:
// Only what works here: Go to's keys, browsing and typing.
var typing []keys.Action
for _, a := range actions {
if a.Context == keys.Popup || a.Context == keys.Search {
typing = append(typing, a)
}
}
section("While typing, after "+keys.Name(m.keys.GoFilter), typing)
}
cmd := m.showFilterMenu("Keybindings", opts)
m.prompt.footer = footer(move(m.keys.PopupUp, m.keys.PopupDown), hint(m.keys.PopupSelect, "run"), hint(m.keys.PopupClose, "close"))
if ctx != "" {
m.prompt.onClose = func(m *Model) { m.search, m.prompt = search, prompt }
}
return cmd
}
// versionLabel is the version as shown: "v0.1.0", or the build for
// development builds ("dev-c1a4f90").
func (m Model) versionLabel() string {
if m.version == "" || strings.HasPrefix(m.version, "dev") || strings.HasPrefix(m.version, "v") {
return m.version
}
return "v" + m.version
}
// helpBar draws bindings as the help bar. When they don't fit, entries
// before the last keep (like "? help • q quit") give way, marked "…",
// so those always show.
func (m Model) helpBar(bindings []key.Binding, keep int) string {
var bs []key.Binding
for _, b := range bindings {
if b.Enabled() {
bs = append(bs, b)
}
}
h := m.help
h.SetWidth(0) // measure untruncated
limit := max(0, m.w-1)
if v := m.versionLabel(); v != "" {
limit -= ansi.StringWidth(v) + 2 // a gap, the version, the margin
}
if full := h.ShortHelpView(bs); ansi.StringWidth(full) <= limit {
return full
}
keep = min(keep, len(bs))
head, tail := bs[:len(bs)-keep], bs[len(bs)-keep:]
sep := h.Styles.ShortSeparator.Render(h.ShortSeparator)
more := h.Styles.Ellipsis.Render("…")
for n := len(head); n >= 0; n-- {
line := more + sep + h.ShortHelpView(tail)
if n > 0 {
line = h.ShortHelpView(head[:n]) + sep + line
}
if ansi.StringWidth(line) <= limit || n == 0 {
return ansi.Truncate(line, limit, "…")
}
}
return ""
}
// popupKeys are the open popup's keys for the help bar: Go to's for its
// mode and tab, a menu's, or a text field's.
func (m Model) popupKeys() []key.Binding {
km := m.keys
describe := func(b key.Binding, desc string) key.Binding {
b.SetHelp(b.Help().Key, desc)
return b
}
if v := m.search; v != nil {
inSearch := v.tab == tabSearch
if v.typing {
b := []key.Binding{km.PopupDown, km.PopupUp}
if inSearch {
b = append(b, km.SearchSources, km.SearchResults, km.SearchLoad, km.SearchLoadAll)
}
return append(b, km.PopupStar, km.PopupSelect, describe(km.PopupClose, "stop typing"))
}
b := []key.Binding{km.GoDown, km.GoUp, km.GoPrevTab, km.GoNextTab}
if inSearch {
b = append(b, km.GoSources, km.GoResults, km.GoLoad, km.GoLoadAll)
}
return append(b, km.GoFilter, km.GoStar, km.GoSelect, km.GoClose, km.Help)
}
switch p := m.prompt; {
case p.input != nil:
return []key.Binding{describe(km.PopupSelect, "confirm"), describe(km.PopupClose, "cancel")}
case p.filter != nil:
return []key.Binding{km.PopupDown, km.PopupUp, km.PopupSelect, km.PopupClose}
}
return []key.Binding{km.MenuDown, km.MenuUp, km.MenuSelect, km.MenuClose, km.Help}
}
// A keyHint is a key and what it does, for footers and hints, read from
// the bindings so they show what's bound.
type keyHint struct{ key, desc string }
func hint(b key.Binding, desc string) keyHint { return keyHint{keys.Name(b), desc} }
// move is "↑/↓ move" for an up and a down binding.
func move(up, down key.Binding) keyHint {
u, d := keys.Name(up), keys.Name(down)
if u == "" || d == "" {
return keyHint{u + d, "move"}
}
return keyHint{u + "/" + d, "move"}
}
// withKey fills a hint's format with the binding's key ("· %s loads
// everything"), or is empty when the action is unbound.
func withKey(b key.Binding, format string) string {
if k := keys.Name(b); k != "" {
return fmt.Sprintf(format, k)
}
return ""
}
// joinKeys is "ctrl+h/ctrl+l" for two bindings, or whichever is bound.
func joinKeys(a, b key.Binding) string {
x, y := keys.Name(a), keys.Name(b)
if x != "" && y != "" {
return x + "/" + y
}
return x + y
}
// footer joins hints as "↑/↓ move · enter go", leaving out unbound keys.
func footer(hints ...keyHint) string {
var parts []string
for _, h := range hints {
if h.key != "" {
parts = append(parts, h.key+" "+h.desc)
}
}
return strings.Join(parts, " · ")
}
// keyPress builds the key press a binding's key name stands for, so the ?
// menu can run it.
func keyPress(name string) tea.KeyPressMsg {
var mod tea.KeyMod
for {
switch {
case strings.HasPrefix(name, "ctrl+") && len(name) > 5:
mod |= tea.ModCtrl
name = name[5:]
continue
case strings.HasPrefix(name, "shift+") && len(name) > 6:
mod |= tea.ModShift
name = name[6:]
continue
}
break
}
named := map[string]rune{
"space": tea.KeySpace, "enter": tea.KeyEnter, "tab": tea.KeyTab, "esc": tea.KeyEscape,
"up": tea.KeyUp, "down": tea.KeyDown, "left": tea.KeyLeft, "right": tea.KeyRight,
"home": tea.KeyHome, "end": tea.KeyEnd, "backspace": tea.KeyBackspace,
}
if code, ok := named[name]; ok {
k := tea.KeyPressMsg{Code: code, Mod: mod}
if code == tea.KeySpace && mod == 0 {
k.Text = " "
}
return k
}
r := []rune(name)[0]
if mod != 0 {
return tea.KeyPressMsg{Code: r, Mod: mod}
}
return tea.KeyPressMsg{Code: r, Text: name}
}
+198
View File
@@ -0,0 +1,198 @@
package app
import (
"cmp"
"maps"
"slices"
"strings"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// The Favourites and Recent scopes of the Go to popup list what's
// remembered in the store, so they work before anything has loaded.
// storeEntries lists starred items (sources first, then vaults, then
// secrets) and recently used ones (newest first), as Go to entries.
// Entries read as paths: "󰠅 platform-prod / kv-app-prod / api-key · dev",
// the account last, and only when there are several.
func (m *Model) storeEntries() (favs, recent []option) {
type entry struct {
depth int // 0 source, 1 vault, 2 secret: sources list first
sort string
opt option
}
nodes := m.store.Nodes()
keys := slices.Sorted(maps.Keys(nodes))
// entries lists jumps to the item through each of accounts; nil
// means through whichever accounts list a source, else the account
// that last read the item.
entries := func(prefix, key string, n store.Node, accounts []string) []entry {
r, err := ref.Parse(key)
if err != nil {
return nil
}
var out []entry
add := func(path []string, acct string, depth int, run func(*Model) tea.Cmd) {
account := ""
if p, ok := m.byID[acct]; ok {
account = accountOf(provider.Source{Kind: p.Kind(), ProviderLabel: p.Label()})
}
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
out = append(out, entry{depth, sort, option{
label: m.pathLabel(r.Kind, path, account), note: n.Note, mark: " ",
id: prefix + key + "|" + acct, star: key, starAccount: acct, starred: n.StarredFor(acct),
run: func(m *Model) tea.Cmd {
m.used(key)
return run(m)
}}})
}
if r.IsSource() {
for _, p := range m.providers {
if p.Kind() != r.Kind || (accounts != nil && !slices.Contains(accounts, p.ID())) {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if !sourceMatches(r, src) {
continue
}
row := sourceRowKey(p.ID(), src.ID)
add([]string{src.Name}, p.ID(), 0, func(m *Model) tea.Cmd {
m.nav = &nav{sourceRow: row, sourceOnly: true}
return nil
})
}
}
return out
}
if accounts == nil {
accounts = []string{n.Account}
}
depth := 1
if !r.IsContainer() {
depth = 2
}
path := slices.Clone(n.Path)
if len(path) == 0 && !r.IsContainer() {
path = []string{r.ContainerName()} // no context recorded
}
for _, acct := range accounts {
via := n
via.Account = acct
add(append(slices.Clone(path), cmp.Or(n.Name, r.Name, r.ContainerName())), acct, depth,
func(m *Model) tea.Cmd { return m.jumpTo(r, via) })
}
return out
}
var starred []entry
for _, k := range keys {
if n := nodes[k]; len(n.StarredBy) > 0 {
starred = append(starred, entries("fav|", k, n, n.StarredBy)...)
}
}
slices.SortFunc(starred, func(a, b entry) int {
return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort))
})
for _, e := range starred {
favs = append(favs, e.opt)
}
if m.history {
var used []string
for _, k := range keys {
if !nodes[k].LastUsed.IsZero() {
used = append(used, k)
}
}
slices.SortStableFunc(used, func(a, b string) int { return nodes[b].LastUsed.Compare(nodes[a].LastUsed) })
for _, k := range used[:min(len(used), recentMax)] {
for _, e := range entries("recent|", k, nodes[k], nil) {
recent = append(recent, e.opt)
}
}
}
return favs, recent
}
// recentMax is how many recently used items Recent lists.
const recentMax = 20
// pathLabel draws a jump list entry: the provider's icon and the path in
// its colour, then the account.
func (m Model) pathLabel(kind provider.Kind, path []string, account string) string {
style := m.st.Kind(kind)
parts := make([]string, len(path))
for i, p := range path {
parts[i] = style.Render(p)
}
label := style.Render(m.icons.Prefix(kind)) + strings.Join(parts, m.st.Muted.Render(" / "))
if account != "" {
label += m.st.Muted.Render(" · " + account)
}
return label
}
// sourceMatches reports whether src is the subscription or workspace r
// links to.
func sourceMatches(r ref.Ref, src provider.Source) bool {
if r.Kind != provider.KindAzure { // by host: workspaces, repos
return strings.EqualFold(strings.TrimSuffix(src.Detail, "/"), r.Host)
}
return src.ID == r.Subscription
}
// accountOf is the account part of a source's provider label ("alice" in
// "Azure · alice"), or "" when there's a single account.
func accountOf(src provider.Source) string {
account, ok := strings.CutPrefix(src.ProviderLabel, string(src.Kind)+" · ")
if !ok {
return ""
}
return account
}
// jumpTo goes to a remembered vault or secret. With the account that last
// read it signed in, it goes straight there; otherwise it uses the
// accounts that list its subscription, asking if there are several, or
// finds it like a link. Reaching it updates the remembered account.
func (m *Model) jumpTo(r ref.Ref, n store.Node) tea.Cmd {
if n.Source == "" && !r.IsContainer() {
// A secret without context: its vault's record may have it.
parent := r
parent.Type, parent.Name, parent.Version = "", "", ""
if p := m.store.Node(parent.String()); p.Source != "" {
n.Source, n.Account = p.Source, cmp.Or(n.Account, p.Account)
}
}
if _, ok := m.byID[n.Account]; ok && n.Source != "" {
m.startNav(r, actGo, sourceRowKey(n.Account, n.Source))
return nil
}
var choices []choice
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if src.ID == n.Source && n.Source != "" {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
switch len(choices) {
case 0:
return m.openRef(r, actGo)
case 1:
m.startNav(r, actGo, choices[0].sourceRow)
default:
m.chooseSource("Several accounts can see "+r.ContainerName(), r, actGo, choices)
}
return nil
}
+202
View File
@@ -0,0 +1,202 @@
package app
import (
"charm.land/lipgloss/v2"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// Layout: the left column stacks Sources, Vaults and Secrets; the detail
// pane fills the rest. The user can move the divider between them, change
// the left panes' heights, and zoom the focused pane.
type zoom int
const (
zoomNormal zoom = iota
zoomHalf // the focused pane gets most of the screen
zoomFull // only the focused pane
zoomCount
)
const (
// Divider between the left column and the detail pane.
splitStep = 4
minLeftW = 20
minDetailW = 30
defaultMinW = 28
defaultMaxW = 48
// Heights of the left panes.
heightStep = 2
minPaneH = 4 // border, title and two rows
tinyPaneH = 3 // a squeezed pane in half zoom: border and one row
)
func (m Model) defaultLeftWidth() int { return max(defaultMinW, min(defaultMaxW, m.w/3)) }
// leftWidth is the default width moved by the user's divider offset,
// kept within bounds that leave both sides usable.
func (m Model) leftWidth() int {
return max(minLeftW, min(m.w-minDetailW, m.defaultLeftWidth()+m.split))
}
// moveSplit moves the divider by delta columns. The offset is stored as
// what actually applied, so after hitting a limit the other direction
// responds immediately.
func (m *Model) moveSplit(delta int) {
m.split += delta
m.split = m.leftWidth() - m.defaultLeftWidth()
}
func defaultHeights(avail int) [3]int {
src, cont := max(minPaneH, avail/4), max(minPaneH, avail/3)
return [3]int{src, cont, avail - src - cont}
}
// leftHeights applies the user's height changes to the default split.
func (m Model) leftHeights(avail int) [3]int {
h := defaultHeights(avail)
for i := range h {
h[i] += m.heights[i]
}
return fitHeights(h, avail, minPaneH)
}
// fitHeights makes the heights sum to avail with none below lo, taking
// from or giving to the tallest panes.
func fitHeights(h [3]int, avail, lo int) [3]int {
for i := range h {
h[i] = max(lo, h[i])
}
for sum(h) > avail {
i := tallest(h)
if h[i] <= lo {
break // the terminal is too short; the layout check catches it
}
h[i]--
}
for sum(h) < avail {
h[tallest(h)]++
}
return h
}
func sum(h [3]int) int { return h[0] + h[1] + h[2] }
func tallest(h [3]int) int {
i := 0
for j := range h {
if h[j] > h[i] {
i = j
}
}
return i
}
// resizeFocused makes the focused left pane taller (delta > 0) or shorter,
// trading rows with the pane below it, or above it for the bottom pane.
// It reports false if the detail pane is focused.
func (m *Model) resizeFocused(delta int) bool {
if m.focus > paneSecrets {
return false
}
avail := m.listAvail()
h := m.leftHeights(avail)
i := int(m.focus)
j := i + 1
if i == int(paneSecrets) {
j = i - 1
}
delta = max(delta, minPaneH-h[i]) // don't shrink below the minimum
delta = min(delta, h[j]-minPaneH) // or squeeze the neighbour below it
h[i] += delta
h[j] -= delta
base := defaultHeights(avail)
for k := range h {
m.heights[k] = h[k] - base[k]
}
return true
}
// saveLayout remembers the divider and heights for the next run.
func (m *Model) saveLayout() {
if err := m.store.SetLayout(store.Layout{Split: m.split, Heights: m.heights}); err != nil {
m.flash = flash{text: "saving layout: " + err.Error(), isErr: true}
}
}
func (m Model) leftAvail() int { return m.h - lipgloss.Height(m.footerView()) }
// listAvail is the rows the three list panes share, under the status pane.
func (m Model) listAvail() int { return m.leftAvail() - statusH }
func (m *Model) cycleZoom(delta int) {
m.zoom = (m.zoom + zoom(delta) + zoomCount) % zoomCount
}
func (m *Model) layout() {
if m.w < minWidth || m.h < minHeight {
return
}
m.help.SetWidth(max(0, m.w-1)) // one column in, like messages
avail := m.leftAvail()
leftW, h := m.leftWidth(), m.leftHeights(avail-statusH)
switch m.zoom {
case zoomHalf:
if m.focus == paneDetail || m.focus == paneStatus {
leftW = minLeftW
} else {
leftW = max(m.leftWidth(), m.w/2)
h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH}
h[m.focus] = avail - statusH
h = fitHeights(h, avail-statusH, tinyPaneH)
}
case zoomFull:
// Only the focused pane is drawn; it gets the whole screen.
switch m.focus {
case paneDetail, paneStatus:
leftW = 0
default:
leftW = m.w
h = [3]int{}
h[m.focus] = avail
}
}
m.statusW = leftW
m.sources.SetSize(leftW, h[0])
m.containers.SetSize(leftW, h[1])
m.secrets.SetSize(leftW, h[2])
m.detail.SetSize(m.w-leftW, avail)
m.fields.SetSize(m.w-leftW, avail)
}
// active reports whether p is drawn focused: it has focus and the terminal
// does too. Like lazygit, nothing is highlighted while you're in another
// window, so the focus is easy to spot when you come back.
func (m Model) active(p pane) bool { return m.focus == p && !m.termBlurred }
func (m Model) panesView() string {
spin := m.spin.View()
views := [paneStatus]func() string{
func() string { return m.sources.View(m.st, m.active(paneSources), spin) },
func() string { return m.containers.View(m.st, m.active(paneContainers), spin) },
func() string { return m.secrets.View(m.st, m.active(paneSecrets), spin) },
func() string {
if m.fields.Shown() {
return m.fields.View(m.st, m.active(paneDetail), spin)
}
return m.detail.View(m.st, m.active(paneDetail), spin)
},
}
if m.zoom == zoomFull {
if m.focus == paneStatus {
return views[paneDetail]() // its rows
}
return views[m.focus]()
}
left := lipgloss.JoinVertical(lipgloss.Left, m.statusView(), views[0](), views[1](), views[2]())
return lipgloss.JoinHorizontal(lipgloss.Top, left, views[3]())
}
+270
View File
@@ -0,0 +1,270 @@
package app
import (
"path/filepath"
"slices"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// paneHeights reads the left panes' heights back from the screen: each
// starts with a "╭─ [n]" line.
func (d *driver) paneHeights() [3]int {
lines := strings.Split(d.screen(), "\n")
var starts []int
for i, l := range lines {
for n := 1; n <= 3; n++ {
if strings.HasPrefix(l, "╭─ ["+string(rune('0'+n))+"]") {
starts = append(starts, i)
}
}
}
if len(starts) != 3 {
d.t.Fatalf("found %d left panes:\n%s", len(starts), d.screen())
}
bottom := len(lines) - 1 - strings.Count(d.m.footerView(), "\n") // first footer line
return [3]int{starts[1] - starts[0], starts[2] - starts[1], bottom - starts[2]}
}
func TestPaneHeights(t *testing.T) {
t.Parallel()
d := newDriver(t) // 30 rows, one footer line: 29 for panes
start := d.paneHeights()
if sum(start) != 29 {
t.Fatalf("heights %v don't fill the column", start)
}
d.send(tea.KeyPressMsg{Code: tea.KeyDown, Mod: tea.ModCtrl}) // Sources taller, from Vaults
got := d.paneHeights()
if got[0] != start[0]+heightStep || got[1] != start[1]-heightStep || got[2] != start[2] {
t.Errorf("ctrl+↓ on Sources: %v → %v", start, got)
}
d.keys("3", "}") // Secrets is the bottom pane: it takes from Vaults above
got2 := d.paneHeights()
if got2[2] != got[2]+heightStep || got2[1] != got[1]-heightStep {
t.Errorf("} on Secrets: %v → %v", got, got2)
}
for range 20 {
d.keys("{") // shrink Secrets as far as it goes
}
if h := d.paneHeights(); h[2] != minPaneH || sum(h) != 29 {
t.Errorf("minimum height: %v", h)
}
d.keys("4", "}")
if !strings.Contains(d.screen(), "focus a left pane") {
t.Error("no hint when resizing from the detail pane")
}
d.keys("=")
if h := d.paneHeights(); h != start {
t.Errorf("= didn't reset heights: %v", h)
}
}
func TestZoom(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "+") // half: Secrets gets most of the column and half the width
h := d.paneHeights()
if h[0] != tinyPaneH || h[1] != tinyPaneH || h[2] != 29-2*tinyPaneH {
t.Errorf("half zoom heights = %v", h)
}
if got := d.leftEdge(); got != 50 {
t.Errorf("half zoom width = %d, want half of 100", got)
}
d.keys("+") // full: only Secrets
screen := d.screen()
if !strings.Contains(screen, "[3] Secrets") || strings.Contains(screen, "[1] Sources") || strings.Contains(screen, "Secret ─") {
t.Errorf("full zoom should show only the Secrets pane:\n%s", screen)
}
if w := len([]rune(strings.Split(screen, "\n")[0])); w != 100 {
t.Errorf("full zoom width = %d", w)
}
d.keys("4") // the zoom follows focus
if !strings.Contains(d.screen(), "╭─ Secret ") || strings.Contains(d.screen(), "[3] Secrets") {
t.Errorf("full zoom on the detail pane:\n%s", d.screen())
}
d.keys("+") // wraps to normal
if d.m.zoom != zoomNormal {
t.Errorf("zoom = %d after three +", d.m.zoom)
}
d.keys("_")
if d.m.zoom != zoomFull {
t.Errorf("_ from normal should go to full, got %d", d.m.zoom)
}
d.keys("=")
if d.m.zoom != zoomNormal {
t.Error("= didn't reset zoom")
}
}
func TestLayoutIsSaved(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "state.json")
load := func() *store.Store {
s, err := store.Load(path)
if err != nil {
t.Fatal(err)
}
return s
}
d := newDriverOpts(t, Options{Store: load()})
d.keys(">", ">", "}")
edge, heights := d.leftEdge(), d.paneHeights()
d2 := newDriverOpts(t, Options{Store: load()})
if d2.leftEdge() != edge || d2.paneHeights() != heights {
t.Errorf("restored %d %v, want %d %v", d2.leftEdge(), d2.paneHeights(), edge, heights)
}
d2.keys("=")
if l := load().Layout(); l.Split != 0 || l.Heights != [3]int{} {
t.Errorf("reset not saved: %+v", l)
}
}
func TestContextHelpBar(t *testing.T) {
t.Parallel()
d := newDriver(t)
if bar := lastLine(d.screen()); !strings.Contains(bar, "S share") || !strings.HasSuffix(strings.TrimSpace(bar), "? help • q quit") || strings.Contains(bar, "space show") {
t.Errorf("Sources help bar: %q", bar)
}
d.keys("3")
if bar := lastLine(d.screen()); !strings.Contains(bar, "s copy link") || !strings.Contains(bar, "S share") {
t.Errorf("Secrets help bar: %q", bar)
}
}
func TestKeybindingsMenu(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "?")
if d.m.prompt == nil || d.m.prompt.title != "Keybindings" {
t.Fatalf("? didn't open the menu:\n%s", d.screen())
}
// Sections: the focused pane's keys, then global ones; the cursor
// starts on the first key, and a counter shows where it is.
p := d.m.prompt
if !p.options[0].header || p.options[0].label != "Secrets pane" {
t.Errorf("first section: %+v", p.options[0])
}
if sel := ansi.Strip(p.options[p.cursor].label); !strings.HasPrefix(sel, "space") {
t.Errorf("cursor starts on %q", sel)
}
screen := d.screen()
if !strings.Contains(screen, "Global") || !strings.Contains(screen, "1 of ") {
t.Errorf("no Global section or counter:\n%s", screen)
}
d.send(tea.KeyPressMsg{Code: tea.KeyUp}) // can't go up onto the header
if p.options[p.cursor].header {
t.Error("cursor landed on a header")
}
if !strings.Contains(d.screen(), "Show or hide the selected value") {
t.Errorf("no tip for the selected row:\n%s", d.screen())
}
// The filter matches descriptions and groups too; matches stay under
// their section headers, and empty sections drop out.
for _, r := range "copy" {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
var headers []string
for _, o := range d.m.prompt.options {
if o.header {
headers = append(headers, o.label)
}
}
if strings.Join(headers, ",") != "Secrets pane,Global" {
t.Errorf("sections while filtering: %v", headers)
}
if d.m.prompt.options[d.m.prompt.cursor].header {
t.Error("cursor on a header after filtering")
}
var keys []string
for _, o := range d.m.prompt.options {
keys = append(keys, strings.Fields(ansi.Strip(o.label))[0])
}
for _, want := range []string{"y", "Y", "s", "ctrl+y"} {
if !slices.Contains(keys, want) {
t.Errorf("filter copy: %v lacks %s", keys, want)
}
}
// Enter runs the selected binding.
d.keys("esc", "?")
for _, r := range "space" {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
if sel := ansi.Strip(d.m.prompt.options[d.m.prompt.cursor].label); !strings.HasPrefix(sel, "space") {
t.Fatalf("filter space: selected %q", sel)
}
d.keys("enter")
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
t.Errorf("running reveal from the menu didn't reveal:\n%s", d.screen())
}
}
func TestKeyPress(t *testing.T) {
t.Parallel()
for name, want := range map[string]string{
"space": "space", "ctrl+y": "ctrl+y", "shift+tab": "shift+tab", "S": "S", "`": "`", "ctrl+left": "ctrl+left",
} {
if got := keyPress(name).String(); got != want {
t.Errorf("keyPress(%q).String() = %q", name, got)
}
}
}
func lastLine(s string) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
return lines[len(lines)-1]
}
func TestNoHighlightWhileTerminalBlurred(t *testing.T) {
t.Parallel()
d := newDriver(t)
green := "\x1b[32m╭" // a focused border; "current" versions are green too
if !strings.Contains(d.m.View().Content, green) {
t.Fatal("focused pane not highlighted")
}
d.send(tea.BlurMsg{})
if strings.Contains(d.m.View().Content, green) {
t.Error("still highlighted while the terminal is blurred")
}
d.send(tea.FocusMsg{})
if !strings.Contains(d.m.View().Content, green) {
t.Error("highlight not back after focus")
}
// Popups too.
d.keys("?")
d.send(tea.BlurMsg{})
if strings.Contains(d.m.modalView(), "\x1b[32m") || strings.Contains(d.m.modalView(), "\x1b[7m") {
t.Error("popup still highlighted while the terminal is blurred")
}
d.send(tea.FocusMsg{})
if !strings.Contains(d.m.modalView(), "\x1b[32m╭") {
t.Error("popup highlight not back after focus")
}
}
func TestVersionInStatus(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Version: "0.1.0"})
if !strings.Contains(d.screen(), "[0] lazysecrets v0.1.0") {
t.Errorf("no version in the status pane:\n%s", d.screen())
}
if bar := lastLine(d.screen()); strings.Contains(bar, "v0.1.0") || !strings.Contains(bar, "? help • q quit") {
t.Errorf("help line %q", bar)
}
}
+892
View File
@@ -0,0 +1,892 @@
package app
import (
"cmp"
"context"
"errors"
"fmt"
"strings"
"time"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// Opening references: a reference (lazysecrets:// link, Key Vault URL, …)
// is first resolved to a source, then the panes are stepped through to it
// as each list loads. Copy and reveal actions ask before reading the value.
// linksRowID is the Sources row listing vaults opened from links that no
// account could locate (e.g. no read access to the vault resource).
const linksRowID = "links"
type navStage int
const (
navSource navStage = iota
navContainer
navObject
navVersion
navAction
)
// nav is an open in progress.
type nav struct {
ref ref.Ref
act linkAction
// sourceOnly stops at the source (jumping to a starred subscription).
sourceOnly bool
sourceRow string // Sources row to select
stage navStage
// quiet gives up without a word: going back to where lazysecrets
// was left, which may be gone.
quiet bool
}
type resolvedMsg struct {
ref ref.Ref
act linkAction
sourceRow string
others []string // other accounts' rows that also found the vault
choices []choice // several candidates: ask
linked *provider.Container
err error
}
type choice struct {
label string
sourceRow string
}
// linkAction is what to do with an object once it's opened. Links don't
// carry it: whoever opens one chooses.
type linkAction int
const (
actGo linkAction = iota // just go to it
actCopy // and copy its value
actShow // and show its value
)
// offerRef asks what to do with a reference: for an object, go to it, or
// go to it and copy or show its value; for a subscription, vault or scope,
// just open it.
// With ask false, vault and scope links open straight away.
func (m *Model) offerRef(title string, r ref.Ref, ask bool) tea.Cmd {
if r.IsContainer() || r.IsSource() {
if !ask {
return m.openRef(r, actGo)
}
m.showMenu(title, nil, []option{
{key: "o", label: "Open it", run: func(m *Model) tea.Cmd { return m.openRef(r, actGo) }},
})
m.prompt.about = &r
return nil
}
do := func(a linkAction) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.openRef(r, a) }
}
opts := []option{
{key: "o", label: "Go to it", run: do(actGo)},
{key: "c", label: "Go to it and copy its value", run: do(actCopy)},
{key: "r", label: "Go to it and show its value", run: do(actShow)},
}
if r.Kind == provider.KindAzure || r.Host != "" { // {{secrets/s/k}} names no workspace
opts = append(opts, option{key: "y", label: "Copy its value, staying here", run: func(m *Model) tea.Cmd { return m.copyFromRef(r) }})
}
m.showMenu(title, nil, opts)
m.prompt.about = &r
return nil
}
// copyFromRef copies the value a link points at without going there. The
// link says enough to read it: the vault and name (Azure, tried with each
// account, the one that last read it first) or the workspace, scope and
// key (Databricks).
func (m *Model) copyFromRef(r ref.Ref) tea.Cmd {
type try struct {
p provider.Provider
o provider.Object
}
var tries []try
key := r
key.Version = ""
node := m.store.Node(key.String())
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
o := provider.Object{Name: r.Name, Type: cmp.Or(r.Type, provider.TypeSecret), Version: r.Version}
src := provider.Source{Kind: r.Kind, Provider: p.ID(), ID: node.Source}
if len(node.Path) > 0 {
src.Name = node.Path[0]
}
if r.Kind != provider.KindAzure { // by host: Databricks workspaces, repos
o.Type = provider.TypeSecret
src.ID = ""
for _, s := range m.sourceResults[p.ID()].sources {
if sourceMatches(r, s) {
src = s
}
}
if src.ID == "" {
continue
}
o.Container = provider.Container{Source: src, Name: r.Scope}
} else {
o.Container = provider.Container{Source: src, Name: r.ContainerName(), Detail: r.VaultURL()}
}
t := try{p, o}
if p.ID() == node.Account {
tries = append([]try{t}, tries...)
} else {
tries = append(tries, t)
}
}
if len(tries) == 0 {
m.flash = flash{text: "copy: no account or profile for " + describeRef(r), isErr: true}
return nil
}
ctx, req := m.begin(slotCopy)
m.flash = flash{text: "copying " + r.Name + "…"}
return func() tea.Msg {
var errs []error
for _, t := range tries {
v, err := t.p.Value(ctx, t.o)
if err == nil {
msg := copyMsg{req: req, name: r.Name, value: v}
if t.o.Container.Source.ID != "" {
msg.obj = t.o // where it is is known: count the use
}
return msg
}
if ctx.Err() != nil {
return copyMsg{req: req, name: r.Name, err: err}
}
errs = append(errs, fmt.Errorf("%s: %w", t.p.Label(), err))
}
if len(errs) == 1 {
return copyMsg{req: req, name: r.Name, err: errors.Unwrap(errs[0])} // one account: no need to name it
}
return copyMsg{req: req, name: r.Name, err: errors.Join(errs...)}
}
}
// openRef starts opening a reference, doing act once there.
func (m *Model) openRef(r ref.Ref, act linkAction) tea.Cmd {
m.nav = nil
if r.IsSource() {
m.openSource(r)
return nil
}
m.flash = flash{text: "opening " + describeRef(r) + "…"}
cands := m.providers
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), defaultResolveTimeout)
defer cancel()
msg := resolve(ctx, cands, r)
msg.act = act
return msg
}
}
const defaultResolveTimeout = 30 * time.Second
// openSource goes to a subscription or workspace, asking which account
// when several list it. Sources are loaded already, so it needs no lookup.
func (m *Model) openSource(r ref.Ref) {
var choices []choice
for _, p := range m.providers {
if p.Kind() != r.Kind {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
if sourceMatches(r, src) {
row := sourceRowKey(p.ID(), src.ID)
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
}
}
switch len(choices) {
case 0:
m.flash = flash{text: "open: " + describeRef(r) + " isn't in your sources", isErr: true}
case 1:
m.nav = &nav{ref: r, sourceRow: choices[0].sourceRow, sourceOnly: true}
default:
var opts []option
for i, c := range choices {
row := c.sourceRow
opts = append(opts, option{key: fmt.Sprint(i + 1), label: c.label, run: func(m *Model) tea.Cmd {
m.nav = &nav{ref: r, sourceRow: row, sourceOnly: true}
return nil
}})
}
m.showMenu("Several accounts can see "+describeRef(r), nil, opts)
}
}
// refLabel draws what a link points at like the favourites list: the
// provider's icon and the path in its colour, "󰠅 platform-prod /
// kv-app-prod / api-key", with the type and version after it. The
// subscription or workspace isn't in the link: it comes from what's
// remembered about the item, or from lists already loaded.
func (m Model) refLabel(r ref.Ref) string {
var path []string
if src := m.refSource(r); src != "" {
path = append(path, src)
}
switch {
case r.IsSource():
if len(path) == 0 {
path = append(path, cmp.Or(r.Subscription, r.Host))
}
case r.IsContainer():
path = append(path, r.ContainerName())
default:
path = append(path, r.ContainerName(), r.Name)
}
label := m.pathLabel(r.Kind, path, "")
var extra []string
if t := strings.ToLower(pluralLabels[r.Type]); t != "" && r.Type != provider.TypeSecret {
if r.IsContainer() {
extra = append(extra, t+" tab")
} else {
extra = append(extra, strings.TrimSuffix(t, "s")) // "key", "certificate"
}
}
if r.Version != "" {
extra = append(extra, "version "+r.Version)
}
if len(extra) > 0 {
label += m.st.Muted.Render(" · " + strings.Join(extra, " · "))
}
return label
}
// refSource names the subscription or workspace r is in, if known.
func (m Model) refSource(r ref.Ref) string {
key := r
key.Version = ""
if n := m.store.Node(key.String()); len(n.Path) > 0 {
return n.Path[0]
}
for _, res := range m.sourceResults {
for _, src := range res.sources {
if (r.Kind != provider.KindAzure || r.IsSource()) && src.Kind == r.Kind && sourceMatches(r, src) {
return src.Name
}
}
}
if r.Kind == provider.KindAzure && !r.IsSource() {
for _, e := range m.cache.containers {
for _, c := range e.value {
if matchesContainer(c, r) {
return c.Source.Name
}
}
}
}
return ""
}
func describeRef(r ref.Ref) string {
if r.IsSource() {
switch r.Kind {
case provider.KindDatabricks:
return "workspace " + r.Host
case provider.KindRepo:
return "repo " + r.Host
}
return "subscription " + r.Subscription
}
if r.IsContainer() {
return r.ContainerName()
}
return r.Name + " in " + r.ContainerName()
}
// resolve finds the source holding the reference's container.
func resolve(ctx context.Context, providers []provider.Provider, r ref.Ref) resolvedMsg {
msg := resolvedMsg{ref: r}
var errs []error
switch {
case r.Kind == provider.KindDatabricks && r.Host == "":
// {{secrets/scope/key}}: search every workspace for the scope.
for _, p := range providers {
if p.Kind() != provider.KindDatabricks {
continue
}
srcs, err := p.Sources(ctx)
if err != nil {
errs = append(errs, err)
continue
}
for _, src := range srcs {
cs, err := p.Containers(ctx, src)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", src.Name, err))
continue
}
for _, c := range cs {
if c.Name == r.Scope {
msg.choices = append(msg.choices, choice{label: src.Name, sourceRow: sourceRowKey(p.ID(), src.ID)})
}
}
}
}
switch len(msg.choices) {
case 0:
msg.err = errors.Join(append([]error{fmt.Errorf("scope %q isn't in any workspace", r.Scope)}, errs...)...)
case 1:
msg.sourceRow, msg.choices = msg.choices[0].sourceRow, nil
}
return msg
}
for _, p := range providers {
loc, ok := p.(provider.Locator)
if !ok || p.Kind() != r.Kind {
continue
}
id, found, err := loc.Locate(ctx, r.Host)
if err != nil {
errs = append(errs, fmt.Errorf("%s: %w", p.Label(), err))
continue
}
if found {
// Several accounts may see it, with different access: the
// user picks.
row := sourceRowKey(p.ID(), id)
if msg.sourceRow == "" {
msg.sourceRow = row
} else {
msg.others = append(msg.others, row)
}
}
}
if msg.sourceRow != "" {
return msg
}
if r.Kind == provider.KindAzure {
// No account can find the vault resource, but one may still read
// it: open it under "Opened links", where each account is tried.
msg.linked = &provider.Container{
Source: provider.Source{Kind: provider.KindAzure, Name: r.Host},
ID: r.VaultURL(), Name: r.ContainerName(), Detail: r.VaultURL(),
Types: []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate},
}
return msg
}
missing := fmt.Errorf("no Databricks profile for workspace %s", r.Host)
if r.Kind == provider.KindRepo {
missing = fmt.Errorf("repo %s isn't here: start lazysecrets in it, or list it under providers.repos.paths", r.Host)
}
msg.err = errors.Join(append([]error{missing}, errs...)...)
return msg
}
func (m *Model) handleResolved(msg resolvedMsg) {
switch {
case msg.err != nil:
m.flash = flash{text: "open: " + msg.err.Error(), isErr: true}
case len(msg.choices) > 1:
for i := range msg.choices {
msg.choices[i].label = "Open in " + msg.choices[i].label
}
m.chooseSource("Scope "+msg.ref.Scope+" is in several workspaces", msg.ref, msg.act, msg.choices)
case len(msg.others) > 0:
var choices []choice
for _, row := range append([]string{msg.sourceRow}, msg.others...) {
choices = append(choices, choice{label: "Open with " + m.sourceLabel(row), sourceRow: row})
}
m.chooseSource("Several accounts can see "+msg.ref.ContainerName(), msg.ref, msg.act, choices)
case msg.linked != nil:
m.addLinked(*msg.linked)
m.startNav(msg.ref, msg.act, linksRowID)
default:
m.startNav(msg.ref, msg.act, msg.sourceRow)
}
}
// chooseSource asks where to open r.
func (m *Model) chooseSource(title string, r ref.Ref, act linkAction, choices []choice) {
var opts []option
for i, c := range choices {
key := ""
if i < 9 {
key = fmt.Sprint(i + 1)
}
row := c.sourceRow
opts = append(opts, option{key: key, label: c.label, run: func(m *Model) tea.Cmd {
m.startNav(r, act, row)
return nil
}})
}
m.showMenu(title, nil, opts)
}
// sourceLabel names a Sources row as "Azure · admin / app-dev".
func (m Model) sourceLabel(row string) string {
providerID, sourceID := splitSourceRow(row)
label := providerID
if p, ok := m.byID[providerID]; ok {
label = p.Label()
}
for _, s := range m.sourceResults[providerID].sources {
if s.ID == sourceID {
return label + " / " + s.Name
}
}
return label
}
func (m *Model) startNav(r ref.Ref, act linkAction, sourceRow string) {
m.nav = &nav{ref: r, act: act, sourceRow: sourceRow}
m.versionsOf = nil
m.flash = flash{}
}
// addLinked lists a vault under "Opened links" for this session.
func (m *Model) addLinked(c provider.Container) {
for _, l := range m.linked {
if l.ID == c.ID {
return
}
}
m.linked = append(m.linked, c)
m.rebuildSources()
}
func (m *Model) navFail(format string, a ...any) {
if !m.nav.quiet {
m.flash = flash{text: "open: " + fmt.Sprintf(format, a...), isErr: true}
}
m.nav = nil
}
// advanceNav moves an open along as far as the loaded lists allow. It
// reports whether it got further, so the caller can sync and try again.
func (m *Model) advanceNav() bool {
n := m.nav
if n == nil {
return false
}
r := n.ref
switch n.stage {
case navSource:
if !m.sources.Select(n.sourceRow) {
group, _ := splitSourceRow(n.sourceRow)
if m.collapsed[group] {
m.collapsed[group] = false
m.rebuildSources()
return true
}
if m.sourcesPending > 0 {
return false // still loading
}
m.navFail("%s isn't in the Sources list (hidden in the config?)", describeRef(r))
return false
}
m.sourcesTouched = true
m.focus = paneContainers
if n.sourceOnly {
m.nav = nil
return true
}
n.stage = navContainer
return true
case navContainer:
if m.selSource != n.sourceRow || m.containers.Loading {
return false
}
if !m.containers.SelectWhere(func(c provider.Container) bool { return matchesContainer(c, r) }) {
m.navFail("%s not found", r.ContainerName())
return false
}
if r.Type != "" {
m.wantType = r.Type
}
if !r.IsContainer() {
m.focus = paneSecrets
}
n.stage = navObject
return true
case navObject:
c, _ := m.containers.Selected()
if (r.Type != "" && m.objType != r.Type) || m.secrets.Loading ||
!strings.HasPrefix(m.selContainer, containerKey(c)+"|") {
return false
}
if m.secrets.Err != nil {
m.nav = nil // the pane shows the error
return false
}
// Reached it, and its account can read it: remember that.
m.refreshContext(storeKey(c), c.Name, []string{c.Source.Name}, c.Source)
if r.IsContainer() {
m.nav = nil
return true
}
if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Name == r.Name }) {
m.navFail("%s not found in %s", r.Name, r.ContainerName())
return false
}
if o, ok := m.secrets.Selected(); ok {
m.refreshContext(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source)
}
if r.Version != "" {
o, _ := m.secrets.Selected()
m.versionsOf = &o
n.stage = navVersion
return true
}
n.stage = navAction
return true
case navVersion:
if m.versionsOf == nil || m.secrets.Loading || m.secrets.Err != nil {
if m.secrets.Err != nil {
m.nav = nil
}
return false
}
if !m.secrets.SelectWhere(func(o provider.Object) bool { return o.Version == r.Version }) {
m.flash = flash{text: "version " + r.Version + " not found; showing all versions", isErr: true}
}
n.stage = navAction
return true
case navAction:
// The user chose this when opening the link; no need to ask again.
m.nav = nil
switch n.act {
case actCopy:
m.pendingCmd = m.copyValue()
case actShow:
if m.detail.State() != panes.ValueShown {
m.pendingCmd = m.toggleReveal()
}
}
return true
}
return false
}
// matchesContainer compares by vault URL (Azure), or scope or file name.
func matchesContainer(c provider.Container, r ref.Ref) bool {
if r.Kind != provider.KindAzure {
return c.Name == r.Scope
}
return strings.EqualFold(strings.TrimSuffix(c.Detail, "/")+"/", r.VaultURL())
}
// share copies a link to the focused item. With menu, it offers the
// other things worth sharing about it.
func (m *Model) share(menu bool) tea.Cmd {
switch m.focus {
case paneSources:
s, ok := m.selectedSource()
if !ok {
m.flash = warning("select a subscription, workspace, vault or secret to share")
return nil
}
if !menu {
return m.shareSource(s, "o")
}
share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareSource(s, k) }
}
opts := []option{{key: "o", label: "Lazysecrets link", run: share("o")}}
if s.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(s.Kind), run: share("b")})
}
m.showMenu("Share "+s.Name, nil, opts)
case paneContainers:
c, ok := m.containers.Selected()
if !ok {
return nil
}
if !menu {
return m.shareContainer(c, "o")
}
share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareContainer(c, k) }
}
opts := []option{option{key: "o", label: "Lazysecrets link", run: share("o")}}
if c.Source.Kind == provider.KindAzure && m.objType != provider.TypeSecret {
opts = append(opts, option{key: "t", label: "Lazysecrets link to the " + tabLabels[m.objType] + " tab", run: share("t")})
}
if c.Source.Kind == provider.KindAzure {
opts = append(opts, option{key: "u", label: "Vault URL", run: share("u")})
}
if c.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(c.Source.Kind), run: share("b")})
}
m.showMenu("Share "+c.Name, nil, opts)
default:
o, ok := m.detail.Secret()
if !ok {
return nil
}
if !menu {
return m.shareObject(o, "o")
}
share := func(k string) func(*Model) tea.Cmd {
return func(m *Model) tea.Cmd { return m.shareObject(o, k) }
}
opts := []option{
option{key: "o", label: "Lazysecrets link", run: share("o")},
}
if o.Container.Source.Kind == provider.KindAzure {
opts = append(opts, option{key: "u", label: "Key Vault URL", run: share("u")})
}
if o.Reference != "" {
opts = append(opts, option{key: "y", label: "Config reference", run: share("y")})
}
if o.URL != "" {
opts = append(opts, option{key: "b", label: webLabel(o.Container.Source.Kind), run: share("b")})
}
m.showMenu("Share "+o.Name, nil, opts)
}
return nil
}
// webLabel names the web page o (open in browser) opens.
func webLabel(kind provider.Kind) string {
switch kind {
case provider.KindDatabricks:
return "Web page (Databricks workspace)"
case provider.KindRepo:
return "Web page (repository)"
}
return "Web page (Azure portal)"
}
// shareSource copies what the share menu's key k stands for, for a
// subscription or workspace.
func (m *Model) shareSource(s provider.Source, k string) tea.Cmd {
m.usedSource(s)
if k == "b" {
if s.URL == "" {
return nil
}
return m.copyText(s.Name+" web page", s.URL, false)
}
r, err := ref.ForSource(s)
if err != nil {
m.flash = flash{text: "share: " + err.Error(), isErr: true}
return nil
}
return m.copyText("link to "+s.Name, r.String(), false)
}
// shareContainer copies what the share menu's key k stands for, for a
// vault or scope.
func (m *Model) shareContainer(c provider.Container, k string) tea.Cmd {
m.usedContainer(c)
tab := provider.TypeSecret
switch k {
case "b":
if c.URL == "" {
return nil
}
return m.copyText(c.Name+" web page", c.URL, false)
case "t":
tab = m.objType
case "o", "u":
default:
return nil
}
r, err := ref.ForContainer(c, tab)
if err != nil {
m.flash = flash{text: "share: " + err.Error(), isErr: true}
return nil
}
if k == "u" {
if u := r.IdentifierURL(); u != "" {
return m.copyText(c.Name+" URL", u, false)
}
return nil
}
return m.copyText("link to "+c.Name, r.String(), false)
}
// shareObject copies what the share menu's key k stands for.
func (m *Model) shareObject(o provider.Object, k string) tea.Cmd {
m.usedObject(o)
if k == "b" {
if o.URL == "" {
return nil
}
return m.copyText(o.Name+" web page", o.URL, false)
}
if k == "y" {
if o.Reference == "" {
return nil
}
return m.copyText(o.Name+" reference", o.Reference, false)
}
version := ""
if m.versionsOf != nil {
version = o.Version // shared from the versions view: that version
}
r, err := ref.ForObject(o, version)
if err != nil {
m.flash = flash{text: "share: " + err.Error(), isErr: true}
return nil
}
switch k {
case "u":
if u := r.IdentifierURL(); u != "" {
return m.copyText(o.Name+" URL", u, false)
}
return nil
}
return m.copyText("link to "+o.Name, r.String(), false)
}
func (m *Model) startOpenPrompt() tea.Cmd {
in := textinput.New()
in.Prompt = "open: "
in.Placeholder = "lazysecrets:// link, Key Vault URL or reference"
in.SetWidth(max(20, min(m.w-12, 64)))
m.prompt = &modal{
title: "Open a link", input: &in,
lines: []string{m.st.Muted.Render("lazysecrets:// link, Key Vault URL or reference")},
submit: func(m *Model, text string) tea.Cmd { return m.openText(text) },
}
// Pre-fill from the clipboard, read now: it may hold any reference,
// including ones not offered in the footer (our own, dismissed ones).
clip := m.clip
readClip := func() tea.Msg {
text, err := clip.Read()
if err != nil {
return nil
}
text = strings.TrimSpace(text)
if _, err := ref.Parse(text); err != nil || strings.ContainsAny(text, "\n") {
return nil // not a reference: don't keep it
}
return openPromptFillMsg(text)
}
return tea.Batch(m.prompt.input.Focus(), readClip)
}
type openPromptFillMsg string
// fillOpenPrompt puts a reference from the clipboard in the open prompt,
// unless the user has started typing.
func (m *Model) fillOpenPrompt(text string) {
if p := m.prompt; p != nil && p.input != nil && p.input.Value() == "" {
p.input.SetValue(text)
p.input.CursorEnd()
}
}
// openText parses a pasted or typed reference and asks what to do with it.
func (m *Model) openText(s string) tea.Cmd {
r, err := ref.Parse(s)
if err != nil {
m.flash = flash{text: "open: " + err.Error(), isErr: true}
return nil
}
return m.offerRef("Open link", r, false)
}
// handlePaste opens a pasted reference, unless a text input has focus.
func (m *Model) handlePaste(msg tea.PasteMsg) tea.Cmd {
if p := m.prompt; p != nil && p.input != nil {
in, cmd := p.input.Update(msg)
p.input = &in
return cmd
}
if l := m.focusedFilter(); l != nil && l.Filtering() {
return l.UpdateFilter(msg)
}
if m.prompt != nil {
return nil
}
return m.openText(msg.Content)
}
type clipboardTextMsg string
// clipCheckEvery throttles the clipboard check on key presses, which
// covers terminals that don't report focus.
const clipCheckEvery = 3 * time.Second
// maybeCheckClipboard checks the clipboard if it hasn't been for a while.
func (m *Model) maybeCheckClipboard() tea.Cmd {
if now := m.now(); now.Sub(m.clipChecked) >= clipCheckEvery {
m.clipChecked = now
return m.checkClipboard()
}
return nil
}
// checkClipboard reads the clipboard in the background to look for a
// lazysecrets:// link. Only such links are kept; anything else (it may well
// be a password) is dropped as soon as it has been looked at.
func (m Model) checkClipboard() tea.Cmd {
clip := m.clip
return func() tea.Msg {
text, err := clip.Read()
if err != nil {
return nil
}
text = strings.TrimSpace(text)
if !isLink(text) {
return clipboardTextMsg("")
}
return clipboardTextMsg(text)
}
}
// isLink reports whether s looks like a lazysecrets:// link, valid or not.
func isLink(s string) bool {
return strings.HasPrefix(strings.ToLower(s), ref.Scheme+"://")
}
// offerClipboardLink pops up a link found on the clipboard, once: whatever
// the user picks, that link isn't offered again. It isn't offered while
// another popup is open, or if it points at what's selected already (e.g.
// just shared with s). A lazysecrets:// link that doesn't parse is
// reported, so a typo doesn't fail silently.
func (m *Model) offerClipboardLink(text string) {
if text == "" || text == m.dismissedLink || m.prompt != nil || m.search != nil {
return
}
r, err := ref.Parse(text)
if err != nil {
m.dismissedLink = text
m.showMenu("Link on clipboard can't be read", []string{m.st.Error.Render(err.Error())}, nil)
return
}
if m.isSelected(r) {
return
}
m.dismissedLink = text
m.offerRef("Link on clipboard", r, true)
}
// isSelected reports whether r points at the selected container, or the
// selected object in it.
func (m Model) isSelected(r ref.Ref) bool {
c, ok := m.containers.Selected()
if !ok || c.Source.Kind != r.Kind || !matchesContainer(c, r) {
return false
}
if r.IsContainer() {
return true
}
o, ok := m.detail.Secret()
return ok && o.Name == r.Name && (r.Type == "" || o.Type == r.Type) &&
(r.Version == "" || o.Version == r.Version)
}
+600
View File
@@ -0,0 +1,600 @@
package app
import (
"context"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
const kv = "kv-app-prod.vault.azure.net"
// open pastes a reference and, for an object, chooses to go to it.
func (d *driver) open(link string) {
d.t.Helper()
d.openWith(link, "o")
}
// openWith pastes a reference and answers the "Open link" menu with k.
func (d *driver) openWith(link, k string) {
d.t.Helper()
d.send(tea.PasteMsg{Content: link})
if d.m.prompt != nil && d.m.prompt.title == "Open link" {
d.keys(k)
}
}
func TestOpenSecretLink(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("1", "j") // start elsewhere: platform-dev
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
t.Fatalf("opened %s/%s/%s", src, c, s)
}
if d.m.focus != paneSecrets || d.m.detail.State() != panes.ValueHidden {
t.Errorf("focus %d, value state %d", d.m.focus, d.m.detail.State())
}
}
func TestOpenAcceptsOtherFormats(t *testing.T) {
t.Parallel()
for _, in := range []string{
"https://" + kv + "/secrets/api-key",
"@Microsoft.KeyVault(SecretUri=https://" + kv + "/secrets/api-key)",
"@Microsoft.KeyVault(VaultName=kv-app-prod;SecretName=api-key)",
} {
d := newDriver(t)
d.open(in)
if _, _, s := d.selected(); s != "api-key" {
t.Errorf("%s: selected %q", in, s)
}
}
}
func TestOpenVaultLinkOnTab(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("1", "j")
d.open("lazysecrets://azure/" + kv + "/certificates")
if _, c, s := d.selected(); c != "kv-app-prod" || d.m.objType != provider.TypeCertificate || s != "app-tls" {
t.Errorf("vault link: %q tab %s selected %q", c, d.m.objType, s)
}
if d.m.focus != paneContainers {
t.Errorf("focus = %d, want the vaults pane", d.m.focus)
}
}
func TestOpenVersionLink(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.open("lazysecrets://azure/" + kv + "/secrets/db-password/91d3e0")
if d.m.versionsOf == nil {
t.Fatal("versions view not opened")
}
if o, _ := d.m.secrets.Selected(); o.Version != "91d3e0" {
t.Errorf("selected version %q", o.Version)
}
}
func TestOpeningAsksWhatToDo(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.send(tea.PasteMsg{Content: "lazysecrets://azure/" + kv + "/secrets/db-password"})
screen := d.screen()
for _, want := range []string{"Open link", "platform-prod / kv-app-prod / db-password", "o Go to it",
"c Go to it and copy its value", "r Go to it and show its value", "x Dismiss"} {
if !strings.Contains(screen, want) {
t.Fatalf("missing %q:\n%s", want, screen)
}
}
if d.clip().content != "" {
t.Fatal("did something before being asked")
}
d.keys("c")
if d.clip().content != "correct-horse-battery-staple" || strings.Contains(d.screen(), "correct-horse") {
t.Errorf("copy: clipboard %q, revealed %v", d.clip().content, strings.Contains(d.screen(), "correct-horse"))
}
d2 := newDriver(t)
d2.keys("1", "j")
d2.openWith("lazysecrets://azure/"+kv+"/secrets/api-key", "r")
if _, _, s := d2.selected(); s != "api-key" || !strings.Contains(d2.screen(), "sk_live_51HxDEMO") {
t.Errorf("show: selected %q\n%s", s, d2.screen())
}
// Old links carrying ?action= still open, and still ask.
d3 := newDriver(t)
d3.send(tea.PasteMsg{Content: "lazysecrets://azure/" + kv + "/secrets/db-password?action=copy"})
if d3.m.prompt == nil || d3.clip().content != "" {
t.Error("an old ?action=copy link acted without asking")
}
}
func TestOpenDatabricksLinks(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.open("lazysecrets://databricks/adb-2222222222222222.2.azuredatabricks.net/app/storage-key")
if src, c, s := d.selected(); src != "dev" || c != "app" || s != "storage-key" {
t.Errorf("databricks link: %s/%s/%s", src, c, s)
}
// A bare {{secrets/…}} reference: the app scope is in both workspaces.
d.open("{{secrets/app/storage-key}}")
screen := d.screen()
if d.m.prompt == nil || !strings.Contains(screen, "1 Open in prod") || !strings.Contains(screen, "2 Open in dev") ||
!strings.Contains(screen, "x Dismiss") {
t.Fatalf("no choice:\n%s", screen)
}
d.keys("1")
if src, _, s := d.selected(); src != "prod" || s != "storage-key" {
t.Errorf("after choosing prod: %s/%s", src, s)
}
}
// unlocatable hides Locate, like an account that can read a vault's
// secrets but not see the vault resource.
type unlocatable struct{ provider.Provider }
func TestOpenUnlocatableVaultUsesOpenedLinks(t *testing.T) {
t.Parallel()
d := newDriver(t, unlocatable{fake.Azure(0)}, fake.Databricks(0))
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
if !strings.Contains(d.screen(), "↗ Opened links (1)") {
t.Fatalf("no Opened links row:\n%s", d.screen())
}
if _, c, s := d.selected(); c != "kv-app-prod" || s != "api-key" {
t.Errorf("opened %s/%s", c, s)
}
if c, _ := d.m.containers.Selected(); c.Source.Provider == "" {
t.Error("the account that could read it wasn't recorded")
}
}
func TestOpenErrors(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.open("hello")
if !strings.Contains(d.screen(), "open: not a lazysecrets link") {
t.Errorf("bad paste:\n%s", d.screen())
}
d.open("lazysecrets://azure/" + kv + "/secrets/nope")
if !strings.Contains(d.screen(), "open: nope not found in kv-app-prod") {
t.Errorf("missing secret:\n%s", d.screen())
}
d.open("lazysecrets://databricks/adb-9.9.azuredatabricks.net/app")
if !strings.Contains(d.screen(), "no Databricks profile for workspace adb-9.9") {
t.Errorf("unknown workspace:\n%s", d.screen())
}
}
func TestPasteWhileFilteringFilters(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "/")
d.send(tea.PasteMsg{Content: "api"})
if d.m.secrets.Len() != 1 || d.m.nav != nil {
t.Errorf("paste in filter: visible %d", d.m.secrets.Len())
}
}
func TestOpenPromptAndStartupOpen(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
d.send(tea.PasteMsg{Content: "lazysecrets://azure/" + kv + "/secrets/api-key"})
d.keys("enter", "o")
if _, _, s := d.selected(); s != "api-key" {
t.Errorf("ctrl+o: selected %q", s)
}
r, _ := ref.Parse("lazysecrets://azure/" + kv + "/secrets/api-key")
d2 := newDriverOpts(t, Options{Open: &r})
if d2.m.prompt == nil || d2.m.prompt.title != "Open link" {
t.Fatal("open at startup didn't ask what to do")
}
d2.keys("o")
if _, _, s := d2.selected(); s != "api-key" {
t.Errorf("open at startup: selected %q", s)
}
}
func TestShareLinks(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "s") // s: the plain link, straight away
if want := "lazysecrets://azure/" + kv + "/secrets/db-password"; d.clip().content != want {
t.Errorf("s = %q", d.clip().content)
}
if strings.Contains(d.screen(), "copies its value") {
t.Error("share menu still offers action links")
}
for k, want := range map[string]string{
"o": "lazysecrets://azure/" + kv + "/secrets/db-password",
"u": "https://" + kv + "/secrets/db-password",
"y": "@Microsoft.KeyVault(SecretUri=https://" + kv + "/secrets/db-password)",
} {
d.keys("S", k)
if d.clip().content != want {
t.Errorf("S %s = %q, want %q", k, d.clip().content, want)
}
}
d.keys("2", "S", "u") // vault menu: plain vault URL
if want := "https://" + kv + "/"; d.clip().content != want {
t.Errorf("vault URL = %q", d.clip().content)
}
d.keys("]", "S", "t") // link that opens on the Keys tab
if want := "lazysecrets://azure/" + kv + "/keys"; d.clip().content != want {
t.Errorf("tab link = %q", d.clip().content)
}
d.keys("[", "S", "b")
if !strings.HasPrefix(d.clip().content, "https://portal.azure.com/") {
t.Errorf("portal link = %q", d.clip().content)
}
d.keys("2", "s")
if want := "lazysecrets://azure/" + kv; d.clip().content != want {
t.Errorf("vault link = %q", d.clip().content)
}
d.keys("3", "v", "j", "s") // from the versions view: that version
if want := "lazysecrets://azure/" + kv + "/secrets/db-password/91d3e0"; d.clip().content != want {
t.Errorf("version link = %q", d.clip().content)
}
d.keys("esc", "1", "j", "j", "j", "3", "S")
if strings.Contains(d.screen(), "Key Vault URL") {
t.Error("Databricks offered a Key Vault URL")
}
d.keys("o")
if want := "lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key"; d.clip().content != want {
t.Errorf("databricks link = %q", d.clip().content)
}
// Every shared link opens what was shared.
d2 := newDriver(t)
d2.open(d.clip().content)
if src, c, s := d2.selected(); src != "prod" || c != "app" || s != "storage-key" {
t.Errorf("round trip: %s/%s/%s", src, c, s)
}
}
// blind sees vaults (Locate) but may not read them, like an account with
// Reader on the subscription but no Key Vault data-plane role.
type blind struct{ *fake.Provider }
func (blind) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) {
return nil, fake.ErrForbidden
}
func TestOpenAsksWhichAccount(t *testing.T) {
t.Parallel()
// IDs like the real ones: they contain a "/".
alice := blind{fake.Azure(0).As("Azure:alice@example.com/t1", "Azure · alice")}
bob := fake.Azure(0).As("Azure:bob@example.com/t1", "Azure · bob")
d := newDriver(t, alice, bob)
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
screen := d.screen()
for _, want := range []string{"Several accounts can see kv-app-prod",
"1 Open with Azure · alice / platform-prod", "2 Open with Azure · bob / platform-prod", "x Dismiss"} {
if !strings.Contains(screen, want) {
t.Fatalf("missing %q:\n%s", want, screen)
}
}
d.keys("2")
c, _ := d.m.containers.Selected()
if _, _, s := d.selected(); s != "api-key" || c.Source.Provider != "Azure:bob@example.com/t1" {
t.Errorf("opened %q via %q, want api-key via bob", s, c.Source.Provider)
}
// j/k and enter work too; x dismisses.
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
d.keys("x")
if d.m.prompt != nil || d.m.nav != nil {
t.Error("x didn't dismiss")
}
d.open("lazysecrets://azure/" + kv + "/secrets/legacy-token")
d.keys("j", "enter")
if c, _ := d.m.containers.Selected(); c.Source.Provider != "Azure:bob@example.com/t1" {
t.Errorf("j enter picked %q", c.Source.Provider)
}
// Opening into a folded account unfolds it.
d.keys("1", "g") // alice's header
d.keys("enter") // fold it
d.open("lazysecrets://azure/" + kv + "/secrets/api-key")
d.keys("1")
if c, _ := d.m.containers.Selected(); c.Source.Provider != "Azure:alice@example.com/t1" {
t.Errorf("folded account not opened: %q", c.Source.Provider)
}
}
// offered reports whether a "Link on clipboard" popup is showing.
func (d *driver) offered() bool {
return d.m.prompt != nil && strings.HasPrefix(d.m.prompt.title, "Link on clipboard")
}
func TestLinkOnClipboardPopsUp(t *testing.T) {
t.Parallel()
link := "lazysecrets://azure/" + kv + "/secrets/api-key"
d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: link}})
screen := d.screen()
for _, want := range []string{"Link on clipboard", "platform-prod / kv-app-prod / api-key", "o Go to it", "c Go to it and copy its value", "x Dismiss"} {
if !strings.Contains(screen, want) {
t.Fatalf("popup missing %q:\n%s", want, screen)
}
}
d.keys("o")
if _, _, s := d.selected(); s != "api-key" || d.offered() {
t.Errorf("o opened %q, popup still shown: %v", s, d.offered())
}
d.send(tea.FocusMsg{}) // same link: offered once only
if d.offered() {
t.Error("offered again after opening")
}
}
func TestClipboardLinkOfferedOnce(t *testing.T) {
t.Parallel()
d := newDriver(t)
if d.offered() {
t.Fatal("empty clipboard offered a link")
}
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key" // copied in another app
d.send(tea.FocusMsg{})
if !d.offered() {
t.Fatal("not offered on focus")
}
d.keys("esc")
d.clip().content = "something else"
d.send(tea.FocusMsg{})
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key" // copied again later
d.send(tea.FocusMsg{})
if d.offered() {
t.Error("link offered a second time")
}
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/legacy-token" // a different one
d.send(tea.FocusMsg{})
if !d.offered() {
t.Error("new link not offered")
}
}
func TestClipboardIgnoresOtherText(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: "hunter2"}})
if d.offered() || d.m.dismissedLink != "" {
t.Error("non-link clipboard text used")
}
// A Key Vault URL isn't offered: only lazysecrets:// links are.
d.clip().content = "https://" + kv + "/secrets/api-key"
d.send(tea.FocusMsg{})
if d.offered() {
t.Error("Key Vault URL offered")
}
}
func TestLinkToSelectionNotOffered(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "s") // share db-password: it's what's selected
d.send(tea.FocusMsg{})
if d.offered() {
t.Fatal("offered a link to the selected secret")
}
d.keys("1", "j") // elsewhere now: the same link is worth offering
d.send(tea.FocusMsg{})
if !d.offered() || !strings.Contains(d.screen(), "platform-prod / kv-app-prod / db-password") {
t.Errorf("own link not offered after moving away:\n%s", d.screen())
}
}
func TestNoClipboardPopupOverAnotherPopup(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "S") // share menu open
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key"
d.send(tea.FocusMsg{})
if d.offered() || !strings.HasPrefix(d.m.prompt.title, "Share") {
t.Error("clipboard popup replaced the share menu")
}
d.keys("esc")
d.send(tea.FocusMsg{}) // offered once the other popup is gone
if !d.offered() {
t.Error("link not offered after the share menu closed")
}
}
func TestInvalidClipboardLinkIsReported(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: "lazysecrets://azure/" + kv + "/secret/api-key"}})
if !strings.Contains(d.screen(), "Link on clipboard can't be read") || !strings.Contains(d.screen(), `unknown object type "secret"`) {
t.Fatalf("typo not reported:\n%s", d.screen())
}
d.keys("x")
if d.m.prompt != nil {
t.Error("x didn't dismiss the report")
}
}
func TestOpenPromptPrefilledFromClipboard(t *testing.T) {
t.Parallel()
link := "lazysecrets://azure/" + kv + "/secrets/api-key"
d := newDriverOpts(t, Options{Clipboard: &fakeClipboard{content: link}})
d.keys("x") // the clipboard popup comes first
d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
d.keys("enter", "o")
if _, _, s := d.selected(); s != "api-key" {
t.Errorf("prefilled ctrl+o opened %q", s)
}
}
func TestOpenPromptReadsClipboardFresh(t *testing.T) {
t.Parallel()
d := newDriver(t)
// Copied in another app, no focus event: not offered, but ctrl+o
// still picks it up, and so it does for a plain Key Vault URL.
d.clip().content = "https://" + kv + "/secrets/api-key"
d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
d.keys("enter", "o")
if _, _, s := d.selected(); s != "api-key" {
t.Errorf("ctrl+o didn't use the clipboard: %q", s)
}
// Not prefilled with text that isn't a reference.
d.clip().content = "hunter2"
d.send(tea.KeyPressMsg{Code: 'o', Mod: tea.ModCtrl})
if v := d.m.prompt.input.Value(); v != "" {
t.Errorf("prefilled with %q", v)
}
}
func TestClipboardCheckedOnKeysWithoutFocusEvents(t *testing.T) {
t.Parallel()
d := newDriver(t)
clock := time.Now()
d.m.now = func() time.Time { return clock }
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/api-key"
clock = clock.Add(clipCheckEvery + time.Second)
d.keys("j") // any key, once the throttle allows
if !d.offered() {
t.Fatal("not offered after a key press")
}
d.keys("x")
d.clip().content = "lazysecrets://azure/" + kv + "/secrets/legacy-token"
d.keys("k") // within the throttle window: not checked yet
if d.offered() {
t.Error("checked again within the throttle window")
}
clock = clock.Add(clipCheckEvery)
d.keys("j")
if !d.offered() {
t.Error("new link not offered after the window")
}
}
func TestOpenSourceLinks(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.send(tea.PasteMsg{Content: "lazysecrets://azure/subscriptions/sub-dev"})
if src, _, _ := d.selected(); src != "platform-dev" || d.m.focus != paneContainers {
t.Errorf("subscription link: %q, focus %d", src, d.m.focus)
}
d.send(tea.PasteMsg{Content: "lazysecrets://databricks/adb-2222222222222222.2.azuredatabricks.net"})
if src, _, _ := d.selected(); src != "dev" {
t.Errorf("workspace link: %q", src)
}
two := newDriver(t, fake.Azure(0).As("Azure:alice", "Azure · alice"), fake.Azure(0).As("Azure:bob", "Azure · bob"))
two.send(tea.PasteMsg{Content: "lazysecrets://azure/subscriptions/sub-dev"})
if !strings.Contains(two.screen(), "2 Open with Azure · bob / platform-dev") {
t.Fatalf("no account choice:\n%s", two.screen())
}
two.keys("2")
if r, _ := two.m.sources.Selected(); r.provider != "Azure:bob" || r.source.Name != "platform-dev" {
t.Errorf("chose %s/%s", r.provider, r.source.Name)
}
}
func TestHandedOverLinkAndActivity(t *testing.T) {
t.Parallel()
touched := 0
d := newDriverOpts(t, Options{OnActive: func() { touched++ }})
d.keys("j")
d.send(tea.FocusMsg{})
if touched != 2 {
t.Errorf("OnActive called %d times, want 2", touched)
}
r, _ := ref.Parse("lazysecrets://azure/kv-shared-prod.vault.azure.net/secrets/smtp-password")
d.send(OpenLink(r))
if d.m.prompt == nil || !strings.Contains(d.screen(), "Open link") {
t.Fatalf("handed-over link not offered:\n%s", d.screen())
}
d.keys("o")
if _, c, s := d.selected(); c != "kv-shared-prod" || s != "smtp-password" {
t.Errorf("went to %s/%s", c, s)
}
}
func TestRefLabel(t *testing.T) {
t.Parallel()
d := newDriver(t)
for link, want := range map[string]string{
// The subscription from lists already loaded.
"lazysecrets://azure/kv-app-prod.vault.azure.net/keys/signing/abc123": "platform-prod / kv-app-prod / signing · key · version abc123",
"lazysecrets://azure/kv-app-prod.vault.azure.net/certificates": "platform-prod / kv-app-prod · certificates tab",
// Not loaded: the vault alone.
"lazysecrets://azure/kv-elsewhere.vault.azure.net/secrets/x": "kv-elsewhere / x",
} {
r, err := ref.Parse(link)
if err != nil {
t.Fatal(err)
}
if got := ansi.Strip(d.m.refLabel(r)); !strings.HasSuffix(got, want) {
t.Errorf("%s: %q, want …%q", link, got, want)
}
}
}
func TestCopyFromLinkStaysPut(t *testing.T) {
t.Parallel()
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
d := newDriverOpts(t, Options{}, denyingValues{alice}, bob, fake.Databricks(0))
d.keys("j") // platform-dev
before := d.m.sources.SelectedID()
r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password")
d.send(OpenLink(r))
d.keys("y")
if got := d.clip().content; got != "correct-horse-battery-staple" {
t.Fatalf("clipboard = %q (alice denies, bob reads)\n%s", got, d.screen())
}
if d.m.sources.SelectedID() != before || d.m.nav != nil || strings.Contains(d.screen(), "correct-horse") {
t.Errorf("moved or revealed:\n%s", d.screen())
}
// A version, and a Databricks secret.
r, _ = ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password/91d3e0")
d.send(OpenLink(r))
d.keys("y")
if got := d.clip().content; got != "previous-password" {
t.Errorf("version: clipboard = %q", got)
}
r, _ = ref.Parse("lazysecrets://databricks/adb-1111111111111111.11.azuredatabricks.net/app/storage-key")
d.send(OpenLink(r))
d.keys("y")
if got := d.clip().content; got == "" || got == "previous-password" {
t.Errorf("databricks: clipboard = %q\n%s", got, d.screen())
}
}
// denyingValues refuses to read values, like an account without access.
type denyingValues struct{ *counting }
func (d denyingValues) Value(context.Context, provider.Object) (provider.SecretValue, error) {
return provider.SecretValue{}, fake.ErrForbidden
}
func TestShareWebPages(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("S") // a subscription
if !strings.Contains(d.screen(), "o Lazysecrets link") || !strings.Contains(d.screen(), "b Web page (Azure portal)") {
t.Fatalf("subscription share menu:\n%s", d.screen())
}
d.keys("o")
if got := d.clip().content; got != "lazysecrets://azure/subscriptions/sub-prod" {
t.Errorf("subscription link %q", got)
}
d.keys("3", "S", "b") // a secret's web page
if got := d.clip().content; !strings.HasPrefix(got, "https://portal.azure.com/") {
t.Errorf("secret web page %q", got)
}
}
+408
View File
@@ -0,0 +1,408 @@
package app
import (
"fmt"
"slices"
"strings"
"unicode/utf8"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// A modal is a centred popup that takes the keyboard, like lazygit's menus:
// a list of options, each with a shortcut key, or a text input.
type option struct {
key string
label string
tip string // shown under the list while the option is selected
note string // dimmed on the right of the row; the filter matches it
// star is the store key ctrl+f stars or unstars (favourites, search);
// starred shows a ★ before the label.
star string
starAccount string // the account the star is for
starred bool
// mark, if set, goes first: how much is loaded (search). reload,
// if set, runs on ctrl+r.
mark string
reload func(m *Model) tea.Cmd
id string // identifies the entry across rebuilds (search)
group string // the source row it's in (search)
kind provider.Kind // the kind of provider it's from (search)
hits []int // characters of the label the filter matched (Go to)
run func(m *Model) tea.Cmd
// header makes this a section title: not selectable, and hidden
// while a filter narrows the list.
header bool
}
type modal struct {
title string
lines []string // explanation above the options
about *ref.Ref // drawn above lines, redrawn as lists load (refLabel)
options []option
cursor int
// A text input instead of options; submit runs on enter.
input *textinput.Model
submit func(m *Model, text string) tea.Cmd
// A filter over the options: typing narrows them (all holds them
// all), so moving uses arrows or ctrl+j/k.
filter *textinput.Model
all []option
footer string // overrides the default footer
onClose func(m *Model) // runs when it closes, picked or not
}
// showFilterMenu opens a menu with a filter input above the options.
func (m *Model) showFilterMenu(title string, options []option) tea.Cmd {
in := textinput.New()
in.Prompt = "/"
in.Placeholder = "type to filter"
in.SetWidth(40)
m.prompt = &modal{title: title, options: options, all: options, filter: &in}
m.prompt.move(0)
m.flash = flash{}
return m.prompt.filter.Focus()
}
// move moves the cursor by delta, skipping section headers; with nothing
// selectable that way it stays put. move(0) settles on a selectable option.
func (p *modal) move(delta int) {
ok := func(i int) bool { return i >= 0 && i < len(p.options) && !p.options[i].header }
step := 1
if delta < 0 {
step = -1
}
i := max(0, min(p.cursor+delta, len(p.options)-1))
for i >= 0 && i < len(p.options) && !ok(i) {
i += step
}
switch {
case ok(i):
p.cursor = i
case !ok(p.cursor): // e.g. after filtering: first selectable
for p.cursor = 0; p.cursor < len(p.options) && !ok(p.cursor); p.cursor++ {
}
}
}
// counter is "3 of 12" over the selectable options.
func (p *modal) counter() string {
n, at := 0, 0
for i, o := range p.options {
if o.header {
continue
}
n++
if i == p.cursor {
at = n
}
}
if n == 0 {
return ""
}
return fmt.Sprintf("%d of %d", at, n)
}
// refilter narrows the options to fuzzy matches of the filter. Matches
// stay under their section headers, best first within each section, and
// sections without matches drop out.
func (p *modal) refilter() {
q := p.filter.Value()
if q == "" {
p.options = p.all
p.cursor = max(0, min(p.cursor, len(p.options)-1))
p.move(0)
return
}
items := make([]match.Item, len(p.all))
for i, o := range p.all {
label := ansi.Strip(o.label)
items[i] = match.Item{Text: strings.TrimSpace(label + " " + o.note), NameEnd: utf8.RuneCountInString(label)}
}
rank := map[int]int{} // matching options: their place in the ranking
for n, h := range match.Rank(match.Parse(q), items) {
if !p.all[h.Index].header {
rank[h.Index] = n
}
}
p.options = nil
var section []int
header := -1
flush := func() {
if len(section) == 0 {
return
}
slices.SortStableFunc(section, func(a, b int) int { return rank[a] - rank[b] })
if header >= 0 {
p.options = append(p.options, p.all[header])
}
for _, i := range section {
p.options = append(p.options, p.all[i])
}
}
for i, o := range p.all {
if o.header {
flush()
section, header = nil, i
continue
}
if _, ok := rank[i]; ok {
section = append(section, i)
}
}
flush()
p.cursor = max(0, min(p.cursor, len(p.options)-1))
p.move(0)
}
// showMenu opens a menu. Every menu ends with an x Dismiss row.
func (m *Model) showMenu(title string, lines []string, options []option) {
options = append(options, option{key: "x", label: "Dismiss", run: func(*Model) tea.Cmd { return nil }})
m.prompt = &modal{title: title, lines: lines, options: options}
m.flash = flash{}
}
// handleModal routes keys while a modal is shown.
func (m *Model) handleModal(msg tea.KeyPressMsg) tea.Cmd {
p := m.prompt
km := m.keys
textField := p.input != nil || p.filter != nil
closeIt := func() {
m.prompt = nil
if p.onClose != nil {
p.onClose(m)
}
}
if (textField && key.Matches(msg, km.PopupClose)) || (!textField && key.Matches(msg, km.MenuClose)) {
closeIt()
return nil
}
if p.input != nil {
if key.Matches(msg, km.PopupSelect) {
m.prompt = nil
return p.submit(m, p.input.Value())
}
in, cmd := p.input.Update(msg)
p.input = &in
return cmd
}
if p.filter != nil {
switch {
case key.Matches(msg, km.PopupDown):
p.move(1)
case key.Matches(msg, km.PopupUp):
p.move(-1)
case key.Matches(msg, km.PopupSelect):
if len(p.options) > 0 && !p.options[p.cursor].header {
closeIt()
return p.options[p.cursor].run(m)
}
default:
in, cmd := p.filter.Update(msg)
p.filter = &in
p.refilter()
return cmd
}
return nil
}
// A menu: each entry's letter picks it (fixed, part of its label),
// before the moving keys.
for _, o := range p.options {
if o.key != "" && o.key == msg.String() {
m.prompt = nil
return o.run(m)
}
}
switch {
case key.Matches(msg, km.Help):
return m.showKeybindingsFor(keys.Menu)
case key.Matches(msg, km.MenuDown):
p.cursor = min(p.cursor+1, len(p.options)-1)
case key.Matches(msg, km.MenuUp):
p.cursor = max(p.cursor-1, 0)
case key.Matches(msg, km.MenuSelect):
m.prompt = nil
return p.options[p.cursor].run(m)
}
return nil
}
// modalView draws the modal as a box sized to its content.
func (m Model) modalView() string {
p := m.prompt
var body []string
if p.about != nil {
body = append(body, " "+m.refLabel(*p.about))
}
for _, l := range p.lines {
body = append(body, " "+l)
}
if (len(p.lines) > 0 || p.about != nil) && (len(p.options) > 0 || p.input != nil) {
body = append(body, "")
}
km := m.keys
foot := footer(move(km.MenuUp, km.MenuDown), hint(km.MenuSelect, "select"), hint(km.MenuClose, "dismiss"))
if p.input != nil {
body = append(body, " "+p.input.View())
foot = footer(hint(km.PopupSelect, "open"), hint(km.PopupClose, "cancel"))
}
// Size from all options, not the ones shown or the cursor's, so the
// popup doesn't change shape while moving or filtering.
all := p.all
if all == nil {
all = p.options
}
optText := func(o option) string {
pre := ""
if o.mark != "" {
pre = o.mark + " "
}
if o.star != "" {
mark := " "
if o.starred {
mark = m.st.Favourite.Render(star)
}
return pre + mark + o.label
}
if o.key == "" {
return o.label
}
return m.st.Label.Render(o.key) + " " + o.label
}
optW := 0
for _, o := range all {
if !o.header {
w := ansi.StringWidth(optText(o)) + 2 // "▸ " or " "
if o.note != "" {
w += 2 + ansi.StringWidth(o.note)
}
optW = max(optW, w)
}
}
optW = min(optW, max(0, m.w-8)) // notes give way to the screen
row := func(o option) string {
return withNote(m.st, optText(o), o.note, optW-2)
}
const window = 15 // rows of a filtered list
options, first, rows := p.options, 0, len(p.options)
if p.filter != nil {
body = append(body, " "+p.filter.View(), "")
foot = footer(move(km.PopupUp, km.PopupDown), hint(km.PopupSelect, "jump"), hint(km.PopupClose, "close"))
rows = min(len(all), window)
first = max(0, min(p.cursor-window/2, len(options)-window))
options = options[first:min(len(options), first+window)]
if len(options) == 0 {
body = append(body, " "+m.st.Muted.Render("no matches"))
rows--
}
}
var headers []int // body lines that are section headers, drawn once w is known
for j, o := range options {
if o.header {
if j > 0 {
body = append(body, "")
}
headers = append(headers, len(body))
body = append(body, o.label)
continue
}
if i := first + j; i == p.cursor {
sel := m.st.Selected
if m.termBlurred {
sel = m.st.SelectedBlurred
}
body = append(body, " "+panes.Highlight("▸ "+row(o), sel, optW))
} else {
body = append(body, " "+" "+row(o))
}
}
for range rows - len(options) {
body = append(body, "") // keep the height while filtering
}
if p.footer != "" {
foot = p.footer
}
w := max(ansi.StringWidth(p.title)+6, optW+4, ansi.StringWidth(foot)+6)
for _, l := range body {
w = max(w, ansi.StringWidth(l)+3)
}
if p.input != nil || p.filter != nil {
w = max(w, min(m.w-4, 72))
}
w = min(w, m.w-2)
// Section headers as rules: "─── Secrets pane ─────────".
for _, i := range headers {
title := body[i]
fill := max(1, w-2-2-3-2-ansi.StringWidth(title)) // borders, margins, "───", spaces
body[i] = " " + m.st.Muted.Render("─── ") + panes.TitleStyle(m.st, !m.termBlurred).Render(title) +
m.st.Muted.Render(" "+strings.Repeat("─", fill))
}
// The selected option's tip, wrapped; room for the longest one is kept
// so the popup doesn't change height while moving.
wrapTip := func(tip string) []string {
if tip == "" {
return nil
}
return strings.Split(ansi.Wordwrap(tip, w-4, " "), "\n")
}
tipRows := 0
for _, o := range all {
tipRows = max(tipRows, len(wrapTip(o.tip)))
}
if tipRows > 0 {
var tip []string
if p.cursor < len(p.options) {
tip = wrapTip(p.options[p.cursor].tip)
}
body = append(body, "")
for i := range tipRows {
line := ""
if i < len(tip) {
line = " " + m.st.Muted.Render(tip[i])
}
body = append(body, line)
}
}
counter := ""
if p.filter != nil {
counter = p.counter()
}
focused := !m.termBlurred // like the panes, no highlight in an unfocused window
return panes.BoxCounted(m.st, panes.TitleStyle(m.st, focused).Render(p.title), foot, counter, body, w, len(body)+2, focused)
}
// overlay draws the modal centred over the screen.
func (m Model) overlay(base string) string {
var box string
if m.search != nil {
box = m.searchViewBox()
} else {
box = m.modalView()
}
x := max(0, (m.w-lipgloss.Width(box))/2)
y := max(0, (strings.Count(base, "\n")+1-lipgloss.Height(box))/3)
return lipgloss.NewCompositor(
lipgloss.NewLayer(base),
lipgloss.NewLayer(box).X(x).Y(y).Z(1),
).Render()
}
+93
View File
@@ -0,0 +1,93 @@
package app
import (
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// Notes: n writes a short note on the selected subscription, vault or
// secret, kept in its store record. Notes show dimmed on the right of rows
// and in the detail pane, and the filter matches them.
// withNote right-aligns a dimmed note in a row of the given width. The
// note gives way first when space is short.
func withNote(st styles.Styles, left, note string, width int) string {
if note == "" {
return left
}
room := width - ansi.StringWidth(left) - 2
if room < 4 {
return left
}
note = ansi.Truncate(note, room, "…")
pad := width - ansi.StringWidth(left) - ansi.StringWidth(note)
return left + strings.Repeat(" ", pad) + st.Muted.Render(note)
}
// editNote opens a popup to write the focused item's note.
func (m *Model) editNote() tea.Cmd {
var key, name string
var path []string
var src provider.Source
switch m.focus {
case paneSources:
s, ok := m.selectedSource()
if !ok {
m.flash = warning("select a subscription or workspace to add a note")
return nil
}
key, name, src = sourceKey(s), s.Name, s
case paneContainers:
c, ok := m.containers.Selected()
if !ok {
return nil
}
key, name, path, src = storeKey(c), c.Name, []string{c.Source.Name}, c.Source
default:
o, ok := m.detail.Secret()
if !ok {
return nil
}
c := o.Container
key, name, path, src = objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source
}
in := textinput.New()
in.Prompt = "note: "
in.Placeholder = "e.g. prod SQL admin"
in.CharLimit = 80
in.SetWidth(48)
in.SetValue(m.store.Node(key).Note)
in.CursorEnd()
m.prompt = &modal{
title: "Note on " + name, input: &in,
lines: []string{m.st.Muted.Render("Shown on the right of the row. Empty removes it.")},
submit: func(m *Model, text string) tea.Cmd {
note := strings.Join(strings.Fields(text), " ") // one line
if err := m.remember(key, name, path, src, func(n *store.Node) { n.Note = note }); err != nil {
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
return nil
}
m.refreshDetailNote()
return nil
},
footer: footer(hint(m.keys.PopupSelect, "save"), hint(m.keys.PopupClose, "cancel")),
}
m.flash = flash{}
return m.prompt.input.Focus()
}
// refreshDetailNote shows the note of whatever the detail pane shows.
func (m *Model) refreshDetailNote() {
if o, ok := m.detail.Secret(); ok {
m.detail.SetNote(m.store.Node(objectKey(o)).Note)
} else if c, ok := m.detail.Container(); ok {
m.detail.SetNote(m.store.Node(storeKey(c)).Note)
}
}
+93
View File
@@ -0,0 +1,93 @@
package app
import (
"path/filepath"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
func (d *driver) note(text string) {
d.t.Helper()
d.keys("n")
if d.m.prompt == nil || !strings.HasPrefix(d.m.prompt.title, "Note on ") {
d.t.Fatalf("n didn't open the note popup:\n%s", d.screen())
}
for range 40 { // clear what's there
d.send(tea.KeyPressMsg{Code: tea.KeyBackspace})
}
for _, r := range text {
d.send(tea.KeyPressMsg{Code: r, Text: string(r)})
}
d.keys("enter")
}
func TestNotesOnRows(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "state.json")
mem, _ := store.Load(path)
d := newDriverOpts(t, Options{Store: mem})
d.keys("3", "j")
d.note("stripe live")
d.keys("2")
d.note("app secrets")
d.keys("1")
d.note("main prod")
screen := d.screen()
for _, want := range []string{"stripe live", "app secrets", "main prod", "note main prod"} {
if !strings.Contains(screen, want) {
t.Errorf("missing %q:\n%s", want, screen)
}
}
// The filter matches notes.
d.keys("3", "/")
d.typeText("stripe")
if _, _, s := d.selected(); s != "api-key" || d.m.secrets.Len() != 1 {
t.Errorf("filter by note: %q (%d)", s, d.m.secrets.Len())
}
d.keys("esc")
// Saved, and an empty note removes it.
again, _ := store.Load(path)
notes := 0
for _, n := range again.Nodes() {
if n.Note != "" {
notes++
}
}
if notes != 3 {
t.Errorf("saved notes = %v", again.Nodes())
}
d.keys("3")
d.note("")
if strings.Contains(d.screen(), "stripe live") {
t.Error("empty note didn't remove it")
}
}
func TestWithNoteGivesWay(t *testing.T) {
t.Parallel()
st := styles.Default()
if got := withNote(st, "db-password", "prod SQL admin", 30); len([]rune(stripANSI(got))) != 30 ||
!strings.HasSuffix(stripANSI(got), "prod SQL admin") {
t.Errorf("fits: %q", stripANSI(got))
}
if got := stripANSI(withNote(st, "db-password", "prod SQL admin", 20)); !strings.HasPrefix(got, "db-password") ||
!strings.HasSuffix(got, "…") {
t.Errorf("tight: %q", got)
}
if got := stripANSI(withNote(st, "a-very-long-secret-name", "note", 20)); got != "a-very-long-secret-name" {
t.Errorf("no room: %q", got)
}
}
func stripANSI(s string) string { return ansi.Strip(s) }
+248
View File
@@ -0,0 +1,248 @@
package app
import (
"encoding/json"
"errors"
"os"
"path/filepath"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// What was loaded is kept between runs in a cache file (Options.CacheFile):
// the Sources lists, vaults, secrets' metadata and where the cursor was in
// each. Starting again shows it at once, and lists older than cacheTTL
// reload in the background as usual. Values are never in it; neither are
// repos, whose files are read from disk anyway.
//
// And where lazysecrets was left, per folder it ran in, goes in the store,
// to go back there on the next start.
// cacheFileVersion is the file's format; a file of another version is
// ignored, and replaced on the next save.
const cacheFileVersion = 1
// cacheMaxAge drops what hasn't been loaded again for this long.
const cacheMaxAge = 30 * 24 * time.Hour
// persistEvery is how often a changed cache is saved while running, so
// little is lost if lazysecrets is killed rather than quit.
const persistEvery = 30 * time.Second
type cacheFile struct {
Version int `json:"version"`
Sources map[string]stamped[[]provider.Source] `json:"sources,omitempty"` // by provider ID
Containers map[string]stamped[[]provider.Container] `json:"containers,omitempty"`
Objects map[string]stamped[[]provider.Object] `json:"objects,omitempty"`
Details map[string]stamped[provider.Object] `json:"details,omitempty"`
Cursor map[string]string `json:"cursor,omitempty"`
}
type stamped[T any] struct {
At time.Time `json:"at"`
Value T `json:"value"`
}
type persistTickMsg struct{}
type persistedMsg struct{ err error }
// keepCached reports whether a cache key's account still exists and
// isn't a repo: every key starts with the provider ID.
func (m *Model) keepCached(key string) bool {
id, _ := splitSourceRow(key)
p, ok := m.byID[id]
return ok && p.Kind() != provider.KindRepo
}
// loadCacheFile fills the cache from the file, leaving out what's too
// old or belongs to accounts that aren't signed in any more.
func (m *Model) loadCacheFile() {
b, err := os.ReadFile(m.cachePath)
if err != nil {
return
}
var f cacheFile
if json.Unmarshal(b, &f) != nil || f.Version != cacheFileVersion {
return
}
now := m.now()
recent := func(at time.Time) bool { return now.Sub(at) < cacheMaxAge }
for id, e := range f.Sources {
if m.keepCached(id+"|") && recent(e.At) {
m.sourceResults[id] = sourcesMsg{gen: m.sourcesGen, provider: id, sources: e.Value}
m.sourcesAt[id] = e.At
}
}
for k, e := range f.Containers {
if m.keepCached(k) && recent(e.At) {
m.cache.containers[k] = entry[[]provider.Container]{e.Value, e.At}
}
}
for k, e := range f.Objects {
if m.keepCached(k) && recent(e.At) {
m.cache.objects[k] = entry[[]provider.Object]{e.Value, e.At}
}
}
for k, e := range f.Details {
if m.keepCached(k) && recent(e.At) {
m.cache.details[k] = entry[provider.Object]{e.Value, e.At}
}
}
for k, v := range f.Cursor {
if m.keepCached(k) {
m.cache.cursor[k] = v
}
}
}
// cacheSnapshot is the cache as the file holds it.
func (m *Model) cacheSnapshot() cacheFile {
f := cacheFile{
Version: cacheFileVersion,
Sources: map[string]stamped[[]provider.Source]{},
Containers: map[string]stamped[[]provider.Container]{},
Objects: map[string]stamped[[]provider.Object]{},
Details: map[string]stamped[provider.Object]{},
Cursor: map[string]string{},
}
for id, r := range m.sourceResults {
if r.err == nil && m.keepCached(id+"|") {
f.Sources[id] = stamped[[]provider.Source]{m.sourcesAt[id], r.sources}
}
}
for k, e := range m.cache.containers {
if m.keepCached(k) {
f.Containers[k] = stamped[[]provider.Container]{e.at, e.value}
}
}
for k, e := range m.cache.objects {
if m.keepCached(k) {
f.Objects[k] = stamped[[]provider.Object]{e.at, e.value}
}
}
for k, e := range m.cache.details {
if m.keepCached(k) {
f.Details[k] = stamped[provider.Object]{e.at, e.value}
}
}
for k, v := range m.cache.cursor {
if m.keepCached(k) {
f.Cursor[k] = v
}
}
return f
}
// writeCacheFile writes f atomically, readable by the user only.
func writeCacheFile(path string, f cacheFile) error {
b, err := json.Marshal(f)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".cache-*.json")
if err != nil {
return err
}
defer os.Remove(tmp.Name()) // after a successful rename, nothing there
if _, err := tmp.Write(b); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), path)
}
// persistTick saves the cache, and where lazysecrets is, in the
// background when they changed.
func (m *Model) persistTick() tea.Cmd {
cmds := []tea.Cmd{tea.Tick(persistEvery, func(time.Time) tea.Msg { return persistTickMsg{} })}
if m.cacheDirty && m.cachePath != "" {
m.cacheDirty = false
f, path := m.cacheSnapshot(), m.cachePath
cmds = append(cmds, func() tea.Msg { return persistedMsg{writeCacheFile(path, f)} })
}
if p, ok := m.place(); ok && m.placeDir != "" && p.Link != m.savedPlace {
m.savedPlace = p.Link
st, dir := m.store, m.placeDir
cmds = append(cmds, func() tea.Msg { return persistedMsg{st.SetPlace(dir, p)} })
}
return tea.Batch(cmds...)
}
// Persist saves the cache and where lazysecrets was left; run it after
// the program ends.
func (m Model) Persist() error {
var errs []error
if m.cachePath != "" {
errs = append(errs, writeCacheFile(m.cachePath, m.cacheSnapshot()))
}
if m.placeDir != "" {
if p, ok := m.place(); ok {
errs = append(errs, m.store.SetPlace(m.placeDir, p))
}
}
return errors.Join(errs...)
}
// place is the selected item, as deep as the focus.
func (m *Model) place() (store.Place, bool) {
src, ok := m.selectedSource()
if !ok || m.nav != nil {
return store.Place{}, false
}
var r ref.Ref
var err error
switch c, cok := m.containers.Selected(); {
case m.focus == paneSources || !cok:
r, err = ref.ForSource(src)
case m.focus == paneContainers:
r, err = ref.ForContainer(c, m.objType)
default:
o, ook := m.detail.Secret()
if !ook {
r, err = ref.ForContainer(c, m.objType)
} else {
r, err = ref.ForObject(o, "")
}
}
if err != nil {
return store.Place{}, false
}
return store.Place{Link: r.String(), Account: src.Provider, Source: src.ID, At: m.now()}, true
}
// restorePlace goes back to where lazysecrets was left in dir, if that
// account is still signed in.
func (m *Model) restorePlace() {
p, ok := m.store.Place(m.placeDir)
if !ok {
return
}
if _, ok := m.byID[p.Account]; !ok {
return
}
r, err := ref.Parse(p.Link)
if err != nil {
return
}
m.startNav(r, actGo, sourceRowKey(p.Account, p.Source))
m.nav.sourceOnly = r.IsSource()
m.nav.quiet = true
}
func (m *Model) handlePersisted(msg persistedMsg) {
if msg.err != nil {
m.logErrOf("saving the cache or place", msg.err)
}
}
+136
View File
@@ -0,0 +1,136 @@
package app
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
func TestCacheFileShowsLastRunAtOnce(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "lazysecrets", "cache.json")
d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0))
d.keys("3", "space")
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
t.Fatalf("value not revealed:\n%s", d.screen())
}
src, vault, secret := d.selected()
if err := d.m.Persist(); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(file)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(b), "correct-horse") {
t.Error("a value is in the cache file")
}
if fi, _ := os.Stat(file); fi.Mode().Perm() != 0o600 {
t.Errorf("cache file mode %v", fi.Mode().Perm())
}
// Next run, with a backend too slow to answer during the test: the
// lists show from the file, and the cursor is where it was.
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
if strings.Contains(d2.screen(), "cached") {
t.Errorf("a fresh cache said its age:\n%s", d2.screen())
}
d2.keys("2")
if _, v, _ := d2.selected(); v != vault {
t.Errorf("vaults from the cache: selected %q, want %q\n%s", v, vault, d2.screen())
}
d2.keys("3")
if s, v, o := d2.selected(); s != src || v != vault || o != secret {
t.Errorf("selected %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen())
}
}
// Lists from an older cache say how old they are while they reload.
func TestCacheFileSaysItsAge(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "cache.json")
d := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(0))
d.keys("3")
at := time.Now().Add(-3 * time.Hour)
for k, e := range d.m.cache.containers {
d.m.cache.containers[k] = entry[[]provider.Container]{e.value, at}
}
for id := range d.m.sourcesAt {
d.m.sourcesAt[id] = at
}
if err := d.m.Persist(); err != nil {
t.Fatal(err)
}
d2 := newDriverOpts(t, Options{CacheFile: file}, fake.Azure(time.Minute))
d2.keys("2")
if n := strings.Count(d2.screen(), "cached 3h ago"); n != 2 {
t.Errorf("Sources and Vaults should say they're from 3h ago (%d do):\n%s", n, d2.screen())
}
}
func TestCacheFileDropsOldAndSignedOut(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "cache.json")
az := fake.Azure(0)
now := time.Now()
f := cacheFile{
Version: cacheFileVersion,
Containers: map[string]stamped[[]provider.Container]{
az.ID() + "|recent": {now.Add(-time.Hour), []provider.Container{{Name: "kv-recent"}}},
az.ID() + "|old": {now.Add(-cacheMaxAge - time.Hour), []provider.Container{{Name: "kv-old"}}},
"Azure:gone@x/t|recent": {now, []provider.Container{{Name: "kv-gone"}}},
},
}
if err := writeCacheFile(file, f); err != nil {
t.Fatal(err)
}
d := newDriverOpts(t, Options{CacheFile: file}, az)
c := d.m.cache.containers
if _, ok := c[az.ID()+"|recent"]; !ok {
t.Error("dropped a recent entry")
}
if _, ok := c[az.ID()+"|old"]; ok {
t.Error("kept an entry older than cacheMaxAge")
}
if _, ok := c["Azure:gone@x/t|recent"]; ok {
t.Error("kept an entry of an account that isn't signed in")
}
}
func TestBackWhereItWasLeft(t *testing.T) {
t.Parallel()
st, _ := store.Load("")
dir := "/work/project"
d := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0))
d.keys("3", "j", "j")
src, vault, secret := d.selected()
if err := d.m.Persist(); err != nil {
t.Fatal(err)
}
if p, ok := st.Place(dir); !ok || !strings.Contains(p.Link, secret) {
t.Fatalf("place %+v", p)
}
d2 := newDriverOpts(t, Options{Store: st, Dir: dir}, fake.Azure(0))
if s, v, o := d2.selected(); s != src || v != vault || o != secret {
t.Errorf("back at %s/%s/%s, want %s/%s/%s\n%s", s, v, o, src, vault, secret, d2.screen())
}
// Another folder starts as usual; so does opening a link.
d3 := newDriverOpts(t, Options{Store: st, Dir: "/elsewhere"}, fake.Azure(0))
if _, _, o := d3.selected(); o == secret {
t.Errorf("another folder went to %s too", o)
}
r, _ := ref.Parse("lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/db-password")
d4 := newDriverOpts(t, Options{Store: st, Dir: dir, Open: &r}, fake.Azure(0))
if d4.m.nav != nil && d4.m.nav.quiet {
t.Error("a link was overridden by where it was left")
}
}
+344
View File
@@ -0,0 +1,344 @@
package app
import (
tea "charm.land/bubbletea/v2"
"context"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"git.kebcloud.no/keb/lazysecrets/internal/provider/repo"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
const sopsFile = `db:
password: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
sops:
age:
- recipient: age1xxxx
lastmodified: "2026-10-09T10:05:29Z"
mac: ENC[AES256_GCM,data:x,iv:x,tag:x,type:str]
`
// gitRepoWith makes a git repository holding a SOPS file, with a remote.
func gitRepoWith(t *testing.T, remote string) string {
t.Helper()
root, _ := filepath.EvalSymlinks(t.TempDir())
os.MkdirAll(filepath.Join(root, "secrets"), 0o755)
os.WriteFile(filepath.Join(root, "secrets", "app.yaml"), []byte(sopsFile), 0o644)
for _, args := range [][]string{{"init", "-q"}, {"add", "."}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"},
{"remote", "add", "origin", remote}} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("%v %s", err, out)
}
}
return root
}
func TestCurrentRepoFirst(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
here := gitRepoWith(t, "https://git.example.com/team/here.git")
other := gitRepoWith(t, "https://git.example.com/team/aaa-other.git")
mem, _ := store.Load("")
mem.Update("lazysecrets://repo/git.example.com%2Fteam%2Faaa-other", func(n *store.Node) {
n.Source, n.StarredBy = other, []string{"Repos"}
})
d := newDriverOpts(t, Options{Store: mem}, repo.New(repo.Settings{Dir: here, Starred: []string{other}}))
// Grouped: the repo it started in on a row of its own on top,
// selected, then the other repos under Repos, without it.
var rows []string
for _, r := range d.m.sources.Items() {
switch r.kind {
case rowGroup:
rows = append(rows, "group "+r.label)
case rowSource:
rows = append(rows, r.source.ID)
}
}
if want := []string{here, "group Repos", other}; !slices.Equal(rows, want) {
t.Errorf("rows %v, want %v", rows, want)
}
if !strings.Contains(d.screen(), "Repo · "+filepath.Base(here)) {
t.Errorf("no Repo · name row:\n%s", d.screen())
}
if src, ok := d.m.selectedSource(); !ok || src.ID != here {
t.Errorf("selected %v, want the repo it started in", src.ID)
}
// Flat: it sorts first, labelled.
d.keys("`")
if !strings.Contains(d.screen(), filepath.Base(here)+" · here") {
t.Errorf("no here label in the flat list:\n%s", d.screen())
}
}
func TestRepoSource(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root, _ := filepath.EvalSymlinks(t.TempDir())
os.MkdirAll(filepath.Join(root, "secrets"), 0o755)
os.WriteFile(filepath.Join(root, "secrets", "app.yaml"), []byte(sopsFile), 0o644)
for _, args := range [][]string{{"init", "-q"}, {"add", "."}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "x"},
{"remote", "add", "origin", "https://git.example.com/team/infra.git"}} {
if out, err := exec.Command("git", append([]string{"-C", root}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("%v %s", err, out)
}
}
d := newDriverOpts(t, Options{}, []provider.Provider{repo.New(repo.Settings{Dir: root})}...)
scr := d.screen()
for _, want := range []string{"Repos", filepath.Base(root), "[2] Files", "secrets/app.yaml sops · age", "db.password"} {
if !strings.Contains(scr, want) {
t.Fatalf("missing %q:\n%s", want, scr)
}
}
d.keys("3", "f")
key := "lazysecrets://repo/git.example.com%2Fteam%2Finfra/secrets%2Fapp.yaml/db.password"
if !d.m.store.Node(key).StarredFor("Repos") {
t.Errorf("star not stored under the repo link; nodes: %v", d.m.store.Nodes())
}
d.keys("s")
if got := d.clip().content; got != key {
t.Errorf("shared link %q", got)
}
// Opening the link finds the repo by its remote and goes there.
d.keys("1")
r, _ := ref.Parse(key)
d.send(OpenLink(r))
d.keys("o")
if src, c, s := d.selected(); src != filepath.Base(root) || c != "secrets/app.yaml" || s != "db.password" {
t.Errorf("opened %s/%s/%s\n%s", src, c, s, d.screen())
}
}
func TestReposLoadThemselves(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root := gitRepoWith(t, "https://git.example.com/team/x.git")
// Started elsewhere, so the repo isn't the selected source.
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Dir: t.TempDir(), Paths: []string{root}}))
d.openSearch()
d.searchAll()
d.typeText("db.password")
if !strings.Contains(d.screen(), "secrets/app.yaml / db.password") {
t.Errorf("repo keys not searchable without loading them:\n%s", d.screen())
}
}
func TestGroupsStartCollapsed(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
here := gitRepoWith(t, "https://git.example.com/team/here.git")
d := newDriverOpts(t, Options{CollapseGroups: true},
fake.Azure(0), fake.Databricks(0), repo.New(repo.Settings{Dir: here}))
var rows []string
for _, r := range d.m.sources.Items() {
switch r.kind {
case rowGroup:
rows = append(rows, "group "+r.label)
case rowSource:
rows = append(rows, r.source.Name)
}
}
// Folded groups, and the repo it started in on top, selected, so its
// files show.
if want := []string{filepath.Base(here), "group Azure", "group Databricks"}; !slices.Equal(rows, want) {
t.Errorf("rows %v, want %v", rows, want)
}
if src, ok := d.m.selectedSource(); !ok || src.ID != here {
t.Errorf("selected %v, want the repo it started in", src.ID)
}
d.keys("j", "enter") // unfold Azure
if d.sourceCount() < 2 {
t.Errorf("enter on a folded group didn't unfold it:\n%s", d.screen())
}
}
func TestCurrentRepoBeforeTheRest(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
here := gitRepoWith(t, "https://git.example.com/team/here.git")
p := repo.New(repo.Settings{Dir: here})
d := newDriverOpts(t, Options{}, fake.Azure(0), p)
// Sources asked again, nothing answered yet but the started-in repo
// on its own (provider.Current): it shows on top already.
d.m.loadSources()
src, ok := p.Current(context.Background())
if !ok {
t.Fatal("no current repo")
}
src.Provider, src.ProviderLabel = p.ID(), p.Label()
d.send(currentMsg{gen: d.m.sourcesGen, source: src})
if !strings.Contains(d.screen(), "Repo · "+filepath.Base(here)) {
t.Errorf("the started-in repo waits for the others:\n%s", d.screen())
}
}
func TestSearchAllRepos(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
a := gitRepoWith(t, "https://git.example.com/team/alpha.git")
b := gitRepoWith(t, "https://git.example.com/team/beta.git")
d := newDriverOpts(t, Options{}, fake.Azure(0), repo.New(repo.Settings{Paths: []string{a, b}}))
d.openSearch()
d.send(ctrl('h'))
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for d.m.search.scope().id != accountScope+"Repos" {
if d.m.search.src == len(d.m.search.scopes)-1 {
t.Fatalf("no Repos scope: %v", d.m.search.scopes)
}
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
}
azure := func() (n int) {
for row := range d.m.cache.containers {
if p, _ := splitSourceRow(row); p != "Repos" {
n++
}
}
return n
}
azureBefore := azure()
d.typeText("password")
var got []string
for _, o := range d.m.search.shown {
if o.kind != provider.KindRepo {
t.Errorf("not a repo: %s", ansiStrip(o.label))
}
got = append(got, ansiStrip(o.label))
}
if len(got) != 2 {
t.Errorf("want db.password in both repos: %v", got)
}
if azure() != azureBefore || d.m.crawl.running {
t.Errorf("searching all repos loaded Azure: %d → %d lists", azureBefore, azure())
}
if !strings.Contains(d.screen(), "Results in Repos") {
t.Errorf("title:\n%s", d.screen())
}
}
func TestEditFile(t *testing.T) {
// Not parallel: git, run by the repo provider, is slow under load.
if _, err := exec.LookPath("git"); err != nil {
t.Skip("no git")
}
root := gitRepoWith(t, "https://git.example.com/team/x.git")
envPath := filepath.Join(root, "secrets", ".env")
os.WriteFile(envPath, []byte("# local\nTOKEN=x\n"), 0o600)
var opened []string
d := newDriverOpts(t, Options{OpenURL: func(u string) error { opened = append(opened, u); return nil }},
fake.Azure(0), repo.New(repo.Settings{Dir: root}))
env := map[string]string{"NVIM": "/tmp/nvim.sock"}
d.m.getenv = func(k string) string { return env[k] }
var nvim []string
d.m.nvimOpen = func(server, path string, line int) error {
nvim = append(nvim, fmt.Sprintf("%s %s:%d", server, path, line))
return nil
}
// Inside nvim: the file, then a secret at its line, and lazysecrets quits.
d.keys("2")
if _, c, _ := d.selected(); c != "secrets/.env" {
t.Fatalf("selected %q\n%s", c, d.screen())
}
d.keys("e")
d.keys("3", "e")
want := []string{"/tmp/nvim.sock " + envPath + ":0", "/tmp/nvim.sock " + envPath + ":2"}
if !slices.Equal(nvim, want) {
t.Errorf("nvim got %q\nwant %q", nvim, want)
}
if msg := d.m.edit()(); msg != (tea.QuitMsg{}) {
t.Errorf("after opening in nvim: %#v, want quit", msg)
}
// SOPS files go to sops edit, in the terminal, even inside nvim.
nvim = nil
d.keys("2", "j")
if _, c, _ := d.selected(); c != "secrets/app.yaml" {
t.Fatalf("selected %q", c)
}
if cmd := d.m.edit(); cmd == nil || len(nvim) > 0 {
t.Errorf("sops file: cmd %v, nvim %q", cmd, nvim)
}
// Neither nvim nor an editor: the system's default app.
delete(env, "NVIM")
d.keys("k", "e")
if !slices.Equal(opened, []string{envPath}) || !strings.Contains(d.screen(), "opened .env") {
t.Errorf("opened %q\n%s", opened, d.screen())
}
// Done editing: the file's secrets reload.
d.send(editDoneMsg{c: provider.Container{Source: provider.Source{Provider: "Repos"}, ID: envPath}, name: ".env"})
if !strings.Contains(d.screen(), "closed .env") {
t.Errorf("after editing:\n%s", d.screen())
}
// Other providers have no files.
az := newDriver(t, fake.Azure(0))
az.keys("2", "e")
if !strings.Contains(az.screen(), "only SOPS and .env files") {
t.Errorf("azure:\n%s", az.screen())
}
}
func TestEditorArgs(t *testing.T) {
t.Parallel()
f := provider.File{Path: "/r/.env", Line: 7}
for editor, want := range map[string][]string{
"nvim": {"+7", "/r/.env"},
"/usr/bin/vim -p": {"+7", "/r/.env"},
"hx": {"/r/.env:7"},
"code --wait": {"-g", "/r/.env:7"},
"open -a TextEdit": {"/r/.env"},
} {
if got := editorArgs(editor, f); !slices.Equal(got, want) {
t.Errorf("%s: %q, want %q", editor, got, want)
}
}
if got := editorArgs("nvim", provider.File{Path: "/r/.env"}); !slices.Equal(got, []string{"/r/.env"}) {
t.Errorf("no line: %q", got)
}
}
func TestEditorAtLine(t *testing.T) {
t.Parallel()
for _, c := range []struct {
editor string
line int
want string
ok bool
}{
{"nvim", 4, "nvim +4", true},
{"/usr/bin/vim -p", 4, "/usr/bin/vim -p +4", true},
{"code --wait", 4, "", false}, // sops adds the file last: no -g file:N
{"nvim", 0, "", false},
} {
if got, ok := editorAtLine(c.editor, c.line); got != c.want || ok != c.ok {
t.Errorf("%q at %d: %q %v", c.editor, c.line, got, ok)
}
}
}
+61
View File
@@ -0,0 +1,61 @@
package app
import (
"fmt"
"math"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// A revealed value doesn't stay on screen: it hides after the configured
// timeout, counting down in the detail pane, and when the terminal loses
// focus (switching windows, screen sharing another app).
// startRevealTimer starts the countdown for a value just revealed.
func (m *Model) startRevealTimer() tea.Cmd {
m.revealToken++
if m.revealTimeout <= 0 {
m.revealDeadline = time.Time{}
return nil
}
m.revealDeadline = m.now().Add(m.revealTimeout)
m.showHidesIn()
return m.revealTickCmd()
}
// revealTickCmd fires when the displayed second next changes.
func (m *Model) revealTickCmd() tea.Cmd {
wait := m.revealDeadline.Sub(m.now()) % time.Second
if wait <= 0 {
wait = time.Second
}
token := m.revealToken
return tea.Tick(wait, func(time.Time) tea.Msg { return revealTickMsg{token} })
}
func (m *Model) revealTick(msg revealTickMsg) tea.Cmd {
if msg.token != m.revealToken || m.revealDeadline.IsZero() || m.detail.State() != panes.ValueShown {
return nil // hidden meanwhile, or another value shown
}
if !m.now().Before(m.revealDeadline) {
m.hideValue()
return nil
}
m.showHidesIn()
return m.revealTickCmd()
}
func (m *Model) showHidesIn() {
left := max(0, int(math.Ceil(m.revealDeadline.Sub(m.now()).Seconds())))
m.detail.SetHidesIn(fmt.Sprintf("%ds", left))
}
// hideValue hides the revealed value and stops its countdown.
func (m *Model) hideValue() {
m.detail.Hide()
m.revealToken++
m.revealDeadline = time.Time{}
}
+95
View File
@@ -0,0 +1,95 @@
package app
import (
"context"
"fmt"
"slices"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
)
// containerKey identifies a container in the UI and caches. The same vault
// seen through two accounts must not share cached lists, since each
// account may see different things.
func containerKey(c provider.Container) string { return c.Source.Provider + "|" + c.ID }
// providerFor finds the provider instance a source came from. Sources
// remembered from an earlier run (favourites) may name an account that
// isn't signed in any more, or predate accounts; they fall back to the
// first provider of the same kind.
func (m Model) providerFor(src provider.Source) provider.Provider {
if p, ok := m.byID[src.Provider]; ok {
return p
}
for _, p := range m.providers {
if p.Kind() == src.Kind {
return p
}
}
return missingProvider{src}
}
// candidates lists the providers that may serve src, in configured order:
// its own provider when that is known, otherwise every provider of its kind
// that lists the source (all of that kind if their sources haven't loaded).
// Favourites saved before accounts existed, or by an account that is no
// longer signed in, take the second path.
func (m Model) candidates(src provider.Source) []provider.Provider {
if p, ok := m.byID[src.Provider]; ok {
return []provider.Provider{p}
}
var seeing, ofKind []provider.Provider
for _, p := range m.providers {
if p.Kind() != src.Kind {
continue
}
ofKind = append(ofKind, p)
r, loaded := m.sourceResults[p.ID()]
if !loaded || slices.ContainsFunc(r.sources, func(s provider.Source) bool { return s.ID == src.ID }) {
seeing = append(seeing, p)
}
}
switch {
case len(seeing) > 0:
return seeing
case len(ofKind) > 0:
return ofKind
}
return []provider.Provider{missingProvider{src}}
}
// boundTo returns src as served by p.
func boundTo(src provider.Source, p provider.Provider) provider.Source {
src.Provider, src.ProviderLabel = p.ID(), p.Label()
return src
}
// missingProvider answers for a provider that isn't configured, e.g. a
// favourite from a provider disabled in the config.
type missingProvider struct{ src provider.Source }
func (p missingProvider) err() error {
return fmt.Errorf("%s isn't available (disabled in the config or not signed in)", p.src.Kind)
}
func (p missingProvider) Kind() provider.Kind { return p.src.Kind }
func (p missingProvider) ID() string { return p.src.Provider }
func (p missingProvider) Label() string { return string(p.src.Kind) }
func (p missingProvider) Sources(context.Context) ([]provider.Source, error) {
return nil, p.err()
}
func (p missingProvider) Containers(context.Context, provider.Source) ([]provider.Container, error) {
return nil, p.err()
}
func (p missingProvider) Objects(context.Context, provider.Container, provider.ObjectType) ([]provider.Object, error) {
return nil, p.err()
}
func (p missingProvider) Describe(_ context.Context, o provider.Object) (provider.Object, error) {
return o, p.err()
}
func (p missingProvider) Versions(context.Context, provider.Object) ([]provider.Object, error) {
return nil, p.err()
}
func (p missingProvider) Value(context.Context, provider.Object) (provider.SecretValue, error) {
return provider.SecretValue{}, p.err()
}
+400
View File
@@ -0,0 +1,400 @@
package app
import (
"cmp"
"context"
"fmt"
"slices"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ref"
)
// ctrl+s searches everything loaded: subscriptions and workspaces, vaults
// and scopes, and the secrets, keys and certificates of every vault opened
// this session. Azure has no search across vaults, so ctrl+a in the popup
// loads the rest in the background, a few requests at a time; results
// appear as lists arrive, and the panes use them too.
const crawlWorkers = 4
// crawl loads every vault's secrets for search.
type crawl struct {
running bool
queue []crawlJob
active int
pending map[string]bool // cache keys of lists queued or loading
failed map[string]bool // cache keys of lists that couldn't be read
}
type crawlJob struct {
src *provider.Source // list its vaults or scopes
container *provider.Container // list its secrets
row string // the source's row key (containers cache key)
force bool // reload what's loaded already
}
type crawlMsg struct {
job crawlJob
containers []provider.Container
objects []provider.Object
err error
}
func contextWithTimeout(d time.Duration) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), d)
}
// searchEntries lists everything loaded: the sources (with how much of
// each is loaded) and the vaults, scopes and secrets in them, each with
// its source's row key as group.
func (m *Model) searchEntries() (sources, items []option) {
type entry struct {
depth int
sort string
opt option
}
var es []entry
// key is the store key, so ctrl+f stars the entry; mark shows how
// much is loaded, reload (ctrl+r) loads it again.
add := func(depth int, row string, kind provider.Kind, path []string, account, key, mark string, run, reload func(*Model) tea.Cmd) {
n := m.store.Node(key)
sort := strings.ToLower(strings.Join(path, "/")) + " " + account
es = append(es, entry{depth, sort,
option{label: m.pathLabel(kind, path, account), note: n.Note, star: key,
starAccount: providerOf(row), starred: n.StarredFor(providerOf(row)),
mark: mark, run: run, reload: reload, id: sort, group: row, kind: kind}})
}
for _, p := range m.providers {
for _, src := range m.sourceResults[p.ID()].sources {
src.Provider, src.ProviderLabel = p.ID(), p.Label()
row := sourceRowKey(p.ID(), src.ID)
add(0, row, src.Kind, []string{src.Name}, accountOf(src), sourceKey(src), m.sourceMark(row), func(m *Model) tea.Cmd {
m.nav = &nav{sourceRow: row, sourceOnly: true}
return nil
}, func(m *Model) tea.Cmd { return m.loadSource(src) })
for _, c := range m.cache.containers[row].value {
r, err := ref.ForContainer(c, provider.TypeSecret)
if err != nil {
continue
}
reload := func(m *Model) tea.Cmd { return m.loadContainer(c) }
add(1, row, c.Source.Kind, []string{src.Name, c.Name}, accountOf(src), storeKey(c), m.vaultMark(c), func(m *Model) tea.Cmd {
m.startNav(r, actGo, row)
return nil
}, reload)
for _, t := range []provider.ObjectType{provider.TypeSecret, provider.TypeKey, provider.TypeCertificate} {
for _, o := range m.cache.objects[containerKey(c)+"|"+string(t)].value {
r, err := ref.ForObject(o, "")
if err != nil {
continue
}
suffix := accountOf(src)
if t != provider.TypeSecret {
typ := strings.TrimSuffix(strings.ToLower(pluralLabels[t]), "s") // "key"
suffix = strings.Trim(typ+" · "+suffix, " ·")
}
add(2, row, c.Source.Kind, []string{src.Name, c.Name, o.Name}, suffix, objectKey(o), " ", func(m *Model) tea.Cmd {
m.startNav(r, actGo, row)
return nil
}, reload)
}
}
}
}
}
slices.SortStableFunc(es, func(a, b entry) int { return cmp.Or(a.depth-b.depth, strings.Compare(a.sort, b.sort)) })
for _, e := range es {
if e.depth == 0 {
sources = append(sources, e.opt)
} else {
items = append(items, e.opt)
}
}
return sources, items
}
// coverage is how much of everything search can see: sources whose
// vaults are listed, vaults whose secrets are loaded, and secrets. Lists
// that couldn't be read count as done.
type coverage struct {
sources, sourcesListed int
vaults, vaultsLoaded int
secrets, unreadable int
}
func (c coverage) complete() bool {
return c.sourcesListed == c.sources && c.vaultsLoaded == c.vaults
}
func (m *Model) searchCoverage() coverage { return m.coverageOf(nil) }
// providerFilter picks the providers a search or a load covers; nil is
// all of them.
type providerFilter func(provider.Provider) bool
func ofKind(k provider.Kind) providerFilter {
return func(p provider.Provider) bool { return p.Kind() == k }
}
func ofProvider(id string) providerFilter {
return func(p provider.Provider) bool { return p.ID() == id }
}
// coverageOf is searchCoverage for the providers in.
func (m *Model) coverageOf(in providerFilter) coverage {
var c coverage
for _, p := range m.providers {
if in != nil && !in(p) {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
row := sourceRowKey(p.ID(), src.ID)
c.sources++
e, ok := m.cache.containers[row]
if ok || m.crawl.failed[row] {
c.sourcesListed++
}
if m.crawl.failed[row] {
c.unreadable++
}
for _, v := range e.value {
c.vaults++
key := containerKey(v) + "|" + string(provider.TypeSecret)
if objs, ok := m.cache.objects[key]; ok {
c.vaultsLoaded++
c.secrets += len(objs.value)
} else if m.crawl.failed[key] {
c.vaultsLoaded++
c.unreadable++
}
}
}
}
return c
}
// searchLines says what the search covers.
func (m *Model) searchLines() []string {
c := m.searchCoverage()
switch {
case m.crawl.running:
return []string{m.st.Notice.Render(fmt.Sprintf("Loading… %d lists left; results appear as they arrive.",
len(m.crawl.queue)+m.crawl.active))}
case c.complete():
s := fmt.Sprintf("Searching everything: %s, %s, %s",
plural(c.sources, "source"), plural(c.vaults, "vault"), plural(c.secrets, "secret"))
if c.unreadable > 0 {
s += fmt.Sprintf(" (%s unreadable)", plural(c.unreadable, "list"))
}
return []string{m.st.Success.Render("✓") + " " + m.st.Notice.Render(s)}
}
s := fmt.Sprintf("Only what's loaded: %d/%d vaults", c.vaultsLoaded, c.vaults)
if c.sourcesListed < c.sources {
s += fmt.Sprintf(", %d/%d sources listed", c.sourcesListed, c.sources)
}
return []string{m.st.Warning.Render("⚠ " + s + "." + withKey(m.loadAllKey(), " %s loads the rest."))}
}
func plural(n int, word string) string {
if n == 1 {
return "1 " + word
}
return fmt.Sprintf("%d %ss", n, word)
}
// startCrawl loads every unlisted subscription's vaults and every vault's
// secrets not loaded yet.
func (m *Model) startCrawl() tea.Cmd { return m.startCrawlOf(nil) }
// startCrawlOf is startCrawl for the providers in.
func (m *Model) startCrawlOf(in providerFilter) tea.Cmd {
for _, p := range m.providers {
if in != nil && !in(p) {
continue
}
for _, src := range m.sourceResults[p.ID()].sources {
src.Provider, src.ProviderLabel = p.ID(), p.Label()
m.queueSource(src, false)
}
}
return m.crawlStart()
}
// loadSource loads (again) a subscription's vaults and their secrets.
func (m *Model) loadSource(src provider.Source) tea.Cmd {
m.queueSource(src, true)
return m.crawlStart()
}
// loadContainer loads (again) a vault's secrets.
func (m *Model) loadContainer(c provider.Container) tea.Cmd {
m.queueContainer(c, sourceRowKey(c.Source.Provider, c.Source.ID), true)
return m.crawlStart()
}
// crawlStart runs what's queued, and shows it in the popup.
func (m *Model) crawlStart() tea.Cmd {
cmd := m.crawlNext()
m.refreshSearch()
return cmd
}
// queueSource queues listing src's vaults or, with them listed lately
// and not force, loading their secrets. Lists from an earlier run (the
// cache file) are listed again, so searching brings them up to date.
func (m *Model) queueSource(src provider.Source, force bool) {
row := sourceRowKey(src.Provider, src.ID)
if e, ok := m.cache.containers[row]; ok && e.fresh(m.now()) && !force {
for _, c := range e.value {
m.queueContainer(c, row, false)
}
return
}
m.enqueue(crawlJob{src: &src, row: row, force: force}, row)
}
// queueContainer queues loading c's secrets, unless loaded lately and not
// force.
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
key := containerKey(c) + "|" + string(provider.TypeSecret)
if e, ok := m.cache.objects[key]; ok && e.fresh(m.now()) && !force {
return
}
m.enqueue(crawlJob{container: &c, row: row, force: force}, key)
}
func (m *Model) enqueue(job crawlJob, key string) {
if m.crawl.pending == nil {
m.crawl.pending, m.crawl.failed = map[string]bool{}, map[string]bool{}
}
if m.crawl.pending[key] {
return // queued or loading already
}
m.crawl.pending[key] = true
m.crawl.queue = append(m.crawl.queue, job)
}
// crawlNext starts jobs up to crawlWorkers at a time.
func (m *Model) crawlNext() tea.Cmd {
var cmds []tea.Cmd
for m.crawl.active < crawlWorkers && len(m.crawl.queue) > 0 {
job := m.crawl.queue[0]
m.crawl.queue = m.crawl.queue[1:]
m.crawl.active++
cmds = append(cmds, m.crawlCmd(job))
}
m.crawl.running = m.crawl.active > 0 || len(m.crawl.queue) > 0
return tea.Batch(cmds...)
}
func (m *Model) crawlCmd(job crawlJob) tea.Cmd {
timeout := m.timeout
if job.src != nil {
p := m.providerFor(*job.src)
src := *job.src
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
items, err := p.Containers(ctx, src)
for i := range items {
items[i].Source = src
}
return crawlMsg{job: job, containers: items, err: err}
}
}
c := *job.container
p := m.providerFor(c.Source)
return func() tea.Msg {
ctx, cancel := contextWithTimeout(timeout)
defer cancel()
items, err := p.Objects(ctx, c, provider.TypeSecret)
for i := range items {
items[i].Container = c
}
return crawlMsg{job: job, objects: items, err: err}
}
}
func (m *Model) handleCrawl(msg crawlMsg) tea.Cmd {
m.crawl.active--
key := msg.job.row
if msg.job.container != nil {
key = containerKey(*msg.job.container) + "|" + string(provider.TypeSecret)
}
delete(m.crawl.pending, key)
switch {
case msg.err != nil:
m.crawl.failed[key] = true
case msg.job.src != nil:
delete(m.crawl.failed, key)
m.cache.containers[key] = entry[[]provider.Container]{msg.containers, m.now()}
m.cacheDirty = true
for _, c := range msg.containers {
m.queueContainer(c, key, msg.job.force)
}
default:
delete(m.crawl.failed, key)
m.cache.objects[key] = entry[[]provider.Object]{msg.objects, m.now()}
m.cacheDirty = true
}
cmd := m.crawlNext()
m.refreshSearch()
return cmd
}
// sourceMark shows how much of a subscription or workspace is loaded:
// ○ nothing, ◐ some vaults, ● every vault, ◌ loading, ✗ unreadable.
func (m *Model) sourceMark(row string) string {
if m.crawl.pending[row] {
return m.st.Muted.Render("◌")
}
if m.crawl.failed[row] {
return m.st.Error.Render("✗")
}
e, ok := m.cache.containers[row]
if !ok {
return m.st.Muted.Render("○")
}
loaded, loading := 0, false
for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret)
loading = loading || m.crawl.pending[key]
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
switch {
case loading:
return m.st.Muted.Render("◌")
case loaded == len(e.value):
return m.st.Success.Render("●")
case loaded > 0:
return m.st.Warning.Render("◐")
}
return m.st.Muted.Render("○")
}
// vaultMark marks a vault in search only when its secrets can't be read
// (✗) or are loading (◌): whether a subscription is loaded says whether
// its vaults are searchable.
func (m *Model) vaultMark(c provider.Container) string {
key := containerKey(c) + "|" + string(provider.TypeSecret)
switch {
case m.crawl.pending[key]:
return m.st.Muted.Render("◌")
case m.crawl.failed[key]:
return m.st.Error.Render("✗")
}
return " "
}
// providerOf is the provider ID in a source row key.
func providerOf(row string) string {
p, _ := splitSourceRow(row)
return p
}
+468
View File
@@ -0,0 +1,468 @@
package app
import (
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
"github.com/charmbracelet/x/ansi"
)
func (d *driver) openSearch() {
d.t.Helper()
d.send(tea.KeyPressMsg{Code: 's', Mod: tea.ModCtrl})
if d.m.search == nil {
d.t.Fatalf("no search popup:\n%s", d.screen())
}
}
func ctrl(r rune) tea.KeyPressMsg { return tea.KeyPressMsg{Code: r, Mod: tea.ModCtrl} }
// searchAll picks All on the left and goes back to the results.
func (d *driver) searchAll() {
d.send(ctrl('h'))
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for d.m.search.scope().id != allSources {
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
}
d.send(ctrl('l'))
}
func TestSearchLoaded(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("j") // platform-dev: away from what's loaded
d.openSearch()
for _, want := range []string{"Sources", "Results", "All", "platform-dev"} {
if !strings.Contains(d.screen(), want) {
t.Fatalf("missing %q:\n%s", want, d.screen())
}
}
d.typeText("api-key") // on All: found at once
if !strings.Contains(d.screen(), "ctrl+s only platform-dev") {
t.Errorf("no hint to narrow to the source:\n%s", d.screen())
}
d.send(ctrl('s'))
if !strings.Contains(d.screen(), "no matches here · All searches everything") || !strings.Contains(d.screen(), "ctrl+s searches all") {
t.Errorf("no hint to search everything:\n%s", d.screen())
}
d.send(ctrl('s'))
if !strings.Contains(d.screen(), "platform-prod / kv-app-prod / api-key") {
t.Fatalf("loaded secret not found:\n%s", d.screen())
}
d.keys("enter")
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "api-key" {
t.Errorf("jumped to %s/%s/%s", src, c, s)
}
if d.m.search != nil {
t.Error("popup still open")
}
}
func TestSearchLoadsAsYouType(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.openSearch()
d.send(ctrl('s')) // only the source the cursor is on: platform-prod
// Typing loads the scope: that source, without ctrl+a.
d.typeText("smtp")
if !strings.Contains(d.screen(), "platform-prod / kv-shared-prod / smtp-password") {
t.Fatalf("not found in a vault that wasn't loaded:\n%s", d.screen())
}
if _, ok := d.m.cache.objects["Azure|sub-prod/kv-shared-prod|Secret"]; !ok {
t.Error("loaded list not in the panes' cache")
}
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; ok {
t.Error("another source loaded too")
}
// On All: everything.
d.send(ctrl('s'))
d.typeText("-")
if _, ok := d.m.cache.containers[sourceRowKey("Azure", "sub-dev")]; !ok {
t.Errorf("typing on All didn't load the other sources:\n%s", d.screen())
}
if d.m.search.input.Value() != "smtp-" {
t.Error("query lost while loading")
}
if !strings.Contains(d.screen(), "unreadable") { // kv-restricted refuses
t.Errorf("unreadable vaults not counted:\n%s", d.screen())
}
// Once: the next key doesn't ask for the list that failed again;
// ctrl+a still does, on purpose.
d.typeText("p")
if d.m.crawl.running {
t.Error("typing on reloads what failed")
}
}
func TestSearchSourceNarrowsAndLoads(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.openSearch()
d.keys("tab") // to Sources
sel := func() string { return d.m.search.scopes[d.m.search.src].id }
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for !strings.HasPrefix(sel(), "platform-dev") {
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
if d.m.search.src == len(d.m.search.scopes)-1 {
t.Fatal("platform-dev not in sources")
}
}
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "○") {
t.Errorf("unloaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
}
if len(d.m.search.shown) != 0 {
t.Errorf("results not narrowed to platform-dev: %d", len(d.m.search.shown))
}
d.send(ctrl('r'))
if !strings.Contains(ansiStrip(d.m.search.scopes[d.m.search.src].mark), "●") {
t.Errorf("loaded source mark %q", d.m.search.scopes[d.m.search.src].mark)
}
for _, o := range d.m.search.shown {
if !strings.Contains(ansiStrip(o.label), "platform-dev") {
t.Errorf("result from elsewhere: %q", ansiStrip(o.label))
}
}
if len(d.m.search.shown) == 0 {
t.Error("nothing shown after loading platform-dev")
}
if _, ok := d.m.cache.objects["Azure|sub-prod/kv-shared-prod|Secret"]; ok {
t.Error("loaded another subscription too")
}
}
func TestSearchResultsStayPut(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.openSearch()
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
ids := func() []string {
var out []string
for _, o := range d.m.search.shown {
out = append(out, o.id)
}
return out
}
before, at := ids(), d.m.search.shown[d.m.search.res].id
d.send(ctrl('a'))
after := ids()
if len(after) <= len(before) {
t.Fatalf("nothing loaded: %d → %d", len(before), len(after))
}
for i, id := range before {
if after[i] != id {
t.Errorf("row %d changed from %q to %q", i, id, after[i])
}
}
if got := d.m.search.shown[d.m.search.res].id; got != at {
t.Errorf("cursor moved from %q to %q", at, got)
}
}
func TestStarFromSearch(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.openSearch()
d.typeText("api-key")
d.send(ctrl('f'))
if !d.m.store.Node(kvKey + "/secrets/api-key").StarredFor("Azure") {
t.Errorf("ctrl+f in search didn't star:\n%s", d.screen())
}
}
func ansiStrip(s string) string { return ansi.Strip(s) }
func TestSearchStartsWhereYouAre(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("2", "j") // platform-prod / kv-shared-prod
d.openSearch()
v := d.m.search
scope := func() string { return ansiStrip(v.scopes[v.src].label) }
if scope() != "All" {
t.Errorf("opened on %q, want All", scope())
}
if v.res >= len(v.shown) || !strings.Contains(ansiStrip(v.shown[v.res].label), "kv-shared-prod") {
t.Errorf("vault not under the cursor:\n%s", d.screen())
}
d.send(ctrl('s')) // narrows to the source, and back
if !strings.Contains(scope(), "platform-prod") {
t.Errorf("ctrl+s narrowed to %q", scope())
}
d.send(ctrl('s'))
if scope() != "All" {
t.Errorf("second ctrl+s on %q, want All", scope())
}
// From Favourites: to Search, on All.
d.keys("esc", "]", "]") // stop typing; Favourites
d.send(ctrl('s'))
if v.tab != tabSearch || scope() != "All" {
t.Errorf("ctrl+s from another tab: tab %d, %q", v.tab, scope())
}
// From the Sources pane: the source.
d.keys("esc", "1", "j") // close; platform-dev
d.openSearch()
v = d.m.search
d.send(ctrl('s'))
if !strings.Contains(scope(), "platform-dev") {
t.Errorf("ctrl+s narrowed to %q", scope())
}
}
func TestSearchNarrowsToAccount(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("1", "g") // the first group header
header, _ := d.m.sources.Selected()
d.openSearch()
d.send(ctrl('s'))
v := d.m.search
if v.scope().id != accountScope+header.provider {
t.Fatalf("ctrl+s on %q narrowed to %q", header.label, v.scope().id)
}
if len(v.shown) == 0 || !strings.Contains(d.screen(), "Results in "+header.label) {
t.Errorf("no results in the account:\n%s", d.screen())
}
for _, o := range v.shown {
if providerOf(o.group) != header.provider {
t.Errorf("result from another account: %q", ansiStrip(o.label))
}
}
// The account's sources are under it, indented.
if next := v.scopes[v.src+1]; next.group == "" || providerOf(next.group) != header.provider || !strings.HasPrefix(ansiStrip(next.label), " ") {
t.Errorf("after the account: %q", ansiStrip(next.label))
}
}
func TestSearchHint(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.openSearch()
d.searchAll()
if !strings.Contains(d.screen(), "vaults loaded · ctrl+a loads everything") {
t.Errorf("no hint on All:\n%s", d.screen())
}
d.send(ctrl('a'))
if strings.Contains(d.screen(), "ctrl+a loads everything") {
t.Errorf("hint after loading everything:\n%s", d.screen())
}
}
func TestGoToTabsAndModes(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("2", "f", "j", "f") // two favourites
d.keys("F")
v := d.m.search
if v.tab != tabFavs || v.typing {
t.Fatalf("F: tab %d, typing %v", v.tab, v.typing)
}
d.keys("j") // browsing: j moves, doesn't type
if v.res != 1 || v.input.Value() != "" {
t.Errorf("j: cursor %d, input %q", v.res, v.input.Value())
}
d.keys("]")
if v.tab != tabRecent {
t.Errorf("] → tab %d, want Recent", v.tab)
}
d.keys("]", "]")
if v.tab != tabFavs {
t.Errorf("] wraps to tab %d, want Favourites", v.tab)
}
d.keys("[")
if v.tab != tabSearch || !strings.Contains(d.screen(), "Scope") {
t.Errorf("[ → tab %d, want Search:\n%s", v.tab, d.screen())
}
// g and G: top and bottom of the list with the cursor.
d.keys("G")
if v.res != len(v.shown)-1 || v.res == 0 {
t.Errorf("G on results: %d of %d", v.res, len(v.shown))
}
d.keys("g")
if v.res != 0 {
t.Errorf("g on results: %d", v.res)
}
d.keys("h", "G")
if v.src != len(v.scopes)-1 {
t.Errorf("G on scopes: %d of %d", v.src, len(v.scopes))
}
d.keys("g", "l")
if v.src != 0 {
t.Errorf("g on scopes: %d", v.src)
}
d.keys("/")
d.typeText("shared")
if !v.typing || len(v.shown) == 0 {
t.Fatalf("typing: %v, %d results", v.typing, len(v.shown))
}
d.keys("esc") // clears, back to browsing
if v.typing || v.input.Value() != "" || d.m.search == nil {
t.Errorf("esc while typing: typing %v, input %q, open %v", v.typing, v.input.Value(), d.m.search != nil)
}
d.keys("esc") // closes
if d.m.search != nil {
t.Error("esc while browsing didn't close")
}
}
func TestSearchHighlightsMatches(t *testing.T) {
t.Parallel()
// Icons: labels start with a multi-byte glyph, where byte offsets and
// characters differ.
d := newDriverOpts(t, Options{Icons: true})
d.openSearch()
d.typeText("dbpw")
if len(d.m.search.shown) == 0 {
t.Fatal("no results")
}
o := d.m.search.shown[0]
plain := []rune(ansiStrip(o.label))
var got string
for _, h := range o.hits {
got += string(plain[h])
}
if got != "dbpw" {
t.Errorf("hits %v spell %q in %q", o.hits, got, string(plain))
}
with := d.m.searchViewBox()
for i := range d.m.search.shown {
d.m.search.shown[i].hits = nil
}
if with == d.m.searchViewBox() {
t.Error("matched characters not styled")
}
}
func TestHelpBarFollowsPopup(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("F")
bar := lastLine(d.screen())
for _, want := range []string{"[ prev tab", "] next tab", "/ filter", "f star"} {
if !strings.Contains(bar, want) {
t.Errorf("browsing: help bar %q lacks %q", bar, want)
}
}
d.keys("/")
if bar := lastLine(d.screen()); !strings.Contains(bar, "ctrl+f star") || !strings.Contains(bar, "esc stop typing") {
t.Errorf("typing: help bar %q", bar)
}
}
func TestHelpFromPopups(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("F", "?")
if d.m.prompt == nil || d.m.prompt.title != "Keybindings" || d.m.prompt.options[0].label != "Go to" {
t.Fatalf("? in Go to:\n%s", d.screen())
}
if !strings.Contains(d.screen(), "switch list") { // tab: not on the help bar
t.Errorf("Go to's keys missing:\n%s", d.screen())
}
var sections []string
for _, o := range d.m.prompt.options {
if o.header {
sections = append(sections, o.label)
}
if strings.Contains(ansiStrip(o.label), "copy value") {
t.Errorf("pane key listed in Go to's help: %q", ansiStrip(o.label))
}
}
if strings.Join(sections, ",") != "Go to,While typing, after /" {
t.Errorf("sections %v", sections)
}
d.keys("esc") // back to Go to, where it was
if d.m.search == nil || d.m.search.tab != tabFavs || d.m.prompt != nil {
t.Errorf("not back in Go to: search %v, prompt %v", d.m.search != nil, d.m.prompt != nil)
}
// Picking a key in the list runs it in Go to.
d.keys("?")
d.typeText("next tab")
d.keys("enter")
if d.m.search == nil || d.m.search.tab != tabRecent {
t.Errorf("running ] from ? didn't switch Go to's tab")
}
// Menus too.
d.keys("esc", "3", "S", "?")
if d.m.prompt == nil || d.m.prompt.options[0].label != "This menu" {
t.Fatalf("? in a menu:\n%s", d.screen())
}
for _, o := range d.m.prompt.options {
if o.header && o.label != "This menu" {
t.Errorf("menu help has section %q", o.label)
}
}
d.keys("esc")
if d.m.prompt == nil || !strings.HasPrefix(d.m.prompt.title, "Share") {
t.Errorf("not back in the share menu:\n%s", d.screen())
}
}
func TestHelpRunsTypingKeysInGoTo(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("ctrl+s", "esc") // Go to on Search, browsing
before := d.m.search.results
d.keys("?")
d.typeText("ctrl+h") // "sources", a typing key
d.keys("enter")
if d.m.search == nil || !d.m.search.typing || d.m.search.results || !before {
t.Errorf("typing key from ? didn't run: typing %v, results %v", d.m.search != nil && d.m.search.typing, d.m.search.results)
}
}
func TestGoToHelpBarEndsWithHelp(t *testing.T) {
t.Parallel()
d := newDriver(t) // 100 columns: Go to's keys don't all fit
d.keys("ctrl+s", "esc")
bar := strings.TrimSpace(lastLine(d.screen()))
if !strings.HasSuffix(bar, "? help") || !strings.Contains(bar, "…") {
t.Errorf("help bar %q", bar)
}
}
func TestLoadWithEmptyResults(t *testing.T) {
t.Parallel()
c := &counting{Provider: fake.Azure(0)}
d := newDriverOpts(t, Options{}, c)
d.openSearch()
d.send(ctrl('h')) // pick platform-dev, never opened, in the scopes
for d.m.search.src > 0 {
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
}
for i := 0; !strings.Contains(ansiStrip(d.m.search.scope().label), "platform-dev"); i++ {
if i > len(d.m.search.scopes) {
var labels []string
for _, o := range d.m.search.scopes {
labels = append(labels, ansiStrip(o.label))
}
t.Fatalf("no platform-dev scope: %q (at %d, results %v)", labels, d.m.search.src, d.m.search.results)
}
d.send(tea.KeyPressMsg{Code: tea.KeyDown})
}
d.send(ctrl('l'))
if len(d.m.search.shown) != 0 || !d.m.search.results {
t.Fatalf("want empty results with the cursor there: %d shown", len(d.m.search.shown))
}
before := c.containers
d.send(ctrl('r')) // in the results, nothing selected: the source
if c.containers == before {
t.Error("ctrl+r with empty results didn't load the source")
}
if len(d.m.search.shown) == 0 {
t.Errorf("nothing shown after loading:\n%s", d.screen())
}
}
+778
View File
@@ -0,0 +1,778 @@
package app
import (
"fmt"
"slices"
"strings"
"unicode/utf8"
"charm.land/bubbles/v2/key"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"charm.land/lipgloss/v2"
"github.com/charmbracelet/x/ansi"
"git.kebcloud.no/keb/lazysecrets/internal/match"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/keys"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
// Go to is one popup for finding a place and going there. Tabs at the
// top: Search has two lists side by side, the scope on the left (All, or
// one source with how much of it is loaded: ○ ◐ ● ◌ ✗) and what's in it
// on the right; Favourites and Recent (from the store, so they work
// before anything loads) are one list each.
//
// Like a pane, it's either browsing (letters: j/k, [ and ] for tabs, f
// star, / to type) or typing a filter (ctrl+ letters). esc while typing
// clears the text and goes back to browsing; esc while browsing closes.
// F opens it on Favourites and R on Recent, browsing; search on Search,
// typing, on All. search again narrows to the source you were in, and
// back to All.
//
// The results don't move by themselves: as lists load, rows already shown
// keep their order and new ones come after them, with the cursor on the
// same entry. Starring keeps an entry where it is, its ★ showing the
// change. Only typing or picking another tab or scope re-sorts them.
type searchView struct {
input textinput.Model
typing bool // the text field has the keyboard
tab int // tabSearch, tabFavs or tabRecent
results bool // in Search, the results have the cursor (else the scopes)
scopes []option // All, then the sources (Search)
items []option // everything loaded, unfiltered
favs []option // starred, as when the popup opened
recent []option // recently used, as when the popup opened
shown []option // the tab's (or scope's) entries matching the input
src int // cursor in scopes
res int // cursor in shown
here string // the scope of the source Go to was opened from, if any
// loaded are the scopes a search has loaded since the popup opened:
// typing loads the scope once, so lists that fail aren't asked for
// again on every key.
loaded map[string]bool
}
const (
tabSearch = iota
tabFavs
tabRecent
tabCount
)
var tabNames = [tabCount]string{"Search", "Favourites", "Recent"}
const (
scopeFavs = "#favs"
scopeRecent = "#recent"
allSources = "#all"
accountScope = "#account:" // + a provider ID: an account's sources, or Databricks', or the repos
)
const goToTitle = "Go to"
// showSearch opens Go to on Search, typing, on All, with the selected
// vault under the cursor.
func (m *Model) showSearch() tea.Cmd {
cmd := m.openGoTo(tabSearch, true)
m.preselect()
return cmd
}
// showFavourites opens Go to on Favourites; showRecent on Recent. Both
// start browsing: they're short lists.
func (m *Model) showFavourites() tea.Cmd { return m.openGoTo(tabFavs, false) }
func (m *Model) showRecent() tea.Cmd { return m.openGoTo(tabRecent, false) }
func (m *Model) openGoTo(tab int, typing bool) tea.Cmd {
in := textinput.New()
in.Prompt = "/"
in.SetWidth(searchWidth(m.w) - 6) // as wide as the popup
v := &searchView{input: in, tab: tab, results: true}
v.favs, v.recent = m.storeEntries()
m.search = v
m.fillSearch(false)
m.flash = flash{}
return m.setTyping(typing)
}
// setTyping gives the keyboard to the text field, or takes it back.
func (m *Model) setTyping(on bool) tea.Cmd {
v := m.search
v.typing = on
if on {
v.input.Prompt, v.input.Placeholder = "/", "type to filter"
return v.input.Focus()
}
v.input.Blur()
v.input.Prompt = "" // the placeholder names the key
if v.input.Value() != "" {
v.input.Prompt = "/"
}
v.input.Placeholder = withKey(m.keys.GoFilter, "%s to filter")
return nil
}
// scope is what the results come from: the tab, or in Search the scope
// picked on the left.
func (v *searchView) scope() option {
switch v.tab {
case tabFavs:
return option{id: scopeFavs}
case tabRecent:
return option{id: scopeRecent}
}
return v.scopes[v.src]
}
// onSource reports whether the results come from one source.
func (v *searchView) onSource() bool { return v.tab == tabSearch && v.scope().group != "" }
// preselect starts where the user is: the selected source is the one
// search narrows to, and the selected vault or scope is under the cursor.
func (m *Model) preselect() {
v := m.search
row := m.sources.SelectedID()
account := ""
if id, ok := strings.CutPrefix(row, "group:"); ok { // a group header
account = accountScope + id
}
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == account || o.group != "" && o.group == row })
if i < 0 {
return
}
v.here = v.scopes[i].id
if m.focus == paneSources {
return
}
if c, ok := m.containers.Selected(); ok {
key := storeKey(c)
if j := slices.IndexFunc(v.shown, func(o option) bool { return o.star == key }); j >= 0 {
v.res = j
}
}
}
// fillSearch (re)builds the lists from what's loaded. keep keeps the
// order of rows already shown (lists arrived); otherwise the results are
// ranked afresh (the query, tab or scope changed).
func (m *Model) fillSearch(keep bool) {
v := m.search
atSrc, atRes := "", ""
if v.src < len(v.scopes) {
atSrc = v.scopes[v.src].id
}
if v.res < len(v.shown) {
atRes = v.shown[v.res].id
}
prev := map[string]int{}
for i, o := range v.shown {
prev[o.id] = i
}
sources, items := m.searchEntries()
all := option{label: "All", id: allSources, mark: m.coverageMark(nil)}
// Grouped like the Sources pane: each account (or Databricks, or the
// repos) is a scope too, its sources under it, so a search can stay on
// one, like the repos (local, quick), without asking Azure for every
// vault. With only one, it's the same as All.
var groups []provider.Provider
for _, p := range m.providers {
if len(m.sourceResults[p.ID()].sources) > 0 {
groups = append(groups, p)
}
}
scopes := []option{all}
if len(groups) < 2 {
scopes = append(scopes, sources...)
} else {
for _, p := range groups {
k := p.Kind()
scopes = append(scopes, option{
label: m.st.Kind(k).Render(m.icons.Prefix(k) + p.Label()),
id: accountScope + p.ID(), kind: k, mark: m.coverageMark(ofProvider(p.ID())),
})
for _, o := range sources {
if providerOf(o.group) == p.ID() {
o.label = " " + o.label
scopes = append(scopes, o)
}
}
}
}
v.scopes, v.items = scopes, items
v.src = max(0, slices.IndexFunc(v.scopes, func(o option) bool { return o.id == atSrc }))
v.shown = v.match()
if keep {
v.shown = stableOrder(v.shown, prev)
if i := slices.IndexFunc(v.shown, func(o option) bool { return o.id == atRes }); i >= 0 {
v.res = i
}
}
v.res = max(0, min(v.res, len(v.shown)-1))
}
// coverageMark is ○ ◐ ● for how much of the providers in is loaded.
func (m *Model) coverageMark(in providerFilter) string {
switch c := m.coverageOf(in); {
case c.complete():
return m.st.Success.Render("●")
case c.vaultsLoaded > 0:
return m.st.Warning.Render("◐")
}
return m.st.Muted.Render("○")
}
// scopeAccount is the provider whose sources the scope covers, if it's
// an account's.
func (v *searchView) scopeAccount() (string, bool) {
id, ok := strings.CutPrefix(v.scope().id, accountScope)
if !ok || v.tab != tabSearch {
return "", false
}
return id, true
}
// scopeFilter is what loading everything in the scope covers: an
// account's, else all of them.
func (v *searchView) scopeFilter() providerFilter {
if id, ok := v.scopeAccount(); ok {
return ofProvider(id)
}
return nil
}
// scopeName is the scope's label without colours or indent.
func scopeName(o option) string { return strings.TrimSpace(ansi.Strip(o.label)) }
// match lists the scope's entries matching the input, best first.
func (v *searchView) match() []option {
var cand []option
switch sc := v.scope(); sc.id {
case scopeFavs:
cand = v.favs
case scopeRecent:
cand = v.recent
default:
account, ofAccount := v.scopeAccount()
for _, o := range v.items {
if sc.id == allSources || ofAccount && providerOf(o.group) == account || sc.group != "" && o.group == sc.group {
cand = append(cand, o)
}
}
}
q := v.input.Value()
if q == "" {
return slices.Clone(cand)
}
items := make([]match.Item, len(cand))
for i, o := range cand {
label := ansi.Strip(o.label)
start, end := nameRange(label)
items[i] = match.Item{Text: strings.TrimSpace(label + " " + o.note), NameStart: start, NameEnd: end}
}
var out []option
for _, h := range match.Rank(match.Parse(q), items) {
o := cand[h.Index]
// Hits in the label are highlighted; ones in the note just count.
n := utf8.RuneCountInString(ansi.Strip(o.label))
o.hits = nil
for _, p := range h.Pos {
if p < n {
o.hits = append(o.hits, p)
}
}
out = append(out, o)
}
return out
}
// nameRange is where the item's own name is in a Go to label, in runes:
// the last part of "sub / vault / secret · account", which matches count
// most for.
func nameRange(label string) (int, int) {
end := len(label)
if i := strings.LastIndex(label, " · "); i >= 0 {
end = i
}
start := 0 // a source: all of it (an icon doesn't match letters)
if i := strings.LastIndex(label[:end], " / "); i >= 0 {
start = i + len(" / ")
}
return utf8.RuneCountInString(label[:start]), utf8.RuneCountInString(label[:end])
}
// refreshSearch updates an open search as lists arrive.
func (m *Model) refreshSearch() {
if m.search != nil {
m.fillSearch(true)
}
}
// stableOrder puts entries shown before (prev) in their earlier order,
// new ones after them in the order given.
func stableOrder(opts []option, prev map[string]int) []option {
out := slices.Clone(opts)
slices.SortStableFunc(out, func(a, b option) int {
ia, okA := prev[a.id]
ib, okB := prev[b.id]
switch {
case okA && okB:
return ia - ib
case okA:
return -1
case okB:
return 1
}
return 0
})
return out
}
func (m *Model) handleSearch(msg tea.KeyPressMsg) tea.Cmd {
v := m.search
km := m.keys
// The actions shared by both modes, by their keys in the mode.
type modeKeys struct{ down, up, sources, results, switchList, star, load, loadAll, sel key.Binding }
k := modeKeys{km.GoDown, km.GoUp, km.GoSources, km.GoResults, km.GoSwitch, km.GoStar, km.GoLoad, km.GoLoadAll, km.GoSelect}
if key.Matches(msg, km.Search) {
return m.narrow()
}
if v.typing {
k = modeKeys{km.PopupDown, km.PopupUp, km.SearchSources, km.SearchResults, km.SearchSwitch, km.PopupStar, km.SearchLoad, km.SearchLoadAll, km.PopupSelect}
if key.Matches(msg, km.PopupClose) {
// Back to browsing, the filter cleared.
if v.input.Value() != "" {
v.input.SetValue("")
v.res = 0
m.fillSearch(false)
}
return m.setTyping(false)
}
} else {
switch {
case key.Matches(msg, km.GoClose):
m.search = nil
return nil
case key.Matches(msg, km.GoFilter):
return m.setTyping(true)
case key.Matches(msg, km.GoTop), key.Matches(msg, km.GoBottom):
n := len(v.shown)
if !v.results {
n = len(v.scopes)
}
i := 0
if key.Matches(msg, km.GoBottom) {
i = max(0, n-1)
}
if v.results {
v.res = i
} else if v.src != i {
v.src, v.res = i, 0
m.fillSearch(false)
}
return nil
case key.Matches(msg, km.Help):
return m.showKeybindingsFor(keys.GoTo)
case key.Matches(msg, km.GoNextTab), key.Matches(msg, km.GoPrevTab):
d := 1
if key.Matches(msg, km.GoPrevTab) {
d = tabCount - 1
}
v.tab = (v.tab + d) % tabCount
v.res, v.results = 0, true
m.fillSearch(false)
return nil
}
}
inSearch := v.tab == tabSearch
moveSrc := func(d int) {
v.src = max(0, min(v.src+d, len(v.scopes)-1))
v.res = 0
m.fillSearch(false)
}
switch {
case inSearch && key.Matches(msg, k.switchList):
v.results = !v.results
return nil
case inSearch && key.Matches(msg, k.sources):
v.results = false
return nil
case inSearch && key.Matches(msg, k.results):
v.results = true
return nil
case key.Matches(msg, k.up):
if v.results {
v.res = max(0, v.res-1)
} else {
moveSrc(-1)
}
return nil
case key.Matches(msg, k.down):
if v.results {
v.res = max(0, min(v.res+1, len(v.shown)-1))
} else {
moveSrc(1)
}
return nil
case key.Matches(msg, k.sel):
switch {
case v.results && v.res < len(v.shown):
m.search = nil
return v.shown[v.res].run(m)
case !v.results && v.onSource():
m.search = nil
return v.scope().run(m)
case !v.results:
v.results = true // All: on to the results
}
return nil
case inSearch && key.Matches(msg, k.load):
if o, ok := v.selected(); ok && o.reload != nil {
return o.reload(m) // the selected source, or a result's vault
}
// Nothing selected (no results yet, or All): the scope.
if v.onSource() {
return v.scope().reload(m)
}
return m.startCrawlOf(v.scopeFilter())
case inSearch && key.Matches(msg, k.loadAll):
return m.startCrawlOf(v.scopeFilter())
case key.Matches(msg, k.star):
if o, ok := v.selected(); ok && o.star != "" {
m.toggleStarKey(o.star, o.starAccount)
// The entry stays where it is, its ★ showing the change.
on := m.store.Node(o.star).StarredFor(o.starAccount)
for _, list := range [][]option{v.favs, v.recent} {
for i := range list {
if list[i].star == o.star && list[i].starAccount == o.starAccount {
list[i].starred = on
}
}
}
m.fillSearch(true)
}
return nil
}
if !v.typing {
return nil
}
before := v.input.Value()
in, cmd := v.input.Update(msg)
v.input = in
if v.input.Value() != before {
v.res = 0
m.fillSearch(false)
return tea.Batch(cmd, m.searchLoads())
}
return cmd
}
// narrow switches Search between All and the source Go to was opened
// from; from another tab, or another scope, it goes to Search on All.
func (m *Model) narrow() tea.Cmd {
v := m.search
src := 0
if v.tab == tabSearch && v.scope().id == allSources {
src = slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here })
if src < 0 {
return nil
}
}
v.tab, v.src, v.res, v.results = tabSearch, src, 0, true
m.fillSearch(false)
return m.searchLoads()
}
// searchLoads loads what a search in the Search tab covers, like the
// load-all key: everything on All, else the source picked. Only what
// isn't loaded yet, once a popup; repos load by themselves anyway.
func (m *Model) searchLoads() tea.Cmd {
v := m.search
if v.tab != tabSearch || strings.TrimSpace(v.input.Value()) == "" {
return nil
}
sc := v.scope()
if v.loaded[sc.id] {
return nil
}
if v.loaded == nil {
v.loaded = map[string]bool{}
}
v.loaded[sc.id] = true
if _, ok := v.scopeAccount(); ok {
return m.startCrawlOf(v.scopeFilter())
}
if !v.onSource() {
return m.startCrawl()
}
providerID, sourceID := splitSourceRow(sc.group)
for _, src := range m.sourceResults[providerID].sources {
if src.ID == sourceID {
src.Provider, src.ProviderLabel = providerID, m.byID[providerID].Label()
m.queueSource(src, false)
return m.crawlStart()
}
}
return nil
}
// selected is the entry under the cursor in the focused list (a scope
// that isn't one source is none).
func (v *searchView) selected() (option, bool) {
if v.results {
if v.res < len(v.shown) {
return v.shown[v.res], true
}
return option{}, false
}
if v.onSource() {
return v.scope(), true
}
return option{}, false
}
// toggleStarKey stars or unstars a store record for an account and
// re-sorts the panes.
func (m *Model) toggleStarKey(key, account string) {
if err := m.store.Update(key, func(n *store.Node) { n.ToggleStar(account) }); err != nil {
m.flash = flash{text: "saving: " + err.Error(), isErr: true}
return
}
m.resortStarred()
}
// searchHint tops the results when what they come from isn't all
// loaded, saying how to load it.
func (m Model) searchHint() string {
load, narrow := m.loadHint(), m.narrowHint()
switch {
case narrow == "":
return load
case load != "":
narrow = " · " + narrow
}
return load + m.st.Muted.Render(narrow)
}
// narrowHint names the key for the other of All and the source Go to was
// opened from.
func (m Model) narrowHint() string {
v := m.search
if v.tab != tabSearch || m.crawl.running {
return ""
}
if v.scope().id == v.here && v.here != "" {
return withKey(m.keys.Search, "%s searches all")
}
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.id == v.here })
if i < 0 || v.scope().id != allSources {
return ""
}
return withKey(m.keys.Search, "%s only "+scopeName(v.scopes[i]))
}
// loadHint says how much of the scope is loaded, and the key to load it.
func (m Model) loadHint() string {
v := m.search
if m.crawl.running {
return m.st.Muted.Render(fmt.Sprintf("◌ loading… %d lists left", len(m.crawl.queue)+m.crawl.active))
}
if v.tab != tabSearch {
return "" // from the store: nothing to load
}
if _, ok := v.scopeAccount(); ok || v.scope().id == allSources {
c := m.coverageOf(v.scopeFilter())
if c.complete() {
return ""
}
return m.st.Warning.Render(fmt.Sprintf("⚠ %d/%d vaults loaded", c.vaultsLoaded, c.vaults) + withKey(m.loadAllKey(), " · %s loads everything"))
}
src := v.scopes[v.src]
e, listed := m.cache.containers[src.group]
name := ansi.Strip(src.label)
if !listed {
if m.crawl.failed[src.group] {
return ""
}
return m.st.Warning.Render("⚠ not loaded" + withKey(m.loadKey(), " · %s loads "+name))
}
loaded := 0
for _, c := range e.value {
key := containerKey(c) + "|" + string(provider.TypeSecret)
if _, ok := m.cache.objects[key]; ok || m.crawl.failed[key] {
loaded++
}
}
if loaded == len(e.value) {
return ""
}
return m.st.Warning.Render(fmt.Sprintf("⚠ %d/%d vaults loaded", loaded, len(e.value)) + withKey(m.loadKey(), " · %s loads "+name))
}
// loadKey and loadAllKey are the keys for loading in the current mode.
func (m Model) loadKey() key.Binding {
if m.search.typing {
return m.keys.SearchLoad
}
return m.keys.GoLoad
}
func (m Model) loadAllKey() key.Binding {
if m.search.typing {
return m.keys.SearchLoadAll
}
return m.keys.GoLoadAll
}
// searchWidth is most of the screen: paths are long.
func searchWidth(screen int) int {
return max(40, min(screen-4, screen*9/10))
}
// searchViewBox draws the popup.
func (m Model) searchViewBox() string {
v := m.search
focused := !m.termBlurred
w := searchWidth(m.w)
listH := max(5, min(m.h-10, 14))
srcW := 10
for _, o := range v.scopes {
srcW = max(srcW, ansi.StringWidth(ansi.Strip(o.label))+ansi.StringWidth(o.note)+12) // marks, gap, borders
}
srcW = min(srcW, (w-4)*2/5)
resW := w - 4 - srcW
if v.tab != tabSearch {
resW = w - 4 // one list
}
row := func(o option, width int, cursor, active bool) string {
mark := o.mark
if mark == "" {
mark = " "
}
star := " "
if o.starred {
star = m.st.Favourite.Render("★ ")
}
label := panes.MarkMatches(o.label, o.hits, m.st.Match)
text := withNote(m.st, mark+" "+star+label, o.note, width-4) // borders, cursor column
if !cursor {
return " " + text
}
sel := m.st.Selected
if !active || !focused {
sel = m.st.SelectedBlurred
}
return " " + panes.Highlight("▸"+text, sel, width-3) // inside the borders
}
window := func(n, cursor, h int) (int, int) {
first := max(0, min(cursor-h/2, n-h))
return first, min(n, first+h)
}
inner := listH - 2
// The picked source is a choice that stays, like a tab: it keeps the
// full highlight while the results have the cursor.
var left []string
first, last := window(len(v.scopes), v.src, inner)
for i := first; i < last; i++ {
left = append(left, row(v.scopes[i], srcW, i == v.src, true))
}
var right []string
resRows := inner
if hint := m.searchHint(); hint != "" {
right = append(right, " "+hint)
resRows--
}
first, last = window(len(v.shown), v.res, resRows)
for i := first; i < last; i++ {
right = append(right, row(v.shown[i], resW, i == v.res, v.results))
}
if len(v.shown) == 0 {
msg := "no matches"
switch {
case v.input.Value() == "" && v.scope().id == scopeFavs:
msg = "nothing starred yet" + withKey(m.keys.Favourite, ": %s in the panes stars the selected item")
case v.input.Value() == "" && v.scope().id == scopeRecent:
msg = "nothing used yet"
case v.input.Value() == "":
msg = "nothing loaded here yet"
case v.tab == tabSearch && v.scope().id != allSources:
msg += " here · All searches everything"
case v.tab != tabSearch:
msg += withKey(m.keys.GoNextTab, " here · Search (%s) looks in everything loaded")
}
right = append(right, " "+m.st.Muted.Render(msg))
}
counter := func(i, n int) string {
if n == 0 {
return ""
}
return fmt.Sprintf("%d of %d", i+1, n)
}
title := func(s string, active bool) string { return panes.TitleStyle(m.st, active && focused).Render(s) }
resultsTitle := map[string]string{scopeFavs: "Favourites", scopeRecent: "Recent", allSources: "Results in all sources"}[v.scope().id]
if _, ok := v.scopeAccount(); ok || v.onSource() {
resultsTitle = "Results in " + scopeName(v.scope())
}
var lists string
if v.tab == tabSearch {
lists = lipgloss.JoinHorizontal(lipgloss.Top,
panes.BoxCounted(m.st, title("Scope", !v.results), "", counter(v.src, len(v.scopes)), left, srcW, listH, !v.results && focused),
panes.BoxCounted(m.st, title(resultsTitle, v.results), "", counter(v.res, len(v.shown)), right, resW, listH, v.results && focused),
)
} else {
lists = panes.BoxCounted(m.st, title(resultsTitle, true), "", counter(v.res, len(v.shown)), right, resW, listH, focused)
}
body := []string{" " + v.input.View(), ""}
for _, l := range strings.Split(lists, "\n") {
body = append(body, " "+l)
}
body = append(body, "")
if v.tab == tabSearch {
for _, l := range m.searchLines() {
body = append(body, " "+l)
}
} else {
body = append(body, "") // the same height on every tab
}
km := m.keys
var foot string
switch {
case v.typing:
hints := []keyHint{move(km.PopupUp, km.PopupDown)}
if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.SearchSources, km.SearchResults), "switch list"}, hint(km.SearchLoad, "load"))
}
foot = footer(append(hints, hint(km.PopupStar, "star"), hint(km.PopupSelect, "go"), hint(km.PopupClose, "stop typing"))...)
default:
hints := []keyHint{move(km.GoUp, km.GoDown), {joinKeys(km.GoPrevTab, km.GoNextTab), "tab"}}
if v.tab == tabSearch {
hints = append(hints, keyHint{joinKeys(km.GoSources, km.GoResults), "list"}, hint(km.GoLoad, "load"), hint(km.GoLoadAll, "load all"))
}
foot = footer(append(hints, hint(km.GoFilter, "filter"), hint(km.GoStar, "star"), hint(km.GoSelect, "go"), hint(km.GoClose, "close"))...)
}
// Tabs in the title, the current one underlined, like the panes.
ts := panes.TitleStyle(m.st, focused)
head := ts.Render(goToTitle + " ")
for i, name := range tabNames {
if i > 0 {
head += ts.Render(" - ")
}
if i == v.tab {
head += ts.Underline(true).Render(name)
} else {
head += m.st.Muted.Render(name)
}
}
return panes.Box(m.st, head, foot, body, w, len(body)+2, focused)
}
+288
View File
@@ -0,0 +1,288 @@
package app
import (
"cmp"
"fmt"
"slices"
"strings"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
)
// The Sources pane either groups sources under a foldable header per
// provider, or lists them flat with the provider on each row.
type rowKind int
const (
rowGroup rowKind = iota
rowSource
rowLinks // vaults opened from links that no account could locate
)
type sourceRow struct {
kind rowKind
group provider.Kind // colour and icon
provider string // provider ID: one group per instance (account)
label string // group: provider label, e.g. "Azure · keb"
account string // flat source: account part of the label, if any
status string // group: source count, "…" while loading, "!" on error
collapsed bool // group only
flat bool // source only: shown without a group header
top bool // source only: the repo lazysecrets started in, on top
source provider.Source
}
// sourceRowKey joins a provider ID and a source ID. "|" doesn't occur in
// either; provider IDs contain "/" (Azure:user/tenant).
func sourceRowKey(providerID, sourceID string) string { return providerID + "|" + sourceID }
func splitSourceRow(row string) (providerID, sourceID string) {
providerID, sourceID, _ = strings.Cut(row, "|")
return providerID, sourceID
}
func sourceRowID(r sourceRow) string {
switch r.kind {
case rowGroup:
return "group:" + r.provider
case rowLinks:
return linksRowID
}
return sourceRowKey(r.provider, r.source.ID)
}
func newSourcesList(iconSet icons.Set, mem *store.Store) panes.List[sourceRow] {
return panes.NewList("[1] Sources", panes.Item[sourceRow]{
ID: sourceRowID,
// Headers have no filter text, so filtering shows just sources.
Filter: func(r sourceRow) string {
if r.kind != rowSource {
return ""
}
return r.source.Name
},
// So "data" finds Databricks workspaces and an account name finds
// its subscriptions.
FilterExtra: func(r sourceRow) string {
if r.kind != rowSource {
return ""
}
return cmp.Or(r.source.ProviderLabel, string(r.group)) + " " + mem.Node(sourceKey(r.source)).Note
},
Render: func(st styles.Styles, r sourceRow, width int) string {
node := mem.Node(sourceKey(r.source))
kind := st.Kind(r.group)
starred := ""
if r.kind == rowSource && node.StarredFor(r.source.Provider) {
starred = st.Favourite.Render(star)
}
switch {
case r.kind == rowLinks:
return st.Notice.Render("↗ Opened links") + " " + st.Muted.Render(fmt.Sprintf("(%s)", r.status))
case r.kind == rowSource && r.top:
return withNote(st, kind.Render(iconSet.Prefix(r.group)+"Repo · ")+starred+kind.Render(r.source.Name), node.Note, width)
case r.kind == rowSource && r.flat:
line := kind.Render(iconSet.Prefix(r.group)+fmt.Sprintf("%-10s", r.group)) + " " + starred + r.source.Name
if r.account != "" {
line += st.Muted.Render(" · " + r.account)
}
return withNote(st, line+here(st, r.source), node.Note, width)
case r.kind == rowSource:
// The provider's colour, so long groups stay recognisable.
return withNote(st, " "+starred+kind.Render(r.source.Name), node.Note, width)
}
arrow := "▾"
if r.collapsed {
arrow = "▸"
}
status := st.Muted.Render(fmt.Sprintf("(%s)", r.status))
if r.status == "!" {
status = st.Error.Render("(!)")
}
return kind.Render(arrow+" "+iconSet.Prefix(r.group)+r.label) + " " + status
},
})
}
// rebuildSources lays out the Sources pane from the per-provider results.
func (m *Model) rebuildSources() {
// While filtering, headers drop out, so rows show their provider and
// account like the flat view (and folded groups are searched too).
flat := m.flatSources || m.sources.HasFilter() || m.sourcesFavs
m.sourcesFiltered = m.sources.HasFilter()
// Tabs: everything, or just the starred ones ([ and ]).
m.sources.Title, m.sources.Tabs = "[1]", []string{"Sources", "Favourites"}
m.sources.Tab, m.sources.Empty = 0, "no sources found"
if m.sourcesFavs {
m.sources.Tab, m.sources.Empty = 1, "nothing starred"+withKey(m.keys.Favourite, ": %s on a subscription or workspace stars it")
}
var rows []sourceRow
if n := len(m.linked); n > 0 && !m.sourcesFavs {
rows = append(rows, sourceRow{kind: rowLinks, status: fmt.Sprint(n)})
}
// The repo lazysecrets started in gets a row of its own on top, so it
// opens on it; flat, it sorts first instead.
if src, ok := m.currentSource(); ok && !flat {
rows = append(rows, sourceRow{kind: rowSource, group: src.Kind, provider: src.Provider, top: true, source: src})
}
for _, p := range m.providers {
kind, id := p.Kind(), p.ID()
account, _ := strings.CutPrefix(p.Label(), string(kind)+" · ")
if account == p.Label() {
account = ""
}
r, done := m.sourceResults[id]
sources := r.sources
if !flat {
sources = slices.DeleteFunc(slices.Clone(sources), func(s provider.Source) bool { return s.Here })
}
if kind == provider.KindRepo && !(done && (r.err != nil || len(sources) > 0)) {
continue // repos show once some are found to have secret files
}
status := "…"
switch {
case done && r.err != nil:
status = "!"
case done:
status = fmt.Sprint(len(sources))
}
if !flat {
rows = append(rows, sourceRow{
kind: rowGroup, group: kind, provider: id, label: p.Label(),
status: status, collapsed: m.collapsed[id],
})
if m.collapsed[id] {
continue
}
}
for _, s := range m.starredSourcesFirst(sources) {
if m.sourcesFavs && !m.store.Node(sourceKey(s)).StarredFor(s.Provider) {
continue
}
rows = append(rows, sourceRow{
kind: rowSource, group: kind, provider: id, account: account,
flat: flat, source: s,
})
}
}
if flat {
// Flat: starred sources go to the very top, across providers.
slices.SortStableFunc(rows, func(a, b sourceRow) int {
return starRank(m.store, a) - starRank(m.store, b)
})
}
m.sources.SetItems(rows)
// Until the user moves, keep the cursor on the first source in
// provider order rather than a header, so the other panes fill in on
// their own. Providers answer in any order, so this can change as
// results arrive.
if !m.sourcesTouched {
m.sources.Top() // scroll back up so the first header shows
for _, r := range rows {
if r.kind == rowSource {
m.sources.Select(sourceRowID(r))
break
}
}
}
}
// currentSource is the source lazysecrets started in: from its
// provider's list once that's in, else as found on its own beforehand.
func (m Model) currentSource() (provider.Source, bool) {
for _, p := range m.providers {
r, done := m.sourceResults[p.ID()]
if !done {
if m.current != nil && m.current.Provider == p.ID() {
return *m.current, true
}
continue
}
for _, src := range r.sources {
if src.Here {
return src, true
}
}
}
return provider.Source{}, false
}
// starredSourcesFirst moves starred sources to the front, keeping the
// order otherwise. It doesn't modify sources.
func (m Model) starredSourcesFirst(sources []provider.Source) []provider.Source {
out := slices.Clone(sources)
rank := func(s provider.Source) int {
return boolRank(m.store.Node(sourceKey(s)).StarredFor(s.Provider))
}
slices.SortStableFunc(out, func(a, b provider.Source) int { return rank(a) - rank(b) })
return out
}
// starRank orders the Favourites row, then starred sources, then the rest.
func starRank(mem *store.Store, r sourceRow) int {
switch {
case r.kind == rowLinks:
return 0
case r.kind == rowSource && r.source.Here:
return 1
case r.kind == rowSource && mem.Node(sourceKey(r.source)).StarredFor(r.source.Provider):
return 2
}
return 3
}
func boolRank(starred bool) int {
if starred {
return 0
}
return 1
}
// selectedSource is the source under the cursor; false on a header.
func (m Model) selectedSource() (provider.Source, bool) {
r, ok := m.sources.Selected()
if !ok || r.kind != rowSource {
return provider.Source{}, false
}
return r.source, true
}
// onLinks reports whether the cursor is on the Opened links row.
func (m Model) onLinks() bool {
return m.sources.SelectedID() == linksRowID
}
// toggleSourcesView switches between grouped and flat, keeping the cursor
// on the same source.
func (m *Model) toggleSourcesView() {
m.flatSources = !m.flatSources
m.rebuildSources()
}
// toggleGroup folds or unfolds the group under the cursor. It reports
// whether the cursor was on a group header.
func (m *Model) toggleGroup() bool {
r, ok := m.sources.Selected()
if !ok || r.kind != rowGroup {
return false
}
m.collapsed[r.provider] = !m.collapsed[r.provider]
m.rebuildSources()
m.sources.Select(sourceRowID(r))
return true
}
// here labels the source lazysecrets was started in, in the flat list
// (grouped, it has the "Repo · name" row on top).
func here(st styles.Styles, s provider.Source) string {
if s.Here {
return st.Muted.Render(" · here")
}
return ""
}
+200
View File
@@ -0,0 +1,200 @@
package app
import (
"context"
"slices"
"strings"
"testing"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/provider/fake"
)
func TestGroupedSources(t *testing.T) {
t.Parallel()
d := newDriver(t)
screen := d.screen()
for _, want := range []string{"▾ Azure (2)", " platform-prod", "▾ Databricks (2)", " prod"} {
if !strings.Contains(screen, want) {
t.Errorf("missing %q:\n%s", want, screen)
}
}
if src, _, _ := d.selected(); src != "platform-prod" {
t.Errorf("startup cursor on %q, want the first source", src)
}
}
func TestFoldGroup(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("k", "enter") // Azure header: fold
if !strings.Contains(d.screen(), "▸ Azure (2)") || strings.Contains(d.screen(), "platform-prod") {
t.Fatalf("Azure not folded:\n%s", d.screen())
}
if d.m.focus != paneSources {
t.Error("enter on a header moved focus")
}
d.keys("space") // unfold
if !strings.Contains(d.screen(), "platform-prod") {
t.Fatalf("Azure not unfolded:\n%s", d.screen())
}
// enter on a source still drills in.
d.keys("j", "enter")
if d.m.focus != paneContainers {
t.Error("enter on a source didn't move to vaults")
}
}
func TestToggleFlatKeepsSelection(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("j", "j", "j") // Databricks prod
d.keys("`")
if !d.m.flatSources || strings.Contains(d.screen(), "▾") {
t.Fatalf("not flat:\n%s", d.screen())
}
if !strings.Contains(d.screen(), "Databricks prod") {
t.Errorf("flat rows should show the provider:\n%s", d.screen())
}
if src, _, _ := d.selected(); src != "prod" {
t.Errorf("selection lost: %q", src)
}
d.keys("`")
if src, _, _ := d.selected(); d.m.flatSources || src != "prod" {
t.Errorf("back to grouped: flat=%v selected=%q", d.m.flatSources, src)
}
}
func TestFlatSourcesOption(t *testing.T) {
t.Parallel()
d := newDriverOpts(t, Options{FlatSources: true})
if strings.Contains(d.screen(), "▾") || d.m.sources.Len() != 4 {
t.Errorf("FlatSources ignored:\n%s", d.screen())
}
}
func TestGroupHeaderShowsProviderError(t *testing.T) {
t.Parallel()
d := newDriver(t, failingSources{fake.Azure(0)}, fake.Databricks(0))
if !strings.Contains(d.screen(), "▾ Azure (!)") {
t.Errorf("no error marker:\n%s", d.screen())
}
if src, _, _ := d.selected(); src != "prod" {
t.Errorf("cursor should skip to the first working source, got %q", src)
}
}
// slowSources delivers Azure's sources after Databricks', like the real
// Azure provider (network) versus Databricks (a local file).
func TestStartupPrefersFirstProviderWhenItAnswersLast(t *testing.T) {
t.Parallel()
m := New([]provider.Provider{fake.Azure(0), fake.Databricks(0)}, Options{Clipboard: &fakeClipboard{}})
d := &driver{t: t, m: m}
d.send(tea.WindowSizeMsg{Width: 100, Height: 30})
d.send(sourcesMsg{gen: m.sourcesGen, provider: "Databricks", sources: mustSources(t, fake.Databricks(0))})
if src, _, _ := d.selected(); src != "prod" {
t.Fatalf("before Azure answers: %q", src)
}
d.send(sourcesMsg{gen: m.sourcesGen, provider: "Azure", sources: mustSources(t, fake.Azure(0))})
if src, _, _ := d.selected(); src != "platform-prod" {
t.Errorf("cursor not moved to the first Azure source: %q", src)
}
// Once the user has moved, late results don't move the cursor.
d2 := &driver{t: t, m: New([]provider.Provider{fake.Azure(0), fake.Databricks(0)}, Options{Clipboard: &fakeClipboard{}})}
d2.send(tea.WindowSizeMsg{Width: 100, Height: 30})
d2.send(sourcesMsg{gen: d2.m.sourcesGen, provider: "Databricks", sources: mustSources(t, fake.Databricks(0))})
d2.keys("j") // dev
d2.send(sourcesMsg{gen: d2.m.sourcesGen, provider: "Azure", sources: mustSources(t, fake.Azure(0))})
if src, _, _ := d2.selected(); src != "dev" {
t.Errorf("cursor moved after the user navigated: %q", src)
}
}
func mustSources(t *testing.T, p provider.Provider) []provider.Source {
t.Helper()
s, err := p.Sources(context.Background())
if err != nil {
t.Fatal(err)
}
return s
}
func TestTwoAccounts(t *testing.T) {
t.Parallel()
alice := &counting{Provider: fake.Azure(0).As("Azure:alice", "Azure · alice")}
bob := &counting{Provider: fake.Azure(0).As("Azure:bob", "Azure · bob")}
d := newDriver(t, alice, bob)
screen := d.screen()
if !strings.Contains(screen, "▾ Azure · alice (2)") || !strings.Contains(screen, "▾ Azure · bob (2)") {
t.Fatalf("one group per account expected:\n%s", screen)
}
// Both accounts see platform-prod; bob's is a different row, and
// browsing it goes through bob's provider.
d.keys("j", "j", "j") // alice dev, bob header, bob prod
if src, c, s := d.selected(); src != "platform-prod" || c != "kv-app-prod" || s != "db-password" {
t.Fatalf("bob's platform-prod: %s/%s/%s", src, c, s)
}
c, _ := d.m.containers.Selected()
if c.Source.Provider != "Azure:bob" || d.m.containers.SelectedID() != "Azure:bob|"+c.ID {
t.Errorf("vault not tied to bob: provider %q, key %q", c.Source.Provider, d.m.containers.SelectedID())
}
if bob.objects != 1 {
t.Errorf("bob listed objects %d times, want 1", bob.objects)
}
before := alice.objects
d.keys("space") // reveal goes through bob too
if alice.values != 0 || bob.values != 1 || alice.objects != before {
t.Errorf("reveal routed wrong: alice values %d, bob values %d", alice.values, bob.values)
}
if !strings.Contains(d.screen(), "source Azure · bob / platform-prod") {
t.Errorf("detail should name the account:\n%s", d.screen())
}
// Flat: the account is shown on each row.
d.keys("`")
if !strings.Contains(d.screen(), "platform-prod · bob") {
t.Errorf("flat rows should name the account:\n%s", d.screen())
}
}
func TestFilterSourcesByProvider(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("/")
d.typeText("databr")
d.m.sources.Top()
var names []string
for range d.m.sources.Len() {
r, _ := d.m.sources.Selected()
names = append(names, r.source.Name)
d.m.sources.Move(1)
}
slices.Sort(names)
if strings.Join(names, ",") != "dev,prod" {
t.Errorf("\"databr\" matched %v, want the Databricks workspaces", names)
}
}
func TestFilteringShowsAccounts(t *testing.T) {
t.Parallel()
alice := fake.Azure(0).As("Azure:alice", "Azure · alice")
bob := fake.Azure(0).As("Azure:bob", "Azure · bob")
d := newDriver(t, alice, bob)
d.keys("k", "enter") // fold alice: filtering still finds her sources
d.keys("/")
d.typeText("dev")
screen := d.screen()
if !strings.Contains(screen, "platform-dev · ali") || !strings.Contains(screen, "platform-dev · bob") {
t.Errorf("filtered rows should name the account:\n%s", screen)
}
d.keys("esc") // clear: back to groups
if !strings.Contains(d.screen(), "▸ Azure · alice") || strings.Contains(d.screen(), "platform-dev · bob") {
t.Errorf("not grouped again after clearing:\n%s", d.screen())
}
}
+204
View File
@@ -0,0 +1,204 @@
package app
import (
"errors"
"fmt"
"strings"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// The status pane, like lazygit's: a line on top of the left panes with
// the version, a newer release and how the accounts are doing. Focused
// (0), the right pane has the rest: updates, each account, the files
// lazysecrets uses, links, and the changelog.
// logo heads the status pane's rows.
var logo = []string{
` _ _`,
`| | __ _ _____ _ ___ ___ ___ _ __ ___| |_ ___`,
`| |/ _` + "`" + ` |_ / | | / __|/ _ \/ __| '__/ _ \ __/ __|`,
`| | (_| |/ /| |_| \__ \ __/ (__| | | __/ |_\__ \`,
`|_|\__,_/___|\__, |___/\___|\___|_| \___|\__|___/`,
` |___/`,
}
// statusH is the status pane's height: one line in a box.
const statusH = 3
// statusLine is what the status pane says while it isn't focused.
func (m Model) statusLine() string {
var parts []string
switch {
case m.updated != "":
parts = append(parts, m.st.Notice.Render("→ "+m.updated+" (restart)"))
case m.newer != nil:
parts = append(parts, m.st.Notice.Render("↑ "+m.newer.Tag+withKey(m.keys.Update, " %s")))
}
accounts, failing, repos := 0, 0, 0
for _, p := range m.providers {
if p.Kind() == provider.KindRepo {
repos += len(m.sourceResults[p.ID()].sources) // those with secret files
continue
}
accounts++
if r, ok := m.sourceResults[p.ID()]; ok && r.err != nil {
failing++
}
}
if n := len(m.expiringItems()); n > 0 {
parts = append(parts, m.st.Warning.Render(fmt.Sprintf("⚠ %d expiring", n)))
}
if accounts > 0 {
s := plural(accounts, "account")
if failing > 0 {
s += " · " + m.st.Error.Render(fmt.Sprintf("%d need sign-in", failing))
}
parts = append(parts, s)
}
if repos > 0 {
parts = append(parts, plural(repos, "repo"))
}
return " " + strings.Join(parts, m.st.Muted.Render(" · "))
}
func (m Model) statusView() string {
title := "[0] lazysecrets"
if v := m.versionLabel(); v != "" {
title += " " + v
}
return panes.Box(m.st, panes.TitleStyle(m.st, m.active(paneStatus)).Render(title), "",
[]string{m.statusLine()}, m.statusW, statusH, m.active(paneStatus))
}
// statusFields are the status pane's rows on the right.
func (m Model) statusFields() []panes.Field {
var out []panes.Field
add := func(f panes.Field) {
if f.Value != "" {
out = append(out, f)
}
}
// A release's rows open its release page.
version := panes.Field{Label: "version", Value: cmpOr(m.versionLabel(), "dev")}
if strings.HasPrefix(version.Value, "v") {
version.URL = update.DefaultRepo + "/releases/tag/" + version.Value
}
add(version)
upd := panes.Field{Label: "update", Value: m.updateStatus(), Info: m.newer == nil}
if m.newer != nil {
upd.URL = update.DefaultRepo + "/releases/tag/" + m.newer.Tag
}
add(upd)
add(panes.Field{Label: "license", Value: "GPL-3.0-or-later · © 2026 Karl-Einar Bergstrøm", Info: true})
for i, p := range m.providers {
f := panes.Field{Value: p.Label() + " · " + m.accountStatus(p), Provider: p.ID()}
if i == 0 {
f.Label = "accounts"
}
var auth *provider.AuthError
f.Err = errors.As(m.sourceResults[p.ID()].err, &auth)
add(f)
}
out = append(out, m.expiringFields()...)
out = append(out, m.errorFields()...)
for i, path := range m.configFiles {
label := ""
if i == 0 {
label = "config"
}
add(panes.Field{Label: label, Value: path, Path: path})
}
add(panes.Field{Label: "state", Value: m.statePath, Path: m.statePath})
add(panes.Field{Label: "cache", Value: m.cachePath, Path: m.cachePath})
// Short, without https://; y copies and o opens the whole URL.
repo := strings.TrimPrefix(update.DefaultRepo, "https://")
add(panes.Field{Label: "docs", Value: repo + "/…/docs", URL: update.DefaultRepo + "/src/branch/main/docs"})
add(panes.Field{Label: "source", Value: repo, URL: update.DefaultRepo})
// Issues are on the GitHub mirror.
github := strings.TrimPrefix(update.GitHubRepo, "https://")
add(panes.Field{Label: "issues", Value: github + "/issues", URL: update.GitHubRepo + "/issues"})
add(panes.Field{Label: "releases", Value: repo + "/releases", URL: update.DefaultRepo + "/releases"})
add(panes.Field{Label: "changelog", Value: repo + "/…/CHANGELOG.md", URL: update.DefaultRepo + "/src/branch/main/CHANGELOG.md"})
return append(out, m.changelog...)
}
// updateStatus says whether a newer release is out, and when it was
// last checked.
func (m Model) updateStatus() string {
switch {
case m.updated != "":
return "updated to " + m.updated + ": restart to use it"
case m.newer != nil:
return m.newer.Tag + " is out" + withKey(m.keys.Update, " · %s updates")
}
at := m.store.UpdateCheck().At
if at.IsZero() {
return ""
}
return "up to date · checked " + ago(m.now().Sub(at))
}
// accountStatus says whether an account's sources loaded.
func (m Model) accountStatus(p provider.Provider) string {
r, done := m.sourceResults[p.ID()]
var auth *provider.AuthError
switch {
case !done:
return "loading…"
case errors.As(r.err, &auth) && len(auth.Login) > 0:
return "sign-in required" + withKey(m.keys.Login, " · %s signs in")
case r.err != nil:
return r.err.Error()
}
noun := map[provider.Kind]string{provider.KindAzure: "subscription", provider.KindDatabricks: "workspace", provider.KindRepo: "repo"}[p.Kind()]
return plural(len(r.sources), cmpOr(noun, "source"))
}
// ago is a duration as people say it: "just now", "5m ago", "2h ago".
func ago(d time.Duration) string {
switch {
case d < time.Minute:
return "just now"
case d < time.Hour:
return fmt.Sprintf("%dm ago", int(d.Minutes()))
case d < 48*time.Hour:
return fmt.Sprintf("%dh ago", int(d.Hours()))
}
return fmt.Sprintf("%dd ago", int(d.Hours()/24))
}
// changelogFields are CHANGELOG.md as rows: each release's version and
// date, then its entries, marked by what kind they are.
func changelogFields(md string) []panes.Field {
var out []panes.Field
kind := ""
for _, line := range strings.Split(md, "\n") {
line = strings.TrimSpace(line)
switch {
case strings.HasPrefix(line, "## "):
version, date, _ := strings.Cut(strings.TrimPrefix(line, "## "), " – ")
out = append(out, panes.Field{Label: version, Value: date, Info: true})
kind = ""
case strings.HasPrefix(line, "### "):
kind = strings.ToLower(strings.TrimPrefix(line, "### "))
case strings.HasPrefix(line, "- ") && len(out) > 0:
text := update.PlainLinks(strings.TrimPrefix(line, "- "))
if kind != "" {
text = kind + ": " + text
}
out = append(out, panes.Field{Value: text})
case line != "" && len(out) > 0 && !strings.HasPrefix(line, "<!--"):
// A release's own words ("Hello world 👋").
out = append(out, panes.Field{Value: update.PlainLinks(line)})
}
}
return out
}
+30
View File
@@ -0,0 +1,30 @@
package app
import (
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/ui/panes"
)
func TestChangelogFields(t *testing.T) {
t.Parallel()
md := "# Changelog\n\nEvery release of lazysecrets, newest first.\n\n## v0.2.0 – 2026-10-12\n\n### Added\n\n" +
"- Search ([#11](https://x/pulls/11)) ([65dc2d7](https://x/commit/65dc2d7))\n\n### Fixed\n\n- A fix (#3)\n\n" +
"## v0.1.0 – 2026-10-10\n\nHello world 👋\n\n<!-- generated by git-cliff (cliff.toml) -->\n"
got := changelogFields(md)
want := []panes.Field{
{Label: "v0.2.0", Value: "2026-10-12", Info: true},
{Value: "added: Search (#11) (65dc2d7)"},
{Value: "fixed: A fix (#3)"},
{Label: "v0.1.0", Value: "2026-10-10", Info: true},
{Value: "Hello world 👋"},
}
if len(got) != len(want) {
t.Fatalf("got %+v", got)
}
for i := range want {
if got[i] != want[i] {
t.Errorf("row %d: %+v, want %+v", i, got[i], want[i])
}
}
}
+33
View File
@@ -0,0 +1,33 @@
╭─ [0] lazysecrets ─────────────╮╭─ platform-prod ─────────────────────────────────────────────────╮
│ 2 accounts ││ name platform-prod │
╰───────────────────────────────╯│ ID sub-prod │
╭─ [1] Sources - Favourites ────╮│ web https://portal.azure.com/#resource/subscriptions/sub…│
│ ▾ Azure (2) ││ vaults 3 │
│ platform-prod ││ │
│ platform-dev ││ │
│ ▾ Databricks (2) ││ │
│ prod ││ │
╰────────────────────── 2 of 6 ─╯│ │
╭─ [2] Vaults ──────────────────╮│ │
│ kv-app-prod ││ │
│ kv-shared-prod ││ │
│ kv-restricted ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰────────────────────── 1 of 3 ─╯│ │
╭─ [3] Secrets - Keys - Certs ──╮│ │
│ db-password ││ │
│ api-key ││ │
│ legacy-token (disabled) ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
│ ││ │
╰────────────────────── 1 of 3 ─╯╰──────────────────────────────────────────────────────── 1 of 4 ─╯
enter open • ctrl+s search • s copy link • S share • f star • F favourites • … • ? help • q quit
+157
View File
@@ -0,0 +1,157 @@
package app
import (
"context"
"errors"
"strings"
"time"
tea "charm.land/bubbletea/v2"
"git.kebcloud.no/keb/lazysecrets/internal/browser"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// Updater finds and installs newer releases (internal/update).
type Updater struct {
Latest func(context.Context) (update.Release, error)
Install func(context.Context, update.Release) error
}
// updateEvery is how often lazysecrets looks for a newer release on its
// own; within that, the last answer (in the state file) is used.
const updateEvery = 24 * time.Hour
type updateMsg struct {
release update.Release
err error
asked bool // the user asked (U), rather than the daily check
}
type installedMsg struct {
release update.Release
err error
}
// startUpdateCheck sets up the startup look for a newer release: the
// state file's answer when it looked less than a day ago, else a quiet
// look at the server (fetchOnStart, for Init).
func (m *Model) startUpdateCheck() {
if m.updater == nil || !m.updateCheck {
return
}
last := m.store.UpdateCheck()
if m.now().Sub(last.At) < updateEvery {
m.offer(update.Release{Tag: last.Tag, Body: last.Notes, URL: last.URL})
return
}
m.fetchOnStart = true
}
func (m Model) fetchLatest(asked bool) tea.Cmd {
latest := m.updater.Latest
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
r, err := latest(ctx)
return updateMsg{release: r, err: err, asked: asked}
}
}
// offer remembers r as the update to offer, if it's newer.
func (m *Model) offer(r update.Release) {
m.newer = nil
if update.Newer(m.version, r.Tag) {
m.newer = &r
}
}
func (m *Model) handleUpdate(msg updateMsg) tea.Cmd {
if msg.err != nil {
if msg.asked {
m.flash = flash{text: "couldn't check for a newer lazysecrets: " + msg.err.Error(), isErr: true}
}
return nil // the daily check stays quiet: offline, or the server's down
}
m.store.SetUpdateCheck(store.UpdateCheck{
At: m.now(), Tag: msg.release.Tag, Notes: msg.release.Body, URL: msg.release.URL,
})
m.offer(msg.release)
if msg.asked {
if m.newer == nil {
m.flash = done("lazysecrets " + m.versionLabel() + " is the latest (" + msg.release.Tag + ")")
return nil
}
m.showUpdate()
}
return nil
}
// update is the update key: the newer release, or a look for one.
func (m *Model) update() tea.Cmd {
switch {
case m.updater == nil:
m.flash = warning("updates aren't available in this build")
return nil
case m.newer != nil:
m.showUpdate()
return nil
case !update.Newer(m.version, "v999.0.0"): // dev builds aren't versions
m.flash = warning("this is a development build (" + m.versionLabel() + "): update it with go install or install.sh")
return nil
}
m.flash = flash{text: "looking for a newer lazysecrets…"}
return m.fetchLatest(true)
}
// showUpdate offers the newer release: its changelog, and updating.
func (m *Model) showUpdate() {
r := *m.newer
lines := []string{m.st.Muted.Render("You have " + m.versionLabel() + ".")}
if notes := r.Notes(); notes != "" {
lines = append(lines, "")
for _, l := range strings.Split(notes, "\n") {
if h, ok := strings.CutPrefix(l, "### "); ok {
l = m.st.Label.Render(h)
}
lines = append(lines, l)
}
}
opts := []option{{key: "u", label: "Update now", tip: "Download, check and replace this lazysecrets; restart to use it.",
run: func(m *Model) tea.Cmd { return m.install(r) }}}
if r.URL != "" {
opts = append(opts, option{key: "o", label: "Open the release page", run: func(m *Model) tea.Cmd {
switch err := m.openURL(r.URL); {
case errors.Is(err, browser.ErrRemote):
cmd := m.copyText("link to the release", r.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}
}
return nil
}})
}
m.showMenu("lazysecrets "+r.Tag, lines, opts)
}
func (m *Model) install(r update.Release) tea.Cmd {
m.flash = flash{text: "downloading lazysecrets " + r.Tag + "…"}
install := m.updater.Install
return func() tea.Msg {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
return installedMsg{release: r, err: install(ctx, r)}
}
}
func (m *Model) handleInstalled(msg installedMsg) {
if msg.err != nil {
m.flash = flash{text: "update failed: " + msg.err.Error(), isErr: true}
return
}
m.newer = nil
m.updated = msg.release.Tag
m.flash = done("updated to lazysecrets " + msg.release.Tag + " · restart lazysecrets to use it")
}
+118
View File
@@ -0,0 +1,118 @@
package app
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"git.kebcloud.no/keb/lazysecrets/internal/store"
"git.kebcloud.no/keb/lazysecrets/internal/update"
)
// fakeUpdater offers latest and records what it installs.
type fakeUpdater struct {
latest update.Release
err error
asked int
installed string
}
func (f *fakeUpdater) updater() *Updater {
return &Updater{
Latest: func(context.Context) (update.Release, error) {
f.asked++
return f.latest, f.err
},
Install: func(_ context.Context, r update.Release) error {
f.installed = r.Tag
return nil
},
}
}
var v020 = update.Release{
Tag: "v0.2.0",
Body: "### Added\n\n- Install with a one-liner (#7)\n\n## Install\n\ncurl …",
URL: "https://git.example.com/keb/lazysecrets/releases/tag/v0.2.0",
}
func TestUpdate(t *testing.T) {
t.Parallel()
f := &fakeUpdater{latest: v020}
mem, _ := store.Load(filepath.Join(t.TempDir(), "state.json"))
d := newDriverOpts(t, Options{Version: "0.1.1", Updater: f.updater(), UpdateCheck: true, Store: mem})
if f.asked != 1 {
t.Fatalf("asked %d times at startup", f.asked)
}
if scr := d.screen(); !strings.Contains(scr, "[0] lazysecrets v0.1.1") || !strings.Contains(scr, "↑ v0.2.0 U") {
t.Errorf("no update in the status pane:\n%s", scr)
}
// U: the changelog, without the install notes; u installs.
d.keys("U")
scr := d.screen()
for _, want := range []string{"lazysecrets v0.2.0", "You have v0.1.1.", "Install with a one-liner (#7)", "Update now", "Open the release page"} {
if !strings.Contains(scr, want) {
t.Errorf("missing %q:\n%s", want, scr)
}
}
if strings.Contains(scr, "curl") {
t.Errorf("install notes shown:\n%s", scr)
}
d.keys("u")
if f.installed != "v0.2.0" {
t.Fatalf("installed %q", f.installed)
}
if !strings.Contains(d.screen(), "updated to lazysecrets v0.2.0 · restart lazysecrets to use it") ||
!strings.Contains(d.screen(), "→ v0.2.0 (restart)") {
t.Errorf("after updating:\n%s", d.screen())
}
// Within a day, the next start uses what it found: no request.
f2 := &fakeUpdater{latest: v020}
d2 := newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, Store: mem})
if f2.asked != 0 || !strings.Contains(d2.screen(), "↑ v0.2.0") {
t.Errorf("asked %d times within a day:\n%s", f2.asked, d2.screen())
}
// A day later it asks again.
mem.SetUpdateCheck(store.UpdateCheck{At: time.Now().Add(-25 * time.Hour), Tag: "v0.2.0"})
newDriverOpts(t, Options{Version: "0.1.1", Updater: f2.updater(), UpdateCheck: true, Store: mem})
if f2.asked != 1 {
t.Errorf("asked %d times a day later", f2.asked)
}
}
func TestUpdateAsked(t *testing.T) {
t.Parallel()
mem, _ := store.Load(filepath.Join(t.TempDir(), "state.json"))
// Not looking on its own: U looks.
f := &fakeUpdater{latest: update.Release{Tag: "v0.1.1"}}
d := newDriverOpts(t, Options{Version: "0.1.1", Updater: f.updater(), Store: mem})
if f.asked != 0 {
t.Fatal("looked without updateCheck")
}
d.keys("U")
if f.asked != 1 || !strings.Contains(d.screen(), "lazysecrets v0.1.1 is the latest (v0.1.1)") {
t.Errorf("asked %d:\n%s", f.asked, d.screen())
}
// The daily check stays quiet when offline; U says why.
f.err = errors.New("no network")
d.keys("U")
if !strings.Contains(d.screen(), "couldn't check for a newer lazysecrets: no network") {
t.Errorf("no error:\n%s", d.screen())
}
// A development build isn't offered releases.
dev := newDriverOpts(t, Options{Version: "dev-2ba762c", Updater: (&fakeUpdater{latest: v020}).updater(), UpdateCheck: true, Store: mem})
if strings.Contains(dev.screen(), "↑") {
t.Errorf("dev build offered an update:\n%s", dev.screen())
}
dev.keys("U")
if !strings.Contains(dev.screen(), "development build") {
t.Errorf("U on a dev build:\n%s", dev.screen())
}
}
+40
View File
@@ -0,0 +1,40 @@
package app
import (
"git.kebcloud.no/keb/lazysecrets/internal/provider"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
// Usage: revealing, copying, opening in the browser, sharing and jumping
// to an item count as using it. The store keeps when each item was last
// used and how often, for the Recent section of the jump list. The config's
// history: false turns this off.
// used counts a use of the remembered item key, keeping its context.
func (m *Model) used(key string) {
if !m.history {
return
}
_ = m.store.Use(key, m.now(), func(*store.Node) {}) // best effort: never in the way
}
// usedItem counts a use of an item, recording its context.
func (m *Model) usedItem(key, name string, path []string, src provider.Source) {
if !m.history {
return
}
_ = m.store.Use(key, m.now(), withContext(name, path, src))
}
func (m *Model) usedSource(s provider.Source) {
m.usedItem(sourceKey(s), s.Name, nil, s)
}
func (m *Model) usedContainer(c provider.Container) {
m.usedItem(storeKey(c), c.Name, []string{c.Source.Name}, c.Source)
}
func (m *Model) usedObject(o provider.Object) {
c := o.Container
m.usedItem(objectKey(o), o.Name, []string{c.Source.Name, c.Name}, c.Source)
}
+47
View File
@@ -0,0 +1,47 @@
package app
import (
"strings"
"testing"
"git.kebcloud.no/keb/lazysecrets/internal/store"
)
func TestRecentScope(t *testing.T) {
t.Parallel()
d := newDriver(t)
d.keys("3", "j", "space") // reveal api-key
d.keys("j", "s") // share legacy-token: a use too
d.keys("2", "j", "f") // star kv-shared-prod
key := "lazysecrets://azure/kv-app-prod.vault.azure.net/secrets/api-key"
if n := d.m.store.Node(key); n.Uses != 1 || n.LastUsed.IsZero() || n.Name != "api-key" {
t.Fatalf("reveal not counted: %+v", n)
}
d.keys("R")
var recent []string
for _, o := range d.m.search.shown {
recent = append(recent, ansiStrip(o.label))
}
if len(recent) < 2 || !strings.Contains(recent[0], "legacy-token") || !strings.Contains(recent[1], "api-key") {
t.Fatalf("want newest first: %q", recent)
}
d.keys("enter") // jump to legacy-token
if _, _, s := d.selected(); s != "legacy-token" {
t.Errorf("jumped to %q", s)
}
d.keys("F")
if len(d.m.search.shown) != 1 || !strings.Contains(ansiStrip(d.m.search.shown[0].label), "kv-shared-prod") {
t.Errorf("favourites: %d entries\n%s", len(d.m.search.shown), d.screen())
}
}
func TestNoHistory(t *testing.T) {
t.Parallel()
mem, _ := store.Load("")
d := newDriverOpts(t, Options{Store: mem, NoHistory: true})
d.keys("3", "j", "space", "y")
if len(mem.Nodes()) != 0 {
t.Errorf("recorded with history off: %v", mem.Nodes())
}
}