Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 42 additions & 40 deletions lua/peekstack/ui/keymaps.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ local M = {}
---@class PeekstackSourcePopupMapState
---@field winid integer
---@field bufnr integer
---@field lhs string[]
---@field original table<string, vim.api.keyset.get_keymap?>
---@field installed string[] lhs of the installed mappings, as resolved when they were set
---@field original table[] maparg() dicts of the buffer-local mappings that were shadowed

--- Buffer-local keymaps temporarily installed for the currently focused
--- source-mode popup window. They are restored on WinLeave/close so the
Expand Down Expand Up @@ -166,43 +166,32 @@ local function mapping_specs()
return keymap_spec.normalize(raw)
end

--- Snapshot the buffer-local normal-mode mapping that `lhs` resolves to.
--- `maparg()` applies the same key resolution as Neovim (`<C-j>` vs `<C-J>`,
--- `<leader>`, `<C-w>`), and the returned dict keeps callback, expr, `<SID>`
--- and script context so `mapset()` can recreate it exactly.
---@param bufnr integer
---@param lhs string
---@return vim.api.keyset.get_keymap?
---@return table?
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
local item = vim.api.nvim_buf_call(bufnr, function()
return vim.fn.maparg(lhs, "n", false, true)
end)
if item.buffer ~= 1 then
return nil
end
return nil
return item
end

--- Recreate a snapshot in normal mode only. Installing a normal-mode map
--- leaves the other modes of a `:map` mapping untouched, so they must not be
--- reset to the snapshot.
---@param bufnr integer
---@param item vim.api.keyset.get_keymap?
---@param item table
local function restore_buffer_map(bufnr, item)
if not item or not item.lhs or item.lhs == "" then
return
end

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,
}

if item.callback ~= nil then
vim.keymap.set("n", item.lhs, item.callback, opts)
return
end

if type(item.rhs) == "string" then
vim.keymap.set("n", item.lhs, item.rhs, opts)
end
vim.api.nvim_buf_call(bufnr, function()
vim.fn.mapset("n", false, item)
end)
end

local function deactivate_active_source_popup()
Expand All @@ -216,9 +205,13 @@ local function deactivate_active_source_popup()
return
end

for _, lhs in ipairs(active.lhs) do
-- Delete every installed mapping before restoring, so a restored mapping is
-- never removed again by another lhs spelling that resolves to the same keys.
for _, lhs in ipairs(active.installed) do
pcall(vim.keymap.del, "n", lhs, { buffer = active.bufnr })
restore_buffer_map(active.bufnr, active.original[lhs])
end
for _, item in ipairs(active.original) do
restore_buffer_map(active.bufnr, item)
end
end

Expand All @@ -234,21 +227,30 @@ local function activate_source_popup(popup)
deactivate_active_source_popup()

local specs = mapping_specs()
---@type table<string, vim.api.keyset.get_keymap?>
---@type table[]
local original = {}
---@type string[]
local lhs_list = {}
-- Snapshot every original mapping before installing any, so an lhs that
-- resolves to the same keys as an earlier spec does not capture our own map.
for _, spec in ipairs(specs) do
original[#original + 1] = get_buffer_map(popup.bufnr, spec.lhs)
end
keymap_spec.apply(popup.bufnr, specs)

-- Record the resolved keys so cleanup does not depend on `mapleader` at
-- that later time.
---@type string[]
local installed = {}
for _, spec in ipairs(specs) do
original[spec.lhs] = get_buffer_map(popup.bufnr, spec.lhs)
keymap_spec.set(popup.bufnr, spec)
lhs_list[#lhs_list + 1] = spec.lhs
local item = get_buffer_map(popup.bufnr, spec.lhs)
if item then
installed[#installed + 1] = item.lhs
end
end

active_source_maps = {
winid = popup.winid,
bufnr = popup.bufnr,
lhs = lhs_list,
installed = installed,
original = original,
}
end
Expand Down
168 changes: 168 additions & 0 deletions tests/popup_source_mode_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,174 @@ describe("popup source mode", function()
vim.fn.delete(temp)
end)

describe("existing buffer-local mappings with other spellings", function()
local saved_leader
local temp
local script
local source_bufnr
local root_win

--- Resolve a mapping the same way Neovim does. Lua callbacks are re-wrapped
--- by maparg(), so only their presence is compared; behaviour is checked
--- by assert_originals_work(). A `:map` entry may come back split per mode,
--- so the combined mode spelling is ignored.
---@param lhs string
---@param mode string
---@return table
local function resolve_mode(lhs, mode)
local item = vim.api.nvim_buf_call(source_bufnr, function()
return vim.fn.maparg(lhs, mode, false, true)
end)
item.lnum = nil
item.callback = item.callback and true or nil
item.mode = nil
item.mode_bits = nil
return item
end

---@param lhs string
---@return table
local function resolve(lhs)
return resolve_mode(lhs, "n")
end

before_each(function()
saved_leader = vim.g.mapleader
vim.g.mapleader = ","
temp = vim.fn.tempname() .. ".lua"
vim.fn.writefile({ "print('peekstack')" }, temp)
vim.api.nvim_cmd({ cmd = "edit", args = { temp } }, {})
root_win = vim.api.nvim_get_current_win()
source_bufnr = vim.api.nvim_get_current_buf()

-- Mappings written with different spellings than peekstack's specs,
-- including a script-local <SID> expr mapping and a Lua callback.
script = vim.fn.tempname() .. ".vim"
vim.fn.writefile({
"function! s:Original() abort",
" let g:peekstack_original_sid = 1",
" return ''",
"endfunction",
"nnoremap <buffer> <expr> <C-J> <SID>Original()",
"noremap <buffer> <silent> <Leader>os :let g:peekstack_original_leader = 1<CR>",
}, script)
vim.api.nvim_cmd({ cmd = "source", args = { script } }, {})
vim.keymap.set("n", "<c-w>h", function()
vim.g.peekstack_original_callback = 1
end, { buffer = source_bufnr, desc = "Original window left" })
end)

after_each(function()
vim.g.mapleader = saved_leader
vim.g.peekstack_original_sid = nil
vim.g.peekstack_original_leader = nil
vim.g.peekstack_original_callback = nil
vim.fn.delete(temp)
vim.fn.delete(script)
end)

local function open_source_popup()
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)
return model
end

---@param keys string
local function feed(keys)
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), "x", false)
end

local function assert_originals_work()
vim.api.nvim_set_current_win(root_win)
feed("<C-j>")
feed("<leader>os")
feed("<C-w>h")
assert.equals(1, vim.g.peekstack_original_sid)
assert.equals(1, vim.g.peekstack_original_leader)
assert.equals(1, vim.g.peekstack_original_callback)
end

it("restores Ctrl, leader and <SID> mappings after popup close", function()
local lhs_list = { "<C-j>", "<leader>os", "<C-w>h" }
local before = vim.tbl_map(resolve, lhs_list)
local visual_before = resolve_mode(",os", "x")
local pending_before = resolve_mode(",os", "o")

local model = open_source_popup()
assert.equals("Peekstack focus next", resolve("<C-j>").desc)
assert.equals("Peekstack stack view", resolve("<leader>os").desc)
assert.equals("Peekstack navigate left", resolve("<C-w>h").desc)

popup.close(model)

for i, lhs in ipairs(lhs_list) do
assert.same(before[i], resolve(lhs))
end
-- The `:noremap` mapping also keeps its visual/operator-pending modes.
assert.same(visual_before, resolve_mode(",os", "x"))
assert.same(pending_before, resolve_mode(",os", "o"))
assert_originals_work()
end)

it("restores Ctrl, leader and <SID> mappings when leaving the popup", function()
require("peekstack.core.events").setup()
local model = open_source_popup()
assert.equals(model.winid, vim.api.nvim_get_current_win())
assert.equals("Peekstack focus next", resolve("<C-j>").desc)

vim.api.nvim_set_current_win(root_win)

assert.is_nil(resolve("<C-j>").desc)
assert.equals("Original window left", resolve("<C-w>h").desc)
assert_originals_work()

popup.close(model)
end)

it("keeps other modes of a :map mapping changed while the popup is open", function()
local model = open_source_popup()
vim.keymap.set("x", ",os", "<Nop>", { buffer = source_bufnr, desc = "Updated visual" })
vim.api.nvim_set_current_win(root_win)
vim.api.nvim_cmd({ cmd = "enew" }, {})
assert.is_not.equals(source_bufnr, vim.api.nvim_get_current_buf())

popup.close(model)

assert.equals("Updated visual", resolve_mode(",os", "x").desc)
assert.equals(":let g:peekstack_original_leader = 1<CR>", resolve("<leader>os").rhs)
end)

it("cleans up leader mappings after mapleader changes", function()
vim.keymap.set("n", ";os", "<Nop>", { buffer = source_bufnr, desc = "Unrelated" })
local model = open_source_popup()
vim.g.mapleader = ";"

popup.close(model)

assert.equals("Unrelated", resolve(";os").desc)
assert.equals(":let g:peekstack_original_leader = 1<CR>", resolve(",os").rhs)
end)

it("does not turn global mappings into buffer-local ones", function()
vim.keymap.del("n", "<C-j>", { buffer = source_bufnr })
vim.keymap.set("n", "<C-J>", "<Nop>", { desc = "Global next" })

local model = open_source_popup()
popup.close(model)

local item = resolve("<C-j>")
assert.equals(0, item.buffer)
assert.equals("Global next", item.desc)
vim.keymap.del("n", "<C-J>")
end)
end)

it("installs <C-w>hjkl navigation keymaps on copy-mode popups", function()
local loc = make_location()
local model = popup.create(loc, { buffer_mode = "copy" })
Expand Down
Loading