Skip to content

Commit edda42f

Browse files
committed
perf(renderer): cache absolute folds in ctx.part_folds for incremental updates
Streaming flushes touch at most a few parts at a time, but set_all_folds walks ctx.formatted_parts and recomputes every part's folds every call. For an append-heavy tool output that's quadratic in part count. Add ctx.part_folds as a self-checked cache of absolute fold ranges, keyed by part_id. Cache entries store line_start so rebuild can detect shift_all invalidation without external notification; the rebuild also drops entries whose source part is gone from ctx.formatted_parts, covering removal paths. update_part_folds(id) becomes the streaming hot path: it computes or reuses the target part's entry, then rebuild scans the cache (cheap) and dispatches the sorted fold list. set_all_folds remains the cold path for bulk mode and explicit resets. Microbench on N=20 parts, single-part update: set_all_folds: 4.05 us update_part_folds: 0.30 us (13.5x) On N=100 hot-part streaming, 67x. The bug the previous incarnation had (stale fold ranges surviving in the cache after part removal, eventually fed to foldopen! and raising E16) is structurally closed: rebuild reads ctx.formatted_parts as the source of truth and drops any cache entry without one. Tested explicitly in renderer_buffer_spec.
1 parent da23d63 commit edda42f

3 files changed

Lines changed: 193 additions & 83 deletions

File tree

lua/opencode/ui/renderer/buffer.lua

Lines changed: 67 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -638,7 +638,7 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt
638638
end
639639

640640
if formatted_data.fold_ranges and #formatted_data.fold_ranges > 0 then
641-
M.set_all_folds()
641+
M.update_part_folds(part_id)
642642
end
643643

644644
return true
@@ -647,83 +647,87 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt
647647
return false
648648
end
649649

