mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user