diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 8adfddd8140..3616acb77a4 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -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) + + 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) + + 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) + + 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', () => { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index c09dd812fe4..461415c6402 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -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 return } const validIds = new Set() + // 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 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)) } })