Skip to content

Commit b6f413d

Browse files
committed
feat(ui): navigate stack view cursor by stack items
1 parent eba6788 commit b6f413d

4 files changed

Lines changed: 241 additions & 4 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,8 @@ Defaults in stack view:
130130
- `r` — rename
131131
- `p` — pin
132132
- `/` — filter
133+
- `gg/G` — jump to first/last stack item
134+
- `j/k` — move cursor by stack item (skip header/preview lines)
133135
- `J/K` — move item down/up
134136
- `?` — help
135137
- `q` — close

doc/peekstack.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -381,6 +381,8 @@ Stack view keymaps:
381381
`r` Rename the selected popup
382382
`p` Toggle pin (skip auto-close)
383383
`/` Filter the list
384+
`gg/G` Jump to first/last stack item
385+
`j/k` Move cursor by stack item (skip header/preview lines)
384386
`J/K` Move the selected item down/up
385387
`?` Toggle help
386388
`q` Close the stack view

lua/peekstack/ui/stack_view.lua

Lines changed: 131 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -598,10 +598,14 @@ local function render(s)
598598
end
599599
apply_preview_treesitter_highlights(s.bufnr, preview_lines)
600600

601-
if s.winid and vim.api.nvim_win_is_valid(s.winid) and #visible > 0 then
602-
local cursor = vim.api.nvim_win_get_cursor(s.winid)[1]
603-
if cursor <= s.header_lines then
604-
vim.api.nvim_win_set_cursor(s.winid, { s.header_lines + 1, 0 })
601+
if s.winid and vim.api.nvim_win_is_valid(s.winid) and s.bufnr and vim.api.nvim_buf_is_valid(s.bufnr) then
602+
local line_count = vim.api.nvim_buf_line_count(s.bufnr)
603+
if line_count > s.header_lines then
604+
local min_line = s.header_lines + 1
605+
local cursor = vim.api.nvim_win_get_cursor(s.winid)[1]
606+
if cursor < min_line then
607+
vim.api.nvim_win_set_cursor(s.winid, { min_line, 0 })
608+
end
605609
end
606610
end
607611
end
@@ -623,6 +627,88 @@ local function move_cursor_to_id(s, id)
623627
end
624628
end
625629

630+
---@param s table
631+
---@return integer[]
632+
local function entry_lines(s)
633+
local id_to_line = {}
634+
for line, id in pairs(s.line_to_id or {}) do
635+
if line > (s.header_lines or 0) and (not id_to_line[id] or line < id_to_line[id]) then
636+
id_to_line[id] = line
637+
end
638+
end
639+
local lines = {}
640+
for _, line in pairs(id_to_line) do
641+
table.insert(lines, line)
642+
end
643+
table.sort(lines)
644+
return lines
645+
end
646+
647+
---@param s table
648+
local function ensure_non_header_cursor(s)
649+
if not (s.winid and vim.api.nvim_win_is_valid(s.winid) and s.bufnr and vim.api.nvim_buf_is_valid(s.bufnr)) then
650+
return
651+
end
652+
local line_count = vim.api.nvim_buf_line_count(s.bufnr)
653+
if line_count <= 0 then
654+
return
655+
end
656+
local min_line = math.min((s.header_lines or 0) + 1, line_count)
657+
local cursor = vim.api.nvim_win_get_cursor(s.winid)[1]
658+
if cursor < min_line then
659+
vim.api.nvim_win_set_cursor(s.winid, { min_line, 0 })
660+
end
661+
end
662+
663+
---@param s table
664+
---@param step integer
665+
local function move_cursor_by_stack_item(s, step)
666+
if not (s.winid and vim.api.nvim_win_is_valid(s.winid)) then
667+
return
668+
end
669+
local lines = entry_lines(s)
670+
if #lines == 0 then
671+
ensure_non_header_cursor(s)
672+
return
673+
end
674+
675+
local cursor_line = vim.api.nvim_win_get_cursor(s.winid)[1]
676+
if cursor_line <= (s.header_lines or 0) then
677+
vim.api.nvim_win_set_cursor(s.winid, { lines[1], 0 })
678+
return
679+
end
680+
681+
local current_id = s.line_to_id[cursor_line]
682+
local base_line = cursor_line
683+
if current_id then
684+
for line, id in pairs(s.line_to_id) do
685+
if id == current_id and line < base_line then
686+
base_line = line
687+
end
688+
end
689+
end
690+
691+
local target_line = base_line
692+
if step > 0 then
693+
for _, line in ipairs(lines) do
694+
if line > base_line then
695+
target_line = line
696+
break
697+
end
698+
end
699+
else
700+
for idx = #lines, 1, -1 do
701+
local line = lines[idx]
702+
if line < base_line then
703+
target_line = line
704+
break
705+
end
706+
end
707+
end
708+
709+
vim.api.nvim_win_set_cursor(s.winid, { target_line, 0 })
710+
end
711+
626712
---@param s table
627713
local function toggle_help(s)
628714
if s.help_winid and vim.api.nvim_win_is_valid(s.help_winid) then
@@ -641,6 +727,8 @@ local function toggle_help(s)
641727
"r Rename selected popup",
642728
"p Toggle pin (skip auto-close)",
643729
"/ Filter list",
730+
"gg/G Jump to first/last stack item",
731+
"j/k Move cursor by stack item",
644732
"J/K Move item down/up",
645733
"q Close stack view",
646734
"? Toggle this help",
@@ -798,6 +886,38 @@ local function apply_keymaps(s)
798886
end)
799887
end, { buffer = s.bufnr, nowait = true, silent = true })
800888

889+
vim.keymap.set("n", "gg", function()
890+
local lines = entry_lines(s)
891+
if #lines == 0 then
892+
ensure_non_header_cursor(s)
893+
return
894+
end
895+
vim.api.nvim_win_set_cursor(s.winid, { lines[1], 0 })
896+
end, { buffer = s.bufnr, nowait = true, silent = true })
897+
898+
vim.keymap.set("n", "G", function()
899+
local lines = entry_lines(s)
900+
if #lines == 0 then
901+
ensure_non_header_cursor(s)
902+
return
903+
end
904+
vim.api.nvim_win_set_cursor(s.winid, { lines[#lines], 0 })
905+
end, { buffer = s.bufnr, nowait = true, silent = true })
906+
907+
vim.keymap.set("n", "j", function()
908+
local count = vim.v.count1
909+
for _ = 1, count do
910+
move_cursor_by_stack_item(s, 1)
911+
end
912+
end, { buffer = s.bufnr, nowait = true, silent = true })
913+
914+
vim.keymap.set("n", "k", function()
915+
local count = vim.v.count1
916+
for _ = 1, count do
917+
move_cursor_by_stack_item(s, -1)
918+
end
919+
end, { buffer = s.bufnr, nowait = true, silent = true })
920+
801921
vim.keymap.set("n", "J", function()
802922
local line = vim.api.nvim_win_get_cursor(s.winid)[1]
803923
local id = s.line_to_id[line]
@@ -994,6 +1114,13 @@ function M.open()
9941114
end)
9951115
end,
9961116
})
1117+
vim.api.nvim_create_autocmd("CursorMoved", {
1118+
group = au_group,
1119+
buffer = s.bufnr,
1120+
callback = function()
1121+
ensure_non_header_cursor(s)
1122+
end,
1123+
})
9971124

9981125
apply_keymaps(s)
9991126
render(s)

tests/stack_view_spec.lua

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,112 @@ describe("peekstack.ui.stack_view", function()
164164
assert.is_true(found, "H keymap should be bound in stack view")
165165
end)
166166

167+
it("moves cursor by stack item with j/k", function()
168+
local tmpfile1 = vim.fn.tempname() .. ".lua"
169+
local tmpfile2 = vim.fn.tempname() .. ".lua"
170+
vim.fn.writefile({ "local first = 1" }, tmpfile1)
171+
vim.fn.writefile({ "local second = 2" }, tmpfile2)
172+
173+
local loc1 = helpers.make_location({
174+
uri = vim.uri_from_fname(tmpfile1),
175+
range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } },
176+
})
177+
local loc2 = helpers.make_location({
178+
uri = vim.uri_from_fname(tmpfile2),
179+
range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } },
180+
})
181+
182+
local m1 = stack.push(loc1)
183+
local m2 = stack.push(loc2)
184+
assert.is_not_nil(m1)
185+
assert.is_not_nil(m2)
186+
187+
stack_view.open()
188+
local state = stack_view._get_state()
189+
stack_view._render(state)
190+
vim.api.nvim_set_current_win(state.winid)
191+
192+
local min_by_id = {}
193+
local max_by_id = {}
194+
for line, id in pairs(state.line_to_id) do
195+
if not min_by_id[id] or line < min_by_id[id] then
196+
min_by_id[id] = line
197+
end
198+
if not max_by_id[id] or line > max_by_id[id] then
199+
max_by_id[id] = line
200+
end
201+
end
202+
203+
assert.is_not_nil(min_by_id[m1.id])
204+
assert.is_not_nil(min_by_id[m2.id])
205+
assert.is_not_nil(max_by_id[m2.id])
206+
207+
local entry_lines = { min_by_id[m1.id], min_by_id[m2.id] }
208+
table.sort(entry_lines)
209+
local last_entry_line = entry_lines[#entry_lines]
210+
local prev_entry_line = entry_lines[#entry_lines - 1]
211+
assert.is_not_nil(prev_entry_line)
212+
213+
local start_id = nil
214+
if min_by_id[m1.id] == last_entry_line then
215+
start_id = m1.id
216+
else
217+
start_id = m2.id
218+
end
219+
assert.is_not_nil(start_id)
220+
assert.is_true(max_by_id[start_id] >= last_entry_line)
221+
222+
vim.api.nvim_win_set_cursor(state.winid, { max_by_id[start_id], 0 })
223+
vim.api.nvim_feedkeys("k", "mx", false)
224+
vim.wait(50, function()
225+
return vim.api.nvim_win_get_cursor(state.winid)[1] == prev_entry_line
226+
end)
227+
assert.equals(prev_entry_line, vim.api.nvim_win_get_cursor(state.winid)[1])
228+
229+
vim.api.nvim_feedkeys("j", "mx", false)
230+
vim.wait(50, function()
231+
return vim.api.nvim_win_get_cursor(state.winid)[1] == last_entry_line
232+
end)
233+
assert.equals(last_entry_line, vim.api.nvim_win_get_cursor(state.winid)[1])
234+
235+
vim.fn.delete(tmpfile1)
236+
vim.fn.delete(tmpfile2)
237+
end)
238+
239+
it("does not allow cursor on stack header line", function()
240+
local tmpfile = vim.fn.tempname() .. ".lua"
241+
vim.fn.writefile({ "local header_guard = true" }, tmpfile)
242+
243+
local loc = helpers.make_location({
244+
uri = vim.uri_from_fname(tmpfile),
245+
range = { start = { line = 0, character = 0 }, ["end"] = { line = 0, character = 0 } },
246+
})
247+
local model = stack.push(loc)
248+
assert.is_not_nil(model)
249+
250+
stack_view.open()
251+
local state = stack_view._get_state()
252+
stack_view._render(state)
253+
vim.api.nvim_set_current_win(state.winid)
254+
255+
vim.api.nvim_feedkeys("gg", "mx", false)
256+
vim.wait(50, function()
257+
return vim.api.nvim_win_get_cursor(state.winid)[1] > state.header_lines
258+
end)
259+
assert.is_true(vim.api.nvim_win_get_cursor(state.winid)[1] > state.header_lines)
260+
261+
local first_entry_line = nil
262+
for line, _ in pairs(state.line_to_id) do
263+
if line > state.header_lines and (not first_entry_line or line < first_entry_line) then
264+
first_entry_line = line
265+
end
266+
end
267+
assert.is_not_nil(first_entry_line)
268+
assert.equals(first_entry_line, vim.api.nvim_win_get_cursor(state.winid)[1])
269+
270+
vim.fn.delete(tmpfile)
271+
end)
272+
167273
it("does not error when render is called after close", function()
168274
stack_view.open()
169275
local state = stack_view._get_state()

0 commit comments

Comments
 (0)