feat: e opens a repo's SOPS or .env file, in nvim, sops edit or your editor (#1)
`e` on a file in a repo opens it; on a secret, it opens the file at that key's line. - **SOPS files**: `sops edit` in the terminal, then back to lazysecrets with the keys reloaded. Exit 200 (nothing changed) shows "unchanged". - **Other files inside nvim** (`$NVIM` set): lazysecrets asks that nvim, over `--remote-expr`, to open the file once lazysecrets' terminal closes, then quits. The file lands in the window you came from, not the closing float. Started from a shell in a terminal, it opens in the previous window straight away. No change to the nvim plugin is needed. - **Otherwise**: `$VISUAL`/`$EDITOR` in the terminal (with `+N`, `file:N` or `-g file:N` for editors that take a line), else the system default app. The repo provider now records each key's line (YAML node lines, env/ini line numbers) behind a new optional `provider.Files` interface. Other providers show "only SOPS and .env files in repos can be opened". The nvim handoff was tested against a headless nvim 0.12: a floating terminal closed on exit the way snacks does, and a shell in a split. A file name with a space and a quote opened at the right line in both. Unit tests cover lines per format, the nvim, sops and default-app paths, and the editor line arguments. Note: `e` is a new default key, so a config that already binds `e` to something else now gets the "bound to both" error, until one of them is rebound. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Karl-Einar Bergstrøm <karl-einarb@outlook.com> Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
1 parent
6fab41703e
commit
17d2705e12
11 files changed
+445
-12
No files matched your search
@@ -169,3 +169,18 @@ type Current interface {
|
||||
type Locator interface {
|
||||
Locate(ctx context.Context, address string) (sourceID string, ok bool, err error)
|
||||
}
|
||||
|
||||
// Files is implemented by providers whose containers are files on disk
|
||||
// (SOPS and .env files in repos), so they can be opened in an editor.
|
||||
type Files interface {
|
||||
// File is where c is, and for o (nil for the whole file) the line its
|
||||
// key is on.
|
||||
File(c Container, o *Object) (File, error)
|
||||
}
|
||||
|
||||
// File is a container's file, to open.
|
||||
type File struct {
|
||||
Path string
|
||||
Line int // 1-based; 0 for the top
|
||||
Sops string // the sops command to edit it with; empty for a plain file
|
||||
}
|
||||
@@ -412,6 +412,37 @@ func (p *Provider) Value(ctx context.Context, o provider.Object) (provider.Secre
|
||||
return provider.NewSecretValue(stdout.Bytes()), nil
|
||||
}
|
||||
|
||||
// File is the file c is, and the line o's key is on.
|
||||
func (p *Provider) File(c provider.Container, o *provider.Object) (provider.File, error) {
|
||||
path := c.ID
|
||||
if path == "" { // made from a link
|
||||
path = filepath.Join(c.Source.ID, filepath.FromSlash(c.Name))
|
||||
}
|
||||
f := provider.File{Path: path}
|
||||
if _, isSops := readMeta(path); isSops {
|
||||
f.Sops = p.s.Sops
|
||||
}
|
||||
if o == nil {
|
||||
return f, nil
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
keys, err := parseKeys(path, b)
|
||||
if err != nil {
|
||||
return f, nil // still worth opening, at the top
|
||||
}
|
||||
_, extract, _ := strings.Cut(o.ID, "\x00")
|
||||
for _, k := range keys {
|
||||
if k.extract == extract || (extract == "" && k.name == o.Name) {
|
||||
f.Line = k.line
|
||||
break
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// sopsError is sops' own message, whole: which keys it tried and why
|
||||
// each failed can all matter.
|
||||
func sopsError(stderr string, err error) string {
|
||||
@@ -871,6 +902,7 @@ type key struct {
|
||||
name string // db.password, list[0]
|
||||
extract string // sops --extract: ["db"]["password"]
|
||||
encrypted bool
|
||||
line int // 1-based
|
||||
}
|
||||
|
||||
// parseKeys lists a sops file's keys without decrypting anything.
|
||||
@@ -889,18 +921,18 @@ func parseKeys(file string, b []byte) ([]key, error) {
|
||||
return out, nil
|
||||
case "dotenv":
|
||||
var out []key
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
for i, line := range strings.Split(string(b), "\n") {
|
||||
k, v, ok := envLine(line)
|
||||
if !ok || strings.HasPrefix(k, "sops_") {
|
||||
continue
|
||||
}
|
||||
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC[")})
|
||||
out = append(out, key{name: k, extract: quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
||||
}
|
||||
return out, nil
|
||||
case "ini":
|
||||
var out []key
|
||||
section := ""
|
||||
for _, line := range strings.Split(string(b), "\n") {
|
||||
for i, line := range strings.Split(string(b), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
switch {
|
||||
case line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";"):
|
||||
@@ -912,7 +944,7 @@ func parseKeys(file string, b []byte) ([]key, error) {
|
||||
continue
|
||||
}
|
||||
k, v = strings.TrimSpace(k), strings.TrimSpace(v)
|
||||
out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC[")})
|
||||
out = append(out, key{name: section + "." + k, extract: quote(section) + quote(k), encrypted: strings.HasPrefix(v, "ENC["), line: i + 1})
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
@@ -944,7 +976,7 @@ func walk(n *yaml.Node, name, extract string, top bool, out *[]key) {
|
||||
walk(n.Alias, name, extract, false, out)
|
||||
}
|
||||
default:
|
||||
*out = append(*out, key{name: name, extract: extract, encrypted: strings.HasPrefix(n.Value, "ENC[")})
|
||||
*out = append(*out, key{name: name, extract: extract, encrypted: strings.HasPrefix(n.Value, "ENC["), line: n.Line})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package repo
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"maps"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -435,3 +436,44 @@ func TestEnvFilesSkipPackages(t *testing.T) {
|
||||
t.Errorf("files %v, want %v (nothing from node_modules or vendor)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFile(t *testing.T) {
|
||||
root := gitRepo(t, map[string]string{
|
||||
".gitignore": ".env\n",
|
||||
"secrets/app.yaml": sopsYAML,
|
||||
})
|
||||
os.WriteFile(filepath.Join(root, ".env"), []byte("# local\nTOKEN=x\n\nURL=y\n"), 0o600)
|
||||
p := New(Settings{Dir: root, Sops: "/opt/sops"})
|
||||
ctx := context.Background()
|
||||
srcs, _ := p.Sources(ctx)
|
||||
files, err := p.Containers(ctx, srcs[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := map[string]int{}
|
||||
for _, c := range files {
|
||||
f, err := p.File(c, nil)
|
||||
if err != nil || f.Path != filepath.Join(root, c.Name) || f.Line != 0 {
|
||||
t.Errorf("%s: %+v, %v", c.Name, f, err)
|
||||
}
|
||||
if sops := f.Sops != ""; sops != (c.Name == "secrets/app.yaml") || (sops && f.Sops != "/opt/sops") {
|
||||
t.Errorf("%s: sops %q", c.Name, f.Sops)
|
||||
}
|
||||
keys, _ := p.Objects(ctx, c, provider.TypeSecret)
|
||||
for _, k := range keys {
|
||||
f, err := p.File(c, &k)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines[c.Name+" "+k.Name] = f.Line
|
||||
}
|
||||
}
|
||||
want := map[string]int{
|
||||
".env TOKEN": 2, ".env URL": 4,
|
||||
"secrets/app.yaml db.password": 2, "secrets/app.yaml db.port": 3,
|
||||
"secrets/app.yaml list[0]": 5, "secrets/app.yaml public_unencrypted": 6,
|
||||
}
|
||||
if !maps.Equal(lines, want) {
|
||||
t.Errorf("lines %v\nwant %v", lines, want)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"fmt"
|
||||
"image/color"
|
||||
"math"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -156,6 +157,8 @@ type Model struct {
|
||||
clip Clipboard
|
||||
clipClear time.Duration
|
||||
openURL func(string) error
|
||||
getenv func(string) string
|
||||
nvimOpen func(server, path string, line int) error // edit.go
|
||||
now func() time.Time
|
||||
|
||||
// A pending clear of a copied value, counted down in the footer.
|
||||
@@ -337,6 +340,8 @@ func New(providers []provider.Provider, opts Options) Model {
|
||||
clip: opts.Clipboard,
|
||||
clipClear: opts.ClipboardClear,
|
||||
now: time.Now,
|
||||
getenv: os.Getenv,
|
||||
nvimOpen: nvimOpen,
|
||||
cache: newCache(),
|
||||
}
|
||||
if m.clip == nil {
|
||||
@@ -522,6 +527,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
case commandDoneMsg:
|
||||
m.handleCommandDone(msg)
|
||||
|
||||
case editDoneMsg:
|
||||
cmds = append(cmds, m.handleEditDone(msg))
|
||||
|
||||
case crawlMsg:
|
||||
cmds = append(cmds, m.handleCrawl(msg))
|
||||
|
||||
@@ -833,6 +841,8 @@ func (m *Model) handleKey(msg tea.KeyPressMsg) (cmd tea.Cmd, quit bool) {
|
||||
}
|
||||
case key.Matches(msg, k.Open):
|
||||
return m.openSelected(), false
|
||||
case key.Matches(msg, k.Edit):
|
||||
return m.edit(), false
|
||||
case key.Matches(msg, k.Login):
|
||||
if m.auth != nil && len(m.auth.Login) > 0 {
|
||||
c := exec.Command(m.auth.Login[0], m.auth.Login[1:]...)
|
||||
|
||||
@@ -973,16 +973,16 @@ func TestSourcesFavouritesTab(t *testing.T) {
|
||||
func TestRebindingShowsEverywhere(t *testing.T) {
|
||||
t.Parallel()
|
||||
km := keys.Default()
|
||||
if err := km.Apply(map[string]map[string][]string{"lists": {"reveal": {"e"}, "versions": {}}}); err != nil {
|
||||
if err := km.Apply(map[string]map[string][]string{"lists": {"reveal": {"z"}, "versions": {}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := newDriverOpts(t, Options{Keys: &km})
|
||||
d.keys("3")
|
||||
scr := d.screen()
|
||||
if !strings.Contains(scr, "(e to reveal)") || strings.Contains(scr, "space to reveal") {
|
||||
if !strings.Contains(scr, "(z to reveal)") || strings.Contains(scr, "space to reveal") {
|
||||
t.Errorf("detail hint doesn't follow the binding:\n%s", scr)
|
||||
}
|
||||
if !strings.Contains(lastLine(scr), "e reveal/hide") {
|
||||
if !strings.Contains(lastLine(scr), "z reveal/hide") {
|
||||
t.Errorf("help bar: %q", lastLine(scr))
|
||||
}
|
||||
if strings.Contains(scr, "to browse") {
|
||||
@@ -992,9 +992,9 @@ func TestRebindingShowsEverywhere(t *testing.T) {
|
||||
if strings.Contains(d.screen(), "correct-horse") {
|
||||
t.Error("space still reveals")
|
||||
}
|
||||
d.keys("e")
|
||||
d.keys("z")
|
||||
if !strings.Contains(d.screen(), "correct-horse-battery-staple") {
|
||||
t.Errorf("e doesn't reveal:\n%s", d.screen())
|
||||
t.Errorf("z doesn't reveal:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/browser"
|
||||
"git.kebcloud.no/keb/lazysecrets/internal/provider"
|
||||
)
|
||||
|
||||
// Opening a repo's file (e): SOPS files with sops edit, which decrypts
|
||||
// them for the editor and encrypts them again after. Other files, inside
|
||||
// nvim: in that nvim, once lazysecrets has quit; else in $VISUAL or
|
||||
// $EDITOR, coming back after; else in the system's default app. On a
|
||||
// secret, editors that can open at a line start on its key.
|
||||
|
||||
type editDoneMsg struct {
|
||||
c provider.Container
|
||||
name string
|
||||
err error
|
||||
}
|
||||
|
||||
// sopsUnchanged is sops edit's exit code when the file wasn't changed.
|
||||
const sopsUnchanged = 200
|
||||
|
||||
// edit opens the selected file, or the file of the selected secret.
|
||||
func (m *Model) edit() tea.Cmd {
|
||||
var c provider.Container
|
||||
var o *provider.Object
|
||||
switch m.focus {
|
||||
case paneSources:
|
||||
m.flash = warning("select a file to open it")
|
||||
return nil
|
||||
case paneContainers:
|
||||
sel, ok := m.containers.Selected()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
c = sel
|
||||
default:
|
||||
sel, ok := m.detail.Secret()
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
c, o = sel.Container, &sel
|
||||
}
|
||||
files, ok := m.providerFor(c.Source).(provider.Files)
|
||||
if !ok {
|
||||
m.flash = warning("only SOPS and .env files in repos can be opened")
|
||||
return nil
|
||||
}
|
||||
f, err := files.File(c, o)
|
||||
if err != nil {
|
||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
if o != nil {
|
||||
m.usedObject(*o)
|
||||
} else {
|
||||
m.usedContainer(c)
|
||||
}
|
||||
name := filepath.Base(f.Path)
|
||||
finished := func(err error) tea.Msg { return editDoneMsg{c: c, name: name, err: err} }
|
||||
|
||||
if f.Sops != "" {
|
||||
cmd := exec.Command(f.Sops, "edit", f.Path)
|
||||
cmd.Dir = filepath.Dir(f.Path) // sops looks for .sops.yaml from here
|
||||
// sops starts SOPS_EDITOR (else EDITOR, else vim) on the decrypted
|
||||
// copy, which keeps the file's lines: start it on the key.
|
||||
editor := cmp.Or(m.getenv("SOPS_EDITOR"), m.getenv("EDITOR"), "vim")
|
||||
if at, ok := editorAtLine(editor, f.Line); ok {
|
||||
cmd.Env = append(os.Environ(), "SOPS_EDITOR="+at)
|
||||
}
|
||||
m.flash = flash{}
|
||||
return tea.ExecProcess(cmd, finished)
|
||||
}
|
||||
if server := m.getenv("NVIM"); server != "" {
|
||||
open := m.nvimOpen
|
||||
return func() tea.Msg {
|
||||
if err := open(server, f.Path, f.Line); err != nil {
|
||||
return finished(err)
|
||||
}
|
||||
return tea.QuitMsg{}
|
||||
}
|
||||
}
|
||||
if editor := cmpOr(m.getenv("VISUAL"), m.getenv("EDITOR")); editor != "" {
|
||||
// Through the shell, so EDITOR can carry arguments ("code -w").
|
||||
args := append([]string{"-c", editor + ` "$@"`, "lazysecrets"}, editorArgs(editor, f)...)
|
||||
m.flash = flash{}
|
||||
return tea.ExecProcess(exec.Command("/bin/sh", args...), finished)
|
||||
}
|
||||
switch err := m.openURL(f.Path); {
|
||||
case errors.Is(err, browser.ErrRemote):
|
||||
m.flash = warning("set $EDITOR to open files over SSH")
|
||||
case err != nil:
|
||||
m.flash = flash{text: "open: " + err.Error(), isErr: true}
|
||||
default:
|
||||
m.flash = done("opened " + name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// plusLine are editors that open at a line with +N before the file.
|
||||
var plusLine = map[string]bool{
|
||||
"vi": true, "vim": true, "nvim": true, "nano": true, "emacs": true, "emacsclient": true,
|
||||
"micro": true, "kak": true, "mg": true, "ne": true, "joe": true, "vis": true,
|
||||
}
|
||||
|
||||
// editorAtLine is editor starting at line, for editors that take +N:
|
||||
// sops adds the file after it.
|
||||
func editorAtLine(editor string, line int) (string, bool) {
|
||||
fields := strings.Fields(editor)
|
||||
if line == 0 || len(fields) == 0 || !plusLine[filepath.Base(fields[0])] {
|
||||
return "", false
|
||||
}
|
||||
return editor + " +" + strconv.Itoa(line), true
|
||||
}
|
||||
|
||||
// editorArgs opens f at its line, for editors known to take one.
|
||||
func editorArgs(editor string, f provider.File) []string {
|
||||
fields := strings.Fields(editor)
|
||||
if f.Line == 0 || len(fields) == 0 {
|
||||
return []string{f.Path}
|
||||
}
|
||||
at := f.Path + ":" + strconv.Itoa(f.Line)
|
||||
switch base := filepath.Base(fields[0]); {
|
||||
case plusLine[base]:
|
||||
return []string{"+" + strconv.Itoa(f.Line), f.Path}
|
||||
case base == "hx" || base == "helix" || base == "subl" || base == "zed":
|
||||
return []string{at}
|
||||
case base == "code" || base == "codium" || base == "cursor" || base == "windsurf":
|
||||
return []string{"-g", at}
|
||||
}
|
||||
return []string{f.Path}
|
||||
}
|
||||
|
||||
// handleEditDone reports how editing went and reloads the file's secrets:
|
||||
// their names, lines or values may have changed.
|
||||
func (m *Model) handleEditDone(msg editDoneMsg) tea.Cmd {
|
||||
var exit *exec.ExitError
|
||||
switch {
|
||||
case errors.As(msg.err, &exit) && exit.ExitCode() == sopsUnchanged:
|
||||
m.flash = flash{text: msg.name + " unchanged"}
|
||||
return nil
|
||||
case errors.Is(msg.err, exec.ErrNotFound):
|
||||
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error() + " (is sops installed?)", isErr: true}
|
||||
return nil
|
||||
case msg.err != nil:
|
||||
m.flash = flash{text: "open " + msg.name + ": " + msg.err.Error(), isErr: true}
|
||||
return nil
|
||||
}
|
||||
m.flash = done("closed " + msg.name)
|
||||
if c, ok := m.containers.Selected(); ok && containerKey(c) == containerKey(msg.c) {
|
||||
m.hideValue()
|
||||
m.secrets.Refresh()
|
||||
return m.loadObjects(c, m.objType, m.selContainer)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nvimLua opens a file in the nvim lazysecrets runs in. If lazysecrets is
|
||||
// that terminal's own process (the nvim plugin, or :terminal lazysecrets),
|
||||
// it waits for the terminal to close, so the file opens in the window
|
||||
// below rather than in one about to close. Otherwise (a shell in a
|
||||
// terminal) it opens it in the previous window now. The autocmd isn't
|
||||
// the buffer's own: snacks wipes the buffer when the terminal closes,
|
||||
// which would delete it before it runs.
|
||||
const nvimLua = `local path, line, pid = ...
|
||||
local function open()
|
||||
local ok, err = pcall(vim.cmd, "edit " .. vim.fn.fnameescape(path))
|
||||
if not ok then vim.notify("lazysecrets: " .. err, vim.log.levels.ERROR) return end
|
||||
if line > 0 then pcall(vim.api.nvim_win_set_cursor, 0, { line, 0 }) vim.cmd("normal! zz") end
|
||||
end
|
||||
for _, b in ipairs(vim.api.nvim_list_bufs()) do
|
||||
if vim.bo[b].buftype == "terminal" and vim.b[b].terminal_job_pid == pid then
|
||||
vim.api.nvim_create_autocmd("TermClose", { callback = function(ev)
|
||||
if ev.buf ~= b then return end
|
||||
vim.defer_fn(open, 50)
|
||||
return true
|
||||
end })
|
||||
return
|
||||
end
|
||||
end
|
||||
pcall(vim.cmd, "wincmd p")
|
||||
open()`
|
||||
|
||||
// nvimOpen asks the nvim at server to open path at line.
|
||||
func nvimOpen(server, path string, line int) error {
|
||||
q := func(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
||||
lua := strings.ReplaceAll(nvimLua, "\n", " ")
|
||||
expr := "luaeval('load(_A[1])(_A[2], _A[3], _A[4])', [" + q(lua) + ", " + q(path) + ", " +
|
||||
strconv.Itoa(line) + ", " + strconv.Itoa(os.Getpid()) + "])"
|
||||
out, err := exec.Command("nvim", "--server", server, "--remote-expr", expr).CombinedOutput()
|
||||
if err != nil {
|
||||
if msg := strings.TrimSpace(string(out)); msg != "" {
|
||||
return errors.New(msg)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package app
|
||||
import (
|
||||
tea "charm.land/bubbletea/v2"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -238,3 +239,106 @@ func TestSearchAllRepos(t *testing.T) {
|
||||
t.Errorf("title:\n%s", d.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditFile(t *testing.T) {
|
||||
// Not parallel: git, run by the repo provider, is slow under load.
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("no git")
|
||||
}
|
||||
root := gitRepoWith(t, "https://git.example.com/team/x.git")
|
||||
envPath := filepath.Join(root, "secrets", ".env")
|
||||
os.WriteFile(envPath, []byte("# local\nTOKEN=x\n"), 0o600)
|
||||
var opened []string
|
||||
d := newDriverOpts(t, Options{OpenURL: func(u string) error { opened = append(opened, u); return nil }},
|
||||
fake.Azure(0), repo.New(repo.Settings{Dir: root}))
|
||||
env := map[string]string{"NVIM": "/tmp/nvim.sock"}
|
||||
d.m.getenv = func(k string) string { return env[k] }
|
||||
var nvim []string
|
||||
d.m.nvimOpen = func(server, path string, line int) error {
|
||||
nvim = append(nvim, fmt.Sprintf("%s %s:%d", server, path, line))
|
||||
return nil
|
||||
}
|
||||
|
||||
// Inside nvim: the file, then a secret at its line, and lazysecrets quits.
|
||||
d.keys("2")
|
||||
if _, c, _ := d.selected(); c != "secrets/.env" {
|
||||
t.Fatalf("selected %q\n%s", c, d.screen())
|
||||
}
|
||||
d.keys("e")
|
||||
d.keys("3", "e")
|
||||
want := []string{"/tmp/nvim.sock " + envPath + ":0", "/tmp/nvim.sock " + envPath + ":2"}
|
||||
if !slices.Equal(nvim, want) {
|
||||
t.Errorf("nvim got %q\nwant %q", nvim, want)
|
||||
}
|
||||
if msg := d.m.edit()(); msg != (tea.QuitMsg{}) {
|
||||
t.Errorf("after opening in nvim: %#v, want quit", msg)
|
||||
}
|
||||
|
||||
// SOPS files go to sops edit, in the terminal, even inside nvim.
|
||||
nvim = nil
|
||||
d.keys("2", "j")
|
||||
if _, c, _ := d.selected(); c != "secrets/app.yaml" {
|
||||
t.Fatalf("selected %q", c)
|
||||
}
|
||||
if cmd := d.m.edit(); cmd == nil || len(nvim) > 0 {
|
||||
t.Errorf("sops file: cmd %v, nvim %q", cmd, nvim)
|
||||
}
|
||||
|
||||
// Neither nvim nor an editor: the system's default app.
|
||||
delete(env, "NVIM")
|
||||
d.keys("k", "e")
|
||||
if !slices.Equal(opened, []string{envPath}) || !strings.Contains(d.screen(), "opened .env") {
|
||||
t.Errorf("opened %q\n%s", opened, d.screen())
|
||||
}
|
||||
|
||||
// Done editing: the file's secrets reload.
|
||||
d.send(editDoneMsg{c: provider.Container{Source: provider.Source{Provider: "Repos"}, ID: envPath}, name: ".env"})
|
||||
if !strings.Contains(d.screen(), "closed .env") {
|
||||
t.Errorf("after editing:\n%s", d.screen())
|
||||
}
|
||||
|
||||
// Other providers have no files.
|
||||
az := newDriver(t, fake.Azure(0))
|
||||
az.keys("2", "e")
|
||||
if !strings.Contains(az.screen(), "only SOPS and .env files") {
|
||||
t.Errorf("azure:\n%s", az.screen())
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorArgs(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := provider.File{Path: "/r/.env", Line: 7}
|
||||
for editor, want := range map[string][]string{
|
||||
"nvim": {"+7", "/r/.env"},
|
||||
"/usr/bin/vim -p": {"+7", "/r/.env"},
|
||||
"hx": {"/r/.env:7"},
|
||||
"code --wait": {"-g", "/r/.env:7"},
|
||||
"open -a TextEdit": {"/r/.env"},
|
||||
} {
|
||||
if got := editorArgs(editor, f); !slices.Equal(got, want) {
|
||||
t.Errorf("%s: %q, want %q", editor, got, want)
|
||||
}
|
||||
}
|
||||
if got := editorArgs("nvim", provider.File{Path: "/r/.env"}); !slices.Equal(got, []string{"/r/.env"}) {
|
||||
t.Errorf("no line: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditorAtLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, c := range []struct {
|
||||
editor string
|
||||
line int
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"nvim", 4, "nvim +4", true},
|
||||
{"/usr/bin/vim -p", 4, "/usr/bin/vim -p +4", true},
|
||||
{"code --wait", 4, "", false}, // sops adds the file last: no -g file:N
|
||||
{"nvim", 0, "", false},
|
||||
} {
|
||||
if got, ok := editorAtLine(c.editor, c.line); got != c.want || ok != c.ok {
|
||||
t.Errorf("%q at %d: %q %v", c.editor, c.line, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,7 +40,7 @@ type Map struct {
|
||||
Pane1, Pane2, Pane3, Pane4 key.Binding
|
||||
NextTab, PrevTab key.Binding
|
||||
Enter, Back, Filter key.Binding
|
||||
Reveal, Copy, CopyRef, Open key.Binding
|
||||
Reveal, Copy, CopyRef, Open, Edit key.Binding
|
||||
Versions, Favourite, Favourites key.Binding
|
||||
Recent key.Binding
|
||||
Note, Share, ShareMenu, OpenRef key.Binding
|
||||
@@ -111,6 +111,7 @@ func (m *Map) Actions() []Action {
|
||||
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, "edit", &m.Edit, "open file", "secrets", "Open the selected SOPS or .env file, or the file of the selected secret at its line. SOPS files open with sops edit. Others open in the nvim lazysecrets runs in (quitting lazysecrets), else in $VISUAL or $EDITOR, else in the system's default app."),
|
||||
|
||||
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."),
|
||||
@@ -183,7 +184,7 @@ var defaults = map[string][]string{
|
||||
"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.versions": {"v"}, "lists.openInBrowser": {"o"}, "lists.edit": {"e"},
|
||||
"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", "{"},
|
||||
|
||||
Reference in new issue
Block a user