650-
function M.set_all_folds()
650+
---@param data Output
651+
---@param line_start integer
652+
---@return {from: integer, to: integer}[]
653+
local function compute_part_folds(data, line_start)
654+
local folds = {}
655+
for _, f in ipairs(data.fold_ranges) do
656+
folds[#folds + 1] = {
657+
from = line_start + f.from - 1,
658+
to = line_start + f.to - 1,
659+
}
660+
end
661+
return folds
662+
end
663+
664+
---Self-checked rebuild from ctx.part_folds.
665+
---* cached.line_start mismatch -> recompute (covers shift_all)
666+
---* source part no longer in formatted_parts -> drop entry (covers part removal)
667+
local function rebuild_folds()
668+
for part_id, cached in pairs(ctx.part_folds) do
669+
local part = ctx.render_state:get_part(part_id)
670+
local data = ctx.formatted_parts[part_id]
671+
if not part or not part.line_start or not data or not data.fold_ranges then
672+
ctx.part_folds[part_id] = nil
673+
elseif cached.line_start ~= part.line_start then
674+
ctx.part_folds[part_id] = {
675+
line_start = part.line_start,
676+
folds = compute_part_folds(data, part.line_start),
677+
}
678+
end
679+
end
680+
681+
for part_id in pairs(ctx.part_folds) do
682+
if not ctx.formatted_parts[part_id] then
683+
ctx.part_folds[part_id] = nil
684+
end
685+
end
686+
651687
local all_folds = {}
652-
ctx.part_folds = {}
653-
for part_id_iter, data in pairs(ctx.formatted_parts) do
654-
if data.fold_ranges then
655-
local cached_part = ctx.render_state:get_part(part_id_iter)
656-
if cached_part and cached_part.line_start then
657-
local part_abs_folds = {}
658-
for _, f in ipairs(data.fold_ranges) do
659-
local abs = {
660-
from = cached_part.line_start + f.from - 1,
661-
to = cached_part.line_start + f.to - 1,
662-
}
663-
table.insert(part_abs_folds, abs)
664-
table.insert(all_folds, abs)
665-
end
666-
ctx.part_folds[part_id_iter] = part_abs_folds
667-
end
688+
for _, cached in pairs(ctx.part_folds) do
689+
for _, f in ipairs(cached.folds) do
690+
all_folds[#all_folds + 1] = f
668691
end
669692
end
670-
ctx.global_folds = all_folds
693+
table.sort(all_folds, function(a, b)
694+
return a.from < b.from
695+
end)
671696
output_window.set_folds(all_folds)
672697
end
673698

674-
local function folds_equal(a, b)
675-
if not a or not b then
676-
return false
677-
end
678-
if #a ~= #b then
679-
return false
680-
end
681-
for i = 1, #a do
682-
if a[i].from ~= b[i].from or a[i].to ~= b[i].to then
683-
return false
699+
function M.set_all_folds()
700+
ctx.part_folds = {}
701+
for part_id, data in pairs(ctx.formatted_parts) do
702+
local part = ctx.render_state:get_part(part_id)
703+
if part and part.line_start and data.fold_ranges then
704+
ctx.part_folds[part_id] = {
705+
line_start = part.line_start,
706+
folds = compute_part_folds(data, part.line_start),
707+
}
684708
end
685709
end
686-
return true
710+
rebuild_folds()
687711
end
688712

689-
---Update folds for a single part during streaming, avoiding a full rebuild.
690713
---@param part_id string
691714
function M.update_part_folds(part_id)
692-
local formatted_data = ctx.formatted_parts[part_id]
693-
if not formatted_data or not formatted_data.fold_ranges then
694-
ctx.part_folds[part_id] = nil
695-
M.set_all_folds()
696-
return
697-
end
698-
local cached_part = ctx.render_state:get_part(part_id)
699-
if not cached_part or not cached_part.line_start then
700-
return
701-
end
715+
local data = ctx.formatted_parts[part_id]
716+
local part = ctx.render_state:get_part(part_id)
702717

703-
local new_folds = {}
704-
for _, f in ipairs(formatted_data.fold_ranges) do
705-
table.insert(new_folds, {
706-
from = cached_part.line_start + f.from - 1,
707-
to = cached_part.line_start + f.to - 1,
708-
})
709-
end
710-
711-
if folds_equal(ctx.part_folds[part_id], new_folds) then
712-
return
713-
end
714-
715-
ctx.part_folds[part_id] = new_folds
716-
local new_global = {}
717-
for _, pf in pairs(ctx.part_folds) do
718-
for _, f in ipairs(pf) do
719-
table.insert(new_global, f)
718+
if not part or not part.line_start or not data or not data.fold_ranges then
719+
ctx.part_folds[part_id] = nil
720+
else
721+
local cached = ctx.part_folds[part_id]
722+
if not cached or cached.line_start ~= part.line_start then
723+
ctx.part_folds[part_id] = {
724+
line_start = part.line_start,
725+
folds = compute_part_folds(data, part.line_start),
726+
}
720727
end
721728
end
722-
table.sort(new_global, function(a, b)
723-
return a.from < b.from
724-
end)
725-
ctx.global_folds = new_global
726-
output_window.set_folds(new_global)
729+
730+
rebuild_folds()
727731
end
728732

729733
---@param part_id string

lua/opencode/ui/renderer/ctx.lua

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,12 @@ local ctx = {
2929
bulk_buffer_lines = {},
3030
bulk_extmarks_by_line = {},
3131
bulk_folds = {},
32-
---@type {from: number, to: number}[]
33-
global_folds = {},
34-
---@type table<string, {from: number, to: number}[]>
32+
---@type table<string, { line_start: integer, folds: {from: integer, to: integer}[] }>
33+
---Absolute folds per part, keyed by part_id. Used by set_all_folds /
34+
---update_part_folds to avoid recomputing folds for unchanged parts.
35+
---Invalidation is self-contained: rebuild compares cached.line_start
36+
---against render_state line_start, and clears entries whose
37+
---formatted_parts source has been removed.
3538
part_folds = {},
3639
---@type integer|nil Number of messages to render from the end (nil = all)
3740
lazy_render_count = nil,
@@ -56,7 +59,6 @@ function ctx:reset()
5659
}
5760
self.flush_scheduled = false
5861
self.markdown_render_scheduled = false
59-
self.global_folds = {}
6062
self.part_folds = {}
6163
self:bulk_reset()
6264
end

tests/unit/renderer_buffer_spec.lua

Lines changed: 120 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -197,65 +197,169 @@ describe('renderer.buffer extmarks', function()
197197
end)
198198
end)
199199

