Skip to content

Commit bbcba06

Browse files
committed
perf(stack-view): render only changed lines
1 parent 29aa6f5 commit bbcba06

4 files changed

Lines changed: 192 additions & 21 deletions

File tree

lua/peekstack/types.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@
151151
---@field winid integer?
152152
---@field root_winid integer?
153153
---@field line_to_id table<integer, integer>
154+
---@field render_keys string[]
154155
---@field filter string?
155156
---@field header_lines integer
156157
---@field help_bufnr integer?

lua/peekstack/ui/stack_view/init.lua

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ local function get_state()
6161
winid = nil,
6262
root_winid = nil,
6363
line_to_id = {},
64+
render_keys = {},
6465
filter = nil,
6566
header_lines = 0,
6667
help_bufnr = nil,
@@ -150,6 +151,7 @@ local function reset_open_state(s)
150151
s.winid = nil
151152
s.bufnr = nil
152153
s.root_winid = nil
154+
s.render_keys = {}
153155
s.autoclose_suspended = 0
154156
s.help_augroup = nil
155157
end
@@ -231,6 +233,7 @@ function M.open()
231233
s.root_winid = find_root_winid()
232234
s.bufnr = vim.api.nvim_create_buf(false, true)
233235
s.winid = vim.api.nvim_open_win(s.bufnr, true, stack_view_win_config())
236+
s.render_keys = {}
234237

235238
vim.wo[s.winid].cursorline = true
236239
vim.wo[s.winid].winhighlight = "CursorLine:PeekstackStackViewCursorLine"

lua/peekstack/ui/stack_view/render.lua

Lines changed: 121 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,110 @@ local function get_preview_line(source_bufnr, line, max_width, preview_prefix)
315315
}
316316
end
317317

