diff --git a/docs/performance-audit.md b/docs/performance-audit.md new file mode 100644 index 00000000..dd8284d7 --- /dev/null +++ b/docs/performance-audit.md @@ -0,0 +1,82 @@ +# Runtime and maintainability audit + +This audit reviewed transport, event batching and subscriptions, timers, file completion, +promise settlement, history persistence, snapshot/review commands, and renderer update +primitives. It includes targeted fixes and regression tests. It is not a guarantee that +every runtime or platform combination is free of defects. + +## Fixed + +| Priority | Finding | Change | +| --- | --- | --- | +| High | File completion synchronously waited for server responses and shell searches. Server errors prevented fallback; Lua also treats `executable()` returning `0` as true. | Yield through promises, catch failed searches, skip unavailable executables, and enforce the configured result limit. | +| High | Delayed server-ready callbacks could reconnect after stop; an old server's shutdown could close the replacement subscription. Buffered and late events could cross subscription boundaries. | Guard callbacks with lifecycle/subscription identities, check the current server, and clear pending batches and cached parts at subscription cleanup. | +| High | The server test suite passed fake PIDs to real process enumeration and signal functions. The first full run terminated with exit 143. | Stub both OS operations for server tests, including restoration after each test. The termination's precise cause was not independently proven. | +| Medium | Collapsing repeated part updates rescanned all intervening events, producing quadratic work. | Track the most recent permission-event index, preserving the existing permission ordering rule in constant time per update. | +| Medium | Streaming line extraction copied the remaining buffer for each line. | Scan by offset and retain the remaining suffix once per chunk. Extremely long, incomplete lines can still cause repeated concatenation across chunks. | +| Medium | HTTP parsing stopped at a proxy CONNECT or informational response, potentially reporting the wrong status and treating final headers as body text. | Consume preliminary header blocks and parse the final response; preserve bodies that happen to resemble HTTP headers. | +| Medium | A cleared throttle callback could consume a new batch before its own deadline. | Invalidate cancelled callbacks using a generation counter. | +| Medium | Queued timer ticks could run after stop/restart; an old tick could stop a replacement timer created inside the callback. | Check timer identity before invocation and before stopping. | +| Medium | A listener unsubscribing itself during event emission could cause the next listener to be skipped. | Iterate a snapshot of the listener list. | + +## Measurements and verification + +`./run_tests.sh`: **1,224 passed, zero failed**, including minimal, unit, and replay tests. +The sandboxed run could not create Neovim swap files; the successful complete run used +approved filesystem access. Targeted tests cover pending completion responses, fallback, +subscription replacement, timer restart, cancellation, HTTP headers, and stream boundaries. +Changed Lua files were formatted with the repository's StyLua configuration; `git diff --check` passed. + +A synthetic benchmark compared the original and updated event manager in headless Neovim. +Each batch contained repeated full updates to one text part; event delivery was disabled +to isolate normalization and collapsing. Results are the median of five runs, with GC +before each run, on the audit machine: + +| Updates per batch | Before | After | +| --- | ---: | ---: | +| 1,000 | 1.49 ms | 0.67 ms | +| 4,000 | 18.05 ms | 2.33 ms | +| 8,000 | 78.79 ms | 4.80 ms | + +This demonstrates the removed quadratic scan. It is not an end-to-end rendering or typing +latency measurement. Live server, Windows shell, and large-repository interactive testing +remain useful follow-up validation. + +## Remaining findings, in recommended order + +1. **Addressed: history encoding and deletion.** New writes use JSON lines in `history.jsonl`, + with legacy `history.txt` reading and migration on the first write. Existing ambiguous + legacy records retain their previous interpretation; the legacy file remains intact. + Deletion deduplicates indices and commits rewrites atomically without mutating the cache + on failure. Regression tests cover round trips, migration, clear, duplicate indices and + failed writes. +2. **Addressed: snapshot/review and API startup waits.** Snapshot APIs now return promises; + revert operations resolve to `{ id, deleted_files }`. Callers await Git processes and + picker choices, and command handlers return the complete promise chain. Operations capture + their session/directory and serialize access to each snapshot index. Stale review results + are ignored. API startup and version detection yield, with cancellation available before + an event subscription starts. Real temporary Git repositories exercise revert and recovery; + mocked delayed processes exercise concurrency and workspace switches. Checkout errors no + longer imply that a file should be deleted, and diff previews preserve original bytes. +3. **Addressed: promise retention and falsy rejections.** Settlement clears both callback + queues and the waiting coroutine list after scheduling consumers. Rejection has its own + state flag, preserving `false` and `nil` rejection reasons consistently through chaining, + `finally`, synchronous waiting and coroutine awaiting. Both early and late consumers have + regression coverage. +4. **Architecture remains tightly coupled.** The required topology scanner reports **5 cycles**, + a **largest strongly connected component of 41 modules**, **18 policy violations**, and + **11 ungrouped modules**. The HEAD-to-worktree diff adds/removes zero dependency edges and + introduces zero violations. Of the violations, 12 are capability-to-entry dependencies, + 4 entry-to-infrastructure, and 2 capability-to-dispatch. Prioritize renderer/formatter + dependencies on permission/question windows and the `ui.ui` orchestration boundary. + Expand scanner group coverage before using its totals as a comprehensive architecture gate. + +The remaining findings above are based on source inspection, not newly added reproductions. +They are intentionally recorded separately from the tested fixes. + +Scanner commands (install `scripts/dependency-topology/requirements.txt` first): + +```sh +python3 scripts/dependency-topology/scan_topology.py scan --json +python3 scripts/dependency-topology/scan_topology.py diff --from HEAD --to worktree --json +``` diff --git a/lua/opencode/api_client.lua b/lua/opencode/api_client.lua index 223b2fbc..26a6bc30 100644 --- a/lua/opencode/api_client.lua +++ b/lua/opencode/api_client.lua @@ -1,4 +1,5 @@ local server_job = require('opencode.server_job') +local Promise = require('opencode.promise') local state = require('opencode.state') local url_encode = require('opencode.util').url_encode local apply_path_map = require('opencode.util').apply_path_map @@ -65,38 +66,41 @@ local function normalize_global_event(event) } end ----Ensure that base_url is set. Even thought we're subscribed to ----state.opencode_server, we still need this check because ----it's possible someone will try to make an api call in their event ----handler (e.g. event_manager or header) ----@return boolean -function OpencodeApiClient:_ensure_base_url() - -- NOTE: eventhough we're subscribed opencode_server, we need this check for - -- base_url because the notification about opencode_server being set to - -- non-nil my not have gotten to us in time +---@return Promise +OpencodeApiClient._ensure_base_url = Promise.async(function(self) if self.base_url then return true end - - if not state.opencode_server then - -- this is last resort - try to start the server and could be blocking - state.jobs.set_server(server_job.ensure_server():wait() --[[@as OpencodeServer]]) - -- shouldn't normally happen but prevents error in replay tester - if not state.opencode_server then + if self._connecting then + return self._connecting:await() + end + local connecting = Promise.new() + self._connecting = connecting + local ok, result = pcall(function() + local server = state.opencode_server or server_job.ensure_server():await() + if not server then return false end - end - - if not state.opencode_server.url then - state.opencode_server:get_spawn_promise():wait() - if not state.opencode_server.url then + if not server.url then + server:get_spawn_promise():await() + end + if not server.url then return false end + if state.opencode_server and state.opencode_server ~= server then + error('Server changed while connecting') + end + self.base_url = server.url:gsub('/$', '') + return true + end) + self._connecting = nil + if not ok then + connecting:reject(result) + error(result, 0) end - - self.base_url = state.opencode_server.url:gsub('/$', '') - return true -end + connecting:resolve(result) + return result +end) --- Make a typed API call --- @param endpoint string The API endpoint path @@ -104,8 +108,12 @@ end --- @param body table|nil|boolean Request body --- @param query table|nil Query parameters --- @return Promise promise -function OpencodeApiClient:_call(endpoint, method, body, query) - if not self:_ensure_base_url() then +OpencodeApiClient._call = Promise.async(function(self, endpoint, method, body, query) + if query then + query = vim.deepcopy(query) + query.directory = query.directory or state.current_cwd or vim.fn.getcwd() + end + if not self:_ensure_base_url():await() then return require('opencode.promise').new():reject('No server base url') end local url = self.base_url .. endpoint @@ -137,7 +145,7 @@ function OpencodeApiClient:_call(endpoint, method, body, query) return server_job.call_api(url, method, body):and_then(function(result) return reverse_transform_paths_recursive(result) end) -end +end) -- Project endpoints @@ -207,10 +215,7 @@ end --- directories instead of being filtered to the current cwd. --- @return Promise function OpencodeApiClient:list_sessions_global() - if not self:_ensure_base_url() then - return require('opencode.promise').new():reject('No server base url') - end - return server_job.call_api(self.base_url .. '/experimental/session', 'GET') + return self:_call('/experimental/session', 'GET') end --- Create a new session @@ -527,66 +532,53 @@ end --- @param on_event fun(event: table) Event callback --- @return table The streaming job handle function OpencodeApiClient:subscribe_to_events(directory, on_event) - -- Make sure we have a base URL before attempting to subscribe. If we - -- cannot determine a base URL (server not running), return nil so - -- callers can handle the absence of a subscription without an error. - if not self:_ensure_base_url() then - return nil - end - - local version = assert(state.opencode_cli_version):wait() - if is_version_greater_or_equal(version, '1.14.42') then - return self:_subscribe_to_global_events(directory, on_event) - end - - local url = self.base_url .. '/event' - if directory then - local mapped_directory = apply_path_map(directory) - url = url .. '?directory=' .. url_encode(mapped_directory) - end - - return server_job.stream_api(url, 'GET', nil, function(chunk) - chunk = chunk:gsub('^data:%s*', '') - local ok, event = pcall(vim.json.decode, vim.trim(chunk)) - if ok and event then - local transformed_event = reverse_transform_paths_recursive(event) - on_event(transformed_event --[[@as table]]) - end - end) -end - ---- Subscribe to events (streaming) ---- @param directory string|nil Directory path ---- @param on_event fun(event: table) Event callback ---- @return table The streaming job handle -function OpencodeApiClient:_subscribe_to_global_events(directory, on_event) - -- Ensure base_url is available. If not, return nil instead of erroring. - if not self:_ensure_base_url() then - return nil - end - - local version = assert(state.opencode_cli_version):wait() - if not is_version_greater_or_equal(version, '1.14.42') then - error('subscribe_to_global_events should not be called directly') - end - - local url = self.base_url .. '/global/event' - if directory then - local mapped_directory = apply_path_map(directory) - url = url .. '?directory=' .. url_encode(mapped_directory) - end - - return server_job.stream_api(url, 'GET', nil, function(chunk) - chunk = chunk:gsub('^data:%s*', '') - local ok, event = pcall(vim.json.decode, vim.trim(chunk)) - if ok and event then - local normalized_event = normalize_global_event(event) - if normalized_event then - local transformed_event = reverse_transform_paths_recursive(normalized_event) - on_event(transformed_event --[[@as table]]) + local stopped = false + local job + local handle = { + shutdown = function() + stopped = true + if job and job.shutdown then + job:shutdown() end + end, + is_running = function() + return not stopped and (not job or not job.is_running or job:is_running()) + end, + } + Promise.spawn(function() + if not self:_ensure_base_url():await() or stopped then + stopped = true + return + end + local version = assert(state.opencode_cli_version):await() + if stopped then + return + end + local global = is_version_greater_or_equal(version, '1.14.42') + local url = self.base_url .. (global and '/global/event' or '/event') + if directory then + url = url .. '?directory=' .. url_encode(apply_path_map(directory)) end + job = server_job.stream_api(url, 'GET', nil, function(chunk) + if stopped then + return + end + chunk = chunk:gsub('^data:%s*', '') + local ok, event = pcall(vim.json.decode, vim.trim(chunk)) + if ok and event then + if global then + event = normalize_global_event(event) + end + if event then + on_event(reverse_transform_paths_recursive(event)) + end + end + end) + end):catch(function(err) + stopped = true + require('opencode.log').notify('Failed to subscribe to events: ' .. vim.inspect(err), vim.log.levels.ERROR) end) + return handle end -- Skill endpoints diff --git a/lua/opencode/commands/handlers/diff.lua b/lua/opencode/commands/handlers/diff.lua index 5f290a54..7b965dc4 100644 --- a/lua/opencode/commands/handlers/diff.lua +++ b/lua/opencode/commands/handlers/diff.lua @@ -14,8 +14,8 @@ local function with_output_open(callback, open_if_closed) local open_fn = open_if_closed and session_runtime.open_if_closed or session_runtime.open return function(...) local args = { ... } - open_fn({ new_session = false, focus = 'output' }):and_then(function() - callback(unpack(args)) + return open_fn({ new_session = false, focus = 'output' }):and_then(function() + return callback(unpack(args)) end) end end @@ -43,30 +43,30 @@ end ---@param from_snapshot_id? string ---@param _to_snapshot_id? string|number M.actions.diff_open = with_output_open(function(from_snapshot_id, _to_snapshot_id) - git_review.review(extract_hash_arg(from_snapshot_id)) + return git_review.review(extract_hash_arg(from_snapshot_id)) end, true) M.actions.diff_next = with_output_open(function() - git_review.next_diff() + return git_review.next_diff() end, false) M.actions.diff_prev = with_output_open(function() - git_review.prev_diff() + return git_review.prev_diff() end, false) M.actions.diff_close = with_output_open(function() - git_review.close_diff() + return git_review.close_diff() end, false) ---@param from_snapshot_id? string M.actions.diff_revert_all = with_output_open(function(from_snapshot_id) - git_review.revert_all(from_snapshot_id) + return git_review.revert_all(from_snapshot_id) end, false) ---@param from_snapshot_id? string ---@param _to_snapshot_id? string M.actions.diff_revert_selected_file = with_output_open(function(from_snapshot_id, _to_snapshot_id) - git_review.revert_selected_file(from_snapshot_id) + return git_review.revert_selected_file(from_snapshot_id) end, false) ---@return string|nil @@ -87,12 +87,12 @@ M.actions.diff_revert_all_last_prompt = with_output_open(function() return end - git_review.revert_all(snapshot_id) + return git_review.revert_all(snapshot_id) end, false) ---@param snapshot_id? string M.actions.diff_revert_this = with_output_open(function(snapshot_id) - git_review.revert_current(snapshot_id) + return git_review.revert_current(snapshot_id) end, false) M.actions.diff_revert_this_last_prompt = with_output_open(function() @@ -101,21 +101,21 @@ M.actions.diff_revert_this_last_prompt = with_output_open(function() return end - git_review.revert_current(snapshot_id) + return git_review.revert_current(snapshot_id) end, false) ---@param restore_point_id? string M.actions.diff_restore_snapshot_file = with_output_open(function(restore_point_id) - git_review.restore_snapshot_file(restore_point_id) + return git_review.restore_snapshot_file(restore_point_id) end, false) ---@param restore_point_id? string M.actions.diff_restore_snapshot_all = with_output_open(function(restore_point_id) - git_review.restore_snapshot_all(restore_point_id) + return git_review.restore_snapshot_all(restore_point_id) end, false) M.actions.set_review_breakpoint = with_output_open(function() - git_review.create_snapshot() + return git_review.create_snapshot() end, false) ---@type table diff --git a/lua/opencode/config_file.lua b/lua/opencode/config_file.lua index ecfd46ba..66de552f 100644 --- a/lua/opencode/config_file.lua +++ b/lua/opencode/config_file.lua @@ -27,8 +27,11 @@ M.get_opencode_config = Promise.async(function() return result end) ----@type fun(): Promise -M.get_opencode_project = Promise.async(function() +---@type fun(directory?: string): Promise +M.get_opencode_project = Promise.async(function(directory) + if directory then + return require('opencode.state').api_client:get_current_project(directory):await() + end if not M.project_promise then local state = require('opencode.state') M.project_promise = Promise.retry(function() @@ -50,9 +53,10 @@ end) ---Get the snapshot storage path for the current workspace ---Matches opencode's Global.Path.data + "snapshot" + projectId + Hash.fast(worktree) ---Can be overridden via config.snapshot_path (base path, project_id and worktree_hash are appended) ----@type fun(): Promise -M.get_workspace_snapshot_path = Promise.async(function() - local project = M.get_opencode_project():await() --[[@as OpencodeProject|nil]] +---@type fun(directory?: string): Promise +M.get_workspace_snapshot_path = Promise.async(function(directory) + local cwd = directory or vim.fn.getcwd() + local project = M.get_opencode_project(cwd):await() --[[@as OpencodeProject|nil]] if not project then return '' end @@ -64,7 +68,6 @@ M.get_workspace_snapshot_path = Promise.async(function() end data_home = vim.fs.joinpath(data_home, 'opencode') end - local cwd = vim.fn.getcwd() local worktree_hash = sha1(cwd) if not worktree_hash then return '' diff --git a/lua/opencode/curl.lua b/lua/opencode/curl.lua index 150a4a0f..4f82e6f6 100644 --- a/lua/opencode/curl.lua +++ b/lua/opencode/curl.lua @@ -110,31 +110,34 @@ end --- @param output string Raw curl output with headers --- @return table response Response object with status, headers, and body local function parse_response(output) - local lines = vim.split(output, '\n') local status = 200 local headers = {} local body_start = 1 - -- Find status line and headers - for i, line in ipairs(lines) do - if line:match('^HTTP/') then - status = math.floor(tonumber(line:match('HTTP/[%d%.]+%s+(%d+)')) or 200) - elseif line:match('^[%w%-]+:') then + -- curl may prepend proxy CONNECT and informational response headers. + while output:sub(body_start, body_start + 4) == 'HTTP/' do + local header_end, separator_end = output:find('\r?\n\r?\n', body_start) + if not header_end then + break + end + local block = output:sub(body_start, header_end - 1) + status = tonumber(block:match('^HTTP/[%d%.]+%s+(%d+)')) or 200 + headers = {} + for line in block:gmatch('[^\r\n]+') do local key, value = line:match('^([%w%-]+):%s*(.*)$') - if key and value then + if key then headers[key:lower()] = value end - elseif line == '' then - body_start = i + 1 + end + body_start = separator_end + 1 + if + not (status >= 100 and status < 200 and status ~= 101) + and not block:match('^HTTP/[%d%.]+%s+200%s+[Cc]onnection established') + then break end end - - local body_lines = {} - for i = body_start, #lines do - table.insert(body_lines, lines[i]) - end - local body = table.concat(body_lines, '\n') + local body = output:sub(body_start) return { status = status, @@ -170,16 +173,16 @@ function M.request(opts) if chunk then buffer = buffer .. chunk - -- Extract complete lines - while buffer:find('\n') do - local line, rest = buffer:match('([^\n]*\n)(.*)') - if line then - opts.stream(nil, line) - buffer = rest - else + local start = 1 + while true do + local newline = buffer:find('\n', start, true) + if not newline then break end + opts.stream(nil, buffer:sub(start, newline)) + start = newline + 1 end + buffer = buffer:sub(start) end end, stderr = function(err, data) diff --git a/lua/opencode/event_manager.lua b/lua/opencode/event_manager.lua index ffd6c78d..ba35a15c 100644 --- a/lua/opencode/event_manager.lua +++ b/lua/opencode/event_manager.lua @@ -398,8 +398,12 @@ function EventManager:_on_drained_events(events) local collapsed_events = {} local part_update_indices = {} + local last_permission_index = 0 for i, event in ipairs(normalized_events) do + if event.type == 'permission.updated' or event.type == 'permission.asked' then + last_permission_index = i + end if event.type == 'message.part.updated' and event.properties.part then local part_id = event.properties.part.id if part_update_indices[part_id] then @@ -408,18 +412,7 @@ function EventManager:_on_drained_events(events) -- Preserve ordering dependencies for permission events. -- Moving a later part update earlier can break correlation when -- permission.updated/permission.asked sits between the two updates. - local has_intervening_permission_event = false - for j = previous_index + 1, i - 1 do - if - normalized_events[j] - and (normalized_events[j].type == 'permission.updated' or normalized_events[j].type == 'permission.asked') - then - has_intervening_permission_event = true - break - end - end - - if has_intervening_permission_event then + if last_permission_index > previous_index then collapsed_events[previous_index] = nil collapsed_events[i] = event part_update_indices[part_id] = i @@ -481,7 +474,7 @@ function EventManager:emit(event_name, data) end if listeners then - for _, callback in ipairs(listeners) do + for _, callback in ipairs(vim.list_extend({}, listeners)) do local ok, result = util.pcall_trace(callback, data) if not ok then @@ -505,6 +498,8 @@ function EventManager:start() end self.is_started = true + local lifecycle = {} + self._lifecycle = lifecycle if self.state_server_listener then state.store.unsubscribe('opencode_server', self.state_server_listener) @@ -515,13 +510,21 @@ function EventManager:start() self:emit('custom.server_starting', { url = current.url }) current:get_spawn_promise():and_then(function(server) + if self._lifecycle ~= lifecycle or state.opencode_server ~= current then + return + end self:emit('custom.server_ready', { url = server.url }) vim.defer_fn(function() - self:_subscribe_to_server_events(server) + if self._lifecycle == lifecycle and state.opencode_server == current then + self:_subscribe_to_server_events(server) + end end, 200) end) current:get_shutdown_promise():and_then(function() + if self._lifecycle ~= lifecycle or state.opencode_server ~= current then + return + end self:emit('custom.server_stopped', {}) self:_cleanup_server_subscription() end) @@ -553,6 +556,7 @@ function EventManager:stop() end self.is_started = false + self._lifecycle = nil if self.state_server_listener then state.store.unsubscribe('opencode_server', self.state_server_listener) self.state_server_listener = nil @@ -578,8 +582,13 @@ function EventManager:_subscribe_to_server_events(server) self:_cleanup_server_subscription() local api_client = state.api_client + local subscription = {} + self._subscription = subscription local emitter = function(event) + if self._subscription ~= subscription then + return + end if not event or not event.type then log.warn('Received malformed event from server: %s', vim.inspect(event)) return @@ -597,6 +606,9 @@ function EventManager:_subscribe_to_server_events(server) end function EventManager:_cleanup_server_subscription() + self._subscription = nil + self.throttling_emitter:clear() + self._parts_by_id = {} if self.server_subscription then pcall(function() if self.server_subscription.shutdown then diff --git a/lua/opencode/git_review.lua b/lua/opencode/git_review.lua index cf0e04af..6150230f 100644 --- a/lua/opencode/git_review.lua +++ b/lua/opencode/git_review.lua @@ -3,397 +3,284 @@ local snapshot = require('opencode.snapshot') local diff_tab = require('opencode.ui.diff_tab') local utils = require('opencode.util') local session = require('opencode.session') -local config_file = require('opencode.config_file') local picker = require('opencode.ui.picker') +local Promise = require('opencode.promise') local M = {} +local breakpoint +local review_cache +local generation = 0 ----@param cmd_args string[] ----@param opts? vim.SystemOpts ----@return string|nil, string|nil -local function snapshot_git(cmd_args, opts) - if not M.__snapshot_path then - vim.notify('No snapshot path for the active session.') - return nil, nil - end - local cwd = vim.fn.getcwd() - local args = { 'git', '--git-dir', M.__snapshot_path, '--work-tree', cwd } - vim.list_extend(args, cmd_args) - local result = vim.system(args, opts or { cwd = cwd }):wait() - if result and result.code == 0 then - return vim.trim(result.stdout), result.stderr - else - return nil, result and result.stderr or nil - end +local function is_current(context) + return context.generation == generation and state.active_session == context.session and vim.fn.getcwd() == context.cwd end -M.__snapshot_path = nil -M.__changed_files = nil -M.__current_file_index = nil -M.__diff_tab = nil -M.__current_ref = nil -M.__last_ref = nil - -local git = { - is_project = function() - if M.__is_git_project ~= nil then - return M.__is_git_project - end - - local git_dir = vim.fn.getcwd() .. '/.git' - M.__is_git_project = vim.fn.isdirectory(git_dir) == 1 - - return M.__is_git_project - end, - - list_changed_files = function() - if not M.__current_ref then - return {} - end - local patch = snapshot.patch(M.__current_ref) - return patch and patch.files or {} - end, - - is_tracked = function(file_path) - local out = snapshot_git({ 'ls-files', '--error-unmatch', file_path }) - return out ~= nil - end, -} +local function run_snapshot(context, name, ...) + local args, count = { ... }, select('#', ...) + return snapshot + .with_context(function() + return snapshot[name](unpack(args, 1, count)):await() + end, context) + :await() +end ----@generic T ----@param fn T ----@param silent any ----@return T -local require_git_project = function(fn, silent) +local function review_action(fn) return function(...) - if not git.is_project() then - if not silent then - vim.notify('Error: Not in a git project.') - end - return - end - if not state.active_session then - if not silent then - vim.notify('Error: No active session found.') + generation = generation + 1 + local context = { + cwd = vim.fn.getcwd(), + session = state.active_session, + current_file = vim.fn.expand('%:p'), + generation = generation, + first_snapshot = M.get_first_snapshot(), + } + local args, count = { ... }, select('#', ...) + return Promise.spawn(function() + if not context.session then + error('No active session found.') end - return - end - if not M.__snapshot_path then - M.__snapshot_path = config_file.get_workspace_snapshot_path():wait() - end + return fn(context, unpack(args, 1, count)) + end) + end +end - if not M.__snapshot_path or vim.fn.isdirectory(M.__snapshot_path) == 0 then - if not silent then - vim.notify('Error: No snapshot path for the active session.') - end - return +---@return string|nil +function M.get_first_snapshot() + if breakpoint and breakpoint.session == state.active_session and breakpoint.cwd == vim.fn.getcwd() then + return breakpoint.id + end + for _, msg in ipairs(state.messages or {}) do + local ids = session.get_message_snapshot_ids(msg) + if ids and #ids > 0 then + return ids[1] end - return fn(...) end end -local function get_changed_files(ref) +local function get_changed_files(context, ref) + ref = ref or context.first_snapshot + if not ref then + return {} + end + local patch = run_snapshot(context, 'patch', ref) local files = {} - - local git_files = git.list_changed_files() - - for _, file in ipairs(git_files) do - if file ~= '' then - table.insert(files, snapshot.diff_file(ref or M.__current_ref, file)) + for _, file in ipairs(patch and patch.files or {}) do + if not is_current(context) then + return {} end + files[#files + 1] = run_snapshot(context, 'diff_file', ref, file) end - - M.__changed_files = files - return files end -local function display_file_at_index(idx) - local file_data = M.__changed_files[idx] - local file_name = vim.fn.fnamemodify(file_data.left, ':t') - vim.notify(string.format('Showing file %d of %d: %s', idx, #M.__changed_files, file_name)) - diff_tab.open_diff_tab(file_data.left, file_data.right, file_data.file_type) -end - ----@param rev string ----@param n? number|string ----@return string|nil -local function get_git_rev(rev, n) - if n and type(n) ~= 'number' then +local function select_item(context, items, opts) + if not is_current(context) or #items == 0 then return nil end - if n == 0 or n == nil then - return snapshot_git({ 'rev-parse', rev }) - elseif n < 0 then - return snapshot_git({ 'rev-parse', string.format('%s~%d', rev, math.abs(n)) }) + if #items == 1 then + return items[1] end - return nil + local selected = Promise.new() + picker.select(items, opts, function(choice) + selected:resolve(choice) + end) + local choice = selected:await() + return is_current(context) and choice or nil end -M.get_first_snapshot = require_git_project(function() - if not state.active_session then - vim.notify('No active session found.') - return nil - end +local function select_file(context, files, prompt) + return select_item(context, files, { + prompt = prompt, + format_item = function(file) + return file.left + end, + }) +end - for _, msg in ipairs(state.messages or {}) do - local snapshots = session.get_message_snapshot_ids(msg) - if snapshots and #snapshots > 0 then - return snapshots[1] - end +local function display(context, file) + if file and is_current(context) then + diff_tab.open_diff_tab(file.left, file.right, file.file_type) end -end) - -M.review = require_git_project(function(ref) - M.__current_ref = ref or M.get_first_snapshot() - local files = get_changed_files() +end - if #files == 0 then +---@type fun(ref?: string): Promise +M.review = review_action(function(context, ref) + local files = get_changed_files(context, ref) + if #files == 0 and is_current(context) then vim.notify('No changes to review.') return end - - if #files == 1 then - M.__current_file_index = 1 - diff_tab.open_diff_tab(files[1].left, files[1].right, files[1].file_type) - else - picker.select( - vim.tbl_map(function(f) - return vim.fn.fnamemodify(f.left, ':.') - end, files), - { prompt = 'Select a file to review:' }, - function(choice, idx) - if not choice then - return - end - M.__current_file_index = idx - - diff_tab.open_diff_tab(files[idx].left, files[idx].right, files[idx].file_type) - end - ) - end + display(context, select_file(context, files, 'Select a file to review:')) end) -M.next_diff = require_git_project(function(ref, last_ref) - M.__current_ref = ref or M.get_first_snapshot() - M.__last_ref = last_ref and get_git_rev(ref, last_ref) or 'HEAD' - if not M.__changed_files or not M.__current_file_index or M.__current_file_index >= #M.__changed_files then - local files = get_changed_files() - if #files == 0 then - vim.notify('No changes to review.') +local function navigate(context, ref, direction) + ref = ref or context.first_snapshot + if + not review_cache + or review_cache.cwd ~= context.cwd + or review_cache.session ~= context.session + or review_cache.ref ~= ref + then + local files = get_changed_files(context, ref) + if not is_current(context) then return end - M.__changed_files = files - M.__current_file_index = 1 - else - M.__current_file_index = M.__current_file_index + 1 + review_cache = { cwd = context.cwd, session = context.session, ref = ref, files = files } end - - display_file_at_index(M.__current_file_index) -end) - -M.prev_diff = require_git_project(function(ref, last_ref) - M.__current_ref = ref or M.get_first_snapshot() - M.__last_ref = last_ref and get_git_rev(ref, last_ref) or 'HEAD' - if not M.__changed_files or #M.__changed_files == 0 then - local files = get_changed_files() - if #files == 0 then - vim.notify('No changes to review.') - return - end - M.__current_file_index = #files - else - if not M.__current_file_index or M.__current_file_index <= 1 then - M.__current_file_index = #M.__changed_files - else - M.__current_file_index = M.__current_file_index - 1 - end + local files = review_cache.files + if #files == 0 then + vim.notify('No changes to review.') + return end + local index = review_cache.index or (direction == 1 and 0 or 1) + index = (index - 1 + direction) % #files + 1 + review_cache.index = index + display(context, files[index]) +end - display_file_at_index(M.__current_file_index) +---@type fun(ref?: string): Promise +M.next_diff = review_action(function(context, ref) + return navigate(context, ref, 1) +end) +---@type fun(ref?: string): Promise +M.prev_diff = review_action(function(context, ref) + return navigate(context, ref, -1) end) -M.revert_current = require_git_project( - ---@param current_ref? string|nil - ---@param last_ref? string|nil - function(current_ref, last_ref) - M.__current_ref = current_ref or M.get_first_snapshot() - - local files = get_changed_files() - local current_file = vim.fn.expand('%:p') - local abs_path = vim.fn.fnamemodify(current_file, ':p') +local function revert_file(context, file, ref) + if not is_current(context) then + return + end + local result = run_snapshot(context, 'revert_file', ref or context.first_snapshot, file) + review_cache = nil + if result and is_current(context) then + vim.cmd('checktime') + end + return result +end - local changed_file = nil - for _, file_data in ipairs(files) do - if file_data[1] == abs_path then - changed_file = file_data - break +---@type fun(file: string, ref?: string): Promise +M.revert_file = review_action(revert_file) +---@type fun(ref?: string): Promise +M.revert_current = review_action(function(context, ref) + local files = get_changed_files(context, ref) + for _, file in ipairs(files) do + if file.left == context.current_file and is_current(context) then + if vim.fn.input('Revert current file? (y/n): '):lower() == 'y' then + return revert_file(context, file.left, ref) end - end - - if not changed_file then - vim.notify('No changes to revert.') - return - end - - if vim.fn.input('Revert current file? (y/n): '):lower() ~= 'y' then return end - - if M.revert_file(changed_file[1], current_ref) then - vim.cmd('e!') - vim.cmd('checktime') - end end -) - -M.revert_file = require_git_project(function(file_path, ref) - snapshot.revert_file(ref, file_path) -end) - -M.revert_selected_file = require_git_project(function(ref) - M.__current_ref = ref or M.get_first_snapshot() - - local files = get_changed_files() - - if #files == 0 then + if is_current(context) then vim.notify('No changes to revert.') - return end +end) - if #files == 1 then - if M.revert_file(files[1].left, ref) then - vim.cmd('checktime') - end - return +---@type fun(ref?: string): Promise +M.revert_selected_file = review_action(function(context, ref) + local files = get_changed_files(context, ref) + local file = select_file(context, files, 'Select a file to revert:') + if file then + return revert_file(context, file.left, ref) end - - picker.select( - vim.tbl_map(function(f) - return vim.fn.fnamemodify(f.left, ':.') - end, files), - { prompt = 'Select a file to revert:' }, - function(choice, idx) - if not choice then - return - end - local file_data = files[idx] - if M.revert_file(file_data.left, ref) then - vim.cmd('checktime') - end - end - ) end) -M.revert_all = require_git_project(function(ref) - M.__current_ref = ref or M.get_first_snapshot() - - local files = get_changed_files() - +---@type fun(ref?: string): Promise +M.revert_all = review_action(function(context, ref) + local files = get_changed_files(context, ref) + if not is_current(context) then + return + end if #files == 0 then vim.notify('No changes to revert.') return end - if vim.fn.input('Revert all ' .. #files .. ' changed files? (y/n): '):lower() ~= 'y' then return end - snapshot.revert(M.__current_ref) - - vim.notify('Reverted ' .. #files .. ' files.') + local result = run_snapshot(context, 'revert', ref or context.first_snapshot) + review_cache = nil + if result and is_current(context) then + vim.notify('Reverted ' .. #files .. ' files.') + end + return result end) -M.restore_snapshot = require_git_project(function(ref) - M.__current_ref = ref or M.get_first_snapshot() +local function select_restore_point(context, parent) + local points + if parent then + points = snapshot.get_restore_points_by_parent(parent) + else + points = snapshot.get_restore_points() + end + return select_item(context, points or {}, { + prompt = 'Select a restore point to restore:', + format_item = function(item) + return ('%s - %s'):format(item.id:sub(1, 8), utils.format_time(item.created_at) or 'unknown') + end, + }) +end - if not M.__current_ref then - vim.notify('No snapshot to restore.') +---@type fun(parent?: string): Promise +M.restore_snapshot = review_action(function(context, parent) + local point = select_restore_point(context, parent) + if not point then return end - - M.with_restore_point(ref, function(restore_point) - if not restore_point then - vim.notify('No restore point selected.') - return - end - - snapshot.restore(restore_point.id) + local result = run_snapshot(context, 'restore', point.id) + review_cache = nil + if result and is_current(context) then vim.cmd('checktime') - end) + end + return result end) +M.restore_snapshot_all = M.restore_snapshot -M.restore_snapshot_file = require_git_project(function(restore_point_id) - M.__current_ref = restore_point_id or M.get_first_snapshot() - - if not M.__current_ref then - vim.notify('No snapshot to restore.') +---@type fun(parent?: string): Promise +M.restore_snapshot_file = review_action(function(context, parent) + local point = select_restore_point(context, parent) + if not point then return end + local file = select_file(context, get_changed_files(context, point.id), 'Select a file to restore:') + if not file then + return + end + local result = run_snapshot(context, 'restore_file', point.id, file.left) + review_cache = nil + if result and is_current(context) then + vim.cmd('checktime') + end + return result +end) - M.with_restore_point(restore_point_id, function(restore_point) - if not restore_point then - vim.notify('No restore point selected.') - return - end - local files = get_changed_files(restore_point.id) - - picker.select( - vim.tbl_map(function(f) - return vim.fn.fnamemodify(f.left, ':.') - end, files), - { prompt = 'Select a file to restore:' }, - function(choice, idx) - if not choice then - return - end - local file_data = files[idx] - if snapshot.restore_file(restore_point.id, file_data.left) then - vim.cmd('checktime') - end - end - ) - end) +---@type fun(parent: string|nil, fn: fun(point: RestorePoint): any): Promise +M.with_restore_point = review_action(function(context, parent, fn) + local point = select_restore_point(context, parent) + if point then + return fn(point) + end end) ---- Select a restore point and execute a function with it ---- @param restore_point_id string|nil ---- @param fn fun(restore_point: RestorePoint) -function M.with_restore_point(restore_point_id, fn) - local restore_points = restore_point_id and snapshot.get_restore_points_by_parent(restore_point_id) - or snapshot.get_restore_points() - if #restore_points == 1 then - return fn(restore_points[1]) +---@type fun(): Promise +M.create_snapshot = review_action(function(context) + local id = run_snapshot(context, 'create') + if is_current(context) then + breakpoint = { id = id, session = context.session, cwd = context.cwd } + review_cache = nil end - picker.select(restore_points, { - prompt = 'Select a restore point to restore:', - format_item = function(item) - return (require('opencode.ui.icons').get('file') .. '[+%d,-%d] %s - %s (from: %s)'):format( - item.files and #item.files or 0, - item.deleted_files and #item.deleted_files or 0, - item.id:sub(1, 8), - utils.format_time(item.created_at) or 'unknown', - item.from_snapshot_id and item.from_snapshot_id:sub(1, 8) or 'none' - ) - end, - }, function(selected_snapshot) - if not selected_snapshot then - return - end - fn(selected_snapshot) - if snapshot then - vim.notify('Reverted restore snapshot: ' .. selected_snapshot.id, vim.log.levels.INFO) - else - vim.notify('Failed to restore to snapshot: ' .. selected_snapshot.id, vim.log.levels.ERROR) - end - end) -end + return id +end) -M.close_diff = function() +function M.close_diff() + generation = generation + 1 diff_tab.close_diff_tab() end -M.reset_git_status = function() - M.__is_git_project = nil +function M.reset_git_status() + generation = generation + 1 + review_cache = nil end return M diff --git a/lua/opencode/history.lua b/lua/opencode/history.lua index a31789aa..539d6211 100644 --- a/lua/opencode/history.lua +++ b/lua/opencode/history.lua @@ -13,54 +13,58 @@ local function get_history_file() return data_dir .. '/history.txt' end +---@param prompt string M.write = function(prompt) local history = M.read() if #history > 0 and history[1] == prompt then return end - local file = io.open(get_history_file(), 'a') - if file then - -- Escape any newlines in the prompt - local escaped_prompt = prompt:gsub('\n', '\\n') - file:write(escaped_prompt .. '\n') - file:close() - -- Invalidate cache when writing new history - cached_history = nil + local path = get_history_file():gsub('%.txt$', '.jsonl') + if not vim.uv.fs_stat(path) then + return M._write_history(vim.list_extend({ prompt }, history)) + end + local file = io.open(path, 'a') + if not file then + return false end + local written = file:write(vim.json.encode(prompt) .. '\n') + local closed = file:close() + cached_history = nil + return written ~= nil and closed ~= nil end +---@return string[] M.read = function() - -- Return cached result if available if cached_history then return cached_history end - local line_by_index = {} - local file = io.open(get_history_file(), 'r') - + local legacy_path = get_history_file() + local file = io.open(legacy_path:gsub('%.txt$', '.jsonl'), 'r') + local is_json = file ~= nil + file = file or io.open(legacy_path, 'r') + local lines = {} if file then - local lines = {} - - -- Read all non-empty lines for line in file:lines() do - if line:gsub('%s', '') ~= '' then - -- Unescape any escaped newlines - local unescaped_line = line:gsub('\\n', '\n') - table.insert(lines, unescaped_line) + if is_json then + local ok, prompt = pcall(vim.json.decode, line) + if ok and type(prompt) == 'string' then + lines[#lines + 1] = prompt + end + elseif line:find('%S') then + -- Legacy records cannot distinguish literal backslashes from escaped newlines. + lines[#lines + 1] = line:gsub('\\n', '\n') end end file:close() - - -- Reverse the array to have index 1 = most recent - for i = 1, #lines do - line_by_index[i] = lines[#lines - i + 1] - end end - -- Cache the result - cached_history = line_by_index - return line_by_index + cached_history = {} + for i = #lines, 1, -1 do + cached_history[#cached_history + 1] = lines[i] + end + return cached_history end M.prev = function() @@ -110,8 +114,10 @@ M.delete = function(indices) -- Sort indices in descending order to avoid index shifting issues local sorted_indices = {} + local seen = {} for _, idx in ipairs(indices) do - if idx > 0 and idx <= #history then + if type(idx) == 'number' and idx % 1 == 0 and idx > 0 and idx <= #history and not seen[idx] then + seen[idx] = true table.insert(sorted_indices, idx) end end @@ -119,6 +125,7 @@ M.delete = function(indices) return a > b end) + history = vim.list_extend({}, history) for _, idx in ipairs(sorted_indices) do table.remove(history, idx) end @@ -135,23 +142,26 @@ end ---@param history_array table Array of history entries to write ---@return boolean success Whether the write operation succeeded M._write_history = function(history_array) - local file = io.open(get_history_file(), 'w') - if not file then + local path = get_history_file():gsub('%.txt$', '.jsonl') + local lines = {} + for i = #history_array, 1, -1 do + lines[#lines + 1] = vim.json.encode(history_array[i]) + end + local content = #lines > 0 and table.concat(lines, '\n') .. '\n' or '' + local fd, temp_path = vim.uv.fs_mkstemp(path .. '.XXXXXX') + if not fd then return false end - - for i = #history_array, 1, -1 do - local entry = history_array[i] - if entry and entry ~= '' then - local escaped_entry = entry:gsub('\n', '\\n') - file:write(escaped_entry .. '\n') - end + local written = vim.uv.fs_write(fd, content, 0) + local closed = vim.uv.fs_close(fd) + if written ~= #content or not closed or not vim.uv.fs_rename(temp_path, path) then + vim.uv.fs_unlink(temp_path) + return false end - file:close() - cached_history = nil - + M.index = nil + prompt_before_history = nil return true end diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index 29eda642..124dd49f 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -53,6 +53,8 @@ function M.setup(opts) local OpencodeApiClient = require('opencode.api_client') state.jobs.set_api_client(OpencodeApiClient.create()) + require('opencode.ui.permission_window') + require('opencode.ui.question_window') require('opencode.commands').setup() require('opencode.ui.completion').setup() require('opencode.keymap').setup(config.keymap) diff --git a/lua/opencode/promise.lua b/lua/opencode/promise.lua index 315ae706..cf79db50 100644 --- a/lua/opencode/promise.lua +++ b/lua/opencode/promise.lua @@ -3,6 +3,7 @@ ---@class Promise ---@field __index Promise ---@field _resolved boolean +---@field _rejected boolean ---@field _value T ---@field _error any ---@field _then_callbacks fun(value: T)[] @@ -47,6 +48,7 @@ end function Promise.new() local self = setmetatable({ _resolved = false, + _rejected = false, _value = nil, _error = nil, _then_callbacks = {}, @@ -73,6 +75,9 @@ function Promise:resolve(value) end resume_coroutines(self._coroutines, value, nil) + self._then_callbacks = {} + self._catch_callbacks = {} + self._coroutines = {} return self end @@ -84,6 +89,7 @@ function Promise:reject(err) return self end self._error = err + self._rejected = true self._resolved = true local schedule_catch = vim.schedule_wrap(function(cb, e) @@ -94,6 +100,9 @@ function Promise:reject(err) end resume_coroutines(self._coroutines, nil, err) + self._then_callbacks = {} + self._catch_callbacks = {} + self._coroutines = {} return self end @@ -128,10 +137,10 @@ function Promise:and_then(callback) end end - if self._resolved and not self._error then + if self._resolved and not self._rejected then local schedule_then = vim.schedule_wrap(handle_callback) schedule_then(self._value) - elseif self._resolved and self._error then + elseif self._resolved and self._rejected then new_promise:reject(self._error) else table.insert(self._then_callbacks, handle_callback) @@ -172,10 +181,10 @@ function Promise:catch(error_callback) new_promise:resolve(value) end - if self._resolved and self._error then + if self._resolved and self._rejected then local schedule_catch = vim.schedule_wrap(handle_error) schedule_catch(self._error) - elseif self._resolved and not self._error then + elseif self._resolved and not self._rejected then new_promise:resolve(self._value) else table.insert(self._catch_callbacks, handle_error) @@ -211,11 +220,11 @@ function Promise:finally(callback) new_promise:reject(err) end - if self._resolved and not self._error then + if self._resolved and not self._rejected then -- Promise already resolved successfully local schedule_finally = vim.schedule_wrap(handle_success) schedule_finally(self._value) - elseif self._resolved and self._error then + elseif self._resolved and self._rejected then -- Promise already rejected local schedule_finally = vim.schedule_wrap(handle_error) schedule_finally(self._error) @@ -237,8 +246,8 @@ end ---@return T function Promise:wait(timeout, interval) if self._resolved then - if self._error then - error(self._error) + if self._rejected then + error(self._error, 0) end return self._value end @@ -254,8 +263,8 @@ function Promise:wait(timeout, interval) error('Promise timed out after ' .. timeout .. 'ms') end - if self._error then - error(self._error) + if self._rejected then + error(self._error, 0) end return self._value @@ -274,7 +283,7 @@ function Promise:is_resolved() end function Promise:is_rejected() - return self._resolved and self._error ~= nil + return self._rejected end ---Await the promise from within a coroutine @@ -286,8 +295,8 @@ function Promise:await() -- If already resolved, return immediately local value if self._resolved then - if self._error then - error(self._error) + if self._rejected then + error(self._error, 0) end value = self._value ---@cast value T @@ -306,8 +315,8 @@ function Promise:await() ---@diagnostic disable-next-line: await-in-sync local value, err = coroutine.yield() - if err then - error(err) + if self._rejected then + error(err, 0) end ---@cast value T diff --git a/lua/opencode/server_job.lua b/lua/opencode/server_job.lua index 6ad92530..95f0ffa6 100644 --- a/lua/opencode/server_job.lua +++ b/lua/opencode/server_job.lua @@ -196,21 +196,49 @@ local function _start_server() return promise end ---- Ensure the opencode server is running, starting it if necessary. ---- @return Promise +local pending_connection + +---Ensure all callers share startup and health checks until the server is ready. +---@return Promise function M.ensure_server() - if state.opencode_server and state.opencode_server:is_running() then - return state.opencode_server:check_health():and_then(function(healthy) - if healthy then - return state.opencode_server - end - log.warn('ensure_server: cached server unhealthy, reconnecting') - state.jobs.clear_server() - return _start_server() - end) + if pending_connection then + return pending_connection end - return _start_server() + local connection = Promise.new() + pending_connection = connection + Promise.spawn(function() + while true do + local server = state.opencode_server + if not server or not server:is_running() then + return _start_server():await() + end + + local starting = server.get_spawn_promise and server:get_spawn_promise() + if starting and not starting:is_resolved() then + return starting:await() + end + + local healthy = server:check_health():await() + if state.opencode_server == server then + if healthy then + return server + end + log.warn('ensure_server: cached server unhealthy, reconnecting') + state.jobs.clear_server() + return _start_server():await() + end + end + end) + :and_then(function(server) + pending_connection = nil + connection:resolve(server) + end) + :catch(function(err) + pending_connection = nil + connection:reject(err) + end) + return connection end local function retry_connect(base_url, timeout, max_retries, on_success, on_failure) @@ -296,24 +324,31 @@ end --- @param port? number|string Optional custom port --- @param hostname? string Optional custom hostname function M.spawn_local_server(promise, port, hostname) - state.jobs.set_server(opencode_server.new()) + local server = opencode_server.new() + local cwd = vim.fn.getcwd() + state.jobs.set_server(server) local spawn_opts = { + cwd = cwd, on_ready = function(job, base_url) local url_port = base_url:match(':(%d+)') log.notify(string.format('Started local server at %s', base_url), vim.log.levels.INFO) if url_port then local port_num = tonumber(url_port) - state.jobs.set_server_port(port_num) + if state.opencode_server == server then + state.jobs.set_server_port(port_num) + else + server.port = port_num + end local server_pid = job and job.pid - port_mapping.register(port_num, vim.fn.getcwd(), true, 'serve', nil, server_pid) + port_mapping.register(port_num, cwd, true, 'serve', nil, server_pid) log.debug( 'spawn_local_server: registered port %d for reference counting (server_pid=%s)', port_num, tostring(server_pid) ) end - promise:resolve(state.opencode_server) + promise:resolve(server) end, on_error = function(err) log.notify(' Failed to start opencode server' .. vim.inspect(err), vim.log.levels.ERROR) @@ -333,7 +368,7 @@ function M.spawn_local_server(promise, port, hostname) spawn_opts.hostname = hostname end - state.opencode_server:spawn(spawn_opts) + server:spawn(spawn_opts) end return M diff --git a/lua/opencode/services/AGENTS.md b/lua/opencode/services/AGENTS.md index 3acedd6e..85be7db9 100644 --- a/lua/opencode/services/AGENTS.md +++ b/lua/opencode/services/AGENTS.md @@ -75,9 +75,7 @@ The following entry files still directly require `opencode.session`/`opencode.ap - [ ] `lua/opencode/ui/debug_helper.lua` -> `opencode.session` - [ ] `lua/opencode/ui/permission_window.lua` -> `opencode.api` - [ ] `lua/opencode/ui/contextual_actions.lua` -> `opencode.api` -- [ ] `lua/opencode/ui/session_picker.lua` -> `opencode.api` - [ ] `lua/opencode/ui/timeline_picker.lua` -> `opencode.api` -- [ ] `lua/opencode/ui/ui.lua` -> `opencode.api` - [ ] `lua/opencode/commands/handlers/diff.lua` -> `opencode.session` - [ ] `lua/opencode/commands/handlers/session.lua` -> `opencode.session` diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index 452596b3..366c80c6 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -90,7 +90,7 @@ M.select_session = Promise.async(function(parent_id, scope) return end - ui.select_session(filtered_sessions, function(selected_session) + require('opencode.ui.session_picker').select(filtered_sessions, function(selected_session) if not selected_session then if state.ui.is_visible() then ui.focus_input() diff --git a/lua/opencode/snapshot.lua b/lua/opencode/snapshot.lua index c3075b11..7d4142b5 100644 --- a/lua/opencode/snapshot.lua +++ b/lua/opencode/snapshot.lua @@ -1,30 +1,81 @@ -- This file is a port of the snapshot management logic from the original OpenCode ---@see https://github.com/sst/opencode/blob/dev/packages/opencode/src/snapshot/index.ts +---@class OpencodeSnapshot +---@field track fun(): Promise +---@field create fun(): Promise +---@field patch fun(hash: string): Promise +---@field diff fun(hash: string): Promise +---@field diff_file fun(hash: string, file: string): Promise<{left: string, right: string, file_type: string}> +---@field revert fun(hash: string): Promise<{id: string, deleted_files: string[]}|nil> +---@field revert_file fun(hash: string, file: string): Promise<{id: string, deleted_files: string[]}|nil> +---@field restore fun(hash: string): Promise +---@field restore_file fun(hash: string, file: string): Promise +---@field save_restore_point fun(hash: string, parent?: string, deleted_files?: string[]): Promise local M = {} +local operations = {} local state = require('opencode.state') local util = require('opencode.util') local config_file = require('opencode.config_file') local session = require('opencode.session') +local Promise = require('opencode.promise') + +local contexts = setmetatable({}, { __mode = 'k' }) +local pending = {} + +local function operation_context() + return assert(contexts[coroutine.running()], 'Snapshot operation requires an async context') +end + +local function canonical_path(path) + local normalized = vim.fs.normalize(path) + local resolved = vim.fn.resolve(normalized) + return resolved ~= '' and vim.fs.normalize(resolved) or normalized +end ---@param cmd_args string[] ---@param opts? vim.SystemOpts ---@return string|nil, string|nil local function snapshot_git(cmd_args, opts) - local snapshot_dir = config_file.get_workspace_snapshot_path():wait() - if not snapshot_dir or snapshot_dir == '' then - vim.notify('No snapshot path for the active session.') - return nil, nil - end - local cwd = vim.fn.getcwd() - local args = { 'git', '--git-dir', snapshot_dir, '--work-tree', cwd } + local context = operation_context() + local args = { 'git', '--git-dir', context.snapshot_dir, '--work-tree', context.cwd } vim.list_extend(args, cmd_args) + local ok, result = pcall(function() + return Promise.system(args, vim.tbl_extend('force', opts or {}, { cwd = context.cwd })):await() + end) + if ok then + return result.stdout or '', nil + end + return nil, type(result) == 'table' and result.stderr or tostring(result) +end + +local function relative_path(file) + local cwd = operation_context().cwd + local absolute = canonical_path(file:sub(1, 1) == '/' and file or vim.fs.joinpath(cwd, file)) + local prefix = cwd:gsub('/$', '') .. '/' + if absolute:sub(1, #prefix) ~= prefix then + error('Snapshot file is outside the captured workspace: ' .. file) + end + return absolute:sub(#prefix + 1) +end - local result = vim.system(args, opts or { cwd = cwd }):wait() - if result and result.code == 0 then - return vim.trim(result.stdout), nil +local function checkout_or_delete(snapshot_id, file, deleted_files) + local relative = relative_path(file) + local present, lookup_error = snapshot_git({ 'ls-tree', '--name-only', snapshot_id, '--', relative }) + if not present then + error('Failed to inspect snapshot: ' .. (lookup_error or 'unknown error')) + end + if present ~= '' then + local result, err = snapshot_git({ 'checkout', snapshot_id, '--', relative }) + if not result then + error('Failed to checkout file: ' .. (err or 'unknown error')) + end else - return nil, result and result.stderr or nil + local absolute = operation_context().cwd .. '/' .. relative + if vim.fn.delete(absolute) ~= 0 and vim.uv.fs_stat(absolute) then + error('Failed to delete file: ' .. absolute) + end + deleted_files[#deleted_files + 1] = absolute end end @@ -40,21 +91,15 @@ local function write_to_temp_file(content) return temp_file end -function M.track() - if not state.active_session then +function operations.track() + if not operation_context().session then vim.notify('No active session', vim.log.levels.ERROR) return nil end - local snapshot_dir = config_file.get_workspace_snapshot_path():wait() - if not snapshot_dir then - vim.notify('No snapshot path for the active session.') - return nil - end - local _, add_err = snapshot_git({ 'add', '.' }) if add_err then - vim.notify('Failed to add files: ' .. add_err, vim.log.levels.WARN) + error('Failed to add files: ' .. add_err) end local hash_output, write_tree_err = snapshot_git({ 'write-tree' }) @@ -66,18 +111,19 @@ function M.track() return vim.trim(hash_output) end -function M.create() - return M.track() +function operations.create() + return M.track():await() end -function M.save_restore_point(snapshot_id, from_snapshot_id, deleted_files) - if not state.active_session then +function operations.save_restore_point(snapshot_id, from_snapshot_id, deleted_files) + if not operation_context().session then vim.notify('No active session', vim.log.levels.ERROR) return nil end - local cache_path = session.get_cache_path(state.active_session.id) - local patch_result = M.patch(snapshot_id) + local context = operation_context() + local cache_path = session.get_cache_path(context.session.id) + local patch_result = M.patch(snapshot_id):await() local snapshot = { id = snapshot_id, from_snapshot_id = from_snapshot_id or nil, @@ -98,7 +144,9 @@ function M.save_restore_point(snapshot_id, from_snapshot_id, deleted_files) return nil end - state.event_manager:emit('custom.restore_point.created', { restore_point = snapshot }) + if state.active_session == context.session and state.event_manager then + state.event_manager:emit('custom.restore_point.created', { restore_point = snapshot }) + end return snapshot end @@ -124,30 +172,28 @@ function M.get_restore_points() end ---@return OpencodeSnapshotPatch|nil -function M.patch(hash) - if not state.active_session then +function operations.patch(hash) + if not operation_context().session then vim.notify('No active session', vim.log.levels.ERROR) return nil end local _, add_err = snapshot_git({ 'add', '.' }) if add_err then - vim.notify('Failed to add files: ' .. add_err .. _, vim.log.levels.WARN) + error('Failed to add files: ' .. add_err) end - local files_output, diff_err = snapshot_git({ 'diff', '--cached', '--no-ext-diff', '--name-only', hash, '--', '.' }) + local files_output, diff_err = + snapshot_git({ 'diff', '--cached', '--no-ext-diff', '--name-only', '-z', hash, '--', '.' }) if not files_output then vim.notify('Failed to get diff: ' .. (diff_err or 'unknown error'), vim.log.levels.ERROR) return nil end local files = {} - local cwd = vim.fn.getcwd() - for line in files_output:gmatch('[^\r\n]+') do - local trimmed = vim.trim(line) - if trimmed ~= '' then - table.insert(files, cwd .. '/' .. trimmed) - end + local cwd = operation_context().cwd + for file in files_output:gmatch('[^%z]+') do + table.insert(files, cwd .. '/' .. file) end return { @@ -156,8 +202,8 @@ function M.patch(hash) } end -function M.diff(hash) - if not state.active_session then +function operations.diff(hash) + if not operation_context().session then vim.notify('No active session', vim.log.levels.ERROR) return nil end @@ -171,65 +217,50 @@ function M.diff(hash) return vim.trim(result) end -function M.diff_file(snapshot_id, file_path) - local relative_path = vim.fn.fnamemodify(file_path, ':.') - relative_path = relative_path:gsub('\\', '/') - local file_at_snapshot = snapshot_git({ 'show', snapshot_id .. ':' .. relative_path }) +function operations.diff_file(snapshot_id, file_path) + local path = relative_path(file_path) + local file_at_snapshot = snapshot_git({ 'show', snapshot_id .. ':' .. path }) local temp_file = write_to_temp_file(file_at_snapshot or '') local file_type = vim.fn.fnamemodify(file_path, ':e') return { left = file_path, right = temp_file, file_type = file_type } end -function M.revert(snapshot_id) - local restore_point_id = M.create() - local patch_result = M.patch(snapshot_id) +function operations.revert(snapshot_id) + local restore_point_id = M.create():await() + if not restore_point_id then + error('Failed to create restore point') + end + local patch_result = M.patch(snapshot_id):await() if not patch_result then vim.notify('Failed to revert snapshot: ' .. snapshot_id, vim.log.levels.ERROR) return end local deleted_files = {} for _, file in ipairs(patch_result.files) do - local relative_path = file:match('^' .. vim.pesc(vim.fn.getcwd()) .. '/?(.*)$') - relative_path = relative_path:gsub('\\', '/') - local res, err = snapshot_git({ 'checkout', snapshot_id, '--', relative_path }) - if not res then - vim.notify( - 'file not found in history, deleting: ' .. file .. ' - ' .. (err or 'unknown error'), - vim.log.levels.WARN - ) - vim.fn.delete(file) - table.insert(deleted_files, file) - end - vim.cmd('checktime') + checkout_or_delete(snapshot_id, file, deleted_files) end - M.save_restore_point(restore_point_id, snapshot_id, deleted_files) - return restore_point_id, deleted_files + vim.cmd('checktime') + M.save_restore_point(restore_point_id, snapshot_id, deleted_files):await() + return { id = restore_point_id, deleted_files = deleted_files } end ---@param snapshot_id string ---@param file_path string ---@return string|nil, string[] -function M.revert_file(snapshot_id, file_path) - local restore_point_id = M.create() - local relative_path = vim.fn.fnamemodify(file_path, ':.') - local res, err = snapshot_git({ 'checkout', snapshot_id, '--', relative_path }) - local deleted_files = {} - - if not res then - vim.notify( - 'file not found in history, deleting: ' .. file_path .. ' - ' .. (err or 'unknown error'), - vim.log.levels.WARN - ) - vim.fn.delete(file_path) - table.insert(deleted_files, file_path) +function operations.revert_file(snapshot_id, file_path) + local restore_point_id = M.create():await() + if not restore_point_id then + error('Failed to create restore point') end + local deleted_files = {} + checkout_or_delete(snapshot_id, file_path, deleted_files) vim.cmd('checktime') - M.save_restore_point(restore_point_id, snapshot_id, deleted_files) - return restore_point_id, deleted_files + M.save_restore_point(restore_point_id, snapshot_id, deleted_files):await() + return { id = restore_point_id, deleted_files = deleted_files } end ---@param snapshot_id string -function M.restore(snapshot_id) +function operations.restore(snapshot_id) local read_tree_out, read_tree_err = snapshot_git({ 'read-tree', snapshot_id }) if not read_tree_out then vim.notify('Failed to read-tree: ' .. (read_tree_err or 'unknown error'), vim.log.levels.ERROR) @@ -243,22 +274,24 @@ function M.restore(snapshot_id) end vim.notify('Restored snapshot: ' .. snapshot_id, vim.log.levels.INFO) + return true end -function M.restore_file(snapshot_id, file_path) +function operations.restore_file(snapshot_id, file_path) local read_tree_out, read_tree_err = snapshot_git({ 'read-tree', snapshot_id }) if not read_tree_out then vim.notify('Failed to read-tree: ' .. (read_tree_err or 'unknown error'), vim.log.levels.ERROR) return end - local checkout_out, checkout_err = snapshot_git({ 'checkout-index', '-f', '--', file_path }) + local checkout_out, checkout_err = snapshot_git({ 'checkout-index', '-f', '--', relative_path(file_path) }) if not checkout_out then vim.notify('Failed to checkout-index: ' .. (checkout_err or 'unknown error'), vim.log.levels.ERROR) return end vim.notify('Restored file: ' .. file_path .. ' from snapshot: ' .. snapshot_id, vim.log.levels.INFO) + return true end ---@param from_snapshot_id string @@ -277,4 +310,73 @@ function M.get_restore_points_by_parent(from_snapshot_id) return restore_points end +---Run a snapshot operation against the session and directory captured at invocation. +---Nested operations share the same context and index lock. +---@generic T +---@param fn fun(): T +---@param captured? {cwd: string, session: Session|nil} +---@return Promise +function M.with_context(fn, captured) + local inherited = coroutine.running() and contexts[coroutine.running()] + local context = inherited or captured or { cwd = vim.fn.getcwd(), session = state.active_session } + if not inherited then + context.cwd = canonical_path(context.cwd) + end + return Promise.spawn(function() + local co = coroutine.running() + contexts[co] = context + local release + local ok, result = pcall(function() + if not inherited then + if not context.session then + error('No active session found.') + end + context.snapshot_dir = config_file.get_workspace_snapshot_path(context.cwd):await() + if not context.snapshot_dir or context.snapshot_dir == '' then + error('No snapshot path for the active session.') + end + local previous = pending[context.snapshot_dir] + release = Promise.new() + pending[context.snapshot_dir] = release + if previous then + previous:await() + end + end + return fn() + end) + contexts[co] = nil + if release then + if pending[context.snapshot_dir] == release then + pending[context.snapshot_dir] = nil + end + release:resolve(true) + end + if not ok then + error(result) + end + return result + end) +end + +for _, name in ipairs({ + 'track', + 'create', + 'save_restore_point', + 'patch', + 'diff', + 'diff_file', + 'revert', + 'revert_file', + 'restore', + 'restore_file', +}) do + local operation = operations[name] + M[name] = function(...) + local args, count = { ... }, select('#', ...) + return M.with_context(function() + return operation(unpack(args, 1, count)) + end) + end +end + return M diff --git a/lua/opencode/throttling_emitter.lua b/lua/opencode/throttling_emitter.lua index fb6eabdc..73dc0aa9 100644 --- a/lua/opencode/throttling_emitter.lua +++ b/lua/opencode/throttling_emitter.lua @@ -15,12 +15,13 @@ ThrottlingEmitter.__index = ThrottlingEmitter --- make sure we're not generating so many events that we don't overwhelm --- neovim, particularly treesitter. --- @param process_fn function Function to call for each item ---- @param drain_interval_ms number? Interval between drains in milliseconds (default 10) +--- @param drain_interval_ms number? Interval between drains in milliseconds (default 40) --- @return ThrottlingEmitter function M.new(process_fn, drain_interval_ms) return setmetatable({ queue = {}, drain_scheduled = false, + _generation = 0, process_fn = process_fn, drain_interval_ms = drain_interval_ms or 40, }, ThrottlingEmitter) @@ -33,7 +34,11 @@ function ThrottlingEmitter:enqueue(item) if not self.drain_scheduled then self.drain_scheduled = true + local generation = self._generation vim.defer_fn(function() + if generation ~= self._generation then + return + end self:_drain() end, self.drain_interval_ms) end @@ -49,19 +54,11 @@ function ThrottlingEmitter:_drain() if #items_to_process > 0 then self.process_fn(items_to_process) end - - -- double check that items weren't added while processing - if #self.queue > 0 and not self.drain_scheduled then - self.drain_scheduled = true - vim.defer_fn(function() - self:_drain() - end, self.drain_interval_ms) - end - -- end) end --- Clear the queue and cancel any pending drain function ThrottlingEmitter:clear() + self._generation = self._generation + 1 self.queue = {} self.drain_scheduled = false end diff --git a/lua/opencode/ui/completion/files.lua b/lua/opencode/ui/completion/files.lua index 2761da52..a2bc977d 100644 --- a/lua/opencode/ui/completion/files.lua +++ b/lua/opencode/ui/completion/files.lua @@ -17,26 +17,26 @@ local function should_keep(ignore_patterns) end local function run_systemlist(cmd) - local ok, result = pcall(vim.fn.systemlist, cmd) - return ok and vim.v.shell_error == 0 and result or nil + local args = { vim.o.shell } + vim.list_extend(args, vim.split(vim.o.shellcmdflag, '%s+', { trimempty = true })) + args[#args + 1] = cmd + local result = Promise.system(args, { text = true }):await() + return vim.split(result.stdout or '', '\n', { trimempty = true }) end local function try_tool(tool, args, pattern, max, ignore_patterns) - if type(args) == 'function' then - local promise = args(pattern, max) - local result = promise and promise.and_then and promise:wait() - - if result and type(result) == 'table' then - return vim.tbl_filter(should_keep(ignore_patterns), result) + local ok, result = pcall(function() + if type(args) == 'function' then + return args(pattern, max):await() end - end - - if vim.fn.executable(tool) then - pattern = vim.fn.shellescape(pattern) or '.' - local result = run_systemlist(tool .. string.format(args, pattern, max)) - if result then - return vim.tbl_filter(should_keep(ignore_patterns), result) + if vim.fn.executable(tool) == 1 then + return run_systemlist(tool .. string.format(args, vim.fn.shellescape(pattern), max)) end + end) + + if ok and type(result) == 'table' then + local filtered = vim.tbl_filter(should_keep(ignore_patterns), result) + return vim.list_slice(filtered, 1, max) end return nil end diff --git a/lua/opencode/ui/event_scope.lua b/lua/opencode/ui/event_scope.lua index a93f9482..e3c6c8d6 100644 --- a/lua/opencode/ui/event_scope.lua +++ b/lua/opencode/ui/event_scope.lua @@ -54,9 +54,8 @@ local function active_question_reply(properties) return false end - return require('opencode.ui.question_window').matches_active_question({ - id = properties.requestID, - }) + local questions = require('opencode.ui.renderer.ctx').prompt_controllers.question + return questions ~= nil and questions.matches_active_question({ id = properties.requestID }) end ---@type table diff --git a/lua/opencode/ui/formatter.lua b/lua/opencode/ui/formatter.lua index 8a7ede8b..5d247607 100644 --- a/lua/opencode/ui/formatter.lua +++ b/lua/opencode/ui/formatter.lua @@ -6,7 +6,7 @@ local state = require('opencode.state') local config = require('opencode.config') local snapshot = require('opencode.snapshot') local mention = require('opencode.ui.mention') -local permission_window = require('opencode.ui.permission_window') +local system_formatters = require('opencode.ui.formatter.system') local symbol_tokens = require('opencode.ui.symbol_tokens') local tool_formatters = require('opencode.ui.formatter.tools') local format_utils = require('opencode.ui.formatter.utils') @@ -1062,12 +1062,7 @@ function M.format_part(part, message, is_last_part, context) content_added = true end elseif role == 'system' then - if part.type == 'permissions-display' then - permission_window.format_display(output) - content_added = true - elseif part.type == 'questions-display' then - local question_window = require('opencode.ui.question_window') - question_window.format_display(output) + if system_formatters.format(part.type, output) then content_added = true elseif part.type == 'revert-display' then local revert_index = part.state and part.state.revert_index diff --git a/lua/opencode/ui/formatter/system.lua b/lua/opencode/ui/formatter/system.lua new file mode 100644 index 00000000..4e1b07b8 --- /dev/null +++ b/lua/opencode/ui/formatter/system.lua @@ -0,0 +1,26 @@ +local M = {} + +---@type table +local formatters = {} + +---Controllers register their synthetic part presentation without making the +---message formatter load interactive windows or dispatch commands. +---@param part_type string +---@param format fun(output: Output): nil +function M.register(part_type, format) + formatters[part_type] = format +end + +---@param part_type string +---@param output Output +---@return boolean handled +function M.format(part_type, output) + local format = formatters[part_type] + if not format then + return false + end + format(output) + return true +end + +return M diff --git a/lua/opencode/ui/output_window.lua b/lua/opencode/ui/output_window.lua index ba3ca9b9..dbceb3cd 100644 --- a/lua/opencode/ui/output_window.lua +++ b/lua/opencode/ui/output_window.lua @@ -39,6 +39,12 @@ local function build_fold_state(folds) return fold_state end +local function clear_manual_folds(win) + vim.api.nvim_win_call(win, function() + vim.cmd('silent! normal! zE') + end) +end + local _update_depth = 0 local _update_buf = nil @@ -133,7 +139,7 @@ function M.is_at_bottom(win) return true end - local effective_bottom = M.get_effective_bottom_line(state.windows.output_buf, line_count) + local effective_bottom = M.get_scroll_bottom_line(state.windows.output_buf, line_count) local ok2, cursor = pcall(vim.api.nvim_win_get_cursor, win) if not ok2 then @@ -141,7 +147,7 @@ function M.is_at_bottom(win) end local prev_line_count = M._prev_line_count_by_win[win] or line_count - local prev_effective_bottom = M.get_effective_bottom_line(state.windows.output_buf, prev_line_count) + local prev_effective_bottom = M.get_scroll_bottom_line(state.windows.output_buf, prev_line_count) return cursor[1] >= prev_effective_bottom or cursor[1] >= effective_bottom end @@ -166,6 +172,32 @@ function M.get_effective_bottom_line(buf, line_count) return line_count end +---@param buf integer +---@param line_count? integer +---@return integer +function M.get_scroll_bottom_line(buf, line_count) + local bottom = M.get_effective_bottom_line(buf, line_count) + line_count = line_count or vim.api.nvim_buf_line_count(buf) + + if bottom >= line_count then + return bottom + end + + local bottom_text = vim.api.nvim_buf_get_lines(buf, bottom - 1, bottom, false)[1] + if bottom_text ~= '' then + return bottom + end + + -- Bulk rendering leaves an extra padding line after a terminal fold. + for _, range in ipairs(state.ui.get_output_folds().ranges) do + if range.to == bottom - 1 then + return range.to + end + end + + return bottom +end + ---@param win? integer ---@return integer|nil function M.get_visible_bottom_line(win) @@ -405,6 +437,7 @@ function M.set_folds(fold_ranges) vim.api.nvim_win_call(win, function() local view = preserve_view and vim.fn.winsaveview() or nil + clear_manual_folds(win) local line_count = vim.api.nvim_buf_line_count(buf) local fold_commands = {} @@ -819,6 +852,10 @@ end ---Clear the output buffer and all namespaces. function M.clear() + if M.mounted() then + clear_manual_folds(state.windows.output_win) + end + state.ui.clear_output_folds() M.set_lines({}) -- clear extmarks in all namespaces as I've seen RenderMarkdown leave some -- extmarks behind diff --git a/lua/opencode/ui/permission_window.lua b/lua/opencode/ui/permission_window.lua index a3b1a90f..1670b051 100644 --- a/lua/opencode/ui/permission_window.lua +++ b/lua/opencode/ui/permission_window.lua @@ -364,9 +364,7 @@ function M._setup_dialog() end local function is_active_permission(permission_id) - return M._processing - and is_current_permission(permission_id) - and M._interaction == interaction + return M._processing and is_current_permission(permission_id) and M._interaction == interaction end local function on_select(index) @@ -580,4 +578,10 @@ function M.get_permission_count() return #M._permission_queue end +require('opencode.ui.renderer.ctx').prompt_controllers.permission = M + +require('opencode.ui.formatter.system').register('permissions-display', function(output) + M.format_display(output) +end) + return M diff --git a/lua/opencode/ui/question_window.lua b/lua/opencode/ui/question_window.lua index 2318834e..8ecec070 100644 --- a/lua/opencode/ui/question_window.lua +++ b/lua/opencode/ui/question_window.lua @@ -939,4 +939,15 @@ function M._send_reject(request_id) end end +---@return OpencodeQuestionRequest|nil +function M.get_current_request() + return M._current_question +end + +require('opencode.ui.renderer.ctx').prompt_controllers.question = M + +require('opencode.ui.formatter.system').register('questions-display', function(output) + M.format_display(output) +end) + return M diff --git a/lua/opencode/ui/renderer.lua b/lua/opencode/ui/renderer.lua index cf069b3f..c28dfaa8 100644 --- a/lua/opencode/ui/renderer.lua +++ b/lua/opencode/ui/renderer.lua @@ -1,7 +1,6 @@ local state = require('opencode.state') local config = require('opencode.config') local output_window = require('opencode.ui.output_window') -local permission_window = require('opencode.ui.permission_window') local reference_facts = require('opencode.ui.reference_facts') local Promise = require('opencode.promise') local ctx = require('opencode.ui.renderer.ctx') @@ -293,7 +292,9 @@ function M.reset() ctx:reset() reference_facts.clear() output_window.clear() - permission_window.clear_all() + if ctx.prompt_controllers.permission then + ctx.prompt_controllers.permission.clear_all() + end state.renderer.reset() flush.trigger_on_data_rendered() end @@ -449,8 +450,13 @@ function M.render_from_cache(session_data) }) local active_session = state.active_session if active_session and active_session.id then - require('opencode.ui.question_window').restore_pending_question(active_session.id) - permission_window.restore_pending_permissions(active_session.id) + local prompts = ctx.prompt_controllers + if prompts.question then + prompts.question.restore_pending_question(active_session.id) + end + if prompts.permission then + prompts.permission.restore_pending_permissions(active_session.id) + end end end @@ -513,8 +519,13 @@ function M.render_full_session() }) local active_session = state.active_session if active_session and active_session.id then - require('opencode.ui.question_window').restore_pending_question(active_session.id) - permission_window.restore_pending_permissions(active_session.id) + local prompts = ctx.prompt_controllers + if prompts.question then + prompts.question.restore_pending_question(active_session.id) + end + if prompts.permission then + prompts.permission.restore_pending_permissions(active_session.id) + end end return session_data end) @@ -567,7 +578,8 @@ end ---Re-render the permission display when focus changes (updates shortcut hints) function M.on_focus_changed() - if not permission_window.get_all_permissions()[1] then + local permissions = ctx.prompt_controllers.permission + if not permissions or not permissions.get_all_permissions()[1] then return end flush.mark_part_dirty('permission-display-part', 'permission-display-message') diff --git a/lua/opencode/ui/renderer/buffer.lua b/lua/opencode/ui/renderer/buffer.lua index cb4160b9..139bca8a 100644 --- a/lua/opencode/ui/renderer/buffer.lua +++ b/lua/opencode/ui/renderer/buffer.lua @@ -486,7 +486,7 @@ function M.upsert_part_now(part_id, message_id, formatted_data, previous_formatt end 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 + if formatted_data.fold_ranges then M.update_part_folds(part_id) end @@ -670,6 +670,7 @@ function M.remove_part_now(part_id) output_window.shift_folds(cached.line_start, delta) ctx.render_state:remove_part(part_id) ctx.part_folds[part_id] = nil + M.set_all_folds() end ---@param message_id string @@ -692,6 +693,7 @@ function M.remove_message_now(message_id) local delta = -(cached.line_end - cached.line_start + 1) output_window.shift_folds(cached.line_start, delta) ctx.render_state:remove_message(message_id) + M.set_all_folds() end return M diff --git a/lua/opencode/ui/renderer/ctx.lua b/lua/opencode/ui/renderer/ctx.lua index 65ea0757..59f8ea83 100644 --- a/lua/opencode/ui/renderer/ctx.lua +++ b/lua/opencode/ui/renderer/ctx.lua @@ -2,8 +2,28 @@ local RenderState = require('opencode.ui.render_state') ---Shared mutable context for the renderer modules. ---Single instance, shared via Lua's require cache. +---@class PermissionController +---@field get_all_permissions fun(): OpencodePermission[] +---@field clear_all fun() +---@field restore_pending_permissions fun(session_id: string): Promise +---@field add_permission fun(permission: OpencodePermission) +---@field remove_permission fun(permission_id: string) +---@field update_permission_from_part fun(permission_id: string, part: OpencodeMessagePart) + +---@class QuestionController +---@field get_current_request fun(): OpencodeQuestionRequest|nil +---@field uses_vim_ui_select fun(request?: OpencodeQuestionRequest): boolean +---@field has_question fun(): boolean +---@field clear_question fun() +---@field show_question fun(request: OpencodeQuestionRequest) +---@field restore_pending_question fun(session_id: string): Promise +---@field matches_active_question fun(request: table): boolean + ---@class RendererCtx local ctx = { + ---Controllers are registered by the entry layer during plugin setup. + ---@type {permission?: PermissionController, question?: QuestionController} + prompt_controllers = {}, ---@type RenderState render_state = RenderState.new(), ---@type { part_id: string|nil, formatted_data: Output|nil } diff --git a/lua/opencode/ui/renderer/events.lua b/lua/opencode/ui/renderer/events.lua index 57b8b35f..b6560405 100644 --- a/lua/opencode/ui/renderer/events.lua +++ b/lua/opencode/ui/renderer/events.lua @@ -1,7 +1,7 @@ local state = require('opencode.state') local config = require('opencode.config') local ctx = require('opencode.ui.renderer.ctx') -local permission_window = require('opencode.ui.permission_window') +local prompts = ctx.prompt_controllers local flush = require('opencode.ui.renderer.flush') local reference_facts = require('opencode.ui.reference_facts') @@ -151,7 +151,7 @@ end ---Render pending permissions as a synthetic part at the end of the buffer function M.render_permissions_display() - local permissions = permission_window.get_all_permissions() + local permissions = prompts.permission and prompts.permission.get_all_permissions() or {} if not permissions or #permissions == 0 then flush.queue_part_removal('permission-display-part') flush.queue_message_removal('permission-display-message') @@ -185,8 +185,11 @@ end ---Render the current question as a synthetic part at the end of the buffer function M.render_question_display() - local question_window = require('opencode.ui.question_window') - local current_question = question_window._current_question + local question_window = prompts.question + if not question_window then + return + end + local current_question = question_window.get_current_request() if question_window.uses_vim_ui_select(current_question) then flush.queue_part_removal('question-display-part') @@ -226,7 +229,10 @@ end ---Remove the question display from the buffer function M.clear_question_display() - local question_window = require('opencode.ui.question_window') + local question_window = prompts.question + if not question_window then + return + end question_window.clear_question() end @@ -455,13 +461,13 @@ function M.on_part_updated(properties, revert_index) end -- Update the permission window if this part has a pending permission - if part.callID and state.pending_permissions then + if prompts.permission and part.callID and state.pending_permissions then for _, permission in ipairs(state.pending_permissions) do local tool = permission.tool local perm_callID = tool and tool.callID or permission.callID local perm_messageID = tool and tool.messageID or permission.messageID if perm_callID == part.callID and perm_messageID == part.messageID then - permission_window.update_permission_from_part(permission.id, part) + prompts.permission.update_permission_from_part(permission.id, part) break end end @@ -626,7 +632,10 @@ function M.on_permission_updated(permission) end end) - permission_window.add_permission(permission) + if not prompts.permission then + return + end + prompts.permission.add_permission(permission) M.render_permissions_display() end @@ -642,8 +651,11 @@ function M.on_permission_replied(properties) return end - permission_window.remove_permission(permission_id) - state.renderer.set_pending_permissions(vim.deepcopy(permission_window.get_all_permissions())) + if not prompts.permission then + return + end + prompts.permission.remove_permission(permission_id) + state.renderer.set_pending_permissions(vim.deepcopy(prompts.permission.get_all_permissions())) end ---Handle question.asked — show the question picker UI @@ -652,7 +664,10 @@ function M.on_question_asked(properties) if not properties or not properties.id or not properties.questions then return end - local question_window = require('opencode.ui.question_window') + local question_window = prompts.question + if not question_window then + return + end question_window.show_question(properties) end diff --git a/lua/opencode/ui/renderer/scroll.lua b/lua/opencode/ui/renderer/scroll.lua index 45dc0603..8ed1c21e 100644 --- a/lua/opencode/ui/renderer/scroll.lua +++ b/lua/opencode/ui/renderer/scroll.lua @@ -92,7 +92,7 @@ function M.scroll_win_to_bottom(win, buf) return end - local target_line = output_window.get_effective_bottom_line(buf, line_count) + local target_line = output_window.get_scroll_bottom_line(buf, line_count) if target_line <= 0 then return end diff --git a/lua/opencode/ui/session_picker.lua b/lua/opencode/ui/session_picker.lua index 7ff8d034..f05775aa 100644 --- a/lua/opencode/ui/session_picker.lua +++ b/lua/opencode/ui/session_picker.lua @@ -2,7 +2,6 @@ local M = {} local config = require('opencode.config') local base_picker = require('opencode.ui.base_picker') local util = require('opencode.util') -local api = require('opencode.api') local Promise = require('opencode.promise') ---Check whether any session id in `delete_ids` is the session itself or an ancestor @@ -426,4 +425,37 @@ function M.pick(sessions, callback, opts) }) end +---@param sessions Session[] +---@param cb fun(session: Session|nil) +---@param opts? { scope?: 'project' | 'global' } +function M.select(sessions, cb, opts) + local util = require('opencode.util') + local picker = require('opencode.ui.picker') + + local success = M.pick(sessions, cb, opts) + if not success then + picker.select(sessions, { + prompt = '', + format_item = function(session) + local parts = {} + + if session.title then + table.insert(parts, session.title) + else + table.insert(parts, session.id) + end + + local modified = util.format_time(session.modified) + if modified then + table.insert(parts, modified) + end + + return table.concat(parts, ' ~ ') + end, + }, function(session_choice) + cb(session_choice) + end) + end +end + return M diff --git a/lua/opencode/ui/timer.lua b/lua/opencode/ui/timer.lua index 9cbe6052..61621cbe 100644 --- a/lua/opencode/ui/timer.lua +++ b/lua/opencode/ui/timer.lua @@ -30,8 +30,11 @@ function Timer:start() self._uv_timer = timer local on_tick = vim.schedule_wrap(function() + if self._uv_timer ~= timer then + return + end local ok, continue = pcall(self.on_tick, unpack(self.args)) - if not ok or not self.repeat_timer or (continue == false) then + if self._uv_timer == timer and (not ok or not self.repeat_timer or (continue == false)) then self:stop() end end) diff --git a/lua/opencode/ui/ui.lua b/lua/opencode/ui/ui.lua index d969aadb..d1965986 100644 --- a/lua/opencode/ui/ui.lua +++ b/lua/opencode/ui/ui.lua @@ -530,40 +530,6 @@ function M.render_lines(lines) renderer.render_lines(lines) end ----@param sessions Session[] ----@param cb fun(session: Session|nil) ----@param opts? { scope?: 'project' | 'global' } -function M.select_session(sessions, cb, opts) - local session_picker = require('opencode.ui.session_picker') - local util = require('opencode.util') - local picker = require('opencode.ui.picker') - - local success = session_picker.pick(sessions, cb, opts) - if not success then - picker.select(sessions, { - prompt = '', - format_item = function(session) - local parts = {} - - if session.title then - table.insert(parts, session.title) - else - table.insert(parts, session.id) - end - - local modified = util.format_time(session.modified) - if modified then - table.insert(parts, modified) - end - - return table.concat(parts, ' ~ ') - end, - }, function(session_choice) - cb(session_choice) - end) - end -end - ---Switch focus between the input and output panes. function M.toggle_pane() local current_win = vim.api.nvim_get_current_win() @@ -577,20 +543,6 @@ function M.toggle_pane() end end ----Swap the split position and reopen the UI. -function M.swap_position() - local ui_conf = config.ui - local new_pos = (ui_conf.position == 'left') and 'right' or 'left' - config.values.ui.position = new_pos - - if state.windows then - M.close_windows(state.windows, false) - end - vim.schedule(function() - require('opencode.api').toggle(state.active_session == nil) - end) -end - ---Toggle the current Opencode window width between normal and zoomed. function M.toggle_zoom() local windows = state.windows diff --git a/scripts/dependency-topology/topology.jsonc b/scripts/dependency-topology/topology.jsonc index 5819f33c..f7643743 100644 --- a/scripts/dependency-topology/topology.jsonc +++ b/scripts/dependency-topology/topology.jsonc @@ -48,7 +48,6 @@ // UI modules that act as entry points: after user interaction, // they call api.lua (dispatch_action) to trigger commands. - "opencode.ui.session_picker", // user picks session → api.select_session "opencode.ui.timeline_picker", // user picks timeline → api.select_timeline_entry "opencode.ui.permission_window", // user grants/denies → api.permission_grant/deny "opencode.ui.question_window", // user answers question → handler callback @@ -99,17 +98,12 @@ "opencode.lsp.*", // LSP completion service // PR #360 services (split from core.lua) - "opencode.services.*", // REVIEW: only exists on clean-code-remove-core branch. - // Currently has 5 violations (services → UI entry), - // same deps core.lua had — now visible because grouped. - - "opencode.core", // REVIEW: god module (out-degree 21), being removed - // by PR #360. Violations: core→api, core→permission_window. + "opencode.services.*", // shared business operations // UI rendering & display "opencode.ui.ui", // window container management // REVIEW: currently violates no_capabilities_to_entry - // (→ api, autocmds, contextual_actions, session_picker). + // (→ autocmds, contextual_actions). // These deps should eventually be removed. "opencode.ui.input_window", // input buffer management "opencode.ui.output_window", // output buffer management @@ -132,6 +126,16 @@ "opencode.ui.mention", // @mention UI "opencode.ui.file_picker", // file browser "opencode.ui.picker", // generic picker + "opencode.ui.session_picker", // picker presentation; actions use session services + "opencode.ui.symbol_snapshot", + "opencode.ui.inline_input", + "opencode.ui.symbol_tokens", + "opencode.ui.reference_parser", + "opencode.ui.reference_facts", + "opencode.ui.event_scope", + "opencode.ui.float_layout", + "opencode.ui.skill_picker", + "opencode.ui.session_scope", "opencode.ui.history_picker", // history browser "opencode.ui.mcp_picker", // MCP tool browser "opencode.ui.permission.permission" // permission display @@ -163,6 +167,8 @@ "opencode.promise", // async primitive (in-degree 28) "opencode.log", // logging "opencode.types", // type definitions (zero runtime code) + "opencode.auth", // credentials and authentication headers + "opencode.sha1", // hashing primitive "opencode.id", // ID generation "opencode.curl", // HTTP low-level wrapper "opencode.throttling_emitter", // batching primitive @@ -190,7 +196,7 @@ "from": "entry_layer", "to": ["cli_infrastructure_layer"] // Entry should go through Dispatch/Capabilities, not call infra directly. - // Current violations (3): opencode→event_manager, health→opencode_server, health→server_job + // Startup and health entry points still call infrastructure directly. }, { "name": "no_dispatch_to_entry", @@ -203,16 +209,13 @@ "from": "capabilities_layer", "to": ["entry_layer"] // Capabilities must not drive Entry UI or call api.lua. - // Current violations (15) — the biggest category. Root causes: - // - renderer/formatter → permission_window/question_window (popup during render) - // - core/renderer/ui.ui → api (capability modules triggering commands) - // - ui.ui → autocmds/contextual_actions/session_picker (container knows entry modules) + // Remaining UI mounting and keymap wiring dependencies are tracked by scan output. }, { "name": "no_capabilities_to_dispatch", "from": "capabilities_layer", "to": ["dispatch_layer"] - // Current violations (2): completion.commands→commands.slash, input_window→commands.slash + // completion.commands and input_window currently depend on commands.slash // These need slash command list for completion — may need a data-only export. }, { diff --git a/tests/data/ansi-codes.expected.json b/tests/data/ansi-codes.expected.json index 54c3b41e..3c06b14b 100644 --- a/tests/data/ansi-codes.expected.json +++ b/tests/data/ansi-codes.expected.json @@ -8580,5 +8580,11 @@ "effective_bottom": 409, "line_count": 410, "visible_bottom": 410 + }, + "session_window": { + "cursor": [ + 408, + 4 + ] } } diff --git a/tests/replay/renderer_spec.lua b/tests/replay/renderer_spec.lua index d0d59d1f..f7b48051 100644 --- a/tests/replay/renderer_spec.lua +++ b/tests/replay/renderer_spec.lua @@ -6,7 +6,7 @@ local assert = require('luassert') local stub = require('luassert.stub') local config = require('opencode.config') -local function assert_output_matches(expected, actual, name) +local function assert_output_matches(expected, actual, name, expected_window_override) local normalized_extmarks = helpers.normalize_namespace_ids(actual.extmarks) local function legacy_effective_bottom(window) @@ -131,31 +131,36 @@ local function assert_output_matches(expected, actual, name) ) end - if expected.window then + local expected_window = expected.window + if expected_window_override then + expected_window = vim.tbl_deep_extend('force', vim.deepcopy(expected_window), expected_window_override) + end + + if expected_window then local actual_window = actual.window or {} - assert.are.same(expected.window.cursor, actual_window.cursor, 'Window cursor mismatch') - assert.are.same(expected.window.line_count, actual_window.line_count, 'Window line_count mismatch') + assert.are.same(expected_window.cursor, actual_window.cursor, 'Window cursor mismatch') + assert.are.same(expected_window.line_count, actual_window.line_count, 'Window line_count mismatch') - local expected_has_effective_bottom = expected.window.effective_bottom ~= nil + local expected_has_effective_bottom = expected_window.effective_bottom ~= nil if expected_has_effective_bottom then assert.are.same( - expected.window.effective_bottom, + expected_window.effective_bottom, actual_window.effective_bottom, 'Window effective_bottom mismatch' ) assert.is_true( - visible_bottom_equivalent(expected.window, actual_window), + visible_bottom_equivalent(expected_window, actual_window), string.format( 'Window visible_bottom mismatch: expected %s, got %s (effective_bottom=%s)', - vim.inspect(expected.window.visible_bottom), + vim.inspect(expected_window.visible_bottom), vim.inspect(actual_window.visible_bottom), - vim.inspect(expected.window.effective_bottom) + vim.inspect(expected_window.effective_bottom) ) ) else - local expected_visible_bottom = expected.window.visible_bottom + local expected_visible_bottom = expected_window.visible_bottom local actual_visible_bottom = actual_window.visible_bottom - local expected_effective_bottom = legacy_effective_bottom(expected.window) + local expected_effective_bottom = legacy_effective_bottom(expected_window) local matches_legacy_bottom_follow = actual_visible_bottom == expected_visible_bottom or actual_visible_bottom == expected_effective_bottom @@ -913,7 +918,7 @@ describe('renderer functional tests', function() end local actual = helpers.capture_output(state.windows and state.windows.output_buf, output_window.namespace) - assert_output_matches(expected, actual, name) + assert_output_matches(expected, actual, name, expected.session_window) end) end end diff --git a/tests/unit/api_client_spec.lua b/tests/unit/api_client_spec.lua index 3c4c2fce..c38ab2d7 100644 --- a/tests/unit/api_client_spec.lua +++ b/tests/unit/api_client_spec.lua @@ -126,18 +126,16 @@ describe('api_client', function() local received = {} server_job.stream_api = function(_, _, _, on_chunk) - on_chunk( - 'data: ' .. vim.json.encode({ - payload = { - id = 'evt_1', - type = 'session.status', - properties = { - sessionID = 'ses_1', - status = { type = 'busy' }, - }, + on_chunk('data: ' .. vim.json.encode({ + payload = { + id = 'evt_1', + type = 'session.status', + properties = { + sessionID = 'ses_1', + status = { type = 'busy' }, }, - }) - ) + }, + })) return { shutdown = function() end } end @@ -170,28 +168,26 @@ describe('api_client', function() local received = {} server_job.stream_api = function(_, _, _, on_chunk) - on_chunk( - 'data: ' .. vim.json.encode({ - payload = { - type = 'sync', - syncEvent = { - id = 'evt_2', - type = 'message.part.updated.1', - data = { + on_chunk('data: ' .. vim.json.encode({ + payload = { + type = 'sync', + syncEvent = { + id = 'evt_2', + type = 'message.part.updated.1', + data = { + sessionID = 'ses_1', + part = { + id = 'prt_1', + type = 'text', + text = 'hello', + messageID = 'msg_1', sessionID = 'ses_1', - part = { - id = 'prt_1', - type = 'text', - text = 'hello', - messageID = 'msg_1', - sessionID = 'ses_1', - }, }, }, - id = 'evt_2', }, - }) - ) + id = 'evt_2', + }, + })) return { shutdown = function() end } end @@ -221,3 +217,68 @@ describe('api_client', function() server_job.stream_api = original_stream_api end) end) + +describe('API startup responsiveness', function() + local Promise = require('opencode.promise') + local state = require('opencode.state') + local server_job = require('opencode.server_job') + local original + before_each(function() + original = { + ensure = server_job.ensure_server, + call = server_job.call_api, + stream = server_job.stream_api, + server = state.opencode_server, + cwd = state.current_cwd, + version = state.opencode_cli_version, + } + state.jobs.clear_server() + state.context.set_current_cwd('/origin') + end) + after_each(function() + server_job.ensure_server, server_job.call_api, server_job.stream_api = + original.ensure, original.call, original.stream + state.jobs.set_server(original.server) + state.context.set_current_cwd(original.cwd) + state.jobs.set_opencode_cli_version(original.version) + end) + it('shares pending startup and captures each request directory before yielding', function() + local starting, calls, starts = Promise.new(), {}, 0 + server_job.ensure_server = function() + starts = starts + 1 + return starting + end + server_job.call_api = function(url) + calls[#calls + 1] = url + return Promise.new():resolve({}) + end + local client = api_client.new() + local first, second = client:list_projects(), client:list_sessions() + assert.is_false(first:is_resolved()) + assert.equals(1, starts) + state.context.set_current_cwd('/later') + starting:resolve({ url = 'http://localhost:8080' }) + first:wait() + second:wait() + assert.equals(2, #calls) + for _, url in ipairs(calls) do + assert.matches('directory=%%2Forigin', url) + end + end) + it('cancels a subscription before version detection completes', function() + local version = Promise.new() + state.jobs.set_opencode_cli_version(version) + local calls = 0 + server_job.stream_api = function() + calls = calls + 1 + end + local handle = api_client.new('http://localhost:8080'):subscribe_to_events('/origin', function() end) + handle:shutdown() + version:resolve('1.14.42') + vim.wait(20, function() + return false + end) + assert.equals(0, calls) + assert.is_false(handle:is_running()) + end) +end) diff --git a/tests/unit/completion_files_spec.lua b/tests/unit/completion_files_spec.lua new file mode 100644 index 00000000..0692f8ab --- /dev/null +++ b/tests/unit/completion_files_spec.lua @@ -0,0 +1,85 @@ +local Promise = require('opencode.promise') +local config = require('opencode.config') +local state = require('opencode.state') + +describe('file completion responsiveness', function() + local original_system, original_executable, original_client, original_config + local source + + before_each(function() + original_system = vim.system + original_executable = vim.fn.executable + original_client = state.api_client + original_config = vim.deepcopy(config.ui.completion.file_sources) + config.ui.completion.file_sources.preferred_cli_tool = 'server' + config.ui.completion.file_sources.enabled = true + config.ui.completion.file_sources.ignore_patterns = {} + package.loaded['opencode.ui.completion.files'] = nil + source = require('opencode.ui.completion.files').get_source() + end) + + after_each(function() + vim.system = original_system + vim.fn.executable = original_executable + state.jobs.set_api_client(original_client) + config.ui.completion.file_sources = original_config + package.loaded['opencode.ui.completion.files'] = nil + end) + + local function complete() + return source.complete({ input = 'file', trigger_char = source.get_trigger_character() }) + end + + it('returns control while the server search is pending', function() + local search = Promise.new() + state.jobs.set_api_client({ + find_files = function() + return search + end, + }) + local result = complete() + assert.is_false(result:is_resolved()) + search:resolve({ 'file.lua' }) + assert.equals('file.lua', result:wait()[1].insert_text) + end) + + it('falls back asynchronously when the server search rejects', function() + state.jobs.set_api_client({ + find_files = function() + return Promise.new():reject('offline') + end, + }) + vim.fn.executable = function(tool) + return tool == 'fd' and 1 or 0 + end + local on_exit + vim.system = function(_, _, cb) + on_exit = cb + return {} + end + local result = complete() + assert.is_function(on_exit) + assert.is_false(result:is_resolved()) + on_exit({ code = 0, stdout = 'file.lua\n' }) + assert.equals('file.lua', result:wait()[1].insert_text) + end) + + it('skips unavailable executables and limits server results', function() + config.ui.completion.file_sources.preferred_cli_tool = 'fd' + config.ui.completion.file_sources.max_files = 1 + package.loaded['opencode.ui.completion.files'] = nil + source = require('opencode.ui.completion.files').get_source() + vim.fn.executable = function() + return 0 + end + vim.system = function() + error('unavailable tools must not run') + end + state.jobs.set_api_client({ + find_files = function() + return Promise.new():resolve({ 'file.lua', 'file2.lua' }) + end, + }) + assert.equals(1, #complete():wait()) + end) +end) diff --git a/tests/unit/curl_spec.lua b/tests/unit/curl_spec.lua index c557d040..8205f40e 100644 --- a/tests/unit/curl_spec.lua +++ b/tests/unit/curl_spec.lua @@ -11,6 +11,61 @@ describe('curl stream handle lifecycle', function() vim.system = original_system end) + it('parses the final response after informational and proxy headers', function() + local response + vim.system = function(_, _, cb) + cb({ + code = 0, + stdout = 'HTTP/1.1 200 Connection established\r\n\r\n' + .. 'HTTP/1.1 100 Continue\r\nX-Interim: yes\r\n\r\n' + .. 'HTTP/2 403 Forbidden\r\nContent-Type: application/json\r\n\r\n{"error":"denied"}', + }) + end + curl.request({ + url = 'https://example.test', + callback = function(value) + response = value + end, + }) + assert.equals(403, response.status) + assert.equals('{"error":"denied"}', response.body) + assert.same({ ['content-type'] = 'application/json' }, response.headers) + end) + + it('preserves body text that resembles HTTP headers', function() + local response + vim.system = function(_, _, cb) + cb({ code = 0, stdout = 'HTTP/1.1 200 OK\n\nHTTP/1.1 404 Not Found\n\nbody' }) + end + curl.request({ + url = 'http://example.test', + callback = function(value) + response = value + end, + }) + assert.equals(200, response.status) + assert.equals('HTTP/1.1 404 Not Found\n\nbody', response.body) + end) + + it('preserves streaming lines across arbitrary chunk boundaries and EOF', function() + local stdout, complete + local lines = {} + vim.system = function(_, opts, cb) + stdout, complete = opts.stdout, cb + return { pid = 123 } + end + curl.request({ + url = 'http://example.test/event', + stream = function(_, line) + lines[#lines + 1] = line + end, + }) + stdout(nil, 'one\ntw') + stdout(nil, 'o\n\nthree\nfour') + complete({ code = 0, signal = 0 }) + assert.same({ 'one\n', 'two\n', '\n', 'three\n', 'four' }, lines) + end) + it('marks stream handle as stopped after process exit', function() local on_complete diff --git a/tests/unit/cursor_tracking_spec.lua b/tests/unit/cursor_tracking_spec.lua index ef61c4c9..540c77a6 100644 --- a/tests/unit/cursor_tracking_spec.lua +++ b/tests/unit/cursor_tracking_spec.lua @@ -458,6 +458,29 @@ describe('renderer.scroll_to_bottom', function() assert.equals(2, cursor[1]) end) + it('keeps a terminal fold reachable when multiple padding lines follow it', function() + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'line 1', + 'line 2', + 'fold line 1', + 'fold line 2', + 'fold line 3', + '', + '', + }) + output_window.setup({ output_buf = buf, output_win = win }) + output_window.set_folds({ { from = 3, to = 5 } }) + + local scroll = require('opencode.ui.renderer.scroll') + scroll.scroll_win_to_bottom(win, buf) + + assert.equals(5, vim.api.nvim_win_get_cursor(win)[1]) + vim.api.nvim_win_call(win, function() + vim.cmd('normal! zo') + end) + assert.equals(-1, vim.fn.foldclosed(3)) + end) + it('skips zb when the followed bottom line is already visible', function() vim.api.nvim_buf_set_lines(buf, 0, -1, false, { 'line 1', 'line 2', 'line 3' }) vim.api.nvim_win_set_height(win, 10) diff --git a/tests/unit/event_manager_spec.lua b/tests/unit/event_manager_spec.lua index b7df5c61..f2dc91a3 100644 --- a/tests/unit/event_manager_spec.lua +++ b/tests/unit/event_manager_spec.lua @@ -65,6 +65,22 @@ describe('EventManager', function() assert.is_true(callback2_called) end) + it('does not skip listeners when a callback unsubscribes itself', function() + local calls = {} + local first + first = function() + calls[#calls + 1] = 'first' + event_manager:unsubscribe('test_event', first) + end + event_manager:subscribe('test_event', first) + event_manager:subscribe('test_event', function() + calls[#calls + 1] = 'second' + end) + event_manager:emit('test_event', {}) + event_manager:emit('test_event', {}) + assert.same({ 'first', 'second', 'second' }, calls) + end) + it('should unsubscribe correctly', function() local callback_called = false local callback = function(data) @@ -329,3 +345,105 @@ describe('EventManager', function() end) end) end) + +describe('EventManager subscription lifecycle', function() + local manager, original_client, original_defer, original_server + + before_each(function() + manager = EventManager.new() + original_client = state.api_client + original_server = state.opencode_server + original_defer = vim.defer_fn + end) + + after_each(function() + manager:stop() + manager:_cleanup_server_subscription() + vim.defer_fn = original_defer + state.jobs.set_api_client(original_client) + state.jobs.set_server(original_server) + vim.wait(10, function() + return false + end) + end) + + it('discards buffered and late events from a replaced subscription', function() + local callbacks = {} + state.jobs.set_api_client({ + subscribe_to_events = function(_, _, callback) + callbacks[#callbacks + 1] = callback + return { shutdown = function() end } + end, + }) + local server = { url = 'http://example.test' } + manager:_subscribe_to_server_events(server) + callbacks[1]({ type = 'session.idle', properties = { sessionID = 'old' } }) + manager:_subscribe_to_server_events(server) + callbacks[1]({ type = 'session.idle', properties = { sessionID = 'late' } }) + callbacks[2]({ type = 'session.idle', properties = { sessionID = 'new' } }) + assert.equals(1, #manager.throttling_emitter.queue) + assert.equals('new', manager.throttling_emitter.queue[1].properties.sessionID) + end) + + it('does not reconnect from a delayed ready callback after stop', function() + local deferred + vim.defer_fn = function(callback) + deferred = callback + end + local calls = 0 + manager._subscribe_to_server_events = function() + calls = calls + 1 + end + local server = { url = 'http://example.test' } + server.get_spawn_promise = function() + return Promise.new():resolve(server) + end + server.get_shutdown_promise = function() + return Promise.new() + end + manager:start() + state.jobs.set_server(server) + assert.is_true(vim.wait(200, function() + return deferred ~= nil + end)) + manager:stop() + deferred() + assert.equals(0, calls) + end) + + it('ignores an old server shutdown after the server is replaced', function() + local shutdown = Promise.new() + local old = { url = 'http://old.test' } + old.get_spawn_promise = function() + return Promise.new():resolve(old) + end + old.get_shutdown_promise = function() + return shutdown + end + vim.defer_fn = function() end + manager:start() + state.jobs.set_server(old) + vim.wait(20, function() + return false + end) + local replacement = { url = 'http://new.test' } + replacement.get_spawn_promise = function() + return Promise.new() + end + replacement.get_shutdown_promise = function() + return Promise.new() + end + state.jobs.set_server(replacement) + local stopped = false + manager.server_subscription = { + shutdown = function() + stopped = true + end, + } + shutdown:resolve(true) + vim.wait(20, function() + return false + end) + assert.is_false(stopped) + end) +end) diff --git a/tests/unit/git_review_spec.lua b/tests/unit/git_review_spec.lua new file mode 100644 index 00000000..119346ff --- /dev/null +++ b/tests/unit/git_review_spec.lua @@ -0,0 +1,78 @@ +local Promise = require('opencode.promise') +local state = require('opencode.state') +local snapshot = require('opencode.snapshot') +local diff_tab = require('opencode.ui.diff_tab') +local picker = require('opencode.ui.picker') + +describe('asynchronous git review', function() + local original, review, cwd, displayed + before_each(function() + original = { + snapshot = vim.tbl_extend('force', {}, snapshot), + cwd = vim.fn.getcwd, + session = state.active_session, + display = diff_tab.open_diff_tab, + select = picker.select, + } + cwd, displayed = '/project', {} + vim.fn.getcwd = function() + return cwd + end + state.session.set_active({ id = 'one' }) + snapshot.with_context = function(fn) + return Promise.spawn(fn) + end + snapshot.patch = function() + return Promise.new():resolve({ files = { '/project/file.lua' } }) + end + snapshot.diff_file = function(_, file) + return Promise.new():resolve({ left = file, right = '/tmp/before', file_type = 'lua' }) + end + diff_tab.open_diff_tab = function(left) + displayed[#displayed + 1] = left + end + package.loaded['opencode.git_review'] = nil + review = require('opencode.git_review') + end) + after_each(function() + for key, value in pairs(original.snapshot) do + snapshot[key] = value + end + vim.fn.getcwd = original.cwd + state.session.set_active(original.session) + diff_tab.open_diff_tab, picker.select = original.display, original.select + package.loaded['opencode.git_review'] = nil + end) + it('does not display stale results after a directory switch', function() + local patch = Promise.new() + snapshot.patch = function() + return patch + end + local result = review.review('hash') + assert.is_false(result:is_resolved()) + cwd = '/other' + patch:resolve({ files = { '/project/file.lua' } }) + result:wait() + assert.same({}, displayed) + end) + it('keeps the command pending until picker selection and ignores a stale choice', function() + snapshot.patch = function() + return Promise.new():resolve({ files = { '/project/a', '/project/b' } }) + end + local choice, items + picker.select = function(values, _, callback) + items, choice = values, callback + end + local result = review.review('hash') + assert.is_function(choice) + assert.is_false(result:is_resolved()) + state.session.set_active({ id = 'two' }) + choice(items[1]) + result:wait() + assert.same({}, displayed) + end) + it('displays a completed diff for the active workspace', function() + review.review('hash'):wait() + assert.same({ '/project/file.lua' }, displayed) + end) +end) diff --git a/tests/unit/history_spec.lua b/tests/unit/history_spec.lua new file mode 100644 index 00000000..f812d3cb --- /dev/null +++ b/tests/unit/history_spec.lua @@ -0,0 +1,58 @@ +describe('prompt history persistence', function() + local history, data_dir, original_stdpath, original_rename + before_each(function() + data_dir = vim.fn.tempname() + vim.fn.mkdir(data_dir .. '/opencode', 'p') + original_stdpath = vim.fn.stdpath + original_rename = vim.uv.fs_rename + vim.fn.stdpath = function(kind) + return kind == 'data' and data_dir or original_stdpath(kind) + end + package.loaded['opencode.history'] = nil + history = require('opencode.history') + end) + after_each(function() + vim.fn.stdpath = original_stdpath + vim.uv.fs_rename = original_rename + package.loaded['opencode.history'] = nil + vim.fn.delete(data_dir, 'rf') + end) + local function reload() + package.loaded['opencode.history'] = nil + history = require('opencode.history') + return history.read() + end + it('round trips literal escapes, newlines, quotes, Unicode and whitespace', function() + local prompts = { [[print('\n')]], 'first\nsecond', '"quoted" café\\path', ' ' } + for _, prompt in ipairs(prompts) do + assert.is_true(history.write(prompt)) + end + assert.same({ prompts[4], prompts[3], prompts[2], prompts[1] }, reload()) + end) + it('migrates legacy records once and does not resurrect them after clear', function() + vim.fn.writefile({ 'old\\nmultiline', 'latest' }, data_dir .. '/opencode/history.txt') + assert.same({ 'latest', 'old\nmultiline' }, history.read()) + history.write([[new\ntext]]) + assert.same({ [[new\ntext]], 'latest', 'old\nmultiline' }, reload()) + history.clear() + assert.same({}, reload()) + assert.equals(2, #vim.fn.readfile(data_dir .. '/opencode/history.txt')) + end) + it('removes each selected index once and ignores invalid indices', function() + for _, prompt in ipairs({ 'a', 'b', 'c', 'd' }) do + history.write(prompt) + end + history.delete({ 2, 2, 0, -1, 1.5, '3', 9 }) + assert.same({ 'd', 'b', 'a' }, reload()) + end) + it('preserves the file and cached history when replacement fails', function() + history.write('a') + history.write('b') + vim.uv.fs_rename = function() + return nil, 'permission denied' + end + assert.is_false(history.delete({ 1 })) + assert.same({ 'b', 'a' }, history.read()) + assert.same({ 'b', 'a' }, reload()) + end) +end) diff --git a/tests/unit/opencode_server_spec.lua b/tests/unit/opencode_server_spec.lua index c8da57c0..028c1f44 100644 --- a/tests/unit/opencode_server_spec.lua +++ b/tests/unit/opencode_server_spec.lua @@ -5,11 +5,24 @@ local assert = require('luassert') describe('opencode.opencode_server', function() local original_system local original_curl_request + local original_kill + local original_get_children before_each(function() + original_kill = vim.uv.kill + original_get_children = vim.api.nvim_get_proc_children + -- Fake job PIDs must never reach the operating system. + vim.uv.kill = function() + return true + end + vim.api.nvim_get_proc_children = function() + return {} + end original_system = vim.system original_curl_request = curl.request end) after_each(function() + vim.uv.kill = original_kill + vim.api.nvim_get_proc_children = original_get_children vim.system = original_system curl.request = original_curl_request end) diff --git a/tests/unit/output_window_spec.lua b/tests/unit/output_window_spec.lua index e5d1b21e..e3917529 100644 --- a/tests/unit/output_window_spec.lua +++ b/tests/unit/output_window_spec.lua @@ -278,6 +278,38 @@ describe('output_window.setup', function() assert.equals(7, foldclosed_at(7)) assert.equals(7, foldclosed_at(8)) end) + + it('replaces stale manual folds when ranges change', function() + output_window.setup({ output_buf = buf, output_win = win }) + output_window.set_lines({ 'a', 'b', 'c', 'd', 'e', 'f' }) + + output_window.set_folds({ { from = 1, to = 5 } }) + output_window.set_folds({ { from = 1, to = 3 } }) + + local fold_end = vim.api.nvim_win_call(win, function() + return vim.fn.foldclosedend(1) + end) + + assert.equals(3, fold_end) + end) + + it('preserves an open fold when its range changes', function() + output_window.setup({ output_buf = buf, output_win = win }) + output_window.set_lines({ 'a', 'b', 'c', 'd', 'e', 'f' }) + output_window.set_folds({ { from = 1, to = 3 } }) + + vim.api.nvim_win_set_cursor(win, { 1, 0 }) + vim.api.nvim_win_call(win, function() + vim.cmd('normal! zo') + end) + output_window.set_folds({ { from = 1, to = 5 } }) + + local fold_closed = vim.api.nvim_win_call(win, function() + return vim.fn.foldclosed(1) + end) + + assert.equals(-1, fold_closed) + end) end) describe('output_window extmarks', function() diff --git a/tests/unit/promise_spec.lua b/tests/unit/promise_spec.lua new file mode 100644 index 00000000..c959cb1b --- /dev/null +++ b/tests/unit/promise_spec.lua @@ -0,0 +1,117 @@ +local Promise = require('opencode.promise') + +describe('Promise settlement', function() + for _, rejected in ipairs({ false, true }) do + it('releases listeners and waiting coroutines after ' .. (rejected and 'rejection' or 'resolution'), function() + local promise = Promise.new() + local callbacks = 0 + local next_promise = promise + :and_then(function(value) + callbacks = callbacks + 1 + return value + end) + :catch(function(err) + callbacks = callbacks + 1 + return err + end) + local awaiting = Promise.spawn(function() + return promise:await() + end) + if rejected then + promise:reject('result') + else + promise:resolve('result') + end + assert.same({}, promise._then_callbacks) + assert.same({}, promise._catch_callbacks) + assert.same({}, promise._coroutines) + assert.equals('result', next_promise:wait()) + assert.equals(1, callbacks) + assert.is_true(vim.wait(200, function() + return awaiting:is_resolved() + end)) + assert.equals(rejected, awaiting:is_rejected()) + end) + end + + for _, reason in ipairs({ { value = false }, {} }) do + for _, already_settled in ipairs({ false, true }) do + it( + 'preserves falsy rejection through chaining, finally, wait and await (' + .. tostring(reason.value) + .. ', settled=' + .. tostring(already_settled) + .. ')', + function() + local promise = Promise.new() + if already_settled then + promise:reject(reason.value) + end + local success_called = false + local chained = promise:and_then(function() + success_called = true + end) + local finally_called = false + local finalized = chained:finally(function() + finally_called = true + end) + local waiting = Promise.spawn(function() + return promise:await() + end) + local caught = false + local recovered = promise:catch(function(err) + caught = true + assert.equals(reason.value, err) + return 'recovered' + end) + if not already_settled then + promise:reject(reason.value) + end + assert.equals('recovered', recovered:wait()) + assert.is_true(vim.wait(200, function() + return finalized:is_resolved() and waiting:is_resolved() + end)) + assert.is_true(caught) + assert.is_true(finally_called) + assert.is_false(success_called) + assert.is_true(finalized:is_rejected()) + assert.is_true(waiting:is_rejected()) + local ok, err = pcall(function() + return promise:wait() + end) + assert.is_false(ok) + assert.equals(reason.value, err) + end + ) + end + end + + it('settles only once and accepts late success handlers', function() + local promise = Promise.new():resolve(false) + promise:reject('too late') + assert.is_false(promise:is_rejected()) + assert.is_false(promise + :and_then(function(value) + return value + end) + :wait()) + end) +end) + +describe('Promise error propagation', function() + it('preserves the original error through nested coroutine boundaries', function() + local first = Promise.new() + local second = Promise.spawn(function() + return first:await() + end) + local third = Promise.spawn(function() + return second:await() + end) + first:reject('address already in use') + local ok, err = pcall(function() + third:wait() + end) + assert.is_false(ok) + assert.equals('address already in use', err) + end) +end) diff --git a/tests/unit/server_job_spec.lua b/tests/unit/server_job_spec.lua index 677740c7..c2b990be 100644 --- a/tests/unit/server_job_spec.lua +++ b/tests/unit/server_job_spec.lua @@ -499,3 +499,86 @@ describe('server_job', function() end) end) end) + +describe('concurrent server startup', function() + local state = require('opencode.state') + local config = require('opencode.config') + local OpencodeServer = require('opencode.opencode_server') + local port_mapping = require('opencode.port_mapping') + local original, starts, spawned, callbacks + before_each(function() + original = { + server = state.opencode_server, + new = OpencodeServer.new, + register = port_mapping.register, + url = config.values.server.url, + } + starts, spawned, callbacks = 0, {}, {} + config.values.server.url = nil + state.jobs.clear_server() + port_mapping.register = function() end + OpencodeServer.new = function() + local server = { spawn_promise = Promise.new(), url = nil } + server.is_running = function(self) + return self.job ~= nil + end + server.get_spawn_promise = function(self) + return self.spawn_promise + end + server.check_health = function() + error('startup must finish before health checks run') + end + server.spawn = function(self, opts) + starts = starts + 1 + self.job = { pid = 123 } + spawned[#spawned + 1], callbacks[#callbacks + 1] = self, opts + end + return server + end + end) + after_each(function() + state.jobs.set_server(original.server) + OpencodeServer.new, port_mapping.register = original.new, original.register + config.values.server.url = original.url + end) + local function ready(index) + local server = spawned[index] + server.url = 'http://127.0.0.1:4096' + server.spawn_promise:resolve(server) + callbacks[index].on_ready(server.job, server.url) + end + it('shares a single startup between API initialization and panel opening', function() + local client = require('opencode.api_client').new() + local api = client:_ensure_base_url() + local panel = server_job.ensure_server() + local another_panel = server_job.ensure_server() + assert.equals(1, starts) + assert.equals(panel, another_panel) + assert.is_false(api:is_resolved()) + assert.is_false(panel:is_resolved()) + ready(1) + assert.is_true(api:wait()) + assert.equals(spawned[1], panel:wait()) + end) + it('joins a directly spawned process before health checking it', function() + local direct = Promise.new() + server_job.spawn_local_server(direct) + local panel = server_job.ensure_server() + assert.equals(1, starts) + ready(1) + assert.equals(spawned[1], direct:wait()) + assert.equals(spawned[1], panel:wait()) + end) + it('releases failed startup so the next request can retry', function() + local first = server_job.ensure_server() + spawned[1].job = nil + callbacks[1].on_error('address already in use') + assert.is_false(pcall(function() + first:wait() + end)) + local second = server_job.ensure_server() + assert.equals(2, starts) + ready(2) + assert.equals(spawned[2], second:wait()) + end) +end) diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 9df6066e..ad33e339 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -298,7 +298,7 @@ describe('opencode.services.session_runtime', function() return p end) local passed - stub(ui, 'select_session').invokes(function(sessions, cb) + stub(require('opencode.ui.session_picker'), 'select').invokes(function(sessions, cb) passed = sessions cb(sessions[2]) end) @@ -324,7 +324,7 @@ describe('opencode.services.session_runtime', function() return Promise.new():resolve(mock_sessions) end) local passed - stub(ui, 'select_session').invokes(function(sessions, cb) + stub(require('opencode.ui.session_picker'), 'select').invokes(function(sessions, cb) passed = sessions cb(nil) end) @@ -406,7 +406,6 @@ describe('opencode.services.session_runtime', function() input_window._hide:revert() state.ui.is_visible = orig_is_visible end) - end) describe('cancel', function() diff --git a/tests/unit/snapshot_spec.lua b/tests/unit/snapshot_spec.lua index c4974be8..789de3bc 100644 --- a/tests/unit/snapshot_spec.lua +++ b/tests/unit/snapshot_spec.lua @@ -3,127 +3,215 @@ local state = require('opencode.state') local Promise = require('opencode.promise') local config_file = require('opencode.config_file') --- Save originals to restore after tests -local orig_notify = vim.notify -local orig_system = vim.system -local orig_getcwd = vim.fn.getcwd -local orig_get_workspace_snapshot_path = config_file.get_workspace_snapshot_path - -describe('snapshot.restore', function() - local system_calls = {} - +describe('asynchronous snapshot operations', function() + local original_system, original_notify, original_getcwd, original_path, original_session, original_delete + local calls, notify, cwd before_each(function() - -- Reset system calls tracking - system_calls = {} - - -- Mock notify, system, getcwd - vim.notify = function(msg, level) - vim.g._last_notify = { msg = msg, level = level } + original_system, original_notify = vim.system, vim.notify + original_getcwd, original_path = vim.fn.getcwd, config_file.get_workspace_snapshot_path + original_session, original_delete = state.active_session, vim.fn.delete + cwd, calls = '/mock/project/root', {} + vim.fn.getcwd = function() + return cwd end - - vim.system = function(cmd, opts) - table.insert(system_calls, { cmd = cmd, opts = opts }) - vim.g._last_system = { cmd = cmd, opts = opts } - -- Simulate success for both commands + vim.notify = function(message) + notify = message + end + config_file.get_workspace_snapshot_path = function() + return Promise.new():resolve('/mock/gitdir') + end + state.session.set_active({ id = 'origin' }) + vim.system = function(cmd, opts, on_exit) + calls[#calls + 1] = { cmd = cmd, opts = opts } + on_exit({ code = 0, stdout = '', stderr = '' }) return { wait = function() - return { code = 0, stdout = '', stderr = '' } + error('must not block on a system process') end, } end - - vim.fn.getcwd = function() - return '/mock/project/root' - end - - -- Mock config_file.get_workspace_snapshot_path to return a resolved promise - config_file.get_workspace_snapshot_path = function() - local p = Promise.new() - p:resolve('/mock/gitdir') - return p - end - - state.session.set_active({ snapshot_path = '/mock/gitdir' }) - vim.g._last_notify = nil - vim.g._last_system = nil end) - after_each(function() - vim.notify = orig_notify - vim.system = orig_system - vim.fn.getcwd = orig_getcwd - config_file.get_workspace_snapshot_path = orig_get_workspace_snapshot_path - state.session.clear_active() - vim.g._last_notify = nil - vim.g._last_system = nil - system_calls = {} + vim.system, vim.notify = original_system, original_notify + vim.fn.getcwd, config_file.get_workspace_snapshot_path = original_getcwd, original_path + vim.fn.delete = original_delete + state.session.set_active(original_session) end) - - it('runs read-tree and checkout-index and notifies on success', function() - snapshot.restore('abc123') - - -- Should have made 2 system calls - assert.equal(2, #system_calls) - - -- First call: read-tree - assert.same({ 'git', '--git-dir', '/mock/gitdir', '--work-tree', '/mock/project/root', 'read-tree', 'abc123' }, system_calls[1].cmd) - - -- Second call: checkout-index - assert.same({ 'git', '--git-dir', '/mock/gitdir', '--work-tree', '/mock/project/root', 'checkout-index', '-a', '-f' }, system_calls[2].cmd) - - -- Notification - assert.is_truthy(vim.g._last_notify) - assert.is_truthy(vim.g._last_notify.msg:find('Restored snapshot')) + it('runs read-tree and checkout-index in order', function() + assert.is_true(snapshot.restore('abc123'):wait()) + assert.equals(2, #calls) + assert.same({ 'git', '--git-dir', '/mock/gitdir', '--work-tree', cwd, 'read-tree', 'abc123' }, calls[1].cmd) + assert.same({ 'git', '--git-dir', '/mock/gitdir', '--work-tree', cwd, 'checkout-index', '-a', '-f' }, calls[2].cmd) + assert.matches('Restored snapshot', notify) end) - - it('notifies error if no active session', function() + it('rejects without an active session', function() state.session.clear_active() - -- When there's no active session, the promise still resolves but snapshot_git will fail - config_file.get_workspace_snapshot_path = function() - local p = Promise.new() - p:resolve(nil) - return p + local result = snapshot.restore('abc123') + assert.is_true(result:is_rejected()) + assert.equals(0, #calls) + end) + it('stops after read-tree failure', function() + vim.system = function(_, _, cb) + cb({ code = 1, stderr = 'read-tree failed' }) end - snapshot.restore('abc123') - assert.is_truthy(vim.g._last_notify) - -- Should match either "No snapshot path" or "Failed to read-tree" depending on implementation - local msg = vim.g._last_notify.msg - assert.is_truthy(msg:find('No snapshot path') or msg:find('Failed to read%-tree')) + assert.is_nil(snapshot.restore('abc123'):wait()) + assert.matches('Failed to read%-tree', notify) end) - - it('notifies error if read-tree fails', function() - vim.system = function(cmd, opts) - return { - wait = function() - return { code = 1, stdout = '', stderr = 'fail read-tree' } - end, - } + it('reports checkout-index failure', function() + local count = 0 + vim.system = function(_, _, cb) + count = count + 1 + cb({ code = count == 1 and 0 or 1, stdout = '', stderr = 'checkout failed' }) + end + assert.is_nil(snapshot.restore('abc123'):wait()) + assert.matches('Failed to checkout%-index', notify) + end) + it('yields and keeps the originating directory across startup and Git waits', function() + local path = Promise.new() + config_file.get_workspace_snapshot_path = function(directory) + assert.equals('/mock/project/root', directory) + return path + end + local callbacks = {} + vim.system = function(cmd, opts, cb) + calls[#calls + 1] = { cmd = cmd, opts = opts } + callbacks[#callbacks + 1] = cb + return {} + end + local result = snapshot.restore('abc123') + assert.is_false(result:is_resolved()) + cwd = '/another/project' + state.session.set_active({ id = 'another' }) + path:resolve('/mock/gitdir') + assert.is_true(vim.wait(200, function() + return #callbacks == 1 + end)) + callbacks[1]({ code = 0, stdout = '' }) + assert.is_true(vim.wait(200, function() + return #callbacks == 2 + end)) + callbacks[2]({ code = 0, stdout = '' }) + assert.is_true(result:wait()) + for _, call in ipairs(calls) do + assert.equals('/mock/project/root', call.opts.cwd) + assert.equals('/mock/project/root', call.cmd[5]) + end + end) + it('serializes operations sharing a snapshot index', function() + local callbacks = {} + vim.system = function(cmd, _, cb) + calls[#calls + 1] = cmd + callbacks[#callbacks + 1] = cb + return {} end - snapshot.restore('abc123') - assert.is_truthy(vim.g._last_notify) - assert.is_truthy(vim.g._last_notify.msg:find('Failed to read%-tree')) + local first, second = snapshot.restore('first'), snapshot.restore('second') + assert.equals(1, #calls) + callbacks[1]({ code = 0, stdout = '' }) + assert.is_true(vim.wait(200, function() + return #calls == 2 + end)) + callbacks[2]({ code = 0, stdout = '' }) + assert.is_true(vim.wait(200, function() + return #calls == 3 + end)) + assert.equals('second', calls[3][7]) + callbacks[3]({ code = 0, stdout = '' }) + assert.is_true(vim.wait(200, function() + return #calls == 4 + end)) + callbacks[4]({ code = 0, stdout = '' }) + assert.is_true(first:wait()) + assert.is_true(second:wait()) end) + it('never deletes a file when checkout fails for a present snapshot path', function() + local deleted = false + vim.fn.delete = function() + deleted = true + end + vim.system = function(cmd, _, cb) + local command = cmd[6] + cb({ + code = command == 'checkout' and 1 or 0, + stdout = command == 'write-tree' and 'backup\n' or command == 'ls-tree' and 'file.lua\n' or '', + stderr = 'index locked', + }) + end + local result = snapshot.revert_file('abc123', cwd .. '/file.lua') + local ok = pcall(function() + result:wait() + end) + assert.is_false(ok) + assert.is_false(deleted) + end) + it('accepts file paths through symlink aliases', function() + local real = vim.fn.tempname() + local alias = real .. '-alias' + vim.fn.mkdir(real, 'p') + assert.is_true(vim.uv.fs_symlink(real, alias)) + vim.fn.writefile({ 'content' }, real .. '/file.lua') + cwd = real + + local result = snapshot.diff_file('abc123', alias .. '/file.lua'):wait() + + assert.equals(alias .. '/file.lua', result.left) + assert.equals('abc123:file.lua', calls[1].cmd[#calls[1].cmd]) + vim.fn.delete(alias, 'rf') + vim.fn.delete(real, 'rf') + end) +end) - it('notifies error if checkout-index fails', function() - local call_count = 0 - vim.system = function(cmd, opts) - call_count = call_count + 1 - if call_count == 1 then - return { - wait = function() - return { code = 0, stdout = '', stderr = '' } - end, - } - else - return { - wait = function() - return { code = 1, stdout = '', stderr = 'fail checkout' } - end, - } - end +describe('snapshot Git integration', function() + local root, cwd, original_path, original_session, original_cache, session + before_each(function() + root, cwd = vim.fn.tempname(), vim.fn.getcwd() + vim.fn.mkdir(root .. '/work', 'p') + vim.fn.mkdir(root .. '/cache', 'p') + root = vim.fn.resolve(root) + assert.equals(0, vim.system({ 'git', 'init', '--bare', root .. '/snapshot' }):wait().code) + vim.cmd.cd(vim.fn.fnameescape(root .. '/work')) + original_path = config_file.get_workspace_snapshot_path + original_session = state.active_session + session = require('opencode.session') + original_cache = session.get_cache_path + session.get_cache_path = function() + return root .. '/cache/' end - snapshot.restore('abc123') - assert.is_truthy(vim.g._last_notify) - assert.is_truthy(vim.g._last_notify.msg:find('Failed to checkout%-index')) + config_file.get_workspace_snapshot_path = function() + return Promise.new():resolve(root .. '/snapshot') + end + state.session.set_active({ id = 'integration' }) + end) + after_each(function() + vim.cmd.cd(vim.fn.fnameescape(cwd)) + config_file.get_workspace_snapshot_path = original_path + session.get_cache_path = original_cache + state.session.set_active(original_session) + vim.fn.delete(root, 'rf') + end) + it('preserves diff bytes and restores an edited file with a recovery snapshot', function() + local file = root .. '/work/file with spaces.lua' + vim.fn.writefile({ ' original', '' }, file) + local hash = snapshot.create():wait() + assert.is_string(hash) + vim.fn.writefile({ 'edited' }, file) + local diff = snapshot.diff_file(hash, file):wait() + assert.same({ ' original', '' }, vim.fn.readfile(diff.right)) + vim.fn.delete(diff.right) + local recovery = snapshot.revert_file(hash, file):wait() + assert.same({ ' original', '' }, vim.fn.readfile(file)) + assert.is_string(recovery.id) + assert.is_true(snapshot.restore_file(recovery.id, file):wait()) + assert.same({ 'edited' }, vim.fn.readfile(file)) + end) + it('deletes only files absent from the target and captures them for recovery', function() + vim.fn.writefile({ 'original' }, root .. '/work/tracked') + local hash = snapshot.create():wait() + local added = root .. '/work/new file' + vim.fn.writefile({ 'new' }, added) + local recovery = snapshot.revert(hash):wait() + assert.same({ added }, recovery.deleted_files) + assert.equals(0, vim.fn.filereadable(added)) + assert.is_true(snapshot.restore(recovery.id):wait()) + assert.same({ 'new' }, vim.fn.readfile(added)) end) end) diff --git a/tests/unit/throttling_emitter_spec.lua b/tests/unit/throttling_emitter_spec.lua new file mode 100644 index 00000000..63adc852 --- /dev/null +++ b/tests/unit/throttling_emitter_spec.lua @@ -0,0 +1,49 @@ +local ThrottlingEmitter = require('opencode.throttling_emitter') + +describe('ThrottlingEmitter', function() + local original_defer, pending + + before_each(function() + original_defer = vim.defer_fn + pending = {} + vim.defer_fn = function(callback) + pending[#pending + 1] = callback + end + end) + + after_each(function() + vim.defer_fn = original_defer + end) + + it('does not let a cancelled drain consume a newer batch', function() + local batches = {} + local emitter = ThrottlingEmitter.new(function(items) + batches[#batches + 1] = items + end) + emitter:enqueue('old') + emitter:clear() + emitter:enqueue('new') + pending[1]() + assert.same({}, batches) + assert.is_true(emitter.drain_scheduled) + pending[2]() + assert.same({ { 'new' } }, batches) + end) + + it('schedules a single follow-up batch for events enqueued during processing', function() + local batches = {} + local emitter + emitter = ThrottlingEmitter.new(function(items) + batches[#batches + 1] = items + if #batches == 1 then + emitter:enqueue('second') + emitter:enqueue('third') + end + end) + emitter:enqueue('first') + pending[1]() + assert.equals(2, #pending) + pending[2]() + assert.same({ { 'first' }, { 'second', 'third' } }, batches) + end) +end) diff --git a/tests/unit/timer_spec.lua b/tests/unit/timer_spec.lua index 901321d7..0543d94d 100644 --- a/tests/unit/timer_spec.lua +++ b/tests/unit/timer_spec.lua @@ -94,6 +94,40 @@ describe('Timer', function() end) end) + it('ignores queued ticks from a stopped or replaced timer', function() + local count = 0 + timer = Timer.new({ + interval = 10, + on_tick = function() + count = count + 1 + end, + }) + timer:start() + local old = timer._uv_timer + timer:stop() + old:fire() + timer:start() + old:fire() + assert.equals(0, count) + timer._uv_timer:fire() + assert.equals(1, count) + end) + + it('does not stop a replacement timer started inside a tick', function() + timer = Timer.new({ + interval = 10, + on_tick = function() + timer:start() + return false + end, + }) + timer:start() + local old = timer._uv_timer + old:fire() + assert.is_true(timer:is_running()) + assert.is_not.equal(old, timer._uv_timer) + end) + describe('Timer:start', function() it('starts a repeating timer', function() local tick_count = 0