diff --git a/README.md b/README.md index 60bbc54..1e6c607 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,8 @@ A terminal UI for finding, reading and sharing secrets. Inspired by the fantasti - **Read-only**: lazysecrets never creates, changes or deletes a secret, so it's safe to point at production. That's by design and may stay that way. + (`e` opens a repo's SOPS or `.env` file in your editor, or `sops edit`; + any change is yours.) - **Zero setup**: uses the logins you already have (`az`, the Databricks CLI, `sops`, `git`). No config file needed. - **Search everything**: `ctrl+s` searches every source at once, with diff --git a/docs/keys.md b/docs/keys.md index 0ca0908..337b24e 100644 --- a/docs/keys.md +++ b/docs/keys.md @@ -52,6 +52,7 @@ match case exactly. | `Y` | copy the reference (`@Microsoft.KeyVault(...)`, key/certificate URL, `{{secrets/scope/key}}`) | | `v` | versions of the selected item (Azure); `space`/`y`/`Y` then act on that version; `esc`, `⌫` or `v` to go back | | `o` | open the selected item in the browser (over SSH: copy the link) | +| `e` | open the selected SOPS or `.env` file, or a secret's file at its line ([how](repos.md#opening-files)) | | `s` / `S` | copy a [link](links.md) to the selected item / share menu | | `ctrl+o` | open a link or reference (or just paste one) | | `x` | [custom commands](custom-commands.md) | diff --git a/docs/repos.md b/docs/repos.md index 565b072..bc2ccdf 100644 --- a/docs/repos.md +++ b/docs/repos.md @@ -63,6 +63,22 @@ copying or `lazysecrets get` decrypts one value with your `sops` binary, so whatever sops uses works (age keys, Azure Key Vault through az, AWS/GCP KMS, PGP). When sops can't decrypt, its whole error message is shown. +## Opening files + +`e` opens the selected file, or on a secret, its file at that key's line: + +- **SOPS files** open with `sops edit`, which decrypts them into your + editor (`$SOPS_EDITOR` or `$EDITOR`) and encrypts them again when you + save. lazysecrets comes back after, with the file's keys reloaded. +- **Other files**, when lazysecrets runs inside nvim (its plugin, or + `:terminal`): lazysecrets quits and the file opens in that nvim, in the + window you came from. +- Otherwise in `$VISUAL` or `$EDITOR` in the terminal, coming back after; + without either, in the system's default app for the file. + +Editors that take a line (vim, nvim, nano, emacs, micro, helix, VS Code, +Zed, Sublime and others) start on the key. + ## Links Links name the repo by its git remote, so they open in a colleague's clone diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4b93b07..0b4b918 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -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 +} diff --git a/internal/provider/repo/repo.go b/internal/provider/repo/repo.go index 6cdeed1..d306433 100644 --- a/internal/provider/repo/repo.go +++ b/internal/provider/repo/repo.go @@ -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}) } } diff --git a/internal/provider/repo/repo_test.go b/internal/provider/repo/repo_test.go index 890c977..b7b2f09 100644 --- a/internal/provider/repo/repo_test.go +++ b/internal/provider/repo/repo_test.go @@ -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) + } +} diff --git a/internal/ui/app/app.go b/internal/ui/app/app.go index e1c0e07..3cf04c5 100644 --- a/internal/ui/app/app.go +++ b/internal/ui/app/app.go @@ -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:]...) diff --git a/internal/ui/app/app_test.go b/internal/ui/app/app_test.go index 6fab678..b4d501a 100644 --- a/internal/ui/app/app_test.go +++ b/internal/ui/app/app_test.go @@ -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()) } } diff --git a/internal/ui/app/edit.go b/internal/ui/app/edit.go new file mode 100644 index 0000000..a89772e --- /dev/null +++ b/internal/ui/app/edit.go @@ -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 +} diff --git a/internal/ui/app/repo_test.go b/internal/ui/app/repo_test.go index 167919b..d7ca1a7 100644 --- a/internal/ui/app/repo_test.go +++ b/internal/ui/app/repo_test.go @@ -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) + } +} diff --git a/internal/ui/keys/keys.go b/internal/ui/keys/keys.go index 2b558d9..f8663a2 100644 --- a/internal/ui/keys/keys.go +++ b/internal/ui/keys/keys.go @@ -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", "{"},