From d0542eff2fa6a19713278bcf510a6541274d5144 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Wed, 14 Oct 2026 08:34:50 -0400 Subject: [PATCH 1/4] feat: restore session tabs across Neovim restarts Persist logical panel tabs by workspace and server, restoring their order, active tab, directory bindings, and session locks on first open. This should fix #519 --- docs/configuration.md | 2 + docs/usage.md | 13 + lua/opencode/config.lua | 1 + lua/opencode/init.lua | 1 + lua/opencode/services/session_runtime.lua | 5 + .../services/session_tab_persistence.lua | 271 +++++++++++++++ lua/opencode/types.lua | 1 + tests/unit/services_session_runtime_spec.lua | 44 +++ tests/unit/session_tab_persistence_spec.lua | 312 ++++++++++++++++++ 9 files changed, 650 insertions(+) create mode 100644 lua/opencode/services/session_tab_persistence.lua create mode 100644 tests/unit/session_tab_persistence_spec.lua diff --git a/docs/configuration.md b/docs/configuration.md index 99e52ed3..3697b305 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -257,6 +257,7 @@ require('opencode').setup({ notify_on_background_prompt = true, window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder', persist_state = true, + persist_session_tabs = true, -- Save and restore logical panel tabs across Neovim restarts icons = { preset = 'nerdfonts', overrides = {}, @@ -497,6 +498,7 @@ the everyday ones. Session diff remapping has its own [example](review.md#custom | `ui.window_width` | `0.40` | Split width as a fraction of editor width | | `ui.zoom_width` | `0.8` | Zoomed split width | | `ui.persist_state` | `true` | Preserve UI buffers when closing/hiding | +| `ui.persist_session_tabs` | `true` | Restore logical panel tabs across Neovim restarts | | `ui.input.min_height` / `max_height` | `0.10` / `0.25` | Input height bounds as window-height fractions | | `ui.input.text.wrap` | `false` | Wrap prompt text | | `ui.input.auto_hide` | `false` | Hide input after submission or focus moves to output | diff --git a/docs/usage.md b/docs/usage.md index d57a35ea..c6652d0c 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -48,6 +48,19 @@ The tab strip is hidden while there is only one tab; set asks a question or needs a permission, you get a notification. Turn that off with `ui.notify_on_background_prompt = false`. +Panel tabs are saved when Neovim exits and restored the first time you open +the panel in the same editor directory with the same configured server. +Their order, active tab, directory bindings, and session locks are restored; +drafts and window state are not. Deleted sessions and missing directories are +skipped. If the first open starts an explicit new session, saved tabs are +restored behind it and the new session stays active. + +Set `ui.persist_session_tabs = false` to disable saving and restoring tabs. +This is separate from `ui.persist_state`, which keeps buffers when hiding the +panel within a running Neovim instance. Snapshots live under +`stdpath('state')/opencode/session-tabs/`; concurrent instances in the same +workspace use the last saved snapshot. + To open a session in another worktree without changing your editor directory, see [Worktree sessions](recipes/worktree.md). diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index 3cb6d0c2..eee1d861 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -239,6 +239,7 @@ M.defaults = { notify_on_background_prompt = true, window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder', persist_state = true, + persist_session_tabs = true, icons = { preset = 'nerdfonts', overrides = {}, diff --git a/lua/opencode/init.lua b/lua/opencode/init.lua index 8630b838..14e6ad9e 100644 --- a/lua/opencode/init.lua +++ b/lua/opencode/init.lua @@ -30,6 +30,7 @@ function M.setup(opts) state = require('opencode.state') state.session_tabs.setup() + require('opencode.services.session_tab_persistence').setup() session_runtime.setup_subscriptions() state.store.subscribe('opencode_server', on_opencode_server) state.store.subscribe('user_message_count', session_runtime._on_user_message_count_change) diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index b81650be..39445903 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -9,6 +9,7 @@ local Promise = require('opencode.promise') local log = require('opencode.log') local agent_model = require('opencode.services.agent_model') local session_tabs = require('opencode.state.session_tabs') +local tab_persistence = require('opencode.services.session_tab_persistence') local M = {} @@ -385,6 +386,10 @@ M.open = Promise.async(function(opts) if open_action ~= 'reuse_visible' then M.is_prompting_allowed() end + local restoring = tab_persistence.restore(opts.new_session == true) + if restoring then + restoring:await() + end created_windows = ui.prepare_windows(open_action, opts) return server_job.ensure_server():await() end) diff --git a/lua/opencode/services/session_tab_persistence.lua b/lua/opencode/services/session_tab_persistence.lua new file mode 100644 index 00000000..1a423bb4 --- /dev/null +++ b/lua/opencode/services/session_tab_persistence.lua @@ -0,0 +1,271 @@ +local config = require('opencode.config') +local tabs = require('opencode.state.session_tabs') +local server_job = require('opencode.server_job') +local Promise = require('opencode.promise') +local util = require('opencode.util') +local log = require('opencode.log') +local v = require('opencode.shape') + +local M = {} +local attempted = false +---@type Promise|nil +local pending_restore +-- A failed restore must neither overwrite the snapshot on exit nor block a later retry. +local restore_failed = false + +---@class OpencodeSavedSessionTab +---@field session_id string +---@field directory string +---@field bound_directory? string +---@field session_locked? boolean + +---@class OpencodeSessionTabSnapshot +---@field version integer +---@field cwd string +---@field server string +---@field tabs OpencodeSavedSessionTab[] +---@field active_index integer + +-- The snapshot file is external input: it may be corrupt or hand-edited. +local snapshot_shape = v.object({ + version = v.literal(1), + cwd = 'string', + server = 'string', + active_index = v.integer(), + tabs = v.array(v.object({ + session_id = 'string', + directory = 'string', + bound_directory = v.optional('string'), + session_locked = v.optional('boolean'), + })), +}) + +---@alias OpencodePersistedSessionLookup fun(connection: OpencodeV1Connection|OpencodeV2Connection, session_id: string, location: OpencodeLocation, path_map: OpencodePathMap, reverse_path_map: OpencodePathMap): Promise + +---@return string, string +local function workspace() + local cwd = vim.fs.normalize(vim.fn.getcwd()) + -- Use configured identity, not a discovered/random port that changes on restart. + local server = vim.json.encode({ + config.opencode_executable, + config.server.url or '', + tostring(config.server.port or ''), + }) + return cwd, server +end + +---@param cwd string +---@param server string +---@return string +function M.path(cwd, server) + local key = vim.fn.sha256(cwd .. '\n' .. server) + return vim.fs.joinpath(vim.fn.stdpath('state'), 'opencode', 'session-tabs', key .. '.json') +end + +---Invalid snapshots read as missing, so a bad file never breaks opening the panel. +---@param cwd string +---@param server string +---@return OpencodeSessionTabSnapshot|nil +function M.load(cwd, server) + local file = io.open(M.path(cwd, server), 'r') + if not file then + return nil + end + local content = file:read('*a') + file:close() + local ok, snapshot = pcall(vim.json.decode, content) + -- cwd/server are re-checked to reject sha256 path collisions. + if not ok or not snapshot_shape:check(snapshot) or snapshot.cwd ~= cwd or snapshot.server ~= server then + return nil + end + return snapshot --[[@as OpencodeSessionTabSnapshot]] +end + +---Write atomically; readers never see a partially written snapshot. +---@param snapshot OpencodeSessionTabSnapshot +---@return boolean, string|nil +local function write(snapshot) + local path = M.path(snapshot.cwd, snapshot.server) + local temporary = path .. '.' .. vim.fn.getpid() .. '.tmp' + local ok, err = pcall(function() + vim.fn.mkdir(vim.fs.dirname(path), 'p') + assert(vim.fn.writefile({ vim.json.encode(snapshot) }, temporary) == 0, 'Failed to write session tabs') + assert(os.rename(temporary, path)) + end) + if not ok then + os.remove(temporary) + return false, tostring(err) + end + return true +end + +---@param current OpencodeSessionTabRuntime[] +---@param background boolean +---@return boolean +local function untouched(current, background) + return #current == 1 and (background or not current[1].active_session) +end + +---@param entry OpencodeSavedSessionTab +---@return boolean +local function directories_exist(entry) + return vim.fn.isdirectory(entry.directory) == 1 + and (not entry.bound_directory or vim.fn.isdirectory(entry.bound_directory) == 1) +end + +---@param snapshot OpencodeSessionTabSnapshot +---@param current OpencodeSessionTabRuntime +---@param cwd string +---@param background boolean +local restore_snapshot = Promise.async(function(snapshot, current, cwd, background) + local connection = server_job.ensure_server():await() + if not connection then + error('Server failed to start') + end + + -- Each connection owns its matching protocol adapter; both return neutral session facts. + local lookup = connection.operations.get_session --[[@as OpencodePersistedSessionLookup]] + ---@type { entry: OpencodeSavedSessionTab, index: integer }[] + local candidates = {} + ---@type Promise[] + local lookups = {} + local seen = {} + for index, entry in ipairs(snapshot.tabs) do + if not seen[entry.session_id] and directories_exist(entry) then + seen[entry.session_id] = true + table.insert(candidates, { entry = entry, index = index }) + table.insert( + lookups, + lookup( + connection, + entry.session_id, + { directory = entry.directory }, + util.apply_path_map, + util.apply_reverse_path_map + ):catch(function() + return nil + end) + ) + end + end + local sessions = Promise.all(lookups):await() + + -- Lookups yield; explicit session actions or cwd changes meanwhile take priority. + local latest = tabs.list() + if latest[1] ~= current or not untouched(latest, background) or workspace() ~= cwd then + return + end + local occupied = current.active_session and current.active_session.id + local restored = {} + local active + for i, item in ipairs(candidates) do + local session = sessions[i] + if session and session.id ~= occupied then + local runtime = tabs.create(session) + runtime.current_cwd = item.entry.directory + runtime.bound_directory = item.entry.bound_directory + if item.entry.bound_directory then + runtime.bound_editor_cwd = cwd + end + runtime.session_locked = item.entry.session_locked + table.insert(restored, runtime) + if item.index == snapshot.active_index then + active = runtime + end + end + end + -- Background restores keep the current tab active for the explicit new session. + if #restored > 0 and not background then + tabs.remove(current) + tabs.activate(active or restored[1]) + end +end) + +---Restore once into an untouched tab registry, without mounting windows. +---Returns nil when there is nothing to wait for, so callers keep their normal open order. +---Concurrent opens share the same restore rather than mounting half-loaded state. +---Never rejects: failures are logged, fall back to ordinary session selection and retry on the next open. +---@param background boolean Explicit new-session opens keep the current tab and append saved tabs behind it. +---@return Promise|nil +function M.restore(background) + if pending_restore then + if not pending_restore:is_resolved() then + return pending_restore + end + if not restore_failed then + return nil + end + pending_restore = nil + attempted = false + restore_failed = false + end + if attempted or not config.ui.persist_session_tabs then + return nil + end + attempted = true + local current = tabs.list() + if not untouched(current, background) then + return nil + end + local cwd, server = workspace() + local snapshot = M.load(cwd, server) + if not snapshot or #snapshot.tabs == 0 then + return nil + end + pending_restore = restore_snapshot(snapshot, current[1], cwd, background):catch(function(err) + restore_failed = true + log.warn('Failed to restore session tabs: ' .. tostring(err)) + end) + return pending_restore +end + +---Save only durable session references; never buffers, drafts or permission state. +---Returns false on disk failure (logged), leaving the previous snapshot intact. +---@return boolean +function M.save() + if + not config.ui.persist_session_tabs + or not attempted + or restore_failed + or (pending_restore and not pending_restore:is_resolved()) + then + return true + end + local cwd, server = workspace() + ---@type OpencodeSessionTabSnapshot + local snapshot = { version = 1, cwd = cwd, server = server, tabs = {}, active_index = 0 } + for _, runtime in ipairs(tabs.list()) do + local session = runtime.active_session + if session then + local directory = runtime.bound_directory or runtime.current_cwd or cwd + table.insert(snapshot.tabs, { + session_id = session.id, + directory = directory, + bound_directory = runtime.bound_directory, + session_locked = runtime.session_locked, + }) + if runtime.id == tabs.active_id() then + snapshot.active_index = #snapshot.tabs + end + end + end + local ok, err = write(snapshot) + if not ok then + log.warn('Failed to persist session tabs: ' .. tostring(err)) + end + return ok +end + +function M.setup() + attempted = false + pending_restore = nil + restore_failed = false + vim.api.nvim_create_autocmd('VimLeavePre', { + group = vim.api.nvim_create_augroup('OpencodeSessionTabPersistence', { clear = true }), + callback = function() + M.save() + end, + }) +end + +return M diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 5c12910b..b318512b 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -259,6 +259,7 @@ ---@field input_position 'bottom'|'top' # Position of the input window (default: 'bottom') ---@field window_width number ---@field persist_state boolean +---@field persist_session_tabs boolean # Save and restore logical panel tabs across Neovim restarts ---@field zoom_width number ---@field float OpencodeUIFloatConfig ---@field picker_width number|false|nil # Width for pickers. 01 = absolute columns; false = use picker backend defaults. diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 9dec0aa4..632c25fc 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -21,6 +21,7 @@ local support = require('tests.unit.services_spec_support') describe('opencode.services.session_runtime', function() local original + local original_persist_session_tabs local function set_session_fact(session_id, parent_id) local connection = state.opencode_server @@ -30,6 +31,9 @@ describe('opencode.services.session_runtime', function() before_each(function() original = support.snapshot_state() + -- Keep open() away from snapshots in the real stdpath('state'). + original_persist_session_tabs = config.ui.persist_session_tabs + config.ui.persist_session_tabs = false vim.fn.executable = function(_) return 1 @@ -62,6 +66,7 @@ describe('opencode.services.session_runtime', function() after_each(function() support.restore_state(original) + config.ui.persist_session_tabs = original_persist_session_tabs for _, fn in ipairs({ 'create_windows', @@ -121,6 +126,45 @@ describe('opencode.services.session_runtime', function() end) describe('open', function() + it('restores persisted tabs before mounting windows', function() + local persistence = require('opencode.services.session_tab_persistence') + local order = {} + local restore = stub(persistence, 'restore').invokes(function(background) + assert.is_false(background) + table.insert(order, 'restore') + return Promise.new():resolve(nil) + end) + local prepare = stub(ui, 'prepare_windows').invokes(function() + table.insert(order, 'windows') + return false + end) + local ok, err = pcall(function() + session_runtime.open({ new_session = false }):wait() + assert.same({ 'restore', 'windows' }, order) + end) + restore:revert() + prepare:revert() + assert.is_true(ok, tostring(err)) + end) + + it('mounts windows before server startup when there is nothing to restore', function() + local server_job = require('opencode.server_job') + local connection = state.opencode_server + local starting = Promise.new() + local ensure = stub(server_job, 'ensure_server').returns(starting) + local prepare = stub(ui, 'prepare_windows').returns(false) + local opening = session_runtime.open({ new_session = false }) + local mounted = #prepare.calls + starting:resolve(connection) + local ok, err = pcall(function() + opening:wait() + end) + ensure:revert() + prepare:revert() + assert.equals(1, mounted) + assert.is_true(ok, tostring(err)) + end) + it("creates windows if they don't exist", function() state.ui.set_windows(nil) session_runtime.open({ new_session = false, focus = 'input' }):wait() diff --git a/tests/unit/session_tab_persistence_spec.lua b/tests/unit/session_tab_persistence_spec.lua new file mode 100644 index 00000000..2bfb2ba5 --- /dev/null +++ b/tests/unit/session_tab_persistence_spec.lua @@ -0,0 +1,312 @@ +local state = require('opencode.state') +local tabs = state.session_tabs +local config = require('opencode.config') +local server_job = require('opencode.server_job') +local service = require('opencode.services.session_tab_persistence') +local Promise = require('opencode.promise') +local stub = require('luassert.stub') + +describe('session tab persistence', function() + local original_state + local original_enabled + local original_server + local temporary + local path_stub + local server_stub + local connection + + local function restart() + tabs.reset() + state.store.set_raw('active_session', nil) + state.store.set_raw('windows', nil) + state.store.set_raw('_hidden_buffers', nil) + state.store.set_raw('current_cwd', vim.fn.getcwd()) + tabs.setup() + service.setup() + end + + before_each(function() + vim.wait(20) + original_state = vim.deepcopy(state.store.state()) + original_enabled = config.ui.persist_session_tabs + original_server = vim.deepcopy(config.server) + config.ui.persist_session_tabs = true + temporary = vim.fn.tempname() + vim.fn.mkdir(temporary, 'p') + path_stub = stub(vim.fn, 'stdpath', function() + return temporary + end) + connection = { operations = {} } + connection.operations.get_session = function(_, id, location) + return Promise.new():resolve({ id = id, title = id, location = location }) + end + server_stub = stub(server_job, 'ensure_server', function() + return Promise.new():resolve(connection) + end) + restart() + end) + + ---@param background? boolean + local function restore(background) + local restoring = service.restore(background == true) + if restoring then + restoring:wait() + end + end + + after_each(function() + tabs.reset() + vim.api.nvim_del_augroup_by_name('OpencodeSessionTabPersistence') + path_stub:revert() + server_stub:revert() + vim.fn.delete(temporary, 'rf') + config.ui.persist_session_tabs = original_enabled + config.server = original_server + for key in pairs(state.store.state()) do + state.store.set_raw(key, nil) + end + for key, value in pairs(original_state) do + state.store.set_raw(key, value) + end + end) + + local function save_tabs() + restore() + state.session.set_active({ id = 'first', title = 'First' }) + local first = tabs.current() + first.bound_directory = temporary + first.bound_editor_cwd = vim.fn.getcwd() + state.context.set_current_cwd(temporary) + state.session.set_locked(true) + local second = tabs.create({ id = 'second', title = 'Second' }) + second.current_cwd = vim.fn.getcwd() + second.session_locked = false + tabs.activate(second) + assert.is_true(service.save()) + end + + local function snapshot_path() + return vim.fn.glob(temporary .. '/opencode/session-tabs/*.json') + end + + it('restores order, active tab, directory binding and locks, without runtime UI data', function() + save_tabs() + local saved = vim.json.decode(table.concat(vim.fn.readfile(snapshot_path()), '\n')) + assert.same({ 'first', 'second' }, { saved.tabs[1].session_id, saved.tabs[2].session_id }) + assert.is_nil(saved.tabs[1].windows) + assert.is_nil(saved.tabs[1].messages) + assert.is_nil(saved.tabs[1].pending_permissions) + restart() + restore() + local restored = tabs.list() + assert.equals(2, #restored) + assert.equals('first', restored[1].active_session.id) + assert.equals('second', tabs.current().active_session.id) + assert.equals(temporary, restored[1].bound_directory) + assert.equals(vim.fn.getcwd(), restored[1].bound_editor_cwd) + assert.is_true(restored[1].session_locked) + assert.is_false(restored[2].session_locked) + assert.is_nil(state.windows) + end) + + it('restores on first open only, not on setup or subsequent calls', function() + save_tabs() + restart() + assert.equals(1, #tabs.list()) + assert.is_nil(tabs.current().active_session) + restore() + tabs.remove(tabs.list()[1]) + restore() + assert.equals(1, #tabs.list()) + end) + + it('does not read or write snapshots when disabled', function() + save_tabs() + local saved = vim.fn.readfile(snapshot_path()) + restart() + config.ui.persist_session_tabs = false + restore() + assert.is_nil(tabs.current().active_session) + assert.is_true(service.save()) + assert.same(saved, vim.fn.readfile(snapshot_path())) + end) + + it('does not overwrite snapshots if the panel was never opened', function() + save_tabs() + local saved = vim.fn.readfile(snapshot_path()) + restart() + service.save() + assert.same(saved, vim.fn.readfile(snapshot_path())) + end) + + it('shares an in-flight restore and does not overwrite its snapshot on exit', function() + save_tabs() + local saved = vim.fn.readfile(snapshot_path()) + restart() + local lookup = Promise.new() + connection.operations.get_session = function() + return lookup + end + local first = service.restore(false) + local second = service.restore(false) + assert.is_not_nil(first) + assert.equals(first, second) + service.save() + assert.same(saved, vim.fn.readfile(snapshot_path())) + lookup:resolve({ id = 'first' }) + first:wait() + assert.is_nil(service.restore(false)) + end) + + it('does not replace a session selected while saved sessions are loading', function() + save_tabs() + restart() + local lookup = Promise.new() + connection.operations.get_session = function() + return lookup + end + local restoring = service.restore(false) + state.session.set_active({ id = 'explicit' }) + lookup:resolve({ id = 'first' }) + restoring:wait() + assert.equals(1, #tabs.list()) + assert.equals('explicit', tabs.current().active_session.id) + end) + + it('returns nothing to wait for without a snapshot', function() + assert.is_nil(service.restore(false)) + assert.stub(server_stub).was_not_called() + end) + + it('restores saved tabs behind the current tab for explicit new-session opens', function() + save_tabs() + restart() + local current = tabs.current() + restore(true) + local restored = tabs.list() + assert.equals(3, #restored) + assert.equals(current, tabs.current()) + assert.is_nil(tabs.current().active_session) + assert.same({ 'first', 'second' }, { restored[2].active_session.id, restored[3].active_session.id }) + end) + + it('does not restore over existing sessions', function() + save_tabs() + restart() + state.session.set_active({ id = 'explicit' }) + restore() + assert.equals('explicit', tabs.current().active_session.id) + assert.equals(1, #tabs.list()) + end) + + it('logs a failed restore, keeps the snapshot and retries on the next open', function() + save_tabs() + local saved = vim.fn.readfile(snapshot_path()) + restart() + server_stub:revert() + server_stub = stub(server_job, 'ensure_server', function() + return Promise.new():reject('startup failed') + end) + local restoring = service.restore(false) + assert.is_not_nil(restoring) + restoring:wait() + assert.is_false(restoring:is_rejected()) + assert.equals(1, #tabs.list()) + assert.is_true(service.save()) + assert.same(saved, vim.fn.readfile(snapshot_path())) + server_stub:revert() + server_stub = stub(server_job, 'ensure_server', function() + return Promise.new():resolve(connection) + end) + restore() + assert.equals(2, #tabs.list()) + end) + + it('skips deleted sessions and falls back to the first surviving active tab', function() + save_tabs() + restart() + connection.operations.get_session = function(_, id, location) + if id == 'second' then + return Promise.new():reject('Not found') + end + return Promise.new():resolve({ id = id, location = location }) + end + restore() + assert.equals(1, #tabs.list()) + assert.equals('first', tabs.current().active_session.id) + end) + + it('skips missing directories and preserves the empty fallback tab when none survive', function() + save_tabs() + local path = snapshot_path() + local saved = vim.json.decode(table.concat(vim.fn.readfile(path), '\n')) + for _, entry in ipairs(saved.tabs) do + entry.directory = temporary .. '/missing' + end + vim.fn.writefile({ vim.json.encode(saved) }, path) + restart() + restore() + assert.equals(1, #tabs.list()) + assert.is_nil(tabs.current().active_session) + end) + + it('isolates different editor directories and configured servers', function() + assert.are_not.equals(service.path('/one', 'server'), service.path('/two', 'server')) + save_tabs() + restart() + config.server.url = 'http://different-server' + restore() + assert.is_nil(tabs.current().active_session) + end) + + it('ignores corrupt files, invalid schema, other versions and mismatched workspace data', function() + local path = service.path('/workspace', 'server') + vim.fn.mkdir(vim.fs.dirname(path), 'p') + for _, content in ipairs({ + 'not json', + '42', + '{"version":2,"cwd":"/workspace","server":"server","tabs":[],"active_index":0}', + '{"version":1,"cwd":"/other","server":"server","tabs":[],"active_index":0}', + '{"version":1,"cwd":"/workspace","server":"server","active_index":0}', + '{"version":1,"cwd":"/workspace","server":"server","tabs":[{}],"active_index":1}', + '{"version":1,"cwd":"/workspace","server":"server","tabs":[],"active_index":0.5}', + }) do + vim.fn.writefile({ content }, path) + assert.is_nil(service.load('/workspace', 'server')) + end + end) + + it('keeps the previous snapshot if an atomic write fails', function() + save_tabs() + local path = snapshot_path() + local saved = vim.fn.readfile(path) + local rename = stub(os, 'rename').returns(nil, 'rename failed') + local ok = service.save() + rename:revert() + assert.is_false(ok) + assert.same(saved, vim.fn.readfile(path)) + assert.equals('', vim.fn.glob(path .. '.*.tmp')) + end) + + it('saves the tab set through VimLeavePre and removes closed tabs', function() + save_tabs() + tabs.remove(tabs.list()[1]) + vim.api.nvim_exec_autocmds('VimLeavePre', { group = 'OpencodeSessionTabPersistence' }) + restart() + restore() + assert.equals(1, #tabs.list()) + assert.equals('second', tabs.current().active_session.id) + end) + + it('does not resurrect tabs after all tabs were explicitly closed', function() + save_tabs() + for _, runtime in ipairs(tabs.list()) do + tabs.remove(runtime) + end + service.save() + restart() + restore() + assert.equals(1, #tabs.list()) + assert.is_nil(tabs.current().active_session) + end) +end) From 35778a0bbda5834825e219d750aa62ced2ed1517 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 15 Oct 2026 07:17:13 -0400 Subject: [PATCH 2/4] fix(config): rename session tab persistence option to restore_tabs_on_startup --- docs/configuration.md | 4 ++-- docs/usage.md | 2 +- lua/opencode/config.lua | 2 +- lua/opencode/services/session_tab_persistence.lua | 4 ++-- lua/opencode/types.lua | 2 +- tests/unit/services_session_runtime_spec.lua | 8 ++++---- tests/unit/session_tab_persistence_spec.lua | 8 ++++---- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 3697b305..17cc6006 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -257,7 +257,7 @@ require('opencode').setup({ notify_on_background_prompt = true, window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder', persist_state = true, - persist_session_tabs = true, -- Save and restore logical panel tabs across Neovim restarts + restore_tabs_on_startup = true, -- Save and restore logical panel tabs across Neovim restarts icons = { preset = 'nerdfonts', overrides = {}, @@ -498,7 +498,7 @@ the everyday ones. Session diff remapping has its own [example](review.md#custom | `ui.window_width` | `0.40` | Split width as a fraction of editor width | | `ui.zoom_width` | `0.8` | Zoomed split width | | `ui.persist_state` | `true` | Preserve UI buffers when closing/hiding | -| `ui.persist_session_tabs` | `true` | Restore logical panel tabs across Neovim restarts | +| `ui.restore_tabs_on_startup` | `true` | Restore logical panel tabs across Neovim restarts | | `ui.input.min_height` / `max_height` | `0.10` / `0.25` | Input height bounds as window-height fractions | | `ui.input.text.wrap` | `false` | Wrap prompt text | | `ui.input.auto_hide` | `false` | Hide input after submission or focus moves to output | diff --git a/docs/usage.md b/docs/usage.md index c6652d0c..430a7e62 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -55,7 +55,7 @@ drafts and window state are not. Deleted sessions and missing directories are skipped. If the first open starts an explicit new session, saved tabs are restored behind it and the new session stays active. -Set `ui.persist_session_tabs = false` to disable saving and restoring tabs. +Set `ui.restore_tabs_on_startup = false` to disable saving and restoring tabs. This is separate from `ui.persist_state`, which keeps buffers when hiding the panel within a running Neovim instance. Snapshots live under `stdpath('state')/opencode/session-tabs/`; concurrent instances in the same diff --git a/lua/opencode/config.lua b/lua/opencode/config.lua index eee1d861..805c8767 100644 --- a/lua/opencode/config.lua +++ b/lua/opencode/config.lua @@ -239,7 +239,7 @@ M.defaults = { notify_on_background_prompt = true, window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder', persist_state = true, - persist_session_tabs = true, + restore_tabs_on_startup = true, icons = { preset = 'nerdfonts', overrides = {}, diff --git a/lua/opencode/services/session_tab_persistence.lua b/lua/opencode/services/session_tab_persistence.lua index 1a423bb4..84f18618 100644 --- a/lua/opencode/services/session_tab_persistence.lua +++ b/lua/opencode/services/session_tab_persistence.lua @@ -199,7 +199,7 @@ function M.restore(background) attempted = false restore_failed = false end - if attempted or not config.ui.persist_session_tabs then + if attempted or not config.ui.restore_tabs_on_startup then return nil end attempted = true @@ -224,7 +224,7 @@ end ---@return boolean function M.save() if - not config.ui.persist_session_tabs + not config.ui.restore_tabs_on_startup or not attempted or restore_failed or (pending_restore and not pending_restore:is_resolved()) diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index b318512b..60b81a3b 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -259,7 +259,7 @@ ---@field input_position 'bottom'|'top' # Position of the input window (default: 'bottom') ---@field window_width number ---@field persist_state boolean ----@field persist_session_tabs boolean # Save and restore logical panel tabs across Neovim restarts +---@field restore_tabs_on_startup boolean # Save and restore logical panel tabs across Neovim restarts ---@field zoom_width number ---@field float OpencodeUIFloatConfig ---@field picker_width number|false|nil # Width for pickers. 01 = absolute columns; false = use picker backend defaults. diff --git a/tests/unit/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 632c25fc..34ff6b6a 100644 --- a/tests/unit/services_session_runtime_spec.lua +++ b/tests/unit/services_session_runtime_spec.lua @@ -21,7 +21,7 @@ local support = require('tests.unit.services_spec_support') describe('opencode.services.session_runtime', function() local original - local original_persist_session_tabs + local original_restore_tabs_on_startup local function set_session_fact(session_id, parent_id) local connection = state.opencode_server @@ -32,8 +32,8 @@ describe('opencode.services.session_runtime', function() before_each(function() original = support.snapshot_state() -- Keep open() away from snapshots in the real stdpath('state'). - original_persist_session_tabs = config.ui.persist_session_tabs - config.ui.persist_session_tabs = false + original_restore_tabs_on_startup = config.ui.restore_tabs_on_startup + config.ui.restore_tabs_on_startup = false vim.fn.executable = function(_) return 1 @@ -66,7 +66,7 @@ describe('opencode.services.session_runtime', function() after_each(function() support.restore_state(original) - config.ui.persist_session_tabs = original_persist_session_tabs + config.ui.restore_tabs_on_startup = original_restore_tabs_on_startup for _, fn in ipairs({ 'create_windows', diff --git a/tests/unit/session_tab_persistence_spec.lua b/tests/unit/session_tab_persistence_spec.lua index 2bfb2ba5..6f8a4585 100644 --- a/tests/unit/session_tab_persistence_spec.lua +++ b/tests/unit/session_tab_persistence_spec.lua @@ -28,9 +28,9 @@ describe('session tab persistence', function() before_each(function() vim.wait(20) original_state = vim.deepcopy(state.store.state()) - original_enabled = config.ui.persist_session_tabs + original_enabled = config.ui.restore_tabs_on_startup original_server = vim.deepcopy(config.server) - config.ui.persist_session_tabs = true + config.ui.restore_tabs_on_startup = true temporary = vim.fn.tempname() vim.fn.mkdir(temporary, 'p') path_stub = stub(vim.fn, 'stdpath', function() @@ -60,7 +60,7 @@ describe('session tab persistence', function() path_stub:revert() server_stub:revert() vim.fn.delete(temporary, 'rf') - config.ui.persist_session_tabs = original_enabled + config.ui.restore_tabs_on_startup = original_enabled config.server = original_server for key in pairs(state.store.state()) do state.store.set_raw(key, nil) @@ -124,7 +124,7 @@ describe('session tab persistence', function() save_tabs() local saved = vim.fn.readfile(snapshot_path()) restart() - config.ui.persist_session_tabs = false + config.ui.restore_tabs_on_startup = false restore() assert.is_nil(tabs.current().active_session) assert.is_true(service.save()) From b0aa89a082462db9fedfabc1d23dc71f4f37aede Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Thu, 15 Oct 2026 07:58:00 -0400 Subject: [PATCH 3/4] feat(session-tabs): restore project-scoped tab groups Preserve tab groups across project switches, isolate groups by server, and restore saved groups across Neovim restarts. Document project-scoped behavior and test group switching, persistence, and directory-change orchestration. --- docs/configuration.md | 4 +- docs/usage.md | 14 +- lua/opencode/services/session_runtime.lua | 89 +++++- .../services/session_tab_persistence.lua | 260 ++++++++++++------ lua/opencode/state/session_tabs.lua | 66 ++++- lua/opencode/types.lua | 2 +- lua/opencode/ui/autocmds.lua | 2 +- lua/opencode/ui/session_tab_notifications.lua | 4 +- tests/unit/directory_session_spec.lua | 3 + tests/unit/lock_policy_spec.lua | 3 + tests/unit/session_tab_persistence_spec.lua | 235 +++++++++++++++- 11 files changed, 576 insertions(+), 106 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 17cc6006..700c682f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -257,7 +257,7 @@ require('opencode').setup({ notify_on_background_prompt = true, window_highlight = 'Normal:OpencodeBackground,FloatBorder:OpencodeBorder', persist_state = true, - restore_tabs_on_startup = true, -- Save and restore logical panel tabs across Neovim restarts + restore_tabs_on_startup = true, -- Keep project-scoped tab groups and restore them across Neovim restarts icons = { preset = 'nerdfonts', overrides = {}, @@ -498,7 +498,7 @@ the everyday ones. Session diff remapping has its own [example](review.md#custom | `ui.window_width` | `0.40` | Split width as a fraction of editor width | | `ui.zoom_width` | `0.8` | Zoomed split width | | `ui.persist_state` | `true` | Preserve UI buffers when closing/hiding | -| `ui.restore_tabs_on_startup` | `true` | Restore logical panel tabs across Neovim restarts | +| `ui.restore_tabs_on_startup` | `true` | Keep and restore project-scoped panel tab groups | | `ui.input.min_height` / `max_height` | `0.10` / `0.25` | Input height bounds as window-height fractions | | `ui.input.text.wrap` | `false` | Wrap prompt text | | `ui.input.auto_hide` | `false` | Hide input after submission or focus moves to output | diff --git a/docs/usage.md b/docs/usage.md index 430a7e62..328db578 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -48,14 +48,22 @@ The tab strip is hidden while there is only one tab; set asks a question or needs a permission, you get a notification. Turn that off with `ui.notify_on_background_prompt = false`. -Panel tabs are saved when Neovim exits and restored the first time you open -the panel in the same editor directory with the same configured server. +Panel tabs belong to an OpenCode project and configured server. Subdirectories +and worktrees with the same project ID share a tab group. Changing projects +with `:cd` saves the outgoing group and switches the entire panel to the new +project's group; a project without saved tabs starts empty. A locked session +pins the current group until unlocked. Non-project directories use separate +directory-scoped groups. + +Groups are saved on project switches and when Neovim exits, and restored on +first use. Switching back within Neovim resumes the group's buffers and drafts. Their order, active tab, directory bindings, and session locks are restored; drafts and window state are not. Deleted sessions and missing directories are skipped. If the first open starts an explicit new session, saved tabs are restored behind it and the new session stays active. -Set `ui.restore_tabs_on_startup = false` to disable saving and restoring tabs. +Set `ui.restore_tabs_on_startup = false` to disable project groups and persistence, +and retain the previous cwd-based current-session selection behavior. This is separate from `ui.persist_state`, which keeps buffers when hiding the panel within a running Neovim instance. Snapshots live under `stdpath('state')/opencode/session-tabs/`; concurrent instances in the same diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index 39445903..d3e89d52 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -14,6 +14,8 @@ local tab_persistence = require('opencode.services.session_tab_persistence') local M = {} local active_binding +local directory_change_generation = 0 +local group_panel_visible = false local function release_active_observation() local previous = active_binding @@ -339,6 +341,13 @@ end M.check_cwd = function() local runtime = session_tabs.current() + if config.ui.restore_tabs_on_startup then + if runtime and runtime.bound_directory then + runtime.bound_editor_cwd = vim.fn.getcwd() + state.context.set_current_cwd(runtime.bound_directory) + end + return + end if runtime and runtime.bound_directory then local cwd = vim.fn.getcwd() if runtime.bound_editor_cwd == cwd or M.is_session_locked(cwd) then @@ -364,9 +373,21 @@ M.check_cwd = function() end end +local function prepare_group_switch() + group_panel_visible = group_panel_visible or state.ui.is_visible() + state.ui.set_opening(false) + session_tabs.set_context(context.snapshot()) + if state.ui.is_visible() then + ui.prepare_session_tab_switch() + ui.hide_visible_windows(state.windows, true) + end + session_tabs.sync() +end + ---@param opts? OpenOpts M.open = Promise.async(function(opts) opts = opts or { focus = 'input', new_session = false } + local editor_cwd = vim.fs.normalize(vim.fn.getcwd()) session_tabs.ensure_current() session_tabs.set_context(context.snapshot()) @@ -375,7 +396,27 @@ M.open = Promise.async(function(opts) local created_windows local server_ok, server = pcall(function() + local previous = session_tabs.current() + local pinned = state.active_session and M.is_session_locked() and tab_persistence.is_initialized() + local restoring + if not pinned then + restoring = tab_persistence.restore(opts.new_session == true, prepare_group_switch) + end + if restoring then + restoring:await() + end + if vim.fs.normalize(vim.fn.getcwd()) ~= editor_cwd then + error('Working directory changed while opening panel') + end + local switched = session_tabs.current() ~= previous + if switched then + context.restore(session_tabs.get_context()) + end + state.ui.set_opening(true) local open_action = opts.open_action or state.ui.resolve_open_windows_action() + if switched and open_action == 'reuse_visible' then + open_action = state.ui.resolve_open_windows_action() + end if opts.new_session then context.clear_files() context.clear_selections() @@ -386,11 +427,8 @@ M.open = Promise.async(function(opts) if open_action ~= 'reuse_visible' then M.is_prompting_allowed() end - local restoring = tab_persistence.restore(opts.new_session == true) - if restoring then - restoring:await() - end created_windows = ui.prepare_windows(open_action, opts) + group_panel_visible = false return server_job.ensure_server():await() end) if not server_ok then @@ -414,9 +452,11 @@ M.open = Promise.async(function(opts) else agent_model.ensure_current_mode():await() if not state.active_session then - state.session.set_active(last_workspace_session():await()) - if not state.active_session then - state.session.set_active(M.create_new_session():await()) + if not config.ui.restore_tabs_on_startup then + state.session.set_active(last_workspace_session():await()) + if not state.active_session then + state.session.set_active(M.create_new_session():await()) + end end elseif not state.display_route and created_windows and ui.is_output_empty() then ui.render_output() @@ -1066,6 +1106,8 @@ end) ---@param cwd? string Directory from DirChanged; defaults to Neovim cwd. M.handle_directory_change = Promise.async(function(cwd) + directory_change_generation = directory_change_generation + 1 + local request = directory_change_generation cwd = cwd or vim.fn.getcwd() local locked = M.is_session_locked(cwd) log.debug('Working directory change %s', vim.inspect({ cwd = cwd, locked = locked })) @@ -1084,6 +1126,39 @@ M.handle_directory_change = Promise.async(function(cwd) return end + if config.ui.restore_tabs_on_startup then + local visible = state.ui.is_visible() or group_panel_visible + local previous = session_tabs.current() + local restoring = tab_persistence.restore(false, prepare_group_switch, cwd) + if restoring then + restoring:await() + end + -- A newer DirChanged owns the panel after an asynchronous lookup. + if request ~= directory_change_generation or vim.fs.normalize(vim.fn.getcwd()) ~= vim.fs.normalize(cwd) then + return + end + local current = session_tabs.current() + if current ~= previous or group_panel_visible then + context.restore(session_tabs.get_context()) + if visible then + ui.prepare_windows('restore_hidden', { focus = 'input', new_session = false }) + if state.active_session then + ui.render_output() + end + end + group_panel_visible = false + end + for _, tab in ipairs(session_tabs.list()) do + if tab.bound_directory then + tab.bound_editor_cwd = cwd + end + end + if current and not current.bound_directory then + state.context.set_current_cwd(current.current_cwd or cwd) + end + return + end + vim.notify('Loading last session for new working dir [' .. cwd .. ']', vim.log.levels.INFO) local runtime = session_tabs.current() diff --git a/lua/opencode/services/session_tab_persistence.lua b/lua/opencode/services/session_tab_persistence.lua index 84f18618..cbfdefce 100644 --- a/lua/opencode/services/session_tab_persistence.lua +++ b/lua/opencode/services/session_tab_persistence.lua @@ -7,11 +7,26 @@ local log = require('opencode.log') local v = require('opencode.shape') local M = {} -local attempted = false +---@class OpencodeTabGroup +---@field project string +---@field server string +---@field cwd string +---@field attempted boolean +---@field failed boolean +---@field restoring boolean +---@field restore_generation? integer + +---@type table +local groups = {} +---@type OpencodeTabGroup|nil +local owner +local generation = 0 +---@type string|nil +local requested_cwd +---@type string|nil +local requested_server ---@type Promise|nil local pending_restore --- A failed restore must neither overwrite the snapshot on exit nor block a later retry. -local restore_failed = false ---@class OpencodeSavedSessionTab ---@field session_id string @@ -22,60 +37,62 @@ local restore_failed = false ---@class OpencodeSessionTabSnapshot ---@field version integer ---@field cwd string +---@field project string ---@field server string ---@field tabs OpencodeSavedSessionTab[] ---@field active_index integer -- The snapshot file is external input: it may be corrupt or hand-edited. +local saved_tabs_shape = v.array(v.object({ + session_id = 'string', + directory = 'string', + bound_directory = v.optional('string'), + session_locked = v.optional('boolean'), +})) local snapshot_shape = v.object({ - version = v.literal(1), + version = v.literal(2), cwd = 'string', + project = 'string', server = 'string', active_index = v.integer(), - tabs = v.array(v.object({ - session_id = 'string', - directory = 'string', - bound_directory = v.optional('string'), - session_locked = v.optional('boolean'), - })), + tabs = saved_tabs_shape, }) +local project_shape = v.object({ id = 'string' }) ---@alias OpencodePersistedSessionLookup fun(connection: OpencodeV1Connection|OpencodeV2Connection, session_id: string, location: OpencodeLocation, path_map: OpencodePathMap, reverse_path_map: OpencodePathMap): Promise ----@return string, string -local function workspace() - local cwd = vim.fs.normalize(vim.fn.getcwd()) +---@return string +local function server_identity() -- Use configured identity, not a discovered/random port that changes on restart. local server = vim.json.encode({ config.opencode_executable, config.server.url or '', tostring(config.server.port or ''), }) - return cwd, server + return server end ----@param cwd string +---@param project string ---@param server string ---@return string -function M.path(cwd, server) - local key = vim.fn.sha256(cwd .. '\n' .. server) +function M.path(project, server) + local key = vim.fn.sha256(project .. '\n' .. server) return vim.fs.joinpath(vim.fn.stdpath('state'), 'opencode', 'session-tabs', key .. '.json') end ---Invalid snapshots read as missing, so a bad file never breaks opening the panel. ----@param cwd string +---@param project string ---@param server string ---@return OpencodeSessionTabSnapshot|nil -function M.load(cwd, server) - local file = io.open(M.path(cwd, server), 'r') +function M.load(project, server) + local file = io.open(M.path(project, server), 'r') if not file then return nil end local content = file:read('*a') file:close() local ok, snapshot = pcall(vim.json.decode, content) - -- cwd/server are re-checked to reject sha256 path collisions. - if not ok or not snapshot_shape:check(snapshot) or snapshot.cwd ~= cwd or snapshot.server ~= server then + if not ok or not snapshot_shape:check(snapshot) or snapshot.project ~= project or snapshot.server ~= server then return nil end return snapshot --[[@as OpencodeSessionTabSnapshot]] @@ -85,7 +102,7 @@ end ---@param snapshot OpencodeSessionTabSnapshot ---@return boolean, string|nil local function write(snapshot) - local path = M.path(snapshot.cwd, snapshot.server) + local path = M.path(snapshot.project, snapshot.server) local temporary = path .. '.' .. vim.fn.getpid() .. '.tmp' local ok, err = pcall(function() vim.fn.mkdir(vim.fs.dirname(path), 'p') @@ -99,6 +116,35 @@ local function write(snapshot) return true end +---@param key string +---@param group OpencodeTabGroup +---@return boolean +local function save_group(key, group) + local cwd, server = group.cwd, group.server + ---@type OpencodeSessionTabSnapshot + local snapshot = { version = 2, project = group.project, cwd = cwd, server = server, tabs = {}, active_index = 0 } + for _, runtime in ipairs(tabs.list(key)) do + local session = runtime.active_session + if session then + local directory = runtime.bound_directory or runtime.current_cwd or cwd + table.insert(snapshot.tabs, { + session_id = session.id, + directory = directory, + bound_directory = runtime.bound_directory, + session_locked = runtime.session_locked, + }) + if runtime.id == tabs.selection(key) then + snapshot.active_index = #snapshot.tabs + end + end + end + local ok, err = write(snapshot) + if not ok then + log.warn('Failed to persist session tabs: ' .. tostring(err)) + end + return ok +end + ---@param current OpencodeSessionTabRuntime[] ---@param background boolean ---@return boolean @@ -117,7 +163,8 @@ end ---@param current OpencodeSessionTabRuntime ---@param cwd string ---@param background boolean -local restore_snapshot = Promise.async(function(snapshot, current, cwd, background) +---@param is_current fun(): boolean +local restore_snapshot = Promise.async(function(snapshot, current, cwd, background, is_current) local connection = server_job.ensure_server():await() if not connection then error('Server failed to start') @@ -152,7 +199,7 @@ local restore_snapshot = Promise.async(function(snapshot, current, cwd, backgrou -- Lookups yield; explicit session actions or cwd changes meanwhile take priority. local latest = tabs.list() - if latest[1] ~= current or not untouched(latest, background) or workspace() ~= cwd then + if not is_current() or latest[1] ~= current or not untouched(latest, background) then return end local occupied = current.active_session and current.active_session.id @@ -181,39 +228,110 @@ local restore_snapshot = Promise.async(function(snapshot, current, cwd, backgrou end end) ----Restore once into an untouched tab registry, without mounting windows. ----Returns nil when there is nothing to wait for, so callers keep their normal open order. ----Concurrent opens share the same restore rather than mounting half-loaded state. ----Never rejects: failures are logged, fall back to ordinary session selection and retry on the next open. +---@return boolean +function M.is_initialized() + return owner ~= nil +end + +---Resolve the editor project, switch its runtime group, then restore its durable layout once. +---Concurrent opens for the same cwd/server share a request. Newer requests win. +---Never rejects: failures are logged, snapshots stay intact, and the next open retries. ---@param background boolean Explicit new-session opens keep the current tab and append saved tabs behind it. +---@param before_switch? fun() Called before parking outgoing runtime state +---@param directory? string Editor cwd, never a tab's bound directory ---@return Promise|nil -function M.restore(background) - if pending_restore then - if not pending_restore:is_resolved() then - return pending_restore - end - if not restore_failed then - return nil - end - pending_restore = nil - attempted = false - restore_failed = false - end - if attempted or not config.ui.restore_tabs_on_startup then +function M.restore(background, before_switch, directory) + if not config.ui.restore_tabs_on_startup then return nil end - attempted = true - local current = tabs.list() - if not untouched(current, background) then - return nil + local cwd = vim.fs.normalize(directory or vim.fn.getcwd()) + local server = server_identity() + if requested_cwd == cwd and requested_server == server and pending_restore and not pending_restore:is_resolved() then + return pending_restore end - local cwd, server = workspace() - local snapshot = M.load(cwd, server) - if not snapshot or #snapshot.tabs == 0 then + if requested_cwd == cwd and requested_server == server and owner and owner.attempted and not owner.failed then return nil end - pending_restore = restore_snapshot(snapshot, current[1], cwd, background):catch(function(err) - restore_failed = true + requested_cwd = cwd + requested_server = server + generation = generation + 1 + local request = generation + ---@type OpencodeTabGroup|nil + local group + pending_restore = Promise.async(function() + local connection = server_job.ensure_server():await() + if not connection then + error('Server failed to start') + end + local project = connection.operations + .get_current_project(connection, { directory = cwd }, util.apply_path_map, util.apply_reverse_path_map) + :await() + assert(project_shape:check(project), 'Project lookup requires a string id') + -- OpenCode's global project is shared by unrelated non-VCS directories. + local identity = project.id + if identity == '' or identity == 'global' then + identity = 'directory:' .. cwd + end + if request ~= generation then + return + end + local key = M.path(identity, server) + if not groups[key] then + groups[key] = + { project = identity, server = server, cwd = cwd, attempted = false, failed = false, restoring = false } + end + group = groups[key] + ---@type OpencodeTabGroup|nil + local previous = owner + if owner ~= group then + if previous then + M.save() + if previous.restoring then + previous.attempted = false + end + if before_switch then + before_switch() + end + end + tabs.switch_group(key, cwd) + owner = group + end + group.cwd = cwd + if group.restoring and group.restore_generation ~= request then + group.attempted = false + end + if group.attempted and not group.failed then + return + end + group.attempted = true + group.failed = false + local current = tabs.list() + if not untouched(current, background) then + return + end + local snapshot = M.load(identity, server) + if not snapshot or #snapshot.tabs == 0 then + return + end + group.restoring = true + group.restore_generation = request + restore_snapshot(snapshot, current[1], cwd, background, function() + return owner == group and request == generation + end):await() + if group.restore_generation == request then + group.restoring = false + if request ~= generation then + group.attempted = false + end + end + end)():catch(function(err) + if group and (not group.restore_generation or group.restore_generation == request) then + group.failed = true + group.restoring = false + end + if request == generation then + requested_cwd = nil + end log.warn('Failed to restore session tabs: ' .. tostring(err)) end) return pending_restore @@ -223,43 +341,25 @@ end ---Returns false on disk failure (logged), leaving the previous snapshot intact. ---@return boolean function M.save() - if - not config.ui.restore_tabs_on_startup - or not attempted - or restore_failed - or (pending_restore and not pending_restore:is_resolved()) - then + if not config.ui.restore_tabs_on_startup then return true end - local cwd, server = workspace() - ---@type OpencodeSessionTabSnapshot - local snapshot = { version = 1, cwd = cwd, server = server, tabs = {}, active_index = 0 } - for _, runtime in ipairs(tabs.list()) do - local session = runtime.active_session - if session then - local directory = runtime.bound_directory or runtime.current_cwd or cwd - table.insert(snapshot.tabs, { - session_id = session.id, - directory = directory, - bound_directory = runtime.bound_directory, - session_locked = runtime.session_locked, - }) - if runtime.id == tabs.active_id() then - snapshot.active_index = #snapshot.tabs - end + local success = true + for key, group in pairs(groups) do + if group.attempted and not group.failed and not group.restoring then + success = save_group(key, group) and success end end - local ok, err = write(snapshot) - if not ok then - log.warn('Failed to persist session tabs: ' .. tostring(err)) - end - return ok + return success end function M.setup() - attempted = false + groups = {} + owner = nil + generation = generation + 1 + requested_cwd = nil + requested_server = nil pending_restore = nil - restore_failed = false vim.api.nvim_create_autocmd('VimLeavePre', { group = vim.api.nvim_create_augroup('OpencodeSessionTabPersistence', { clear = true }), callback = function() diff --git a/lua/opencode/state/session_tabs.lua b/lua/opencode/state/session_tabs.lua index c6395be6..6c27798f 100644 --- a/lua/opencode/state/session_tabs.lua +++ b/lua/opencode/state/session_tabs.lua @@ -3,6 +3,7 @@ local renderer_context = require('opencode.ui.renderer.ctx') ---@class OpencodeSessionTabRuntime ---@field id string Logical panel-tab identifier +---@field group string Owning project/server group ---@field active_session OpencodeSession|nil ---@field windows OpencodeWindowState|nil Buffers and the currently mounted panel windows ---@field is_opening boolean @@ -106,6 +107,9 @@ local UI_KEYS = { local runtimes = {} local next_id = 1 local setup_done = false +local active_group = '' +---@type table +local group_selection = {} local function notify_change() store.update('session_tabs_changed', function(current) @@ -122,6 +126,7 @@ end local function default_runtime(id) return { id = id, + group = active_group, active_session = nil, windows = nil, is_opening = false, @@ -216,12 +221,15 @@ local function capture_runtime(id) return runtime end +---@param group? string Omitted includes every group ---@return OpencodeSessionTabRuntime[] -function M.list() +local function list(group) M.sync() local tabs = {} for _, runtime in pairs(runtimes) do - table.insert(tabs, runtime) + if not group or runtime.group == group then + table.insert(tabs, runtime) + end end table.sort(tabs, function(a, b) local a_order = tonumber(a.id:match('(%d+)$')) or 0 @@ -231,6 +239,52 @@ function M.list() return tabs end +---@param group? string Defaults to the active project group +---@return OpencodeSessionTabRuntime[] +function M.list(group) + return list(group or active_group) +end + +---@return OpencodeSessionTabRuntime[] +function M.all() + return list() +end + +---Adopt the initial registry, or park it and resume another project's runtimes. +---Inactive runtimes remain addressable by id for in-flight sends and notifications. +---@param group string +---@param cwd string +function M.switch_group(group, cwd) + M.sync() + if active_group == '' then + for _, runtime in pairs(runtimes) do + runtime.group = group + if not runtime.active_session and not runtime.bound_directory then + runtime.current_cwd = cwd + end + end + active_group = group + local current = runtimes[store.get('active_session_tab')] + if current and not current.active_session and not current.bound_directory then + store.set('current_cwd', cwd) + end + return + end + group_selection[active_group] = M.active_id() + active_group = group + local selected = group_selection[group] and runtimes[group_selection[group]] + if not selected then + selected = M.list(group)[1] + end + if not selected then + selected = default_runtime(new_id()) + selected.current_cwd = cwd + runtimes[selected.id] = selected + end + M.activate(selected) + notify_change() +end + ---@param id string ---@return OpencodeSessionTabRuntime|nil function M.get(id) @@ -378,6 +432,12 @@ function M.active_id() return store.get('active_session_tab') end +---@param group string +---@return string|nil +function M.selection(group) + return group == active_group and M.active_id() or group_selection[group] +end + ---@return boolean function M.is_current_bound() return M.current() ~= nil @@ -566,6 +626,8 @@ function M.reset() end renderer_context.select() runtimes = {} + active_group = '' + group_selection = {} next_id = 1 setup_done = false store.set_raw('active_session_tab', nil) diff --git a/lua/opencode/types.lua b/lua/opencode/types.lua index 60b81a3b..f343df31 100644 --- a/lua/opencode/types.lua +++ b/lua/opencode/types.lua @@ -259,7 +259,7 @@ ---@field input_position 'bottom'|'top' # Position of the input window (default: 'bottom') ---@field window_width number ---@field persist_state boolean ----@field restore_tabs_on_startup boolean # Save and restore logical panel tabs across Neovim restarts +---@field restore_tabs_on_startup boolean # Keep project-scoped panel tab groups and restore them across Neovim restarts ---@field zoom_width number ---@field float OpencodeUIFloatConfig ---@field picker_width number|false|nil # Width for pickers. 01 = absolute columns; false = use picker backend defaults. diff --git a/lua/opencode/ui/autocmds.lua b/lua/opencode/ui/autocmds.lua index 56db7fe6..11075d8d 100644 --- a/lua/opencode/ui/autocmds.lua +++ b/lua/opencode/ui/autocmds.lua @@ -209,7 +209,7 @@ function M.setup_autocmds(windows) pattern = { 'global', 'tabpage' }, group = group, callback = function(event) - if state.current_cwd == event.file then + if not require('opencode.config').ui.restore_tabs_on_startup and state.current_cwd == event.file then return end diff --git a/lua/opencode/ui/session_tab_notifications.lua b/lua/opencode/ui/session_tab_notifications.lua index 723dee67..5f7b6957 100644 --- a/lua/opencode/ui/session_tab_notifications.lua +++ b/lua/opencode/ui/session_tab_notifications.lua @@ -59,7 +59,7 @@ local function clear(kind, request_id) return end - local tabs = session_tabs.list() + local tabs = session_tabs.all() for _, runtime in ipairs(tabs) do if kind == 'permission' then session_tabs.remove_pending_permission(runtime.id, request_id) @@ -136,7 +136,7 @@ function M.setup() end function M.reset() - for _, runtime in ipairs(session_tabs.list()) do + for _, runtime in ipairs(session_tabs.all()) do runtime.background_notifications = {} end end diff --git a/tests/unit/directory_session_spec.lua b/tests/unit/directory_session_spec.lua index cfa795ee..b1ff8d03 100644 --- a/tests/unit/directory_session_spec.lua +++ b/tests/unit/directory_session_spec.lua @@ -179,12 +179,15 @@ describe('directory-bound session API', function() end) it('preserves a locked binding but releases it on unlocked DirChanged', function() + local enabled = config.ui.restore_tabs_on_startup + config.ui.restore_tabs_on_startup = false runtime.open_session({ directory = directory, new = true }):wait() runtime.set_session_lock(true) runtime.handle_directory_change(vim.fn.getcwd()):wait() assert.equal(directory, state.current_cwd) runtime.set_session_lock(false) runtime.handle_directory_change(vim.fn.getcwd()):wait() + config.ui.restore_tabs_on_startup = enabled assert.is_nil(state.session_tabs.current().bound_directory) assert.equal(vim.fn.getcwd(), state.current_cwd) end) diff --git a/tests/unit/lock_policy_spec.lua b/tests/unit/lock_policy_spec.lua index 3943f7fb..97841e88 100644 --- a/tests/unit/lock_policy_spec.lua +++ b/tests/unit/lock_policy_spec.lua @@ -72,10 +72,13 @@ describe('session lock policy', function() end) it('loads the target when the function returns false', function() + local enabled = config.ui.restore_tabs_on_startup + config.ui.restore_tabs_on_startup = false config.lock_session_to_directory = function() return false end runtime.handle_directory_change(other):wait() + config.ui.restore_tabs_on_startup = enabled assert.equal('other', state.active_session.id) assert.equal(other, state.current_cwd) end) diff --git a/tests/unit/session_tab_persistence_spec.lua b/tests/unit/session_tab_persistence_spec.lua index 6f8a4585..d4db150e 100644 --- a/tests/unit/session_tab_persistence_spec.lua +++ b/tests/unit/session_tab_persistence_spec.lua @@ -14,6 +14,7 @@ describe('session tab persistence', function() local path_stub local server_stub local connection + local project_id local function restart() tabs.reset() @@ -37,6 +38,10 @@ describe('session tab persistence', function() return temporary end) connection = { operations = {} } + project_id = 'test-project' + connection.operations.get_current_project = function() + return Promise.new():resolve({ id = project_id }) + end connection.operations.get_session = function(_, id, location) return Promise.new():resolve({ id = id, title = id, location = location }) end @@ -173,9 +178,10 @@ describe('session tab persistence', function() assert.equals('explicit', tabs.current().active_session.id) end) - it('returns nothing to wait for without a snapshot', function() - assert.is_nil(service.restore(false)) - assert.stub(server_stub).was_not_called() + it('resolves the editor project even without a snapshot', function() + restore() + assert.is_nil(tabs.current().active_session) + assert.stub(server_stub).was_called() end) it('restores saved tabs behind the current tab for explicit new-session opens', function() @@ -265,11 +271,12 @@ describe('session tab persistence', function() for _, content in ipairs({ 'not json', '42', - '{"version":2,"cwd":"/workspace","server":"server","tabs":[],"active_index":0}', - '{"version":1,"cwd":"/other","server":"server","tabs":[],"active_index":0}', - '{"version":1,"cwd":"/workspace","server":"server","active_index":0}', - '{"version":1,"cwd":"/workspace","server":"server","tabs":[{}],"active_index":1}', - '{"version":1,"cwd":"/workspace","server":"server","tabs":[],"active_index":0.5}', + '{"version":3,"project":"/workspace","cwd":"/workspace","server":"server","tabs":[],"active_index":0}', + '{"version":2,"project":"/other","cwd":"/workspace","server":"server","tabs":[],"active_index":0}', + '{"version":2,"project":"/workspace","cwd":"/workspace","server":"other","tabs":[],"active_index":0}', + '{"version":2,"project":"/workspace","cwd":"/workspace","server":"server","active_index":0}', + '{"version":2,"project":"/workspace","cwd":"/workspace","server":"server","tabs":[{}],"active_index":1}', + '{"version":2,"project":"/workspace","cwd":"/workspace","server":"server","tabs":[],"active_index":0.5}', }) do vim.fn.writefile({ content }, path) assert.is_nil(service.load('/workspace', 'server')) @@ -309,4 +316,216 @@ describe('session tab persistence', function() assert.equals(1, #tabs.list()) assert.is_nil(tabs.current().active_session) end) + + local function enter(project, cwd) + project_id = project + local restoring = service.restore(false, nil, cwd) + if restoring then + restoring:wait() + end + end + + it('switches whole project groups, preserving runtime state and active selection', function() + save_tabs() + local first, second = unpack(tabs.list()) + state.ui.set_input_content({ 'unfinished draft' }) + enter('other-project', temporary) + assert.equals(1, #tabs.list()) + assert.is_nil(tabs.current().active_session) + assert.equals(second, tabs.get(second.id)) + state.session.set_active({ id = 'other' }) + enter('test-project', vim.fn.getcwd()) + assert.same({ first, second }, tabs.list()) + assert.equals(second, tabs.current()) + assert.same({ 'unfinished draft' }, state.input_content) + enter('other-project', temporary) + assert.equals('other', tabs.current().active_session.id) + end) + + it('does not retarget tabs when subdirectories or worktrees resolve to the same project', function() + save_tabs() + local current = tabs.current() + enter('test-project', temporary) + assert.equals(current, tabs.current()) + assert.equals(2, #tabs.list()) + assert.equals('second', state.active_session.id) + assert.equals(1, #vim.fn.glob(temporary .. '/opencode/session-tabs/*.json', false, true)) + end) + + it('writes outgoing groups under their owner and restores both projects after restart', function() + save_tabs() + local path_a = snapshot_path() + local a = vim.json.decode(table.concat(vim.fn.readfile(path_a), '\n')) + enter('other-project', temporary) + local path_b = service.path('other-project', a.server) + assert.equals('test-project', vim.json.decode(table.concat(vim.fn.readfile(path_a), '\n')).project) + state.session.set_active({ id = 'other', location = { directory = temporary } }) + service.save() + assert.equals('other-project', vim.json.decode(table.concat(vim.fn.readfile(path_b), '\n')).project) + restart() + enter('test-project', vim.fn.getcwd()) + assert.equals(2, #tabs.list()) + enter('other-project', temporary) + assert.equals('other', state.active_session.id) + assert.equals(1, #tabs.list()) + end) + + it('isolates configured servers even at the same editor cwd', function() + save_tabs() + config.server.url = 'http://another-server' + restore() + assert.is_nil(tabs.current().active_session) + config.server = vim.deepcopy(original_server) + restore() + assert.equals('second', state.active_session.id) + end) + + it('isolates unrelated directories in the global project', function() + enter('global', '/one') + state.session.set_active({ id = 'one' }) + enter('global', '/two') + assert.is_nil(state.active_session) + enter('global', '/one') + assert.equals('one', state.active_session.id) + end) + + it('adopts the editor directory when the first group opens after a cwd change', function() + enter('test-project', temporary) + assert.equals(temporary, state.current_cwd) + assert.equals(temporary, tabs.current().current_cwd) + end) + + it('ignores out-of-order project resolutions', function() + restore() + state.session.set_active({ id = 'initial' }) + local slow = Promise.new() + connection.operations.get_current_project = function(_, location) + if location.directory == '/slow' then + return slow + end + return Promise.new():resolve({ id = 'fast-project' }) + end + local stale = service.restore(false, nil, '/slow') + service.restore(false, nil, '/fast'):wait() + state.session.set_active({ id = 'fast' }) + slow:resolve({ id = 'slow-project' }) + stale:wait() + assert.equals('fast', state.active_session.id) + assert.equals(1, #tabs.list()) + end) + + it('does not apply stale session lookups after leaving and returning to a group', function() + save_tabs() + restart() + local slow = Promise.new() + local original_lookup = connection.operations.get_session + connection.operations.get_session = function() + return slow + end + local stale = service.restore(false) + assert.is_true(vim.wait(1000, function() + return #server_stub.calls >= 3 + end)) + enter('other-project', temporary) + connection.operations.get_session = original_lookup + enter('test-project', vim.fn.getcwd()) + assert.equals(2, #tabs.list()) + slow:resolve({ id = 'stale' }) + stale:wait() + assert.equals(2, #tabs.list()) + assert.equals('second', state.active_session.id) + end) + + it('ignores an outdated snapshot and overwrites it on the next save', function() + save_tabs() + local path = snapshot_path() + local saved = vim.json.decode(table.concat(vim.fn.readfile(path), '\n')) + saved.version, saved.project = 1, nil + vim.fn.writefile({ vim.json.encode(saved) }, path) + restart() + restore() + assert.is_nil(state.active_session) + service.save() + local current = vim.json.decode(table.concat(vim.fn.readfile(path), '\n')) + assert.equals(2, current.version) + assert.equals('test-project', current.project) + assert.same({}, current.tabs) + end) + + it('clears pending requests on parked runtimes without exposing their tabs', function() + save_tabs() + local parked = tabs.current() + tabs.add_pending_permission(parked.id, { id = 'permission', sessionID = 'second' }) + enter('other-project', temporary) + assert.equals(1, #tabs.list()) + assert.equals(3, #tabs.all()) + require('opencode.ui.session_tab_notifications').clear_permission('permission') + assert.same({}, parked.pending_prompt_permissions) + end) + + describe('runtime project orchestration', function() + local runtime = require('opencode.services.session_runtime') + local ui = require('opencode.ui.ui') + local agent_model = require('opencode.services.agent_model') + local runtime_stubs + local cwd + + before_each(function() + cwd = vim.fn.getcwd() + runtime_stubs = { + stub(vim.fn, 'getcwd', function() + return cwd + end), + stub(ui, 'prepare_session_tab_switch'), + stub(ui, 'hide_visible_windows'), + stub(ui, 'prepare_windows').returns(false), + stub(ui, 'render_output'), + stub(ui, 'is_opencode_focused').returns(true), + stub(agent_model, 'ensure_current_mode').returns(Promise.new():resolve(nil)), + stub(agent_model, 'sync_from_server'), + } + connection.operations.get_current_project = function(_, location) + return Promise.new():resolve({ id = location.directory == temporary and 'other-project' or 'test-project' }) + end + end) + + after_each(function() + for _, item in ipairs(runtime_stubs) do + item:revert() + end + end) + + it('swaps the visible panel instead of retargeting its active tab', function() + save_tabs() + local visible = stub(state.ui, 'is_visible').returns(true) + table.insert(runtime_stubs, visible) + local previous = tabs.current() + cwd = temporary + runtime.handle_directory_change(cwd):wait() + assert.is_nil(state.active_session) + assert.equals(1, #tabs.list()) + assert.equals('second', previous.active_session.id) + assert.stub(ui.hide_visible_windows).was_called(1) + assert.stub(ui.prepare_windows).was_called(1) + cwd = vim.uv.cwd() + runtime.handle_directory_change(cwd):wait() + assert.equals(previous, tabs.current()) + assert.equals('second', state.active_session.id) + end) + + it('pins a locked group across directory changes and subsequent panel opens', function() + save_tabs() + table.insert(runtime_stubs, stub(state.ui, 'is_visible').returns(false)) + runtime.set_session_lock(true) + cwd = temporary + runtime.handle_directory_change(cwd):wait() + runtime.open({ new_session = false, open_action = 'restore_hidden' }):wait() + assert.equals('second', state.active_session.id) + assert.equals(2, #tabs.list()) + runtime.set_session_lock(false) + runtime.open({ new_session = false, open_action = 'restore_hidden' }):wait() + assert.is_nil(state.active_session) + assert.equals(1, #tabs.list()) + end) + end) end) From 6220a7ca3d5d0c66559e34f48bc2b95311881026 Mon Sep 17 00:00:00 2001 From: Francis Belanger Date: Fri, 16 Oct 2026 08:00:59 -0400 Subject: [PATCH 4/4] fix(session): create default session when no tabs restore --- lua/opencode/services/session_runtime.lua | 8 +-- tests/unit/native_service_spec.lua | 12 +++- tests/unit/session_tab_persistence_spec.lua | 77 ++++++++++++++++++++- 3 files changed, 87 insertions(+), 10 deletions(-) diff --git a/lua/opencode/services/session_runtime.lua b/lua/opencode/services/session_runtime.lua index d3e89d52..7e2f1daf 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -454,9 +454,9 @@ M.open = Promise.async(function(opts) if not state.active_session then if not config.ui.restore_tabs_on_startup then state.session.set_active(last_workspace_session():await()) - if not state.active_session then - state.session.set_active(M.create_new_session():await()) - end + end + if not state.active_session then + state.session.set_active(M.create_new_session():await()) end elseif not state.display_route and created_windows and ui.is_output_empty() then ui.render_output() @@ -1141,7 +1141,7 @@ M.handle_directory_change = Promise.async(function(cwd) if current ~= previous or group_panel_visible then context.restore(session_tabs.get_context()) if visible then - ui.prepare_windows('restore_hidden', { focus = 'input', new_session = false }) + M.open({ focus = 'input', new_session = false, open_action = 'restore_hidden' }):await() if state.active_session then ui.render_output() end diff --git a/tests/unit/native_service_spec.lua b/tests/unit/native_service_spec.lua index 9110ad73..c4fdc76b 100644 --- a/tests/unit/native_service_spec.lua +++ b/tests/unit/native_service_spec.lua @@ -79,6 +79,7 @@ describe('native V2 service discovery', function() it('checkhealth clears and closes the Connection it acquired without killing the native service', function() local health_api = vim.health or require('health') + local config_file = require('opencode.config_file') local original = { executable = vim.fn.executable, system = vim.system, @@ -88,6 +89,7 @@ describe('native V2 service discovery', function() error = health_api.error, warn = health_api.warn, info = health_api.info, + get_opencode_config = config_file.get_opencode_config, } local messages, acquired, killed = {}, nil, false for _, name in ipairs({ 'start', 'ok', 'error', 'warn', 'info' }) do @@ -108,6 +110,11 @@ describe('native V2 service discovery', function() require('opencode.util').kill_pid = function() killed = true end + -- Health owns connection cleanup, not protocol-specific configuration routes. + config_file.get_opencode_config = function() + acquired = state.opencode_server + return Promise.new():resolve({}) + end curl.request = function(opts) vim.schedule(function() if opts.url:match('/api/info$') then @@ -115,9 +122,7 @@ describe('native V2 service discovery', function() elseif opts.url:match('/openapi%.json$') then opts.callback({ status = 404, body = '{}' }) else - acquired = state.opencode_server - assert.matches('^http://127%.0%.0%.1:49374/api/config%?', opts.url) - opts.callback({ status = 200, body = '{}' }) + error('Unexpected health request: ' .. opts.url) end end) return { @@ -133,6 +138,7 @@ describe('native V2 service discovery', function() vim.fn.executable = original.executable vim.system = original.system require('opencode.util').kill_pid = original.kill_pid + config_file.get_opencode_config = original.get_opencode_config for _, name in ipairs({ 'start', 'ok', 'error', 'warn', 'info' }) do health_api[name] = original[name] end diff --git a/tests/unit/session_tab_persistence_spec.lua b/tests/unit/session_tab_persistence_spec.lua index d4db150e..311f8b57 100644 --- a/tests/unit/session_tab_persistence_spec.lua +++ b/tests/unit/session_tab_persistence_spec.lua @@ -481,12 +481,22 @@ describe('session tab persistence', function() stub(ui, 'prepare_windows').returns(false), stub(ui, 'render_output'), stub(ui, 'is_opencode_focused').returns(true), - stub(agent_model, 'ensure_current_mode').returns(Promise.new():resolve(nil)), + stub(agent_model, 'ensure_current_mode').invokes(function() + state.model.set_mode('build') + return Promise.new():resolve(true) + end), + stub(agent_model, 'initialize_current_model').invokes(function() + state.model.set_model('test/model') + return Promise.new():resolve('test/model') + end), stub(agent_model, 'sync_from_server'), } connection.operations.get_current_project = function(_, location) return Promise.new():resolve({ id = location.directory == temporary and 'other-project' or 'test-project' }) end + connection.operations.create_session = function(_, location) + return Promise.new():resolve({ id = 'default-session', location = location }) + end end) after_each(function() @@ -502,7 +512,8 @@ describe('session tab persistence', function() local previous = tabs.current() cwd = temporary runtime.handle_directory_change(cwd):wait() - assert.is_nil(state.active_session) + assert.equals('default-session', state.active_session.id) + assert.equals(temporary, state.active_session.location.directory) assert.equals(1, #tabs.list()) assert.equals('second', previous.active_session.id) assert.stub(ui.hide_visible_windows).was_called(1) @@ -513,6 +524,66 @@ describe('session tab persistence', function() assert.equals('second', state.active_session.id) end) + it('creates a default session on first open without a snapshot and reuses it on reopen', function() + cwd = temporary + local create = stub(connection.operations, 'create_session').invokes(function(_, location) + return Promise.new():resolve({ id = 'fresh', location = location }) + end) + table.insert(runtime_stubs, create) + runtime.open({ new_session = false }):wait() + assert.equals('fresh', state.active_session.id) + assert.equals(temporary, state.active_session.location.directory) + assert.equals(1, #tabs.list()) + runtime.open({ new_session = false }):wait() + assert.stub(create).was_called(1) + end) + + it('defers default-session creation for a hidden incoming group until the panel opens', function() + save_tabs() + table.insert(runtime_stubs, stub(state.ui, 'is_visible').returns(false)) + cwd = temporary + runtime.handle_directory_change(cwd):wait() + assert.is_nil(state.active_session) + runtime.open({ new_session = false }):wait() + assert.equals('default-session', state.active_session.id) + assert.equals(temporary, state.active_session.location.directory) + assert.equals(1, #tabs.list()) + end) + + it('keeps restored sessions without creating an extra default session', function() + save_tabs() + restart() + local create = stub(connection.operations, 'create_session') + table.insert(runtime_stubs, create) + runtime.open({ new_session = false }):wait() + assert.equals('second', state.active_session.id) + assert.equals(2, #tabs.list()) + assert.stub(create).was_not_called() + end) + + for _, empty_snapshot in ipairs({ false, true }) do + it( + 'creates a default session when ' .. (empty_snapshot and 'the snapshot is empty' or 'no saved sessions survive'), + function() + save_tabs() + if empty_snapshot then + for _, tab in ipairs(tabs.list()) do + tabs.remove(tab) + end + service.save() + else + connection.operations.get_session = function() + return Promise.new():reject('Not found') + end + end + restart() + runtime.open({ new_session = false }):wait() + assert.equals('default-session', state.active_session.id) + assert.equals(1, #tabs.list()) + end + ) + end + it('pins a locked group across directory changes and subsequent panel opens', function() save_tabs() table.insert(runtime_stubs, stub(state.ui, 'is_visible').returns(false)) @@ -524,7 +595,7 @@ describe('session tab persistence', function() assert.equals(2, #tabs.list()) runtime.set_session_lock(false) runtime.open({ new_session = false, open_action = 'restore_hidden' }):wait() - assert.is_nil(state.active_session) + assert.equals('default-session', state.active_session.id) assert.equals(1, #tabs.list()) end) end)