From df1ea2a7736808da4043ace6ff31ae542a500bf2 Mon Sep 17 00:00:00 2001 From: William Hsieh Date: Thu, 4 Jun 2026 17:50:10 +0800 Subject: [PATCH] fix(restore): preserve live buffer and cursor across close/restore popup.opts.target_bufnr / lnum / col are captured once by the adapter at peek time and never refreshed. If the user navigates inside a popup (gd / gf / :edit / direct cursor moves), the popup's window state diverges from opts: the window shows a different buffer or has the cursor at a different position. close_all + restore then reopens what was *originally* peeked, not what the user was last viewing -- losing their navigation. Snapshot the popup's live state into opts at two points: 1. Popup:snapshot_state() reads nvim_win_get_buf + nvim_win_get_cursor and writes back to self.opts. Idempotent; rerunning is a no-op if the popup hasn't moved since the last snapshot. 2. Two callers cover the close paths: - Popup:close (the programmatic close_all path; close_all suppresses WinLeave via eventignore so the autocmd below cannot catch it here). - A new WinLeave autocmd in init.lua (the focus-leaves and external `:close` paths; Popup:close is not invoked when the popup closes via nvim's own WinClosed -> on_popup_closed dispatch). Every close path is covered by at least one snapshot point. The autocmd reads `is_overlook_popup` via nvim_win_get_var rather than vim.w because tests reassign `vim.w = {}` for isolation, which detaches the proxy from real window-locals. opts.col is 1-indexed (the adapters use vim.fn.getpos); nvim_win_get_cursor returns 0-indexed col, so snapshot_state adds 1 for the round-trip with set_cursor_position's `math.max(0, col - 1)` on restore. Adds two regression tests: one for the WinLeave path (user switches focus before close_all), one for the Popup:close path (user is still inside the popup at close_all time). Co-Authored-By: Claude Opus 4.7 (1M context) --- lua/overlook/init.lua | 31 +++++++++++++++ lua/overlook/popup.lua | 21 ++++++++++ tests/spec/window_spec.lua | 81 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+) diff --git a/lua/overlook/init.lua b/lua/overlook/init.lua index c998567..241137e 100644 --- a/lua/overlook/init.lua +++ b/lua/overlook/init.lua @@ -54,6 +54,37 @@ local function setup_autocmd() state.cleanup_touched_buffer(args.buf) end, }) + + -- Snapshot the popup's live buffer+cursor into opts whenever focus leaves + -- it, so a later restore reopens what the user was last viewing (after gd, + -- gf, manual cursor moves, etc.). close_all suppresses WinLeave via + -- eventignore so this autocmd doesn't fire during bulk close -- Popup:close + -- snapshots there directly. This autocmd covers the other paths: user + -- switches focus to host (popup still open, will be restored later) and + -- user runs `:close` from inside a popup (WinLeave fires before WinClosed). + vim.api.nvim_create_autocmd("WinLeave", { + group = augroup, + callback = function() + local winid = vim.api.nvim_get_current_win() + -- Read via the API rather than vim.w because tests reassign + -- `vim.w = {}` which detaches the proxy from real window-locals; the + -- API call still reaches the underlying var. + local ok, is_popup = pcall(vim.api.nvim_win_get_var, winid, "is_overlook_popup") + if not ok or not is_popup then + return + end + local w = require("overlook.window").find_by_popup_winid(winid) + if not w then + return + end + for _, popup in ipairs(w.stack.items) do + if popup.winid == winid then + popup:snapshot_state() + return + end + end + end, + }) end --- Initialize and configure overlook.nvim with user-provided options. diff --git a/lua/overlook/popup.lua b/lua/overlook/popup.lua index 04c308d..287f6a9 100644 --- a/lua/overlook/popup.lua +++ b/lua/overlook/popup.lua @@ -197,8 +197,29 @@ function Popup:open(enter) return true end +---Capture the popup's live buffer + cursor into self.opts so restore reopens +---what the user was last looking at, not the original peek target. Called +---from two places: (1) Popup:close, which catches the close_all path +---(eventignore suppresses WinLeave there); (2) the WinLeave autocmd in +---init.lua, which catches focus-leaves and external `:close` paths (where +---Popup:close is not invoked because the popup closes via nvim's own +---WinClosed -> on_popup_closed route). +function Popup:snapshot_state() + if not self:is_valid() then + return + end + self.opts.target_bufnr = api.nvim_win_get_buf(self.winid) + local cursor = api.nvim_win_get_cursor(self.winid) + self.opts.lnum = cursor[1] + -- opts.col is 1-indexed (the adapters use vim.fn.getpos which returns + -- 1-indexed); nvim_win_get_cursor returns 0-indexed. Convert. + -- set_cursor_position converts back via math.max(0, col - 1) on restore. + self.opts.col = cursor[2] + 1 +end + function Popup:close() if self:is_valid() then + self:snapshot_state() pcall(api.nvim_win_close, self.winid, false) end end diff --git a/tests/spec/window_spec.lua b/tests/spec/window_spec.lua index 7a088ff..a4763c0 100644 --- a/tests/spec/window_spec.lua +++ b/tests/spec/window_spec.lua @@ -654,6 +654,87 @@ describe("Window:restore_all rollback when a trashed popup is unrestorable", fun end) end) +describe("Window: restore preserves live buffer and cursor", function() + local Window = require("overlook.window") + + before_each(function() + Window.instances = {} + vim.w = {} + -- Register the WinLeave snapshot autocmd (idempotent: the augroup uses + -- clear = true). + require("overlook").setup {} + end) + + -- When the user navigates inside a popup (gd, gf, :edit, etc.) the popup's + -- window buffer changes but popup.opts.target_bufnr is captured once at + -- peek time and never refreshed -- restore would reopen the ORIGINAL peek + -- target instead of what the user was last viewing. Same applies to + -- cursor position. Two snapshot paths cover this: + -- (1) WinLeave autocmd in init.lua -- focus leaves the popup + -- (2) Popup:close -- close_all suppresses WinLeave + it("snapshots via WinLeave when user switches focus before close_all", function() + local host = api.nvim_get_current_win() + local buf_a = make_buf() + local buf_b = make_buf() + + local w = Window.current() + local p = w:open_popup { target_bufnr = buf_a, lnum = 1, col = 1 } + assert.is_not_nil(p) + + -- Mimic gd: switch popup's buffer and move the cursor. + api.nvim_win_set_buf(p.winid, buf_b) + api.nvim_win_set_cursor(p.winid, { 3, 0 }) -- 0-indexed col 0 == 1-indexed col 1 + + -- User switches focus -> WinLeave fires on the popup -> snapshot. + api.nvim_set_current_win(host) + + -- Live state was written back to opts. + assert.are.equal(buf_b, p.opts.target_bufnr) + assert.are.equal(3, p.opts.lnum) + assert.are.equal(1, p.opts.col) -- opts.col is 1-indexed + + -- Close + restore should reopen buf_b at the snapshotted cursor. + w:close_all() + w:restore() + local restored = w.stack:top() + assert.are.equal(buf_b, api.nvim_win_get_buf(restored.winid)) + local cursor = api.nvim_win_get_cursor(restored.winid) + assert.are.equal(3, cursor[1]) + assert.are.equal(0, cursor[2]) + + w:close_all() + end) + + it("snapshots via Popup:close when user is inside the popup at close_all time", function() + local buf_a = make_buf() + local buf_b = make_buf() + + local w = Window.current() + local p = w:open_popup { target_bufnr = buf_a, lnum = 1, col = 1 } + assert.is_not_nil(p) + + api.nvim_win_set_buf(p.winid, buf_b) + api.nvim_win_set_cursor(p.winid, { 2, 0 }) + + -- Do NOT leave focus first. close_all wraps the loop in eventignore so + -- WinLeave is suppressed; Popup:close's snapshot must catch the live state. + w:close_all() + + assert.are.equal(buf_b, w.stack.trash[1].opts.target_bufnr) + assert.are.equal(2, w.stack.trash[1].opts.lnum) + assert.are.equal(1, w.stack.trash[1].opts.col) + + w:restore() + local restored = w.stack:top() + assert.are.equal(buf_b, api.nvim_win_get_buf(restored.winid)) + local cursor = api.nvim_win_get_cursor(restored.winid) + assert.are.equal(2, cursor[1]) + assert.are.equal(0, cursor[2]) + + w:close_all() + end) +end) + describe("Popup:open(false) opens without taking focus", function() local Popup = require("overlook.popup")