Preserve floating workspace state after restart (#5047)

* Preserve floating workspace state during restart purge

* rm doc
This commit is contained in:
Jinjing
2026-06-11 12:27:57 -07:00
committed by Jinwoo-H
parent 6255c5dabd
commit 9e81af8ae3
2 changed files with 270 additions and 2 deletions
@@ -88,6 +88,7 @@ import {
registerPersistentWebview,
unregisterPersistentWebview
} from '../../components/browser-pane/webview-registry'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
function resetRemoteRuntimeMocks() {
clearRuntimeCompatibilityCacheForTests()
@@ -3243,6 +3244,117 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => {
})
expect(store.getState().hasHydratedWorktreePurge).toBe(true)
})
it('preserves floating workspace state while purging a real stale worktree', async () => {
const store = createTestStore()
const wtA = makeWorktree({ id: 'repoA::/a/wt1', repoId: 'repoA', path: '/a/wt1' })
const wtB = makeWorktree({ id: 'repoB::/b/wt1', repoId: 'repoB', path: '/b/wt1' })
const staleId = 'repoA::/a/zombie'
const floatingFile = {
id: 'floating-file',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
filePath: '/floating/note.md',
relativePath: 'note.md',
language: 'markdown',
isDirty: false,
isPreview: false,
mode: 'edit' as const
}
mockApi.worktrees.list.mockImplementation(async ({ repoId }: { repoId: string }) =>
repoId === 'repoA' ? [wtA] : [wtB]
)
store.setState({
repos: [repoA, repoB],
activeWorktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeFileId: 'floating-file',
activeTabId: 'floating-terminal-tab',
activeTabType: 'editor' as const,
tabsByWorktree: {
[wtA.id]: [{ id: 'tab-A', worktreeId: wtA.id }],
[staleId]: [{ id: 'tab-zombie', worktreeId: staleId }],
[FLOATING_TERMINAL_WORKTREE_ID]: [
{ id: 'floating-terminal-tab', worktreeId: FLOATING_TERMINAL_WORKTREE_ID }
]
},
browserTabsByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: [{ id: 'floating-browser', url: 'https://orca.test' }]
},
activeBrowserTabIdByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: 'floating-browser'
},
openFiles: [
floatingFile,
{
id: 'stale-file',
worktreeId: staleId,
filePath: '/a/zombie/stale.ts',
relativePath: 'stale.ts',
language: 'typescript',
isDirty: false,
isPreview: false,
mode: 'edit' as const
}
],
activeFileIdByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: 'floating-file',
[staleId]: 'stale-file'
},
unifiedTabsByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: 'floating-unified-tab',
type: 'editor',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
fileId: 'floating-file'
}
],
[staleId]: [{ id: 'stale-unified-tab', worktreeId: staleId }]
},
groupsByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: 'floating-group',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: 'floating-unified-tab'
}
],
[staleId]: [{ id: 'stale-group', worktreeId: staleId, activeTabId: 'stale-unified-tab' }]
},
layoutByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: { type: 'leaf', groupId: 'floating-group' },
[staleId]: { type: 'leaf', groupId: 'stale-group' }
}
} as unknown as Partial<AppState>)
await store.getState().fetchAllWorktrees()
expect(store.getState().hasHydratedWorktreePurge).toBe(true)
expect(store.getState().tabsByWorktree).toEqual({
[wtA.id]: [{ id: 'tab-A', worktreeId: wtA.id }],
[FLOATING_TERMINAL_WORKTREE_ID]: [
{ id: 'floating-terminal-tab', worktreeId: FLOATING_TERMINAL_WORKTREE_ID }
]
})
expect(store.getState().browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toEqual([
{ id: 'floating-browser', url: 'https://orca.test' }
])
expect(store.getState().openFiles).toEqual([floatingFile])
expect(store.getState().activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(
'floating-file'
)
expect(store.getState().unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toHaveLength(1)
expect(store.getState().groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toHaveLength(1)
expect(store.getState().layoutByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toEqual({
type: 'leaf',
groupId: 'floating-group'
})
expect(store.getState().activeWorktreeId).toBe(FLOATING_TERMINAL_WORKTREE_ID)
expect(store.getState().activeFileId).toBe('floating-file')
expect(store.getState().activeTabId).toBe('floating-terminal-tab')
expect(store.getState().activeTabType).toBe('editor')
})
})
// Why: design §4.4 — purgeWorktreeTerminalState wipes every worktree-scoped
@@ -3342,6 +3454,157 @@ describe('purgeWorktreeTerminalState direct (design §4.4)', () => {
expect(store.getState().tabsByWorktree).toBe(before)
})
it('ignores the floating workspace sentinel while purging mixed real ids', () => {
const store = createTestStore()
const staleId = 'repoA::/a/wt1'
const floatingFile = {
id: 'floating-file',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
filePath: '/floating/note.md',
relativePath: 'note.md',
language: 'markdown',
isDirty: false,
isPreview: false,
mode: 'edit' as const
}
store.setState({
tabsByWorktree: {
[staleId]: [{ id: 'tab-1', worktreeId: staleId }],
[FLOATING_TERMINAL_WORKTREE_ID]: [
{ id: 'floating-terminal-tab', worktreeId: FLOATING_TERMINAL_WORKTREE_ID }
]
},
browserTabsByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: [{ id: 'floating-browser', url: 'https://orca.test' }]
},
openFiles: [
floatingFile,
{
id: 'stale-file',
worktreeId: staleId,
filePath: '/a/wt1/stale.ts',
relativePath: 'stale.ts',
language: 'typescript',
isDirty: false,
isPreview: false,
mode: 'edit' as const
}
],
activeFileIdByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: 'floating-file',
[staleId]: 'stale-file'
},
unifiedTabsByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: 'floating-unified-tab',
type: 'editor',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
fileId: 'floating-file'
}
],
[staleId]: [{ id: 'stale-unified-tab', worktreeId: staleId }]
},
groupsByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: 'floating-group',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: 'floating-unified-tab'
}
],
[staleId]: [{ id: 'stale-group', worktreeId: staleId, activeTabId: 'stale-unified-tab' }]
},
layoutByWorktree: {
[FLOATING_TERMINAL_WORKTREE_ID]: { type: 'leaf', groupId: 'floating-group' },
[staleId]: { type: 'leaf', groupId: 'stale-group' }
}
} as unknown as Partial<AppState>)
store.getState().purgeWorktreeTerminalState([staleId, FLOATING_TERMINAL_WORKTREE_ID])
expect(store.getState().tabsByWorktree).toEqual({
[FLOATING_TERMINAL_WORKTREE_ID]: [
{ id: 'floating-terminal-tab', worktreeId: FLOATING_TERMINAL_WORKTREE_ID }
]
})
expect(store.getState().browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toEqual([
{ id: 'floating-browser', url: 'https://orca.test' }
])
expect(store.getState().openFiles).toEqual([floatingFile])
expect(store.getState().activeFileIdByWorktree).toEqual({
[FLOATING_TERMINAL_WORKTREE_ID]: 'floating-file'
})
expect(store.getState().unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toHaveLength(1)
expect(store.getState().groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toHaveLength(1)
expect(store.getState().layoutByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toEqual({
type: 'leaf',
groupId: 'floating-group'
})
})
it('is a no-op when only the floating workspace sentinel is passed', () => {
const store = createTestStore()
const tabsByWorktree = {}
const browserTabsByWorktree = {
[FLOATING_TERMINAL_WORKTREE_ID]: [{ id: 'floating-browser', url: 'https://orca.test' }]
}
const openFiles = [
{
id: 'floating-file',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
filePath: '/floating/note.md',
relativePath: 'note.md',
language: 'markdown',
isDirty: false,
isPreview: false,
mode: 'edit' as const
}
]
const unifiedTabsByWorktree = {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: 'floating-unified-tab',
type: 'editor',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
fileId: 'floating-file'
}
]
}
const groupsByWorktree = {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: 'floating-group',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: 'floating-unified-tab'
}
]
}
const layoutByWorktree = {
[FLOATING_TERMINAL_WORKTREE_ID]: { type: 'leaf', groupId: 'floating-group' }
}
store.setState({
tabsByWorktree,
browserTabsByWorktree,
openFiles,
activeFileIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: 'floating-file' },
unifiedTabsByWorktree,
groupsByWorktree,
layoutByWorktree
} as unknown as Partial<AppState>)
store.getState().purgeWorktreeTerminalState([FLOATING_TERMINAL_WORKTREE_ID])
expect(store.getState().tabsByWorktree).toBe(tabsByWorktree)
expect(store.getState().browserTabsByWorktree).toBe(browserTabsByWorktree)
expect(store.getState().openFiles).toBe(openFiles)
expect(store.getState().unifiedTabsByWorktree).toBe(unifiedTabsByWorktree)
expect(store.getState().groupsByWorktree).toBe(groupsByWorktree)
expect(store.getState().layoutByWorktree).toBe(layoutByWorktree)
})
})
describe('markWorktreeVisited', () => {
+7 -2
View File
@@ -45,6 +45,7 @@ import {
parseExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers'
// Why: old runtime servers only have `worktree.list`; preserve the large-list
@@ -940,6 +941,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
return
}
const validIds = new Set<string>()
// Why: floating is persisted renderer state, but not a repo worktree that
// authoritative runtime scans can return.
validIds.add(FLOATING_TERMINAL_WORKTREE_ID)
for (const result of Object.values(get().detectedWorktreesByRepo)) {
if (!result.authoritative) {
continue
@@ -2556,9 +2560,10 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
getKnownWorktreeById: (worktreeId) => findKnownWorktreeById(get(), worktreeId),
purgeWorktreeTerminalState: (worktreeIds: string[]) => {
if (worktreeIds.length === 0) {
const purgeableWorktreeIds = worktreeIds.filter((id) => id !== FLOATING_TERMINAL_WORKTREE_ID)
if (purgeableWorktreeIds.length === 0) {
return
}
set((s) => buildWorktreePurgeState(s, worktreeIds))
set((s) => buildWorktreePurgeState(s, purgeableWorktreeIds))
}
})