feat: e opens a repo's SOPS or .env file, in nvim, sops edit or your editor
CI / Checks (pull_request) Successful in 26s
PR title / Conventional title (pull_request) Successful in 1s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kebandClaude Opus 5.5 committed 2026-10-11 00:09:45 +02:00
1 parent 6fab41703e
commit c29319b821
11 files changed
+395 -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())
}
}
+179
View File
@@ -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
}
+85
View File
@@ -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)
}
}