diff --git a/lua/opencode/lru_cache.lua b/lua/opencode/lru_cache.lua new file mode 100644 index 00000000..1fa06ee6 --- /dev/null +++ b/lua/opencode/lru_cache.lua @@ -0,0 +1,59 @@ +---@generic T +---@class LruCache +---@field private capacity integer +---@field private entries table +---@field private size integer +---@field private clock integer +local LruCache = {} +LruCache.__index = LruCache + +---@param capacity integer +---@return LruCache +function LruCache.new(capacity) + assert(capacity > 0, 'cache capacity must be positive') + return setmetatable({ + capacity = capacity, + entries = {}, + size = 0, + clock = 0, + }, LruCache) +end + +---@param key any +---@return T +function LruCache:get(key) + local entry = self.entries[key] + if not entry then + return nil + end + + self.clock = self.clock + 1 + entry.used = self.clock + return entry.value +end + +---@param key any +---@param value T +function LruCache:set(key, value) + local entry = self.entries[key] + if not entry and self.size >= self.capacity then + local oldest_key + local oldest_use = math.huge + for cached_key, cached_entry in pairs(self.entries) do + if cached_entry.used < oldest_use then + oldest_key = cached_key + oldest_use = cached_entry.used + end + end + self.entries[oldest_key] = nil + self.size = self.size - 1 + end + + if not entry then + self.size = self.size + 1 + end + self.clock = self.clock + 1 + self.entries[key] = { value = value, used = self.clock } +end + +return LruCache diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 6925cec6..c5571f3c 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -569,6 +569,7 @@ ---@class FormatterContext ---@field interactive boolean +---@field resolve_symbol_targets? boolean ---@field get_child_parts? fun(session_id: string): OpencodeMessagePart[]? ---@field current_refs? CodeReference[] ---@field current_files? string[] diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index a00352c8..f9169e9e 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -795,7 +795,7 @@ local function add_file_reference_targets(output, rendered, rendered_reference_r end local function add_symbol_reference_targets(output, rendered, rendered_mention_ranges, first_line_idx, context) - if not (context and context.interactive and context.symbol_cycle) then + if not (context and context.interactive and context.resolve_symbol_targets ~= false and context.symbol_cycle) then return {} end diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index c28dfaa8..55463a33 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -283,7 +283,6 @@ function M.event_subscriptions() { 'file.edited', events.on_file_edited }, { 'file.watcher.updated', events.on_file_watcher_updated }, { 'custom.restore_point.created', events.on_restore_points }, - { 'custom.emit_events.finished', M.on_emit_events_finished }, } end @@ -422,10 +421,10 @@ function M._render_full_session_data(session_data, opts) events.on_part_updated({ part = revert_message.parts[1] }) end - local t_format_end = vim.uv.hrtime() flush.flush() flush.end_bulk_mode() - local t_flush_end = vim.uv.hrtime() + + events.refresh_rendered_symbol_targets() if opts.restore_model_from_messages then require('opencode.services.agent_model').initialize_current_model({ restore_from_messages = true }) @@ -600,11 +599,6 @@ end M.reconcile_rendered_message_limit = reconcile_rendered_message_limit M.is_message_visible = is_message_visible ----Scroll to bottom after all queued events have been processed -function M.on_emit_events_finished() - M.scroll_to_bottom() -end - ---Return all actions available at a given (0-indexed) line ---@param line integer ---@return table[] diff --git a/lua/opencode/ui/renderer/events.lua b/lua/opencode/ui/renderer/events.lua index b6560405..0903b4e6 100644 --- a/lua/opencode/ui/renderer/events.lua +++ b/lua/opencode/ui/renderer/events.lua @@ -104,6 +104,7 @@ local function mark_rendered_assistant_text_parts_dirty() local message_data = ctx.render_state:get_message(part_data.message_id) local message = message_data and message_data.message or find_message_in_state(part_data.message_id) if is_assistant_message(message) and message.info.sessionID == active_session_id then + ctx.formatted_parts[part_id] = nil flush.mark_part_dirty(part_id, part_data.message_id) end end @@ -118,6 +119,11 @@ end local M = {} +function M.refresh_rendered_symbol_targets() + reference_facts.refresh_current_files() + mark_rendered_assistant_text_parts_dirty() +end + function M.invalidate_reference_targets_for_file_change() reference_facts.refresh_current_files() mark_rendered_assistant_text_parts_dirty() diff --git a/lua/opencode/ui/renderer/flush.lua b/lua/opencode/ui/renderer/flush.lua index d1a93576..178ebb56 100644 --- a/lua/opencode/ui/renderer/flush.lua +++ b/lua/opencode/ui/renderer/flush.lua @@ -14,9 +14,7 @@ local warned_part_render_error = false local function output_window_is_in_background_tab() local output_win = state.windows and state.windows.output_win - return output_win - and vim.api.nvim_win_is_valid(output_win) - and not state.ui.is_window_in_current_tab(output_win) + return output_win and vim.api.nvim_win_is_valid(output_win) and not state.ui.is_window_in_current_tab(output_win) end ---@param part_id string @@ -251,6 +249,7 @@ end local function new_formatter_context() return { interactive = true, + resolve_symbol_targets = not ctx.bulk_mode, get_child_parts = function(session_id) return ctx.render_state:get_child_session_parts(session_id) end, @@ -543,6 +542,7 @@ function M.resume_deferred_rendering() M.flush() if ctx.bulk_mode then M.end_bulk_mode() + require('opencode.ui.renderer.events').refresh_rendered_symbol_targets() end M.flush_pending_on_data_rendered() end diff --git a/lua/opencode/ui/symbol_snapshot.lua b/lua/opencode/ui/symbol_snapshot.lua index b01a8034..6a5eb6be 100644 --- a/lua/opencode/ui/symbol_snapshot.lua +++ b/lua/opencode/ui/symbol_snapshot.lua @@ -1,6 +1,35 @@ local M = {} local MIN_DEFINITION_TOKEN_LENGTH = 2 +local path_cache = require('opencode.lru_cache').new(256) + +local function timestamp_key(timestamp) + if type(timestamp) == 'table' then + return string.format('%s:%s', timestamp.sec or '', timestamp.nsec or '') + end + return tostring(timestamp or '') +end + +local function source_version(path) + local bufnr = vim.fn.bufnr and vim.fn.bufnr(path) or -1 + if bufnr and bufnr > 0 and vim.api.nvim_buf_is_loaded and vim.api.nvim_buf_is_loaded(bufnr) then + local ok, changedtick = pcall(vim.api.nvim_buf_get_changedtick, bufnr) + return ok and 'buffer:' .. bufnr .. ':' .. changedtick or nil + end + + local stat = (vim.uv or vim.loop).fs_stat(path) + if not stat then + return nil + end + return table.concat({ + 'disk', + stat.dev or '', + stat.ino or '', + timestamp_key(stat.mtime), + timestamp_key(stat.ctime), + stat.size, + }, ':') +end local function absolute_path(path) if path:sub(1, 1) == '/' then @@ -126,6 +155,13 @@ local function collect_path(path) lang = parser_lang end + local version = source_version(path) + local cache_key = version and lang .. ':' .. version + local cached = path_cache:get(path) + if cached and cached.version == cache_key then + return cached.by_token + end + local query_ok, query = pcall(function() if vim.treesitter and vim.treesitter.query and vim.treesitter.query.get then return vim.treesitter.query.get(lang, 'locals') @@ -170,6 +206,10 @@ local function collect_path(path) end end + if cache_key then + path_cache:set(path, { version = cache_key, by_token = by_token }) + end + return by_token end diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index f7b48051..486f5395 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -214,6 +214,10 @@ describe('renderer unit tests', function() })) end) + it('leaves post-flush scrolling to the renderer flush', function() + assert.is_false(vim.tbl_contains(event_subscriptions(), 'custom.emit_events.finished')) + end) + it('unsubsribes from events correctly', function() local renderer = require('opencode.ui.renderer') local event_manager = state.event_manager @@ -503,7 +507,15 @@ describe('renderer unit tests', function() return original_filereadable(path) end state.session.set_active(helpers.get_session_from_events(events, true)) + vim.wait(0) renderer._render_full_session_data(helpers.load_session_from_events(events)) + local ctx = require('opencode.ui.renderer.ctx') + assert.is_true( + vim.wait(1000, function() + return not ctx:has_pending_work() + end), + 'Timed out waiting for deferred symbol targets' + ) local actual = helpers.capture_output(state.windows.output_buf, output_window.namespace) local symbol_mark diff --git a/tests/unit/lru_cache_spec.lua b/tests/unit/lru_cache_spec.lua new file mode 100644 index 00000000..c6fb64dc --- /dev/null +++ b/tests/unit/lru_cache_spec.lua @@ -0,0 +1,27 @@ +local LruCache = require('opencode.lru_cache') + +describe('LRU cache', function() + it('evicts the least recently used entry', function() + local cache = LruCache.new(2) + cache:set('first', 1) + cache:set('second', 2) + assert.equal(1, cache:get('first')) + + cache:set('third', 3) + + assert.is_nil(cache:get('second')) + assert.equal(1, cache:get('first')) + assert.equal(3, cache:get('third')) + end) + + it('updates existing entries without evicting another entry', function() + local cache = LruCache.new(2) + cache:set('first', 1) + cache:set('second', 2) + + cache:set('first', 3) + + assert.equal(3, cache:get('first')) + assert.equal(2, cache:get('second')) + end) +end) diff --git a/tests/unit/persist_state_spec.lua b/tests/unit/persist_state_spec.lua index fa589c76..5302e5ba 100644 --- a/tests/unit/persist_state_spec.lua +++ b/tests/unit/persist_state_spec.lua @@ -112,6 +112,7 @@ describe('persist_state', function() vim.fn.writefile(lines or { 'line 1', 'line 2', 'line 3', 'line 4', 'line 5' }, tmpfile) code_buf = vim.fn.bufadd(tmpfile) + vim.bo[code_buf].swapfile = false vim.fn.bufload(code_buf) vim.bo[code_buf].buflisted = true diff --git a/tests/unit/symbol_snapshot_spec.lua b/tests/unit/symbol_snapshot_spec.lua index 5ad84495..bef37862 100644 --- a/tests/unit/symbol_snapshot_spec.lua +++ b/tests/unit/symbol_snapshot_spec.lua @@ -7,7 +7,11 @@ describe('opencode.ui.symbol_snapshot', function() local original_filetype local original_treesitter local original_notify + local original_uv + local original_loop + local original_lru_cache local files + local file_versions local buffers local captures_by_content local read_counts @@ -15,6 +19,7 @@ describe('opencode.ui.symbol_snapshot', function() local query_available local parser_available local notify_calls + local cached_paths local function fake_node(text, row, col) return { @@ -27,6 +32,7 @@ describe('opencode.ui.symbol_snapshot', function() local function set_file(path, lines, captures) files[path] = lines + file_versions[path] = (file_versions[path] or 0) + 1 captures_by_content[table.concat(lines, '\n')] = captures or {} end @@ -36,7 +42,11 @@ describe('opencode.ui.symbol_snapshot', function() original_filetype = vim.filetype original_treesitter = vim.treesitter original_notify = vim.notify + original_uv = vim.uv + original_loop = vim.loop + original_lru_cache = package.loaded['opencode.lru_cache'] files = {} + file_versions = {} buffers = {} captures_by_content = {} read_counts = {} @@ -44,6 +54,7 @@ describe('opencode.ui.symbol_snapshot', function() query_available = true parser_available = true notify_calls = {} + cached_paths = {} vim.fn = vim.tbl_extend('force', vim.fn or {}, { getcwd = function() @@ -175,6 +186,27 @@ describe('opencode.ui.symbol_snapshot', function() table.insert(notify_calls, { msg = msg, level = level }) end + local uv = { + fs_stat = function(path) + local version = file_versions[path] + return version and { mtime = { sec = version, nsec = 0 }, size = #table.concat(files[path], '\n') } or nil + end, + } + vim.uv = uv + vim.loop = uv + + package.loaded['opencode.lru_cache'] = { + new = function() + return { + get = function(_, path) + return cached_paths[path] + end, + set = function(_, path, value) + cached_paths[path] = value + end, + } + end, + } package.loaded['opencode.ui.symbol_snapshot'] = nil symbol_snapshot = require('opencode.ui.symbol_snapshot') end) @@ -185,7 +217,10 @@ describe('opencode.ui.symbol_snapshot', function() vim.filetype = original_filetype vim.treesitter = original_treesitter vim.notify = original_notify + vim.uv = original_uv + vim.loop = original_loop package.loaded['opencode.ui.symbol_snapshot'] = nil + package.loaded['opencode.lru_cache'] = original_lru_cache end) it('exports only the frozen public API', function() @@ -246,6 +281,28 @@ describe('opencode.ui.symbol_snapshot', function() assert.equal(1, parse_counts[content]) end) + it('reuses parsed candidate files across cycles until they change', function() + local path = '/test/project/src/main.lua' + local content = 'local function foo() end' + set_file(path, { content }, { + { id = 1, node = fake_node('foo', 0, 15) }, + }) + + local first = symbol_snapshot.new_cycle() + local second = symbol_snapshot.new_cycle() + assert.equal(1, #symbol_snapshot.targets_for_token(first, 'foo', { path })) + assert.equal(1, #symbol_snapshot.targets_for_token(second, 'foo', { path })) + assert.equal(1, read_counts[path]) + assert.equal(1, parse_counts[content]) + + set_file(path, { 'local function bar() end' }, { + { id = 1, node = fake_node('bar', 0, 15) }, + }) + local changed = symbol_snapshot.new_cycle() + assert.equal(1, #symbol_snapshot.targets_for_token(changed, 'bar', { path })) + assert.equal(2, read_counts[path]) + end) + it('collects definition tokens from referenced readable Lua files', function() set_file('/test/project/src/main.lua', { 'local function foo() end' }, { { id = 1, node = fake_node('foo', 0, 15) },