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 <help@stably.ai>
This commit is contained in:
Neil
2026-07-20 00:03:58 -07:00
co-authored by Orca
parent 78515c4a37
commit fe337935ec
2 changed files with 344 additions and 1 deletions
@@ -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<Record<string, unknown>> = {}): 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')
})
})
+138 -1
View File
@@ -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<string, string>()
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<T>(
record: Record<string, T>,
migrations: ReadonlyMap<string, string>
): Record<string, T> {
let changed = false
const next: Record<string, T> = {}
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<AppState, [], [], EditorSlice> = (s
}
}),
rekeyOpenFilesForPathChange: ({ worktreeId, rekeys }) => {
if (rekeys.length === 0) {
return { ok: true }
}
let result: RekeyOpenFilesResult = { ok: true }
set((s) => {
const migrations = new Map<string, string>()
const rekeyByOldId = new Map<string, OpenFilePathRekey>()
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<string>()
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<string, string | null> = {}
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))