Isolate code editor text and Undo history by execution host (#25174)

* Isolate code editor text and undo history by execution host

* Verify editor owner resolution and Windows model path isolation

* Respect native model line endings in content sync history coverage

* Preserve selection and scroll when editor ownership resolves

* Verify owner synchronization against a reachable editor change callback
This commit is contained in:
Neil
2026-10-04 15:27:30 -07:00
committed by GitHub
parent 8e8efb1947
commit a753affffe
18 changed files with 1070 additions and 63 deletions
@@ -1,12 +1,39 @@
// @vitest-environment happy-dom
import { cleanup, render } from '@testing-library/react'
import { useEffect } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { buildOwnedEditorFileId } from '@/store/slices/editor/file-ids/editor-file-ids'
import type { OpenFile } from '@/store/slices/editor'
import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route'
const editorProps = vi.hoisted(() => ({ current: null as Record<string, unknown> | null }))
const editorProps = vi.hoisted(() => {
const state: {
current: Record<string, unknown> | null
files: OpenFile[]
ownerCatalog: WorktreeOperationRouteState
mounts: unknown[]
unmounts: unknown[]
} = {
current: null,
files: [],
ownerCatalog: {
worktreesByRepo: { local: [{ id: 'local-worktree', repoId: 'local', hostId: 'local' }] }
},
mounts: [],
unmounts: []
}
return state
})
vi.mock('@monaco-editor/react', () => ({
default: (props: Record<string, unknown>) => {
default: function MockEditor(props: Record<string, unknown>) {
editorProps.current = props
useEffect(() => {
editorProps.mounts.push(props.path)
return () => {
editorProps.unmounts.push(props.path)
}
}, [props.path])
return null
},
loader: { config: vi.fn() }
@@ -16,6 +43,8 @@ vi.mock('@/store', () => ({
selector({
settings: { theme: 'dark', terminalFontSize: 13, terminalFontFamily: 'monospace' },
editorFontZoomLevel: 0,
openFiles: editorProps.files,
...editorProps.ownerCatalog,
setPendingEditorReveal: vi.fn(),
setEditorCursorLine: vi.fn(),
addDiffComment: vi.fn(),
@@ -38,6 +67,12 @@ import MonacoEditor from './MonacoEditor'
afterEach(() => {
cleanup()
editorProps.current = null
editorProps.files = []
editorProps.ownerCatalog = {
worktreesByRepo: { local: [{ id: 'local-worktree', repoId: 'local', hostId: 'local' }] }
}
editorProps.mounts = []
editorProps.unmounts = []
})
describe('MonacoEditor content ownership', () => {
@@ -59,4 +94,91 @@ describe('MonacoEditor content ownership', () => {
expect(editorProps.current?.defaultValue).toBe('initial content')
expect(editorProps.current).not.toHaveProperty('value')
})
it('isolates same-path files on different hosts while sharing split panes', () => {
const filePath = '/srv/repo/file.ts'
const ownedId = buildOwnedEditorFileId(filePath, 'ssh-worktree', 'remote-runtime')
editorProps.files = [
{
id: filePath,
filePath,
relativePath: 'file.ts',
worktreeId: 'local-worktree',
mode: 'edit',
language: 'typescript',
isDirty: false
},
{
id: ownedId,
filePath,
relativePath: 'file.ts',
worktreeId: 'ssh-worktree',
runtimeEnvironmentId: 'remote-runtime',
mode: 'edit',
language: 'typescript',
isDirty: false
}
]
const props = {
filePath,
relativePath: 'file.ts',
language: 'typescript',
onContentChange: vi.fn(),
onSave: vi.fn()
}
const local = render(
<MonacoEditor {...props} fileId={filePath} viewStateKey="local-pane" content="local" />
)
const localModel = editorProps.current?.path
local.unmount()
const remote = render(
<MonacoEditor {...props} fileId={ownedId} viewStateKey="remote-pane" content="remote" />
)
const remoteModel = editorProps.current?.path
expect(remoteModel).not.toBe(localModel)
remote.unmount()
render(<MonacoEditor {...props} fileId={ownedId} viewStateKey="split-pane" content="remote" />)
expect(editorProps.current?.path).toBe(remoteModel)
})
it('keeps the current draft when restored ownership resolves and remounts the widget', () => {
const file: OpenFile = {
id: 'restored-file',
filePath: '/repo/restored.ts',
relativePath: 'restored.ts',
worktreeId: 'restored-worktree',
mode: 'edit',
language: 'typescript',
isDirty: true
}
editorProps.files = [file]
editorProps.ownerCatalog = {}
const onContentChange = vi.fn()
const props = {
fileId: file.id,
filePath: file.filePath,
viewStateKey: 'pane:restored-file',
relativePath: file.relativePath,
language: file.language,
onContentChange,
onSave: vi.fn()
}
const rendered = render(<MonacoEditor {...props} content="restored draft" />)
const unresolvedModel = editorProps.current?.path
rendered.rerender(<MonacoEditor {...props} content="latest dirty draft" />)
expect(editorProps.mounts).toEqual([unresolvedModel])
editorProps.ownerCatalog = {
worktreesByRepo: {
repo: [{ id: file.worktreeId, repoId: 'repo', hostId: 'local' }]
}
}
rendered.rerender(<MonacoEditor {...props} content="latest dirty draft" />)
const resolvedModel = editorProps.current?.path
expect(resolvedModel).not.toBe(unresolvedModel)
expect(editorProps.current?.defaultValue).toBe('latest dirty draft')
expect(editorProps.mounts).toEqual([unresolvedModel, resolvedModel])
expect(editorProps.unmounts).toEqual([unresolvedModel])
})
})
@@ -0,0 +1,161 @@
// @vitest-environment happy-dom
import { act, cleanup, render, waitFor } from '@testing-library/react'
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { afterEach, expect, it, vi } from 'vitest'
import type { AppState } from '@/store/types'
import { createTestStore, makeWorktree } from '@/store/slices/store-test-helpers'
import type { OpenFile } from '@/store/slices/editor'
import { editorSelectionCache, scrollTopCache } from '@/lib/scroll-cache'
import { getDefaultSettings } from '../../../../shared/constants'
const testState = vi.hoisted(() => {
const state: { store: ReturnType<typeof createTestStore> | null } = { store: null }
return state
})
vi.mock('@/store', () => {
const getStore = () => {
if (!testState.store) {
throw new Error('Missing test store')
}
return testState.store
}
return {
useAppStore: Object.assign((selector: (state: AppState) => unknown) => getStore()(selector), {
getState: () => getStore().getState()
})
}
})
vi.mock('@/lib/monaco-setup', async () => {
const { loader } = await import('@monaco-editor/react')
loader.config({ monaco })
return {}
})
vi.mock('./useContextualCopySetup', () => ({
useContextualCopySetup: () => ({ setupCopy: vi.fn(), toastNode: null })
}))
vi.mock('../diff-comments/useDiffCommentDecorator', () => ({
useDiffCommentDecorator: vi.fn()
}))
vi.mock('sonner', () => ({
toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() }
}))
import MonacoEditor from './MonacoEditor'
const originalGetContext = HTMLCanvasElement.prototype.getContext
let creationListener: monaco.IDisposable | null = null
afterEach(() => {
cleanup()
creationListener?.dispose()
creationListener = null
for (const model of monaco.editor.getModels()) {
model.dispose()
}
editorSelectionCache.clear()
scrollTopCache.clear()
Reflect.set(HTMLCanvasElement.prototype, 'getContext', originalGetContext)
testState.store = null
})
it('preserves a draft, selected range and scroll when the same tab gains a known owner', async () => {
// Only text metrics are needed; this test does not inspect canvas painting.
Reflect.set(HTMLCanvasElement.prototype, 'getContext', () => ({
webkitBackingStorePixelRatio: 1,
measureText: (value: string) => ({ width: value.length * 8 }),
fillRect: () => {},
clearRect: () => {},
fillText: () => {},
setTransform: () => {},
save: () => {},
restore: () => {},
beginPath: () => {},
moveTo: () => {},
lineTo: () => {},
stroke: () => {},
createImageData: (width: number, height: number) => ({
data: new Uint8ClampedArray(width * height * 4)
}),
getImageData: (width: number, height: number) => ({
data: new Uint8ClampedArray(width * height * 4)
}),
putImageData: () => {}
}))
const store = createTestStore()
testState.store = store
const file: OpenFile = {
id: 'restored-file',
worktreeId: 'pending-workspace',
filePath: '/repo/restored.txt',
relativePath: 'restored.txt',
mode: 'edit',
language: 'plaintext',
isDirty: true
}
store.setState({
openFiles: [file],
repos: [],
worktreesByRepo: {},
detectedWorktreesByRepo: {},
settings: { ...getDefaultSettings('/fixture'), theme: 'dark', editorWordWrap: false }
})
const widgets: monaco.editor.ICodeEditor[] = []
creationListener = monaco.editor.onDidCreateEditor((instance) => widgets.push(instance))
const props = {
fileId: file.id,
filePath: file.filePath,
viewStateKey: 'pane:restored-file',
relativePath: file.relativePath,
language: file.language,
content: Array.from({ length: 300 }, (_, index) => `line ${index + 1}`).join('\n'),
onContentChange: vi.fn(),
onSave: vi.fn()
}
const rendered = render(<MonacoEditor {...props} />)
await waitFor(() => expect(widgets).toHaveLength(1))
const before = widgets[0]
if (!before) {
throw new Error('Missing initial editor')
}
const originalModel = before.getModel()
if (!originalModel) {
throw new Error('Missing original model')
}
const draft = `${props.content}\nunsaved draft`
rendered.rerender(<MonacoEditor {...props} content={draft} />)
expect(originalModel.getValue()).toBe(draft)
expect(originalModel.canUndo()).toBe(true)
before.layout({ width: 600, height: 300 })
const selection = new monaco.Selection(80, 2, 82, 5)
before.setSelection(selection)
before.setScrollTop(1500)
expect(before.getScrollTop()).toBe(1500)
const openFiles = store.getState().openFiles
act(() => {
store.setState({
worktreesByRepo: {
repo: [
makeWorktree({ id: file.worktreeId, repoId: 'repo', path: '/repo', hostId: 'local' })
]
}
})
})
expect(store.getState().openFiles).toBe(openFiles)
await waitFor(() => expect(widgets).toHaveLength(2))
const after = widgets[1]
if (!after) {
throw new Error('Missing resolved editor')
}
expect(after.getModel()).not.toBe(originalModel)
expect(after.getModel()?.getValue()).toBe(draft)
expect(after.getModel()?.canUndo()).toBe(false)
await waitFor(() => expect(after.getSelection()?.toString()).toBe(selection.toString()))
expect(after.getScrollTop()).toBe(1500)
expect(props.onContentChange).not.toHaveBeenCalled()
act(() => {
after.executeEdits('user-edit', [{ range: new monaco.Range(301, 14, 301, 14), text: '!' }])
})
expect(props.onContentChange).toHaveBeenCalledExactlyOnceWith(`${draft}!`)
})
@@ -13,6 +13,7 @@ import { isLinuxUserAgent } from '../terminal-pane/pane-helpers'
import { MAX_TOKENIZATION_LINE_LENGTH } from '@/lib/monaco-languages/monarch-embed-entry-budget'
import { buildFileEditorWordWrapOptions } from './file-editor-word-wrap-options'
import { toEditorModelUri } from './editor-model-uri'
import { getEditorModelOwnerKey } from './editor-model-owner'
import { getMonacoAutoHeightForContent, isMonacoAutoHeightCapped } from './monaco-auto-height'
import { monacoFindOptions } from './monaco-find-options'
import { useMonacoRevealScheduler } from './use-monaco-reveal-scheduler'
@@ -97,7 +98,16 @@ export default function MonacoEditor({
)
const editorFontFamily = resolveEditorFontFamily(settings)
const editorWordWrap = settings?.editorWordWrap
const modelUri = useMemo(() => toEditorModelUri(filePath), [filePath])
const modelOwnerKey = useAppStore((state) => {
const file = state.openFiles?.find((entry) => entry.id === fileId)
return file
? getEditorModelOwnerKey(file, state)
: JSON.stringify([null, `unresolved:${fileId}`])
})
const modelUri = useMemo(
() => toEditorModelUri(filePath, modelOwnerKey),
[filePath, modelOwnerKey]
)
const estimatedAutoHeight = useMemo(() => {
if (!autoHeight) {
return null
@@ -127,7 +137,7 @@ export default function MonacoEditor({
content,
contentRef,
contentSyncModeRef,
filePath,
modelKey: modelUri,
onContentChange
})
const annotations = useMonacoMarkdownAnnotations({
@@ -154,7 +164,7 @@ export default function MonacoEditor({
unregisterFileSearchSelectionRef.current?.()
unregisterFileSearchSelectionRef.current = null
}
}, [cancelScheduledReveal, clearTransientRevealHighlight, viewStateKey])
}, [cancelScheduledReveal, clearTransientRevealHighlight, modelUri, viewStateKey])
// Update editor options when settings change
useEffect(() => {
@@ -183,6 +193,7 @@ export default function MonacoEditor({
const handleMount = useMonacoEditorMount({
fileId,
filePath,
modelOwnerKey,
viewStateKey,
viewStateId,
worktreeId,
@@ -232,6 +243,7 @@ export default function MonacoEditor({
onSubmitMarkdownComment={annotations.handleSubmitMarkdownComment}
/>
<Editor
key={modelUri}
height={renderedEditorHeight === null ? '100%' : `${renderedEditorHeight}px`}
language={language}
// Why: defaultValue, not controlled value — Orca owns post-mount content sync; a controlled path would double setValue.
@@ -5,8 +5,13 @@ import { editorModelRegistry } from '@/lib/editor-model-registry'
import { disposeClosedEditorModels, type ClosedEditorTab } from './closed-editor-tab-disposal'
import type { MonacoModelRegistry, DisposableMonacoModel } from './diff-monaco-model-disposal'
import { toEditorModelUri } from './editor-model-uri'
import { getEditorModelOwnerKey } from './editor-model-owner'
import type { WorktreeOperationRouteState } from '@/lib/worktree-operation-route'
type EditorStore = Pick<StoreApi<{ openFiles: OpenFile[] }>, 'getState' | 'subscribe'>
type EditorStore = Pick<
StoreApi<WorktreeOperationRouteState & { openFiles: OpenFile[] }>,
'getState' | 'subscribe'
>
type RetainedModel = {
files: Map<string, ClosedEditorTab>
detach: { dispose(): void }
@@ -14,7 +19,7 @@ type RetainedModel = {
}
function ownerKey(file: ClosedEditorTab): string {
return JSON.stringify([file.id, file.mode, file.filePath])
return JSON.stringify([file.id, file.mode, file.filePath, file.modelOwnerKey])
}
export function attachClosedEditorTabCleanup(
@@ -22,7 +27,8 @@ export function attachClosedEditorTabCleanup(
bridge = editorModelRegistry
): () => void {
let registry = bridge.get()
let previousFiles = store.getState().openFiles
let previousState = store.getState()
let previousFiles = previousState.openFiles
const pendingFiles = new Map<string, ClosedEditorTab>()
const candidateModels = new Set<editor.ITextModel>()
const retainedModels = new Map<editor.ITextModel, RetainedModel>()
@@ -96,24 +102,26 @@ export function attachClosedEditorTabCleanup(
return
}
const flushGeneration = generation
let checkedOpenFiles: OpenFile[] | null = null
let checkedState: ReturnType<EditorStore['getState']> | null = null
let openIds = new Set<string>()
let openEditUris = new Set<string>()
const stillOwned = (file: ClosedEditorTab): boolean => {
const openFiles = store.getState().openFiles
if (openFiles !== checkedOpenFiles) {
checkedOpenFiles = openFiles
const currentState = store.getState()
const openFiles = currentState.openFiles
if (currentState !== checkedState) {
checkedState = currentState
openIds = new Set(openFiles.map((openFile) => openFile.id))
openEditUris = new Set(
openFiles
.filter((openFile) => openFile.mode === 'edit')
.map((openFile) => toEditorModelUri(openFile.filePath))
.map((openFile) =>
toEditorModelUri(openFile.filePath, getEditorModelOwnerKey(openFile, currentState))
)
)
}
return (
openIds.has(file.id) ||
(file.mode === 'edit' && openEditUris.has(toEditorModelUri(file.filePath)))
)
return file.mode === 'edit'
? openEditUris.has(toEditorModelUri(file.filePath, file.modelOwnerKey))
: openIds.has(file.id)
}
const disposeCaptured = (
files: ClosedEditorTab[],
@@ -181,23 +189,52 @@ export function attachClosedEditorTabCleanup(
}
const unsubscribe = store.subscribe(() => {
const openFiles = store.getState().openFiles
if (openFiles === previousFiles) {
const currentState = store.getState()
const openFiles = currentState.openFiles
const previous = previousFiles
const closedState = previousState
previousFiles = openFiles
previousState = currentState
if (
openFiles === previous &&
currentState.repos === closedState.repos &&
currentState.worktreesByRepo === closedState.worktreesByRepo &&
currentState.detectedWorktreesByRepo === closedState.detectedWorktreesByRepo &&
currentState.folderWorkspaces === closedState.folderWorkspaces &&
currentState.projectGroups === closedState.projectGroups &&
currentState.restoredRuntimeHostIdByWorkspaceSessionKey ===
closedState.restoredRuntimeHostIdByWorkspaceSessionKey &&
currentState.runtimeEnvironmentCatalogHydrated ===
closedState.runtimeEnvironmentCatalogHydrated &&
currentState.removedRuntimeEnvironmentIds === closedState.removedRuntimeEnvironmentIds &&
currentState.runtimeEnvironments === closedState.runtimeEnvironments &&
currentState.settings === closedState.settings
) {
return
}
const previous = previousFiles
previousFiles = openFiles
const liveIds = new Set(openFiles.map((file) => file.id))
const liveFiles = new Map(openFiles.map((file) => [file.id, file]))
let removed = false
let removedDiff = false
for (const file of previous) {
if (!liveIds.has(file.id)) {
const descriptor = { id: file.id, mode: file.mode, filePath: file.filePath }
const priorOwner = getEditorModelOwnerKey(file, closedState)
const liveFile = liveFiles.get(file.id)
if (
!liveFile ||
liveFile.mode !== file.mode ||
liveFile.filePath !== file.filePath ||
(file.mode === 'edit' && getEditorModelOwnerKey(liveFile, currentState) !== priorOwner)
) {
const descriptor = {
id: file.id,
mode: file.mode,
filePath: file.filePath,
modelOwnerKey: priorOwner
}
pendingFiles.set(ownerKey(descriptor), descriptor)
removed = true
if (file.mode === 'edit' && registry) {
const model = registry.editor.getModel(
registry.Uri.parse(toEditorModelUri(file.filePath))
registry.Uri.parse(toEditorModelUri(file.filePath, descriptor.modelOwnerKey))
)
if (model) {
candidateModels.add(model)
@@ -17,7 +17,9 @@ import {
} from './closed-editor-tab-cache-sweep'
import { toEditorModelUri } from './editor-model-uri'
export type ClosedEditorTab = Pick<OpenFile, 'id' | 'mode' | 'filePath'>
export type ClosedEditorTab = Pick<OpenFile, 'id' | 'mode' | 'filePath'> & {
modelOwnerKey?: string
}
// One registry sweep avoids quadratic close-all work.
export function disposeClosedEditorModels(
@@ -37,7 +39,7 @@ export function disposeClosedEditorModels(
}
if (closedFile.mode === 'edit') {
const model = monacoRegistry.editor.getModel(
monacoRegistry.Uri.parse(toEditorModelUri(closedFile.filePath))
monacoRegistry.Uri.parse(toEditorModelUri(closedFile.filePath, closedFile.modelOwnerKey))
)
if (model?.isAttachedToEditor()) {
onAttachedModel?.(model, closedFile)
@@ -42,7 +42,10 @@ function open(path: string, text = 'original') {
'plaintext',
monaco.Uri.parse(toEditorModelUri(path))
)
const store = createStore(() => ({ openFiles: [file] }))
const store = createStore(() => ({
openFiles: [file],
worktreesByRepo: { fixture: [{ id: 'fixture', repoId: 'fixture', hostId: 'local' as const }] }
}))
const bridge = createEditorModelRegistry()
disposeOwners.push(bridge.register(monaco), attachClosedEditorTabCleanup(store, bridge))
return {
@@ -0,0 +1,324 @@
// @vitest-environment happy-dom
import * as monaco from 'monaco-editor/esm/vs/editor/editor.api.js'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import type { WorktreeOperationRoute } from '@/lib/worktree-operation-route'
import {
attachModelLifetimeView,
createModelLifetimeFixture,
modelLifetimeFile,
modelLifetimeTextModel,
resetModelLifetimeFixtures
} from './editor-model-lifetime-fixture'
import { getEditorModelOwnerKey } from './editor-model-owner'
import { toEditorModelUri } from './editor-model-uri'
import {
beginProgrammaticContentSync,
endProgrammaticContentSync,
resetProgrammaticContentSyncForTests,
shouldIgnoreMonacoContentChange
} from './monaco-programmatic-sync'
vi.mock('sonner', () => ({ toast: { info: vi.fn(), error: vi.fn(), success: vi.fn() } }))
const FILE_PATH = '/fixture/workspace/same-path.txt'
const HOSTS: readonly WorktreeOperationRoute[] = [
{ executionHostId: 'local', runtimeEnvironmentId: null },
{ executionHostId: 'ssh:target', runtimeEnvironmentId: null },
{ executionHostId: 'runtime:hub-a', runtimeEnvironmentId: 'hub-a' },
{ executionHostId: 'ssh:target', runtimeEnvironmentId: 'hub-a' },
{ executionHostId: 'ssh:target', runtimeEnvironmentId: 'hub-b' }
]
function file(id: string, route: WorktreeOperationRoute, filePath = FILE_PATH): OpenFile {
return {
...modelLifetimeFile(id),
filePath,
relativePath: 'same-path.txt',
operationProvenance: {
ownershipProjection: 'explicit',
generation: {
route,
runtimeConnectionGeneration: null,
runtimePairingRevision: undefined,
runtimeSshGeneration: null,
nestedSshGeneration: null,
directSshGeneration: null
}
}
}
}
function createHostModels(filePath = FILE_PATH) {
const fixture = createModelLifetimeFixture()
const owners = HOSTS.map((route, index) => {
const ownedFile = file(`host-${index}`, route, filePath)
const modelKey = toEditorModelUri(
ownedFile.filePath,
getEditorModelOwnerKey(ownedFile, fixture.store.getState())
)
const initial = `text owned by ${route.executionHostId} through ${route.runtimeEnvironmentId}`
const model = modelLifetimeTextModel(modelKey, initial)
return { file: ownedFile, model, modelKey, initial }
})
fixture.store.setState({ openFiles: owners.map((owner) => owner.file) })
return { ...fixture, owners }
}
function edit(model: monaco.editor.ITextModel, text: string) {
model.pushStackElement()
model.pushEditOperations([], [{ range: model.getFullModelRange(), text }], () => null)
model.pushStackElement()
}
afterEach(() => {
resetProgrammaticContentSyncForTests()
resetModelLifetimeFixtures()
})
describe('same-path models on different execution hosts', () => {
it.each([FILE_PATH, 'C:\\fixture\\workspace\\same-path.txt', '\\\\server\\share\\same-path.txt'])(
'keeps host text and undo histories independent for %s',
async (filePath) => {
const { owners } = createHostModels(filePath)
expect(new Set(owners.map((owner) => owner.modelKey)).size).toBe(HOSTS.length)
expect(new Set(owners.map((owner) => owner.model.uri.fsPath))).toEqual(
new Set([monaco.Uri.file(filePath).fsPath])
)
for (const [index, owner] of owners.entries()) {
expect(monaco.editor.getModel(monaco.Uri.parse(owner.modelKey))).toBe(owner.model)
edit(owner.model, `edited host ${index}`)
expect(owner.model.canUndo()).toBe(true)
expect(owners.map((candidate) => candidate.model.getValue())).toEqual(
owners.map((candidate, candidateIndex) =>
candidateIndex <= index ? `edited host ${candidateIndex}` : candidate.initial
)
)
}
for (const [index, owner] of owners.entries()) {
await owner.model.undo()
expect(owner.model.canRedo()).toBe(true)
expect(owners.map((candidate) => candidate.model.getValue())).toEqual(
owners.map((candidate, candidateIndex) =>
candidateIndex <= index ? candidate.initial : `edited host ${candidateIndex}`
)
)
}
await owners[1]!.model.redo()
expect(owners.map((owner) => owner.model.getValue())).toEqual(
owners.map((owner, index) => (index === 1 ? 'edited host 1' : owner.initial))
)
}
)
it('suppresses watcher echoes in shared local panes without hiding an SSH user edit', () => {
const { owners } = createHostModels()
const local = owners[0]!
const ssh = owners[1]!
const accepted: string[] = []
const listeners = [local, local, ssh].map((owner, index) =>
owner.model.onDidChangeContent(() => {
if (
!shouldIgnoreMonacoContentChange({
modelKey: owner.modelKey,
isApplyingProgrammaticContent: false
})
) {
accepted.push(`pane-${index}:${owner.model.getValue()}`)
}
})
)
beginProgrammaticContentSync(local.modelKey)
try {
local.model.setValue('local watcher update')
expect(accepted).toEqual([])
edit(ssh.model, 'SSH user edit')
expect(accepted).toEqual(['pane-2:SSH user edit'])
expect(local.model.getValue()).toBe('local watcher update')
expect(owners.slice(2).map((owner) => owner.model.getValue())).toEqual(
owners.slice(2).map((owner) => owner.initial)
)
} finally {
endProgrammaticContentSync(local.modelKey)
listeners.forEach((listener) => listener.dispose())
}
})
it('closes only the selected host model while siblings retain their text and undo', async () => {
const { owners, store, attach } = createHostModels()
owners.forEach((owner, index) => edit(owner.model, `edited host ${index}`))
attach()
const enumeration = vi.spyOn(monaco.editor, 'getModels')
for (const [index, owner] of owners.entries()) {
store.getState().closeFile(owner.file.id)
await Promise.resolve()
expect(owner.model.isDisposed()).toBe(true)
for (const [siblingIndex, sibling] of owners.slice(index + 1).entries()) {
expect(sibling.model.isDisposed()).toBe(false)
expect(sibling.model.getValue()).toBe(`edited host ${index + siblingIndex + 1}`)
expect(sibling.model.canUndo()).toBe(true)
}
}
expect(enumeration).not.toHaveBeenCalled()
expect(store.getState().openFiles).toHaveLength(0)
})
it.each([FILE_PATH, 'C:\\fixture\\workspace\\same-path.txt', '\\\\server\\share\\same-path.txt'])(
'restores independent closed-tab undo for remote owners of %s',
async (filePath) => {
const { owners, store, attach } = createHostModels(filePath)
const remoteOwners = [owners[1], owners[4]]
attach()
const closed = remoteOwners.map((owner, index) => {
if (!owner) {
throw new Error('Missing remote owner')
}
edit(owner.model, `saved remote draft ${index}`)
return { ...owner, saved: owner.model.getValue() }
})
for (const owner of closed) {
store.getState().closeFile(owner.file.id)
}
await Promise.resolve()
expect(closed.every((owner) => owner.model.isDisposed())).toBe(true)
store.setState({
openFiles: [...store.getState().openFiles, ...closed.map((owner) => owner.file)]
})
const reopened = closed.map((owner) => modelLifetimeTextModel(owner.modelKey, owner.saved))
expect(reopened.every((model) => model.canUndo())).toBe(true)
for (const [index, model] of reopened.entries()) {
await model.undo()
expect(reopened.map((candidate) => candidate.getValue())).toEqual(
closed.map((owner, ownerIndex) => (ownerIndex <= index ? owner.initial : owner.saved))
)
}
for (const owner of owners.filter(
(candidate) => !closed.some((entry) => entry.file.id === candidate.file.id)
)) {
expect(owner.model.isDisposed()).toBe(false)
expect(owner.model.getValue()).toBe(owner.initial)
}
}
)
it('releases a closed SSH model after its view detaches without touching a live local model', async () => {
const { owners, store, attach } = createHostModels()
const local = owners[0]!
const ssh = owners[1]!
const detach = attachModelLifetimeView(ssh.model)
attach()
store.getState().closeFile(ssh.file.id)
await Promise.resolve()
expect(ssh.model.isDisposed()).toBe(false)
detach()
expect(ssh.model.isDisposed()).toBe(false)
await Promise.resolve()
expect(ssh.model.isDisposed()).toBe(true)
expect(local.model.isDisposed()).toBe(false)
edit(local.model, 'local still editable')
await local.model.undo()
expect(local.model.getValue()).toBe(local.initial)
})
it('preserves local same-path sharing until the final local tab closes', async () => {
const { owners, store, attach } = createHostModels()
const local = owners[0]!
const sharedLocal = {
...local.file,
id: 'local-other-workspace',
worktreeId: 'another-workspace'
}
expect(toEditorModelUri(FILE_PATH, getEditorModelOwnerKey(sharedLocal, store.getState()))).toBe(
local.modelKey
)
store.setState({ openFiles: [...store.getState().openFiles, sharedLocal] })
attach()
edit(local.model, 'shared local edit')
store.getState().closeFile(local.file.id)
await Promise.resolve()
expect(local.model.isDisposed()).toBe(false)
await local.model.undo()
expect(local.model.getValue()).toBe(local.initial)
store.getState().closeFile(sharedLocal.id)
await Promise.resolve()
expect(local.model.isDisposed()).toBe(true)
expect(owners.slice(1).every((owner) => !owner.model.isDisposed())).toBe(true)
})
it('releases an attached old owner when the same tab moves to a live runtime model', async () => {
const { owners, store, attach } = createHostModels()
const local = owners[0]!
const runtime = owners[2]!
const detach = attachModelLifetimeView(local.model)
edit(runtime.model, 'runtime independent edit')
attach()
store.setState({
openFiles: store
.getState()
.openFiles.map((opened) =>
opened.id === local.file.id
? { ...opened, operationProvenance: runtime.file.operationProvenance }
: opened
)
})
await Promise.resolve()
expect(local.model.isDisposed()).toBe(false)
detach()
await Promise.resolve()
expect(local.model.isDisposed()).toBe(true)
expect(runtime.model.isDisposed()).toBe(false)
expect(runtime.model.getValue()).toBe('runtime independent edit')
store.getState().closeFile(local.file.id)
await Promise.resolve()
expect(runtime.model.isDisposed()).toBe(false)
await runtime.model.undo()
expect(runtime.model.getValue()).toBe(runtime.initial)
})
it('releases the prior owner when an uncaptured tab changes host without changing the tab array', async () => {
const { owners, store, attach } = createHostModels()
const local = owners[0]!
const ssh = owners[1]!
const legacyFile = { ...local.file, operationProvenance: undefined }
store.setState({ openFiles: [legacyFile, ...owners.slice(1).map((owner) => owner.file)] })
expect(getEditorModelOwnerKey(legacyFile, store.getState())).toBe('')
const openFiles = store.getState().openFiles
const detach = attachModelLifetimeView(local.model)
attach()
store.setState({
worktreesByRepo: Object.fromEntries(
Object.entries(store.getState().worktreesByRepo).map(([repoId, worktrees]) => [
repoId,
worktrees.map((worktree) => ({ ...worktree, hostId: 'ssh:target' as const }))
])
)
})
expect(store.getState().openFiles).toBe(openFiles)
expect(toEditorModelUri(FILE_PATH, getEditorModelOwnerKey(legacyFile, store.getState()))).toBe(
ssh.modelKey
)
await Promise.resolve()
expect(local.model.isDisposed()).toBe(false)
detach()
await Promise.resolve()
expect(local.model.isDisposed()).toBe(true)
expect(ssh.model.isDisposed()).toBe(false)
expect(ssh.model.getValue()).toBe(ssh.initial)
})
it('uses prior host ownership when a catalog and its uncaptured tab disappear together', async () => {
const { owners, store, attach } = createHostModels()
const local = owners[0]!
const legacyFile = { ...local.file, operationProvenance: undefined }
store.setState({ openFiles: [legacyFile, ...owners.slice(1).map((owner) => owner.file)] })
expect(getEditorModelOwnerKey(legacyFile, store.getState())).toBe('')
attach()
store.setState({ openFiles: owners.slice(1).map((owner) => owner.file), worktreesByRepo: {} })
await Promise.resolve()
expect(local.model.isDisposed()).toBe(true)
for (const owner of owners.slice(1)) {
expect(owner.model.isDisposed()).toBe(false)
expect(owner.model.getValue()).toBe(owner.initial)
}
})
})
@@ -0,0 +1,151 @@
import { describe, expect, it } from 'vitest'
import type { OpenFile } from '@/store/slices/editor'
import type {
WorktreeOperationRoute,
WorktreeOperationRouteState
} from '@/lib/worktree-operation-route'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import { getEditorModelOwnerKey } from './editor-model-owner'
function file(overrides: Partial<OpenFile> = {}): OpenFile {
return {
id: 'file',
filePath: '/srv/repo/file.ts',
relativePath: 'file.ts',
worktreeId: 'repo::/srv/repo',
language: 'typescript',
mode: 'edit',
isDirty: false,
...overrides
}
}
function captured(route: WorktreeOperationRoute): OpenFile['operationProvenance'] {
return {
ownershipProjection: 'explicit',
generation: {
route,
runtimeConnectionGeneration: null,
runtimePairingRevision: undefined,
runtimeSshGeneration: null,
nestedSshGeneration: null,
directSshGeneration: null
}
}
}
const localState: WorktreeOperationRouteState = {
worktreesByRepo: { repo: [{ id: 'repo::/srv/repo', repoId: 'repo', hostId: 'local' }] }
}
describe('editor model ownership', () => {
it('shares local physical files across workspace and tab identities', () => {
expect(getEditorModelOwnerKey(file(), localState)).toBe('')
expect(
getEditorModelOwnerKey(
file({
id: 'other',
worktreeId: 'other',
operationProvenance: captured({ executionHostId: 'local', runtimeEnvironmentId: null })
}),
{}
)
).toBe('')
})
it('keeps captured ownership when the focused host changes', () => {
const opened = file({
operationProvenance: captured({
executionHostId: 'ssh:target',
runtimeEnvironmentId: 'hub-a'
})
})
const key = getEditorModelOwnerKey(opened, {})
expect(
getEditorModelOwnerKey(opened, {
...localState,
activeWorktreeId: opened.worktreeId,
activeWorkspaceExecutionHostId: 'runtime:hub-b'
})
).toBe(key)
expect(key).toBe('["hub-a","ssh:target"]')
})
it('keeps distinct paired transports to the same target separate', () => {
const keys = ['hub-a', 'hub-b'].map((runtimeEnvironmentId) =>
getEditorModelOwnerKey(
file({
operationProvenance: captured({ executionHostId: 'ssh:target', runtimeEnvironmentId })
}),
{}
)
)
expect(keys[0]).not.toBe(keys[1])
expect(keys).not.toContain(
getEditorModelOwnerKey(
file({ externalSshTargetId: 'target', runtimeEnvironmentId: null }),
{}
)
)
})
it('uses the tab runtime hint when no owner catalog is present', () => {
expect(getEditorModelOwnerKey(file({ runtimeEnvironmentId: 'hub-a' }), {})).toBe(
'["hub-a","runtime:hub-a"]'
)
expect(getEditorModelOwnerKey(file({ runtimeEnvironmentId: 'hub-b' }), {})).toBe(
'["hub-b","runtime:hub-b"]'
)
})
it('honors an explicit local transport hint instead of the focused runtime', () => {
const opened = file({ runtimeEnvironmentId: null, externalSshTargetId: 'target' })
const state: WorktreeOperationRouteState = {
worktreesByRepo: {
repo: [
{
id: opened.worktreeId,
repoId: 'repo',
hostId: 'ssh:target',
runtimeOwnerEnvironmentId: 'hub-a'
}
]
}
}
expect(getEditorModelOwnerKey(opened, state)).toBe('[null,"ssh:target"]')
})
it('does not adopt a new active host for a legacy local tab', () => {
const opened = file()
expect(
getEditorModelOwnerKey(opened, {
...localState,
activeWorktreeId: opened.worktreeId,
activeWorkspaceExecutionHostId: 'ssh:new-target'
})
).toBe('')
})
it('keeps unresolved owners separate from local files', () => {
expect(getEditorModelOwnerKey(file(), {})).not.toBe('')
expect(getEditorModelOwnerKey(file(), {})).not.toBe(
getEditorModelOwnerKey(file({ worktreeId: 'another-owner' }), {})
)
})
it('supports local and remote folder workspaces', () => {
const opened = file({ worktreeId: folderWorkspaceKey('folder') })
expect(
getEditorModelOwnerKey(opened, {
folderWorkspaces: [{ id: 'folder', projectGroupId: 'group', executionHostId: 'local' }]
})
).toBe('')
expect(
getEditorModelOwnerKey(opened, {
folderWorkspaces: [
{ id: 'folder', projectGroupId: 'group', executionHostId: 'runtime:hub-a' }
]
})
).toBe('["hub-a","runtime:hub-a"]')
})
})
@@ -0,0 +1,39 @@
import type { OpenFile } from '@/store/slices/editor'
import {
resolveWorktreeOperationRoute,
type WorktreeOperationRouteState
} from '@/lib/worktree-operation-route'
import { toRuntimeExecutionHostId, toSshExecutionHostId } from '../../../../shared/execution-host'
export function getEditorModelOwnerKey(file: OpenFile, state: WorktreeOperationRouteState): string {
const captured = file.operationProvenance?.generation.route
const route =
captured ??
resolveWorktreeOperationRoute(
state.activeWorktreeId === file.worktreeId ? { ...state, activeWorktreeId: null } : state,
file.worktreeId
)
const environmentId = captured
? captured.runtimeEnvironmentId
: file.runtimeEnvironmentId !== undefined
? file.runtimeEnvironmentId?.trim() || null
: (route?.runtimeEnvironmentId ?? null)
const hostId = captured
? captured.executionHostId
: file.externalSshTargetId
? toSshExecutionHostId(file.externalSshTargetId)
: route &&
(file.runtimeEnvironmentId === undefined ||
(file.runtimeEnvironmentId || null) === route.runtimeEnvironmentId)
? route.executionHostId
: environmentId
? toRuntimeExecutionHostId(environmentId)
: null
if (hostId === 'local' && !environmentId) {
return ''
}
// Unresolved owners must not borrow another host's retained text.
return JSON.stringify(
hostId ? [environmentId, hostId] : [environmentId, 'unresolved', file.worktreeId, file.id]
)
}
@@ -37,4 +37,16 @@ describe('toEditorModelUri', () => {
it('keeps distinct POSIX paths distinct', () => {
expect(toEditorModelUri('/repo/a b.ts')).not.toBe(toEditorModelUri('/repo/a%20b.ts'))
})
it.each(paths)('isolates owners while preserving the filesystem path for %s', (path) => {
const local = URI.parse(toEditorModelUri(path))
const remoteUri = toEditorModelUri(path, '["hub-a","ssh:target#1"]')
const remote = URI.parse(remoteUri)
expect(remote.scheme).toBe('file')
expect(remote.fsPath).toBe(local.fsPath)
expect(remote.toString()).not.toBe(local.toString())
expect(remote.toString()).toBe(remoteUri)
expect(toEditorModelUri(remoteUri)).toBe(remoteUri)
expect(toEditorModelUri(path, '["hub-b","ssh:target#1"]')).not.toBe(remoteUri)
})
})
@@ -12,6 +12,10 @@ const FILE_SCHEME = /^file:/i
* re-parses to itself, so a consumer that can only take a string (the `path` prop of
* `@monaco-editor/react`, which calls `Uri.parse` internally) lands on the same key.
*/
export function toEditorModelUri(filePath: string): string {
return URI.file(FILE_SCHEME.test(filePath) ? URI.parse(filePath).fsPath : filePath).toString()
export function toEditorModelUri(filePath: string, ownerKey = ''): string {
const parsed = FILE_SCHEME.test(filePath) ? URI.parse(filePath) : URI.file(filePath)
// Equal paths on different owners must not share text or undo history.
return URI.file(parsed.fsPath)
.with({ fragment: ownerKey || parsed.fragment })
.toString()
}
@@ -35,7 +35,7 @@ describe('Monaco external-content undo history', () => {
syncContentUpdate(editorInstance, 'first line\nappended', 'read-only-live-tail')
expect(model.getValue()).toBe('first line\nappended')
expect(model.getValue()).toBe(['first line', 'appended'].join(model.getEOL()))
expect(model.canUndo()).toBe(false)
})
@@ -14,6 +14,7 @@ export type MonacoEditorPropsRef = MutableRefObject<{
}>
export type MonacoEditorMountParams = {
modelOwnerKey?: string
fileId: string
filePath: string
viewStateKey: string
@@ -12,25 +12,25 @@ afterEach(() => {
describe('shouldIgnoreMonacoContentChange', () => {
it('ignores echoed shared-model changes in the sibling split pane', () => {
const filePath = '/repo/seed.spec.ts'
const modelKey = '/repo/seed.spec.ts'
beginProgrammaticContentSync(filePath)
beginProgrammaticContentSync(modelKey)
try {
expect(
shouldIgnoreMonacoContentChange({
filePath,
modelKey,
isApplyingProgrammaticContent: false
})
).toBe(true)
} finally {
endProgrammaticContentSync(filePath)
endProgrammaticContentSync(modelKey)
}
})
it('ignores local programmatic sync even without a sibling pane', () => {
expect(
shouldIgnoreMonacoContentChange({
filePath: '/repo/seed.spec.ts',
modelKey: '/repo/seed.spec.ts',
isApplyingProgrammaticContent: true
})
).toBe(true)
@@ -39,9 +39,27 @@ describe('shouldIgnoreMonacoContentChange', () => {
it('does not ignore a real user edit once programmatic sync is finished', () => {
expect(
shouldIgnoreMonacoContentChange({
filePath: '/repo/seed.spec.ts',
modelKey: '/repo/seed.spec.ts',
isApplyingProgrammaticContent: false
})
).toBe(false)
})
it('keeps nested sync suppression scoped to one model owner', () => {
const local = 'file:///repo/file.ts'
const remote = `${local}#remote-owner`
beginProgrammaticContentSync(local)
beginProgrammaticContentSync(local)
endProgrammaticContentSync(local)
expect(
shouldIgnoreMonacoContentChange({ modelKey: local, isApplyingProgrammaticContent: false })
).toBe(true)
expect(
shouldIgnoreMonacoContentChange({ modelKey: remote, isApplyingProgrammaticContent: false })
).toBe(false)
endProgrammaticContentSync(local)
expect(
shouldIgnoreMonacoContentChange({ modelKey: local, isApplyingProgrammaticContent: false })
).toBe(false)
})
})
@@ -1,37 +1,37 @@
const programmaticContentSyncDepthByFilePath = new Map<string, number>()
const programmaticContentSyncDepthByModelKey = new Map<string, number>()
export function beginProgrammaticContentSync(filePath: string): void {
programmaticContentSyncDepthByFilePath.set(
filePath,
(programmaticContentSyncDepthByFilePath.get(filePath) ?? 0) + 1
export function beginProgrammaticContentSync(modelKey: string): void {
programmaticContentSyncDepthByModelKey.set(
modelKey,
(programmaticContentSyncDepthByModelKey.get(modelKey) ?? 0) + 1
)
}
export function endProgrammaticContentSync(filePath: string): void {
const depth = programmaticContentSyncDepthByFilePath.get(filePath) ?? 0
export function endProgrammaticContentSync(modelKey: string): void {
const depth = programmaticContentSyncDepthByModelKey.get(modelKey) ?? 0
if (depth <= 1) {
programmaticContentSyncDepthByFilePath.delete(filePath)
programmaticContentSyncDepthByModelKey.delete(modelKey)
return
}
programmaticContentSyncDepthByFilePath.set(filePath, depth - 1)
programmaticContentSyncDepthByModelKey.set(modelKey, depth - 1)
}
export function isProgrammaticContentSyncInFlight(filePath: string): boolean {
return (programmaticContentSyncDepthByFilePath.get(filePath) ?? 0) > 0
export function isProgrammaticContentSyncInFlight(modelKey: string): boolean {
return (programmaticContentSyncDepthByModelKey.get(modelKey) ?? 0) > 0
}
export function shouldIgnoreMonacoContentChange(args: {
filePath: string
modelKey: string
isApplyingProgrammaticContent: boolean
}): boolean {
const { filePath, isApplyingProgrammaticContent } = args
const { modelKey, isApplyingProgrammaticContent } = args
// Why: split panes can share one retained Monaco model by file path. If any
// Why: split panes can share one retained model. If any
// pane is currently reconciling prop content into that shared model, every
// pane sees the echoed change event and must treat it as programmatic.
return isApplyingProgrammaticContent || isProgrammaticContentSyncInFlight(filePath)
return isApplyingProgrammaticContent || isProgrammaticContentSyncInFlight(modelKey)
}
export function resetProgrammaticContentSyncForTests(): void {
programmaticContentSyncDepthByFilePath.clear()
programmaticContentSyncDepthByModelKey.clear()
}
@@ -24,10 +24,10 @@ export function useMonacoContentSyncBridge(params: {
content: string
contentRef: MutableRefObject<string>
contentSyncModeRef: MutableRefObject<MonacoContentSyncMode>
filePath: string
modelKey: string
onContentChange: (content: string) => void
}): MonacoContentSyncBridge {
const { editorRef, content, contentRef, contentSyncModeRef, filePath, onContentChange } = params
const { editorRef, content, contentRef, contentSyncModeRef, modelKey, onContentChange } = params
const lastSyncedContentRef = useRef<string>(content)
@@ -38,6 +38,9 @@ export function useMonacoContentSyncBridge(params: {
const handleChange = useCallback(
(value: string | undefined) => {
if (value !== undefined) {
if (editorRef.current?.getModel()?.uri.toString() !== modelKey) {
return
}
// Why: split panes share one retained model, so a sibling must ignore the echoed programmatic-sync onChange or it marks the file dirty.
if (isApplyingLargePasteRef.current) {
lastSyncedContentRef.current = value
@@ -45,7 +48,7 @@ export function useMonacoContentSyncBridge(params: {
}
if (
shouldIgnoreMonacoContentChange({
filePath,
modelKey,
isApplyingProgrammaticContent: isApplyingProgrammaticContentRef.current
})
) {
@@ -55,25 +58,29 @@ export function useMonacoContentSyncBridge(params: {
onContentChange(value)
}
},
[filePath, onContentChange]
[editorRef, modelKey, onContentChange]
)
// Why: sync the model on external `content` drift; useLayoutEffect lands the overwrite before paint so no stale text flashes. On-mount handled in handleMount.
useLayoutEffect(() => {
const ed = editorRef.current
if (!ed || lastSyncedContentRef.current === content) {
if (
!ed ||
ed.getModel()?.uri.toString() !== modelKey ||
lastSyncedContentRef.current === content
) {
return
}
beginProgrammaticContentSync(filePath)
beginProgrammaticContentSync(modelKey)
isApplyingProgrammaticContentRef.current = true
try {
syncContentUpdate(ed, content, contentSyncModeRef.current)
lastSyncedContentRef.current = content
} finally {
isApplyingProgrammaticContentRef.current = false
endProgrammaticContentSync(filePath)
endProgrammaticContentSync(modelKey)
}
}, [content, contentSyncModeRef, editorRef, filePath])
}, [content, contentSyncModeRef, editorRef, modelKey])
return {
contentRef,
@@ -3,6 +3,7 @@ import type { OnMount } from '@monaco-editor/react'
import { useAppStore } from '@/store'
import { registerFileSearchSelectedTextProvider } from '@/lib/file-search-selection'
import { syncContentOnMount } from './monaco-content-sync'
import { toEditorModelUri } from './editor-model-uri'
import {
beginProgrammaticContentSync,
endProgrammaticContentSync
@@ -24,6 +25,7 @@ export function useMonacoEditorMount(params: MonacoEditorMountParams): OnMount {
const {
fileId,
filePath,
modelOwnerKey,
viewStateKey,
viewStateId,
worktreeId,
@@ -103,7 +105,8 @@ export function useMonacoEditorMount(params: MonacoEditorMountParams): OnMount {
updateMarkdownCompletionDocuments()
// Why: see contentRef — reconcile the retained model to the current prop before user interaction (surfaces edits made while unmounted).
beginProgrammaticContentSync(filePath)
const modelKey = toEditorModelUri(filePath, modelOwnerKey)
beginProgrammaticContentSync(modelKey)
isApplyingProgrammaticContentRef.current = true
try {
const didSyncOnMount = syncContentOnMount(
@@ -116,7 +119,7 @@ export function useMonacoEditorMount(params: MonacoEditorMountParams): OnMount {
}
} finally {
isApplyingProgrammaticContentRef.current = false
endProgrammaticContentSync(filePath)
endProgrammaticContentSync(modelKey)
}
setupCopy(editorInstance, monaco, filePath, propsRef)
@@ -219,6 +222,7 @@ export function useMonacoEditorMount(params: MonacoEditorMountParams): OnMount {
setupCopy,
fileId,
filePath,
modelOwnerKey,
setEditorCursorLine,
updateMarkdownCompletionDocuments,
viewStateKey,
@@ -0,0 +1,110 @@
import { randomUUID } from 'node:crypto'
import { rm, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { test, expect } from './helpers/orca-app'
import { getActiveWorktreeContext } from './helpers/markdown-editor-fixture'
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
import { buildOwnedEditorFileId } from '../../src/renderer/src/store/slices/editor/file-ids/editor-file-ids'
test('keeps same-path host models and undo history separate', async ({
orcaPage,
registerPostElectronShutdownCleanup
}, testInfo) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
const context = await getActiveWorktreeContext(orcaPage)
const filePath = path.join(context.rootPath, `host-model-${randomUUID()}.txt`)
await writeFile(filePath, 'LOCAL_MARKER', 'utf8')
registerPostElectronShutdownCleanup(() => rm(filePath, { force: true }))
const remoteId = buildOwnedEditorFileId(filePath, context.worktreeId, 'host-model-fixture')
// Controlled owner records exercise renderer identity; file transport remains local.
await orcaPage.evaluate(
({ filePath, remoteId, worktreeId }) => {
const store = window.__store
if (!store) {
throw new Error('Editor store unavailable')
}
const base = {
filePath,
relativePath: filePath.split(/[\\/]/).pop() ?? filePath,
worktreeId,
language: 'plaintext',
mode: 'edit' as const,
isDirty: true
}
const generation = {
runtimeConnectionGeneration: null,
runtimePairingRevision: undefined,
runtimeSshGeneration: null,
nestedSshGeneration: null,
directSshGeneration: null
}
store.setState((state) => ({
openFiles: [
{
...base,
id: filePath,
operationProvenance: {
ownershipProjection: 'explicit',
generation: {
...generation,
route: { executionHostId: 'local', runtimeEnvironmentId: null }
}
}
},
{
...base,
id: remoteId,
operationProvenance: {
ownershipProjection: 'explicit',
generation: {
...generation,
route: { executionHostId: 'ssh:fixture-target', runtimeEnvironmentId: null }
}
}
}
],
editorDrafts: {
...state.editorDrafts,
[filePath]: 'LOCAL_MARKER',
[remoteId]: 'REMOTE_MARKER'
},
activeFileId: filePath,
activeFileIdByWorktree: { ...state.activeFileIdByWorktree, [worktreeId]: filePath }
}))
const state = store.getState()
const localTab = state.createUnifiedTab(worktreeId, 'editor', {
entityId: filePath,
label: 'Local file'
})
state.createUnifiedTab(worktreeId, 'editor', { entityId: remoteId, label: 'Remote file' })
store.getState().activateTab(localTab.id)
},
{ filePath, remoteId, worktreeId: context.worktreeId }
)
const editor = orcaPage.locator('.monaco-editor').first()
await expect(editor).toBeVisible({ timeout: 25_000 })
const valueTail = () => orcaPage.evaluate(() => window.__monacoEditorE2E?.snapshot().valueTail)
await expect.poll(valueTail).toBe('LOCAL_MARKER')
await editor.click()
await orcaPage.keyboard.press('ControlOrMeta+End')
await orcaPage.keyboard.type('!')
await expect.poll(valueTail).toBe('LOCAL_MARKER!')
await orcaPage.evaluate((id) => window.__store?.getState().setActiveFile(id), remoteId)
await expect.poll(valueTail).toBe('REMOTE_MARKER')
await orcaPage.screenshot({ path: testInfo.outputPath('remote-before-undo.png') })
await editor.click()
await orcaPage.keyboard.press('ControlOrMeta+z')
await orcaPage.screenshot({ path: testInfo.outputPath('remote-after-undo.png') })
await expect.poll(valueTail).toBe('REMOTE_MARKER')
await orcaPage.keyboard.press('ControlOrMeta+End')
await orcaPage.keyboard.type('?')
await expect.poll(valueTail).toBe('REMOTE_MARKER?')
await orcaPage.keyboard.press('ControlOrMeta+z')
await expect.poll(valueTail).toBe('REMOTE_MARKER')
await orcaPage.evaluate((id) => window.__store?.getState().setActiveFile(id), filePath)
await expect.poll(valueTail).toBe('LOCAL_MARKER!')
await orcaPage.screenshot({ path: testInfo.outputPath('local-retained-edit.png') })
})