Skip to content
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Local test deps
# Local test deps and artifacts
deps/
.nvimlog
.tmp/


### https://raw.githubusercontent.com/github/gitignore/86922aee426c32ffb6098f9a38c42da75169501e/Global/Windows.gitignore
Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,10 @@ Built-in provider names:
- `: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)
- `: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
- `: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)
Expand Down Expand Up @@ -404,6 +404,9 @@ to future actions, and to existing stacks only after those popups are reopened,
- **source**: uses the real source buffer; useful for editing, with safety options in
`ui.popup.source` (`confirm_on_close`, `prevent_auto_close_if_modified`)

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.
Expand Down
16 changes: 12 additions & 4 deletions doc/peekstack.txt
Original file line number Diff line number Diff line change
Expand Up @@ -290,19 +290,23 @@ Commands are registered after `setup()` is called.
no name is given, prompts to select one from the saved sessions.

:PeekstackRestorePopup *:PeekstackRestorePopup*
Restore the last closed popup from history (undo close).
Restore the last closed popup from history (undo close). Every close is
recorded, including popups closed with `:q`, `<C-w>c` or other commands
outside peekstack.

:PeekstackRestoreAllPopups *:PeekstackRestoreAllPopups*
Restore all closed popups from history.
Restore all closed popups from history. Parent/child links between the
restored popups are kept whatever order they were closed in.

:PeekstackCloseAll *:PeekstackCloseAll*
Close all popups in the current stack.

:PeekstackToggle *:PeekstackToggle*
Temporarily hide or show all popups in the current stack. When hidden,
popup windows are closed but the stack state is preserved. Toggling
again recreates the windows in their original layout. Pushing a new
popup while hidden automatically restores visibility.
again recreates the windows in their original layout. Pushing,
focusing or restoring a popup while hidden automatically restores
visibility.

:PeekstackZoom *:PeekstackZoom*
Toggle zoom on the top popup. When zoomed, the popup fills the entire
Expand Down Expand Up @@ -523,6 +527,10 @@ BUFFER MODES *peekstack-buffer-modes*
`ui.popup.source.confirm_on_close` prompts on close if modified
`ui.popup.source.prevent_auto_close_if_modified` blocks auto-close

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

==============================================================================
STACK VIEW *peekstack-stack-view*

Expand Down
7 changes: 4 additions & 3 deletions lua/peekstack/core/cleanup.lua
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,13 @@ function M.scan(now_ms)
-- Close stale popups
stack.close_stale(now_ms, auto_close_cfg)

-- Close popups with invalid origin buffers
-- Collect targets first to avoid modifying the table during iteration
-- Close popups whose origin buffer is gone, with the same rule as the
-- BufWipeout handler. Collect targets first to avoid modifying the table
-- during iteration.
local to_close = {}
for root_winid, stk in pairs(stack._all_stacks()) do
for _, item in ipairs(stk.popups) do
if item.origin and item.origin.bufnr and not vim.api.nvim_buf_is_valid(item.origin.bufnr) then
if stack.closes_with_origin(item) and not vim.api.nvim_buf_is_valid(item.origin.bufnr) then
table.insert(to_close, { id = item.id, root = root_winid })
end
end
Expand Down
50 changes: 20 additions & 30 deletions lua/peekstack/core/events.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@ local M = {}
---@type uv.uv_timer_t?
local reflow_timer = nil
local REFLOW_DEBOUNCE_MS = 80
---@type table<integer, boolean>
local popup_cursor_buffers = {}

local function reset_reflow_timer()
local store = timer_util.get_store()
Expand All @@ -34,27 +32,6 @@ local function debounced_reflow()
end)
end

---@param group integer
---@param bufnr integer
local function ensure_popup_cursor_tracking(group, bufnr)
if popup_cursor_buffers[bufnr] then
return
end
popup_cursor_buffers[bufnr] = true

vim.api.nvim_create_autocmd("CursorMoved", {
group = group,
buffer = bufnr,
callback = function()
local winid = vim.api.nvim_get_current_win()
if vim.w[winid].peekstack_popup_id == nil then
return
end
stack.touch(winid)
end,
})
end

---Close ephemeral popups that belong to the current root window.
local function close_ephemeral_popups()
stack.close_ephemerals(vim.api.nvim_get_current_win())
Expand All @@ -70,7 +47,6 @@ end

function M.setup()
reset_reflow_timer()
popup_cursor_buffers = {}
local group = vim.api.nvim_create_augroup("PeekstackEvents", { clear = true })
local keymaps = require("peekstack.ui.keymaps")

Expand All @@ -89,7 +65,13 @@ function M.setup()
callback = function(args)
stack.handle_buf_wipeout(args.buf)
stack.handle_origin_wipeout(args.buf)
popup_cursor_buffers[args.buf] = nil
end,
})

vim.api.nvim_create_autocmd("BufWinEnter", {
group = group,
callback = function(args)
stack.handle_buf_win_enter(vim.api.nvim_get_current_win(), args.buf)
end,
})

Expand All @@ -102,10 +84,6 @@ function M.setup()
group = group,
callback = function()
local winid = vim.api.nvim_get_current_win()
if vim.w[winid].peekstack_popup_id ~= nil then
local bufnr = vim.api.nvim_win_get_buf(winid)
ensure_popup_cursor_tracking(group, bufnr)
end
keymaps.activate_source_popup(winid)
if is_floating_window(winid) then
stack.touch(winid)
Expand All @@ -125,9 +103,21 @@ function M.setup()
end,
})

-- Registered globally rather than per popup buffer: the first WinEnter
-- of a popup fires before the popup is registered, so a per-buffer hook
-- installed there would miss every popup that is never re-entered.
vim.api.nvim_create_autocmd("CursorMoved", {
group = group,
callback = function()
local winid = vim.api.nvim_get_current_win()
if vim.w[winid].peekstack_popup_id ~= nil then
stack.touch(winid)
end
end,
})

local current_winid = vim.api.nvim_get_current_win()
if vim.w[current_winid].peekstack_popup_id ~= nil then
ensure_popup_cursor_tracking(group, vim.api.nvim_win_get_buf(current_winid))
keymaps.activate_source_popup(current_winid)
end

Expand Down
117 changes: 62 additions & 55 deletions lua/peekstack/core/history.lua
Original file line number Diff line number Diff line change
Expand Up @@ -66,64 +66,36 @@ local function popup_exists_in_stack(stack, popup_id)
return false
end

---Resolve parent_popup_id for a history entry being restored.
---Uses id_remap (for bulk restore) and verifies the target exists.
---Resolve the parent of a restored entry: follow the id the parent got when
---it was restored itself, and keep it only while that popup is in the stack.
---@param stack PeekstackStackModel
---@param entry PeekstackHistoryEntry
---@param id_remap? table<integer, integer>
---@return integer?
local function resolve_parent_id(stack, entry, id_remap)
local function resolve_parent_id(stack, entry)
local parent_id = entry.parent_popup_id
if not parent_id then
return nil
end
if id_remap and id_remap[parent_id] then
parent_id = id_remap[parent_id]
if stack._id_remap and stack._id_remap[parent_id] then
parent_id = stack._id_remap[parent_id]
end
if popup_exists_in_stack(stack, parent_id) then
return parent_id
end
return nil
end

---Return the stack-level id remap table, creating it if needed.
---@param stack PeekstackStackModel
---@return table<integer, integer>
local function ensure_id_remap(stack)
if not stack._id_remap then
stack._id_remap = {}
end
return stack._id_remap
end

---Record the mapping from old popup_id to new model id.
---@param stack PeekstackStackModel
---@param entry PeekstackHistoryEntry
---@param model PeekstackPopupModel
local function record_remap(stack, entry, model)
if entry.popup_id then
ensure_id_remap(stack)[entry.popup_id] = model.id
end
end

---Restore a history entry into the stack.
---Open a popup for a history entry and insert it into the stack, without
---re-layout or events.
---@param stack PeekstackStackModel
---@param entry PeekstackHistoryEntry
---@param id_remap? table<integer, integer> extra old popup id -> new popup id mapping
---@param parent_popup_id integer?
---@return PeekstackPopupModel?
function M.restore_entry(stack, entry, id_remap)
deps()
-- Merge stack-level remap with any caller-provided remap.
local merged = ensure_id_remap(stack)
if id_remap then
for k, v in pairs(id_remap) do
merged[k] = v
end
end
local function open_entry(stack, entry, parent_popup_id)
local create_opts = {
buffer_mode = entry.buffer_mode or "copy",
origin_winid = stack.root_winid,
parent_popup_id = resolve_parent_id(stack, entry, merged),
parent_popup_id = parent_popup_id,
}
-- Only pass title override for user-renamed popups (no title_chunks).
-- Auto-generated titles are regenerated by build_title() to preserve
Expand Down Expand Up @@ -153,12 +125,37 @@ function M.restore_entry(stack, entry, id_remap)
else
table.insert(stack.popups, model)
end

stack.focused_id = model.id
layout.reflow(stack)

if entry.popup_id then
stack._id_remap = stack._id_remap or {}
stack._id_remap[entry.popup_id] = model.id
end
return model
end

---@param stack PeekstackStackModel
---@param model PeekstackPopupModel
local function announce_restored(stack, model)
emit_popup_event("PeekstackPush", model, stack.root_winid)
user_events.emit("PeekstackRestorePopup", user_events.build_popup_data(model, stack.root_winid))
end

---Restore a single history entry into the stack.
---@param stack PeekstackStackModel
---@param entry PeekstackHistoryEntry
---@return PeekstackPopupModel?
local function restore_entry(stack, entry)
deps()
-- A popup restored into a hidden stack would otherwise be reopened again
-- when the stack is shown.
require("peekstack.core.stack.operations.visibility").show(stack)
local model = open_entry(stack, entry, resolve_parent_id(stack, entry))
if not model then
return nil
end
layout.reflow(stack)
announce_restored(stack, model)
return model
end

Expand All @@ -169,37 +166,49 @@ function M.restore_last(stack)
if #stack.history == 0 then
return nil
end
local entry = stack.history[#stack.history]
local restored = M.restore_entry(stack, entry)
local restored = restore_entry(stack, stack.history[#stack.history])
if not restored then
return nil
end
record_remap(stack, entry, restored)
table.remove(stack.history)
return restored
end

---Restore all closed popups from history.
---Parents are linked only after every popup is open, because a parent
---closed before its child is restored after it.
---@param stack PeekstackStackModel
---@return PeekstackPopupModel[]
function M.restore_all(stack)
local restored = {}
deps()
if #stack.history == 0 then
return {}
end
require("peekstack.core.stack.operations.visibility").show(stack)

---@type { entry: PeekstackHistoryEntry, model: PeekstackPopupModel }[]
local opened = {}
local remaining = {}
---@type table<integer, integer>
local id_remap = {}
while #stack.history > 0 do
local entry = table.remove(stack.history)
local model = M.restore_entry(stack, entry, id_remap)
if model and entry.popup_id then
id_remap[entry.popup_id] = model.id
table.insert(restored, model)
elseif model then
table.insert(restored, model)
local model = open_entry(stack, entry, nil)
if model then
table.insert(opened, { entry = entry, model = model })
else
table.insert(remaining, 1, entry)
end
end
stack.history = remaining

local restored = {}
for _, item in ipairs(opened) do
item.model.parent_popup_id = resolve_parent_id(stack, item.entry)
table.insert(restored, item.model)
end
layout.reflow(stack)
for _, model in ipairs(restored) do
announce_restored(stack, model)
end
return restored
end

Expand All @@ -211,12 +220,10 @@ function M.restore_from_history(stack, idx)
if idx < 1 or idx > #stack.history then
return nil
end
local entry = stack.history[idx]
local restored = M.restore_entry(stack, entry)
local restored = restore_entry(stack, stack.history[idx])
if not restored then
return nil
end
record_remap(stack, entry, restored)
table.remove(stack.history, idx)
return restored
end
Expand Down
Loading
Loading