Skip to content

About

Stackable code peek popups for Neovim that preserve navigation context.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Repository files navigation

👀 peekstack.nvim

DeepWiki GitHub Release CI MIT License

Exploration-first peek stack for Neovim — stack LSP/diagnostics/files/grep results without losing context.

peekstack.nvim keeps your exploration flow intact by stacking “peek” windows. You can move through the stack, then promote only the results you care about into splits or tabs.

💡 Why peekstack.nvim?

Most peek workflows are built around viewing one thing at a time. peekstack.nvim focuses on preserving the trail of your exploration.

  • Continuity: chase LSP/diagnostics/grep without breaking flow
  • Context-preserving: move back/forward in a stack of popups
  • Promote when needed: elevate only the interesting results to splits/tabs
  • Session persistence: save and restore exploration sessions

demo

✨ Features

Core

  • 🧭 Peek stack UI: stack / cascade / single layouts
  • 🧱 Stack view: list popups, focus, pin, rename, history, preview syntax highlights
  • 🌳 Tree guides: stack entries are grouped by navigation hierarchy
  • 🔍 Providers: LSP / diagnostics / file / marks
  • 🚀 Promote: fast split/tab promotion
  • 🧷 Inline + quick peek: inline preview or ephemeral popups
  • 🧩 Buffer modes: copy (default) / source

Optional

  • 🔎 ripgrep integration: rg-based grep search
  • 🧺 Picker integration: builtin / telescope / fzf-lua / snacks.nvim
  • 💾 Persist: save/restore sessions + auto save/restore
  • 🧹 Auto close: close stale popups by idle time

📦 Requirements

  • Neovim ≥ 0.12
  • rg (only if you use grep.search)
  • Optional: telescope.nvim / fzf-lua / snacks.nvim (if you switch picker backends)
  • Optional: Tree-sitter parsers (for ui.title.context and stack view preview syntax highlighting; Neovim bundles the runtime, but parsers are separate)

🚀 Installation

Using lazy.nvim:

{
  "mhiro2/peekstack.nvim",
  config = function()
    local peekstack = require("peekstack")
    peekstack.setup({
      -- Optional: enable additional providers
      providers = {
        marks = { enable = true },  -- browse vim marks
      },
    })

    -- LSP: peek at definitions and references
    vim.keymap.set("n", "<leader>pd", function() peekstack.peek.definition() end)
    vim.keymap.set("n", "<leader>pr", function() peekstack.peek.references() end)

    -- Diagnostics & files: peek at diagnostics or files under cursor
    vim.keymap.set("n", "<leader>pl", function() peekstack.peek.diagnostics_cursor() end)
    vim.keymap.set("n", "<leader>pf", function() peekstack.peek.file_under_cursor() end)

    -- Marks: browse buffer marks (requires marks provider enabled)
    vim.keymap.set("n", "<leader>pm", function() peekstack.peek.marks_buffer() end)

    -- Utility: temporarily hide/show all popups in current stack
    vim.keymap.set("n", "<leader>ph", "<cmd>PeekstackToggle<cr>", { desc = "Peekstack: toggle" })
  end,
}

🧭 Usage

-- Call by provider name
require("peekstack").peek("lsp.definition")
require("peekstack").peek("diagnostics.under_cursor")
require("peekstack").peek("file.under_cursor")
require("peekstack").peek("marks.buffer")

-- Inline preview (no stack)
require("peekstack").peek.definition({ mode = "inline" })

-- Quick peek (temporary, no stack)
require("peekstack").peek.references({ mode = "quick" })

-- Document symbols in current buffer
require("peekstack").peek.symbols_document()

Built-in provider names: lsp.definition, lsp.implementation, lsp.references, lsp.type_definition, lsp.declaration, lsp.symbols_document, diagnostics.under_cursor, diagnostics.in_buffer, file.under_cursor, grep.search, marks.buffer, marks.global, marks.all (marks require their provider enabled; grep.search requires rg).

