Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions lua/opencode/lru_cache.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
---@generic T
---@class LruCache<T>
---@field private capacity integer
---@field private entries table<any, { value: T, used: integer }>
---@field private size integer
---@field private clock integer
local LruCache = {}
LruCache.__index = LruCache

---@param capacity integer
---@return LruCache<T>
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
1 change: 1 addition & 0 deletions lua/opencode/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
2 changes: 1 addition & 1 deletion lua/opencode/ui/formatter.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 2 additions & 8 deletions lua/opencode/ui/renderer.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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[]
Expand Down
6 changes: 6 additions & 0 deletions lua/opencode/ui/renderer/events.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand Down
6 changes: 3 additions & 3 deletions lua/opencode/ui/renderer/flush.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions lua/opencode/ui/symbol_snapshot.lua
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions tests/replay/renderer_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions tests/unit/lru_cache_spec.lua
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions tests/unit/persist_state_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
57 changes: 57 additions & 0 deletions tests/unit/symbol_snapshot_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,19 @@ 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
local parse_counts
local query_available
local parser_available
local notify_calls
local cached_paths

local function fake_node(text, row, col)
return {
Expand All @@ -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

Expand All @@ -36,14 +42,19 @@ 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 = {}
parse_counts = {}
query_available = true
parser_available = true
notify_calls = {}
cached_paths = {}

vim.fn = vim.tbl_extend('force', vim.fn or {}, {
getcwd = function()
Expand Down Expand Up @@ -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)
Expand All @@ -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()
Expand Down Expand Up @@ -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) },
Expand Down
Loading