Skip to content

Commit 0187086

Browse files
committed
refactor(renderer/flush): suppress output-window autocmds and ensure update cleanup
Add a with_suppressed_output_autocmds helper that temporarily sets eventignorewin on the output window, wraps output_window.begin_update / end_update with xpcall to guarantee cleanup, and restores the original state even if the wrapped operation errors. Use the helper in apply_pending and end_bulk_mode so writes and part/message updates won't trigger autocmds mid-write and cleanup always runs. Re-throw errors after restoring state. Add unit tests exercising failure during bulk writes to verify eventignore is restored, end_update is called, and bulk mode state is cleared.
1 parent b1ad9d7 commit 0187086

File tree

2 files changed

+152
-45
lines changed

2 files changed

+152
-45
lines changed

lua/opencode/ui/renderer/flush.lua

Lines changed: 81 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,44 @@ local append = require('opencode.ui.renderer.append')
99

1010
local M = {}
1111

12+
local function with_suppressed_output_autocmds(fn)
13+
local output_win = state.windows and state.windows.output_win
14+
local has_output_win = output_win and vim.api.nvim_win_is_valid(output_win)
15+
local saved_eventignorewin = has_output_win and vim.api.nvim_get_option_value('eventignorewin', { win = output_win })
16+
or nil
17+
18+
if has_output_win then
19+
vim.api.nvim_set_option_value('eventignorewin', 'all', { win = output_win, scope = 'local' })
20+
end
21+
22+
local begin_ok, began_update = xpcall(output_window.begin_update, debug.traceback)
23+
if not begin_ok then
24+
if has_output_win then
25+
vim.api.nvim_set_option_value('eventignorewin', saved_eventignorewin, { win = output_win, scope = 'local' })
26+
end
27+
error(began_update)
28+
end
29+
30+
local ok, result = xpcall(fn, debug.traceback)
31+
local end_ok, end_err = true, nil
32+
33+
if began_update then
34+
end_ok, end_err = xpcall(output_window.end_update, debug.traceback)
35+
end
36+
if has_output_win then
37+
vim.api.nvim_set_option_value('eventignorewin', saved_eventignorewin, { win = output_win, scope = 'local' })
38+
end
39+
40+
if not ok then
41+
error(result)
42+
end
43+
if not end_ok then
44+
error(end_err)
45+
end
46+
47+
return result
48+
end
49+
1250
local function lines_equal(a, b)
1351
a = a or {}
1452
b = b or {}
@@ -282,50 +320,46 @@ local function apply_pending(pending)
282320
end
283321

284322
local scroll_snapshot = scroll.pre_flush(buf)
285-
local saved_eventignore = vim.o.eventignore
286-
vim.o.eventignore = 'all'
287-
output_window.begin_update()
288-
289-
for _, part_id in ipairs(pending.removed_part_order) do
290-
if pending.removed_parts[part_id] then
291-
buffer.remove_part_now(part_id)
323+
with_suppressed_output_autocmds(function()
324+
for _, part_id in ipairs(pending.removed_part_order) do
325+
if pending.removed_parts[part_id] then
326+
buffer.remove_part_now(part_id)
327+
end
292328
end
293-
end
294329

295-
for _, message_id in ipairs(pending.removed_message_order) do
296-
if pending.removed_messages[message_id] then
297-
buffer.remove_message_now(message_id)
330+
for _, message_id in ipairs(pending.removed_message_order) do
331+
if pending.removed_messages[message_id] then
332+
buffer.remove_message_now(message_id)
333+
end
298334
end
299-
end
300335

301-
for _, message_id in ipairs(pending.dirty_message_order) do
302-
if pending.dirty_messages[message_id] then
303-
apply_message(message_id)
304-
end
336+
for _, message_id in ipairs(pending.dirty_message_order) do
337+
if pending.dirty_messages[message_id] then
338+
apply_message(message_id)
339+
end
305340

306-
local dirty_parts = pending.dirty_part_by_message[message_id]
307-
if dirty_parts then
308-
local message = ctx.render_state:get_message(message_id)
309-
local parts = message and message.message and message.message.parts or {}
310-
for _, part in ipairs(parts or {}) do
311-
if part.id and dirty_parts[part.id] then
312-
apply_part(part.id, message_id)
313-
dirty_parts[part.id] = nil
314-
pending.dirty_parts[part.id] = nil
341+
local dirty_parts = pending.dirty_part_by_message[message_id]
342+
if dirty_parts then
343+
local message = ctx.render_state:get_message(message_id)
344+
local parts = message and message.message and message.message.parts or {}
345+
for _, part in ipairs(parts or {}) do
346+
if part.id and dirty_parts[part.id] then
347+
apply_part(part.id, message_id)
348+
dirty_parts[part.id] = nil
349+
pending.dirty_parts[part.id] = nil
350+
end
315351
end
316352
end
317353
end
318-
end
319354

320-
for _, part_id in ipairs(pending.dirty_part_order) do
321-
local message_id = pending.dirty_parts[part_id]
322-
if message_id then
323-
apply_part(part_id, message_id)
355+
for _, part_id in ipairs(pending.dirty_part_order) do
356+
local message_id = pending.dirty_parts[part_id]
357+
if message_id then
358+
apply_part(part_id, message_id)
359+
end
324360
end
325-
end
361+
end)
326362

