From e07281e5e9fecc69b32b1dec1a4407469bef0f24 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sat, 3 Oct 2026 22:10:52 +0900 Subject: [PATCH 1/8] fix(stack): close every popup through one detach-then-release path close_all closed each window while its popup was still in the stack, so the WinClosed autocmd reported the popup a second time and three popups produced six PeekstackClose events. A quick peek closed from outside dropped its model without releasing it, leaving diagnostic extmarks in the source buffer. Every close route now detaches the model first, then releases decorations and keymaps, closes the window, and records the close and history entry once; an externally closed popup can now be restored with undo close as well. --- lua/peekstack/core/popup.lua | 17 +- lua/peekstack/core/stack/common.lua | 97 ++++++++- lua/peekstack/core/stack/events.lua | 197 +++--------------- .../core/stack/operations/cleanup.lua | 58 ++---- lua/peekstack/core/stack/operations/close.lua | 82 +------- tests/lifecycle_spec.lua | 163 +++++++++++++++ 6 files changed, 332 insertions(+), 282 deletions(-) create mode 100644 tests/lifecycle_spec.lua diff --git a/lua/peekstack/core/popup.lua b/lua/peekstack/core/popup.lua index 0db4756..e5d5ae1 100644 --- a/lua/peekstack/core/popup.lua +++ b/lua/peekstack/core/popup.lua @@ -84,13 +84,22 @@ function M.focus(popup) return false end +---Release the decorations and source-mode keymaps a popup placed on its +---buffer. Safe to call after the window is gone or more than once. ---@param popup PeekstackPopupModel -function M.close(popup) - -- Remove source-mode keymaps before closing the window so they do not - -- leak into normal editing of the shared buffer. - require("peekstack.ui.keymaps").remove_popup(popup) +function M.release(popup) + -- Remove source-mode keymaps so they do not leak into normal editing of + -- the shared buffer. + keymaps.remove_popup(popup) diagnostics_ui.clear(popup.diagnostics) viewport_ui.clear(popup.viewport_marks) + popup.diagnostics = nil + popup.viewport_marks = nil +end + +---@param popup PeekstackPopupModel +function M.close(popup) + M.release(popup) if popup.winid and vim.api.nvim_win_is_valid(popup.winid) then vim.api.nvim_win_close(popup.winid, true) end diff --git a/lua/peekstack/core/stack/common.lua b/lua/peekstack/core/stack/common.lua index ff37401..6dd7f32 100644 --- a/lua/peekstack/core/stack/common.lua +++ b/lua/peekstack/core/stack/common.lua @@ -1,9 +1,14 @@ +local state = require("peekstack.core.stack.state") + local M = {} -local popup, user_events +local layout, popup, feedback, history, user_events local function deps() if not popup then + layout = require("peekstack.core.layout") popup = require("peekstack.core.popup") + feedback = require("peekstack.ui.feedback") + history = require("peekstack.core.history") user_events = require("peekstack.core.user_events") end end @@ -39,4 +44,94 @@ function M.reopen_popup(item, stack) return model end +---Remove a stack popup and record its close exactly once. +---The model is detached before the window closes, so the WinClosed autocmd +---triggered by the close no longer finds it. Decorations and source-mode +---keymaps are released even when the window is already gone. +---@param stack PeekstackStackModel +---@param idx integer +---@param item PeekstackPopupModel +---@param opts? { highlight_origin?: boolean } +function M.remove_stack_popup(stack, idx, item, opts) + deps() + table.remove(stack.popups, idx) + state.unindex_popup(item) + if stack.zoomed_id == item.id then + stack.zoomed_id = nil + end + popup.close(item) + if not opts or opts.highlight_origin ~= false then + feedback.highlight_origin(item.origin) + end + M.emit_popup_event("PeekstackClose", item, stack.root_winid) + history.push_entry(stack, history.build_entry(item, idx)) + user_events.emit("PeekstackHistoryPush", { + popup_id = item.id, + location = item.location, + root_winid = stack.root_winid, + }) +end + +---Remove every popup of `stack` matching `predicate`, top first. Targets are +---collected up front and looked up again before each removal, because +---closing one popup can wipe a buffer whose BufWipeout handler removes +---others in the meantime. +---@param stack PeekstackStackModel +---@param predicate fun(item: PeekstackPopupModel): boolean +---@param opts? { highlight_origin?: boolean } +---@return boolean removed +function M.remove_stack_popups(stack, predicate, opts) + local targets = {} + for idx = #stack.popups, 1, -1 do + if predicate(stack.popups[idx]) then + table.insert(targets, stack.popups[idx]) + end + end + for _, item in ipairs(targets) do + for idx, current in ipairs(stack.popups) do + if current == item then + M.remove_stack_popup(stack, idx, item, opts) + break + end + end + end + return #targets > 0 +end + +---Move focus to the top popup when the focused one was removed, then +---re-layout the remaining popups. +---@param stack PeekstackStackModel +function M.settle(stack) + deps() + if stack.focused_id ~= nil then + local found = false + for _, item in ipairs(stack.popups) do + if item.id == stack.focused_id then + found = true + break + end + end + if not found then + local top = stack.popups[#stack.popups] + stack.focused_id = top and top.id or nil + end + end + layout.reflow(stack) +end + +---Remove an ephemeral popup, releasing it the same way as stack popups. +---@param id integer +---@param item PeekstackPopupModel +---@param opts? { highlight_origin?: boolean } +function M.remove_ephemeral(id, item, opts) + deps() + local root_winid = state.ephemeral_root_winid(item) + state.unregister_ephemeral(id) + popup.close(item) + if opts and opts.highlight_origin then + feedback.highlight_origin(item.origin) + end + user_events.emit("PeekstackClose", user_events.build_popup_data(item, root_winid, { ephemeral = true })) +end + return M diff --git a/lua/peekstack/core/stack/events.lua b/lua/peekstack/core/stack/events.lua index 5945920..4077a74 100644 --- a/lua/peekstack/core/stack/events.lua +++ b/lua/peekstack/core/stack/events.lua @@ -3,59 +3,30 @@ local common = require("peekstack.core.stack.common") local M = {} -local layout, popup, feedback, history, user_events -local function deps() - if not layout then - layout = require("peekstack.core.layout") - popup = require("peekstack.core.popup") - feedback = require("peekstack.ui.feedback") - history = require("peekstack.core.history") - user_events = require("peekstack.core.user_events") - end -end - ----@param stack PeekstackStackModel ----@param idx integer ----@param item PeekstackPopupModel ----@param opts? { close_window?: boolean, highlight_origin?: boolean } -local function remove_stack_popup(stack, idx, item, opts) - opts = opts or {} - if stack.zoomed_id == item.id then - stack.zoomed_id = nil - end - if opts.highlight_origin ~= false then - feedback.highlight_origin(item.origin) +---Remove every stack and ephemeral popup matching `predicate`. +---@param predicate fun(item: PeekstackPopupModel): boolean +local function remove_matching(predicate) + -- Collect first: a removal can wipe buffers and re-enter this module. + local ephemerals = {} + for id, item in pairs(state.ephemerals) do + if predicate(item) then + ephemerals[id] = item + end end - common.emit_popup_event("PeekstackClose", item, stack.root_winid) - history.push_entry(stack, history.build_entry(item, idx)) - user_events.emit("PeekstackHistoryPush", { - popup_id = item.id, - location = item.location, - root_winid = stack.root_winid, - }) - state.unindex_popup(item) - table.remove(stack.popups, idx) - if opts.close_window ~= false and item.winid and vim.api.nvim_win_is_valid(item.winid) then - popup.close(item) + for id, item in pairs(ephemerals) do + if state.ephemerals[id] == item then + common.remove_ephemeral(id, item) + end end -end - ----@param id integer ----@param item PeekstackPopupModel ----@param opts? { close_window?: boolean } -local function remove_ephemeral(id, item, opts) - opts = opts or {} - local root_winid = state.ephemeral_root_winid(item) - if opts.close_window ~= false and item.winid and vim.api.nvim_win_is_valid(item.winid) then - popup.close(item) + for _, stack in pairs(vim.tbl_values(state.stacks)) do + if common.remove_stack_popups(stack, predicate) then + common.settle(stack) + end end - state.unregister_ephemeral(id) - user_events.emit("PeekstackClose", user_events.build_popup_data(item, root_winid, { ephemeral = true })) end ---@param winid integer function M.handle_win_closed(winid) - deps() if state.suppress_win_events then return end @@ -63,105 +34,34 @@ function M.handle_win_closed(winid) state.stack_view_wins[winid] = nil return end - for id, item in pairs(state.ephemerals) do - if item.winid == winid then - user_events.emit( - "PeekstackClose", - user_events.build_popup_data(item, state.ephemeral_root_winid(item), { ephemeral = true }) - ) - state.unregister_ephemeral(id) - end - end - for root_winid, stack in pairs(state.stacks) do - if stack.root_winid == winid then - for idx = #stack.popups, 1, -1 do - local item = stack.popups[idx] - common.emit_popup_event("PeekstackClose", item, root_winid) - history.push_entry(stack, history.build_entry(item, idx)) - table.remove(stack.popups, idx) - state.unindex_popup(item) - popup.close(item) - end - state.stacks[root_winid] = nil - else - local removed = false - local focused_removed = false - for idx = #stack.popups, 1, -1 do - local item = stack.popups[idx] - if item.winid == winid then - if stack.focused_id == item.id then - focused_removed = true - end - if stack.zoomed_id == item.id then - stack.zoomed_id = nil - end - common.emit_popup_event("PeekstackClose", item, root_winid) - feedback.highlight_origin(item.origin) - table.remove(stack.popups, idx) - state.unindex_popup(item) - popup.close(item) - removed = true - end - end - if removed then - if focused_removed then - if #stack.popups > 0 then - stack.focused_id = stack.popups[#stack.popups].id - else - stack.focused_id = nil - end - end - layout.reflow(stack) - end - end + local root_stack = state.stacks[winid] + if root_stack then + -- Drop the stack before closing its popups so their WinClosed events + -- do not find it again. + state.stacks[winid] = nil + common.remove_stack_popups(root_stack, function() + return true + end, { highlight_origin = false }) end + remove_matching(function(item) + return item.winid == winid + end) end ---@param bufnr integer function M.handle_buf_wipeout(bufnr) - deps() if state.suppress_win_events then return end - for id, item in pairs(state.ephemerals) do - if item.bufnr == bufnr then - remove_ephemeral(id, item, { close_window = false }) - end - end - for _, stack in pairs(state.stacks) do - local removed = false - local focused_removed = false - for idx = #stack.popups, 1, -1 do - local item = stack.popups[idx] - if item.bufnr == bufnr then - if stack.focused_id == item.id then - focused_removed = true - end - remove_stack_popup(stack, idx, item, { close_window = false }) - removed = true - end - end - if removed then - if focused_removed then - if #stack.popups > 0 then - stack.focused_id = stack.popups[#stack.popups].id - else - stack.focused_id = nil - end - end - layout.reflow(stack) - end - end + remove_matching(function(item) + return item.bufnr == bufnr + end) end ---@param bufnr integer function M.handle_origin_wipeout(bufnr) - deps() - local function should_close_for_origin(item) - if not (item.origin and item.origin.bufnr == bufnr) then - return false - end - if item.origin_is_popup == true then + remove_matching(function(item) + if not (item.origin and item.origin.bufnr == bufnr) or item.origin_is_popup == true then return false end if vim.api.nvim_buf_is_valid(bufnr) then @@ -171,36 +71,7 @@ function M.handle_origin_wipeout(bufnr) end end return true - end - for id, item in pairs(state.ephemerals) do - if should_close_for_origin(item) then - remove_ephemeral(id, item) - end - end - for _, stack in pairs(state.stacks) do - local removed = false - local focused_removed = false - for idx = #stack.popups, 1, -1 do - local item = stack.popups[idx] - if should_close_for_origin(item) then - if stack.focused_id == item.id then - focused_removed = true - end - remove_stack_popup(stack, idx, item) - removed = true - end - end - if removed then - if focused_removed then - if #stack.popups > 0 then - stack.focused_id = stack.popups[#stack.popups].id - else - stack.focused_id = nil - end - end - layout.reflow(stack) - end - end + end) end return M diff --git a/lua/peekstack/core/stack/operations/cleanup.lua b/lua/peekstack/core/stack/operations/cleanup.lua index d4bf0f7..ced57ba 100644 --- a/lua/peekstack/core/stack/operations/cleanup.lua +++ b/lua/peekstack/core/stack/operations/cleanup.lua @@ -1,14 +1,12 @@ local state = require("peekstack.core.stack.state") +local common = require("peekstack.core.stack.common") local M = {} -local config, layout, popup, user_events +local config local function deps() if not config then config = require("peekstack.config") - layout = require("peekstack.core.layout") - popup = require("peekstack.core.popup") - user_events = require("peekstack.core.user_events") end end @@ -21,7 +19,9 @@ function M.close_stale(now_ms, opts) local ignore_pinned = opts.ignore_pinned ~= false local prevent_modified = config.get().ui.popup.source.prevent_auto_close_if_modified - local close = require("peekstack.core.stack.operations.close") + -- Collect first: closing one popup can wipe buffers whose handlers close + -- others while this loop runs. + local stale = {} for root_winid, stack in pairs(state.stacks) do for idx = #stack.popups, 1, -1 do local item = stack.popups[idx] @@ -31,51 +31,27 @@ function M.close_stale(now_ms, opts) and vim.api.nvim_buf_is_valid(item.bufnr) and vim.bo[item.bufnr].modified - if not is_modified_source then - local idle_time = now_ms - item.last_active_at - if idle_time > idle_ms then - close.close(item.id, root_winid) - end + if not is_modified_source and now_ms - item.last_active_at > idle_ms then + table.insert(stale, { id = item.id, root_winid = root_winid }) end end end end -end - ----@param winid? integer -function M.close_ephemerals(winid) - deps() - local target_root_winid = nil - if winid ~= nil or vim.api.nvim_get_current_win() ~= nil then - target_root_winid = state.get_root_winid(winid) - end - for _, stack in pairs(state.stacks) do - if target_root_winid == nil or stack.root_winid == target_root_winid then - local removed = false - for idx = #stack.popups, 1, -1 do - local item = stack.popups[idx] - if item.ephemeral then - popup.close(item) - state.unindex_popup(item) - table.remove(stack.popups, idx) - removed = true - end - end - if removed then - layout.reflow(stack) - end + local close = require("peekstack.core.stack.operations.close") + for _, target in ipairs(stale) do + if state.lookup_by_id(target.id) then + close.close_by_id(target.id, target.root_winid) end end +end +---@param winid? integer +function M.close_ephemerals(winid) + local target_root_winid = state.get_root_winid(winid) for id, item in pairs(state.ephemerals) do - local entry = state.lookup_by_id(item.id) - local root_winid = entry and entry.root_winid or nil - if target_root_winid == nil or root_winid == target_root_winid then - local event_root_winid = state.ephemeral_root_winid(item) - popup.close(item) - state.unregister_ephemeral(id) - user_events.emit("PeekstackClose", user_events.build_popup_data(item, event_root_winid, { ephemeral = true })) + if state.ephemeral_root_winid(item) == target_root_winid then + common.remove_ephemeral(id, item) end end end diff --git a/lua/peekstack/core/stack/operations/close.lua b/lua/peekstack/core/stack/operations/close.lua index 929f8b0..877e687 100644 --- a/lua/peekstack/core/stack/operations/close.lua +++ b/lua/peekstack/core/stack/operations/close.lua @@ -3,52 +3,14 @@ local common = require("peekstack.core.stack.common") local M = {} -local layout, popup, feedback, user_events, history -local function deps() - if not layout then - layout = require("peekstack.core.layout") - popup = require("peekstack.core.popup") - feedback = require("peekstack.ui.feedback") - user_events = require("peekstack.core.user_events") - history = require("peekstack.core.history") - end -end - ---@param stack PeekstackStackModel ---@param idx integer ---@param item PeekstackPopupModel local function close_stack_item(stack, idx, item) - deps() local current_win = vim.api.nvim_get_current_win() local should_restore_focus = item.winid == current_win and vim.w[current_win].peekstack_popup_id ~= nil - if stack.zoomed_id == item.id then - stack.zoomed_id = nil - end - table.remove(stack.popups, idx) - state.unindex_popup(item) - - feedback.highlight_origin(item.origin) - popup.close(item) - - common.emit_popup_event("PeekstackClose", item, stack.root_winid) - - history.push_entry(stack, history.build_entry(item, idx)) - - user_events.emit("PeekstackHistoryPush", { - popup_id = item.id, - location = item.location, - root_winid = stack.root_winid, - }) - - layout.reflow(stack) - - if stack.focused_id == item.id then - if #stack.popups > 0 then - stack.focused_id = stack.popups[#stack.popups].id - else - stack.focused_id = nil - end - end + common.remove_stack_popup(stack, idx, item) + common.settle(stack) if should_restore_focus and #stack.popups > 0 then local next_popup = stack.popups[#stack.popups] @@ -60,16 +22,9 @@ end ---@param winid? integer ---@return boolean function M.close_by_id(id, winid) - deps() local ephemeral_id, ephemeral = state.find_ephemeral(id) if ephemeral_id and ephemeral then - local root_winid = state.ephemeral_root_winid(ephemeral) - feedback.highlight_origin(ephemeral.origin) - popup.close(ephemeral) - state.unregister_ephemeral(ephemeral_id) - - user_events.emit("PeekstackClose", user_events.build_popup_data(ephemeral, root_winid, { ephemeral = true })) - + common.remove_ephemeral(ephemeral_id, ephemeral, { highlight_origin = true }) return true end @@ -139,33 +94,14 @@ end ---@param winid? integer function M.close_all(winid) - deps() local stack = state.ensure_stack(winid) + -- Hidden popups have no window, so there is no origin to point back to. + local highlight_origin = not stack.hidden + common.remove_stack_popups(stack, function() + return true + end, { highlight_origin = highlight_origin }) stack.zoomed_id = nil - if stack.hidden then - for idx = #stack.popups, 1, -1 do - local item = stack.popups[idx] - common.emit_popup_event("PeekstackClose", item, stack.root_winid) - history.push_entry(stack, history.build_entry(item, idx)) - state.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) - popup.close(item) - - common.emit_popup_event("PeekstackClose", item, stack.root_winid) - - history.push_entry(stack, history.build_entry(item, idx)) - - state.unindex_popup(item) - table.remove(stack.popups, idx) - end + stack.hidden = false stack.focused_id = nil end diff --git a/tests/lifecycle_spec.lua b/tests/lifecycle_spec.lua new file mode 100644 index 0000000..751bc9a --- /dev/null +++ b/tests/lifecycle_spec.lua @@ -0,0 +1,163 @@ +-- Popup lifecycle through real autocmds: setup -> create -> switch -> close. +local stack = require("peekstack.core.stack") +local config = require("peekstack.config") +local events = require("peekstack.core.events") +local helpers = require("tests.helpers") + +---@param patterns string[] +---@return table received popup ids per event +---@return integer group +local function record_events(patterns) + local received = {} + for _, pattern in ipairs(patterns) do + received[pattern] = {} + end + local group = vim.api.nvim_create_augroup("PeekstackLifecycleSpec", { clear = true }) + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = patterns, + callback = function(args) + table.insert(received[args.match], args.data and args.data.popup_id) + end, + }) + return received, group +end + +---@return integer[] +local function floating_wins() + local wins = {} + for _, winid in ipairs(vim.api.nvim_list_wins()) do + if vim.api.nvim_win_get_config(winid).relative ~= "" then + table.insert(wins, winid) + end + end + return wins +end + +local function close_floats() + for _, winid in ipairs(floating_wins()) do + pcall(vim.api.nvim_win_close, winid, true) + end +end + +describe("popup lifecycle", function() + before_each(function() + stack._reset() + config.setup({ + ui = { + quick_peek = { close_events = { "InsertEnter" } }, + popup = { auto_close = { enabled = false } }, + }, + }) + events.setup() + end) + + after_each(function() + pcall(vim.api.nvim_del_augroup_by_name, "PeekstackLifecycleSpec") + close_floats() + stack._reset() + end) + + describe("close", function() + it("emits one close event and history entry per popup on close_all", function() + local received, group = record_events({ "PeekstackClose", "PeekstackHistoryPush" }) + local ids = {} + for _ = 1, 3 do + table.insert(ids, stack.push(helpers.make_location()).id) + end + + stack.close_all() + vim.api.nvim_del_augroup_by_id(group) + + table.sort(received.PeekstackClose) + table.sort(received.PeekstackHistoryPush) + assert.same(ids, received.PeekstackClose) + assert.same(ids, received.PeekstackHistoryPush) + assert.equals(3, #stack.history_list()) + assert.equals(0, #floating_wins()) + end) + + it("emits one close event when a single popup is closed", function() + local received, group = record_events({ "PeekstackClose" }) + local model = stack.push(helpers.make_location()) + + stack.close(model.id) + vim.api.nvim_del_augroup_by_id(group) + + assert.same({ model.id }, received.PeekstackClose) + assert.equals(1, #stack.history_list()) + end) + + it("records an externally closed popup once and keeps it restorable", function() + local received, group = record_events({ "PeekstackClose", "PeekstackHistoryPush" }) + local first = stack.push(helpers.make_location()) + local second = stack.push(helpers.make_location()) + + vim.api.nvim_win_close(second.winid, true) + vim.api.nvim_del_augroup_by_id(group) + + assert.same({ second.id }, received.PeekstackClose) + assert.same({ second.id }, received.PeekstackHistoryPush) + assert.same({ first }, stack.list()) + assert.equals(first.id, stack.focused_id()) + + local restored = stack.restore_last() + assert.is_not_nil(restored) + assert.equals(2, #stack.list()) + end) + + it("survives popups removed by a nested wipe while closing all", function() + local path = vim.fn.tempname() .. ".txt" + vim.fn.writefile({ "origin" }, path) + local root = vim.api.nvim_get_current_win() + local previous = vim.api.nvim_win_get_buf(root) + local origin_bufnr = vim.fn.bufadd(path) + vim.fn.bufload(origin_bufnr) + vim.bo[origin_bufnr].bufhidden = "wipe" + vim.api.nvim_win_set_buf(root, origin_bufnr) + + local location = helpers.make_location({ uri = vim.uri_from_bufnr(origin_bufnr) }) + local copy = stack.push(location) + vim.api.nvim_set_current_win(root) + local source = stack.push(location, { buffer_mode = "source" }) + vim.api.nvim_set_current_win(root) + -- The source popup keeps the origin buffer displayed, so it survives this. + vim.api.nvim_win_set_buf(root, previous) + assert.is_true(vim.api.nvim_buf_is_valid(origin_bufnr)) + + local received, group = record_events({ "PeekstackClose" }) + assert.has_no.errors(function() + stack.close_all() + end) + vim.api.nvim_del_augroup_by_id(group) + vim.fn.delete(path) + + table.sort(received.PeekstackClose) + assert.same({ copy.id, source.id }, received.PeekstackClose) + assert.equals(0, #stack.list()) + assert.equals(0, #floating_wins()) + end) + + it("releases a source quick peek closed from outside", function() + local received, group = record_events({ "PeekstackClose" }) + local model = stack.push( + helpers.make_location({ + provider = "diagnostics.under_cursor", + text = "external close", + }), + { stack = false, buffer_mode = "source" } + ) + assert.is_not_nil(model) + local ns = vim.api.nvim_get_namespaces().peekstack_diagnostics + assert.is_true(#vim.api.nvim_buf_get_extmarks(model.bufnr, ns, 0, -1, {}) > 0) + + vim.api.nvim_win_close(model.winid, true) + vim.api.nvim_del_augroup_by_id(group) + + assert.is_nil(stack._ephemerals()[model.id]) + assert.is_nil(stack.find_by_id(model.id)) + assert.equals(0, #vim.api.nvim_buf_get_extmarks(model.bufnr, ns, 0, -1, {})) + assert.same({ model.id }, received.PeekstackClose) + end) + end) +end) From b0579dd5db215bbf5d4026ac76d80660ebb34e0a Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sat, 3 Oct 2026 22:11:47 +0900 Subject: [PATCH 2/8] fix(cleanup): keep child popups when their parent popup closes Closing a popup wipes its copy buffer, and the periodic cleanup treated that as a lost origin and closed every popup opened from it, even pinned ones or ones with unsaved source edits. The BufWipeout handler already kept such children. Both now share one rule: only popups opened from a regular buffer close together with it. --- lua/peekstack/core/cleanup.lua | 7 +-- lua/peekstack/core/stack.lua | 2 + lua/peekstack/core/stack/common.lua | 9 ++++ lua/peekstack/core/stack/events.lua | 11 +---- tests/lifecycle_spec.lua | 70 +++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 13 deletions(-) diff --git a/lua/peekstack/core/cleanup.lua b/lua/peekstack/core/cleanup.lua index 399e331..1c4306a 100644 --- a/lua/peekstack/core/cleanup.lua +++ b/lua/peekstack/core/cleanup.lua @@ -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 diff --git a/lua/peekstack/core/stack.lua b/lua/peekstack/core/stack.lua index 2471ad6..587d9ed 100644 --- a/lua/peekstack/core/stack.lua +++ b/lua/peekstack/core/stack.lua @@ -7,6 +7,7 @@ local history_ops = require("peekstack.core.stack.operations.history") local visibility_ops = require("peekstack.core.stack.operations.visibility") local cleanup_ops = require("peekstack.core.stack.operations.cleanup") local query_ops = require("peekstack.core.stack.operations.query") +local common = require("peekstack.core.stack.common") local M = {} @@ -53,6 +54,7 @@ M.toggle_pin_by_id = query_ops.toggle_pin_by_id M.handle_win_closed = events.handle_win_closed M.handle_buf_wipeout = events.handle_buf_wipeout M.handle_origin_wipeout = events.handle_origin_wipeout +M.closes_with_origin = common.closes_with_origin ---@return table function M._all_stacks() diff --git a/lua/peekstack/core/stack/common.lua b/lua/peekstack/core/stack/common.lua index 6dd7f32..71439c1 100644 --- a/lua/peekstack/core/stack/common.lua +++ b/lua/peekstack/core/stack/common.lua @@ -44,6 +44,15 @@ function M.reopen_popup(item, stack) return model end +---Whether a popup belongs to its origin buffer and should close with it. +---Popups opened from another popup or the stack view outlive that buffer, +---which is wiped as soon as its window closes. +---@param item PeekstackPopupModel +---@return boolean +function M.closes_with_origin(item) + return item.origin ~= nil and item.origin.bufnr ~= nil and item.origin_is_popup ~= true +end + ---Remove a stack popup and record its close exactly once. ---The model is detached before the window closes, so the WinClosed autocmd ---triggered by the close no longer finds it. Decorations and source-mode diff --git a/lua/peekstack/core/stack/events.lua b/lua/peekstack/core/stack/events.lua index 4077a74..b5b2a96 100644 --- a/lua/peekstack/core/stack/events.lua +++ b/lua/peekstack/core/stack/events.lua @@ -61,16 +61,7 @@ end ---@param bufnr integer function M.handle_origin_wipeout(bufnr) remove_matching(function(item) - if not (item.origin and item.origin.bufnr == bufnr) or item.origin_is_popup == true then - return false - end - if vim.api.nvim_buf_is_valid(bufnr) then - local ft = vim.bo[bufnr].filetype - if ft == "peekstack-stack" or ft == "peekstack-stack-help" then - return false - end - end - return true + return common.closes_with_origin(item) and item.origin.bufnr == bufnr end) end diff --git a/tests/lifecycle_spec.lua b/tests/lifecycle_spec.lua index 751bc9a..bbe3a77 100644 --- a/tests/lifecycle_spec.lua +++ b/tests/lifecycle_spec.lua @@ -160,4 +160,74 @@ describe("popup lifecycle", function() assert.same({ model.id }, received.PeekstackClose) end) end) + + describe("cleanup after the parent popup closes", function() + local cleanup = require("peekstack.core.cleanup") + + ---@param child_opts? table + ---@return PeekstackPopupModel parent, PeekstackPopupModel child + local function push_parent_and_child(child_opts) + local location = helpers.make_location() + local parent = stack.push(location) + vim.api.nvim_set_current_win(parent.winid) + local child = stack.push(location, child_opts) + assert.equals(parent.id, child.parent_popup_id) + assert.is_true(child.origin_is_popup) + stack.close(parent.id) + assert.is_false(vim.api.nvim_buf_is_valid(child.origin.bufnr)) + return parent, child + end + + before_each(function() + config.setup({ + ui = { + quick_peek = { close_events = { "InsertEnter" } }, + popup = { + auto_close = { enabled = true, idle_ms = 300000, check_interval_ms = 60000, ignore_pinned = true }, + }, + }, + }) + events.setup() + end) + + after_each(function() + cleanup.stop() + end) + + it("keeps a child that is still within the idle threshold", function() + local _, child = push_parent_and_child() + cleanup.scan(vim.uv.now()) + assert.is_not_nil(stack.find_by_id(child.id)) + end) + + it("keeps a pinned child", function() + local _, child = push_parent_and_child() + stack.toggle_pin_by_id(child.id) + cleanup.scan(vim.uv.now() + 600000) + assert.is_not_nil(stack.find_by_id(child.id)) + end) + + it("keeps a child whose source buffer is modified", function() + local _, child = push_parent_and_child({ buffer_mode = "source" }) + vim.bo[child.bufnr].modified = true + cleanup.scan(vim.uv.now() + 600000) + assert.is_not_nil(stack.find_by_id(child.id)) + vim.bo[child.bufnr].modified = false + end) + + it("still closes a popup whose origin buffer was wiped", function() + local scratch = vim.api.nvim_create_buf(true, true) + local root = vim.api.nvim_get_current_win() + local previous = vim.api.nvim_win_get_buf(root) + vim.api.nvim_win_set_buf(root, scratch) + local model = stack.push(helpers.make_location({ uri = vim.uri_from_bufnr(previous) })) + vim.api.nvim_set_current_win(root) + vim.api.nvim_win_set_buf(root, previous) + -- Wipe without the BufWipeout handler so the periodic scan has to catch it. + vim.api.nvim_cmd({ cmd = "bwipeout", args = { tostring(scratch) }, mods = { noautocmd = true } }, {}) + + cleanup.scan(vim.uv.now()) + assert.is_nil(stack.find_by_id(model.id)) + end) + end) end) From 58bd2a8f98003c7be9abfa8ad8b8cf5494304712 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sat, 3 Oct 2026 22:11:55 +0900 Subject: [PATCH 3/8] chore: ignore the test state directory The test init points XDG_STATE_HOME at .tmp/state, so every test run left an untracked .tmp/ directory in the working tree. --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6d36279..f7faee6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 From bfd67c553863c3df36aab7f733186df297a0edec Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sat, 3 Oct 2026 22:12:30 +0900 Subject: [PATCH 4/8] fix(stack): show a hidden stack before restoring or focusing a popup Restoring from history or focusing a popup while the stack was hidden opened a window without showing the stack, and the next toggle opened every popup again, leaving the earlier window as an untracked float that close-all could not reach. These operations and push now show the stack first through one path, which reopens only popups without a live window; reopen_by_id also returns a popup that is already shown as is. --- lua/peekstack/core/history.lua | 3 + lua/peekstack/core/stack/operations/focus.lua | 4 + lua/peekstack/core/stack/operations/push.lua | 4 +- .../core/stack/operations/visibility.lua | 61 +++++++++----- tests/lifecycle_spec.lua | 80 +++++++++++++++++++ 5 files changed, 128 insertions(+), 24 deletions(-) diff --git a/lua/peekstack/core/history.lua b/lua/peekstack/core/history.lua index a8dc786..0d86bcd 100644 --- a/lua/peekstack/core/history.lua +++ b/lua/peekstack/core/history.lua @@ -113,6 +113,9 @@ end ---@return PeekstackPopupModel? function M.restore_entry(stack, entry, id_remap) 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) -- Merge stack-level remap with any caller-provided remap. local merged = ensure_id_remap(stack) if id_remap then diff --git a/lua/peekstack/core/stack/operations/focus.lua b/lua/peekstack/core/stack/operations/focus.lua index 802ad70..8f4dad6 100644 --- a/lua/peekstack/core/stack/operations/focus.lua +++ b/lua/peekstack/core/stack/operations/focus.lua @@ -19,6 +19,9 @@ function M.reopen_by_id(id, winid) local stack = state.ensure_stack(winid) for idx, item in ipairs(stack.popups) do if item.id == id then + if item.winid and vim.api.nvim_win_is_valid(item.winid) then + return item + end local model = common.reopen_popup(item, stack) if not model then return nil @@ -39,6 +42,7 @@ end function M.focus_by_id(id, winid) deps() local stack = state.ensure_stack(winid) + require("peekstack.core.stack.operations.visibility").show(stack) for _, item in ipairs(stack.popups) do if item.id == id or item.winid == id then local target = item diff --git a/lua/peekstack/core/stack/operations/push.lua b/lua/peekstack/core/stack/operations/push.lua index 5396eb4..f3bff9a 100644 --- a/lua/peekstack/core/stack/operations/push.lua +++ b/lua/peekstack/core/stack/operations/push.lua @@ -115,9 +115,7 @@ function M.push(location, opts) end local stack = state.ensure_stack(root_winid) - if stack.hidden then - require("peekstack.core.stack.operations.visibility").toggle(stack.root_winid) - end + require("peekstack.core.stack.operations.visibility").show(stack) if stack.zoomed_id then stack.zoomed_id = nil end diff --git a/lua/peekstack/core/stack/operations/visibility.lua b/lua/peekstack/core/stack/operations/visibility.lua index 88ee306..1fad2d2 100644 --- a/lua/peekstack/core/stack/operations/visibility.lua +++ b/lua/peekstack/core/stack/operations/visibility.lua @@ -24,6 +24,44 @@ function M.reflow_all() end end +---@param stack PeekstackStackModel +local function hide(stack) + stack.zoomed_id = nil + if vim.api.nvim_win_is_valid(stack.root_winid) then + vim.api.nvim_set_current_win(stack.root_winid) + end + state.suppress_win_events = true + for _, item in ipairs(stack.popups) do + popup.close(item) + state.unindex_popup(item) + item.winid = nil + end + state.suppress_win_events = false + stack.hidden = true +end + +---Show a hidden stack without moving focus. Popups that already have a live +---window keep it, so no window is ever opened twice for one popup. +---Every operation that opens or focuses a popup calls this first. +---@param stack PeekstackStackModel +function M.show(stack) + deps() + if not stack.hidden then + return + end + stack.hidden = false + for idx, item in ipairs(stack.popups) do + if not (item.winid and vim.api.nvim_win_is_valid(item.winid)) then + local model = common.reopen_popup(item, stack) + if model then + stack.popups[idx] = model + state.index_popup(model, stack.root_winid) + end + end + end + layout.reflow(stack) +end + ---@param winid? integer ---@return boolean function M.toggle(winid) @@ -34,28 +72,9 @@ function M.toggle(winid) end if not stack.hidden then - stack.zoomed_id = nil - if vim.api.nvim_win_is_valid(stack.root_winid) then - vim.api.nvim_set_current_win(stack.root_winid) - end - state.suppress_win_events = true - for _, item in ipairs(stack.popups) do - popup.close(item) - state.unindex_popup(item) - item.winid = nil - end - state.suppress_win_events = false - stack.hidden = true + hide(stack) else - for idx, item in ipairs(stack.popups) do - local model = common.reopen_popup(item, stack) - if model then - stack.popups[idx] = model - state.index_popup(model, stack.root_winid) - end - end - layout.reflow(stack) - stack.hidden = false + M.show(stack) if stack.focused_id then require("peekstack.core.stack.operations.focus").focus_by_id(stack.focused_id, stack.root_winid) end diff --git a/tests/lifecycle_spec.lua b/tests/lifecycle_spec.lua index bbe3a77..cc5375b 100644 --- a/tests/lifecycle_spec.lua +++ b/tests/lifecycle_spec.lua @@ -230,4 +230,84 @@ describe("popup lifecycle", function() assert.is_nil(stack.find_by_id(model.id)) end) end) + + describe("hidden stack", function() + ---Every floating window belongs to a popup in the stack. + local function assert_all_floats_tracked() + local tracked = {} + for _, item in ipairs(stack.list()) do + assert.is_not_nil(item.winid) + tracked[item.winid] = true + end + local floats = floating_wins() + for _, winid in ipairs(floats) do + assert.is_true(tracked[winid] == true, "untracked float " .. winid) + end + assert.equals(#stack.list(), #floats) + end + + local function assert_close_all_closes_everything() + stack.close_all() + assert.equals(0, #floating_wins()) + end + + ---@param restore fun() + local function restore_while_hidden(restore) + local first = stack.push(helpers.make_location()) + local second = stack.push(helpers.make_location()) + stack.close(second.id) + stack.toggle() + assert.is_true(stack.is_hidden()) + + restore() + assert.is_false(stack.is_hidden()) + assert_all_floats_tracked() + + stack.toggle() + stack.toggle() + assert_all_floats_tracked() + assert.equals(first.id, stack.list()[1].id) + assert_close_all_closes_everything() + end + + it("shows the stack when restore_last runs while hidden", function() + restore_while_hidden(function() + assert.is_not_nil(stack.restore_last()) + end) + end) + + it("shows the stack when restore_all runs while hidden", function() + restore_while_hidden(function() + assert.equals(1, #stack.restore_all()) + end) + end) + + it("shows the stack when restore_from_history runs while hidden", function() + restore_while_hidden(function() + assert.is_not_nil(stack.restore_from_history(1)) + end) + end) + + it("shows the stack when a popup is focused while hidden", function() + local first = stack.push(helpers.make_location()) + stack.push(helpers.make_location()) + stack.toggle() + + assert.is_true(stack.focus_by_id(first.id)) + assert.is_false(stack.is_hidden()) + assert.equals(stack.find_by_id(first.id).winid, vim.api.nvim_get_current_win()) + assert_all_floats_tracked() + + stack.toggle() + stack.toggle() + assert_all_floats_tracked() + assert_close_all_closes_everything() + end) + + it("does not open a second window for a popup that is already shown", function() + local model = stack.push(helpers.make_location()) + assert.equals(model, stack.reopen_by_id(model.id)) + assert_all_floats_tracked() + end) + end) end) From f9c86cc5a63e228ae2cd206829384c1452d80929 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sat, 3 Oct 2026 22:13:33 +0900 Subject: [PATCH 5/8] fix(history): link restored parents after every popup is open restore_all reopened entries newest first and resolved each parent on the spot, so a parent closed before its child was not open yet and the child lost its parent. Popups are now opened first and their parents linked in a second pass through the ids they were restored under, and the push events follow once the tree is complete. Single restores keep using the same id mapping. --- lua/peekstack/core/history.lua | 120 +++++++++++++++++---------------- tests/lifecycle_spec.lua | 85 +++++++++++++++++++++++ 2 files changed, 147 insertions(+), 58 deletions(-) diff --git a/lua/peekstack/core/history.lua b/lua/peekstack/core/history.lua index 0d86bcd..3639a7e 100644 --- a/lua/peekstack/core/history.lua +++ b/lua/peekstack/core/history.lua @@ -66,19 +66,18 @@ 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 ---@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 @@ -86,47 +85,17 @@ local function resolve_parent_id(stack, entry, id_remap) return nil end ----Return the stack-level id remap table, creating it if needed. ----@param stack PeekstackStackModel ----@return table -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. +---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 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. ----@param stack PeekstackStackModel ----@param entry PeekstackHistoryEntry ----@param id_remap? table extra old popup id -> new popup id mapping +---@param parent_popup_id integer? ---@return PeekstackPopupModel? -function M.restore_entry(stack, entry, id_remap) - 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) - -- 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 @@ -156,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 @@ -172,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 - 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 @@ -214,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 diff --git a/tests/lifecycle_spec.lua b/tests/lifecycle_spec.lua index cc5375b..cb0787c 100644 --- a/tests/lifecycle_spec.lua +++ b/tests/lifecycle_spec.lua @@ -310,4 +310,89 @@ describe("popup lifecycle", function() assert_all_floats_tracked() end) end) + + describe("restore_all", function() + ---@return PeekstackPopupModel parent, PeekstackPopupModel child + local function push_parent_and_child() + local location = helpers.make_location() + local parent = stack.push(location) + vim.api.nvim_set_current_win(parent.winid) + local child = stack.push(location) + assert.equals(parent.id, child.parent_popup_id) + return parent, child + end + + ---@param restored PeekstackPopupModel[] + ---@param parent_id integer old id of the parent + ---@return PeekstackPopupModel? parent, PeekstackPopupModel? child + local function split_restored(restored, parent_id) + local parent, child + for _, model in ipairs(restored) do + if model.parent_popup_id ~= nil then + child = model + else + parent = model + end + end + assert.is_not.equals(parent_id, parent and parent.id) + return parent, child + end + + it("links the child when the parent was closed first", function() + local parent, child = push_parent_and_child() + stack.close(parent.id) + stack.close(child.id) + + local restored = stack.restore_all() + assert.equals(2, #restored) + local new_parent, new_child = split_restored(restored, parent.id) + assert.is_not_nil(new_parent) + assert.is_not_nil(new_child) + assert.equals(new_parent.id, new_child.parent_popup_id) + end) + + it("links the child when the child was closed first", function() + local parent, child = push_parent_and_child() + stack.close(child.id) + stack.close(parent.id) + + local restored = stack.restore_all() + local new_parent, new_child = split_restored(restored, parent.id) + assert.is_not_nil(new_child) + assert.equals(new_parent.id, new_child.parent_popup_id) + end) + + it("announces restored popups only after their parents are linked", function() + local parent, child = push_parent_and_child() + stack.close(parent.id) + stack.close(child.id) + + local parent_at_push = {} + local group = vim.api.nvim_create_augroup("PeekstackLifecycleSpec", { clear = true }) + vim.api.nvim_create_autocmd("User", { + group = group, + pattern = "PeekstackRestorePopup", + callback = function(args) + parent_at_push[args.data.popup_id] = stack.find_by_id(args.data.popup_id).parent_popup_id or false + end, + }) + + local _, new_child = split_restored(stack.restore_all(), parent.id) + assert.equals(new_child.parent_popup_id, parent_at_push[new_child.id]) + end) + + it("keeps the link around an entry that fails to restore", function() + local parent, child = push_parent_and_child() + stack.close(parent.id) + local broken = { location = { uri = nil }, title = "broken", buffer_mode = "copy" } + table.insert(stack.history_list(), broken) + stack.close(child.id) + + local restored = stack.restore_all() + assert.equals(2, #restored) + local new_parent, new_child = split_restored(restored, parent.id) + assert.equals(new_parent.id, new_child.parent_popup_id) + assert.same({ broken }, stack.history_list()) + end) + end) end) From 05b2c66a07132552c15315dfe21fb86736fed99e Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sat, 3 Oct 2026 22:14:28 +0900 Subject: [PATCH 6/8] fix(events): track popup activity from the first display Cursor tracking was attached to a popup buffer on WinEnter, but the first WinEnter fires while the window is opened, before the popup is registered. A popup that was never left and re-entered kept its creation time as last activity and was auto-closed while in use. The CursorMoved hook is now global and touches whichever popup window the cursor moves in. --- lua/peekstack/core/events.lua | 43 +++++++++--------------------- tests/events_spec.lua | 23 ---------------- tests/lifecycle_spec.lua | 50 +++++++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 53 deletions(-) diff --git a/lua/peekstack/core/events.lua b/lua/peekstack/core/events.lua index 9c4fb21..3c046f4 100644 --- a/lua/peekstack/core/events.lua +++ b/lua/peekstack/core/events.lua @@ -7,8 +7,6 @@ local M = {} ---@type uv.uv_timer_t? local reflow_timer = nil local REFLOW_DEBOUNCE_MS = 80 ----@type table -local popup_cursor_buffers = {} local function reset_reflow_timer() local store = timer_util.get_store() @@ -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()) @@ -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") @@ -89,7 +65,6 @@ 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, }) @@ -102,10 +77,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) @@ -125,9 +96,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 diff --git a/tests/events_spec.lua b/tests/events_spec.lua index 2fbbf1b..9ff9297 100644 --- a/tests/events_spec.lua +++ b/tests/events_spec.lua @@ -57,29 +57,6 @@ describe("peekstack.core.events", function() assert.equals(1, touch_calls) end) - it("registers CursorMoved autocmd only for popup buffers", function() - local winid = vim.api.nvim_get_current_win() - local bufnr = vim.api.nvim_get_current_buf() - - events.setup() - local before = vim.api.nvim_get_autocmds({ - group = "PeekstackEvents", - event = "CursorMoved", - buffer = bufnr, - }) - assert.equals(0, #before) - - vim.w[winid].peekstack_popup_id = 999 - vim.api.nvim_exec_autocmds("WinEnter", { modeline = false }) - - local after = vim.api.nvim_get_autocmds({ - group = "PeekstackEvents", - event = "CursorMoved", - buffer = bufnr, - }) - assert.equals(1, #after) - end) - it("stops cleanup timer when re-setup disables auto_close", function() local timer_store = require("peekstack.util.timer").get_store() diff --git a/tests/lifecycle_spec.lua b/tests/lifecycle_spec.lua index cb0787c..74c1aa2 100644 --- a/tests/lifecycle_spec.lua +++ b/tests/lifecycle_spec.lua @@ -395,4 +395,54 @@ describe("popup lifecycle", function() assert.same({ broken }, stack.history_list()) end) end) + + describe("activity tracking", function() + local path + + -- A headless test never reaches the main loop, which is where Neovim + -- fires CursorMoved, so fire it from the popup window after the motion. + local function move_cursor_down() + vim.api.nvim_feedkeys("j", "x", false) + vim.api.nvim_exec_autocmds("CursorMoved", { buffer = vim.api.nvim_get_current_buf(), modeline = false }) + end + + before_each(function() + path = vim.fn.tempname() .. ".txt" + vim.fn.writefile({ "one", "two", "three", "four" }, path) + end) + + after_each(function() + vim.fn.delete(path) + end) + + it("updates last_active_at when the cursor moves in a new popup", function() + local model = stack.push(helpers.make_location({ uri = vim.uri_from_fname(path) })) + assert.equals(model.winid, vim.api.nvim_get_current_win()) + model.last_active_at = 0 + + move_cursor_down() + + assert.equals(2, vim.api.nvim_win_get_cursor(model.winid)[1]) + assert.is_true(model.last_active_at > 0) + end) + + it("keeps a popup in use open across the auto-close scan", function() + config.setup({ + ui = { + quick_peek = { close_events = { "InsertEnter" } }, + popup = { auto_close = { enabled = true, idle_ms = 1000, check_interval_ms = 60000 } }, + }, + }) + events.setup() + local cleanup = require("peekstack.core.cleanup") + local model = stack.push(helpers.make_location({ uri = vim.uri_from_fname(path) })) + model.last_active_at = vim.uv.now() - 5000 + + move_cursor_down() + cleanup.scan(vim.uv.now()) + cleanup.stop() + + assert.is_not_nil(stack.find_by_id(model.id)) + end) + end) end) From 96c08f320a3dfafd9ced5f75c196494545586acc Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sat, 3 Oct 2026 22:15:21 +0900 Subject: [PATCH 7/8] fix(popup): follow a buffer switched into the popup window After :buffer or :edit inside a popup, the popup still described the previous buffer, so provider requests used the new cursor position in the old file and the stack view, title and history kept the old place. For copy popups the wiped copy buffer even dropped the popup and left its window as an untracked float. The popup now follows the new buffer as a source-mode popup at the cursor, moving its keymaps, title and location along and releasing what it placed on the old buffer. --- lua/peekstack/core/events.lua | 7 ++ lua/peekstack/core/popup.lua | 81 +++++++++++++++++++++++ lua/peekstack/core/stack.lua | 1 + lua/peekstack/core/stack/events.lua | 21 +++++- lua/peekstack/types.lua | 1 + lua/peekstack/ui/render.lua | 4 +- tests/lifecycle_spec.lua | 99 +++++++++++++++++++++++++++++ tests/stack_spec.lua | 2 + 8 files changed, 213 insertions(+), 3 deletions(-) diff --git a/lua/peekstack/core/events.lua b/lua/peekstack/core/events.lua index 3c046f4..0baf322 100644 --- a/lua/peekstack/core/events.lua +++ b/lua/peekstack/core/events.lua @@ -68,6 +68,13 @@ function M.setup() 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, + }) + vim.api.nvim_create_autocmd({ "VimResized", "WinResized" }, { group = group, callback = debounced_reflow, diff --git a/lua/peekstack/core/popup.lua b/lua/peekstack/core/popup.lua index e5d5ae1..66e3178 100644 --- a/lua/peekstack/core/popup.lua +++ b/lua/peekstack/core/popup.lua @@ -1,5 +1,6 @@ local buffer = require("peekstack.core.popup.buffer") local origin = require("peekstack.core.popup.origin") +local render = require("peekstack.ui.render") local window = require("peekstack.core.popup.window") local diagnostics_ui = require("peekstack.ui.diagnostics") local keymaps = require("peekstack.ui.keymaps") @@ -84,6 +85,41 @@ function M.focus(popup) return false end +---@param popup PeekstackPopupModel +---@return boolean +local function shows_own_buffer(popup) + return popup.winid ~= nil + and vim.api.nvim_win_is_valid(popup.winid) + and vim.api.nvim_win_get_buf(popup.winid) == popup.bufnr +end + +---Point a retargeted popup at the cursor of its window and retitle it. +---@param popup PeekstackPopupModel +local function relocate(popup) + local cursor = vim.api.nvim_win_get_cursor(popup.winid) + local pos = { line = cursor[1] - 1, character = cursor[2] } + popup.location = { + uri = vim.uri_from_bufnr(popup.bufnr), + range = { start = pos, ["end"] = pos }, + provider = popup.location.provider, + } + + -- A title without chunks was set by the user; keep it. + if popup.title_chunks then + local title_chunks = render.build_title(popup.location) + popup.title_chunks = title_chunks + popup.title = title_chunks and render.title_text(title_chunks) or nil + if popup.win_opts then + popup.win_opts.title = title_chunks + popup.win_opts.title_pos = title_chunks and "center" or nil + end + pcall(vim.api.nvim_win_set_config, popup.winid, { + title = title_chunks or "", + title_pos = title_chunks and "center" or nil, + }) + end +end + ---Release the decorations and source-mode keymaps a popup placed on its ---buffer. Safe to call after the window is gone or more than once. ---@param popup PeekstackPopupModel @@ -99,12 +135,57 @@ end ---@param popup PeekstackPopupModel function M.close(popup) + if popup.relocate_pending then + popup.relocate_pending = nil + if shows_own_buffer(popup) then + relocate(popup) + end + end M.release(popup) if popup.winid and vim.api.nvim_win_is_valid(popup.winid) then vim.api.nvim_win_close(popup.winid, true) end end +---Make a popup follow a buffer that replaced its own in the popup window +---(`:buffer`, `:edit`). The window now shows a regular buffer, so the popup +---becomes a source-mode popup at the cursor: provider requests, keymaps, +---title and history all refer to that buffer from now on. +---@param popup PeekstackPopupModel +---@param bufnr integer +function M.retarget(popup, bufnr) + M.release(popup) + local old_bufnr = popup.bufnr + if vim.api.nvim_buf_is_valid(old_bufnr) and vim.b[old_bufnr].peekstack_popup_id == popup.id then + vim.b[old_bufnr].peekstack_popup_id = nil + end + + popup.bufnr = bufnr + popup.source_bufnr = bufnr + popup.buffer_mode = "source" + popup.line_offset = 0 + popup.viewport = nil + vim.b[bufnr].peekstack_popup_id = popup.id + relocate(popup) + + -- BufWinEnter fires before the command places the cursor (`:edit +100`, + -- the buffer's last position), so take the position again once it is done, + -- or when the popup closes first (see M.close). + local winid = popup.winid + popup.relocate_pending = true + vim.schedule(function() + if popup.relocate_pending and shows_own_buffer(popup) and popup.winid == winid then + popup.relocate_pending = nil + relocate(popup) + require("peekstack.ui.stack_view").refresh_all() + end + end) + + if winid == vim.api.nvim_get_current_win() then + keymaps.apply_popup(popup) + end +end + --- Reset next_id (for testing). function M._reset() next_id = 1 diff --git a/lua/peekstack/core/stack.lua b/lua/peekstack/core/stack.lua index 587d9ed..fe991f8 100644 --- a/lua/peekstack/core/stack.lua +++ b/lua/peekstack/core/stack.lua @@ -53,6 +53,7 @@ M.toggle_pin_by_id = query_ops.toggle_pin_by_id M.handle_win_closed = events.handle_win_closed M.handle_buf_wipeout = events.handle_buf_wipeout +M.handle_buf_win_enter = events.handle_buf_win_enter M.handle_origin_wipeout = events.handle_origin_wipeout M.closes_with_origin = common.closes_with_origin diff --git a/lua/peekstack/core/stack/events.lua b/lua/peekstack/core/stack/events.lua index b5b2a96..c2e57b0 100644 --- a/lua/peekstack/core/stack/events.lua +++ b/lua/peekstack/core/stack/events.lua @@ -54,10 +54,29 @@ function M.handle_buf_wipeout(bufnr) return end remove_matching(function(item) - return item.bufnr == bufnr + if item.bufnr ~= bufnr then + return false + end + -- A buffer wiped while its popup window still shows it is being replaced + -- in that window (`:buffer`, `:edit`); handle_buf_win_enter makes the + -- popup follow the new buffer instead. + local winid = item.winid + return not (winid and vim.api.nvim_win_is_valid(winid) and vim.api.nvim_win_get_buf(winid) == bufnr) end) end +---Follow a buffer that replaced the one shown in a popup window. +---@param winid integer +---@param bufnr integer +function M.handle_buf_win_enter(winid, bufnr) + local entry = state.lookup_by_winid(winid) + if not entry or entry.popup.bufnr == bufnr then + return + end + require("peekstack.core.popup").retarget(entry.popup, bufnr) + require("peekstack.ui.stack_view").refresh_all() +end + ---@param bufnr integer function M.handle_origin_wipeout(bufnr) remove_matching(function(item) diff --git a/lua/peekstack/types.lua b/lua/peekstack/types.lua index fd4c647..5becf63 100644 --- a/lua/peekstack/types.lua +++ b/lua/peekstack/types.lua @@ -67,6 +67,7 @@ ---@field last_active_at integer ---@field ephemeral boolean ---@field win_opts PeekstackRenderWinOpts? +---@field relocate_pending? boolean cursor of a switched-in buffer not taken yet ---@class PeekstackStackModel ---@field root_winid integer diff --git a/lua/peekstack/ui/render.lua b/lua/peekstack/ui/render.lua index fd3f011..9696e14 100644 --- a/lua/peekstack/ui/render.lua +++ b/lua/peekstack/ui/render.lua @@ -101,7 +101,7 @@ end ---Build the title string for a popup window ---@param location PeekstackLocation ---@return PeekstackTitleChunk[]? -local function build_title(location) +function M.build_title(location) local ui = config.get().ui if not ui.title.enabled then return nil @@ -310,7 +310,7 @@ function M.open(bufnr, location, opts) zindex = layout_opts.zindex, } - local title = build_title(location) + local title = M.build_title(location) if title then win_opts.title = title win_opts.title_pos = "center" diff --git a/tests/lifecycle_spec.lua b/tests/lifecycle_spec.lua index 74c1aa2..c6e4e89 100644 --- a/tests/lifecycle_spec.lua +++ b/tests/lifecycle_spec.lua @@ -445,4 +445,103 @@ describe("popup lifecycle", function() assert.is_not_nil(stack.find_by_id(model.id)) end) end) + + describe("buffer switch inside a popup", function() + local first_path, second_path + + before_each(function() + first_path = vim.fn.tempname() .. ".txt" + second_path = vim.fn.tempname() .. ".txt" + vim.fn.writefile({ "first one", "first two" }, first_path) + vim.fn.writefile({ "second one", "second two", "second three" }, second_path) + end) + + after_each(function() + for _, path in ipairs({ first_path, second_path }) do + local bufnr = vim.fn.bufnr(path) + if bufnr > 0 then + pcall(vim.api.nvim_buf_delete, bufnr, { force = true }) + end + vim.fn.delete(path) + end + end) + + ---@param bufnr integer + ---@param lhs string + ---@return boolean + local function has_buffer_map(bufnr, lhs) + local item = vim.api.nvim_buf_call(bufnr, function() + return vim.fn.maparg(lhs, "n", false, true) + end) + return item.buffer == 1 + end + + for _, case in ipairs({ + { mode = "source", cmd = "buffer" }, + { mode = "source", cmd = "edit" }, + { mode = "copy", cmd = "buffer" }, + { mode = "copy", cmd = "edit" }, + }) do + it(("follows :%s in a %s popup"):format(case.cmd, case.mode), function() + local close_key = config.get().ui.keys.close + local model = + stack.push(helpers.make_location({ uri = vim.uri_from_fname(first_path) }), { buffer_mode = case.mode }) + local winid = model.winid + local first_bufnr = model.source_bufnr + local second_bufnr = vim.fn.bufadd(second_path) + vim.fn.bufload(second_bufnr) + + vim.api.nvim_cmd( + { cmd = case.cmd, args = { case.cmd == "buffer" and tostring(second_bufnr) or second_path } }, + {} + ) + vim.api.nvim_win_set_cursor(winid, { 3, 2 }) + + assert.equals(winid, vim.api.nvim_get_current_win()) + assert.equals(model, stack.find_by_id(model.id)) + assert.equals(second_bufnr, model.bufnr) + assert.equals("source", model.buffer_mode) + + local ctx = require("peekstack.core.context").current() + assert.equals(second_bufnr, ctx.bufnr) + assert.equals(model.id, ctx.popup_id) + assert.same({ line = 2, character = 2 }, ctx.position) + assert.equals(vim.uri_from_bufnr(second_bufnr), model.location.uri) + assert.is_truthy(model.title:find(vim.fn.fnamemodify(second_path, ":t"), 1, true)) + + assert.is_true(has_buffer_map(second_bufnr, close_key)) + assert.is_false(has_buffer_map(first_bufnr, close_key)) + + stack.close_all() + assert.is_false(vim.api.nvim_win_is_valid(winid)) + assert.is_false(has_buffer_map(second_bufnr, close_key)) + assert.equals(vim.uri_from_bufnr(second_bufnr), stack.history_list()[1].location.uri) + end) + end + + it("records the position the switch command leaves the cursor at", function() + local model = stack.push(helpers.make_location({ uri = vim.uri_from_fname(first_path) })) + vim.cmd("edit +3 " .. vim.fn.fnameescape(second_path)) + assert.equals(3, vim.api.nvim_win_get_cursor(model.winid)[1]) + + assert.is_true(vim.wait(500, function() + return model.location.range.start.line == 2 + end)) + assert.is_truthy(model.title:find(":3", 1, true)) + + stack.close(model.id) + local restored = stack.restore_last() + assert.equals(3, vim.api.nvim_win_get_cursor(restored.winid)[1]) + end) + + it("records the final position when the popup closes right after the switch", function() + local model = stack.push(helpers.make_location({ uri = vim.uri_from_fname(first_path) })) + -- Close in the same tick, before the deferred cursor read runs. + vim.cmd("edit +3 " .. vim.fn.fnameescape(second_path)) + stack.close_all() + assert.is_false(vim.api.nvim_win_is_valid(model.winid)) + + assert.equals(2, stack.history_list()[1].location.range.start.line) + end) + end) end) diff --git a/tests/stack_spec.lua b/tests/stack_spec.lua index bae4a3c..0440e6b 100644 --- a/tests/stack_spec.lua +++ b/tests/stack_spec.lua @@ -346,6 +346,8 @@ describe("stack.handle_buf_wipeout", function() local model = stack.push(helpers.make_location()) assert.is_not_nil(model) + -- Neovim closes the windows showing a buffer before wiping it. + vim.api.nvim_win_close(model.winid, true) stack.handle_buf_wipeout(model.bufnr) local history = stack.history_list() From f9efaa90705679a26953ac3848d695d1871bd56e Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sat, 3 Oct 2026 22:15:53 +0900 Subject: [PATCH 8/8] docs: describe close history, hidden stack and buffer switch behavior Document that undo close also covers popups closed outside peekstack, that restore-all keeps parent links, that focusing or restoring a popup shows a hidden stack, and that a popup follows a buffer switched into its window. --- README.md | 7 +++++-- doc/peekstack.txt | 16 ++++++++++++---- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 83a7b49..4302b58 100644 --- a/README.md +++ b/README.md @@ -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` - `: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) @@ -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. diff --git a/doc/peekstack.txt b/doc/peekstack.txt index e371fc0..6cad5ff 100644 --- a/doc/peekstack.txt +++ b/doc/peekstack.txt @@ -290,10 +290,13 @@ 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` 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. @@ -301,8 +304,9 @@ Commands are registered after `setup()` is called. :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 @@ -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*