Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,9 @@ Using lazy.nvim:

-- 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>PeekstackToggleVisibility<cr>", { desc = "Peekstack: toggle visibility" })
end,
}
```
Expand Down Expand Up @@ -109,6 +112,7 @@ Built-in provider names:
- `:PeekstackRestorePopup` — restore the last closed popup (undo close)
- `:PeekstackRestoreAllPopups` — restore all closed popups
- `:PeekstackCloseAll` — close all popups in the current stack
- `:PeekstackToggleVisibility` — temporarily hide/show all popups in the current stack
- `:PeekstackHistory` — show popup history and select to restore
- `:PeekstackQuickPeek [provider]` — quick peek without stacking (default: `lsp.definition`, accepts any registered provider)

Expand Down
6 changes: 6 additions & 0 deletions doc/peekstack.txt
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,12 @@ Commands are registered after `setup()` is called.
:PeekstackCloseAll *:PeekstackCloseAll*
Close all popups in the current stack.

:PeekstackToggleVisibility *:PeekstackToggleVisibility*
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.

:PeekstackHistory *:PeekstackHistory*
Show popup history and select an entry to restore.

Expand Down
8 changes: 8 additions & 0 deletions lua/peekstack/commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ local COMMAND_NAMES = {
"PeekstackHistory",
"PeekstackCloseAll",
"PeekstackQuickPeek",
"PeekstackToggleVisibility",
}

---@param session PeekstackSession|table
Expand Down Expand Up @@ -173,6 +174,13 @@ function M.setup()
require("peekstack.core.stack").close_all()
end, {})

vim.api.nvim_create_user_command("PeekstackToggleVisibility", function()
local toggled = require("peekstack.core.stack").toggle_visibility()
if not toggled then
notify.info("No popups in the current stack")
end
end, {})

vim.api.nvim_create_user_command("PeekstackQuickPeek", function(opts)
local provider = opts.args and opts.args ~= "" and opts.args or "lsp.definition"
require("peekstack").peek(provider, { mode = "quick" })
Expand Down
120 changes: 107 additions & 13 deletions lua/peekstack/core/stack.lua
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ local stacks = {}
local ephemerals = {}
---@type table<integer, boolean>
local stack_view_wins = {}
--- Guard flag: when true, WinClosed/BufWipeout handlers skip popup removal.
local suppress_win_events = false
---@class PeekstackPopupLookupEntry
---@field popup PeekstackPopupModel
---@field root_winid integer?
Expand Down Expand Up @@ -277,6 +279,10 @@ function M.push(location, opts)
end

local stack = ensure_stack()
-- Auto-show hidden stack before pushing a new popup
if stack.hidden then
M.toggle_visibility(stack.root_winid)
end
local model = popup.create(location, create_opts)
if not model then
return nil
Expand Down Expand Up @@ -531,6 +537,30 @@ function M.focus_by_id(id, winid)
return false
end

---Re-create a popup window for an existing stack item.
---@param item PeekstackPopupModel
---@param stack PeekstackStackModel
---@return PeekstackPopupModel?
local function reopen_popup(item, stack)
local reopen_opts = {
buffer_mode = item.buffer_mode or "copy",
origin_winid = stack.root_winid,
parent_popup_id = item.parent_popup_id,
}
if not item.title_chunks then
reopen_opts.title = item.title
end
local model = popup.create(item.location, reopen_opts)
if not model then
return nil
end
model.id = item.id
model.pinned = item.pinned or false
vim.b[model.bufnr].peekstack_popup_id = model.id
vim.w[model.winid].peekstack_popup_id = model.id
return model
end

---Re-open a popup by id when its window is gone.
---@param id integer
---@param winid? integer
Expand All @@ -540,22 +570,10 @@ function M.reopen_by_id(id, winid)
local stack = ensure_stack(winid)
for idx, item in ipairs(stack.popups) do
if item.id == id then
local reopen_opts = {
buffer_mode = item.buffer_mode or "copy",
origin_winid = stack.root_winid,
parent_popup_id = item.parent_popup_id,
}
if not item.title_chunks then
reopen_opts.title = item.title
end
local model = popup.create(item.location, reopen_opts)
local model = reopen_popup(item, stack)
if not model then
return nil
end
model.id = item.id
model.pinned = item.pinned or false
vim.b[model.bufnr].peekstack_popup_id = model.id
vim.w[model.winid].peekstack_popup_id = model.id
unindex_popup(item)
stack.popups[idx] = model
index_popup(model, stack.root_winid)
Expand Down Expand Up @@ -601,6 +619,9 @@ end
---@param winid integer
function M.handle_win_closed(winid)
deps()
if suppress_win_events then
return
end
if stack_view_wins[winid] then
stack_view_wins[winid] = nil
return
Expand Down Expand Up @@ -688,6 +709,9 @@ end
---@param bufnr integer
function M.handle_buf_wipeout(bufnr)
deps()
if suppress_win_events then
return
end
for id, item in pairs(ephemerals) do
if item.bufnr == bufnr then
unregister_ephemeral(id)
Expand Down Expand Up @@ -820,11 +844,80 @@ function M.reflow_all()
end
end

---Toggle visibility of all popups in the current stack.
---When hidden, popup windows are closed but models remain in the stack.
---When shown, popup windows are recreated from the stored models.
---@param winid? integer
---@return boolean
function M.toggle_visibility(winid)
deps()
local stack = ensure_stack(winid)
if #stack.popups == 0 then
return false
end

if not stack.hidden then
-- Move focus back to root window before hiding
if vim.api.nvim_win_is_valid(stack.root_winid) then
vim.api.nvim_set_current_win(stack.root_winid)
end
-- Close all popup windows but keep models in the stack.
-- Suppress WinClosed/BufWipeout handlers to prevent them from
-- removing popups that we intend to keep in the stack.
suppress_win_events = true
for _, item in ipairs(stack.popups) do
popup.close(item)
unindex_popup(item)
item.winid = nil
end
suppress_win_events = false
stack.hidden = true
else
-- Recreate all popup windows
for idx, item in ipairs(stack.popups) do
local model = reopen_popup(item, stack)
if model then
stack.popups[idx] = model
index_popup(model, stack.root_winid)
end
end
layout.reflow(stack)
stack.hidden = false
-- Restore focus to the previously focused popup
if stack.focused_id then
M.focus_by_id(stack.focused_id, stack.root_winid)
end
end
return true
end

---Check whether the current stack is hidden.
---@param winid? integer
---@return boolean
function M.is_hidden(winid)
local stack = ensure_stack(winid)
return stack.hidden == true
end

--- Close all popups in the current (or given) window's stack.
---@param winid? integer
function M.close_all(winid)
deps()
local stack = ensure_stack(winid)
-- When hidden, windows are already closed; just clear hidden state
-- and record history for each popup.
if stack.hidden then
for idx = #stack.popups, 1, -1 do
local item = stack.popups[idx]
emit_popup_event("PeekstackClose", item, stack.root_winid)
history.push_entry(stack, history.build_entry(item, idx))
unindex_popup(item)
table.remove(stack.popups, idx)
end
stack.hidden = false
stack.focused_id = nil
return
end
for idx = #stack.popups, 1, -1 do
local item = stack.popups[idx]
feedback.highlight_origin(item.origin)
Expand Down Expand Up @@ -855,6 +948,7 @@ function M._reset()
stack_view_wins = {}
popup_by_id = {}
popup_by_winid = {}
suppress_win_events = false
end

---Get ephemeral popups (for testing).
Expand Down
1 change: 1 addition & 0 deletions lua/peekstack/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
---@field history PeekstackHistoryEntry[]
---@field layout_state any
---@field focused_id integer?
---@field hidden boolean?

---@class PeekstackUserEventData
---@field event string
Expand Down
133 changes: 133 additions & 0 deletions tests/toggle_visibility_spec.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
local stack = require("peekstack.core.stack")
local config = require("peekstack.config")
local events = require("peekstack.core.events")
local helpers = require("tests.helpers")

describe("stack.toggle_visibility", function()
before_each(function()
stack._reset()
config.setup({})
events.setup()
end)

after_each(function()
local s = stack.current_stack()
-- Restore visibility so windows can be closed normally
if s.hidden then
stack.toggle_visibility()
end
for i = #s.popups, 1, -1 do
stack.close(s.popups[i].id)
end
stack._reset()
end)

it("returns false on empty stack", function()
assert.is_false(stack.toggle_visibility())
assert.is_false(stack.is_hidden())
end)

it("hides all popup windows", function()
local loc = helpers.make_location()
local m1 = stack.push(loc)
local m2 = stack.push(loc)
assert.is_not_nil(m1)
assert.is_not_nil(m2)

assert.is_true(stack.toggle_visibility())
assert.is_true(stack.is_hidden())

-- Windows should be gone but popups remain in the stack
local s = stack.current_stack()
assert.equals(2, #s.popups)
for _, item in ipairs(s.popups) do
assert.is_nil(item.winid)
end
end)

it("restores popup windows on second toggle", function()
local loc = helpers.make_location()
local m1 = stack.push(loc)
local m2 = stack.push(loc)
assert.is_not_nil(m1)
assert.is_not_nil(m2)

-- Hide
stack.toggle_visibility()
assert.is_true(stack.is_hidden())

-- Show
assert.is_true(stack.toggle_visibility())
assert.is_false(stack.is_hidden())

local s = stack.current_stack()
assert.equals(2, #s.popups)
for _, item in ipairs(s.popups) do
assert.is_not_nil(item.winid)
assert.is_true(vim.api.nvim_win_is_valid(item.winid))
end
end)

it("preserves popup ids across hide/show cycle", function()
local loc = helpers.make_location()
local m1 = stack.push(loc)
local m2 = stack.push(loc)

local id1 = m1.id
local id2 = m2.id

stack.toggle_visibility()
stack.toggle_visibility()

local s = stack.current_stack()
assert.equals(id1, s.popups[1].id)
assert.equals(id2, s.popups[2].id)
end)

it("auto-shows when push is called while hidden", function()
local loc = helpers.make_location()
stack.push(loc)

stack.toggle_visibility()
assert.is_true(stack.is_hidden())

-- Pushing should auto-show
local m2 = stack.push(loc)
assert.is_not_nil(m2)
assert.is_false(stack.is_hidden())

local s = stack.current_stack()
assert.equals(2, #s.popups)
for _, item in ipairs(s.popups) do
assert.is_not_nil(item.winid)
assert.is_true(vim.api.nvim_win_is_valid(item.winid))
end
end)

it("close_all works while hidden", function()
local loc = helpers.make_location()
stack.push(loc)
stack.push(loc)

stack.toggle_visibility()
assert.is_true(stack.is_hidden())

stack.close_all()
assert.is_false(stack.is_hidden())

local s = stack.current_stack()
assert.equals(0, #s.popups)
end)

it("does not leak popups to history when hiding", function()
local loc = helpers.make_location()
stack.push(loc)
stack.push(loc)

local history_before = #stack.history_list()
stack.toggle_visibility()
local history_after = #stack.history_list()

assert.equals(history_before, history_after)
end)
end)