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:
keb committed 2026-10-11 09:34:05 +02:00
1 parent 6fab41703e
commit 17d2705e12
11 files changed
+445 -12

No files matched your search

+10
View File
@@ -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:]...)
+5 -5
View File
@@ -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())
}
}
+208
View File
@@ -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
}
+104
View File
@@ -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)
}
}
}