327-
output_window.end_update()
328-
vim.o.eventignore = saved_eventignore
329363
scroll.post_flush(scroll_snapshot, buf)
330364
return true
331365
end
@@ -396,21 +430,23 @@ function M.end_bulk_mode()
396430
end
397431

398432
-- Write all lines at once. Suppress autocmds so render-markdown and similar
399-
-- plugins don't fire mid-write; we trigger them explicitly via vim.schedule
400-
-- below. begin_update/end_update handles the modifiable toggle.
401-
local saved_eventignore = vim.o.eventignore
402-
vim.o.eventignore = 'all'
403-
output_window.begin_update()
404-
output_window.set_lines(lines, 0, -1)
405-
output_window.end_update()
406-
vim.o.eventignore = saved_eventignore
407-
408-
if next(ctx.bulk_extmarks_by_line) then
409-
output_window.set_extmarks(ctx.bulk_extmarks_by_line, 0)
410-
end
433+
-- plugins don't fire mid-write; restore state even if the write fails.
434+
local ok, err = xpcall(function()
435+
with_suppressed_output_autocmds(function()
436+
output_window.set_lines(lines, 0, -1)
437+
end)
438+
439+
if next(ctx.bulk_extmarks_by_line) then
440+
output_window.set_extmarks(ctx.bulk_extmarks_by_line, 0)
441+
end
442+
end, debug.traceback)
411443

412444
ctx:bulk_reset()
413445

446+
if not ok then
447+
error(err)
448+
end
449+
414450
vim.schedule(function()
415451
M.request_on_data_rendered(true)
416452
end)

tests/unit/output_window_spec.lua

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
local config = require('opencode.config')
22
local state = require('opencode.state')
33
local output_window = require('opencode.ui.output_window')
4+
local flush = require('opencode.ui.renderer.flush')
45
local stub = require('luassert.stub')
56

67
describe('output_window.create_buf', function()
@@ -180,3 +181,73 @@ describe('output_window extmarks', function()
180181
assert.equals(1, marks[2][2])
181182
end)
182183
end)
184+
185+
describe('renderer flush cleanup', function()
186+
local buf
187+
local win
188+
local original_eventignore
189+
local original_eventignorewin
190+
local begin_update_stub
191+
local end_update_stub
192+
local set_lines_stub
193+
local set_extmarks_stub
194+
195+
before_each(function()
196+
buf = vim.api.nvim_create_buf(false, true)
197+
win = vim.api.nvim_open_win(buf, true, {
198+
relative = 'editor',
199+
width = 80,
200+
height = 10,
201+
row = 0,
202+
col = 0,
203+
})
204+
state.ui.set_windows({ output_buf = buf, output_win = win })
205+
original_eventignore = vim.o.eventignore
206+
original_eventignorewin = vim.api.nvim_get_option_value('eventignorewin', { win = win })
207+
begin_update_stub = stub(output_window, 'begin_update').returns(true)
208+
end_update_stub = stub(output_window, 'end_update')
209+
set_lines_stub = stub(output_window, 'set_lines').invokes(function()
210+
error('boom')
211+
end)
212+
set_extmarks_stub = stub(output_window, 'set_extmarks')
213+
end)
214+
215+
after_each(function()
216+
if set_extmarks_stub then
217+
set_extmarks_stub:revert()
218+
end
219+
if set_lines_stub then
220+
set_lines_stub:revert()
221+
end
222+
if end_update_stub then
223+
end_update_stub:revert()
224+
end
225+
if begin_update_stub then
226+
begin_update_stub:revert()
227+
end
228+
vim.o.eventignore = original_eventignore
229+
if win and vim.api.nvim_win_is_valid(win) then
230+
vim.api.nvim_set_option_value('eventignorewin', original_eventignorewin, { win = win, scope = 'local' })
231+
end
232+
state.ui.set_windows(nil)
233+
pcall(vim.api.nvim_win_close, win, true)
234+
pcall(vim.api.nvim_buf_delete, buf, { force = true })
235+
end)
236+
237+
it('restores output window eventignorewin and ends updates when bulk writes fail', function()
238+
flush.begin_bulk_mode()
239+
local ctx = require('opencode.ui.renderer.ctx')
240+
ctx.bulk_buffer_lines = { 'line 1' }
241+
242+
local ok, err = pcall(flush.end_bulk_mode)
243+
244+
assert.is_false(ok)
245+
assert.matches('boom', err)
246+
assert.equals(original_eventignore, vim.o.eventignore)
247+
assert.equals(original_eventignorewin, vim.api.nvim_get_option_value('eventignorewin', { win = win }))
248+
assert.stub(begin_update_stub).was_called(1)
249+
assert.stub(end_update_stub).was_called(1)
250+
assert.is_false(ctx.bulk_mode)
251+
assert.stub(set_extmarks_stub).was_not_called()
252+
end)
253+
end)

0 commit comments

Comments
 (0)