feat: e opens a repo's SOPS or .env file, in nvim, sops edit or your editor
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
6fab41703e
commit
c29319b821
11 files changed
+395
-12
No files matched your search
@@ -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,179 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"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
|
||||
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
|
||||
}
|
||||
|
||||
// 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 filepath.Base(fields[0]) {
|
||||
case "vi", "vim", "nvim", "nano", "emacs", "emacsclient", "micro", "kak", "mg", "ne", "joe", "vis":
|
||||
return []string{"+" + strconv.Itoa(f.Line), f.Path}
|
||||
case "hx", "helix", "subl", "zed":
|
||||
return []string{at}
|
||||
case "code", "codium", "cursor", "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.
|
||||
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", { buffer = b, once = true, callback = function() vim.defer_fn(open, 50) 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,87 @@ 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)
|
||||
}
|
||||
}
|
||||
@@ -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