diff --git a/lua/opencode/events.lua b/lua/opencode/events.lua index e25597bc..46289d07 100644 --- a/lua/opencode/events.lua +++ b/lua/opencode/events.lua @@ -11,11 +11,59 @@ local M = {} --- ---@field permissions? opencode.events.permissions.Opts -local heartbeat_timer = vim.uv.new_timer() ---How often `opencode` sends heartbeat events. local OPENCODE_HEARTBEAT_INTERVAL_MS = 30000 ----@type number? -local subscription_job_id = nil + +---@class opencode.events.State +---@field heartbeat_timer uv_timer_t +---@field subscription_job_id? number +---@field connected_server? opencode.server.Server + +---@type table +local tab_states = {} + +---@param tab? integer +---@return opencode.events.State, integer +local function get_state(tab) + tab = tab or vim.api.nvim_get_current_tabpage() + if not tab_states[tab] then + tab_states[tab] = { + heartbeat_timer = vim.uv.new_timer(), + subscription_job_id = nil, + connected_server = nil, + } + end + return tab_states[tab], tab +end + +local function refresh_compat_connected_server() + local state = tab_states[vim.api.nvim_get_current_tabpage()] + M.connected_server = state and state.connected_server or nil +end + +local function disconnect_state(state) + if state.subscription_job_id then + vim.fn.jobstop(state.subscription_job_id) + end + if state.heartbeat_timer then + state.heartbeat_timer:stop() + end + + state.subscription_job_id = nil + state.connected_server = nil +end + +local function prune_invalid_tab_states() + for tab, state in pairs(tab_states) do + if not vim.api.nvim_tabpage_is_valid(tab) then + disconnect_state(state) + if state.heartbeat_timer and not state.heartbeat_timer:is_closing() then + state.heartbeat_timer:close() + end + tab_states[tab] = nil + end + end +end ---The currently-connected `opencode` server, if any. ---Executes autocmds for received SSEs with type `OpencodeEvent:`, passing the event and server port as data. @@ -23,18 +71,34 @@ local subscription_job_id = nil ---@type opencode.server.Server? M.connected_server = nil +function M.get_connected_server(tab) + prune_invalid_tab_states() + local state = tab_states[tab or vim.api.nvim_get_current_tabpage()] + return state and state.connected_server or nil +end + ---@param server opencode.server.Server -function M.connect(server) - M.disconnect() +---@param tab? integer +function M.connect(server, tab) + local state + state, tab = get_state(tab) + M.disconnect(tab) require("opencode.promise") .resolve(server) :next(function(_server) ---@param _server opencode.server.Server - subscription_job_id = _server:sse_subscribe(function(response) ---@param response opencode.server.Event - M.connected_server = _server + state.subscription_job_id = _server:sse_subscribe(function(response) ---@param response opencode.server.Event + state.connected_server = _server + refresh_compat_connected_server() - if heartbeat_timer then - heartbeat_timer:start(OPENCODE_HEARTBEAT_INTERVAL_MS + 5000, 0, vim.schedule_wrap(M.disconnect)) + if state.heartbeat_timer then + state.heartbeat_timer:start( + OPENCODE_HEARTBEAT_INTERVAL_MS + 5000, + 0, + vim.schedule_wrap(function() + M.disconnect(tab) + end) + ) end if require("opencode.config").opts.events.enabled then @@ -44,6 +108,7 @@ function M.connect(server) event = response, -- Can't pass metatable through here, so listeners need to reconstruct the server object if they want to use its methods port = _server.port, + tab = tab, }, }) end @@ -51,9 +116,9 @@ function M.connect(server) -- This is also called when the connection is closed normally by `vim.fn.jobstop`. -- i.e. when disconnecting before connecting to a new server. -- In that case, don't re-execute disconnect - it'd disconnect from the new server. - if M.connected_server == _server then + if state.connected_server == _server then -- Server disappeared ungracefully, e.g. process killed, network error, etc. - M.disconnect() + M.disconnect(tab) end end) end) @@ -62,15 +127,24 @@ function M.connect(server) end) end -function M.disconnect() - if subscription_job_id then - vim.fn.jobstop(subscription_job_id) - end - if heartbeat_timer then - heartbeat_timer:stop() +---@param tab? integer +function M.disconnect(tab) + prune_invalid_tab_states() + local state = tab_states[tab or vim.api.nvim_get_current_tabpage()] + if not state then + refresh_compat_connected_server() + return end - M.connected_server = nil + disconnect_state(state) + refresh_compat_connected_server() end +vim.api.nvim_create_autocmd("TabEnter", { + callback = function() + prune_invalid_tab_states() + refresh_compat_connected_server() + end, +}) + return M diff --git a/lua/opencode/server/init.lua b/lua/opencode/server/init.lua index 126b007f..c734e7c9 100644 --- a/lua/opencode/server/init.lua +++ b/lua/opencode/server/init.lua @@ -344,10 +344,14 @@ end ---2. The configured port in `require("opencode.config").opts.port`. ---3. All servers, prioritizing one sharing CWD with Neovim, and prompting the user to select if multiple are found. ---@return Promise -local function find() +---@param tab? integer +local function find(tab) local Promise = require("opencode.promise") local port_opt = require("opencode.config").opts.server.port - local connected_server = require("opencode.events").connected_server + local events = require("opencode.events") + local connected_server = events.get_connected_server(tab) + local tabnr = tab and vim.api.nvim_tabpage_get_number(tab) or nil + local nvim_cwd = tabnr and vim.fn.getcwd(-1, tabnr) or vim.fn.getcwd() return connected_server and Promise.resolve(connected_server) or type(port_opt) == "number" and Server.new(port_opt) @@ -364,7 +368,6 @@ local function find() return Server.new(port) end) or Server.get_all():next(function(servers) ---@param servers opencode.server.Server[] - local nvim_cwd = vim.fn.getcwd() local servers_in_cwd = vim.tbl_filter(function(server) -- Overlaps in either direction, with no non-empty mismatch return server.cwd:find(nvim_cwd, 0, true) == 1 or nvim_cwd:find(server.cwd, 0, true) == 1 @@ -375,14 +378,15 @@ local function find() return servers_in_cwd[1] else -- Can't guess which one the user wants based on CWD - select from *all* - return require("opencode.ui.select_server").select_server(servers) + return require("opencode.ui.select_server").select_server(servers, { cwd = nvim_cwd }) end end) end ---Poll for an `opencode` server, rejecting if not found within five seconds. ---@return Promise -local function poll() +---@param tab? integer +local function poll(tab) local Promise = require("opencode.promise") local poll_timer, timer_err, timer_errname = vim.uv.new_timer() if not poll_timer then @@ -395,7 +399,7 @@ local function poll() 1000, 1000, vim.schedule_wrap(function() - find() + find(tab) :next(function(server) resolve(server) end) @@ -418,9 +422,11 @@ end ---@return Promise function Server.get() local Promise = require("opencode.promise") - local connected_server = require("opencode.events").connected_server + local events = require("opencode.events") + local tab = vim.api.nvim_get_current_tabpage() + local connected_server = events.get_connected_server(tab) - return find() + return find(tab) :catch(function(err) if not err then -- Do nothing when server selection was cancelled @@ -434,11 +440,11 @@ function Server.get() return Promise.reject(err) end - return poll() + return poll(tab) end) :next(function(server) ---@param server opencode.server.Server if not connected_server or connected_server.port ~= server.port then - require("opencode.events").connect(server) + events.connect(server, tab) end return server end) diff --git a/lua/opencode/status.lua b/lua/opencode/status.lua index cf3ccac2..ac0eb005 100644 --- a/lua/opencode/status.lua +++ b/lua/opencode/status.lua @@ -33,7 +33,7 @@ end ---@return string function M.statusline() - local connected_server = require("opencode.events").connected_server + local connected_server = require("opencode.events").get_connected_server() local port = connected_server and connected_server.port return M.statusline_icon() .. (port and (" :" .. tostring(port)) or "") end diff --git a/lua/opencode/terminal.lua b/lua/opencode/terminal.lua index fb579ef9..dc03bc29 100644 --- a/lua/opencode/terminal.lua +++ b/lua/opencode/terminal.lua @@ -2,24 +2,44 @@ local M = {} ---@class opencode.terminal.Opts : vim.api.keyset.win_config -local winid -local bufnr +---@class opencode.terminal.State +---@field winid? integer +---@field bufnr? integer + +---@type table +local states = {} + +---@param tab? integer +---@return opencode.terminal.State, integer +local function get_state(tab) + tab = tab or vim.api.nvim_get_current_tabpage() + if not states[tab] then + states[tab] = {} + end + return states[tab], tab +end + +---@param tab integer +local function clear_state(tab) + states[tab] = nil +end ---Start if not running, else show/hide the window. ---@param cmd string ---@param opts? opencode.terminal.Opts function M.toggle(cmd, opts) + local state = get_state() opts = opts or { split = "right", width = math.floor(vim.o.columns * 0.35), } - if winid ~= nil and vim.api.nvim_win_is_valid(winid) then - vim.api.nvim_win_hide(winid) - winid = nil - elseif bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr) then + if state.winid ~= nil and vim.api.nvim_win_is_valid(state.winid) then + vim.api.nvim_win_hide(state.winid) + state.winid = nil + elseif state.bufnr ~= nil and vim.api.nvim_buf_is_valid(state.bufnr) then local previous_win = vim.api.nvim_get_current_win() - winid = vim.api.nvim_open_win(bufnr, true, opts) + state.winid = vim.api.nvim_open_win(state.bufnr, true, opts) vim.api.nvim_set_current_win(previous_win) else M.open(cmd, opts) @@ -29,7 +49,8 @@ end ---@param cmd string ---@param opts? opencode.terminal.Opts function M.open(cmd, opts) - if bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr) then + local state, tab = get_state() + if state.bufnr ~= nil and vim.api.nvim_buf_is_valid(state.bufnr) then return end @@ -39,8 +60,8 @@ function M.open(cmd, opts) } local previous_win = vim.api.nvim_get_current_win() - bufnr = vim.api.nvim_create_buf(false, false) - winid = vim.api.nvim_open_win(bufnr, true, opts) + state.bufnr = vim.api.nvim_create_buf(false, false) + state.winid = vim.api.nvim_open_win(state.bufnr, true, opts) vim.api.nvim_create_autocmd("ExitPre", { once = true, @@ -48,22 +69,24 @@ function M.open(cmd, opts) -- Delete the buffer so session doesn't save + restore it. -- Not worth the complexity to handle a restored terminal, -- and this is consistent with most other Neovim terminal plugins. - M.close() + M.close_all() end, }) - M.setup(winid) + M.setup(state.winid) -- Redraw terminal buffer on initial render. -- Fixes empty columns on the right side. -- Only affects our implementation for some reason; I don't see this issue in `snacks.terminal`. local auid auid = vim.api.nvim_create_autocmd("TermRequest", { - buffer = bufnr, + buffer = state.bufnr, callback = function(ev) if ev.data.cursor[1] > 1 then vim.api.nvim_del_autocmd(auid) - vim.api.nvim_set_current_win(winid) + if state.winid and vim.api.nvim_win_is_valid(state.winid) then + vim.api.nvim_set_current_win(state.winid) + end -- Enter insert mode to trigger redraw, then exit and return to previous window. vim.cmd([[startinsert | call feedkeys("\\\p", "n")]]) end @@ -73,26 +96,49 @@ function M.open(cmd, opts) vim.fn.jobstart(cmd, { term = true, on_exit = function() - M.close() + M.close(tab) end, }) vim.api.nvim_set_current_win(previous_win) end -function M.close() - local job_id = bufnr and vim.b[bufnr].terminal_job_id +---@param tab? integer +function M.close(tab) + local state + state, tab = get_state(tab) + local job_id = state.bufnr and vim.b[state.bufnr].terminal_job_id if job_id then vim.fn.jobstop(job_id) end - if winid ~= nil and vim.api.nvim_win_is_valid(winid) then - vim.api.nvim_win_close(winid, true) - winid = nil + if state.winid ~= nil and vim.api.nvim_win_is_valid(state.winid) then + vim.api.nvim_win_close(state.winid, true) + state.winid = nil end - if bufnr ~= nil and vim.api.nvim_buf_is_valid(bufnr) then - vim.api.nvim_buf_delete(bufnr, { force = true }) - bufnr = nil + if state.bufnr ~= nil and vim.api.nvim_buf_is_valid(state.bufnr) then + vim.api.nvim_buf_delete(state.bufnr, { force = true }) + state.bufnr = nil + end + + clear_state(tab) +end + +function M.close_all() + for tab, state in pairs(states) do + local job_id = state.bufnr and vim.b[state.bufnr].terminal_job_id + if job_id then + vim.fn.jobstop(job_id) + end + + if state.winid ~= nil and vim.api.nvim_win_is_valid(state.winid) then + vim.api.nvim_win_close(state.winid, true) + end + if state.bufnr ~= nil and vim.api.nvim_buf_is_valid(state.bufnr) then + vim.api.nvim_buf_delete(state.bufnr, { force = true }) + end + + states[tab] = nil end end diff --git a/lua/opencode/ui/ask/cmp.lua b/lua/opencode/ui/ask/cmp.lua index 1f864819..4ebd710d 100644 --- a/lua/opencode/ui/ask/cmp.lua +++ b/lua/opencode/ui/ask/cmp.lua @@ -38,7 +38,7 @@ handlers[ms.textDocument_completion] = function(params, callback) table.insert(items, item) end - local connected_server = require("opencode.events").connected_server + local connected_server = require("opencode.events").get_connected_server() local agents = connected_server and connected_server.subagents or {} for _, agent in ipairs(agents) do local label = "@" .. agent.name diff --git a/lua/opencode/ui/ask/init.lua b/lua/opencode/ui/ask/init.lua index aec24199..6a9be13e 100644 --- a/lua/opencode/ui/ask/init.lua +++ b/lua/opencode/ui/ask/init.lua @@ -61,7 +61,7 @@ _G.opencode_completion = function(ArgLead, CmdLine, CursorPos) for placeholder, _ in pairs(require("opencode.config").opts.contexts) do table.insert(completions, placeholder) end - local server = require("opencode.events").connected_server + local server = require("opencode.events").get_connected_server() local agents = server and server.subagents or {} for _, agent in ipairs(agents) do table.insert(completions, "@" .. agent.name) diff --git a/lua/opencode/ui/select_server.lua b/lua/opencode/ui/select_server.lua index d17e287b..6c1fbc18 100644 --- a/lua/opencode/ui/select_server.lua +++ b/lua/opencode/ui/select_server.lua @@ -29,9 +29,11 @@ end ---Select an `opencode` server from a given list. --- ---@param servers opencode.server.Server[] +---@param opts? { cwd?: string } ---@return Promise -function M.select_server(servers) - local nvim_cwd = vim.fn.getcwd() +function M.select_server(servers, opts) + opts = opts or {} + local nvim_cwd = opts.cwd or vim.fn.getcwd() -- Sort servers by common prefix overlap with Neovim's CWD table.sort(servers, function(a, b)