Skip to content

Commit f24642d

Browse files
delphinusclaude
andcommitted
perf(telescope): debounce previewer rendering and split into staged yields
define_preview ran readfile, build_content, apply_content_to_buffer, and setup_images synchronously. For a markdown file with nine large images that totaled ~50 ms of blocking work per selection — multiplied across rapid j/k navigation and amplified by the terminal's image decoding budget, the picker felt frozen whenever the cursor passed over a heavy file. Restructure the previewer so define_preview returns in <1 ms: cleanup of the previous file's images runs immediately, then a vim.defer_fn timer schedules the actual render 80 ms later. Subsequent file changes cancel the pending timer, so files the user is just scrolling past pay no render cost at all. When the timer fires, the render is split into stages separated by vim.schedule yields: Stage 1: readfile + build_content (~25 ms) Stage 2: apply_content_to_buffer + scroll positioning Stage 3: setup_images A generation counter is bumped on every file change; each stage checks it via still_current() and aborts when a newer change has arrived. The event loop processes queued keypresses between stages, so even when a heavy render does run the picker stays responsive throughout it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ca5348e commit f24642d

1 file changed

Lines changed: 148 additions & 112 deletions

File tree

lua/md-render/telescope.lua

Lines changed: 148 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -66,145 +66,181 @@ function M.previewer(opts)
6666
---@type MdRender.ImageState?
6767
local image_state = nil
6868
local last_filepath = nil
69+
-- Debounce the entire preview render (file read, markdown build, image setup)
70+
-- so rapid j/k navigation in the picker never blocks on heavy files. Old
71+
-- images are torn down immediately on file change; the new file's content
72+
-- is rendered only when selection settles for RENDER_DEBOUNCE_MS.
73+
local RENDER_DEBOUNCE_MS = 80
74+
local render_timer = nil
75+
-- Bumped on every file change. Long-running render is split into stages
76+
-- separated by vim.schedule yields; each stage checks generation and
77+
-- aborts if a newer file change arrived during the yield.
78+
local generation = 0
79+
80+
local function cancel_render_timer()
81+
if render_timer then
82+
pcall(render_timer.stop, render_timer)
83+
pcall(render_timer.close, render_timer)
84+
render_timer = nil
85+
end
86+
end
6987

7088
return previewers.new_buffer_previewer {
7189
title = "Markdown Preview",
7290
define_preview = function(self, entry)
7391
local filepath = entry.path or entry.filename
7492
if not filepath then return end
7593

76-
local is_markdown = filepath:match "%.md$" or filepath:match "%.markdown$"
94+
local file_changed = filepath ~= last_filepath
95+
if not file_changed then return end
96+
7797
local display_utils = require "md-render.display_utils"
7898
local bufnr = self.state.bufnr
7999
local winid = self.state.winid
80100

81-
if not is_markdown then
82-
-- Try to display as image/video
83-
local img_content = build_image_content(filepath, winid)
84-
if img_content then
85-
local file_changed = filepath ~= last_filepath
86-
if file_changed then
87-
if image_state then
88-
display_utils.cleanup_images(image_state)
89-
image_state = nil
90-
end
91-
last_filepath = filepath
92-
end
93-
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, img_content.lines)
94-
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
95-
display_utils.apply_content_to_buffer(bufnr, ns, img_content)
96-
if file_changed then
97-
image_state = display_utils.setup_images(winid, img_content, ns)
101+
-- Tear down old image state synchronously so the previous file's
102+
-- images vanish immediately (no stale overlay during the debounce).
103+
cancel_render_timer()
104+
if image_state then
105+
display_utils.cleanup_images(image_state)
106+
image_state = nil
107+
end
108+
last_filepath = filepath
109+
generation = generation + 1
110+
local my_gen = generation
111+
112+
local function still_current()
113+
return my_gen == generation
114+
and filepath == last_filepath
115+
and vim.api.nvim_win_is_valid(winid)
116+
and vim.api.nvim_buf_is_valid(bufnr)
117+
end
118+
119+
-- Defer all heavy work so the picker stays responsive during rapid
120+
-- navigation. The render is split into multiple stages separated by
121+
-- vim.schedule yields so the event loop can process queued keypresses
122+
-- between each stage; if a newer file change arrives, the in-flight
123+
-- render aborts at the next stage boundary.
124+
render_timer = vim.defer_fn(function()
125+
render_timer = nil
126+
if not still_current() then return end
127+
128+
local is_markdown = filepath:match "%.md$" or filepath:match "%.markdown$"
129+
130+
if not is_markdown then
131+
-- Try to display as image/video
132+
local img_content = build_image_content(filepath, winid)
133+
if img_content then
134+
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, img_content.lines)
135+
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
136+
display_utils.apply_content_to_buffer(bufnr, ns, img_content)
137+
-- Yield before image setup so the picker can absorb pending keys.
138+
vim.schedule(function()
139+
if not still_current() then return end
140+
image_state = display_utils.setup_images(winid, img_content, ns)
141+
end)
142+
return
98143
end
144+
145+
-- Fall back to telescope's default file previewer
146+
last_filepath = nil
147+
local conf = require("telescope.config").values
148+
conf.buffer_previewer_maker(filepath, bufnr, {
149+
bufname = self.state.bufname,
150+
winid = winid,
151+
callback = function(buf)
152+
if entry.lnum then
153+
pcall(vim.api.nvim_buf_call, buf, function()
154+
pcall(vim.api.nvim_win_set_cursor, 0, { entry.lnum, 0 })
155+
vim.cmd "normal! zz"
156+
end)
157+
end
158+
end,
159+
})
99160
return
100161
end
101162

