From fe337935ec5682fa8e2ffc13e0be8b2a777e915a Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 20 Jul 2026 00:03:58 -0700 Subject: [PATCH] feat(editor): atomic rekeyOpenFilesForPathChange store action (move restructure stage 1) Foundation for treating an Orca-owned move as an in-place retarget of the open edit session (not close+reopen), per the locked design. One commit-only store update migrates every path-derived id + all id-keyed state (openFiles full-spread, the 6 file-id maps, activeFileId(+byWorktree), tabBarOrder, unified tabs/groups via the now editor-family-widened migrateHydratedEditorTabsAndGroups, pendingEditorReveal, untitled consume). Preflight fails closed on collision (never merges two live sessions) or stale with zero mutation. Not yet wired to a coordinator (stage 4). Stage 1 of 5; behind the shipped content-identity fix. Co-authored-by: Orca --- .../slices/editor-rekey-open-files.test.ts | 206 ++++++++++++++++++ src/renderer/src/store/slices/editor.ts | 139 +++++++++++- 2 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 src/renderer/src/store/slices/editor-rekey-open-files.test.ts diff --git a/src/renderer/src/store/slices/editor-rekey-open-files.test.ts b/src/renderer/src/store/slices/editor-rekey-open-files.test.ts new file mode 100644 index 00000000000..ede90acec17 --- /dev/null +++ b/src/renderer/src/store/slices/editor-rekey-open-files.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import type { OpenFilePathRekey } from './editor' +import type { Tab, TabGroup } from '../../../../shared/types' + +// Stage-1 foundation: rekeyOpenFilesForPathChange atomically retargets an open +// edit session across an Orca-owned move (no close/reopen), preserving all +// id-keyed state and failing closed on collision/stale without mutating. + +function seedEditTab(overrides: Partial> = {}): void { + const state = useAppStore.getState() + state.openFile( + { + filePath: '/repo/a.md', + relativePath: 'a.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const id = useAppStore.getState().openFiles[0]!.id + state.setEditorDraft(id, '20 min of work') + state.markFileDirty(id, true) + state.setLastKnownDiskSignature(id, 'sig-a') + useAppStore.setState((s) => ({ + editorCursorLine: { ...s.editorCursorLine, [id]: 42 }, + markdownViewMode: { ...s.markdownViewMode, [id]: 'rendered' } as never, + activeFileId: id, + activeFileIdByWorktree: { ...s.activeFileIdByWorktree, 'wt-1': id }, + tabBarOrderByWorktree: { ...s.tabBarOrderByWorktree, 'wt-1': [id] }, + ...overrides + })) +} + +function rekeyFor(oldId: string, newPath: string, newRel: string): OpenFilePathRekey { + return { + oldFileId: oldId, + newFileId: newPath, // path-derived id for a local tab + oldFilePath: '/repo/a.md', + newFilePath: newPath, + newRelativePath: newRel + } +} + +describe('rekeyOpenFilesForPathChange', () => { + beforeEach(() => { + useAppStore.setState(useAppStore.getInitialState(), true) + }) + + it('retargets the session preserving draft, dirty, baseline, cursor, view, active, tab bar', () => { + seedEditTab() + const oldId = useAppStore.getState().openFiles[0]!.id + const newPath = '/repo/sub/a.md' + + const result = useAppStore.getState().rekeyOpenFilesForPathChange({ + worktreeId: 'wt-1', + rekeys: [rekeyFor(oldId, newPath, 'sub/a.md')] + }) + + expect(result).toEqual({ ok: true }) + const s = useAppStore.getState() + const moved = s.openFiles[0]! + expect(moved.id).toBe(newPath) + expect(moved.filePath).toBe(newPath) + expect(moved.relativePath).toBe('sub/a.md') + expect(moved.isDirty).toBe(true) + expect(moved.lastKnownDiskSignature).toBe('sig-a') + // id-keyed state migrated to the new id, old key gone. + expect(s.editorDrafts[newPath]).toBe('20 min of work') + expect(s.editorDrafts[oldId]).toBeUndefined() + expect(s.editorCursorLine[newPath]).toBe(42) + expect(s.editorCursorLine[oldId]).toBeUndefined() + expect(s.markdownViewMode[newPath]).toBe('rendered') + expect(s.activeFileId).toBe(newPath) + expect(s.activeFileIdByWorktree['wt-1']).toBe(newPath) + expect(s.tabBarOrderByWorktree['wt-1']).toEqual([newPath]) + }) + + it('migrates unified tab entityId/id and group order', () => { + seedEditTab() + const oldId = useAppStore.getState().openFiles[0]!.id + const tab: Tab = { + id: oldId, + entityId: oldId, + contentType: 'editor', + groupId: 'g1', + title: 'a.md' + } as never + const group: TabGroup = { + id: 'g1', + tabOrder: [oldId], + activeTabId: oldId, + recentTabIds: [oldId] + } as never + useAppStore.setState({ + unifiedTabsByWorktree: { 'wt-1': [tab] }, + groupsByWorktree: { 'wt-1': [group] } + } as never) + const newPath = '/repo/sub/a.md' + + useAppStore.getState().rekeyOpenFilesForPathChange({ + worktreeId: 'wt-1', + rekeys: [rekeyFor(oldId, newPath, 'sub/a.md')] + }) + + const s = useAppStore.getState() + const movedTab = s.unifiedTabsByWorktree['wt-1']![0]! + expect(movedTab.entityId).toBe(newPath) + expect(movedTab.id).toBe(newPath) + const movedGroup = s.groupsByWorktree['wt-1']![0]! + expect(movedGroup.tabOrder).toEqual([newPath]) + expect(movedGroup.activeTabId).toBe(newPath) + expect(movedGroup.recentTabIds).toEqual([newPath]) + }) + + it('rejects a collision with a distinct live session and mutates nothing', () => { + seedEditTab() + const oldId = useAppStore.getState().openFiles[0]!.id + // A second, unrelated open tab already lives at the destination id. + useAppStore.getState().openFile( + { + filePath: '/repo/sub/a.md', + relativePath: 'sub/a.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit' + }, + { suppressActiveRuntimeFallback: true } + ) + const before = useAppStore.getState().openFiles.map((f) => f.id) + + const result = useAppStore.getState().rekeyOpenFilesForPathChange({ + worktreeId: 'wt-1', + rekeys: [rekeyFor(oldId, '/repo/sub/a.md', 'sub/a.md')] + }) + + expect(result).toEqual({ ok: false, reason: 'collision' }) + expect(useAppStore.getState().openFiles.map((f) => f.id)).toEqual(before) + }) + + it('reports stale when a source id is no longer open, mutating nothing', () => { + seedEditTab() + const before = useAppStore.getState().openFiles.map((f) => f.id) + + const result = useAppStore.getState().rekeyOpenFilesForPathChange({ + worktreeId: 'wt-1', + rekeys: [rekeyFor('editor:gone', '/repo/sub/a.md', 'sub/a.md')] + }) + + expect(result).toEqual({ ok: false, reason: 'stale' }) + expect(useAppStore.getState().openFiles.map((f) => f.id)).toEqual(before) + }) + + it('consumes untitled status on an explicit rename', () => { + const state = useAppStore.getState() + state.openFile( + { + filePath: '/repo/untitled.md', + relativePath: 'untitled.md', + worktreeId: 'wt-1', + runtimeEnvironmentId: null, + language: 'markdown', + mode: 'edit', + isUntitled: true + }, + { suppressActiveRuntimeFallback: true } + ) + const oldId = useAppStore.getState().openFiles[0]!.id + + useAppStore.getState().rekeyOpenFilesForPathChange({ + worktreeId: 'wt-1', + rekeys: [ + { + oldFileId: oldId, + newFileId: '/repo/named.md', + oldFilePath: '/repo/untitled.md', + newFilePath: '/repo/named.md', + newRelativePath: 'named.md', + consumeUntitled: true + } + ] + }) + + const moved = useAppStore.getState().openFiles[0]! + expect(moved.isUntitled).toBe(false) + expect(moved.filePath).toBe('/repo/named.md') + }) + + it('migrates a pending editor reveal to the new path', () => { + seedEditTab() + const oldId = useAppStore.getState().openFiles[0]!.id + useAppStore.setState({ + pendingEditorReveal: { filePath: '/repo/a.md', requestId: 1 } + } as never) + + useAppStore.getState().rekeyOpenFilesForPathChange({ + worktreeId: 'wt-1', + rekeys: [rekeyFor(oldId, '/repo/sub/a.md', 'sub/a.md')] + }) + + expect(useAppStore.getState().pendingEditorReveal?.filePath).toBe('/repo/sub/a.md') + }) +}) diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index 53a0a1bdbab..1689cc67d00 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -533,6 +533,14 @@ export type EditorSlice = { clearPendingDiskBaselineVerification: (fileId: string) => void setPendingDiskBaselineVerification: (fileId: string, value: boolean) => void setPendingLiveDiskVerification: (fileId: string, value: boolean) => void + /** Atomically retargets open editor sessions across an Orca-owned move: one + * commit-only store update migrating every path-derived id + all id-keyed + * state, with no close/reopen lifecycle. Returns collision/stale without + * mutating. See design: preserve dirty editor sessions across moves. */ + rekeyOpenFilesForPathChange: (args: { + worktreeId: string + rekeys: readonly OpenFilePathRekey[] + }) => RekeyOpenFilesResult clearUntitled: (fileId: string) => void openDiff: ( worktreeId: string, @@ -1252,7 +1260,10 @@ function migrateHydratedEditorTabsAndGroups( } const tabIdMigrations = new Map() const nextTabs = tabs.map((tab) => { - if (tab.contentType !== 'editor') { + // Why: hydration + live move rekey share this primitive; a move retargets + // every editor-family tab (edit/diff/conflict-review/check-details), not + // only plain 'editor'. + if (!isEditorTabContentType(tab.contentType)) { return tab } const nextId = idMigrations.get(tab.id) ?? tab.id @@ -1317,6 +1328,39 @@ function migrateHydratedEditorTabsAndGroups( } } +/** One tab's migration in an Orca-owned move; precomputed by the move coordinator. */ +export type OpenFilePathRekey = { + oldFileId: string + newFileId: string + oldFilePath: string + newFilePath: string + newRelativePath: string + newLanguage?: string + newMarkdownPreviewSourceFileId?: string + /** Explicit rename of an untitled file consumes its untitled status. */ + consumeUntitled?: boolean +} + +export type RekeyOpenFilesResult = { ok: true } | { ok: false; reason: 'collision' | 'stale' } + +function rekeyFileIdRecord( + record: Record, + migrations: ReadonlyMap +): Record { + let changed = false + const next: Record = {} + for (const [key, value] of Object.entries(record)) { + const mapped = migrations.get(key) + if (mapped !== undefined && mapped !== key) { + next[mapped] = value + changed = true + } else { + next[key] = value + } + } + return changed ? next : record +} + function deleteUntouchedUntitledFile(state: AppState, file: OpenFile): void { const worktree = findWorktreeById(state.worktreesByRepo, file.worktreeId) const repoId = worktree?.repoId ?? getRepoIdFromWorktreeId(file.worktreeId) @@ -2589,6 +2633,99 @@ export const createEditorSlice: StateCreator = (s } }), + rekeyOpenFilesForPathChange: ({ worktreeId, rekeys }) => { + if (rekeys.length === 0) { + return { ok: true } + } + let result: RekeyOpenFilesResult = { ok: true } + set((s) => { + const migrations = new Map() + const rekeyByOldId = new Map() + for (const rekey of rekeys) { + migrations.set(rekey.oldFileId, rekey.newFileId) + rekeyByOldId.set(rekey.oldFileId, rekey) + } + const openById = new Map(s.openFiles.map((f) => [f.id, f])) + + // Preflight (atomic with apply): every source still open, target ids unique, + // and no target id belongs to an UNAFFECTED live session (never merge two). + const seenNewIds = new Set() + for (const rekey of rekeys) { + if (!openById.has(rekey.oldFileId)) { + result = { ok: false, reason: 'stale' } + return s + } + if (seenNewIds.has(rekey.newFileId)) { + result = { ok: false, reason: 'collision' } + return s + } + seenNewIds.add(rekey.newFileId) + const occupier = openById.get(rekey.newFileId) + if (occupier && !migrations.has(occupier.id)) { + result = { ok: false, reason: 'collision' } + return s + } + } + + const nextOpenFiles = s.openFiles.map((f) => { + const rekey = rekeyByOldId.get(f.id) + if (!rekey) { + return f + } + // Spread the whole OpenFile so every field — including ones this action + // doesn't know about — survives; change only the path-derived ones. + return { + ...f, + id: rekey.newFileId, + filePath: rekey.newFilePath, + relativePath: rekey.newRelativePath, + ...(rekey.newLanguage !== undefined ? { language: rekey.newLanguage } : {}), + ...(rekey.newMarkdownPreviewSourceFileId !== undefined + ? { markdownPreviewSourceFileId: rekey.newMarkdownPreviewSourceFileId } + : {}), + ...(rekey.consumeUntitled ? { isUntitled: false, deleteUntouchedOnClose: undefined } : {}) + } + }) + + const activeFileIdByWorktree: Record = {} + for (const [wtId, activeId] of Object.entries(s.activeFileIdByWorktree)) { + activeFileIdByWorktree[wtId] = activeId ? (migrations.get(activeId) ?? activeId) : activeId + } + + const tabBarOrderByWorktree = { ...s.tabBarOrderByWorktree } + const prevBarOrder = tabBarOrderByWorktree[worktreeId] + if (prevBarOrder) { + tabBarOrderByWorktree[worktreeId] = prevBarOrder.map((id) => migrations.get(id) ?? id) + } + + const reveal = s.pendingEditorReveal + const rekeyForReveal = reveal + ? rekeys.find((r) => r.oldFilePath === reveal.filePath) + : undefined + + return { + openFiles: nextOpenFiles, + editorDrafts: rekeyFileIdRecord(s.editorDrafts, migrations), + editorCursorLine: rekeyFileIdRecord(s.editorCursorLine, migrations), + markdownViewMode: rekeyFileIdRecord(s.markdownViewMode, migrations), + editorViewMode: rekeyFileIdRecord(s.editorViewMode, migrations), + markdownFrontmatterVisible: rekeyFileIdRecord(s.markdownFrontmatterVisible, migrations), + markdownTableOfContentsVisible: rekeyFileIdRecord( + s.markdownTableOfContentsVisible, + migrations + ), + activeFileId: s.activeFileId ? (migrations.get(s.activeFileId) ?? s.activeFileId) : null, + activeFileIdByWorktree, + tabBarOrderByWorktree, + ...migrateHydratedEditorTabsAndGroups(s, { [worktreeId]: migrations }), + ...(reveal && rekeyForReveal + ? { pendingEditorReveal: { ...reveal, filePath: rekeyForReveal.newFilePath } } + : {}) + } + }) + return result + }, + clearUntitled: (fileId) => set((s) => ({ openFiles: s.openFiles.map((f) => (f.id === fileId ? { ...f, isUntitled: undefined } : f))