Files
orca/src/main/persistence-empty-root-layout-load.test.ts
T
Jinwoo Hong aceebea0a4 fix(terminal): name a new tab's first pane so its saved layout is never empty (#28168)
* fix(terminal): name a new tab's first pane so its saved layout is never empty

createTab now always mints the first leaf id, so the window never saves a
terminal tab with an empty layout root. With no empty roots coming from the
window, main's save-time swap that kept its own root over an empty window
root (from #1909) has nothing left to guard, and is deleted.

* test(persistence): an empty-root layout from an old session still loads and gets its pane

* fix(persistence): name an old session's empty layout root once at load

A session saved before tabs named their first pane can hold an unmounted tab
with an empty root. If main bound a terminal there, the window's next save of
that tab dropped the binding once the save-time swap was gone. Name the pane at
load instead (the same pane the window would mount), so no save carries an
empty root. The rootless-pane resolver moves to shared so main and the window
pick the same pane.

* test(persistence): name the bind-then-stale-write order the load repair covers

* fix(persistence): name an old empty tab's pane with the stable headless leaf id

The load repair fell back to a random id, so an unsaved headless serve gave a
phone a new tab::leaf id on every restart. Use deriveHeadlessLegacyTerminalLeafId,
which phones already saw for these tabs. Also import the rootless resolver from
shared directly and return the loaded state unchanged when nothing needs repair.
2026-10-13 04:48:20 -04:00

124 lines
4.4 KiB
TypeScript

import {
closeTestStores,
testState,
createStore,
writeDataFile,
makeRepo,
makeTerminalTab
} from './persistence-test-harness'
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { rmSync, mkdtempSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import type { TerminalLayoutSnapshot } from '../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../shared/workspace-session-state-types'
import { getDefaultWorkspaceSession } from '../shared/constants'
import { isTerminalLeafId } from '../shared/stable-pane-id'
import { TEST_LEAF_1 } from './persistence-session-fixtures'
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: { isEncryptionAvailable: () => false }
}))
const WORKTREE_ID = 'repo-1::/worktree'
// A session saved before tabs named their first pane: the tab exists, its layout has no root.
function oldSession(layout: TerminalLayoutSnapshot): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
activeRepoId: 'repo-1',
activeWorktreeId: WORKTREE_ID,
activeTabId: 'tab-1',
tabsByWorktree: {
[WORKTREE_ID]: [makeTerminalTab({ id: 'tab-1', worktreeId: WORKTREE_ID, ptyId: null })]
},
terminalLayoutsByTabId: { 'tab-1': layout }
}
}
function loadOldSession(layout: TerminalLayoutSnapshot) {
writeDataFile({
schemaVersion: 1,
repos: [makeRepo({ id: 'repo-1', path: '/repo-1' })],
workspaceSession: oldSession(layout)
})
return createStore()
}
describe('a saved layout with an empty root', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-test-'))
})
afterEach(async () => {
await closeTestStores()
rmSync(testState.dir, { recursive: true, force: true })
})
// The order that lost the binding once the save-time swap was gone: main binds first, then a
// stale window write of the loaded (unmounted) tab arrives. Without the load repair that write
// carries an empty root and the bound pane reads as removed.
it('keeps the binding when main binds first and a stale write of the loaded tab follows', async () => {
const store = loadOldSession({ root: null, activeLeafId: null, expandedLeafId: null })
// The window's copy of the session as it was loaded.
const loaded = structuredClone(store.getWorkspaceSession())
// Main binds the pane the loaded layout names (any pane, if it names none).
const leafId = loaded.terminalLayoutsByTabId['tab-1']?.activeLeafId ?? TEST_LEAF_1
await store.persistPtyBinding({
worktreeId: WORKTREE_ID,
tabId: 'tab-1',
leafId,
ptyId: 'pty-1'
})
// Stale write: the window never mounted the tab, so it saves the layout as loaded, unbound.
store.setWorkspaceSession({
...loaded,
terminalLayoutsByTabId: {
'tab-1': { ...loaded.terminalLayoutsByTabId['tab-1']!, ptyIdsByLeafId: {} }
}
})
const session = store.getWorkspaceSession()
expect(session.tabsByWorktree[WORKTREE_ID]?.[0]?.ptyId).toBe('pty-1')
expect(session.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId).toEqual({ [leafId]: 'pty-1' })
})
it('names a fresh pane when the old layout names none', () => {
const layout = loadOldSession({
root: null,
activeLeafId: null,
expandedLeafId: null
}).getWorkspaceSession().terminalLayoutsByTabId['tab-1']
const leafId = layout?.activeLeafId ?? ''
expect(isTerminalLeafId(leafId)).toBe(true)
expect(layout?.root).toEqual({ type: 'leaf', leafId })
})
it('names the same pane on every load until something saves', async () => {
const emptyRoot = { root: null, activeLeafId: null, expandedLeafId: null }
const first = loadOldSession(emptyRoot).getWorkspaceSession().terminalLayoutsByTabId['tab-1']
await closeTestStores()
const second = loadOldSession(emptyRoot).getWorkspaceSession().terminalLayoutsByTabId['tab-1']
expect(second?.root).toEqual(first?.root)
})
it('names the pane the old layout was already bound to', () => {
const store = loadOldSession({
root: null,
activeLeafId: null,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' }
})
expect(store.getWorkspaceSession().terminalLayoutsByTabId['tab-1']).toEqual({
root: { type: 'leaf', leafId: TEST_LEAF_1 },
activeLeafId: TEST_LEAF_1,
expandedLeafId: null,
ptyIdsByLeafId: { [TEST_LEAF_1]: 'pty-1' }
})
})
})