diff --git a/lua/opencode/state/ui.lua b/lua/opencode/state/ui.lua index a6a33bd0..0c6d1e8a 100644 --- a/lua/opencode/state/ui.lua +++ b/lua/opencode/state/ui.lua @@ -25,6 +25,7 @@ local store = require('opencode.state.store') ---@field output_buf integer|nil ---@field output_was_at_bottom boolean|nil ---@field position 'right'|'left'|'current'|'float'|nil +---@field output_folds { ranges: {from: integer, to: integer}[] }|nil ---@class OpencodeUiStateMutations local M = {} @@ -40,6 +41,28 @@ function M.clear_windows() return store.set('windows', nil) end +---@param folds { ranges: {from: integer, to: integer}[] }|nil +function M.set_output_folds(folds) + return store.mutate('windows', function(win) + if win then + win.output_folds = folds + end + end) +end + +---@return { ranges: {from: integer, to: integer}[] } +function M.get_output_folds() + local win = store.get('windows') + if win and win.output_folds then + return win.output_folds + end + return { ranges = {} } +end + +function M.clear_output_folds() + return M.set_output_folds(nil) +end + ---@param is_opening boolean function M.set_opening(is_opening) return store.set('is_opening', is_opening) diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index 5bda4196..7f15b879 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -39,19 +39,6 @@ local function build_fold_state(folds) return fold_state end ----@param buf integer ----@return { ranges: table<{from: integer, to: integer}> } -local function get_fold_state(buf) - local ok, fold_state = pcall(vim.api.nvim_buf_get_var, buf, 'opencode_folds') - if not ok or type(fold_state) ~= 'table' then - return { ranges = {} } - end - if type(fold_state.ranges) == 'table' then - return fold_state - end - return build_fold_state(fold_state) -end - local _update_depth = 0 local _update_buf = nil @@ -87,7 +74,7 @@ end function M.create_buf() local output_buf = vim.api.nvim_create_buf(false, true) - vim.api.nvim_buf_set_var(output_buf, 'opencode_folds', build_fold_state({})) + state.ui.set_output_folds(build_fold_state({})) local buffixwin = require('opencode.ui.buf_fix_win') buffixwin.fix_to_win(output_buf, function() @@ -354,7 +341,7 @@ function M.fold_text() return vim.fn.foldtext() end - local folds = get_fold_state(output_buf).ranges + local folds = state.ui.get_output_folds().ranges local line_count = 0 for _, range in ipairs(folds) do @@ -380,7 +367,7 @@ function M.get_open_fold_starts(win, buf) return {} end - local prev_folds = get_fold_state(buf).ranges + local prev_folds = state.ui.get_output_folds().ranges local was_open = {} vim.api.nvim_win_call(win, function() @@ -395,7 +382,7 @@ function M.get_open_fold_starts(win, buf) end ---Set the folds for the output buffer ----@param fold_ranges table<{from: number, to: number}> +---@param fold_ranges {from: number, to: number}[] function M.set_folds(fold_ranges) local windows = state.windows if not M.mounted() then @@ -406,7 +393,7 @@ function M.set_folds(fold_ranges) local buf = windows.output_buf local win = windows.output_win local folds = build_fold_state(fold_ranges or {}) - local prev_folds = get_fold_state(buf) + local prev_folds = state.ui.get_output_folds() if vim.deep_equal(prev_folds.ranges, folds.ranges) then return @@ -414,23 +401,31 @@ function M.set_folds(fold_ranges) local was_open = M.get_open_fold_starts(win, buf) local preserve_view = not M.is_at_bottom(win) - vim.api.nvim_buf_set_var(buf, 'opencode_folds', folds) + state.ui.set_output_folds(folds) vim.api.nvim_win_call(win, function() local view = preserve_view and vim.fn.winsaveview() or nil local line_count = vim.api.nvim_buf_line_count(buf) + local fold_commands = {} for _, range in ipairs(folds.ranges) do if range.from <= line_count and range.to <= line_count then - vim.cmd(range.from .. ',' .. range.to .. 'fold') + fold_commands[#fold_commands + 1] = range.from .. ',' .. range.to .. 'fold' end end + if #fold_commands > 0 then + vim.api.nvim_exec2(table.concat(fold_commands, ' | '), { output = false }) + end + local open_commands = {} for _, range in ipairs(folds.ranges) do - if was_open[range.from] then - vim.cmd(range.from .. ',' .. range.to .. 'foldopen!') + if was_open[range.from] and range.from <= line_count and range.to <= line_count then + open_commands[#open_commands + 1] = range.from .. ',' .. range.to .. 'foldopen!' end end + if #open_commands > 0 then + vim.api.nvim_exec2(table.concat(open_commands, ' | '), { output = false }) + end if view then vim.fn.winrestview(view) @@ -446,8 +441,7 @@ function M.shift_folds(start_line, delta) if not windows or not windows.output_buf then return end - local buf = windows.output_buf - local fold_state = get_fold_state(buf) + local fold_state = state.ui.get_output_folds() local folds = fold_state.ranges for _, range in ipairs(folds) do @@ -471,11 +465,6 @@ function M.shift_folds(start_line, delta) table.sort(folds, function(a, b) return a.from < b.from end) - - fold_state.starts = {} - for _, range in ipairs(folds) do - fold_state.starts[#fold_state.starts + 1] = range.from - end end ---@return integer @@ -610,11 +599,12 @@ end ---@param start_line integer ---@param end_line integer function M.highlight_changed_lines(start_line, end_line) - local windows = state.windows - if not windows or not windows.output_buf or not vim.api.nvim_buf_is_valid(windows.output_buf) then + if not config.debug.highlight_changed_lines then return end - if not config.debug.highlight_changed_lines then + + local windows = state.windows + if not windows or not windows.output_buf or not vim.api.nvim_buf_is_valid(windows.output_buf) then return end diff --git a/lua/opencode/ui/renderer/append.lua b/lua/opencode/ui/renderer/append.lua deleted file mode 100644 index 52fbd045..00000000 --- a/lua/opencode/ui/renderer/append.lua +++ /dev/null @@ -1,43 +0,0 @@ -local M = {} - ----@param old_lines string[] ----@param new_lines string[] ----@return boolean -function M.is_append_only(old_lines, new_lines) - local old_count = #old_lines - if #new_lines <= old_count then - return false - end - - for i = old_count, 1, -1 do - if old_lines[i] ~= new_lines[i] then - return false - end - end - - return true -end - ----@param old_lines string[] ----@param new_lines string[] ----@return string[] -function M.tail_lines(old_lines, new_lines) - return vim.list_slice(new_lines, #old_lines + 1, #new_lines) -end - ----@param row_offset integer ----@param extmarks table|nil ----@return table -function M.tail_extmarks(row_offset, extmarks) - local tail = {} - - for line_idx, marks in pairs(extmarks or {}) do - if line_idx >= row_offset then - tail[line_idx - row_offset] = vim.deepcopy(marks) - end - end - - return tail -end - -return M diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index 3ff9fa04..cb4160b9 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -1,6 +1,7 @@ local ctx = require('opencode.ui.renderer.ctx') local state = require('opencode.state') local output_window = require('opencode.ui.output_window') +local diff = require('opencode.ui.renderer.output_diff') local M = {} @@ -68,131 +69,6 @@ local function has_actions(actions) return type(actions) == 'table' and #actions > 0 end ----@param previous_formatted Output|nil ----@param formatted_data Output ----@return integer -local function unchanged_prefix_len(previous_formatted, formatted_data) - local previous_lines = previous_formatted and previous_formatted.lines or {} - local next_lines = formatted_data and formatted_data.lines or {} - local prefix_len = 0 - - for i = 1, math.min(#previous_lines, #next_lines) do - if previous_lines[i] ~= next_lines[i] then - break - end - prefix_len = i - end - - return prefix_len -end - ----@param lines string[]|nil ----@param start_idx integer ----@return string[] -local function slice_lines(lines, start_idx) - local slice = {} - for i = start_idx, #(lines or {}) do - slice[#slice + 1] = lines[i] - end - return slice -end - ----@param extmarks table|nil ----@param start_line integer ----@return table -local function slice_extmarks(extmarks, start_line) - local slice = {} - for line_idx, marks in pairs(extmarks or {}) do - if line_idx < 0 then - slice[line_idx] = vim.deepcopy(marks) - elseif line_idx >= start_line then - slice[line_idx - start_line] = vim.deepcopy(marks) - end - end - return slice -end - ----@param folds table<{from: number, to: number}>|nil ----@param start_line integer ----@return table<{from: number, to: number}> -local function slice_folds(folds, start_line) - local slice = {} - for i, range in ipairs(folds or {}) do - if range.from >= start_line then - table.insert(slice, { - from = range.from - start_line, - to = range.to - start_line, - }) - end - end - return slice -end - ----@param mark OutputExtmark|fun(): OutputExtmark ----@return OutputExtmark -local function resolve_mark(mark) - return type(mark) == 'function' and mark() or mark -end - ----@param a (OutputExtmark|fun(): OutputExtmark)[]|nil ----@param b (OutputExtmark|fun(): OutputExtmark)[]|nil ----@return boolean -local function marks_equal(a, b) - a = a or {} - b = b or {} - - if #a ~= #b then - return false - end - - for i = 1, #a do - if not vim.deep_equal(resolve_mark(a[i]), resolve_mark(b[i])) then - return false - end - end - - return true -end - ----@param previous_formatted Output|nil ----@param formatted_data Output ----@return integer -local function unchanged_extmark_prefix_len(previous_formatted, formatted_data) - local previous_extmarks = previous_formatted and previous_formatted.extmarks or {} - local next_extmarks = formatted_data and formatted_data.extmarks or {} - - for line_idx, _ in pairs(previous_extmarks) do - if line_idx < 0 and not marks_equal(previous_extmarks[line_idx], next_extmarks[line_idx]) then - return 0 - end - end - - for line_idx, _ in pairs(next_extmarks) do - if line_idx < 0 and not marks_equal(previous_extmarks[line_idx], next_extmarks[line_idx]) then - return 0 - end - end - - local previous_lines = previous_formatted and previous_formatted.lines or {} - local next_lines = formatted_data and formatted_data.lines or {} - local max_lines = math.max(#previous_lines, #next_lines) - local prefix_len = 0 - - for line_idx = 0, math.max(max_lines - 1, 0) do - local previous_marks = previous_formatted and previous_formatted.extmarks and previous_formatted.extmarks[line_idx] - or nil - local next_marks = formatted_data and formatted_data.extmarks and formatted_data.extmarks[line_idx] or nil - - if not marks_equal(previous_marks, next_marks) then - break - end - - prefix_len = line_idx + 1 - end - - return prefix_len -end - ---@param start_line integer ---@param lines string[] local function highlight_written_lines(start_line, lines) @@ -202,56 +78,40 @@ local function highlight_written_lines(start_line, lines) output_window.highlight_changed_lines(start_line, start_line + #lines - 1) end +---Compute the [clear_start, clear_end) buffer-line range whose extmarks +---need to be wiped before re-applying the new set. ---@param previous_formatted Output|nil ---@param formatted_data Output ---@param line_start integer ---@param old_line_end integer ---@param new_line_end integer +---@param prefix_len integer already-computed unchanged line prefix from caller ---@return integer clear_start ---@return integer clear_end -local function extmark_clear_range(previous_formatted, formatted_data, line_start, old_line_end, new_line_end) - local prefix_len = math.min( - unchanged_prefix_len(previous_formatted, formatted_data), - unchanged_extmark_prefix_len(previous_formatted, formatted_data) - ) - - ---@param formatted Output|nil - ---@return integer|nil - local function min_extmark_line(formatted) - local min_line = nil - for line_idx in pairs(formatted and formatted.extmarks or {}) do - if min_line == nil or line_idx < min_line then - min_line = line_idx - end - end - return min_line - end - - ---@param formatted Output|nil - ---@param fallback integer - ---@return integer - local function max_extmark_line(formatted, fallback) - local max_line = fallback - for line_idx in pairs(formatted and formatted.extmarks or {}) do - max_line = math.max(max_line, line_start + line_idx) - end - return max_line - end - - local clear_start = line_start + prefix_len - local previous_min_extmark = min_extmark_line(previous_formatted) - local next_min_extmark = min_extmark_line(formatted_data) - if previous_min_extmark ~= nil then - clear_start = math.min(clear_start, line_start + previous_min_extmark) - end - if next_min_extmark ~= nil then - clear_start = math.min(clear_start, line_start + next_min_extmark) +local function extmark_clear_range( + previous_formatted, + formatted_data, + line_start, + old_line_end, + new_line_end, + prefix_len +) + local effective_prefix = math.min(prefix_len, diff.unchanged_prefix_extmarks(previous_formatted, formatted_data)) + + local clear_start = line_start + effective_prefix + local previous_min = diff.min_extmark_line(previous_formatted) + local next_min = diff.min_extmark_line(formatted_data) + if previous_min ~= nil then + clear_start = math.min(clear_start, line_start + previous_min) + end + if next_min ~= nil then + clear_start = math.min(clear_start, line_start + next_min) end clear_start = math.max(0, clear_start) local clear_end = math.max( - max_extmark_line(previous_formatted, old_line_end), - max_extmark_line(formatted_data, new_line_end) + diff.max_extmark_line(previous_formatted, old_line_end - line_start) + line_start, + diff.max_extmark_line(formatted_data, new_line_end - line_start) + line_start ) + 1 return clear_start, clear_end @@ -262,16 +122,25 @@ end ---@param line_start integer ---@param old_line_end integer ---@param new_line_end integer +---@param prefix_len integer ---@param skip_clear? boolean -local function apply_extmarks(previous_formatted, formatted_data, line_start, old_line_end, new_line_end, skip_clear) +local function apply_extmarks( + previous_formatted, + formatted_data, + line_start, + old_line_end, + new_line_end, + prefix_len, + skip_clear +) local clear_start, clear_end = - extmark_clear_range(previous_formatted, formatted_data, line_start, old_line_end, new_line_end) + extmark_clear_range(previous_formatted, formatted_data, line_start, old_line_end, new_line_end, prefix_len) if not skip_clear then output_window.clear_extmarks(clear_start, clear_end) end local extmark_start_line = math.max(0, clear_start - line_start) - local extmarks = slice_extmarks(formatted_data.extmarks, extmark_start_line) + local extmarks = diff.slice_extmarks(formatted_data.extmarks, extmark_start_line) if has_extmarks(extmarks) then output_window.set_extmarks(extmarks, clear_start) end @@ -282,9 +151,17 @@ end ---@param line_start integer ---@param old_line_end integer ---@param new_line_end integer -local function apply_appended_extmarks(previous_formatted, formatted_data, line_start, old_line_end, new_line_end) +---@param prefix_len integer +local function apply_appended_extmarks( + previous_formatted, + formatted_data, + line_start, + old_line_end, + new_line_end, + prefix_len +) local clear_start, clear_end = - extmark_clear_range(previous_formatted, formatted_data, line_start, old_line_end, new_line_end) + extmark_clear_range(previous_formatted, formatted_data, line_start, old_line_end, new_line_end, prefix_len) clear_start = math.max(clear_start, old_line_end + 1) if clear_start >= clear_end then return @@ -293,12 +170,37 @@ local function apply_appended_extmarks(previous_formatted, formatted_data, line_ output_window.clear_extmarks(clear_start, clear_end) local extmark_start_line = math.max(0, clear_start - line_start) - local extmarks = slice_extmarks(formatted_data.extmarks, extmark_start_line) + local extmarks = diff.slice_extmarks(formatted_data.extmarks, extmark_start_line) if has_extmarks(extmarks) then output_window.set_extmarks(extmarks, clear_start) end end +---Shared in-place upsert path: compute prefix/write range, clear stale extmarks, +---write the tail of `formatted_data.lines` into the buffer at `cached.line_start`. +---Returns the dimensions the caller needs to apply extmarks and update render_state. +---@param cached {line_start: integer, line_end: integer} +---@param previous_formatted Output|nil +---@param formatted_data Output +---@return integer prefix_len +---@return integer old_line_end +---@return integer new_line_end +local function write_in_place(cached, previous_formatted, formatted_data) + local old_line_end = cached.line_end + local new_line_end = cached.line_start + #formatted_data.lines - 1 + local prefix_len = diff.unchanged_prefix_lines(previous_formatted, formatted_data) + local write_start = cached.line_start + prefix_len + local lines_to_write = diff.slice_lines(formatted_data.lines, prefix_len + 1) + local clear_start, clear_end = + extmark_clear_range(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end, prefix_len) + + output_window.clear_extmarks(clear_start, clear_end) + output_window.set_lines(lines_to_write, write_start, cached.line_end + 1) + highlight_written_lines(write_start, lines_to_write) + + return prefix_len, old_line_end, new_line_end +end + ---@param message_id string ---@return integer local function get_message_insert_line(message_id) @@ -433,11 +335,9 @@ end ---@param formatted_data Output ---@param line_start integer local function apply_part_render_data(part_id, formatted_data, line_start) + ctx.render_state:clear_actions(part_id) if has_actions(formatted_data.actions) then - ctx.render_state:clear_actions(part_id) ctx.render_state:add_actions(part_id, vim.deepcopy(formatted_data.actions), line_start) - else - ctx.render_state:clear_actions(part_id) end ctx.render_state:clear_targets(part_id) @@ -514,24 +414,9 @@ function M.upsert_message_now(message_id, formatted_data, previous_formatted) local cached = ctx.render_state:get_message(message_id) if cached and cached.line_start and cached.line_end then - local old_line_end = cached.line_end - local prefix_len = unchanged_prefix_len(previous_formatted, formatted_data) - local write_start = cached.line_start + prefix_len - local lines_to_write = slice_lines(formatted_data.lines, prefix_len + 1) - local clear_start, clear_end = extmark_clear_range( - previous_formatted, - formatted_data, - cached.line_start, - old_line_end, - cached.line_start + #formatted_data.lines - 1 - ) - - output_window.clear_extmarks(clear_start, clear_end) - output_window.set_lines(lines_to_write, write_start, cached.line_end + 1) - highlight_written_lines(write_start, lines_to_write) + local prefix_len, old_line_end, new_line_end = write_in_place(cached, previous_formatted, formatted_data) - local new_line_end = cached.line_start + #formatted_data.lines - 1 - apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end, true) + apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end, prefix_len, true) ctx.render_state:set_message(cached.message, cached.line_start, new_line_end) local delta = new_line_end - old_line_end @@ -590,29 +475,16 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt local cached = ctx.render_state:get_part(part_id) if cached and cached.line_start and cached.line_end then - local old_line_end = cached.line_end - local prefix_len = unchanged_prefix_len(previous_formatted, formatted_data) - local write_start = cached.line_start + prefix_len - local lines_to_write = slice_lines(formatted_data.lines, prefix_len + 1) - local clear_start, clear_end = extmark_clear_range( - previous_formatted, - formatted_data, - cached.line_start, - old_line_end, - cached.line_start + #formatted_data.lines - 1 - ) + local prefix_len, old_line_end, new_line_end = write_in_place(cached, previous_formatted, formatted_data) - output_window.clear_extmarks(clear_start, clear_end) - output_window.set_lines(lines_to_write, write_start, cached.line_end + 1) - highlight_written_lines(write_start, lines_to_write) - - local new_line_end = cached.line_start + #formatted_data.lines - 1 apply_part_render_data(part_id, formatted_data, cached.line_start) if new_line_end ~= cached.line_end then + local delta = new_line_end - old_line_end ctx.render_state:update_part_lines(part_id, cached.line_start, new_line_end) + output_window.shift_folds(old_line_end + 1, delta) end - apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end, true) + apply_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end, prefix_len, true) if formatted_data.fold_ranges and #formatted_data.fold_ranges > 0 then M.update_part_folds(part_id) @@ -714,9 +586,17 @@ function M.update_part_folds(part_id) ctx.part_folds[part_id] = new_folds local new_global = {} - for _, pf in pairs(ctx.part_folds) do - for _, f in ipairs(pf) do - table.insert(new_global, f) + for pid, data in pairs(ctx.formatted_parts) do + if data.fold_ranges then + local p = ctx.render_state:get_part(pid) + if p and p.line_start then + for _, f in ipairs(data.fold_ranges) do + table.insert(new_global, { + from = p.line_start + f.from - 1, + to = p.line_start + f.to - 1, + }) + end + end end end table.sort(new_global, function(a, b) @@ -744,11 +624,20 @@ function M.append_part_now(part_id, extra_lines, extra_extmarks, previous_format local new_line_end = cached.line_end + #extra_lines ctx.render_state:update_part_lines(part_id, cached.line_start, new_line_end) + output_window.shift_folds(insert_at, #extra_lines) local formatted_data = ctx.formatted_parts[part_id] if formatted_data then apply_part_render_data(part_id, formatted_data, cached.line_start) - apply_appended_extmarks(previous_formatted, formatted_data, cached.line_start, old_line_end, new_line_end) + local prefix_len = diff.unchanged_prefix_lines(previous_formatted, formatted_data) + apply_appended_extmarks( + previous_formatted, + formatted_data, + cached.line_start, + old_line_end, + new_line_end, + prefix_len + ) if formatted_data.fold_ranges then M.update_part_folds(part_id) end @@ -771,6 +660,7 @@ function M.remove_part_now(part_id) local cached = ctx.render_state:get_part(part_id) if not cached or not cached.line_start or not cached.line_end then ctx.render_state:remove_part(part_id) + ctx.part_folds[part_id] = nil return end @@ -779,6 +669,7 @@ function M.remove_part_now(part_id) local delta = -(cached.line_end - cached.line_start + 1) output_window.shift_folds(cached.line_start, delta) ctx.render_state:remove_part(part_id) + ctx.part_folds[part_id] = nil end ---@param message_id string diff --git a/lua/opencode/ui/renderer/ctx.lua b/lua/opencode/ui/renderer/ctx.lua index f8e552cc..65ea0757 100644 --- a/lua/opencode/ui/renderer/ctx.lua +++ b/lua/opencode/ui/renderer/ctx.lua @@ -13,21 +13,22 @@ local ctx = { ---@type table formatted_messages = {}, pending = { - dirty_message_order = {}, - dirty_messages = {}, - dirty_part_by_message = {}, - dirty_part_order = {}, - dirty_parts = {}, - removed_part_order = {}, - removed_parts = {}, - removed_message_order = {}, - removed_messages = {}, + dirty_message_order = {}, ---@type string[] + dirty_messages = {}, ---@type table + dirty_part_by_message = {}, ---@type table + dirty_part_order = {}, ---@type string[] + dirty_parts = {}, ---@type table + removed_part_order = {}, ---@type string[] + removed_parts = {}, ---@type table + removed_message_order = {}, ---@type string[] + removed_messages = {}, ---@type table }, - flush_scheduled = false, - markdown_render_scheduled = false, - bulk_mode = false, + flush_scheduled = false, ---@type boolean + markdown_render_scheduled = false, ---@type boolean + bulk_mode = false, ---@type boolean bulk_buffer_lines = {}, bulk_extmarks_by_line = {}, + ---@type {from: number, to: number}[] bulk_folds = {}, ---@type {from: number, to: number}[] global_folds = {}, diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index 1fa54ce5..652ce809 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -7,7 +7,7 @@ local output_window = require('opencode.ui.output_window') local ctx = require('opencode.ui.renderer.ctx') local scroll = require('opencode.ui.renderer.scroll') local buffer = require('opencode.ui.renderer.buffer') -local append = require('opencode.ui.renderer.append') +local output_diff = require('opencode.ui.renderer.output_diff') local M = {} local warned_part_render_error = false @@ -87,54 +87,6 @@ local function with_suppressed_output_autocmds(fn) return result end ----@param a string[]|nil ----@param b string[]|nil ----@return boolean -local function lines_equal(a, b) - a = a or {} - b = b or {} - if #a ~= #b then - return false - end - for i = 1, #a do - if a[i] ~= b[i] then - return false - end - end - return true -end - ----@param m OutputExtmark|fun(): OutputExtmark ----@return OutputExtmark -local function resolve_mark(m) - return type(m) == 'function' and m() or m -end - ----@param a table|nil ----@param b table|nil ----@return boolean -local function extmarks_equal(a, b) - a = a or {} - b = b or {} - for k, va in pairs(a) do - local vb = b[k] - if not vb or #va ~= #vb then - return false - end - for i = 1, #va do - if not vim.deep_equal(resolve_mark(va[i]), resolve_mark(vb[i])) then - return false - end - end - end - for k in pairs(b) do - if not a[k] then - return false - end - end - return true -end - ---@return boolean local function is_markdown_render_deferred() if not config.ui.output.rendering.markdown_on_idle then @@ -302,20 +254,19 @@ local function new_formatter_context() end ---@param message_id string +---@param prev Output|nil ---@return Output|nil -local function format_message(message_id) +local function format_message(message_id, prev) local rendered_message = ctx.render_state:get_message(message_id) local message = rendered_message and rendered_message.message if not message then return nil end - local prev = ctx.formatted_messages[message_id] local previous_rendered = ctx.render_state:get_previous_message(state.messages or {}, message_id) local formatted = formatter.format_message_header(message, previous_rendered and previous_rendered.message or nil) - if prev and lines_equal(prev.lines, formatted.lines) and extmarks_equal(prev.extmarks, formatted.extmarks) then - -- no visible change + if output_diff.is_unchanged(prev, formatted) then return nil end @@ -352,7 +303,7 @@ end ---@param message_id string local function apply_message(message_id) local previous = ctx.formatted_messages[message_id] - local formatted = format_message(message_id) + local formatted = format_message(message_id, previous) if not formatted then return end @@ -375,16 +326,17 @@ local function apply_part(part_id, message_id, render_context) and cached and cached.line_start and cached.line_end - and append.is_append_only(previous.lines or {}, formatted.lines or {}) + and output_diff.is_append_only(previous.lines or {}, formatted.lines or {}) ctx.formatted_parts[part_id] = formatted ctx.last_part_formatted = { part_id = part_id, formatted_data = formatted } if can_append then + local tail_offset = #(previous.lines or {}) buffer.append_part_now( part_id, - append.tail_lines(previous.lines or {}, formatted.lines or {}), - append.tail_extmarks(#(previous.lines or {}), formatted.extmarks), + output_diff.slice_lines(formatted.lines, tail_offset + 1), + output_diff.slice_extmarks(formatted.extmarks, tail_offset), previous ) return diff --git a/lua/opencode/ui/renderer/output_diff.lua b/lua/opencode/ui/renderer/output_diff.lua new file mode 100644 index 00000000..7314ff03 --- /dev/null +++ b/lua/opencode/ui/renderer/output_diff.lua @@ -0,0 +1,153 @@ +---Shared diff primitives over `Output` for the renderer pipeline. + +local M = {} + +---@param mark OutputExtmark|fun(): OutputExtmark +---@return OutputExtmark +local function resolve_mark(mark) + return type(mark) == 'function' and mark() or mark +end + +---@param a (OutputExtmark|fun(): OutputExtmark)[]|nil +---@param b (OutputExtmark|fun(): OutputExtmark)[]|nil +---@return boolean +local function marks_equal(a, b) + a = a or {} + b = b or {} + if #a ~= #b then + return false + end + for i = 1, #a do + if not vim.deep_equal(resolve_mark(a[i]), resolve_mark(b[i])) then + return false + end + end + return true +end + +---Negative keys (line_idx < 0) are header virt_text; any disagreement +---forces the prefix to 0. +---@param previous_formatted Output|nil +---@param formatted_data Output|nil +---@return integer +function M.unchanged_prefix_extmarks(previous_formatted, formatted_data) + local previous_extmarks = previous_formatted and previous_formatted.extmarks or {} + local next_extmarks = formatted_data and formatted_data.extmarks or {} + + for line_idx in pairs(previous_extmarks) do + if line_idx < 0 and not marks_equal(previous_extmarks[line_idx], next_extmarks[line_idx]) then + return 0 + end + end + for line_idx in pairs(next_extmarks) do + if line_idx < 0 and not marks_equal(previous_extmarks[line_idx], next_extmarks[line_idx]) then + return 0 + end + end + + local previous_lines = previous_formatted and previous_formatted.lines or {} + local next_lines = formatted_data and formatted_data.lines or {} + local max_lines = math.max(#previous_lines, #next_lines) + local prefix_len = 0 + for line_idx = 0, max_lines - 1 do + if not marks_equal(previous_extmarks[line_idx], next_extmarks[line_idx]) then + break + end + prefix_len = line_idx + 1 + end + return prefix_len +end + +---@param previous_formatted Output|nil +---@param formatted_data Output|nil +---@return integer +function M.unchanged_prefix_lines(previous_formatted, formatted_data) + local previous_lines = previous_formatted and previous_formatted.lines or {} + local next_lines = formatted_data and formatted_data.lines or {} + local prefix_len = 0 + for i = 1, math.min(#previous_lines, #next_lines) do + if previous_lines[i] ~= next_lines[i] then + break + end + prefix_len = i + end + return prefix_len +end + +---@param formatted Output|nil +---@return integer|nil +function M.min_extmark_line(formatted) + local min_line + for line_idx in pairs(formatted and formatted.extmarks or {}) do + if min_line == nil or line_idx < min_line then + min_line = line_idx + end + end + return min_line +end + +---@param formatted Output|nil +---@param fallback integer +---@return integer +function M.max_extmark_line(formatted, fallback) + local max_line = fallback + for line_idx in pairs(formatted and formatted.extmarks or {}) do + if line_idx > max_line then + max_line = line_idx + end + end + return max_line +end + +---@param lines string[]|nil +---@param start_idx integer +---@return string[] +function M.slice_lines(lines, start_idx) + local slice = {} + for i = start_idx, #(lines or {}) do + slice[#slice + 1] = lines[i] + end + return slice +end + +---Preserves negative keys untouched, shifts non-negative keys by -start_line. +---@param extmarks table|nil +---@param start_line integer +---@return table +function M.slice_extmarks(extmarks, start_line) + local slice = {} + for line_idx, marks in pairs(extmarks or {}) do + if line_idx < 0 then + slice[line_idx] = vim.deepcopy(marks) + elseif line_idx >= start_line then + slice[line_idx - start_line] = vim.deepcopy(marks) + end + end + return slice +end + +---@param previous Output|nil +---@param formatted Output|nil +---@return boolean +function M.is_unchanged(previous, formatted) + if not previous or not formatted or #previous.lines ~= #formatted.lines then + return false + end + if M.unchanged_prefix_lines(previous, formatted) ~= #previous.lines then + return false + end + return M.unchanged_prefix_extmarks(previous, formatted) >= #previous.lines +end + +---@param old_lines string[] +---@param new_lines string[] +---@return boolean +function M.is_append_only(old_lines, new_lines) + if #new_lines <= #old_lines then + return false + end + local prefix_len = M.unchanged_prefix_lines({ lines = old_lines }, { lines = new_lines }) + return prefix_len == #old_lines +end + +return M \ No newline at end of file diff --git a/tests/unit/output_window_spec.lua b/tests/unit/output_window_spec.lua index 512a0ee1..e5d1b21e 100644 --- a/tests/unit/output_window_spec.lua +++ b/tests/unit/output_window_spec.lua @@ -231,7 +231,7 @@ describe('output_window.setup', function() output_window.setup({ output_buf = buf, output_win = win }) output_window.set_folds({ { from = 3, to = 5 }, { from = 1, to = 2 } }) - local folds = vim.api.nvim_buf_get_var(buf, 'opencode_folds') + local folds = state.ui.get_output_folds() assert.same({ ranges = { @@ -247,11 +247,37 @@ describe('output_window.setup', function() output_window.shift_folds(3, 4) - local folds = vim.api.nvim_buf_get_var(buf, 'opencode_folds') + local folds = state.ui.get_output_folds() assert.same({ ranges = { { from = 1, to = 3 } }, }, folds) end) + + it('applies every fold range in a single set_folds call', function() + output_window.setup({ output_buf = buf, output_win = win }) + output_window.set_lines({ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h' }) + + output_window.set_folds({ + { from = 1, to = 2 }, + { from = 4, to = 5 }, + { from = 7, to = 8 }, + }) + + local foldclosed_at = function(line) + return vim.api.nvim_win_call(win, function() + return vim.fn.foldclosed(line) + end) + end + + assert.equals(1, foldclosed_at(1)) + assert.equals(1, foldclosed_at(2)) + assert.equals(-1, foldclosed_at(3)) + assert.equals(4, foldclosed_at(4)) + assert.equals(4, foldclosed_at(5)) + assert.equals(-1, foldclosed_at(6)) + assert.equals(7, foldclosed_at(7)) + assert.equals(7, foldclosed_at(8)) + end) end) describe('output_window extmarks', function() diff --git a/tests/unit/renderer_buffer_spec.lua b/tests/unit/renderer_buffer_spec.lua index 3625a296..de589f0c 100644 --- a/tests/unit/renderer_buffer_spec.lua +++ b/tests/unit/renderer_buffer_spec.lua @@ -242,8 +242,11 @@ describe('update_part_folds', function() end) it('merges existing folds from other parts', function() - ctx.part_folds['part_b'] = { { from = 5, to = 8 } } - ctx.global_folds = { { from = 5, to = 8 } } + ctx.formatted_parts['part_b'] = { + lines = { 'other' }, + fold_ranges = { { from = 1, to = 4 } }, + } + ctx.render_state:set_part({ id = 'part_b', messageID = 'msg_b', type = 'text' }, 5, 8) ctx.formatted_parts['part_a'] = { lines = { 'title', '', 'content', 'more' }, diff --git a/tests/unit/renderer_diff_spec.lua b/tests/unit/renderer_diff_spec.lua new file mode 100644 index 00000000..c33d2915 --- /dev/null +++ b/tests/unit/renderer_diff_spec.lua @@ -0,0 +1,119 @@ +local output_diff = require('opencode.ui.renderer.output_diff') + +describe('renderer.output_diff.unchanged_prefix_lines', function() + it('returns full length when both inputs are identical', function() + assert.equals(3, output_diff.unchanged_prefix_lines({ lines = { 'a', 'b', 'c' } }, { lines = { 'a', 'b', 'c' } })) + end) + + it('returns 0 when first line differs', function() + assert.equals(0, output_diff.unchanged_prefix_lines({ lines = { 'a' } }, { lines = { 'b' } })) + end) + + it('returns the count of identical leading lines', function() + assert.equals(2, output_diff.unchanged_prefix_lines({ lines = { 'a', 'b', 'c' } }, { lines = { 'a', 'b', 'x' } })) + end) + + it('clamps to the shorter of the two', function() + assert.equals(1, output_diff.unchanged_prefix_lines({ lines = { 'a', 'b' } }, { lines = { 'a' } })) + assert.equals(1, output_diff.unchanged_prefix_lines({ lines = { 'a' } }, { lines = { 'a', 'b' } })) + end) + + it('handles nil inputs', function() + assert.equals(0, output_diff.unchanged_prefix_lines(nil, { lines = { 'a' } })) + assert.equals(0, output_diff.unchanged_prefix_lines({ lines = { 'a' } }, nil)) + end) +end) + +describe('renderer.output_diff.is_unchanged', function() + it('returns false when prev is nil', function() + assert.is_false(output_diff.is_unchanged(nil, { lines = {}, extmarks = {} })) + end) + + it('returns true when lines and extmarks are identical', function() + assert.is_true(output_diff.is_unchanged( + { lines = { 'a', 'b' }, extmarks = {} }, + { lines = { 'a', 'b' }, extmarks = {} } + )) + end) + + it('returns false when line count differs', function() + assert.is_false(output_diff.is_unchanged( + { lines = { 'a' }, extmarks = {} }, + { lines = { 'a', 'b' }, extmarks = {} } + )) + end) + + it('returns false when any line content differs', function() + assert.is_false(output_diff.is_unchanged( + { lines = { 'a', 'b' }, extmarks = {} }, + { lines = { 'a', 'X' }, extmarks = {} } + )) + end) + + it('returns false when extmarks differ on an existing line', function() + assert.is_false(output_diff.is_unchanged( + { lines = { 'a' }, extmarks = { [0] = { { line_hl_group = 'A' } } } }, + { lines = { 'a' }, extmarks = { [0] = { { line_hl_group = 'B' } } } } + )) + end) + + it('returns false when negative-line extmarks differ', function() + assert.is_false(output_diff.is_unchanged( + { lines = { 'a' }, extmarks = { [-1] = { { virt_text = { { 'old' } } } } } }, + { lines = { 'a' }, extmarks = { [-1] = { { virt_text = { { 'new' } } } } } } + )) + end) + + it('returns false when extmark is added on a new line', function() + assert.is_false(output_diff.is_unchanged( + { lines = { 'a', 'b' }, extmarks = {} }, + { lines = { 'a', 'b' }, extmarks = { [1] = { { line_hl_group = 'A' } } } } + )) + end) +end) + +describe('renderer.output_diff.slice_lines', function() + it('returns the tail from start_idx (1-based)', function() + assert.same({ 'b', 'c' }, output_diff.slice_lines({ 'a', 'b', 'c' }, 2)) + end) + + it('returns empty when start_idx exceeds length', function() + assert.same({}, output_diff.slice_lines({ 'a', 'b' }, 5)) + end) + + it('handles nil input', function() + assert.same({}, output_diff.slice_lines(nil, 1)) + end) +end) + +describe('renderer.output_diff.slice_extmarks', function() + it('shifts non-negative keys by -start_line', function() + local out = output_diff.slice_extmarks({ [2] = { { id = 'm' } } }, 2) + assert.same({ [0] = { { id = 'm' } } }, out) + end) + + it('preserves negative keys untouched', function() + local out = output_diff.slice_extmarks({ [-1] = { { id = 'n' } } }, 5) + assert.same({ [-1] = { { id = 'n' } } }, out) + end) + + it('drops keys below start_line', function() + local out = output_diff.slice_extmarks({ [1] = { { id = 'a' } }, [3] = { { id = 'b' } } }, 2) + assert.same({ [1] = { { id = 'b' } } }, out) + end) +end) + +describe('renderer.output_diff.is_append_only', function() + it('returns true when new lines tail-extend old lines', function() + assert.is_true(output_diff.is_append_only({ 'a', 'b' }, { 'a', 'b', 'c' })) + end) + + it('returns false when the tail differs', function() + assert.is_false(output_diff.is_append_only({ 'a', 'b' }, { 'a', 'X', 'c' })) + end) + + it('returns false when new is not strictly longer', function() + assert.is_false(output_diff.is_append_only({ 'a', 'b' }, { 'a', 'b' })) + assert.is_false(output_diff.is_append_only({ 'a', 'b' }, { 'a' })) + end) +end) \ No newline at end of file