@@ -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