Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
6fab41703e
111 files changed
+22988
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
@@ -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 for this session only. It never holds secret values
|
||||
// or errors, and is never written to disk.
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
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 {
|
||||
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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
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")
|
||||
if n := strings.Count(d.screen(), "shared note"); n != 2 {
|
||||
t.Errorf("note shown %d times, want 2:\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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
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
|
||||
}
|
||||
|
||||
func (c contextHelp) ShortHelp() []key.Binding {
|
||||
k := c.k
|
||||
var b []key.Binding
|
||||
if k.Login.Enabled() {
|
||||
b = append(b, k.Login)
|
||||
}
|
||||
switch c.focus {
|
||||
case paneSources:
|
||||
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Favourites, k.Note, k.SourcesView, k.Filter)
|
||||
case paneContainers:
|
||||
b = append(b, k.Enter, k.Share, k.ShareMenu, k.Favourite, k.Note, k.Open, k.Filter)
|
||||
default:
|
||||
b = append(b, k.Reveal, k.Copy, k.Share, k.ShareMenu, k.Versions, k.Favourite, k.Note, 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"}
|
||||
|
||||
// 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}
|
||||
}
|
||||
@@ -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 ("dev" in
|
||||
// "Azure · dev"), 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
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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.leftAvail()
|
||||
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()) }
|
||||
|
||||
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)
|
||||
|
||||
switch m.zoom {
|
||||
case zoomHalf:
|
||||
if m.focus == paneDetail {
|
||||
leftW = minLeftW
|
||||
} else {
|
||||
leftW = max(m.leftWidth(), m.w/2)
|
||||
h = [3]int{tinyPaneH, tinyPaneH, tinyPaneH}
|
||||
h[m.focus] = avail
|
||||
h = fitHeights(h, avail, tinyPaneH)
|
||||
}
|
||||
case zoomFull:
|
||||
// Only the focused pane is drawn; it gets the whole screen.
|
||||
switch m.focus {
|
||||
case paneDetail:
|
||||
leftW = 0
|
||||
default:
|
||||
leftW = m.w
|
||||
h = [3]int{}
|
||||
h[m.focus] = avail
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 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 := [paneCount]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 { return m.detail.View(m.st, m.active(paneDetail), spin) },
|
||||
}
|
||||
if m.zoom == zoomFull {
|
||||
return views[m.focus]()
|
||||
}
|
||||
left := lipgloss.JoinVertical(lipgloss.Left, views[0](), views[1](), views[2]())
|
||||
return lipgloss.JoinHorizontal(lipgloss.Top, left, views[3]())
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
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 reveal") {
|
||||
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 TestVersionBottomRight(t *testing.T) {
|
||||
t.Parallel()
|
||||
d := newDriverOpts(t, Options{Version: "0.1.0"})
|
||||
bar := lastLine(d.screen())
|
||||
if !strings.HasSuffix(bar, "v0.1.0 ") || ansi.StringWidth(bar) != 100 {
|
||||
t.Errorf("help line %q (%d wide)", bar, ansi.StringWidth(bar))
|
||||
}
|
||||
if !strings.Contains(bar, "? help • q quit") {
|
||||
t.Errorf("help squeezed out by the version: %q", bar)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 stripe live"} {
|
||||
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) }
|
||||
@@ -0,0 +1,240 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"context"
|
||||
"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 != kindScope+string(provider.KindRepo) {
|
||||
if d.m.search.src == len(d.m.search.scopes)-1 {
|
||||
t.Fatalf("no All 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 all repos") {
|
||||
t.Errorf("title:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
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("") }
|
||||
|
||||
// coverageOf is searchCoverage for one kind of provider ("" for all).
|
||||
func (m *Model) coverageOf(kind provider.Kind) coverage {
|
||||
var c coverage
|
||||
for _, p := range m.providers {
|
||||
if kind != "" && p.Kind() != kind {
|
||||
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("") }
|
||||
|
||||
// startCrawlOf is startCrawl for one kind of provider ("" for all).
|
||||
func (m *Model) startCrawlOf(kind provider.Kind) tea.Cmd {
|
||||
for _, p := range m.providers {
|
||||
if kind != "" && p.Kind() != kind {
|
||||
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 and not
|
||||
// force, loading their secrets.
|
||||
func (m *Model) queueSource(src provider.Source, force bool) {
|
||||
row := sourceRowKey(src.Provider, src.ID)
|
||||
if e, ok := m.cache.containers[row]; ok && !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 and not force.
|
||||
func (m *Model) queueContainer(c provider.Container, row string, force bool) {
|
||||
key := containerKey(c) + "|" + string(provider.TypeSecret)
|
||||
if _, ok := m.cache.objects[key]; ok && !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()}
|
||||
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()}
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
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")
|
||||
if !strings.Contains(d.screen(), "no matches here · All searches everything") {
|
||||
t.Errorf("no hint to search everything:\n%s", d.screen())
|
||||
}
|
||||
d.searchAll()
|
||||
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() // on 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.keys("tab") // to the scopes
|
||||
for d.m.search.src > 0 {
|
||||
d.send(tea.KeyPressMsg{Code: tea.KeyUp})
|
||||
}
|
||||
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
|
||||
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-prod") {
|
||||
t.Errorf("source not preselected: %q", got)
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
// From the Sources pane: just the source.
|
||||
d.keys("esc", "esc", "1", "j") // stop typing, close; platform-dev
|
||||
d.openSearch()
|
||||
v = d.m.search
|
||||
if got := ansiStrip(v.scopes[v.src].label); !strings.Contains(got, "platform-dev") {
|
||||
t.Errorf("source not preselected: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,701 @@
|
||||
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: Favourites and Recent (from the store, so they work before
|
||||
// anything loads) are one list each; 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.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// 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 // tabFavs, tabRecent or tabSearch
|
||||
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
|
||||
|
||||
// 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 (
|
||||
tabFavs = iota
|
||||
tabRecent
|
||||
tabSearch
|
||||
tabCount
|
||||
)
|
||||
|
||||
var tabNames = [tabCount]string{"Favourites", "Recent", "Search"}
|
||||
|
||||
const (
|
||||
scopeFavs = "#favs"
|
||||
scopeRecent = "#recent"
|
||||
allSources = "#all"
|
||||
kindScope = "#kind:" // + a provider kind: all of Azure, Databricks or repos
|
||||
)
|
||||
|
||||
const goToTitle = "Go to"
|
||||
|
||||
// showSearch opens Go to on Search, typing, in the source you're in
|
||||
// (else 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 picked on the
|
||||
// left, and the selected vault or scope under the cursor on the right.
|
||||
func (m *Model) preselect() {
|
||||
v := m.search
|
||||
row := m.sources.SelectedID()
|
||||
i := slices.IndexFunc(v.scopes, func(o option) bool { return o.group != "" && o.group == row })
|
||||
if i < 0 {
|
||||
return
|
||||
}
|
||||
v.src = i
|
||||
m.fillSearch(false)
|
||||
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("")}
|
||||
scopes := []option{all}
|
||||
// All of one kind, so a search can stay on repos (local, quick)
|
||||
// without asking Azure for every vault; only with several kinds.
|
||||
var kinds []provider.Kind
|
||||
for _, p := range m.providers {
|
||||
if len(m.sourceResults[p.ID()].sources) > 0 && !slices.Contains(kinds, p.Kind()) {
|
||||
kinds = append(kinds, p.Kind())
|
||||
}
|
||||
}
|
||||
if len(kinds) > 1 {
|
||||
for _, k := range kinds {
|
||||
scopes = append(scopes, option{
|
||||
label: m.st.Kind(k).Render(m.icons.Prefix(k) + kindScopeLabel(k)),
|
||||
id: kindScope + string(k), kind: k, mark: m.coverageMark(k),
|
||||
})
|
||||
}
|
||||
}
|
||||
v.scopes, v.items = append(scopes, sources...), 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))
|
||||
}
|
||||
|
||||
// kindScopeLabel names the scope of all of a kind's sources.
|
||||
func kindScopeLabel(k provider.Kind) string {
|
||||
if k == provider.KindRepo {
|
||||
return "All repos"
|
||||
}
|
||||
return "All " + string(k)
|
||||
}
|
||||
|
||||
// coverageMark is ○ ◐ ● for how much of a kind ("" for all) is loaded.
|
||||
func (m *Model) coverageMark(kind provider.Kind) string {
|
||||
switch c := m.coverageOf(kind); {
|
||||
case c.complete():
|
||||
return m.st.Success.Render("●")
|
||||
case c.vaultsLoaded > 0:
|
||||
return m.st.Warning.Render("◐")
|
||||
}
|
||||
return m.st.Muted.Render("○")
|
||||
}
|
||||
|
||||
// scopeKind is the kind a scope covers all of, if it's one of those.
|
||||
func (v *searchView) scopeKind() (provider.Kind, bool) {
|
||||
k, ok := strings.CutPrefix(v.scope().id, kindScope)
|
||||
if !ok || v.tab != tabSearch {
|
||||
return "", false
|
||||
}
|
||||
return provider.Kind(k), true
|
||||
}
|
||||
|
||||
// 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:
|
||||
kind, ofKind := v.scopeKind()
|
||||
for _, o := range v.items {
|
||||
if sc.id == allSources || ofKind && o.kind == kind || 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 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.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)
|
||||
}
|
||||
kind, _ := v.scopeKind()
|
||||
return m.startCrawlOf(kind)
|
||||
case inSearch && key.Matches(msg, k.loadAll):
|
||||
kind, _ := v.scopeKind()
|
||||
return m.startCrawlOf(kind)
|
||||
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
|
||||
}
|
||||
|
||||
// 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 kind, ok := v.scopeKind(); ok {
|
||||
return m.startCrawlOf(kind)
|
||||
}
|
||||
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 {
|
||||
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 kind, ok := v.scopeKind(); ok || v.scope().id == allSources {
|
||||
c := m.coverageOf(kind)
|
||||
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 v.onSource() {
|
||||
resultsTitle = "Results in " + ansi.Strip(v.scope().label)
|
||||
}
|
||||
if kind, ok := v.scopeKind(); ok {
|
||||
resultsTitle = "Results in " + string(kind)
|
||||
if kind == provider.KindRepo {
|
||||
resultsTitle = "Results in all repos"
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
╭─ [1] Sources - Favourites ────╮╭─ Secret ────────────────────────────────────────────────────────╮
|
||||
│ ▾ Azure (2) ││ name db-password │
|
||||
│ platform-prod ││ vault kv-app-prod │
|
||||
│ platform-dev ││ source Azure / platform-prod │
|
||||
│ ▾ Databricks (2) ││ version 3f9c2a │
|
||||
│ prod ││ enabled yes │
|
||||
╰────────────────────── 2 of 6 ─╯│ updated 2026-09-01 09:30 │
|
||||
╭─ [2] Vaults ──────────────────╮│ expires 2027-09-01 09:30 │
|
||||
│ kv-app-prod ││ content text/plain │
|
||||
│ kv-shared-prod ││ tags env=prod, owner=data │
|
||||
│ kv-restricted ││ ref @Microsoft.KeyVault(SecretUri=https://kv-app-prod.vau│
|
||||
│ ││ lt.azure.net/secrets/db-password) │
|
||||
│ ││ │
|
||||
│ ││ value •••••••• (space to reveal) │
|
||||
│ ││ │
|
||||
╰────────────────────── 1 of 3 ─╯│ versions 3 · v to browse │
|
||||
╭─ [3] Secrets - Keys - Certs ──╮│ ▸ 3f9c2a 2026-09-01 current │
|
||||
│ db-password ││ 91d3e0 2026-03-01 expired │
|
||||
│ api-key ││ 0a77c4 2025-09-01 disabled │
|
||||
│ legacy-token (disabled) ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
│ ││ │
|
||||
╰────────────────────── 1 of 3 ─╯╰─────────────────────────────────────────────────────────────────╯
|
||||
enter open • s copy link • S share… • f star • F favourites • n note • … • ? help • q quit
|
||||
@@ -0,0 +1,169 @@
|
||||
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")
|
||||
}
|
||||
|
||||
// updateLabel is the version's suffix in the footer: a newer release to
|
||||
// update to, or the one installed and waiting for a restart.
|
||||
func (m Model) updateLabel() string {
|
||||
switch {
|
||||
case m.updated != "":
|
||||
return " → " + m.updated + " (restart)"
|
||||
case m.newer != nil:
|
||||
return " ↑ " + m.newer.Tag + withKey(m.keys.Update, " %s")
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -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 !strings.Contains(d.screen(), "v0.1.1 ↑ v0.2.0 U") {
|
||||
t.Errorf("no update in the footer:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// 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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Package icons maps backends to Nerd Font glyphs. They are on by default
|
||||
// and can be turned off, since they render as boxes without a Nerd Font
|
||||
// (v3+) installed.
|
||||
package icons
|
||||
|
||||
import "git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
|
||||
type Set struct {
|
||||
Azure string
|
||||
Databricks string
|
||||
Repo string
|
||||
Encrypted string // an encrypted file (SOPS)
|
||||
PlainFile string // a plain text secrets file (.env)
|
||||
}
|
||||
|
||||
// None renders no icons.
|
||||
var None = Set{}
|
||||
|
||||
// NerdFont uses Nerd Fonts v3 Material Design glyphs. There is no Databricks
|
||||
// logo glyph, so it uses stacked layers, which resembles it.
|
||||
var NerdFont = Set{
|
||||
Azure: "\U000F0805", // md-microsoft_azure
|
||||
Databricks: "\U000F0F58", // md-layers_triple
|
||||
Repo: "\U000F02A2", // md-git
|
||||
Encrypted: "\U000F033E", // md-lock
|
||||
PlainFile: "\U000F0214", // md-file
|
||||
}
|
||||
|
||||
// Prefix returns the icon for kind followed by a space, or "" if the set has
|
||||
// no icon for it.
|
||||
func (s Set) Prefix(kind provider.Kind) string {
|
||||
var icon string
|
||||
switch kind {
|
||||
case provider.KindAzure:
|
||||
icon = s.Azure
|
||||
case provider.KindDatabricks:
|
||||
icon = s.Databricks
|
||||
case provider.KindRepo:
|
||||
icon = s.Repo
|
||||
}
|
||||
if icon == "" {
|
||||
return ""
|
||||
}
|
||||
return icon + " "
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
// Package keys defines the keymap: named actions in contexts, their
|
||||
// default keys, and overrides from the config:
|
||||
//
|
||||
// keybinding:
|
||||
// lists:
|
||||
// star: [f]
|
||||
// versions: [] # unbound
|
||||
// filter:
|
||||
// star: [ctrl+f]
|
||||
//
|
||||
// An action set in the config gets exactly the keys given, replacing its
|
||||
// defaults; an empty list unbinds it. Everything that shows a key (the
|
||||
// help bar, ?, footers, hints) reads it from here.
|
||||
package keys
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
)
|
||||
|
||||
// Contexts group the actions by where they apply. The search popup takes
|
||||
// search's actions and popup's.
|
||||
const (
|
||||
Lists = "lists" // the panes, when not typing
|
||||
Filter = "filter" // typing a filter in a pane
|
||||
Menu = "menu" // popups without a text field
|
||||
Popup = "popup" // popups with a text field: favourites, help, search, notes, open link
|
||||
Search = "search" // Go to's own actions while typing
|
||||
GoTo = "goto" // Go to while browsing (no text field active)
|
||||
)
|
||||
|
||||
type Map struct {
|
||||
// lists
|
||||
Up, Down, Top, Bottom key.Binding
|
||||
NextPane, PrevPane key.Binding
|
||||
Pane1, Pane2, Pane3, Pane4 key.Binding
|
||||
NextTab, PrevTab key.Binding
|
||||
Enter, Back, Filter key.Binding
|
||||
Reveal, Copy, CopyRef, Open key.Binding
|
||||
Versions, Favourite, Favourites key.Binding
|
||||
Recent key.Binding
|
||||
Note, Share, ShareMenu, OpenRef key.Binding
|
||||
Search, SourcesView, Commands key.Binding
|
||||
SplitLeft, SplitRight, SplitReset key.Binding
|
||||
Taller, Shorter, ZoomIn, ZoomOut key.Binding
|
||||
Login, Refresh, Help, Quit key.Binding
|
||||
Update key.Binding
|
||||
// filter
|
||||
AcceptFilter, ClearFilter key.Binding
|
||||
FilterUp, FilterDown key.Binding
|
||||
FilterCopy, FilterReveal, FilterStar key.Binding
|
||||
// menu
|
||||
MenuUp, MenuDown, MenuSelect, MenuClose key.Binding
|
||||
// popup
|
||||
PopupUp, PopupDown, PopupSelect, PopupClose, PopupStar key.Binding
|
||||
// search
|
||||
SearchSources, SearchResults, SearchSwitch key.Binding
|
||||
SearchLoad, SearchLoadAll key.Binding
|
||||
// goto
|
||||
GoDown, GoUp, GoPrevTab, GoNextTab key.Binding
|
||||
GoSources, GoResults, GoSwitch key.Binding
|
||||
GoStar, GoLoad, GoLoadAll, GoFilter key.Binding
|
||||
GoSelect, GoClose key.Binding
|
||||
}
|
||||
|
||||
// Action is a binding with its name in the config and what it does.
|
||||
type Action struct {
|
||||
Context, Name string
|
||||
Binding *key.Binding
|
||||
Desc string // short, for the help bar
|
||||
Group string // section in the ? menu
|
||||
Tip string // a sentence or two for the ? menu
|
||||
}
|
||||
|
||||
// Actions lists every action, in the ? menu's order.
|
||||
func (m *Map) Actions() []Action {
|
||||
a := func(ctx, name string, b *key.Binding, desc, group, tip string) Action {
|
||||
return Action{ctx, name, b, desc, group, tip}
|
||||
}
|
||||
return []Action{
|
||||
a(Lists, "up", &m.Up, "up", "move", "Move up in the focused list, or scroll the detail pane."),
|
||||
a(Lists, "down", &m.Down, "down", "move", "Move down in the focused list, or scroll the detail pane."),
|
||||
a(Lists, "top", &m.Top, "top", "move", "Jump to the top of the focused list."),
|
||||
a(Lists, "bottom", &m.Bottom, "bottom", "move", "Jump to the bottom of the focused list."),
|
||||
a(Lists, "nextPane", &m.NextPane, "next pane", "move", "Focus the next pane: Sources, Vaults, Secrets, detail."),
|
||||
a(Lists, "prevPane", &m.PrevPane, "prev pane", "move", "Focus the previous pane."),
|
||||
a(Lists, "pane1", &m.Pane1, "Sources pane", "move", "Focus the Sources pane."),
|
||||
a(Lists, "pane2", &m.Pane2, "Vaults pane", "move", "Focus the Vaults (or Scopes) pane."),
|
||||
a(Lists, "pane3", &m.Pane3, "Secrets pane", "move", "Focus the Secrets pane."),
|
||||
a(Lists, "pane4", &m.Pane4, "detail pane", "move", "Focus the detail pane."),
|
||||
a(Lists, "enter", &m.Enter, "open", "move", "Go into the selected item: from a subscription to its vaults, from a vault to its secrets. On a provider header, fold or unfold it."),
|
||||
a(Lists, "back", &m.Back, "back", "move", "Go back: clear an applied filter, leave the versions view, or focus the previous pane."),
|
||||
|
||||
a(Lists, "filter", &m.Filter, "filter", "lists", "Filter the focused list by name, fuzzily: dbpw finds db-password. In Sources the provider and account match too."),
|
||||
a(Lists, "prevTab", &m.PrevTab, "prev tab", "lists", "Previous tab of the focused pane: Sources and Favourites in Sources; Secrets, Keys, Certs in Secrets."),
|
||||
a(Lists, "nextTab", &m.NextTab, "next tab", "lists", "Next tab of the focused pane."),
|
||||
a(Lists, "sourcesView", &m.SourcesView, "grouped/flat sources", "lists", "Switch the Sources pane between grouped by provider and a flat list."),
|
||||
a(Lists, "star", &m.Favourite, "star", "lists", "Star or unstar the selected subscription, vault or secret, for the account showing it. Starred items get a ★ and sort first."),
|
||||
a(Lists, "search", &m.Search, "search", "lists", "Go to anything: search every vault and secret loaded and jump to one, starting in the source you're in. On the left, the scope: Favourites, Recent, All, or one source (○ ◐ ● for how much of it is loaded)."),
|
||||
a(Lists, "favourites", &m.Favourites, "favourites", "lists", "Go to, on Favourites: everything you've starred, to search and jump to."),
|
||||
a(Lists, "recent", &m.Recent, "recent", "lists", "Go to, on Recent: what you used last, newest first."),
|
||||
a(Lists, "commands", &m.Commands, "commands", "lists", "List the custom commands for the focused pane (customCommands in the config) and run one."),
|
||||
a(Lists, "note", &m.Note, "note", "lists", "Write a short note on the selected subscription, vault or secret, shown on the right of its row. Filtering matches notes too."),
|
||||
|
||||
a(Lists, "reveal", &m.Reveal, "reveal/hide", "secrets", "Show or hide the selected value. For keys and certificates: the public key or certificate."),
|
||||
a(Lists, "copyValue", &m.Copy, "copy value", "secrets", "Copy the selected value without showing it. It is cleared from the clipboard after a countdown."),
|
||||
a(Lists, "copyReference", &m.CopyRef, "copy reference", "secrets", "Copy the reference to use in config, e.g. @Microsoft.KeyVault(SecretUri=…) or {{secrets/scope/key}}."),
|
||||
a(Lists, "versions", &m.Versions, "versions", "secrets", "List the versions of the selected item (Azure). Reveal, copy and share then act on the selected version."),
|
||||
a(Lists, "openInBrowser", &m.Open, "open in browser", "secrets", "Open the selected subscription, vault or item in the browser (Azure portal or Databricks workspace)."),
|
||||
|
||||
a(Lists, "share", &m.Share, "copy link", "share", "Copy a lazysecrets:// link to the selected subscription, workspace, vault, scope or secret, for sharing. From the versions view the link is pinned to that version."),
|
||||
a(Lists, "shareMenu", &m.ShareMenu, "share…", "share", "Choose what to share: the lazysecrets link, the plain Key Vault URL, the config reference, or the web page."),
|
||||
a(Lists, "openLink", &m.OpenRef, "open link", "share", "Open a lazysecrets:// link, Key Vault URL or reference. Pasting one into lazysecrets does the same."),
|
||||
|
||||
a(Lists, "dividerLeft", &m.SplitLeft, "divider left", "layout", "Move the divider between the left panes and the detail pane to the left."),
|
||||
a(Lists, "dividerRight", &m.SplitRight, "divider right", "layout", "Move the divider between the left panes and the detail pane to the right."),
|
||||
a(Lists, "taller", &m.Taller, "taller", "layout", "Make the focused left pane taller."),
|
||||
a(Lists, "shorter", &m.Shorter, "shorter", "layout", "Make the focused left pane shorter."),
|
||||
a(Lists, "zoomIn", &m.ZoomIn, "zoom", "layout", "Zoom the focused pane: normal, half screen, full screen."),
|
||||
a(Lists, "zoomOut", &m.ZoomOut, "unzoom", "layout", "Zoom back out."),
|
||||
a(Lists, "resetLayout", &m.SplitReset, "reset layout", "layout", "Reset the divider, pane heights and zoom."),
|
||||
|
||||
a(Lists, "refresh", &m.Refresh, "refresh", "other", "Reload the focused pane, bypassing the cache."),
|
||||
a(Lists, "login", &m.Login, "sign in", "other", "Sign in again (az login or databricks auth login) when a login has expired."),
|
||||
a(Lists, "update", &m.Update, "update", "other", "Check for a newer lazysecrets: its changelog, and update to it."),
|
||||
a(Lists, "help", &m.Help, "help", "other", "Show this list. Also works in Go to while browsing, and in menus, showing their keys first."),
|
||||
a(Lists, "quit", &m.Quit, "quit", "other", "Quit lazysecrets. ctrl+c always quits too."),
|
||||
|
||||
a(Filter, "down", &m.FilterDown, "down", "while filtering", "Move down while typing a filter."),
|
||||
a(Filter, "up", &m.FilterUp, "up", "while filtering", "Move up while typing a filter."),
|
||||
a(Filter, "copyValue", &m.FilterCopy, "copy value", "while filtering", "Copy the selected value while typing a filter."),
|
||||
a(Filter, "reveal", &m.FilterReveal, "reveal/hide", "while filtering", "Reveal or hide the selected value while typing a filter."),
|
||||
a(Filter, "star", &m.FilterStar, "star", "while filtering", "Star or unstar the selected item while typing a filter."),
|
||||
a(Filter, "accept", &m.AcceptFilter, "apply filter", "while filtering", "Stop typing and keep the filter."),
|
||||
a(Filter, "clear", &m.ClearFilter, "clear filter", "while filtering", "Clear the filter."),
|
||||
|
||||
a(Menu, "down", &m.MenuDown, "down", "menus", "Move down in a menu."),
|
||||
a(Menu, "up", &m.MenuUp, "up", "menus", "Move up in a menu."),
|
||||
a(Menu, "select", &m.MenuSelect, "select", "menus", "Pick the selected entry. Each entry's letter picks it directly."),
|
||||
a(Menu, "close", &m.MenuClose, "dismiss", "menus", "Close the menu."),
|
||||
|
||||
a(Popup, "down", &m.PopupDown, "down", "popups", "Move down in a popup with a text field (favourites, help, search)."),
|
||||
a(Popup, "up", &m.PopupUp, "up", "popups", "Move up in a popup with a text field."),
|
||||
a(Popup, "select", &m.PopupSelect, "go", "popups", "Go to the selected entry, or confirm what you typed."),
|
||||
a(Popup, "close", &m.PopupClose, "close", "popups", "Close the popup."),
|
||||
a(Popup, "star", &m.PopupStar, "star", "popups", "Star or unstar the selected entry in Go to. It stays where it is until Go to opens again."),
|
||||
|
||||
a(Search, "sources", &m.SearchSources, "sources", "search", "Move to the sources list."),
|
||||
a(Search, "results", &m.SearchResults, "results", "search", "Move to the results list."),
|
||||
a(Search, "switchList", &m.SearchSwitch, "switch list", "search", "Switch between the sources and the results."),
|
||||
a(Search, "load", &m.SearchLoad, "load selected", "search", "Load (or reload) the selected source's vaults and secrets, or the selected vault's secrets, in the background."),
|
||||
a(Search, "loadAll", &m.SearchLoadAll, "load all", "search", "Load every source's vaults and secrets in the background, to search them all."),
|
||||
|
||||
a(GoTo, "down", &m.GoDown, "down", "go to", "Move down in Go to while browsing."),
|
||||
a(GoTo, "up", &m.GoUp, "up", "go to", "Move up in Go to while browsing."),
|
||||
a(GoTo, "prevTab", &m.GoPrevTab, "prev tab", "go to", "Previous tab of Go to: Favourites, Recent, Search."),
|
||||
a(GoTo, "nextTab", &m.GoNextTab, "next tab", "go to", "Next tab of Go to."),
|
||||
a(GoTo, "sources", &m.GoSources, "sources", "go to", "Move to the sources list (Search tab)."),
|
||||
a(GoTo, "results", &m.GoResults, "results", "go to", "Move to the results list (Search tab)."),
|
||||
a(GoTo, "switchList", &m.GoSwitch, "switch list", "go to", "Switch between the sources and the results (Search tab)."),
|
||||
a(GoTo, "star", &m.GoStar, "star", "go to", "Star or unstar the selected entry. It stays where it is until Go to opens again."),
|
||||
a(GoTo, "load", &m.GoLoad, "load selected", "go to", "Load (or reload) the selected source or vault in the background (Search tab)."),
|
||||
a(GoTo, "loadAll", &m.GoLoadAll, "load all", "go to", "Load everything in the background, to search it all (Search tab)."),
|
||||
a(GoTo, "filter", &m.GoFilter, "filter", "go to", "Start typing to filter the list."),
|
||||
a(GoTo, "select", &m.GoSelect, "go", "go to", "Go to the selected entry."),
|
||||
a(GoTo, "close", &m.GoClose, "close", "go to", "Close Go to."),
|
||||
}
|
||||
}
|
||||
|
||||
// defaults follow one rule: a letter in the lists, ctrl+ the same letter
|
||||
// where you type.
|
||||
var defaults = map[string][]string{
|
||||
"lists.up": {"k", "up", "ctrl+k", "ctrl+p"}, "lists.down": {"j", "down", "ctrl+j", "ctrl+n"},
|
||||
"lists.top": {"g", "home"}, "lists.bottom": {"G", "end"},
|
||||
"lists.nextPane": {"tab", "l", "right"}, "lists.prevPane": {"shift+tab", "h", "left"},
|
||||
"lists.pane1": {"1"}, "lists.pane2": {"2"}, "lists.pane3": {"3"}, "lists.pane4": {"4"},
|
||||
"lists.enter": {"enter"}, "lists.back": {"esc", "backspace"},
|
||||
"lists.filter": {"/"}, "lists.prevTab": {"["}, "lists.nextTab": {"]"}, "lists.sourcesView": {"`"},
|
||||
"lists.star": {"f"}, "lists.favourites": {"F"}, "lists.recent": {"R"}, "lists.search": {"ctrl+s"}, "lists.note": {"n"},
|
||||
"lists.commands": {"x"},
|
||||
"lists.reveal": {"space"}, "lists.copyValue": {"y"}, "lists.copyReference": {"Y"},
|
||||
"lists.versions": {"v"}, "lists.openInBrowser": {"o"},
|
||||
"lists.share": {"s"}, "lists.shareMenu": {"S"}, "lists.openLink": {"ctrl+o"},
|
||||
"lists.dividerLeft": {"ctrl+left", "<"}, "lists.dividerRight": {"ctrl+right", ">"},
|
||||
"lists.taller": {"ctrl+down", "}"}, "lists.shorter": {"ctrl+up", "{"},
|
||||
"lists.zoomIn": {"+"}, "lists.zoomOut": {"_"}, "lists.resetLayout": {"="},
|
||||
"lists.refresh": {"r"}, "lists.login": {"L"}, "lists.update": {"U"}, "lists.help": {"?"}, "lists.quit": {"q"},
|
||||
|
||||
"filter.down": {"ctrl+j", "ctrl+n", "down"}, "filter.up": {"ctrl+k", "ctrl+p", "up"},
|
||||
"filter.copyValue": {"ctrl+y"}, "filter.reveal": {"ctrl+r"}, "filter.star": {"ctrl+f"},
|
||||
"filter.accept": {"enter"}, "filter.clear": {"esc"},
|
||||
|
||||
"menu.down": {"j", "down", "ctrl+j", "ctrl+n"}, "menu.up": {"k", "up", "ctrl+k", "ctrl+p"},
|
||||
"menu.select": {"enter"}, "menu.close": {"esc"},
|
||||
|
||||
"popup.down": {"ctrl+j", "ctrl+n", "down"}, "popup.up": {"ctrl+k", "ctrl+p", "up"},
|
||||
"popup.select": {"enter"}, "popup.close": {"esc"}, "popup.star": {"ctrl+f"},
|
||||
|
||||
"search.sources": {"ctrl+h"}, "search.results": {"ctrl+l"}, "search.switchList": {"tab", "shift+tab"},
|
||||
"search.load": {"ctrl+r"}, "search.loadAll": {"ctrl+a"},
|
||||
|
||||
"goto.down": {"j", "down"}, "goto.up": {"k", "up"}, "goto.prevTab": {"["}, "goto.nextTab": {"]"},
|
||||
"goto.sources": {"h", "left"}, "goto.results": {"l", "right"}, "goto.switchList": {"tab", "shift+tab"},
|
||||
"goto.star": {"f"}, "goto.load": {"r"}, "goto.loadAll": {"a"}, "goto.filter": {"/"},
|
||||
"goto.select": {"enter"}, "goto.close": {"esc", "q"},
|
||||
}
|
||||
|
||||
// Default is the keymap without overrides.
|
||||
func Default() Map {
|
||||
var m Map
|
||||
for _, a := range m.Actions() {
|
||||
*a.Binding = bind(defaults[a.Context+"."+a.Name], a.Desc)
|
||||
}
|
||||
m.Login.SetEnabled(false) // shown and active only once a login expires
|
||||
return m
|
||||
}
|
||||
|
||||
func bind(keys []string, desc string) key.Binding {
|
||||
if len(keys) == 0 {
|
||||
return key.NewBinding(key.WithDisabled())
|
||||
}
|
||||
return key.NewBinding(key.WithKeys(keys...), key.WithHelp(Label(keys), desc))
|
||||
}
|
||||
|
||||
// Label shows keys in help: the first two, arrows as arrows, a shared
|
||||
// ctrl+ written once ("k/↑", "ctrl+j/n").
|
||||
func Label(keys []string) string {
|
||||
pretty := strings.NewReplacer("left", "←", "right", "→", "up", "↑", "down", "↓", "backspace", "⌫")
|
||||
var out []string
|
||||
for i, k := range keys[:min(2, len(keys))] {
|
||||
k = pretty.Replace(k)
|
||||
if rest, ok := strings.CutPrefix(k, "ctrl+"); ok && i > 0 && strings.HasPrefix(out[i-1], "ctrl+") {
|
||||
k = rest
|
||||
}
|
||||
out = append(out, k)
|
||||
}
|
||||
return strings.Join(out, "/")
|
||||
}
|
||||
|
||||
// Name is the key to mention in a hint ("space to reveal"): the first one
|
||||
// bound, or "" if the action is unbound.
|
||||
func Name(b key.Binding) string {
|
||||
if ks := b.Keys(); len(ks) > 0 {
|
||||
return Label(ks[:1])
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Apply overrides the defaults from the config: each action given gets
|
||||
// exactly the keys listed; none unbinds it. Unknown contexts or actions,
|
||||
// and a key bound to two actions that apply together, are errors.
|
||||
func (m *Map) Apply(cfg map[string]map[string][]string) error {
|
||||
byName := map[string]Action{}
|
||||
contexts := map[string]bool{}
|
||||
for _, a := range m.Actions() {
|
||||
byName[a.Context+"."+a.Name] = a
|
||||
contexts[a.Context] = true
|
||||
}
|
||||
for _, ctx := range sortedKeys(cfg) {
|
||||
if !contexts[ctx] {
|
||||
return fmt.Errorf("keybinding: unknown context %q (known: %s)", ctx, strings.Join(sortedKeys(contexts), ", "))
|
||||
}
|
||||
for _, name := range sortedKeys(cfg[ctx]) {
|
||||
a, ok := byName[ctx+"."+name]
|
||||
if !ok {
|
||||
return fmt.Errorf("keybinding.%s: unknown action %q (known: %s)", ctx, name, strings.Join(m.names(ctx), ", "))
|
||||
}
|
||||
*a.Binding = bind(cfg[ctx][name], a.Desc)
|
||||
}
|
||||
}
|
||||
m.Login.SetEnabled(false)
|
||||
return m.check()
|
||||
}
|
||||
|
||||
func (m *Map) names(ctx string) []string {
|
||||
var out []string
|
||||
for _, a := range m.Actions() {
|
||||
if a.Context == ctx {
|
||||
out = append(out, a.Name)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func sortedKeys[V any](set map[string]V) []string {
|
||||
out := make([]string, 0, len(set))
|
||||
for k := range set {
|
||||
out = append(out, k)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// check finds a key bound to two actions that apply together: within a
|
||||
// context, and in the search popup (search and popup).
|
||||
func (m *Map) check() error {
|
||||
for _, group := range [][]string{{Lists}, {Filter}, {Menu}, {Popup}, {Search, Popup}, {GoTo}} {
|
||||
seen := map[string]string{}
|
||||
for _, a := range m.Actions() {
|
||||
if !slices.Contains(group, a.Context) {
|
||||
continue
|
||||
}
|
||||
name := a.Context + "." + a.Name
|
||||
for _, k := range a.Binding.Keys() {
|
||||
if other, ok := seen[k]; ok && other != name {
|
||||
return fmt.Errorf("keybinding: %q is bound to both %s and %s", k, other, name)
|
||||
}
|
||||
seen[k] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Section is a titled group of actions for the ? menu.
|
||||
type Section struct {
|
||||
Title string
|
||||
Entries []Action
|
||||
}
|
||||
|
||||
// Sections groups the actions for the ? menu.
|
||||
func (m *Map) Sections() []Section {
|
||||
var out []Section
|
||||
for _, a := range m.Actions() {
|
||||
if n := len(out); n == 0 || out[n-1].Title != a.Group {
|
||||
out = append(out, Section{Title: a.Group})
|
||||
}
|
||||
out[len(out)-1].Entries = append(out[len(out)-1].Entries, a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FilterHelp is shown while typing a filter.
|
||||
type FilterHelp struct{ Map }
|
||||
|
||||
func (f FilterHelp) ShortHelp() []key.Binding {
|
||||
return []key.Binding{f.FilterDown, f.FilterUp, f.FilterCopy, f.FilterReveal, f.FilterStar, f.AcceptFilter, f.ClearFilter}
|
||||
}
|
||||
|
||||
func (f FilterHelp) FullHelp() [][]key.Binding { return [][]key.Binding{f.ShortHelp()} }
|
||||
|
||||
// YAML is the keymap as a config section, every action with its keys and
|
||||
// what it does, to copy into the config and edit.
|
||||
func (m *Map) YAML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString("keybinding:\n")
|
||||
ctx := ""
|
||||
for _, a := range m.Actions() {
|
||||
if a.Context != ctx {
|
||||
ctx = a.Context
|
||||
fmt.Fprintf(&b, " %s:\n", ctx)
|
||||
}
|
||||
quoted := make([]string, len(a.Binding.Keys()))
|
||||
for i, k := range a.Binding.Keys() {
|
||||
quoted[i] = fmt.Sprintf("%q", k)
|
||||
}
|
||||
fmt.Fprintf(&b, " %-15s [%s] # %s\n", a.Name+":", strings.Join(quoted, ", "), a.Desc)
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// CheckExtra reports a key some other binding in the lists takes, for keys
|
||||
// added outside the keymap (custom commands). name says whose key it is.
|
||||
func (m *Map) CheckExtra(k, name string) error {
|
||||
for _, a := range m.Actions() {
|
||||
if a.Context == Lists && slices.Contains(a.Binding.Keys(), k) {
|
||||
return fmt.Errorf("%s: %q is bound to lists.%s", name, k, a.Name)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package keys
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/bubbles/v2/key"
|
||||
tea "charm.land/bubbletea/v2"
|
||||
)
|
||||
|
||||
func press(s string) tea.KeyPressMsg { return tea.KeyPressMsg{Code: rune(s[0]), Text: s} }
|
||||
|
||||
func TestDefaultsHaveNoConflicts(t *testing.T) {
|
||||
m := Default()
|
||||
if err := m.check(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, a := range m.Actions() {
|
||||
if len(a.Binding.Keys()) == 0 {
|
||||
t.Errorf("%s.%s has no default keys", a.Context, a.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply(t *testing.T) {
|
||||
m := Default()
|
||||
err := m.Apply(map[string]map[string][]string{
|
||||
"lists": {"star": {"z"}, "versions": {}},
|
||||
"filter": {"star": {"ctrl+x"}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !key.Matches(press("z"), m.Favourite) || key.Matches(press("f"), m.Favourite) {
|
||||
t.Error("star not rebound from f to z")
|
||||
}
|
||||
if m.Favourite.Help().Key != "z" {
|
||||
t.Errorf("help shows %q", m.Favourite.Help().Key)
|
||||
}
|
||||
if m.Versions.Enabled() || Name(m.Versions) != "" {
|
||||
t.Error("versions not unbound")
|
||||
}
|
||||
if Name(m.FilterStar) != "ctrl+x" || Name(m.PopupStar) != "ctrl+f" {
|
||||
t.Errorf("filter star %q, popup star %q", Name(m.FilterStar), Name(m.PopupStar))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyErrors(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
cfg map[string]map[string][]string
|
||||
want string
|
||||
}{
|
||||
{map[string]map[string][]string{"panes": {"star": {"x"}}}, `unknown context "panes"`},
|
||||
{map[string]map[string][]string{"lists": {"starr": {"x"}}}, `unknown action "starr"`},
|
||||
{map[string]map[string][]string{"lists": {"star": {"s"}}}, `"s" is bound to both`}, // share
|
||||
{map[string]map[string][]string{"search": {"load": {"ctrl+f"}}}, `"ctrl+f" is bound to both`}, // popup star
|
||||
} {
|
||||
m := Default()
|
||||
if err := m.Apply(c.cfg); err == nil || !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("%v: %v, want %q", c.cfg, err, c.want)
|
||||
}
|
||||
}
|
||||
// The same key in contexts that never apply together is fine.
|
||||
m := Default()
|
||||
if err := m.Apply(map[string]map[string][]string{"filter": {"star": {"ctrl+r"}, "reveal": {"ctrl+e"}}}); err != nil {
|
||||
t.Errorf("filter ctrl+r and search ctrl+r clash: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLabel(t *testing.T) {
|
||||
for keys, want := range map[string]string{
|
||||
"k up": "k/↑",
|
||||
"ctrl+j ctrl+n down": "ctrl+j/n",
|
||||
"space": "space",
|
||||
"ctrl+left <": "ctrl+←/<",
|
||||
} {
|
||||
if got := Label(strings.Fields(keys)); got != want {
|
||||
t.Errorf("Label(%s) = %q, want %q", keys, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package panes contains the lazygit-style bordered panes.
|
||||
package panes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
// Box draws a rounded border of exactly w×h cells with the title embedded in
|
||||
// the top edge and an optional footer in the bottom edge. The title is
|
||||
// rendered as given, so callers style it (see TitleStyle). Body lines are
|
||||
// truncated or padded to fit; extra lines are dropped.
|
||||
func Box(st styles.Styles, title, footer string, body []string, w, h int, focused bool) string {
|
||||
return BoxCounted(st, title, footer, "", body, w, h, focused)
|
||||
}
|
||||
|
||||
// BoxCounted is Box with a counter like "3 of 12" at the right end of the
|
||||
// bottom edge, where lazygit puts it.
|
||||
func BoxCounted(st styles.Styles, title, footer, counter string, body []string, w, h int, focused bool) string {
|
||||
if w < 4 || h < 2 {
|
||||
return ""
|
||||
}
|
||||
border := st.BorderBlurred
|
||||
if focused {
|
||||
border = st.BorderFocused
|
||||
}
|
||||
plain := func(s ...string) string { return strings.Join(s, "") }
|
||||
inner := w - 2
|
||||
|
||||
edge := func(left, right, label string, styled func(...string) string) string {
|
||||
if label == "" {
|
||||
return border.Render(left + strings.Repeat("─", inner) + right)
|
||||
}
|
||||
label = ansi.Truncate(" "+label+" ", inner-1, "…")
|
||||
fill := inner - 1 - ansi.StringWidth(label)
|
||||
return border.Render(left+"─") + styled(label) + border.Render(strings.Repeat("─", fill)+right)
|
||||
}
|
||||
|
||||
var b strings.Builder
|
||||
b.WriteString(edge("╭", "╮", title, plain))
|
||||
side := border.Render("│")
|
||||
for i := range h - 2 {
|
||||
line := ""
|
||||
if i < len(body) {
|
||||
line = ansi.Truncate(body[i], inner, "…")
|
||||
}
|
||||
b.WriteString("\n" + side + line + strings.Repeat(" ", inner-ansi.StringWidth(line)) + side)
|
||||
}
|
||||
bottom := edge("╰", "╯", footer, border.Render)
|
||||
if counter != "" {
|
||||
// Overwrite the end of the bottom edge: "… 3 of 12 ─╯".
|
||||
label := " " + counter + " "
|
||||
if keep := w - 2 - ansi.StringWidth(label); keep > 2 {
|
||||
bottom = ansi.Truncate(bottom, keep, "") + border.Render(label+"─╯")
|
||||
}
|
||||
}
|
||||
b.WriteString("\n" + bottom)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TitleStyle returns the style for a pane title.
|
||||
func TitleStyle(st styles.Styles, focused bool) lipgloss.Style {
|
||||
if focused {
|
||||
return st.TitleFocused
|
||||
}
|
||||
return st.TitleBlurred
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/icons"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
type ValueState int
|
||||
|
||||
const (
|
||||
ValueHidden ValueState = iota
|
||||
ValueLoading
|
||||
ValueShown
|
||||
ValueFailed
|
||||
)
|
||||
|
||||
// Detail shows metadata for the selected secret (or container) and, on
|
||||
// request, its value.
|
||||
type Detail struct {
|
||||
Now func() time.Time
|
||||
Icons icons.Set
|
||||
// RevealKey and VersionsKey are the keys hints mention ("space to
|
||||
// reveal"); a hint is left out when its key is "" (unbound).
|
||||
RevealKey, VersionsKey string
|
||||
|
||||
secret *provider.Object
|
||||
container *provider.Container
|
||||
|
||||
state ValueState
|
||||
value provider.SecretValue
|
||||
err error
|
||||
hidesIn string // when a shown value hides by itself, if it does
|
||||
|
||||
describing bool
|
||||
describeErr error
|
||||
|
||||
versions []provider.Object // newest first; nil while unknown
|
||||
note string // the user's note on what's shown
|
||||
|
||||
offset int
|
||||
w, h int
|
||||
}
|
||||
|
||||
func NewDetail(iconSet icons.Set) Detail { return Detail{Now: time.Now, Icons: iconSet} }
|
||||
|
||||
// ShowSecret selects a secret and forgets any previously fetched value.
|
||||
func (d *Detail) ShowSecret(s provider.Object) {
|
||||
d.Clear()
|
||||
d.secret = &s
|
||||
d.container = &s.Container
|
||||
}
|
||||
|
||||
// ShowContainer shows a container summary when no secret is selected.
|
||||
func (d *Detail) ShowContainer(c provider.Container) {
|
||||
d.Clear()
|
||||
d.container = &c
|
||||
}
|
||||
|
||||
// Clear drops the selection and any fetched value.
|
||||
func (d *Detail) Clear() {
|
||||
*d = Detail{Now: d.Now, Icons: d.Icons, RevealKey: d.RevealKey, VersionsKey: d.VersionsKey, w: d.w, h: d.h} // drops the note too
|
||||
}
|
||||
|
||||
// SetDescribing marks extra metadata as being fetched.
|
||||
func (d *Detail) SetDescribing() { d.describing, d.describeErr = true, nil }
|
||||
|
||||
// SetDescribed replaces the shown object's metadata, keeping the value
|
||||
// state. It ignores objects other than the one shown.
|
||||
func (d *Detail) SetDescribed(o provider.Object) {
|
||||
if d.secret == nil || d.secret.ID != o.ID {
|
||||
return
|
||||
}
|
||||
// Keep the container (and so the account) the object was shown with;
|
||||
// value requests are routed by it.
|
||||
o.Container = d.secret.Container
|
||||
d.secret = &o
|
||||
d.describing = false
|
||||
}
|
||||
|
||||
// SetNote shows the user's note on the shown object or container.
|
||||
func (d *Detail) SetNote(note string) { d.note = note }
|
||||
|
||||
// SetVersions records the shown object's versions, newest first. It
|
||||
// ignores objects other than the one shown.
|
||||
func (d *Detail) SetVersions(id string, versions []provider.Object) {
|
||||
if d.secret != nil && d.secret.ID == id {
|
||||
d.versions = versions
|
||||
}
|
||||
}
|
||||
|
||||
func (d *Detail) SetDescribeError(err error) { d.describing, d.describeErr = false, err }
|
||||
|
||||
func (d Detail) Secret() (provider.Object, bool) {
|
||||
if d.secret == nil {
|
||||
return provider.Object{}, false
|
||||
}
|
||||
return *d.secret, true
|
||||
}
|
||||
|
||||
// Container is the container shown, if any.
|
||||
func (d Detail) Container() (provider.Container, bool) {
|
||||
if d.container == nil {
|
||||
return provider.Container{}, false
|
||||
}
|
||||
return *d.container, true
|
||||
}
|
||||
|
||||
// Value returns the value if it is shown.
|
||||
func (d Detail) Value() (provider.SecretValue, bool) {
|
||||
return d.value, d.state == ValueShown
|
||||
}
|
||||
|
||||
func (d Detail) State() ValueState { return d.state }
|
||||
|
||||
func (d *Detail) SetLoading() { d.state, d.err = ValueLoading, nil }
|
||||
|
||||
func (d *Detail) SetValue(v provider.SecretValue) { d.state, d.value, d.err = ValueShown, v, nil }
|
||||
|
||||
func (d *Detail) SetValueError(err error) { d.state, d.err = ValueFailed, err }
|
||||
|
||||
// Hide masks the value again and drops it from memory.
|
||||
func (d *Detail) Hide() {
|
||||
d.state, d.value, d.err, d.hidesIn = ValueHidden, provider.SecretValue{}, nil, ""
|
||||
}
|
||||
|
||||
// SetHidesIn shows when a revealed value hides by itself, e.g. "42s".
|
||||
func (d *Detail) SetHidesIn(s string) { d.hidesIn = s }
|
||||
|
||||
func (d *Detail) SetSize(w, h int) {
|
||||
d.w, d.h = w, h
|
||||
d.Scroll(0)
|
||||
}
|
||||
|
||||
func (d *Detail) Scroll(delta int) {
|
||||
total := len(d.lines(styles.Styles{}))
|
||||
d.offset = max(0, min(d.offset+delta, total-(d.h-2)))
|
||||
}
|
||||
|
||||
func (d *Detail) ScrollTop() { d.offset = 0 }
|
||||
func (d *Detail) ScrollBottom() { d.Scroll(1 << 30) }
|
||||
|
||||
func (d Detail) View(st styles.Styles, focused bool, spin string) string {
|
||||
lines := d.lines(st)
|
||||
if spin != "" && d.state == ValueLoading {
|
||||
for i, l := range lines {
|
||||
lines[i] = strings.Replace(l, spinnerPlaceholder, spin, 1)
|
||||
}
|
||||
}
|
||||
rows := max(0, d.h-2)
|
||||
footer := ""
|
||||
if len(lines) > rows {
|
||||
footer = fmt.Sprintf("%d-%d of %d", d.offset+1, min(d.offset+rows, len(lines)), len(lines))
|
||||
}
|
||||
title := "Details"
|
||||
if d.secret != nil {
|
||||
title = string(d.secret.Type)
|
||||
}
|
||||
return Box(st, TitleStyle(st, focused).Render(title), footer, lines[min(d.offset, len(lines)):], d.w, d.h, focused)
|
||||
}
|
||||
|
||||
const spinnerPlaceholder = "\x00spin\x00"
|
||||
|
||||
// lines renders the body, wrapped to the pane width.
|
||||
func (d Detail) lines(st styles.Styles) []string {
|
||||
inner := max(1, d.w-3)
|
||||
var out []string
|
||||
field := func(label, value string) {
|
||||
prefix := " " + st.Label.Render(fmt.Sprintf("%-10s", label)) + " "
|
||||
indent := strings.Repeat(" ", 12)
|
||||
wrapped := strings.Split(ansi.Hardwrap(value, max(1, inner-11), true), "\n")
|
||||
out = append(out, prefix+wrapped[0])
|
||||
for _, w := range wrapped[1:] {
|
||||
out = append(out, indent+w)
|
||||
}
|
||||
}
|
||||
muted := st.Muted.Render
|
||||
|
||||
if d.container == nil {
|
||||
return []string{muted(" Select a vault or scope to browse its secrets.")}
|
||||
}
|
||||
c := *d.container
|
||||
containerLabel := map[provider.Kind]string{provider.KindDatabricks: "scope", provider.KindRepo: "file"}[c.Source.Kind]
|
||||
if containerLabel == "" {
|
||||
containerLabel = "vault"
|
||||
}
|
||||
|
||||
if d.secret == nil {
|
||||
field(containerLabel, c.Name)
|
||||
if d.note != "" {
|
||||
field("note", st.Favourite.Render(d.note))
|
||||
}
|
||||
if c.Detail != "" {
|
||||
field("", muted(c.Detail))
|
||||
}
|
||||
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
||||
out = append(out, "", muted(" Select a secret to see its details."))
|
||||
return out
|
||||
}
|
||||
|
||||
s := *d.secret
|
||||
field("name", s.Name)
|
||||
if d.note != "" {
|
||||
field("note", st.Favourite.Render(d.note))
|
||||
}
|
||||
field(containerLabel, c.Name)
|
||||
if c.Source.Kind != provider.KindAzure && c.Detail != "" {
|
||||
field("", muted(c.Detail))
|
||||
}
|
||||
field("source", d.Icons.Prefix(c.Source.Kind)+cmp.Or(c.Source.ProviderLabel, string(c.Source.Kind))+" / "+c.Source.Name)
|
||||
if s.Version != "" {
|
||||
field("version", s.Version)
|
||||
}
|
||||
|
||||
if s.Enabled != nil {
|
||||
if *s.Enabled {
|
||||
field("enabled", "yes")
|
||||
} else {
|
||||
field("enabled", st.Error.Render("no"))
|
||||
}
|
||||
}
|
||||
if !s.Updated.IsZero() {
|
||||
field("updated", formatTime(s.Updated))
|
||||
}
|
||||
if !s.Expires.IsZero() {
|
||||
exp := formatTime(s.Expires)
|
||||
if s.Expires.Before(d.Now()) {
|
||||
exp = st.Error.Render(exp + " (expired)")
|
||||
}
|
||||
field("expires", exp)
|
||||
}
|
||||
if s.ContentType != "" {
|
||||
field("content", s.ContentType)
|
||||
}
|
||||
for _, a := range s.Attrs {
|
||||
field(a.Label, a.Value)
|
||||
}
|
||||
if len(s.Tags) > 0 {
|
||||
var tags []string
|
||||
for _, k := range slices.Sorted(maps.Keys(s.Tags)) {
|
||||
tags = append(tags, k+"="+s.Tags[k])
|
||||
}
|
||||
field("tags", strings.Join(tags, ", "))
|
||||
}
|
||||
if s.Reference != "" {
|
||||
field("ref", muted(s.Reference))
|
||||
}
|
||||
switch {
|
||||
case d.describing:
|
||||
out = append(out, muted(" loading details…"))
|
||||
case d.describeErr != nil:
|
||||
field("details", st.Error.Render(d.describeErr.Error()))
|
||||
}
|
||||
|
||||
// Secrets are masked; keys and certificates only expose public material.
|
||||
keyHint := func(k, what string) string {
|
||||
if k == "" {
|
||||
return ""
|
||||
}
|
||||
return "(" + k + " to " + what + ")"
|
||||
}
|
||||
label, hidden := "value", st.Masked.Render("••••••••")+muted(" "+keyHint(d.RevealKey, "reveal"))
|
||||
switch s.Type {
|
||||
case provider.TypeKey:
|
||||
label, hidden = "public", muted(keyHint(d.RevealKey, "show public key"))
|
||||
case provider.TypeCertificate:
|
||||
label, hidden = "cert", muted(keyHint(d.RevealKey, "show certificate"))
|
||||
}
|
||||
|
||||
out = append(out, "")
|
||||
switch d.state {
|
||||
case ValueHidden:
|
||||
field(label, hidden)
|
||||
case ValueLoading:
|
||||
field(label, spinnerPlaceholder+" fetching…")
|
||||
case ValueFailed:
|
||||
// Errors can run over several lines (sops lists every key it
|
||||
// tried): all of them, each wrapped at spaces and styled alone,
|
||||
// so lines aren't padded to the longest.
|
||||
for i, line := range strings.Split(strings.TrimRight(d.err.Error(), "\n"), "\n") {
|
||||
// Keep a line's own indent, on each part it wraps into.
|
||||
text := strings.TrimLeft(line, " ")
|
||||
lead := strings.Repeat(" ", len(line)-len(text))
|
||||
for j, w := range strings.Split(ansi.Wrap(text, max(1, inner-11-len(lead)), ""), "\n") {
|
||||
w = lead + w
|
||||
if i == 0 && j == 0 {
|
||||
out = append(out, " "+st.Label.Render(fmt.Sprintf("%-10s", label))+" "+st.Error.Render(w))
|
||||
} else {
|
||||
out = append(out, strings.Repeat(" ", 12)+st.Error.Render(w))
|
||||
}
|
||||
}
|
||||
}
|
||||
case ValueShown:
|
||||
var hints []string
|
||||
if d.RevealKey != "" {
|
||||
hints = append(hints, d.RevealKey+" to hide")
|
||||
}
|
||||
if d.hidesIn != "" {
|
||||
hints = append(hints, "hides in "+d.hidesIn)
|
||||
}
|
||||
line := " " + st.Label.Render(label)
|
||||
if len(hints) > 0 {
|
||||
line += muted(" (" + strings.Join(hints, " · ") + ")")
|
||||
}
|
||||
out = append(out, line)
|
||||
for _, line := range strings.Split(d.value.Reveal(), "\n") {
|
||||
for _, w := range strings.Split(ansi.Hardwrap(line, inner, true), "\n") {
|
||||
out = append(out, " "+st.Value.Render(w))
|
||||
}
|
||||
}
|
||||
}
|
||||
return append(out, d.versionLines(st)...)
|
||||
}
|
||||
|
||||
// maxVersionLines caps the version history shown in the detail pane; the
|
||||
// versions view (v) shows them all.
|
||||
const maxVersionLines = 10
|
||||
|
||||
func (d Detail) versionLines(st styles.Styles) []string {
|
||||
if len(d.versions) == 0 {
|
||||
return nil
|
||||
}
|
||||
muted := st.Muted.Render
|
||||
head := fmt.Sprintf("%d", len(d.versions))
|
||||
if len(d.versions) > 1 {
|
||||
head = st.Notice.Render(head)
|
||||
if d.VersionsKey != "" {
|
||||
head += muted(" · " + d.VersionsKey + " to browse")
|
||||
}
|
||||
}
|
||||
out := []string{"", " " + st.Label.Render(fmt.Sprintf("%-10s", "versions")) + " " + head}
|
||||
current := d.versions[0].Version
|
||||
for i, v := range d.versions {
|
||||
if i == maxVersionLines {
|
||||
out = append(out, muted(fmt.Sprintf(" … %d more", len(d.versions)-i)))
|
||||
break
|
||||
}
|
||||
marker := " "
|
||||
if d.secret.Version != "" && v.Version == d.secret.Version {
|
||||
marker = "▸ " // the version shown above
|
||||
}
|
||||
out = append(out, " "+marker+VersionLine(st, v, d.Now(), current))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// VersionLine draws a version as "id date state".
|
||||
func VersionLine(st styles.Styles, v provider.Object, now time.Time, current string) string {
|
||||
id := v.Version
|
||||
if len(id) > 8 {
|
||||
id = id[:8]
|
||||
}
|
||||
line := fmt.Sprintf("%-8s %s", id, v.Updated.Local().Format("2006-01-02"))
|
||||
switch {
|
||||
case v.Enabled != nil && !*v.Enabled:
|
||||
return st.Muted.Render(line + " disabled")
|
||||
case !v.Expires.IsZero() && v.Expires.Before(now):
|
||||
return st.Muted.Render(line + " expired")
|
||||
case v.Version == current:
|
||||
return line + st.Notice.Render(" current")
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
func formatTime(t time.Time) string { return t.Local().Format("2006-01-02 15:04") }
|
||||
@@ -0,0 +1,53 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"image/color"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
)
|
||||
|
||||
// sgr matches SGR escape sequences.
|
||||
var sgr = regexp.MustCompile("\x1b\\[([0-9;:]*)m")
|
||||
|
||||
// Highlight draws a cursor row: line, padded to width, on style's
|
||||
// background, keeping the colours line already has (a provider's colour,
|
||||
// a dimmed note), like LazyVim's and lazygit's selected lines; bold
|
||||
// carries over too. A reverse style is applied to the plain text instead,
|
||||
// since reversing coloured text would scramble it.
|
||||
func Highlight(line string, style lipgloss.Style, width int) string {
|
||||
pad := strings.Repeat(" ", max(0, width-ansi.StringWidth(line)))
|
||||
if style.GetReverse() {
|
||||
return style.Render(ansi.Strip(line) + pad)
|
||||
}
|
||||
var s ansi.Style
|
||||
if bg := style.GetBackground(); !isNoColor(bg) { // never nil: NoColor when unset
|
||||
s = s.BackgroundColor(bg)
|
||||
}
|
||||
if style.GetBold() {
|
||||
s = s.Bold()
|
||||
}
|
||||
if len(s) == 0 {
|
||||
return line + pad
|
||||
}
|
||||
on := s.String()
|
||||
// Every reset in line (a bare reset, one starting with 0, or a
|
||||
// background reset) would end the highlight: put it back after each.
|
||||
line = sgr.ReplaceAllStringFunc(line, func(seq string) string {
|
||||
params := sgr.FindStringSubmatch(seq)[1]
|
||||
for _, p := range strings.Split(params, ";") {
|
||||
if p == "" || p == "0" || p == "49" {
|
||||
return seq + on
|
||||
}
|
||||
}
|
||||
return seq
|
||||
})
|
||||
return on + line + pad + ansi.ResetStyle
|
||||
}
|
||||
|
||||
func isNoColor(c color.Color) bool {
|
||||
_, ok := c.(lipgloss.NoColor)
|
||||
return ok
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"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/ui/styles"
|
||||
)
|
||||
|
||||
// Item adapts a value for display in a List.
|
||||
type Item[T any] struct {
|
||||
ID func(T) string // stable identity, used to detect selection changes
|
||||
Filter func(T) string // text matched by the filter, and highlighted
|
||||
// FilterExtra, if set, is matched too but not shown highlighted, for
|
||||
// text the row doesn't show (e.g. the provider of a grouped source).
|
||||
FilterExtra func(T) string
|
||||
Render func(st styles.Styles, v T, width int) string
|
||||
}
|
||||
|
||||
// List is a scrollable, filterable list pane with loading and error states.
|
||||
type List[T any] struct {
|
||||
Title string
|
||||
// Tabs, when there are several, are shown after the title; Tab is the
|
||||
// active one.
|
||||
Tabs []string
|
||||
Tab int
|
||||
Empty string // shown when loaded with no items
|
||||
Loading bool
|
||||
Err error
|
||||
|
||||
item Item[T]
|
||||
items []T
|
||||
visible []int // indices into items that match the filter, best first
|
||||
hits [][]int // per visible item: matched rune indices in its Filter text
|
||||
cursor int // index into visible
|
||||
offset int // first visible row
|
||||
w, h int // outer size including border
|
||||
|
||||
input textinput.Model
|
||||
filtering bool
|
||||
}
|
||||
|
||||
func NewList[T any](title string, item Item[T]) List[T] {
|
||||
in := textinput.New()
|
||||
in.Prompt = "/"
|
||||
return List[T]{Title: title, Empty: "nothing here", item: item, input: in}
|
||||
}
|
||||
|
||||
// SetRender changes how items are drawn, e.g. when the pane switches to
|
||||
// showing versions.
|
||||
func (l *List[T]) SetRender(render func(st styles.Styles, v T, width int) string) {
|
||||
l.item.Render = render
|
||||
}
|
||||
|
||||
// SetItems replaces the contents, keeping the cursor on the same item if it
|
||||
// is still present.
|
||||
func (l *List[T]) SetItems(items []T) {
|
||||
prev := l.SelectedID()
|
||||
l.items = items
|
||||
l.Loading = false
|
||||
l.Err = nil
|
||||
l.applyFilter(prev)
|
||||
}
|
||||
|
||||
// Reset clears items, error, filter and cursor.
|
||||
func (l *List[T]) Reset() {
|
||||
l.items, l.visible = nil, nil
|
||||
l.cursor, l.offset = 0, 0
|
||||
l.Loading, l.Err = false, nil
|
||||
l.filtering = false
|
||||
l.input.Reset()
|
||||
}
|
||||
|
||||
func (l *List[T]) SetError(err error) {
|
||||
l.Reset()
|
||||
l.Err = err
|
||||
}
|
||||
|
||||
// Refresh marks the pane as loading while keeping the current items and
|
||||
// selection on screen until SetItems replaces them.
|
||||
func (l *List[T]) Refresh() {
|
||||
l.Loading = true
|
||||
l.Err = nil
|
||||
}
|
||||
|
||||
func (l *List[T]) SetLoading() {
|
||||
l.Reset()
|
||||
l.Loading = true
|
||||
}
|
||||
|
||||
func (l List[T]) Selected() (T, bool) {
|
||||
if l.cursor < 0 || l.cursor >= len(l.visible) {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
return l.items[l.visible[l.cursor]], true
|
||||
}
|
||||
|
||||
// SelectedID is "" when nothing is selected.
|
||||
func (l List[T]) SelectedID() string {
|
||||
v, ok := l.Selected()
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return l.item.ID(v)
|
||||
}
|
||||
|
||||
func (l List[T]) Len() int { return len(l.visible) }
|
||||
|
||||
// Width is the pane's outer width.
|
||||
func (l List[T]) Width() int { return l.w }
|
||||
|
||||
// Items returns all items, ignoring the filter.
|
||||
func (l List[T]) Items() []T { return l.items }
|
||||
|
||||
// Select moves the cursor to the item with the given ID, if visible, and
|
||||
// reports whether it was.
|
||||
func (l *List[T]) Select(id string) bool {
|
||||
return l.SelectWhere(func(v T) bool { return l.item.ID(v) == id })
|
||||
}
|
||||
|
||||
// SelectWhere moves the cursor to the first visible item matching f.
|
||||
func (l *List[T]) SelectWhere(f func(T) bool) bool {
|
||||
for i, idx := range l.visible {
|
||||
if f(l.items[idx]) {
|
||||
l.cursor = i
|
||||
l.scroll()
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (l *List[T]) Move(delta int) {
|
||||
l.cursor = max(0, min(l.cursor+delta, len(l.visible)-1))
|
||||
l.scroll()
|
||||
}
|
||||
|
||||
func (l *List[T]) Top() { l.Move(-len(l.visible)) }
|
||||
func (l *List[T]) Bottom() { l.Move(len(l.visible)) }
|
||||
|
||||
// SetSize sets the outer size of the pane, including its border.
|
||||
func (l *List[T]) SetSize(w, h int) {
|
||||
l.w, l.h = w, h
|
||||
l.input.SetWidth(max(1, w-4))
|
||||
l.scroll()
|
||||
}
|
||||
|
||||
// rows is the number of item rows that fit in the pane.
|
||||
func (l List[T]) rows() int {
|
||||
r := l.h - 2
|
||||
if l.filtering {
|
||||
r--
|
||||
}
|
||||
return max(0, r)
|
||||
}
|
||||
|
||||
// Filtering reports whether the filter input has focus.
|
||||
func (l List[T]) Filtering() bool { return l.filtering }
|
||||
|
||||
// HasFilter reports whether a filter query is applied.
|
||||
func (l List[T]) HasFilter() bool { return l.input.Value() != "" }
|
||||
|
||||
func (l *List[T]) StartFilter() tea.Cmd {
|
||||
l.filtering = true
|
||||
l.scroll()
|
||||
return l.input.Focus()
|
||||
}
|
||||
|
||||
// AcceptFilter leaves filter mode, keeping the query applied.
|
||||
func (l *List[T]) AcceptFilter() {
|
||||
l.filtering = false
|
||||
l.input.Blur()
|
||||
}
|
||||
|
||||
// ClearFilter leaves filter mode and removes the query.
|
||||
func (l *List[T]) ClearFilter() {
|
||||
l.filtering = false
|
||||
l.input.Blur()
|
||||
prev := l.SelectedID()
|
||||
l.input.Reset()
|
||||
l.applyFilter(prev)
|
||||
}
|
||||
|
||||
// UpdateFilter forwards a message to the filter input and re-filters.
|
||||
func (l *List[T]) UpdateFilter(msg tea.Msg) tea.Cmd {
|
||||
var cmd tea.Cmd
|
||||
prev := l.SelectedID()
|
||||
l.input, cmd = l.input.Update(msg)
|
||||
l.applyFilter(prev)
|
||||
return cmd
|
||||
}
|
||||
|
||||
// applyFilter recomputes the visible items and puts the cursor back on the
|
||||
// item with ID keep if it is still visible.
|
||||
func (l *List[T]) applyFilter(keep string) {
|
||||
l.visible, l.hits = l.visible[:0], nil
|
||||
if q := l.input.Value(); q == "" {
|
||||
for i := range l.items {
|
||||
l.visible = append(l.visible, i)
|
||||
}
|
||||
} else {
|
||||
// The row's own text is the name; FilterExtra (a note, a provider)
|
||||
// is context, matched too but ranked lower.
|
||||
items := make([]match.Item, len(l.items))
|
||||
for i, v := range l.items {
|
||||
text := l.item.Filter(v)
|
||||
items[i] = match.Item{Text: text, NameEnd: utf8.RuneCountInString(text)}
|
||||
if l.item.FilterExtra != nil {
|
||||
if extra := l.item.FilterExtra(v); extra != "" {
|
||||
items[i].Text += " " + extra
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, h := range match.Rank(match.Parse(q), items) {
|
||||
var hits []int // the ones in the text the row shows
|
||||
for _, p := range h.Pos {
|
||||
if p < items[h.Index].NameEnd {
|
||||
hits = append(hits, p)
|
||||
}
|
||||
}
|
||||
l.visible = append(l.visible, h.Index)
|
||||
l.hits = append(l.hits, hits)
|
||||
}
|
||||
}
|
||||
l.cursor = max(0, min(l.cursor, len(l.visible)-1))
|
||||
if keep != "" {
|
||||
l.Select(keep)
|
||||
}
|
||||
l.scroll()
|
||||
}
|
||||
|
||||
// MarkMatches styles the characters at hits (rune indexes in the text
|
||||
// shown, without styling) in a styled string, keeping its own colours.
|
||||
func MarkMatches(styled string, hits []int, style lipgloss.Style) string {
|
||||
if len(hits) == 0 {
|
||||
return styled
|
||||
}
|
||||
return lipgloss.StyleRanges(styled, matchRanges(ansi.Strip(styled), hits, style)...)
|
||||
}
|
||||
|
||||
// matchRanges turns matched rune indexes in plain into ranges of screen
|
||||
// cells, merging neighbours, for lipgloss.StyleRanges.
|
||||
func matchRanges(plain string, hits []int, style lipgloss.Style) []lipgloss.Range {
|
||||
runes := []rune(plain)
|
||||
cell := func(i int) int { return ansi.StringWidth(string(runes[:min(i, len(runes))])) }
|
||||
var out []lipgloss.Range
|
||||
for i := 0; i < len(hits); {
|
||||
j := i
|
||||
for j+1 < len(hits) && hits[j+1] == hits[j]+1 {
|
||||
j++
|
||||
}
|
||||
if hits[i] < len(runes) {
|
||||
out = append(out, lipgloss.NewRange(cell(hits[i]), cell(hits[j]+1), style))
|
||||
}
|
||||
i = j + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// RuneIndexes converts byte offsets in s to rune offsets.
|
||||
func RuneIndexes(s string, bytes []int) []int {
|
||||
out := make([]int, 0, len(bytes))
|
||||
for _, b := range bytes {
|
||||
out = append(out, utf8.RuneCountInString(s[:b]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// highlight returns the rune indexes to highlight in a row's plain text:
|
||||
// the filter hits, shifted to where the Filter text appears in the row.
|
||||
// It returns nil if the row doesn't contain the Filter text verbatim.
|
||||
func (l List[T]) highlight(i int, plain string) []int {
|
||||
if i >= len(l.hits) {
|
||||
return nil
|
||||
}
|
||||
off := strings.Index(plain, l.item.Filter(l.items[l.visible[i]]))
|
||||
if off < 0 {
|
||||
return nil
|
||||
}
|
||||
shift := utf8.RuneCountInString(plain[:off])
|
||||
out := make([]int, len(l.hits[i]))
|
||||
for j, h := range l.hits[i] {
|
||||
out[j] = h + shift
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// View renders the pane at the size given to SetSize. spin is the current
|
||||
// spinner frame.
|
||||
func (l List[T]) View(st styles.Styles, focused bool, spin string) string {
|
||||
rows := l.rows()
|
||||
inner := max(0, l.w-2)
|
||||
|
||||
ts := TitleStyle(st, focused)
|
||||
title := ts.Render(l.Title)
|
||||
if len(l.Tabs) > 1 {
|
||||
for i, tab := range l.Tabs {
|
||||
sep := " - "
|
||||
if i == 0 {
|
||||
sep = " "
|
||||
}
|
||||
if i == l.Tab {
|
||||
title += ts.Render(sep) + ts.Underline(true).Render(tab)
|
||||
} else {
|
||||
title += ts.Render(sep) + st.Muted.Render(tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
if q := l.input.Value(); q != "" && !l.filtering {
|
||||
title += ts.Render(" /" + q)
|
||||
}
|
||||
if l.Loading && len(l.items) > 0 {
|
||||
title += ts.Render(" " + spin)
|
||||
}
|
||||
|
||||
var body []string
|
||||
if l.filtering {
|
||||
body = append(body, l.input.View())
|
||||
}
|
||||
|
||||
switch {
|
||||
case l.Loading && len(l.items) == 0:
|
||||
body = append(body, " "+spin+" loading…")
|
||||
case l.Err != nil:
|
||||
for _, line := range strings.Split(ansi.Wrap(l.Err.Error(), inner-1, " -/"), "\n") {
|
||||
body = append(body, st.Error.Render(" "+line))
|
||||
}
|
||||
case len(l.visible) == 0 && len(l.items) > 0:
|
||||
body = append(body, st.Muted.Render(" no matches"))
|
||||
case len(l.visible) == 0:
|
||||
body = append(body, st.Muted.Render(" "+l.Empty))
|
||||
default:
|
||||
for i := l.offset; i < len(l.visible) && i < l.offset+rows; i++ {
|
||||
line := l.item.Render(st, l.items[l.visible[i]], inner-1)
|
||||
plain := ansi.Strip(line)
|
||||
hits := l.highlight(i, plain)
|
||||
if i == l.cursor {
|
||||
base := st.SelectedBlurred
|
||||
if focused {
|
||||
base = st.Selected
|
||||
}
|
||||
text := " " + line
|
||||
if len(hits) > 0 {
|
||||
for j := range hits {
|
||||
hits[j]++ // leading space
|
||||
}
|
||||
text = lipgloss.StyleRanges(text, matchRanges(" "+plain, hits, st.Match)...)
|
||||
}
|
||||
line = Highlight(ansi.Truncate(text, inner, "…"), base, inner)
|
||||
} else if len(hits) > 0 {
|
||||
// Style just the matches, keeping the row's own colours.
|
||||
line = " " + lipgloss.StyleRanges(line, matchRanges(plain, hits, st.Match)...)
|
||||
} else {
|
||||
line = " " + line
|
||||
}
|
||||
body = append(body, line)
|
||||
}
|
||||
}
|
||||
|
||||
counter := ""
|
||||
if len(l.visible) > 0 {
|
||||
counter = fmt.Sprintf("%d of %d", l.cursor+1, len(l.visible))
|
||||
}
|
||||
return BoxCounted(st, title, "", counter, body, l.w, l.h, focused)
|
||||
}
|
||||
|
||||
// scroll keeps the cursor within the visible window.
|
||||
func (l *List[T]) scroll() {
|
||||
rows := l.rows()
|
||||
if rows <= 0 {
|
||||
return
|
||||
}
|
||||
if l.cursor < l.offset {
|
||||
l.offset = l.cursor
|
||||
}
|
||||
if l.cursor >= l.offset+rows {
|
||||
l.offset = l.cursor - rows + 1
|
||||
}
|
||||
l.offset = max(0, min(l.offset, len(l.visible)-rows))
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package panes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"charm.land/lipgloss/v2"
|
||||
"github.com/charmbracelet/x/ansi"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/ui/styles"
|
||||
)
|
||||
|
||||
func newStringList(n int) List[string] {
|
||||
l := NewList("Items", Item[string]{
|
||||
ID: func(s string) string { return s },
|
||||
Filter: func(s string) string { return s },
|
||||
Render: func(_ styles.Styles, s string, _ int) string { return s },
|
||||
})
|
||||
var items []string
|
||||
for i := range n {
|
||||
items = append(items, fmt.Sprintf("item-%02d", i))
|
||||
}
|
||||
l.SetItems(items)
|
||||
l.SetSize(20, 7) // 5 rows
|
||||
return l
|
||||
}
|
||||
|
||||
func TestListScrollKeepsCursorVisible(t *testing.T) {
|
||||
l := newStringList(20)
|
||||
l.Move(9)
|
||||
view := ansi.Strip(l.View(styles.Default(), true, ""))
|
||||
if !strings.Contains(view, "item-09") || strings.Contains(view, "item-04") {
|
||||
t.Fatalf("cursor row not scrolled into view:\n%s", view)
|
||||
}
|
||||
// Moving up within the window must not scroll.
|
||||
l.Move(-2)
|
||||
view = ansi.Strip(l.View(styles.Default(), true, ""))
|
||||
if !strings.Contains(view, "item-05") {
|
||||
t.Fatalf("window jumped when moving up inside it:\n%s", view)
|
||||
}
|
||||
l.Top()
|
||||
if l.SelectedID() != "item-00" || l.offset != 0 {
|
||||
t.Fatalf("Top: selected %q offset %d", l.SelectedID(), l.offset)
|
||||
}
|
||||
l.Bottom()
|
||||
if l.SelectedID() != "item-19" || l.offset != 15 {
|
||||
t.Fatalf("Bottom: selected %q offset %d", l.SelectedID(), l.offset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSetItemsKeepsSelection(t *testing.T) {
|
||||
l := newStringList(5)
|
||||
l.Move(3)
|
||||
l.SetItems([]string{"new", "item-03", "item-04"})
|
||||
if l.SelectedID() != "item-03" {
|
||||
t.Fatalf("selected = %q", l.SelectedID())
|
||||
}
|
||||
l.SetItems([]string{"other"})
|
||||
if l.SelectedID() != "other" {
|
||||
t.Fatalf("selected = %q after item vanished", l.SelectedID())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoxExactSize(t *testing.T) {
|
||||
out := Box(styles.Default(), "a very long title that will not fit", "footer", []string{"x", strings.Repeat("y", 50)}, 12, 5, true)
|
||||
lines := strings.Split(out, "\n")
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("height = %d", len(lines))
|
||||
}
|
||||
for _, line := range lines {
|
||||
if w := ansi.StringWidth(line); w != 12 {
|
||||
t.Errorf("width %d: %q", w, ansi.Strip(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func filterList(t *testing.T, items []string, query string) List[string] {
|
||||
t.Helper()
|
||||
l := NewList("Items", Item[string]{
|
||||
ID: func(s string) string { return s },
|
||||
Filter: func(s string) string { return s },
|
||||
Render: func(_ styles.Styles, s string, _ int) string { return s },
|
||||
})
|
||||
l.SetItems(items)
|
||||
l.SetSize(40, 10)
|
||||
l.StartFilter()
|
||||
for _, r := range query {
|
||||
l.UpdateFilter(tea.KeyPressMsg{Code: r, Text: string(r)})
|
||||
}
|
||||
return l
|
||||
}
|
||||
|
||||
func visibleNames(l List[string]) []string {
|
||||
var out []string
|
||||
for _, i := range l.visible {
|
||||
out = append(out, l.items[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestFuzzyFilterRanksAndSkips(t *testing.T) {
|
||||
l := filterList(t, []string{"api-key", "db-password", "storage-key", "dbx-pat"}, "dbpw")
|
||||
if got := visibleNames(l); !reflect.DeepEqual(got, []string{"db-password"}) {
|
||||
t.Errorf("dbpw matched %v", got)
|
||||
}
|
||||
l = filterList(t, []string{"monkey", "key-vault", "api-key"}, "key")
|
||||
if got := visibleNames(l); got[0] != "key-vault" || len(got) != 3 {
|
||||
t.Errorf("prefix match not ranked first: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterHighlightsMatches(t *testing.T) {
|
||||
l := filterList(t, []string{"db-password", "api-key"}, "dpw")
|
||||
l.AcceptFilter()
|
||||
view := l.View(styles.Default(), false, "")
|
||||
// d, p and w are styled; with ANSI stripped the text is unchanged.
|
||||
if !strings.Contains(ansi.Strip(view), "db-password") {
|
||||
t.Fatalf("row missing:\n%s", ansi.Strip(view))
|
||||
}
|
||||
if hits := l.highlight(0, "db-password"); !reflect.DeepEqual(hits, []int{0, 3, 7}) {
|
||||
t.Errorf("hits = %v", hits)
|
||||
}
|
||||
// Rows that show the Filter text at an offset get shifted hits.
|
||||
if hits := l.highlight(0, " db-password (disabled)"); !reflect.DeepEqual(hits, []int{2, 5, 9}) {
|
||||
t.Errorf("shifted hits = %v", hits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilterKeepsRowColours(t *testing.T) {
|
||||
blue := lipgloss.NewStyle().Foreground(lipgloss.Blue)
|
||||
match := styles.Default().Match
|
||||
|
||||
if got := matchRanges("platform-dev", []int{9, 10, 11}, match); len(got) != 1 || got[0].Start != 9 || got[0].End != 12 {
|
||||
t.Errorf("ranges = %+v, want one range 9-12", got)
|
||||
}
|
||||
|
||||
// Only the matches change style; the rest keeps the row's colour.
|
||||
styled := " " + blue.Render("platform-dev")
|
||||
out := lipgloss.StyleRanges(styled, matchRanges(" platform-dev", []int{11, 12, 13}, match)...)
|
||||
if ansi.Strip(out) != " platform-dev" {
|
||||
t.Fatalf("text changed: %q", ansi.Strip(out))
|
||||
}
|
||||
if !strings.Contains(out, "\x1b[34m") {
|
||||
t.Errorf("unmatched text lost its colour: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCounterInBottomRight(t *testing.T) {
|
||||
out := BoxCounted(styles.Default(), "T", "footer", "3 of 12", []string{"x"}, 30, 3, true)
|
||||
bottom := ansi.Strip(strings.Split(out, "\n")[2])
|
||||
if !strings.HasSuffix(bottom, " 3 of 12 ─╯") || !strings.HasPrefix(bottom, "╰─ footer ") || ansi.StringWidth(bottom) != 30 {
|
||||
t.Errorf("bottom edge = %q", bottom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlightKeepsColours(t *testing.T) {
|
||||
blue := lipgloss.NewStyle().Foreground(lipgloss.Blue)
|
||||
sel := lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true)
|
||||
row := " " + blue.Render("platform-prod") + lipgloss.NewStyle().Faint(true).Render(" note")
|
||||
out := Highlight(row, sel, 30)
|
||||
if ansi.StringWidth(out) != 30 || ansi.Strip(out) != ansi.Strip(row)+strings.Repeat(" ", 30-ansi.StringWidth(row)) {
|
||||
t.Fatalf("text or width changed: %q", ansi.Strip(out))
|
||||
}
|
||||
if !strings.Contains(out, "\x1b[34m") {
|
||||
t.Error("row lost its blue")
|
||||
}
|
||||
// The background is re-applied after every reset, so it spans the row.
|
||||
resets := strings.Count(out, "\x1b[m") + strings.Count(out, "\x1b[0m")
|
||||
if bgs := strings.Count(out, "\x1b[100;1m"); bgs < resets {
|
||||
t.Errorf("background not re-applied after each reset: %d resets, %d backgrounds\n%q", resets, bgs, out)
|
||||
}
|
||||
// Reverse styles fall back to plain text.
|
||||
if got := Highlight(row, lipgloss.NewStyle().Reverse(true), 30); strings.Contains(got, "34m") {
|
||||
t.Error("reverse kept the colours")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
// Package styles holds the lipgloss theme. It sticks to the 16 ANSI colours
|
||||
// so it follows the user's terminal palette in both light and dark themes.
|
||||
package styles
|
||||
|
||||
import (
|
||||
"charm.land/lipgloss/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
type Styles struct {
|
||||
BorderFocused lipgloss.Style
|
||||
BorderBlurred lipgloss.Style
|
||||
TitleFocused lipgloss.Style
|
||||
TitleBlurred lipgloss.Style
|
||||
Footer lipgloss.Style
|
||||
|
||||
Selected lipgloss.Style // cursor row in the focused pane
|
||||
SelectedBlurred lipgloss.Style // cursor row in other panes
|
||||
Muted lipgloss.Style
|
||||
Error lipgloss.Style
|
||||
Notice lipgloss.Style
|
||||
Success lipgloss.Style // the ✓ before confirmations
|
||||
Warning lipgloss.Style // "can't do that here" messages
|
||||
Match lipgloss.Style // filter matches
|
||||
Favourite lipgloss.Style
|
||||
Prompt lipgloss.Style // questions in the footer
|
||||
Label lipgloss.Style
|
||||
Masked lipgloss.Style
|
||||
Value lipgloss.Style
|
||||
|
||||
KindAzure lipgloss.Style
|
||||
KindDatabricks lipgloss.Style
|
||||
KindRepo lipgloss.Style
|
||||
}
|
||||
|
||||
func Default() Styles {
|
||||
green := lipgloss.Green
|
||||
return Styles{
|
||||
BorderFocused: lipgloss.NewStyle().Foreground(green),
|
||||
BorderBlurred: lipgloss.NewStyle().Faint(true),
|
||||
TitleFocused: lipgloss.NewStyle().Foreground(green).Bold(true),
|
||||
TitleBlurred: lipgloss.NewStyle(),
|
||||
Footer: lipgloss.NewStyle().Faint(true),
|
||||
|
||||
// A background rather than reverse, so the row keeps its colours.
|
||||
Selected: lipgloss.NewStyle().Background(lipgloss.BrightBlack).Bold(true),
|
||||
SelectedBlurred: lipgloss.NewStyle().Bold(true),
|
||||
Muted: lipgloss.NewStyle().Faint(true),
|
||||
Error: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||
Notice: lipgloss.NewStyle().Foreground(lipgloss.Green),
|
||||
Success: lipgloss.NewStyle().Foreground(lipgloss.Green).Bold(true),
|
||||
Warning: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
||||
Match: lipgloss.NewStyle().Foreground(lipgloss.Yellow).Bold(true),
|
||||
Favourite: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
||||
Prompt: lipgloss.NewStyle().Foreground(lipgloss.Yellow).Bold(true),
|
||||
Label: lipgloss.NewStyle().Foreground(lipgloss.Blue).Bold(true),
|
||||
Masked: lipgloss.NewStyle().Faint(true),
|
||||
Value: lipgloss.NewStyle().Foreground(lipgloss.Yellow),
|
||||
|
||||
KindAzure: lipgloss.NewStyle().Foreground(lipgloss.Blue),
|
||||
KindDatabricks: lipgloss.NewStyle().Foreground(lipgloss.Red),
|
||||
KindRepo: lipgloss.NewStyle().Foreground(lipgloss.Magenta),
|
||||
}
|
||||
}
|
||||
|
||||
// Kind is the colour of a provider's sources, vaults and paths.
|
||||
func (s Styles) Kind(k provider.Kind) lipgloss.Style {
|
||||
switch k {
|
||||
case provider.KindDatabricks:
|
||||
return s.KindDatabricks
|
||||
case provider.KindRepo:
|
||||
return s.KindRepo
|
||||
}
|
||||
return s.KindAzure
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
package styles
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"slices"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
// A theme maps roles to a colour and attributes, like lazygit's gui.theme:
|
||||
//
|
||||
// activeBorderColor: ["#e0af68", bold]
|
||||
// selectedLineBgColor: ["#2d3f76"]
|
||||
//
|
||||
// Role names follow lazygit where the meaning is the same, so an editor
|
||||
// plugin can map them from highlight groups the way snacks.nvim does for
|
||||
// lazygit. Colours are hex (#rrggbb), a palette number (0-255) or a name
|
||||
// (black, red, green, yellow, blue, magenta, cyan, white, their bright
|
||||
// variants like brightred, or default). Attributes: bold, italic,
|
||||
// underline, reverse, faint.
|
||||
|
||||
// Roles lists the theme roles with what they colour.
|
||||
var Roles = map[string]string{
|
||||
"activeBorderColor": "border and title of the focused pane",
|
||||
"inactiveBorderColor": "borders of other panes",
|
||||
"selectedLineBgColor": "background of the cursor row in the focused pane",
|
||||
"inactiveViewSelectedLineBgColor": "background of the cursor row in other panes",
|
||||
"defaultFgColor": "default text colour",
|
||||
"optionsTextColor": "keys in the help bar and menus, and labels in the detail pane",
|
||||
"searchMatchColor": "matched characters when filtering",
|
||||
"errorColor": "✗ errors",
|
||||
"noticeColor": "notices, the clipboard countdown and current versions",
|
||||
"successColor": "the ✓ before confirmations (copied, opened…)",
|
||||
"warningColor": "⚠ messages: what can't be done here, and why",
|
||||
"mutedColor": "secondary text",
|
||||
"favouriteColor": "stars and questions",
|
||||
"valueColor": "revealed values",
|
||||
"azureColor": "Azure sources and vaults",
|
||||
"databricksColor": "Databricks workspaces and scopes",
|
||||
"repoColor": "repos and their secret files (SOPS)",
|
||||
}
|
||||
|
||||
// FromTheme returns the default styles with the theme's roles applied, and
|
||||
// the default text colour if the theme sets one.
|
||||
func FromTheme(theme map[string][]string) (Styles, color.Color, error) {
|
||||
st := Default()
|
||||
var fg color.Color
|
||||
roles := make([]string, 0, len(theme))
|
||||
for r := range theme {
|
||||
roles = append(roles, r)
|
||||
}
|
||||
sort.Strings(roles)
|
||||
for _, role := range roles {
|
||||
if _, ok := Roles[role]; !ok {
|
||||
return st, nil, fmt.Errorf("theme: unknown role %q (known: %s)", role, strings.Join(sortedRoles(), ", "))
|
||||
}
|
||||
v, err := parse(theme[role])
|
||||
if err != nil {
|
||||
return st, nil, fmt.Errorf("theme: %s: %w", role, err)
|
||||
}
|
||||
switch role {
|
||||
case "activeBorderColor":
|
||||
st.BorderFocused = v.fg(lipgloss.NewStyle())
|
||||
st.TitleFocused = v.fg(lipgloss.NewStyle()).Bold(true)
|
||||
case "inactiveBorderColor":
|
||||
st.BorderBlurred = v.fg(lipgloss.NewStyle())
|
||||
case "selectedLineBgColor":
|
||||
st.Selected = v.bg(lipgloss.NewStyle()).Bold(true)
|
||||
case "inactiveViewSelectedLineBgColor":
|
||||
st.SelectedBlurred = v.bg(lipgloss.NewStyle()).Bold(true)
|
||||
case "defaultFgColor":
|
||||
fg = v.color
|
||||
case "optionsTextColor":
|
||||
st.Label = v.fg(lipgloss.NewStyle())
|
||||
case "searchMatchColor":
|
||||
st.Match = v.fg(lipgloss.NewStyle())
|
||||
case "errorColor":
|
||||
st.Error = v.fg(lipgloss.NewStyle())
|
||||
case "noticeColor":
|
||||
st.Notice = v.fg(lipgloss.NewStyle())
|
||||
case "warningColor":
|
||||
st.Warning = v.fg(lipgloss.NewStyle())
|
||||
case "successColor":
|
||||
st.Success = v.fg(lipgloss.NewStyle()).Bold(true)
|
||||
case "mutedColor":
|
||||
st.Muted = v.fg(lipgloss.NewStyle())
|
||||
st.Masked, st.Footer = st.Muted, st.Muted
|
||||
case "favouriteColor":
|
||||
st.Favourite = v.fg(lipgloss.NewStyle())
|
||||
st.Prompt = v.fg(lipgloss.NewStyle()).Bold(true)
|
||||
case "valueColor":
|
||||
st.Value = v.fg(lipgloss.NewStyle())
|
||||
case "azureColor":
|
||||
st.KindAzure = v.fg(lipgloss.NewStyle())
|
||||
case "databricksColor":
|
||||
st.KindDatabricks = v.fg(lipgloss.NewStyle())
|
||||
case "repoColor":
|
||||
st.KindRepo = v.fg(lipgloss.NewStyle())
|
||||
}
|
||||
}
|
||||
return st, fg, nil
|
||||
}
|
||||
|
||||
func sortedRoles() []string {
|
||||
var out []string
|
||||
for r := range Roles {
|
||||
out = append(out, r)
|
||||
}
|
||||
slices.Sort(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// value is a parsed role: a colour (nil for default) and attributes.
|
||||
type value struct {
|
||||
color color.Color
|
||||
bold, italic, underline, reverse, faint bool
|
||||
}
|
||||
|
||||
// fg styles s with the value as foreground, bg as background.
|
||||
func (v value) fg(s lipgloss.Style) lipgloss.Style {
|
||||
if v.color != nil {
|
||||
s = s.Foreground(v.color)
|
||||
}
|
||||
return v.attrs(s)
|
||||
}
|
||||
|
||||
func (v value) bg(s lipgloss.Style) lipgloss.Style {
|
||||
if v.color != nil {
|
||||
s = s.Background(v.color)
|
||||
}
|
||||
return v.attrs(s)
|
||||
}
|
||||
|
||||
func (v value) attrs(s lipgloss.Style) lipgloss.Style {
|
||||
return s.Bold(v.bold).Italic(v.italic).Underline(v.underline).Reverse(v.reverse).Faint(v.faint)
|
||||
}
|
||||
|
||||
var names = map[string]color.Color{
|
||||
"black": lipgloss.Black, "red": lipgloss.Red, "green": lipgloss.Green, "yellow": lipgloss.Yellow,
|
||||
"blue": lipgloss.Blue, "magenta": lipgloss.Magenta, "cyan": lipgloss.Cyan, "white": lipgloss.White,
|
||||
"brightblack": lipgloss.BrightBlack, "brightred": lipgloss.BrightRed, "brightgreen": lipgloss.BrightGreen,
|
||||
"brightyellow": lipgloss.BrightYellow, "brightblue": lipgloss.BrightBlue,
|
||||
"brightmagenta": lipgloss.BrightMagenta, "brightcyan": lipgloss.BrightCyan, "brightwhite": lipgloss.BrightWhite,
|
||||
}
|
||||
|
||||
func parse(items []string) (value, error) {
|
||||
var v value
|
||||
for _, it := range items {
|
||||
s := strings.ToLower(strings.TrimSpace(it))
|
||||
switch s {
|
||||
case "bold":
|
||||
v.bold = true
|
||||
case "italic":
|
||||
v.italic = true
|
||||
case "underline":
|
||||
v.underline = true
|
||||
case "reverse":
|
||||
v.reverse = true
|
||||
case "faint", "dim":
|
||||
v.faint = true
|
||||
case "default", "":
|
||||
// the terminal's own colour
|
||||
default:
|
||||
c, err := parseColor(s)
|
||||
if err != nil {
|
||||
return v, err
|
||||
}
|
||||
v.color = c
|
||||
}
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func parseColor(s string) (color.Color, error) {
|
||||
if c, ok := names[s]; ok {
|
||||
return c, nil
|
||||
}
|
||||
if strings.HasPrefix(s, "#") {
|
||||
if len(s) != 7 {
|
||||
return nil, fmt.Errorf("%q: want #rrggbb", s)
|
||||
}
|
||||
if _, err := strconv.ParseUint(s[1:], 16, 32); err != nil {
|
||||
return nil, fmt.Errorf("%q: want #rrggbb", s)
|
||||
}
|
||||
return lipgloss.Color(s), nil
|
||||
}
|
||||
if n, err := strconv.Atoi(s); err == nil {
|
||||
if n < 0 || n > 255 {
|
||||
return nil, fmt.Errorf("%q: palette numbers are 0-255", s)
|
||||
}
|
||||
return lipgloss.Color(s), nil
|
||||
}
|
||||
return nil, fmt.Errorf("%q isn't a colour or attribute", s)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package styles
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"charm.land/lipgloss/v2"
|
||||
)
|
||||
|
||||
func TestFromTheme(t *testing.T) {
|
||||
st, fg, err := FromTheme(map[string][]string{
|
||||
"activeBorderColor": {"#e0af68", "bold"},
|
||||
"selectedLineBgColor": {"#2d3f76"},
|
||||
"defaultFgColor": {"#c8d3f5"},
|
||||
"azureColor": {"75"},
|
||||
"databricksColor": {"brightred"},
|
||||
"searchMatchColor": {"default", "underline"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fg == nil {
|
||||
t.Error("defaultFgColor not returned")
|
||||
}
|
||||
if got := st.BorderFocused.GetForeground(); got != lipgloss.Color("#e0af68") || !st.BorderFocused.GetBold() {
|
||||
t.Errorf("active border = %v bold=%v", got, st.BorderFocused.GetBold())
|
||||
}
|
||||
// The theme's selected-line background replaces the default one.
|
||||
if st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.Color("#2d3f76") {
|
||||
t.Errorf("selected = reverse %v bg %v", st.Selected.GetReverse(), st.Selected.GetBackground())
|
||||
}
|
||||
if st.KindAzure.GetForeground() != lipgloss.Color("75") || st.KindDatabricks.GetForeground() != lipgloss.BrightRed {
|
||||
t.Error("provider colours not applied")
|
||||
}
|
||||
if !st.Match.GetUnderline() {
|
||||
t.Error("attribute without colour not applied")
|
||||
}
|
||||
// Roles the theme leaves out keep their defaults.
|
||||
if st.Error.GetForeground() != lipgloss.Red {
|
||||
t.Error("default error colour changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromThemeRejectsMistakes(t *testing.T) {
|
||||
for name, theme := range map[string]map[string][]string{
|
||||
"role": {"activeBorder": {"red"}},
|
||||
"colour": {"errorColor": {"redish"}},
|
||||
"hex": {"errorColor": {"#fff"}},
|
||||
"palette": {"errorColor": {"300"}},
|
||||
} {
|
||||
if _, _, err := FromTheme(theme); err == nil {
|
||||
t.Errorf("%s: no error", name)
|
||||
} else if name == "role" && !strings.Contains(err.Error(), "activeBorderColor") {
|
||||
t.Errorf("unknown role error should list the known ones: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyThemeIsDefault(t *testing.T) {
|
||||
st, fg, err := FromTheme(nil)
|
||||
if err != nil || fg != nil || st.Selected.GetReverse() || st.Selected.GetBackground() != lipgloss.BrightBlack {
|
||||
t.Errorf("empty theme: %v %v reverse=%v bg=%v", err, fg, st.Selected.GetReverse(), st.Selected.GetBackground())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user