Skip to content

Commit 20e559a

Browse files
committed
perf(folds): improve fold lookup and extmark rendering
This should reduce the lag when streaming big sessions with multiple folds
1 parent b093147 commit 20e559a

4 files changed

Lines changed: 186 additions & 41 deletions

File tree

lua/opencode/ui/output_window.lua

Lines changed: 95 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,65 @@ M._last_visible_bottom_by_win = {}
1111
M._was_at_bottom_by_win = {}
1212
M._prev_line_count_by_win = {}
1313

14+
local function build_fold_state(folds)
15+
local fold_state = {
16+
ranges = {},
17+
starts = {},
18+
}
19+
20+
for _, range in ipairs(folds or {}) do
21+
if range.from and range.to then
22+
fold_state.ranges[#fold_state.ranges + 1] = {
23+
from = range.from,
24+
to = range.to,
25+
}
26+
fold_state.starts[#fold_state.starts + 1] = range.from
27+
end
28+
end
29+
30+
table.sort(fold_state.ranges, function(a, b)
31+
return a.from < b.from
32+
end)
33+
table.sort(fold_state.starts)
34+
35+
return fold_state
36+
end
37+
38+
---@param buf integer
39+
---@return { ranges: table<{from: integer, to: integer}>, starts: integer[] }
40+
local function get_fold_state(buf)
41+
local ok, fold_state = pcall(vim.api.nvim_buf_get_var, buf, 'opencode_folds')
42+
if not ok or type(fold_state) ~= 'table' then
43+
return { ranges = {}, starts = {} }
44+
end
45+
if type(fold_state.ranges) == 'table' and type(fold_state.starts) == 'table' then
46+
return fold_state
47+
end
48+
return build_fold_state(fold_state)
49+
end
50+
51+
---@param ranges table<{from: integer, to: integer}>
52+
---@param line integer
53+
---@return boolean
54+
local function line_in_fold(ranges, line)
55+
local lo = 1
56+
local hi = #ranges
57+
58+
while lo <= hi do
59+
local mid = math.floor((lo + hi) / 2)
60+
local range = ranges[mid]
61+
if line < range.from then
62+
hi = mid - 1
63+
elseif line > range.to then
64+
lo = mid + 1
65+
else
66+
return true
67+
end
68+
end
69+
70+
return false
71+
end
72+
1473
local _update_depth = 0
1574
local _update_buf = nil
1675

@@ -48,7 +107,7 @@ function M.create_buf()
48107
local filetype = config.ui.output.filetype or 'opencode_output'
49108
vim.api.nvim_set_option_value('filetype', filetype, { buf = output_buf })
50109

51-
vim.api.nvim_buf_set_var(output_buf, 'opencode_folds', {})
110+
vim.api.nvim_buf_set_var(output_buf, 'opencode_folds', build_fold_state({}))
52111

53112
local buffixwin = require('opencode.ui.buf_fix_win')
54113
buffixwin.fix_to_win(output_buf, function()
@@ -270,19 +329,8 @@ function M.fold_expr()
270329
end
271330

272331
local line = vim.v.lnum
273-
local ok, folds = pcall(function()
274-
return vim.api.nvim_buf_get_var(output_buf, 'opencode_folds')
275-
end)
276-
if not ok or not folds then
277-
return 0
278-
end
279-
280-
for _, range in ipairs(folds) do
281-
if line >= range.from and line <= range.to then
282-
return 1
283-
end
284-
end
285-
return 0
332+
local fold_state = get_fold_state(output_buf)
333+
return line_in_fold(fold_state.ranges, line) and 1 or 0
286334
end
287335

288336
---Fold text for the output buffer
@@ -296,12 +344,7 @@ function M.fold_text()
296344
return vim.fn.foldtext()
297345
end
298346

299-
local ok, folds = pcall(function()
300-
return vim.api.nvim_buf_get_var(output_buf, 'opencode_folds')
301-
end)
302-
if not ok or not folds then
303-
return vim.fn.foldtext()
304-
end
347+
local folds = get_fold_state(output_buf).ranges
305348

306349
local line_count = 0
307350
for _, range in ipairs(folds) do
@@ -328,10 +371,7 @@ function M.get_open_fold_starts(win, buf)
328371
return {}
329372
end
330373

331-
local ok, prev_folds = pcall(vim.api.nvim_buf_get_var, buf, 'opencode_folds')
332-
if not ok or not prev_folds then
333-
return {}
334-
end
374+
local prev_folds = get_fold_state(buf).ranges
335375

336376
local was_open = {}
337377
vim.api.nvim_win_call(win, function()
@@ -356,12 +396,10 @@ function M.set_folds(fold_ranges)
356396

357397
local buf = windows.output_buf
358398
local win = windows.output_win
359-
local folds = fold_ranges or {}
399+
local folds = build_fold_state(fold_ranges or {})
400+
local prev_folds = get_fold_state(buf)
360401

361-
local ok, prev_folds = pcall(vim.api.nvim_buf_get_var, buf, 'opencode_folds')
362-
prev_folds = ok and prev_folds or {}
363-
364-
if #folds == #prev_folds and vim.deep_equal(prev_folds, folds) then
402+
if vim.deep_equal(prev_folds.ranges, folds.ranges) then
365403
return
366404
end
367405

@@ -371,13 +409,24 @@ function M.set_folds(fold_ranges)
371409

372410
vim.api.nvim_win_call(win, function()
373411
local view = vim.fn.winsaveview()
374-
vim.cmd('silent! normal! zX')
375-
for _, range in ipairs(folds) do
376-
local is_open = was_open[range.from]
377-
local cmd = is_open and 'zo' or 'zc'
412+
vim.cmd('silent! normal! zx')
413+
local prev_starts = {}
414+
for _, start_line in ipairs(prev_folds.starts) do
415+
prev_starts[start_line] = true
416+
end
378417

379-
vim.fn.cursor(range.from, 1)
380-
vim.cmd('silent! normal! ' .. cmd)
418+
for _, range in ipairs(folds.ranges) do
419+
if not prev_starts[range.from] then
420+
vim.fn.cursor(range.from, 1)
421+
vim.cmd('silent! normal! zc')
422+
end
423+
end
424+
425+
for _, range in ipairs(folds.ranges) do
426+
if was_open[range.from] then
427+
vim.fn.cursor(range.from, 1)
428+
vim.cmd('silent! normal! zo')
429+
end
381430
end
382431

383432
vim.fn.winrestview(view)
@@ -393,10 +442,8 @@ function M.shift_folds(start_line, delta)
393442
return
394443
end
395444
local buf = windows.output_buf
396-
local ok, folds = pcall(vim.api.nvim_buf_get_var, buf, 'opencode_folds')
397-
if not ok or not folds then
398-
return
399-
end
445+
local fold_state = get_fold_state(buf)
446+
local folds = fold_state.ranges
400447

401448
for _, range in ipairs(folds) do
402449
if range.from > start_line then
@@ -415,6 +462,15 @@ function M.shift_folds(start_line, delta)
415462
range.to = range.from
416463
end
417464
end
465+
466+
table.sort(folds, function(a, b)
467+
return a.from < b.from
468+
end)
469+
470+
fold_state.starts = {}
471+
for _, range in ipairs(folds) do
472+
fold_state.starts[#fold_state.starts + 1] = range.from
473+
end
418474
end
419475

420476
---@return integer

lua/opencode/ui/renderer/buffer.lua

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,27 @@ local function apply_extmarks(previous_formatted, formatted_data, line_start, ol
276276
end
277277
end
278278

279+
---@param previous_formatted Output|nil
280+
---@param formatted_data Output
281+
---@param line_start integer
282+
---@param old_line_end integer
283+
---@param new_line_end integer
284+
local function apply_appended_extmarks(previous_formatted, formatted_data, line_start, old_line_end, new_line_end)
285+
local clear_start, clear_end = extmark_clear_range(previous_formatted, formatted_data, line_start, old_line_end, new_line_end)
286+
clear_start = math.max(clear_start, old_line_end + 1)
287+
if clear_start >= clear_end then
288+
return
289+
end
290+
291+
output_window.clear_extmarks(clear_start, clear_end)
292+
293+
local extmark_start_line = math.max(0, clear_start - line_start)
294+
local extmarks = slice_extmarks(formatted_data.extmarks, extmark_start_line)
295+
if has_extmarks(extmarks) then
296+
output_window.set_extmarks(extmarks, clear_start)
297+
end
298+
end
299+
279300
---@param message_id string
280301
---@return integer
281302
local function get_message_insert_line(message_id)
@@ -727,7 +748,7 @@ function M.append_part_now(part_id, extra_lines, extra_extmarks, previous_format
727748
local formatted_data = ctx.formatted_parts[part_id]
728749
if formatted_data then
729750
apply_part_actions(part_id, formatted_data, cached.line_start)
730-
apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end)
751+
apply_appended_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end)
731752
set_part_extmark_state(part_id, formatted_data)
732753
if formatted_data.fold_ranges then
733754
M.update_part_folds(part_id)

tests/unit/output_window_spec.lua

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,14 +165,49 @@ describe('output_window.setup', function()
165165
assert.equals(1, foldclosed)
166166
end)
167167

168+
it('stores fold metadata in a lookup-friendly structure', function()
169+
output_window.setup({ output_buf = buf, output_win = win })
170+
output_window.set_folds({ { from = 3, to = 5 }, { from = 1, to = 2 } })
171+
172+
local folds = vim.api.nvim_buf_get_var(buf, 'opencode_folds')
173+
174+
assert.same({
175+
ranges = {
176+
{ from = 1, to = 2 },
177+
{ from = 3, to = 5 },
178+
},
179+
starts = { 1, 3 },
180+
}, folds)
181+
end)
182+
168183
it('does not expand a fold when inserting after its end', function()
169184
output_window.setup({ output_buf = buf, output_win = win })
170185
output_window.set_folds({ { from = 1, to = 3 } })
171186

172187
output_window.shift_folds(3, 4)
173188

174189
local folds = vim.api.nvim_buf_get_var(buf, 'opencode_folds')
175-
assert.same({ { from = 1, to = 3 } }, folds)
190+
assert.same({
191+
ranges = { { from = 1, to = 3 } },
192+
starts = { 1 },
193+
}, folds)
194+
end)
195+
196+
it('evaluates fold_expr against the fold lookup structure', function()
197+
output_window.setup({ output_buf = buf, output_win = win })
198+
output_window.set_folds({ { from = 2, to = 4 } })
199+
200+
local inside = vim.api.nvim_win_call(win, function()
201+
vim.v.lnum = 3
202+
return output_window.fold_expr()
203+
end)
204+
local outside = vim.api.nvim_win_call(win, function()
205+
vim.v.lnum = 5
206+
return output_window.fold_expr()
207+
end)
208+
209+
assert.equals(1, inside)
210+
assert.equals(0, outside)
176211
end)
177212
end)
178213

tests/unit/renderer_buffer_spec.lua

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,39 @@ describe('renderer.buffer extmarks', function()
125125
assert.stub(clear_extmarks_stub).was_called()
126126
assert_called_before(call_order, 'clear_extmarks', 'set_lines')
127127
end)
128+
129+
it('only clears and reapplies appended extmarks during append-only updates', function()
130+
ctx.render_state:set_part({ id = 'part_1', messageID = 'msg_1', type = 'text' }, 10, 11)
131+
ctx.formatted_parts['part_1'] = {
132+
lines = { 'alpha', 'beta', 'gamma' },
133+
extmarks = {
134+
[0] = {
135+
{ line_hl_group = 'ExistingHighlight' },
136+
},
137+
[2] = {
138+
{ line_hl_group = 'AppendedHighlight' },
139+
},
140+
},
141+
actions = {},
142+
}
143+
144+
buffer.append_part_now('part_1', { 'gamma' }, nil, {
145+
lines = { 'alpha', 'beta' },
146+
extmarks = {
147+
[0] = {
148+
{ line_hl_group = 'ExistingHighlight' },
149+
},
150+
},
151+
actions = {},
152+
})
153+
154+
assert.stub(clear_extmarks_stub).was_called_with(12, 13)
155+
assert.stub(set_extmarks_stub).was_called_with({
156+
[0] = {
157+
{ line_hl_group = 'AppendedHighlight' },
158+
},
159+
}, 12)
160+
end)
128161
end)
129162

130163
describe('update_part_folds', function()

0 commit comments

Comments
 (0)