318+
---@param line string
319+
---@param line_hls PeekstackStackViewHighlight[]
320+
---@param preview PeekstackStackViewPreviewLine?
321+
---@return string
322+
local function line_render_key(line, line_hls, preview)
323+
local parts = { line }
324+
for _, hl in ipairs(line_hls) do
325+
parts[#parts + 1] = string.format("%d:%d:%s", hl.col_start, hl.col_end, hl.hl_group)
326+
end
327+
if preview then
328+
parts[#parts + 1] = string.format(
329+
"preview:%d:%d:%d:%d:%d",
330+
preview.source_bufnr,
331+
preview.source_line,
332+
preview.source_col_start,
333+
preview.source_col_end,
334+
preview.preview_col_start
335+
)
336+
end
337+
return table.concat(parts, "|")
338+
end
339+
340+
---@param items string[]
341+
---@param start_idx integer
342+
---@param end_idx integer
343+
---@return string[]
344+
local function slice_lines(items, start_idx, end_idx)
345+
if end_idx < start_idx then
346+
return {}
347+
end
348+
349+
---@type string[]
350+
local slice = {}
351+
for idx = start_idx, end_idx do
352+
slice[#slice + 1] = items[idx]
353+
end
354+
return slice
355+
end
356+
357+
---@param old_keys string[]
358+
---@param new_keys string[]
359+
---@return integer?, integer?, integer?
360+
local function diff_range(old_keys, new_keys)
361+
local old_count = #old_keys
362+
local new_count = #new_keys
363+
local start_idx = 1
364+
365+
while start_idx <= old_count and start_idx <= new_count and old_keys[start_idx] == new_keys[start_idx] do
366+
start_idx = start_idx + 1
367+
end
368+
369+
if start_idx > old_count and start_idx > new_count then
370+
return nil, nil, nil
371+
end
372+
373+
local old_end = old_count
374+
local new_end = new_count
375+
while old_end >= start_idx and new_end >= start_idx and old_keys[old_end] == new_keys[new_end] do
376+
old_end = old_end - 1
377+
new_end = new_end - 1
378+
end
379+
380+
return start_idx, old_end, new_end
381+
end
382+
383+
---@param bufnr integer
384+
---@param highlights PeekstackStackViewHighlight[][]
385+
---@param preview_lines table<integer, PeekstackStackViewPreviewLine>
386+
---@param start_idx integer
387+
---@param end_idx integer
388+
local function apply_highlights_in_range(bufnr, highlights, preview_lines, start_idx, end_idx)
389+
if end_idx < start_idx then
390+
return
391+
end
392+
393+
for line_idx = start_idx, end_idx do
394+
for _, hl in ipairs(highlights[line_idx] or {}) do
395+
local opts = {
396+
end_col = hl.col_end,
397+
hl_group = hl.hl_group,
398+
}
399+
if hl.hl_group == "PeekstackStackViewPreview" then
400+
opts.priority = PREVIEW_BASE_HL_PRIORITY
401+
end
402+
vim.api.nvim_buf_set_extmark(bufnr, NS, line_idx - 1, hl.col_start, {
403+
end_col = opts.end_col,
404+
hl_group = opts.hl_group,
405+
priority = opts.priority,
406+
})
407+
end
408+
end
409+
410+
---@type table<integer, PeekstackStackViewPreviewLine>
411+
local changed_previews = {}
412+
for line_idx = start_idx, end_idx do
413+
if preview_lines[line_idx] then
414+
changed_previews[line_idx] = preview_lines[line_idx]
415+
end
416+
end
417+
if next(changed_previews) then
418+
apply_preview_treesitter_highlights(bufnr, changed_previews)
419+
end
420+
end
421+
318422
---@param s PeekstackStackViewState
319423
---@param is_ready fun(s: PeekstackStackViewState): boolean
320424
function M.render(s, is_ready)
@@ -478,29 +582,25 @@ function M.render(s, is_ready)
478582
end
479583
end
480584

481-
vim.bo[s.bufnr].modifiable = true
482-
vim.api.nvim_buf_set_lines(s.bufnr, 0, -1, false, lines)
483-
vim.bo[s.bufnr].modifiable = false
484-
485-
vim.api.nvim_buf_clear_namespace(s.bufnr, NS, 0, -1)
486-
for line_idx, line_hls in ipairs(highlights) do
487-
for _, hl in ipairs(line_hls) do
488-
local opts = {
489-
end_col = hl.col_end,
490-
hl_group = hl.hl_group,
491-
}
492-
if hl.hl_group == "PeekstackStackViewPreview" then
493-
opts.priority = PREVIEW_BASE_HL_PRIORITY
494-
end
495-
vim.api.nvim_buf_set_extmark(s.bufnr, NS, line_idx - 1, hl.col_start, {
496-
end_col = opts.end_col,
497-
hl_group = opts.hl_group,
498-
priority = opts.priority,
499-
})
500-
end
585+
---@type string[]
586+
local line_keys = {}
587+
for line_idx, line in ipairs(lines) do
588+
line_keys[line_idx] = line_render_key(line, highlights[line_idx] or {}, preview_lines[line_idx])
501589
end
502590

503-
apply_preview_treesitter_highlights(s.bufnr, preview_lines)
591+
local old_keys = s.render_keys or {}
592+
local start_idx, old_end, new_end = diff_range(old_keys, line_keys)
593+
if start_idx then
594+
local replace = slice_lines(lines, start_idx, new_end)
595+
596+
vim.bo[s.bufnr].modifiable = true
597+
vim.api.nvim_buf_set_lines(s.bufnr, start_idx - 1, old_end, false, replace)
598+
vim.bo[s.bufnr].modifiable = false
599+
600+
vim.api.nvim_buf_clear_namespace(s.bufnr, NS, start_idx - 1, old_end)
601+
apply_highlights_in_range(s.bufnr, highlights, preview_lines, start_idx, new_end)
602+
end
603+
s.render_keys = line_keys
504604

505605
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
506606
local line_count = vim.api.nvim_buf_line_count(s.bufnr)

tests/stack_view_spec.lua

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,73 @@ describe("peekstack.ui.stack_view", function()
8484
assert.is_true(lines[1]:find("Stack: 2", 1, true) ~= nil)
8585
end)
8686

87+
it("skips line updates when rendered content is unchanged", function()
88+
local root_winid = vim.api.nvim_get_current_win()
89+
local s = stack.current_stack(root_winid)
90+
s.popups = {
91+
{ id = 1, title = "Alpha", location = location_for("/tmp/alpha.lua"), pinned = false },
92+
{ id = 2, title = "Beta", location = location_for("/tmp/beta.lua"), pinned = false },
93+
}
94+
95+
stack_view.open()
96+
local state = stack_view._get_state()
97+
stack_view._render(state)
98+
99+
local original_set_lines = vim.api.nvim_buf_set_lines
100+
local calls = 0
101+
local ok, err = pcall(function()
102+
vim.api.nvim_buf_set_lines = function(...)
103+
calls = calls + 1
104+
return original_set_lines(...)
105+
end
106+
107+
stack_view._render(state)
108+
assert.equals(0, calls)
109+
end)
110+
vim.api.nvim_buf_set_lines = original_set_lines
111+
if not ok then
112+
error(err)
113+
end
114+
end)
115+
116+
it("updates only changed line range when one entry changes", function()
117+
local root_winid = vim.api.nvim_get_current_win()
118+
local s = stack.current_stack(root_winid)
119+
s.popups = {
120+
{ id = 1, title = "Alpha", location = location_for("/tmp/alpha.lua"), pinned = false },
121+
{ id = 2, title = "Beta", location = location_for("/tmp/beta.lua"), pinned = false },
122+
}
123+
124+
stack_view.open()
125+
local state = stack_view._get_state()
126+
stack_view._render(state)
127+
128+
local original_set_lines = vim.api.nvim_buf_set_lines
129+
local calls = {}
130+
local ok, err = pcall(function()
131+
vim.api.nvim_buf_set_lines = function(bufnr, start, finish, strict_indexing, replacement)
132+
table.insert(calls, {
133+
start = start,
134+
finish = finish,
135+
count = #replacement,
136+
})
137+
return original_set_lines(bufnr, start, finish, strict_indexing, replacement)
138+
end
139+
140+
s.popups[2].title = "Beta updated"
141+
stack_view._render(state)
142+
143+
assert.equals(1, #calls)
144+
assert.equals(2, calls[1].start)
145+
assert.equals(3, calls[1].finish)
146+
assert.equals(1, calls[1].count)
147+
end)
148+
vim.api.nvim_buf_set_lines = original_set_lines
149+
if not ok then
150+
error(err)
151+
end
152+
end)
153+
87154
it("renders empty state with header", function()
88155
stack_view.open()
89156
local state = stack_view._get_state()

0 commit comments

Comments
 (0)