102-
-- Fall back to telescope's default file previewer
103-
if image_state then
104-
display_utils.cleanup_images(image_state)
105-
image_state = nil
106-
end
107-
last_filepath = nil
108-
local conf = require("telescope.config").values
109-
conf.buffer_previewer_maker(filepath, bufnr, {
110-
bufname = self.state.bufname,
111-
winid = winid,
112-
callback = function(buf)
113-
if entry.lnum then
163+
-- Limit source lines to prevent UI freeze on very large Markdown files.
164+
local MAX_PREVIEW_LINES = 500
165+
local lines = vim.fn.readfile(filepath, "", MAX_PREVIEW_LINES)
166+
if not lines or #lines == 0 then return end
167+
168+
-- If the target line is beyond the rendered range, fall back to
169+
-- telescope's default previewer (raw markdown with line navigation).
170+
if entry.lnum and entry.lnum > MAX_PREVIEW_LINES then
171+
last_filepath = nil
172+
local conf = require("telescope.config").values
173+
conf.buffer_previewer_maker(filepath, bufnr, {
174+
bufname = self.state.bufname,
175+
winid = winid,
176+
callback = function(buf)
114177
pcall(vim.api.nvim_buf_call, buf, function()
115178
pcall(vim.api.nvim_win_set_cursor, 0, { entry.lnum, 0 })
116179
vim.cmd "normal! zz"
117180
end)
118-
end
119-
end,
120-
})
121-
return
122-
end
123-
124-
-- Markdown rendering
125-
local file_changed = filepath ~= last_filepath
126-
if file_changed then
127-
if image_state then
128-
display_utils.cleanup_images(image_state)
129-
image_state = nil
181+
end,
182+
})
183+
return
130184
end
131-
last_filepath = filepath
132-
end
133185

134-
-- Limit source lines to prevent UI freeze on very large Markdown files.
135-
-- The telescope preview window only shows a few dozen lines, so
136-
-- rendering the entire document is wasteful and can block the UI.
137-
local MAX_PREVIEW_LINES = 500
138-
local lines = vim.fn.readfile(filepath, "", MAX_PREVIEW_LINES)
139-
if not lines or #lines == 0 then return end
140-
141-
-- If the target line is beyond the rendered range, fall back to
142-
-- telescope's default previewer (raw markdown with line navigation).
143-
if entry.lnum and entry.lnum > MAX_PREVIEW_LINES then
144-
if image_state then
145-
display_utils.cleanup_images(image_state)
146-
image_state = nil
147-
end
148-
last_filepath = nil
149-
local conf = require("telescope.config").values
150-
conf.buffer_previewer_maker(filepath, bufnr, {
151-
bufname = self.state.bufname,
152-
winid = winid,
153-
callback = function(buf)
154-
pcall(vim.api.nvim_buf_call, buf, function()
155-
pcall(vim.api.nvim_win_set_cursor, 0, { entry.lnum, 0 })
156-
vim.cmd "normal! zz"
157-
end)
158-
end,
159-
})
160-
return
161-
end
186+
require("md-render").setup_highlights()
187+
local preview = require "md-render.preview"
188+
local max_width = math.max(40, vim.api.nvim_win_get_width(winid) - 4)
189+
-- buf_dir is required to resolve relative image paths and Obsidian
190+
-- wiki-links (e.g. ![[IMG.jpeg]]); without it the vault root cannot
191+
-- be located and images render only as their placeholder header text.
192+
local build_opts = { max_width = max_width, buf_dir = vim.fn.fnamemodify(filepath, ":h") }
162193