The marks providers show the characters in providers.marks.include, letters only by default. Numbered marks (0-9) appear only after adding them to include. Special marks (' ` ^ . < > [ ] ") need both include_special = true and their character in include; include_special alone adds nothing.

💻 Commands

  • :PeekstackStack — open the stack view panel
  • :PeekstackSaveSession — save current stack (persist enabled)
  • :PeekstackRestoreSession — restore a saved session
  • :PeekstackListSessions — list all saved sessions
  • :PeekstackDeleteSession [name] — delete a saved session (prompts to select when no name is given)
  • :PeekstackRestorePopup — restore the last closed popup (undo close), including popups closed with :q or <C-w>c
  • :PeekstackRestoreAllPopups — restore all closed popups
  • :PeekstackCloseAll — close all popups in the current stack
  • :PeekstackToggle — temporarily hide/show all popups in the current stack (pushing, focusing or restoring a popup shows it again)
  • :PeekstackZoom — toggle zoom (maximize the top popup to fill the editor)
  • :PeekstackHistory — show popup history and select to restore
  • :PeekstackQuickPeek [provider] — quick peek without stacking (default: lsp.definition, accepts any registered provider)

⌨️ Keymaps

Defaults inside popup windows:

  • q — close
  • <C-j> — focus next popup
  • <C-k> — focus previous popup
  • <C-x> — promote to horizontal split
  • <C-v> — promote to vertical split
  • <C-t> — promote to new tab
  • <leader>os — open stack view
  • <C-z> — toggle zoom (maximize top popup)
  • <C-w>h/j/k/l — navigate to adjacent split window

Defaults in stack view:

  • <CR> — focus selected popup (moves to it, which closes the panel)
  • dd — close selected popup
  • u — undo close (restore last)
  • U — restore all closed popups
  • H — history list (select to restore)
  • r — rename
  • p — pin
  • / — filter
  • gg/G — jump to first/last stack item
  • j/k — move cursor by stack item (skip header/preview lines)
  • z — toggle zoom of the top popup (the most recently pushed one, not the selected entry)
  • ? — help
  • q — close

The stack view is a read-only panel that closes as soon as focus leaves it. Motions, visual selection and yanking work as usual; editing commands fail because the buffer is not modifiable, and / filters the list instead of searching.

⚙️ Configuration

Configure via require("peekstack").setup({ ... }).

Default Settings
{
  ui = {
    layout = {
      style = "stack",
      offset = { row = 1, col = 4 },
      shrink = { w = 4, h = 2 },
      min_size = { w = 60, h = 12 },
      max_ratio = 0.65,
      zindex_base = 50,
    },
    title = {
      enabled = true,
      format = "{icon}{kind}{provider} {path}:{line}{context}",
      icons = {
        enabled = true,
        map = {
          lsp = "",
          diagnostics = "",
          grep = "",
          file = "",
          marks = "",
        },
      },
      context = {
        enabled = false,
        max_depth = 5,
        separator = " • ",
        node_types = {},
      },
    },
    path = {
      base = "repo", -- "repo" | "cwd" | "absolute"
      max_width = 80,
    },
    stack_view = {
      position = "right", -- "left" | "right" | "bottom"
    },
    inline_preview = {
      enabled = true,
      max_lines = 10,
      hl_group = "PeekstackInlinePreview",
      close_events = { "CursorMoved", "InsertEnter", "BufLeave", "WinLeave" },
    },
    quick_peek = {
      close_events = { "CursorMoved", "InsertEnter", "BufLeave", "WinLeave" },
    },
    popup = {
      editable = false,
      buffer_mode = "copy",          -- "copy" | "source"
      source = {
        prevent_auto_close_if_modified = true,
        confirm_on_close = true,
      },
      history = {
        max_items = 50,
        restore_position = "top",    -- "top" | "original"
      },
      auto_close = {
        enabled = false,
        idle_ms = 300000,
        check_interval_ms = 60000,
        ignore_pinned = true,
      },
    },
    feedback = {
      highlight_origin_on_close = true,
    },
    promote = {
      close_popup = true,
    },
    keys = {
      close = "q",
      focus_next = "<C-j>",
      focus_prev = "<C-k>",
      promote_split = "<C-x>",
      promote_vsplit = "<C-v>",
      promote_tab = "<C-t>",
      toggle_stack_view = "<leader>os",
      zoom = "<C-z>",
    },
  },
  picker = {
    backend = "builtin", -- "builtin" | "telescope" | "fzf-lua" | "snacks" | <custom name>
    builtin = {
      preview_lines = 1,
    },
  },
  providers = {
    lsp = { enable = true },
    diagnostics = { enable = true },
    file = { enable = true },
    marks = {
      enable = false,
      include = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
      include_special = false,
    },
  },
  persist = {
    enabled = false,
    max_items = 200,
    session = {
      default_name = "default",
      prompt_if_missing = true,
    },
    auto = {
      enabled = false,
      session_name = "auto",
      restore = true,
      save = true,
      restore_if_empty = true,
      debounce_ms = 1000,
      save_on_leave = true,
    },
  },
}

Note

setup() never throws on bad config. Invalid values fall back to defaults with a vim.notify warning naming the default, counts, sizes and durations must be integers (fractions, NaN and infinities are rejected), and unknown or mistyped keys (e.g. ui.popups instead of ui.popup) are reported the same way so typos are easy to spot.

🧺 Picker backends (telescope / fzf-lua / snacks.nvim)

peekstack uses a picker when multiple locations are returned (e.g. references). The default backend is builtin. To use an external picker, install the plugin and set picker.backend to one of: telescope, fzf-lua, snacks. When using these external backends, the picker preview window shows the selected file content around the target location. Candidate labels are shown in a readable unified format: <text> - <path>:<line>:<col> (or <path>:<line>:<col> when text is empty).

{
  "mhiro2/peekstack.nvim",
  dependencies = {
    "nvim-telescope/telescope.nvim", -- or "ibhagwan/fzf-lua" / "folke/snacks.nvim"
  },
  config = function()
    require("peekstack").setup({
      picker = { backend = "telescope" },
    })
  end,
}

If the chosen plugin is not installed, a warning is shown and the picker will not open.

You can also plug in your own picker with register_picker(name, mod) and select it with picker.backend = name. The module must implement pick(locations, opts, cb) and call cb with the chosen location (or nil to cancel). Registration works before or after setup() and survives setup() re-runs; if the configured name is not registered, peekstack falls back to builtin.

require("peekstack").register_picker("my_picker", {
  pick = function(locations, opts, cb)
    vim.ui.select(locations, {
      format_item = function(loc)
        return vim.uri_to_fname(loc.uri)
      end,
    }, cb)
  end,
})
require("peekstack").setup({ picker = { backend = "my_picker" } })

🔌 Extensions (push from external pickers)

Push results from external pickers (telescope / fzf-lua / snacks.nvim) directly onto the peekstack stack. Each extension provides push_file, push_grep, push_lsp_references, and a generic actions.push for custom configurations.

-- snacks.nvim
vim.keymap.set("n", "<leader>pf", require("peekstack.extensions.snacks").push_file)

-- fzf-lua
vim.keymap.set("n", "<leader>pf", require("peekstack.extensions.fzf_lua").push_file)

-- telescope
vim.keymap.set("n", "<leader>pf", "<cmd>Telescope peekstack push_file<cr>")

See :help peekstack-extensions for the full API and custom action examples.

💾 Persist sessions

When persist.enabled = true, PeekstackSaveSession uses persist.session.default_name if you do not pass a name. If persist.session.prompt_if_missing = true, you'll be prompted for a name instead of using the default.

A session holds one stack: the popups of the current window (or of the window the stack view belongs to), oldest first, keeping the newest persist.max_items. Only locations, titles, pins, buffer modes and parent links are saved; edits made in copy-mode popups are not. Restoring a session adds its popups to the current window's stack and keeps the popups already there.

Warning

Persistence uses repository storage when the current working directory is inside a git repository. Outside a git repository, sessions fall back to cwd-based storage. The storage is resolved when a save, delete, or rename is requested, so changing directory while it is still being written does not move it to another repository.

Each git repository (each worktree, found by looking upward from the current window's working directory, so :lcd / :tcd count) has its own storage file, and session names are scoped to it.

  • Empty stacks: saving an empty stack replaces the session with an empty one; restoring an empty session reports that there is no saved session. Use :PeekstackDeleteSession to remove one.
  • Skipped items: entries whose file no longer exists or that are malformed are skipped on restore. The warning lists the first five skipped files with their reasons, and the PeekstackRestore event data carries every skipped item as skipped.
  • Unreadable storage: if the storage file cannot be read (an I/O error, invalid JSON, or a version this release does not know), peekstack warns and refuses to save, delete, or rename sessions in it instead of replacing it with an empty store. Fix or move the file to recover.

Important

Sessions are written as plain JSON under vim.fn.stdpath("state") .. "/peekstack/". Each entry stores the file URI, line/column range, title, provider name, pin/buffer-mode flags, parent popup id, and the timestamp the entry was captured. Each session also tracks created_at and updated_at metadata. Any path you peek at while persistence is enabled is recorded on disk in cleartext, so avoid enabling persistence on shared machines or for repositories whose file paths or symbol names are sensitive.

Auto persist (optional)

When persist.auto.enabled = true, peekstack can automatically restore and save a session:

  • Restore on VimEnter / DirChanged (only when the stack is empty if restore_if_empty = true)
  • Save on PeekstackPush / PeekstackClose / PeekstackRestorePopup with a debounce
  • Save on leave on VimLeavePre if save_on_leave = true

The auto session holds a single stack: the one that changed most recently, saved to the repository that was current when it changed. Closing every popup of that stack saves it empty, so nothing is restored next time.

Leaving Neovim saves that stack synchronously, even if the cursor is in another window. It skips the save if no stack changed during the session, and keeps the stored session as-is once that stack's window has been closed. The save on leave waits up to one second for earlier saves to finish; if they are still writing, it is skipped with a warning rather than racing them.

Auto persist only runs inside a git repository and always uses the repository session storage. Make sure persist.enabled = true as well.

🔁 Re-running setup

Calling require("peekstack").setup() again replaces config, re-registers the built-in providers and picker backends for the new config, and recreates the autocmds and auto-persist hooks. Providers and pickers registered with register_provider() / register_picker() are kept.

User commands are created by the first setup() call and reused afterwards; they read the config when they run, so they always use the latest settings.

It does not migrate existing popup windows, stack entries, or history in place. Updated settings apply to future actions, and to existing stacks only after those popups are reopened, restored, or recreated.

🪟 Popup buffer modes

ui.popup.buffer_mode controls how popups are backed:

  • copy (default): scratch buffer with copied lines; editing is controlled by ui.popup.editable
  • source: uses the real source buffer; useful for editing, with safety options in ui.popup.source (confirm_on_close, prevent_auto_close_if_modified)

A copy popup is a snapshot taken when it opens (up to 500 lines around the target in large files) and does not follow later changes to the file. Edits made in it with ui.popup.editable = true stay in the popup: they are never written to the file and are dropped when it closes, so history, sessions and promote reopen the file itself.

A source popup edits the real buffer, so :w saves the file. Closing the popup keeps the buffer loaded with any unsaved changes; confirm_on_close asks before the close key closes a modified popup, and prevent_auto_close_if_modified keeps auto close away from it.

Switching buffers inside a popup (:buffer, :edit) keeps the popup and makes it follow the new buffer as a source popup at the cursor, so its title, keymaps, provider requests and history refer to that buffer.

🧪 Health

Run :checkhealth peekstack to verify requirements.

📚 Documentation

See :help peekstack for complete documentation.

📄 License

MIT License. See LICENSE.

🔁 Alternatives

About

Stackable code peek popups for Neovim that preserve navigation context.

Topics

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages