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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ Defaults inside popup windows:
- `<C-t>` — promote to new tab
- `<leader>os` — open stack view
- `<C-z>` — toggle zoom (maximize top popup)
- `<C-w>h/j/k/l` — navigate to adjacent split window

Defaults in stack view:

Expand Down
1 change: 1 addition & 0 deletions doc/peekstack.txt
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ Default keymaps inside popup windows:
`<C-t>` Promote to new tab
`<leader>os` Toggle the stack view panel
`<C-z>` Toggle zoom (maximize top popup)
`<C-w>h/j/k/l` Navigate to the adjacent split window

Stack view keymaps:

Expand Down
36 changes: 32 additions & 4 deletions lua/peekstack/core/stack.lua
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,26 @@ local suppress_win_events = false
local popup_by_id = {}
---@type table<integer, PeekstackPopupLookupEntry>
local popup_by_winid = {}
---@param winid? integer
---@return integer
local get_root_winid

---@param model PeekstackPopupModel
---@return integer?
local function resolve_ephemeral_root_winid(model)
if not model or not model.origin then
return nil
end
local origin_winid = model.origin.winid
if type(origin_winid) ~= "number" or not vim.api.nvim_win_is_valid(origin_winid) then
return nil
end
local ok_root, root_winid = pcall(get_root_winid, origin_winid)
if ok_root and type(root_winid) == "number" and vim.api.nvim_win_is_valid(root_winid) then
return root_winid
end
return nil
end

---@param model PeekstackPopupModel
local function unindex_popup(model)
Expand Down Expand Up @@ -145,7 +165,7 @@ local function lookup_by_winid(winid)

for _, item in pairs(ephemerals) do
if item.winid == winid then
index_popup(item, nil)
index_popup(item, resolve_ephemeral_root_winid(item))
return popup_by_winid[winid]
end
end
Expand All @@ -161,7 +181,7 @@ end
---@param model PeekstackPopupModel
local function register_ephemeral(model)
ephemerals[model.id] = model
index_popup(model, nil)
index_popup(model, resolve_ephemeral_root_winid(model))
end

---@param id integer
Expand All @@ -180,7 +200,7 @@ local function find_ephemeral(id)
return id, ephemerals[id]
end
local entry = lookup_by_winid(id)
if entry and entry.root_winid == nil then
if entry and entry.popup and entry.popup.ephemeral then
return entry.popup.id, entry.popup
end
return nil
Expand All @@ -190,7 +210,7 @@ end
--- floating window, walk through the stacks to find its origin window instead.
---@param winid? integer
---@return integer
local function get_root_winid(winid)
get_root_winid = function(winid)
local wid = winid or vim.api.nvim_get_current_win()
local win_cfg = vim.api.nvim_win_get_config(wid)
if win_cfg.relative == "" then
Expand Down Expand Up @@ -988,6 +1008,14 @@ function M.focused_id(winid)
return stack.focused_id
end

--- Public wrapper for get_root_winid – used by keymaps to resolve the
--- non-floating owner window for the current (or given) window.
---@param winid? integer
---@return integer
function M.get_root_winid(winid)
return get_root_winid(winid)
end

--- Reset all stacks (for testing).
function M._reset()
stacks = {}
Expand Down
29 changes: 29 additions & 0 deletions lua/peekstack/ui/keymaps.lua
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ local function map(bufnr, lhs, rhs, desc)
vim.keymap.set("n", lhs, rhs, { buffer = bufnr, nowait = true, silent = true, desc = desc })
end

--- Navigate from a popup to an adjacent split window.
--- Moves focus back to the root (non-floating) window first, then executes
--- wincmd in the given direction.
---@param direction string one of "h", "j", "k", "l"
local function nav_to_split(direction)
local stack = require("peekstack.core.stack")
local root = stack.get_root_winid()
if root and vim.api.nvim_win_is_valid(root) then
vim.api.nvim_set_current_win(root)
end
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
Expand Down Expand Up @@ -94,6 +107,22 @@ function M.apply_popup(popup)
local stack = require("peekstack.core.stack")
stack.toggle_zoom()
end, "Peekstack zoom")

-- Window navigation: <C-w>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, "<C-w>h", function()
nav_to_split("h")
end, "Peekstack navigate left")
map(popup.bufnr, "<C-w>j", function()
nav_to_split("j")
end, "Peekstack navigate down")
map(popup.bufnr, "<C-w>k", function()
nav_to_split("k")
end, "Peekstack navigate up")
map(popup.bufnr, "<C-w>l", function()
nav_to_split("l")
end, "Peekstack navigate right")
end

return M
62 changes: 62 additions & 0 deletions tests/popup_source_mode_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,68 @@ describe("popup source mode", function()
vim.fn.delete(temp)
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" })
assert.is_not_nil(model)
assert.is_true(has_buffer_map(model.bufnr, "<C-W>h"))
assert.is_true(has_buffer_map(model.bufnr, "<C-W>j"))
assert.is_true(has_buffer_map(model.bufnr, "<C-W>k"))
assert.is_true(has_buffer_map(model.bufnr, "<C-W>l"))
popup.close(model)
end)

it("<C-w>l navigates from popup to adjacent split", function()
-- Create a vertical split so there are two windows.
vim.api.nvim_cmd({ cmd = "vsplit" }, {})
local left_win = vim.api.nvim_get_current_win()
-- Move to the right split.
vim.api.nvim_cmd({ cmd = "wincmd", args = { "l" } }, {})
local right_win = vim.api.nvim_get_current_win()
assert.is_not.equals(left_win, right_win)

-- Open a popup anchored to the right split.
local loc = make_location()
local model = stack.push(loc)
assert.is_not_nil(model)
-- Focus the popup (simulates user entering the floating window).
vim.api.nvim_set_current_win(model.winid)
assert.equals(model.winid, vim.api.nvim_get_current_win())

-- Execute the <C-w>h keymap callback: should land on the left split.
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<C-w>h", true, false, true), "x", false)
assert.equals(left_win, vim.api.nvim_get_current_win())

-- Cleanup
stack.close(model.id)
vim.api.nvim_set_current_win(right_win)
vim.api.nvim_cmd({ cmd = "close" }, {})
end)

it("does not install <C-w>hjkl navigation keymaps on source-mode popups", 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 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.is_false(has_buffer_map(source_bufnr, "<C-W>h"))
assert.is_false(has_buffer_map(source_bufnr, "<C-W>j"))
assert.is_false(has_buffer_map(source_bufnr, "<C-W>k"))
assert.is_false(has_buffer_map(source_bufnr, "<C-W>l"))

popup.close(model)
vim.fn.delete(temp)
end)

it("deletes copy-mode scratch buffer when render.open fails", function()
local render = require("peekstack.ui.render")
local loc = make_location()
Expand Down
28 changes: 28 additions & 0 deletions tests/quick_peek_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,34 @@ describe("peekstack.quick_peek", function()
assert.is_nil(stack._ephemerals()[model.id])
end)

it("keeps split navigation root for quick peek popups", function()
local location = {
uri = vim.uri_from_bufnr(0),
range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 10 } },
provider = "test",
}

vim.api.nvim_cmd({ cmd = "vsplit" }, {})
vim.api.nvim_cmd({ cmd = "vsplit" }, {})
vim.api.nvim_cmd({ cmd = "wincmd", args = { "h" } }, {})
vim.api.nvim_cmd({ cmd = "wincmd", args = { "h" } }, {})
vim.api.nvim_cmd({ cmd = "wincmd", args = { "l" } }, {})
local middle_win = vim.api.nvim_get_current_win()
vim.api.nvim_cmd({ cmd = "wincmd", args = { "l" } }, {})
local right_win = vim.api.nvim_get_current_win()

vim.api.nvim_set_current_win(middle_win)
local model = stack.push(location, { stack = false })
assert.is_not_nil(model)
vim.api.nvim_set_current_win(model.winid)

vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes("<C-w>l", true, false, true), "x", false)
assert.equals(right_win, vim.api.nvim_get_current_win())

assert.is_true(stack.close(model.id))
vim.api.nvim_cmd({ cmd = "only" }, {})
end)

it("should handle normal peek mode", function()
local location = {
uri = vim.uri_from_bufnr(0),
Expand Down