163-
require("md-render").setup_highlights()
164-
local preview = require "md-render.preview"
165-
local max_width = math.max(40, vim.api.nvim_win_get_width(winid) - 4)
166-
-- buf_dir is required to resolve relative image paths and Obsidian
167-
-- wiki-links (e.g. ![[IMG.jpeg]]); without it the vault root cannot be
168-
-- located and images render only as their placeholder header text.
169-
local build_opts = { max_width = max_width, buf_dir = vim.fn.fnamemodify(filepath, ":h") }
170-
local content = preview.build_content(lines, build_opts)
171-
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
172-
display_utils.apply_content_to_buffer(bufnr, ns, content)
173-
174-
-- Scroll to the matched source line
175-
if entry.lnum and content.source_line_map then
176-
local target = #content.source_line_map
177-
for i, sl in ipairs(content.source_line_map) do
178-
if sl >= entry.lnum then
179-
target = i
180-
break
181-
end
182-
end
194+
-- Stage 1: build markdown content (~25 ms for 9-image files).
195+
local content = preview.build_content(lines, build_opts)
196+
if not still_current() then return end
197+
198+
-- Stage 2: apply to buffer (yields after, so the picker can absorb
199+
-- keypresses queued during Stage 1).
183200
vim.schedule(function()
184-
if not vim.api.nvim_win_is_valid(winid) then return end
185-
local win_buf = vim.api.nvim_win_get_buf(winid)
186-
local buf_lines = vim.api.nvim_buf_line_count(win_buf)
187-
target = math.max(1, math.min(target, buf_lines))
188-
local win_height = vim.api.nvim_win_get_height(winid)
189-
local top = math.max(0, target - 1 - math.floor(win_height / 2))
190-
vim.api.nvim_win_call(winid, function()
191-
vim.fn.winrestview { topline = top + 1 }
201+
if not still_current() then return end
202+
vim.api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
203+
display_utils.apply_content_to_buffer(bufnr, ns, content)
204+
205+
-- Scroll to the matched source line
206+
if entry.lnum and content.source_line_map then
207+
local target = #content.source_line_map
208+
for i, sl in ipairs(content.source_line_map) do
209+
if sl >= entry.lnum then
210+
target = i
211+
break
212+
end
213+
end
214+
vim.schedule(function()
215+
if not still_current() then return end
216+
local win_buf = vim.api.nvim_win_get_buf(winid)
217+
local buf_lines = vim.api.nvim_buf_line_count(win_buf)
218+
target = math.max(1, math.min(target, buf_lines))
219+
local win_height = vim.api.nvim_win_get_height(winid)
220+
local top = math.max(0, target - 1 - math.floor(win_height / 2))
221+
vim.api.nvim_win_call(winid, function()
222+
vim.fn.winrestview { topline = top + 1 }
223+
end)
224+
vim.api.nvim_win_set_cursor(winid, { target, 0 })
225+
end)
226+
end
227+
228+
-- Stage 3: image setup (~15 ms). Final yield so the picker stays
229+
-- responsive even when this stage runs.
230+
vim.schedule(function()
231+
if not still_current() then return end
232+
image_state = display_utils.setup_images(winid, content, ns, {
233+
buf = bufnr,
234+
build_content = function()
235+
return preview.build_content(lines, build_opts)
236+
end,
237+
})
192238
end)
193-
vim.api.nvim_win_set_cursor(winid, { target, 0 })
194239
end)
195-
end
196-
197-
-- Only set up images when the file changes
198-
if file_changed then
199-
image_state = display_utils.setup_images(winid, content, ns, {
200-
buf = bufnr,
201-
build_content = function()
202-
return preview.build_content(lines, build_opts)
203-
end,
204-
})
205-
end
240+
end, RENDER_DEBOUNCE_MS)
206241
end,
207242
teardown = function()
243+
cancel_render_timer()
208244
if image_state then
209245
require("md-render.display_utils").cleanup_images(image_state)
210246
image_state = nil

0 commit comments

Comments
 (0)