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/core/events.lua b/lua/peekstack/core/events.lua index b6c3658..49fa9ed 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() @@ -139,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/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/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/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/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")) 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/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/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/events_spec.lua b/tests/events_spec.lua index 412c9fb..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 = { @@ -102,6 +131,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/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) 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, }, 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/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) 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", }, }, })