diff --git a/docs/configuration.md b/docs/configuration.md index 99e52ed3..700c682f 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, + restore_tabs_on_startup = true, -- Keep project-scoped tab groups and restore them 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.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 d57a35ea..328db578 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -48,6 +48,27 @@ 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 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 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 +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..805c8767 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, + restore_tabs_on_startup = 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..7e2f1daf 100644 --- a/lua/opencode/services/session_runtime.lua +++ b/lua/opencode/services/session_runtime.lua @@ -9,10 +9,13 @@ 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 = {} local active_binding +local directory_change_generation = 0 +local group_panel_visible = false local function release_active_observation() local previous = active_binding @@ -338,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 @@ -363,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()) @@ -374,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,6 +428,7 @@ M.open = Promise.async(function(opts) M.is_prompting_allowed() 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 @@ -409,7 +452,9 @@ 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 config.ui.restore_tabs_on_startup then + state.session.set_active(last_workspace_session():await()) + end if not state.active_session then state.session.set_active(M.create_new_session():await()) end @@ -1061,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 })) @@ -1079,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 + M.open({ focus = 'input', new_session = false, open_action = 'restore_hidden' }):await() + 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 new file mode 100644 index 00000000..cbfdefce --- /dev/null +++ b/lua/opencode/services/session_tab_persistence.lua @@ -0,0 +1,371 @@ +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 = {} +---@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 + +---@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 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(2), + cwd = 'string', + project = 'string', + server = 'string', + active_index = v.integer(), + 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 +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 server +end + +---@param project string +---@param server string +---@return string +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 project string +---@param server string +---@return OpencodeSessionTabSnapshot|nil +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) + 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]] +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.project, 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 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 +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 +---@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') + 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 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 + 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) + +---@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, before_switch, directory) + if not config.ui.restore_tabs_on_startup then + return nil + end + 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 + if requested_cwd == cwd and requested_server == server and owner and owner.attempted and not owner.failed then + return nil + end + 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 +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.restore_tabs_on_startup then + return true + 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 + return success +end + +function M.setup() + groups = {} + owner = nil + generation = generation + 1 + requested_cwd = nil + requested_server = nil + pending_restore = nil + 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/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 5c12910b..f343df31 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 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/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/services_session_runtime_spec.lua b/tests/unit/services_session_runtime_spec.lua index 9dec0aa4..34ff6b6a 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_restore_tabs_on_startup 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_restore_tabs_on_startup = config.ui.restore_tabs_on_startup + config.ui.restore_tabs_on_startup = 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.restore_tabs_on_startup = original_restore_tabs_on_startup 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..311f8b57 --- /dev/null +++ b/tests/unit/session_tab_persistence_spec.lua @@ -0,0 +1,602 @@ +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 project_id + + 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.restore_tabs_on_startup + original_server = vim.deepcopy(config.server) + config.ui.restore_tabs_on_startup = true + temporary = vim.fn.tempname() + vim.fn.mkdir(temporary, 'p') + path_stub = stub(vim.fn, 'stdpath', 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 + 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.restore_tabs_on_startup = 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.restore_tabs_on_startup = 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('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() + 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":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')) + 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) + + 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').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() + 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.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) + 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('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)) + 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.equals('default-session', state.active_session.id) + assert.equals(1, #tabs.list()) + end) + end) +end)