200-
describe('update_part_folds', function()
200+
describe('set_all_folds', function()
201201
local set_folds_stub
202202

203203
before_each(function()
204204
ctx:reset()
205205
set_folds_stub = stub(output_window, 'set_folds')
206-
ctx.global_folds = {}
207-
ctx.part_folds = {}
208206
end)
209207

210208
after_each(function()
211209
set_folds_stub:revert()
212210
ctx:reset()
213211
end)
214212

215-
it('computes absolute fold ranges for a single part', function()
213+
it('rebuilds fold ranges from all formatted parts', function()
216214
ctx.formatted_parts['part_a'] = {
217215
lines = { 'title', '', 'content', 'more' },
218216
fold_ranges = { { from = 1, to = 4 } },
219217
}
218+
ctx.formatted_parts['part_b'] = {
219+
lines = { 'b1', 'b2', 'b3', 'b4' },
220+
fold_ranges = { { from = 2, to = 3 } },
221+
}
220222
ctx.render_state:set_part({ id = 'part_a', messageID = 'msg_1', type = 'text' }, 10, 14)
223+
ctx.render_state:set_part({ id = 'part_b', messageID = 'msg_2', type = 'text' }, 20, 24)
221224

222-
buffer.update_part_folds('part_a')
225+
buffer.set_all_folds()
223226

224227
assert.stub(set_folds_stub).was_called_with({
225228
{ from = 10, to = 13 },
229+
{ from = 21, to = 22 },
226230
})
227231
end)
228232

229-
it('skips set_folds when fold ranges have not changed', function()
233+
it('omits folds for parts without cached line_start', function()
234+
ctx.formatted_parts['part_a'] = {
235+
lines = { 'title' },
236+
fold_ranges = { { from = 1, to = 4 } },
237+
}
238+
239+
buffer.set_all_folds()
240+
241+
assert.stub(set_folds_stub).was_called_with({})
242+
end)
243+
244+
it('omits folds for parts removed from formatted_parts', function()
230245
ctx.formatted_parts['part_a'] = {
231246
lines = { 'title', '', 'content', 'more' },
232247
fold_ranges = { { from = 1, to = 4 } },
233248
}
234249
ctx.render_state:set_part({ id = 'part_a', messageID = 'msg_1', type = 'text' }, 10, 14)
235250

236-
buffer.update_part_folds('part_a')
251+
buffer.set_all_folds()
237252
set_folds_stub:clear()
238253

254+
ctx.formatted_parts['part_a'] = nil
255+
buffer.set_all_folds()
256+
257+
assert.stub(set_folds_stub).was_called_with({})
258+
end)
259+
end)
260+
261+
describe('update_part_folds', function()
262+
local set_folds_stub
263+
264+
before_each(function()
265+
ctx:reset()
266+
set_folds_stub = stub(output_window, 'set_folds')
267+
end)
268+
269+
after_each(function()
270+
set_folds_stub:revert()
271+
ctx:reset()
272+
end)
273+
274+
it('reuses cached folds when line_start is unchanged', function()
275+
ctx.formatted_parts['part_a'] = {
276+
lines = { 'title', '', 'content' },
277+
fold_ranges = { { from = 1, to = 3 } },
278+
}
279+
ctx.formatted_parts['part_b'] = {
280+
lines = { 'b1', 'b2', 'b3' },
281+
fold_ranges = { { from = 1, to = 2 } },
282+
}
283+
ctx.render_state:set_part({ id = 'part_a', messageID = 'msg_1', type = 'text' }, 10, 12)
284+
ctx.render_state:set_part({ id = 'part_b', messageID = 'msg_2', type = 'text' }, 20, 22)
285+
239286
buffer.update_part_folds('part_a')
287+
local first_folds_a = ctx.part_folds['part_a'].folds
288+
local first_folds_b = ctx.part_folds['part_b']
289+
set_folds_stub:clear()
290+
291+
buffer.update_part_folds('part_b')
240292

241-
assert.stub(set_folds_stub).was_not_called()
293+
assert.same(first_folds_a, ctx.part_folds['part_a'].folds)
294+
assert.is_nil(first_folds_b)
295+
assert.is_not_nil(ctx.part_folds['part_b'])
242296
end)
243297

244-
it('merges existing folds from other parts', function()
245-
ctx.part_folds['part_b'] = { { from = 5, to = 8 } }
246-
ctx.global_folds = { { from = 5, to = 8 } }
298+
it('invalidates cached folds when line_start changes (shift)', function()
299+
ctx.formatted_parts['part_b'] = {
300+
lines = { 'b1', 'b2', 'b3' },
301+
fold_ranges = { { from = 1, to = 2 } },
302+
}
303+
ctx.render_state:set_part({ id = 'part_b', messageID = 'msg_2', type = 'text' }, 20, 22)
304+
305+
buffer.update_part_folds('part_b')
306+
local original = ctx.part_folds['part_b'].folds
307+
308+
ctx.render_state:set_part({ id = 'part_b', messageID = 'msg_2', type = 'text' }, 50, 52)
309+
310+
buffer.update_part_folds('part_b')
247311

312+
assert.is_not.same(original, ctx.part_folds['part_b'].folds)
313+
assert.equals(20, original[1].from)
314+
assert.equals(50, ctx.part_folds['part_b'].folds[1].from)
315+
end)
316+
317+
it('clears cache entries when source part is removed from formatted_parts', function()
248318
ctx.formatted_parts['part_a'] = {
249-
lines = { 'title', '', 'content', 'more' },
250-
fold_ranges = { { from = 1, to = 4 } },
319+
lines = { 'a' },
320+
fold_ranges = { { from = 1, to = 1 } },
251321
}
252-
ctx.render_state:set_part({ id = 'part_a', messageID = 'msg_1', type = 'text' }, 10, 14)
322+
ctx.formatted_parts['part_b'] = {
323+
lines = { 'b' },
324+
fold_ranges = { { from = 1, to = 1 } },
325+
}
326+
ctx.render_state:set_part({ id = 'part_a', messageID = 'msg_1', type = 'text' }, 10, 10)
327+
ctx.render_state:set_part({ id = 'part_b', messageID = 'msg_2', type = 'text' }, 20, 20)
253328

254329
buffer.update_part_folds('part_a')
330+
buffer.update_part_folds('part_b')
331+
assert.is_not_nil(ctx.part_folds['part_a'])
332+
assert.is_not_nil(ctx.part_folds['part_b'])
255333

334+
ctx.formatted_parts['part_b'] = nil
335+
set_folds_stub:clear()
336+
buffer.update_part_folds('part_a')
337+
338+
assert.is_not_nil(ctx.part_folds['part_a'])
339+
assert.is_nil(ctx.part_folds['part_b'])
256340
assert.stub(set_folds_stub).was_called_with({
257-
{ from = 5, to = 8 },
258-
{ from = 10, to = 13 },
341+
{ from = 10, to = 10 },
342+
})
343+
end)
344+
345+
it('drops cache entry when part has no fold_ranges', function()
346+
ctx.formatted_parts['part_a'] = {
347+
lines = { 'a' },
348+
fold_ranges = { { from = 1, to = 1 } },
349+
}
350+
ctx.formatted_parts['part_b'] = {
351+
lines = { 'b' },
352+
}
353+
ctx.render_state:set_part({ id = 'part_a', messageID = 'msg_1', type = 'text' }, 10, 10)
354+
ctx.render_state:set_part({ id = 'part_b', messageID = 'msg_2', type = 'text' }, 20, 20)
355+
356+
set_folds_stub:clear()
357+
buffer.update_part_folds('part_a')
358+
buffer.update_part_folds('part_b')
359+
360+
assert.is_nil(ctx.part_folds['part_b'])
361+
assert.stub(set_folds_stub).was_called_with({
362+
{ from = 10, to = 10 },
259363
})
260364
end)
261365
end)

0 commit comments

Comments
 (0)