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 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* 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/events.lua b/lua/peekstack/core/events.lua index 9c4fb21..0baf322 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,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, }) @@ -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) @@ -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 diff --git a/lua/peekstack/core/history.lua b/lua/peekstack/core/history.lua index a8dc786..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,44 +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. ----@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 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 @@ -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 @@ -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 - 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 @@ -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 diff --git a/lua/peekstack/core/popup.lua b/lua/peekstack/core/popup.lua index 0db4756..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") @@ -85,17 +86,106 @@ function M.focus(popup) end ---@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) +---@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 +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) + 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 2471ad6..fe991f8 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 = {} @@ -52,7 +53,9 @@ 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 ---@return table function M._all_stacks() diff --git a/lua/peekstack/core/stack/common.lua b/lua/peekstack/core/stack/common.lua index ff37401..71439c1 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,103 @@ 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 +---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..c2e57b0 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,144 +34,54 @@ 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) + remove_matching(function(item) + 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) - 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 - 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 - 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 + remove_matching(function(item) + return common.closes_with_origin(item) and item.origin.bufnr == bufnr + 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/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/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/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 new file mode 100644 index 0000000..c6e4e89 --- /dev/null +++ b/tests/lifecycle_spec.lua @@ -0,0 +1,547 @@ +-- 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) + + 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) + + 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) + + 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) + + 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) + + 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()