From f539414a3a4bac0749e574a89b36588cabac49f7 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sun, 8 Mar 2026 22:30:17 +0900 Subject: [PATCH 1/6] fix(ui): apply popup keymaps to source mode and harden nvim_win_set_config Source mode popups now receive the same keymaps as copy mode (close, focus, promote, zoom, stack view, window nav). Keymaps are tracked and removed on popup close to prevent leaking into normal editing. This also makes confirm_on_close reachable for source mode. Wrap nvim_win_set_config in pcall in layout.reflow() and stack_view.resize_all() to match update_focus_zindex() safety, preventing WinClosed/WinResized race crashes. --- lua/peekstack/core/events.lua | 10 + lua/peekstack/core/layout.lua | 2 +- lua/peekstack/core/popup.lua | 3 + lua/peekstack/ui/keymaps.lua | 364 +++++++++++++++++++++------ lua/peekstack/ui/stack_view/init.lua | 2 +- tests/events_spec.lua | 2 +- tests/popup_source_mode_spec.lua | 125 ++++++++- 7 files changed, 420 insertions(+), 88 deletions(-) diff --git a/lua/peekstack/core/events.lua b/lua/peekstack/core/events.lua index b6c3658..ba33b6a 100644 --- a/lua/peekstack/core/events.lua +++ b/lua/peekstack/core/events.lua @@ -72,6 +72,7 @@ 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") vim.api.nvim_create_autocmd("WinClosed", { group = group, @@ -105,6 +106,7 @@ function M.setup() 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) local layout = require("peekstack.core.layout") @@ -116,9 +118,17 @@ function M.setup() end, }) + vim.api.nvim_create_autocmd("WinLeave", { + group = group, + callback = function() + keymaps.deactivate_source_popup(vim.api.nvim_get_current_win()) + 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 local cfg = config.get() diff --git a/lua/peekstack/core/layout.lua b/lua/peekstack/core/layout.lua index 4e943bf..440f8e5 100644 --- a/lua/peekstack/core/layout.lua +++ b/lua/peekstack/core/layout.lua @@ -145,7 +145,7 @@ function M.reflow(stack) height = lo.height, zindex = z, }) - vim.api.nvim_win_set_config(popup.winid, win_opts) + pcall(vim.api.nvim_win_set_config, popup.winid, win_opts) if is_zoomed then set_popup_zoom_winhighlight(popup.winid, true) else diff --git a/lua/peekstack/core/popup.lua b/lua/peekstack/core/popup.lua index 6dfd499..0fbe937 100644 --- a/lua/peekstack/core/popup.lua +++ b/lua/peekstack/core/popup.lua @@ -252,6 +252,9 @@ 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) diagnostics_ui.clear(popup.diagnostics) if popup.winid and vim.api.nvim_win_is_valid(popup.winid) then vim.api.nvim_win_close(popup.winid, true) diff --git a/lua/peekstack/ui/keymaps.lua b/lua/peekstack/ui/keymaps.lua index 888055f..6c461e2 100644 --- a/lua/peekstack/ui/keymaps.lua +++ b/lua/peekstack/ui/keymaps.lua @@ -4,9 +4,21 @@ local stack_view = require("peekstack.ui.stack_view") local M = {} +---@class PeekstackSourcePopupMapState +---@field winid integer +---@field bufnr integer +---@field lhs string[] +---@field original table + +--- Buffer-local keymaps temporarily installed for the currently focused +--- source-mode popup window. They are restored on WinLeave/close so the +--- shared source buffer keeps its original mappings in normal editing. +---@type PeekstackSourcePopupMapState? +local active_source_maps = nil + ---@param bufnr integer ---@param lhs string? ----@param rhs function +---@param rhs string|function ---@param desc string local function map(bufnr, lhs, rhs, desc) if not lhs or lhs == "" then @@ -28,101 +40,291 @@ local function nav_to_split(direction) vim.api.nvim_cmd({ cmd = "wincmd", args = { direction } }, {}) end ---- Resolve the current popup by looking up its id in the stack. ---- This avoids holding a stale reference to a popup object. ----@param popup_id integer ----@return table? -local function resolve_popup(popup_id) +--- Resolve the popup in the current window. +---@return PeekstackPopupModel? +local function resolve_current_popup() + local winid = vim.api.nvim_get_current_win() + if vim.w[winid].peekstack_popup_id == nil then + return nil + end local stack = require("peekstack.core.stack") - return stack.find_by_id(popup_id) + local _, popup = stack.find_by_winid(winid) + return popup end ----@param popup table -function M.apply_popup(popup) - if popup.buffer_mode == "source" then - -- Source mode uses the real editing buffer, so buffer-local mappings - -- would leak into normal editing after popup close. - return +---@return { lhs: string, rhs: function, desc: string }[] +local function mapping_specs() + local keys = config.get().ui.keys + ---@type { lhs: string, rhs: function, desc: string }[] + local raw = { + { + lhs = keys.close, + rhs = function() + local stack = require("peekstack.core.stack") + local popup = resolve_current_popup() + if not popup then + return + end + if + popup.buffer_mode == "source" + and vim.api.nvim_buf_is_valid(popup.bufnr) + and vim.bo[popup.bufnr].modified + and config.get().ui.popup.source.confirm_on_close + then + vim.ui.input({ prompt = "Buffer has unsaved changes. Close? (y/n) " }, function(input) + if input and (input == "y" or input == "Y") then + stack.close(popup.id) + end + end) + return + end + stack.close(popup.id) + end, + desc = "Peekstack close", + }, + { + lhs = keys.focus_next, + rhs = function() + local stack = require("peekstack.core.stack") + stack.focus_next() + end, + desc = "Peekstack focus next", + }, + { + lhs = keys.focus_prev, + rhs = function() + local stack = require("peekstack.core.stack") + stack.focus_prev() + end, + desc = "Peekstack focus prev", + }, + { + lhs = keys.promote_split, + rhs = function() + local popup = resolve_current_popup() + if popup then + promote.split(popup) + end + end, + desc = "Peekstack promote split", + }, + { + lhs = keys.promote_vsplit, + rhs = function() + local popup = resolve_current_popup() + if popup then + promote.vsplit(popup) + end + end, + desc = "Peekstack promote vsplit", + }, + { + lhs = keys.promote_tab, + rhs = function() + local popup = resolve_current_popup() + if popup then + promote.tab(popup) + end + end, + desc = "Peekstack promote tab", + }, + { + lhs = keys.toggle_stack_view, + rhs = function() + stack_view.toggle() + end, + desc = "Peekstack stack view", + }, + { + lhs = keys.zoom, + rhs = function() + local stack = require("peekstack.core.stack") + stack.toggle_zoom() + end, + desc = "Peekstack zoom", + }, + { + lhs = "h", + rhs = function() + nav_to_split("h") + end, + desc = "Peekstack navigate left", + }, + { + lhs = "j", + rhs = function() + nav_to_split("j") + end, + desc = "Peekstack navigate down", + }, + { + lhs = "k", + rhs = function() + nav_to_split("k") + end, + desc = "Peekstack navigate up", + }, + { + lhs = "l", + rhs = function() + nav_to_split("l") + end, + desc = "Peekstack navigate right", + }, + } + + ---@type table + local by_lhs = {} + ---@type string[] + local order = {} + for _, spec in ipairs(raw) do + if spec.lhs and spec.lhs ~= "" then + if not by_lhs[spec.lhs] then + order[#order + 1] = spec.lhs + end + by_lhs[spec.lhs] = spec + end end - local keys = config.get().ui.keys - local popup_id = popup.id + ---@type { lhs: string, rhs: function, desc: string }[] + local specs = {} + for _, lhs in ipairs(order) do + specs[#specs + 1] = by_lhs[lhs] + end + return specs +end - map(popup.bufnr, keys.close, function() - local stack = require("peekstack.core.stack") - local p = resolve_popup(popup_id) - if - p - and p.buffer_mode == "source" - and vim.api.nvim_buf_is_valid(p.bufnr) - and vim.bo[p.bufnr].modified - and config.get().ui.popup.source.confirm_on_close - then - vim.ui.input({ prompt = "Buffer has unsaved changes. Close? (y/n) " }, function(input) - if input and (input == "y" or input == "Y") then - stack.close(popup_id) - end - end) - return +---@param bufnr integer +---@param lhs string +---@return vim.api.keyset.get_keymap? +local function get_buffer_map(bufnr, lhs) + for _, item in ipairs(vim.api.nvim_buf_get_keymap(bufnr, "n")) do + if item.lhs == lhs then + return item end - stack.close(popup_id) - end, "Peekstack close") + end + return nil +end - map(popup.bufnr, keys.focus_next, function() - local stack = require("peekstack.core.stack") - stack.focus_next() - end, "Peekstack focus next") +---@param bufnr integer +---@param item vim.api.keyset.get_keymap? +local function restore_buffer_map(bufnr, item) + if not item or not item.lhs or item.lhs == "" then + return + end - map(popup.bufnr, keys.focus_prev, function() - local stack = require("peekstack.core.stack") - stack.focus_prev() - end, "Peekstack focus prev") + local opts = { + buffer = bufnr, + desc = item.desc ~= "" and item.desc or nil, + expr = item.expr == 1, + nowait = item.nowait == 1, + remap = item.noremap == 0, + script = item.script == 1, + silent = item.silent == 1, + } - map(popup.bufnr, keys.promote_split, function() - local p = resolve_popup(popup_id) - if p then - promote.split(p) - end - end, "Peekstack promote split") + if item.callback ~= nil then + vim.keymap.set("n", item.lhs, item.callback, opts) + return + end - map(popup.bufnr, keys.promote_vsplit, function() - local p = resolve_popup(popup_id) - if p then - promote.vsplit(p) - end - end, "Peekstack promote vsplit") + if type(item.rhs) == "string" then + vim.keymap.set("n", item.lhs, item.rhs, opts) + end +end - map(popup.bufnr, keys.promote_tab, function() - local p = resolve_popup(popup_id) - if p then - promote.tab(p) - end - end, "Peekstack promote tab") +local function deactivate_active_source_popup() + local active = active_source_maps + if not active then + return + end + active_source_maps = nil - map(popup.bufnr, keys.toggle_stack_view, function() - stack_view.toggle() - end, "Peekstack stack view") + if not vim.api.nvim_buf_is_valid(active.bufnr) then + return + end + + for _, lhs in ipairs(active.lhs) do + pcall(vim.keymap.del, "n", lhs, { buffer = active.bufnr }) + restore_buffer_map(active.bufnr, active.original[lhs]) + end +end + +---@param popup PeekstackPopupModel +local function activate_source_popup(popup) + if popup.buffer_mode ~= "source" then + return + end + if active_source_maps and active_source_maps.winid == popup.winid then + return + end + + deactivate_active_source_popup() - map(popup.bufnr, keys.zoom, function() + local specs = mapping_specs() + ---@type table + local original = {} + ---@type string[] + local lhs_list = {} + + for _, spec in ipairs(specs) do + original[spec.lhs] = get_buffer_map(popup.bufnr, spec.lhs) + map(popup.bufnr, spec.lhs, spec.rhs, spec.desc) + lhs_list[#lhs_list + 1] = spec.lhs + end + + active_source_maps = { + winid = popup.winid, + bufnr = popup.bufnr, + lhs = lhs_list, + original = original, + } +end + +---@param popup table +function M.apply_popup(popup) + if popup.buffer_mode == "source" then + activate_source_popup(popup) + return + end + + for _, spec in ipairs(mapping_specs()) do + map(popup.bufnr, spec.lhs, spec.rhs, spec.desc) + end +end + +---@param target integer|PeekstackPopupModel +function M.activate_source_popup(target) + local popup = target + if type(target) ~= "table" then local stack = require("peekstack.core.stack") - stack.toggle_zoom() - end, "Peekstack zoom") - - -- Window navigation: h/j/k/l to move from popup to adjacent split. - -- These are hardcoded (not in ui.keys) because they restore standard Vim - -- window-navigation behaviour that floating windows would otherwise break. - map(popup.bufnr, "h", function() - nav_to_split("h") - end, "Peekstack navigate left") - map(popup.bufnr, "j", function() - nav_to_split("j") - end, "Peekstack navigate down") - map(popup.bufnr, "k", function() - nav_to_split("k") - end, "Peekstack navigate up") - map(popup.bufnr, "l", function() - nav_to_split("l") - end, "Peekstack navigate right") + local _, found = stack.find_by_winid(target) + popup = found + end + if not popup then + return + end + activate_source_popup(popup) +end + +---@param target integer|PeekstackPopupModel +function M.deactivate_source_popup(target) + if not active_source_maps then + return + end + + local winid = type(target) == "table" and target.winid or target + if winid ~= active_source_maps.winid then + return + end + + deactivate_active_source_popup() +end + +--- Remove active source-mode popup keymaps before the popup window closes. +---@param popup PeekstackPopupModel +function M.remove_popup(popup) + M.deactivate_source_popup(popup) end return M diff --git a/lua/peekstack/ui/stack_view/init.lua b/lua/peekstack/ui/stack_view/init.lua index b7baf10..73f7003 100644 --- a/lua/peekstack/ui/stack_view/init.lua +++ b/lua/peekstack/ui/stack_view/init.lua @@ -301,7 +301,7 @@ end function M.resize_all() for _, s in pairs(states) do if is_open(s) and s.winid and vim.api.nvim_win_is_valid(s.winid) then - vim.api.nvim_win_set_config(s.winid, stack_view_win_config()) + pcall(vim.api.nvim_win_set_config, s.winid, stack_view_win_config()) render_state(s) end end diff --git a/tests/events_spec.lua b/tests/events_spec.lua index 412c9fb..8dcb3af 100644 --- a/tests/events_spec.lua +++ b/tests/events_spec.lua @@ -102,6 +102,6 @@ describe("peekstack.core.events", function() }) assert.equals(1, #buf_leave) - assert.equals(1, #win_leave) + assert.equals(2, #win_leave) end) end) diff --git a/tests/popup_source_mode_spec.lua b/tests/popup_source_mode_spec.lua index b84736a..1ef4627 100644 --- a/tests/popup_source_mode_spec.lua +++ b/tests/popup_source_mode_spec.lua @@ -15,6 +15,18 @@ describe("popup source mode", function() return false end + ---@param bufnr integer + ---@param lhs string + ---@return vim.api.keyset.get_keymap? + local function get_buffer_map(bufnr, lhs) + for _, item in ipairs(vim.api.nvim_buf_get_keymap(bufnr, "n")) do + if item.lhs == lhs then + return item + end + end + return nil + end + before_each(function() popup._reset() stack._reset() @@ -108,7 +120,7 @@ describe("popup source mode", function() popup.close(model) end) - it("does not install popup keymaps on source buffers", function() + it("installs popup keymaps on source buffers and removes them on close", function() local temp = vim.fn.tempname() .. ".lua" vim.fn.writefile({ "print('peekstack')" }, temp) vim.api.nvim_cmd({ cmd = "edit", args = { temp } }, {}) @@ -126,14 +138,83 @@ describe("popup source mode", function() }) assert.is_not_nil(model) - assert.is_false(has_buffer_map(source_bufnr, close_key)) + -- Keymaps are installed while popup is open + assert.is_true(has_buffer_map(source_bufnr, close_key)) popup.close(model) + -- Keymaps are removed after popup close assert.is_false(has_buffer_map(source_bufnr, close_key)) vim.fn.delete(temp) end) + it("restores existing source buffer keymaps after popup close", function() + local temp = vim.fn.tempname() .. ".lua" + vim.fn.writefile({ "print('peekstack')" }, temp) + vim.api.nvim_cmd({ cmd = "edit", args = { temp } }, {}) + local source_bufnr = vim.api.nvim_get_current_buf() + local close_key = config.get().ui.keys.close + + vim.keymap.set("n", close_key, function() end, { + buffer = source_bufnr, + desc = "Original buffer close", + }) + + local model = popup.create({ + uri = vim.uri_from_fname(temp), + range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } }, + provider = "test", + }, { + buffer_mode = "source", + }) + + assert.is_not_nil(model) + assert.equals("Peekstack close", get_buffer_map(source_bufnr, close_key).desc) + + popup.close(model) + + local restored = get_buffer_map(source_bufnr, close_key) + assert.is_not_nil(restored) + assert.equals("Original buffer close", restored.desc) + + vim.fn.delete(temp) + end) + + it("restores source buffer keymaps when leaving a source popup", function() + require("peekstack.core.events").setup() + local temp = vim.fn.tempname() .. ".lua" + vim.fn.writefile({ "print('peekstack')" }, temp) + vim.api.nvim_cmd({ cmd = "edit", args = { temp } }, {}) + local root_win = vim.api.nvim_get_current_win() + local source_bufnr = vim.api.nvim_get_current_buf() + local close_key = config.get().ui.keys.close + + vim.keymap.set("n", close_key, function() end, { + buffer = source_bufnr, + desc = "Original buffer close", + }) + + local model = popup.create({ + uri = vim.uri_from_fname(temp), + range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } }, + provider = "test", + }, { + buffer_mode = "source", + }) + + assert.is_not_nil(model) + assert.equals("Peekstack close", get_buffer_map(source_bufnr, close_key).desc) + + vim.api.nvim_set_current_win(root_win) + + local restored = get_buffer_map(source_bufnr, close_key) + assert.is_not_nil(restored) + assert.equals("Original buffer close", restored.desc) + + popup.close(model) + vim.fn.delete(temp) + end) + it("installs hjkl navigation keymaps on copy-mode popups", function() local loc = make_location() local model = popup.create(loc, { buffer_mode = "copy" }) @@ -172,7 +253,7 @@ describe("popup source mode", function() vim.api.nvim_cmd({ cmd = "close" }, {}) end) - it("does not install hjkl navigation keymaps on source-mode popups", function() + it("installs hjkl keymaps on source-mode popups and removes them on close", function() local temp = vim.fn.tempname() .. ".lua" vim.fn.writefile({ "print('peekstack')" }, temp) vim.api.nvim_cmd({ cmd = "edit", args = { temp } }, {}) @@ -187,12 +268,48 @@ describe("popup source mode", function() }) assert.is_not_nil(model) + -- Keymaps are installed while popup is open + assert.is_true(has_buffer_map(source_bufnr, "h")) + assert.is_true(has_buffer_map(source_bufnr, "j")) + assert.is_true(has_buffer_map(source_bufnr, "k")) + assert.is_true(has_buffer_map(source_bufnr, "l")) + + popup.close(model) + -- Keymaps are removed after popup close assert.is_false(has_buffer_map(source_bufnr, "h")) assert.is_false(has_buffer_map(source_bufnr, "j")) assert.is_false(has_buffer_map(source_bufnr, "k")) assert.is_false(has_buffer_map(source_bufnr, "l")) - popup.close(model) + vim.fn.delete(temp) + end) + + it("routes source-mode keymaps to the focused popup when multiple popups share a buffer", function() + local temp = vim.fn.tempname() .. ".lua" + vim.fn.writefile({ "print('peekstack')" }, temp) + vim.api.nvim_cmd({ cmd = "edit", args = { temp } }, {}) + local loc = { + uri = vim.uri_from_fname(temp), + range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } }, + provider = "test", + } + + local first = stack.push(loc, { buffer_mode = "source" }) + local second = stack.push(loc, { buffer_mode = "source" }) + assert.is_not_nil(first) + assert.is_not_nil(second) + + vim.api.nvim_set_current_win(first.winid) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(config.get().ui.keys.close, true, false, true), "x", false) + + assert.is_false(vim.api.nvim_win_is_valid(first.winid)) + assert.is_true(vim.api.nvim_win_is_valid(second.winid)) + + vim.api.nvim_set_current_win(second.winid) + vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(config.get().ui.keys.close, true, false, true), "x", false) + + assert.is_false(vim.api.nvim_win_is_valid(second.winid)) + vim.fn.delete(temp) end) From d4c57c6a8f7f1e70ec4b8afb457dace017544d3a Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sun, 8 Mar 2026 22:30:26 +0900 Subject: [PATCH 2/6] fix(providers): add -- separator and --max-count to rg command Prevents dash-prefixed queries from being interpreted as rg options and caps results at 1000 to avoid unbounded output on large matches. --- lua/peekstack/providers/grep.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/peekstack/providers/grep.lua b/lua/peekstack/providers/grep.lua index 89fc66f..58bd503 100644 --- a/lua/peekstack/providers/grep.lua +++ b/lua/peekstack/providers/grep.lua @@ -53,7 +53,7 @@ function M.search(_, cb) return end - vim.system({ "rg", "--vimgrep", query }, { text = true }, function(result) + vim.system({ "rg", "--vimgrep", "--max-count=1000", "--", query }, { text = true }, function(result) vim.schedule(function() if result.code ~= 0 and result.code ~= 1 then notify.warn("rg failed: " .. (result.stderr or "unknown error")) From 444445ec0520f4a58e780d0ea2f77126c130f4f2 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sun, 8 Mar 2026 22:30:34 +0900 Subject: [PATCH 3/6] fix(config): remove unused providers.marks.scope option The marks provider exposes separate buffer/global/all entry points, making the scope config field redundant. Remove it from defaults, validation, types, docs, and tests to avoid misleading users. --- README.md | 1 - doc/peekstack.txt | 1 - lua/peekstack/config.lua | 5 ----- lua/peekstack/types.lua | 1 - tests/config_spec.lua | 1 - tests/marks_provider_spec.lua | 1 - 6 files changed, 10 deletions(-) diff --git a/README.md b/README.md index 31f1a8b..b8c37f1 100644 --- a/README.md +++ b/README.md @@ -247,7 +247,6 @@ Configure via `require("peekstack").setup({ ... })`. file = { enable = true }, marks = { enable = false, - scope = "all", -- "buffer" | "global" | "all" include = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", include_special = false, }, diff --git a/doc/peekstack.txt b/doc/peekstack.txt index d55ac17..eb7df13 100644 --- a/doc/peekstack.txt +++ b/doc/peekstack.txt @@ -113,7 +113,6 @@ With options: file = { enable = true }, marks = { enable = false, - scope = "all", -- "buffer" | "global" | "all" include = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", include_special = false, }, diff --git a/lua/peekstack/config.lua b/lua/peekstack/config.lua index 0f1c716..07889d6 100644 --- a/lua/peekstack/config.lua +++ b/lua/peekstack/config.lua @@ -16,9 +16,6 @@ local KNOWN_BUFFER_MODES = { "copy", "source" } ---@type string[] local KNOWN_RESTORE_POSITIONS = { "top", "original" } ----@type string[] -local KNOWN_MARK_SCOPES = { "buffer", "global", "all" } - ---@type string[] local KNOWN_PATH_BASES = { "repo", "cwd", "absolute" } @@ -171,7 +168,6 @@ M.defaults = { file = { enable = true }, marks = { enable = false, - scope = "all", include = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", include_special = false, }, @@ -443,7 +439,6 @@ local LAYOUT_OFFSET_RULES = { ---@type PeekstackConfigFieldRule[] local MARKS_RULES = { - { key = "scope", validate = field_enum(KNOWN_MARK_SCOPES), require_truthy = true }, { key = "include", validate = field_type("string") }, { key = "include_special", validate = field_type("boolean") }, } diff --git a/lua/peekstack/types.lua b/lua/peekstack/types.lua index a352c7f..09ea1ba 100644 --- a/lua/peekstack/types.lua +++ b/lua/peekstack/types.lua @@ -277,7 +277,6 @@ ---@class PeekstackConfigProviderMarks ---@field enable boolean ----@field scope "buffer"|"global"|"all" ---@field include string ---@field include_special boolean diff --git a/tests/config_spec.lua b/tests/config_spec.lua index 07ad6a6..9f6ebc4 100644 --- a/tests/config_spec.lua +++ b/tests/config_spec.lua @@ -81,7 +81,6 @@ describe("config", function() assert.is_true(providers.diagnostics.enable) assert.is_true(providers.file.enable) assert.is_false(providers.marks.enable) - assert.equals("all", providers.marks.scope) assert.equals("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", providers.marks.include) assert.is_false(providers.marks.include_special) end) diff --git a/tests/marks_provider_spec.lua b/tests/marks_provider_spec.lua index f72a30e..1b74f86 100644 --- a/tests/marks_provider_spec.lua +++ b/tests/marks_provider_spec.lua @@ -21,7 +21,6 @@ describe("marks provider", function() providers = { marks = { enable = true, - scope = "all", include = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ", include_special = false, }, From 16d7effc87a065c98ce47671e70662ef94aa66a7 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sun, 8 Mar 2026 22:33:50 +0900 Subject: [PATCH 4/6] fix(providers): reject missing files and directories in file provider Add fs_stat check before creating a location in file.under_cursor so non-existent paths and directories are filtered out instead of producing invalid popups. --- lua/peekstack/providers/file.lua | 6 ++ tests/file_provider_spec.lua | 95 ++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) create mode 100644 tests/file_provider_spec.lua diff --git a/lua/peekstack/providers/file.lua b/lua/peekstack/providers/file.lua index 22ccd9e..a78c6a4 100644 --- a/lua/peekstack/providers/file.lua +++ b/lua/peekstack/providers/file.lua @@ -16,6 +16,12 @@ function M.under_cursor(ctx, cb) local source_name = vim.api.nvim_buf_get_name(ctx.bufnr) local base = vim.fn.fnamemodify(source_name, ":p:h") target = vim.fn.fnamemodify(base .. "/" .. target, ":p") + + local stat = vim.uv.fs_stat(target) + if not stat or stat.type ~= "file" then + cb({}) + return + end end local uri = fs.fname_to_uri(target) local loc = location.normalize( diff --git a/tests/file_provider_spec.lua b/tests/file_provider_spec.lua new file mode 100644 index 0000000..a08ed57 --- /dev/null +++ b/tests/file_provider_spec.lua @@ -0,0 +1,95 @@ +describe("peekstack.providers.file", function() + local config = require("peekstack.config") + local file_provider = require("peekstack.providers.file") + + local function make_ctx(overrides) + return vim.tbl_extend("force", { + winid = vim.api.nvim_get_current_win(), + bufnr = vim.api.nvim_get_current_buf(), + source_bufnr = nil, + popup_id = nil, + buffer_mode = nil, + line_offset = 0, + position = { line = 0, character = 0 }, + root_winid = vim.api.nvim_get_current_win(), + from_popup = false, + }, overrides or {}) + end + + before_each(function() + config.setup({}) + end) + + it("returns empty when target file does not exist", function() + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + local source = tmpdir .. "/source.lua" + vim.fn.writefile({ "require('nonexistent_file')" }, source) + + local bufnr = vim.fn.bufadd(source) + vim.fn.bufload(bufnr) + vim.api.nvim_set_current_buf(bufnr) + + -- Position cursor on a word that won't resolve to a real file + vim.api.nvim_win_set_cursor(0, { 1, 10 }) + + local result = nil + file_provider.under_cursor(make_ctx({ bufnr = bufnr }), function(locations) + result = locations + end) + assert.is_table(result) + assert.equals(0, #result) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + vim.fn.delete(tmpdir, "rf") + end) + + it("returns empty when target is a directory", function() + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + local subdir = tmpdir .. "/subdir" + vim.fn.mkdir(subdir, "p") + local source = tmpdir .. "/source.lua" + vim.fn.writefile({ "subdir" }, source) + + local bufnr = vim.fn.bufadd(source) + vim.fn.bufload(bufnr) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + local result = nil + file_provider.under_cursor(make_ctx({ bufnr = bufnr }), function(locations) + result = locations + end) + assert.is_table(result) + assert.equals(0, #result) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + vim.fn.delete(tmpdir, "rf") + end) + + it("returns location when target is a valid file", function() + local tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, "p") + local target = tmpdir .. "/target.lua" + vim.fn.writefile({ "-- target" }, target) + local source = tmpdir .. "/source.lua" + vim.fn.writefile({ "target.lua" }, source) + + local bufnr = vim.fn.bufadd(source) + vim.fn.bufload(bufnr) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 0 }) + + local result = nil + file_provider.under_cursor(make_ctx({ bufnr = bufnr }), function(locations) + result = locations + end) + assert.is_table(result) + assert.equals(1, #result) + assert.equals("file.under_cursor", result[1].provider) + + vim.api.nvim_buf_delete(bufnr, { force = true }) + vim.fn.delete(tmpdir, "rf") + end) +end) From 92b2c4b36af9939dbe41c36e936f5bc491ecd094 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sun, 8 Mar 2026 22:33:59 +0900 Subject: [PATCH 5/6] fix(core): stop cleanup timer before re-setup to prevent timer leak Call cleanup.stop() unconditionally before checking auto_close config in events.setup(), so re-setup with auto_close disabled properly clears the previous timer. --- lua/peekstack/core/events.lua | 3 ++- tests/events_spec.lua | 29 +++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/lua/peekstack/core/events.lua b/lua/peekstack/core/events.lua index ba33b6a..49fa9ed 100644 --- a/lua/peekstack/core/events.lua +++ b/lua/peekstack/core/events.lua @@ -149,8 +149,9 @@ function M.setup() end, }) + local cleanup = require("peekstack.core.cleanup") + cleanup.stop() if cfg.ui.popup.auto_close and cfg.ui.popup.auto_close.enabled then - local cleanup = require("peekstack.core.cleanup") cleanup.start() end end diff --git a/tests/events_spec.lua b/tests/events_spec.lua index 8dcb3af..94e491b 100644 --- a/tests/events_spec.lua +++ b/tests/events_spec.lua @@ -80,6 +80,35 @@ describe("peekstack.core.events", function() 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() + + config.setup({ + ui = { + quick_peek = { close_events = {} }, + popup = { + auto_close = { + enabled = true, + idle_ms = 300000, + check_interval_ms = 60000, + ignore_pinned = true, + }, + }, + }, + }) + events.setup() + assert.is_not_nil(timer_store.cleanup) + + config.setup({ + ui = { + quick_peek = { close_events = {} }, + popup = { auto_close = { enabled = false } }, + }, + }) + events.setup() + assert.is_nil(timer_store.cleanup) + end) + it("falls back to default close_events when quick_peek.close_events is invalid", function() config.setup({ ui = { From 43373fcaf449f140168f98d9e2f2b591a4c084e5 Mon Sep 17 00:00:00 2001 From: Masaaki Hirotsu Date: Sun, 8 Mar 2026 22:34:07 +0900 Subject: [PATCH 6/6] fix(persist): warn on session data decode failure instead of silent fallback Add path-prefixed warning when JSON decode fails in store.read and store.read_sync, so users can identify corrupted session files instead of seeing sessions silently disappear. --- lua/peekstack/persist/store.lua | 2 ++ tests/persist_store_spec.lua | 28 ++++++++++++++++++++++++++-- tests/setup_idempotent_spec.lua | 1 - 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/lua/peekstack/persist/store.lua b/lua/peekstack/persist/store.lua index cc92eed..9baafd8 100644 --- a/lua/peekstack/persist/store.lua +++ b/lua/peekstack/persist/store.lua @@ -72,6 +72,7 @@ function M.read(scope, opts) vim.schedule(function() local ok, decoded = pcall(vim.json.decode, data) if not ok or type(decoded) ~= "table" then + notify.warn("Failed to decode session data: " .. path) on_done(empty_data()) return end @@ -104,6 +105,7 @@ function M.read_sync(scope) local ok, decoded = pcall(vim.json.decode, data) if not ok or type(decoded) ~= "table" then + notify.warn("Failed to decode session data: " .. path) return empty_data() end return decoded diff --git a/tests/persist_store_spec.lua b/tests/persist_store_spec.lua index c71b39d..5da7042 100644 --- a/tests/persist_store_spec.lua +++ b/tests/persist_store_spec.lua @@ -120,12 +120,24 @@ describe("peekstack.persist.store", function() assert.same(data, result) end) - it("returns empty data for invalid JSON", function() + it("returns empty data and warns for invalid JSON", function() local path = fs.scope_path(test_scope) write_raw_and_wait(path, "{ invalid json") + local warnings = {} + local original_notify = vim.notify + vim.notify = function(msg, level) + if level == vim.log.levels.WARN then + table.insert(warnings, msg) + end + end + local result = read_and_wait(test_scope) + + vim.notify = original_notify assert.same({ version = 2, sessions = {} }, result) + assert.is_true(#warnings > 0, "should warn about decode failure") + assert.is_true(warnings[1]:find("Failed to decode", 1, true) ~= nil) end) it("read_sync returns data for valid store content", function() @@ -144,12 +156,24 @@ describe("peekstack.persist.store", function() assert.same(data, result) end) - it("read_sync returns empty data for invalid JSON", function() + it("read_sync returns empty data and warns for invalid JSON", function() local path = fs.scope_path(test_scope) write_raw_and_wait(path, "{ invalid json") + local warnings = {} + local original_notify = vim.notify + vim.notify = function(msg, level) + if level == vim.log.levels.WARN then + table.insert(warnings, msg) + end + end + local result = store.read_sync(test_scope) + + vim.notify = original_notify assert.same({ version = 2, sessions = {} }, result) + assert.is_true(#warnings > 0, "should warn about decode failure") + assert.is_true(warnings[1]:find("Failed to decode", 1, true) ~= nil) end) it("write_sync stores data that can be read back", function() diff --git a/tests/setup_idempotent_spec.lua b/tests/setup_idempotent_spec.lua index ffd4361..a24ff6f 100644 --- a/tests/setup_idempotent_spec.lua +++ b/tests/setup_idempotent_spec.lua @@ -35,7 +35,6 @@ describe("peekstack.setup idempotent", function() providers = { marks = { enable = true, - scope = "buffer", }, }, })