fix(plugins): merge disk writes into a custom editor's unsaved edits (#37343)

* fix(plugins): merge disk writes into a custom editor's unsaved edits

A write to the file on disk while a custom editor tab had unsaved edits
never reached the editor: the buffer kept the edits, Orca showed its
Reload/Overwrite banner, and the session's merge never ran. Now a frame
whose document session has merge announces it (editor.merges), and Orca
sends it the new disk text with the text the edits started from
(editor.document with ancestor). The session merges, posts the result,
and the tab stays unsaved with it; the banner is skipped. Without merge
the banner decides as before.

* test(editor): an explicit save under the changed-on-disk banner keeps the edits

* fix(plugins): make the outside merge exact, and narrow it to where it is

- The host settles a merge only on the session's edit made against the
  merge's own document version, and always commits that result to the
  buffer (even when it equals the disk text); silence past the 1 s flush
  deadline, a remount or a throwing merge leave the banner and the
  suspended autosave in place.
- Orca stamps exactly the disk snapshot the acknowledged merge used and
  records it as the next ancestor; a write that landed meanwhile puts the
  conflict back for its own merge.
- The ancestor is the tab's disk text recorded while clean and advanced on
  acknowledged merges, and must match the tab's disk baseline.
- Only a local file in one merging editor view, with no other tab on its
  buffer, takes this path; anything else gets the banner.
- Frame events keep reaching their handlers after the merge capability.

* fix(plugins): move nothing on a failed disk merge; advance the saved text on success

A merge that throws now leaves the session's document, baseline and
version untouched, so the user's next edit goes out against the old
version: Orca refuses it and keeps the banner instead of taking it as the
merge's acknowledgement. After an acknowledged merge the tab's saved text
moves to the disk text it used, so typing back to the old ancestor no
longer looks clean.

* chore(plugins): regenerate the testing bundle on main
This commit is contained in:
Neil
2026-11-02 06:35:01 +00:00
committed by GitHub
parent d712485fe2
commit cb031c4517
20 changed files with 689 additions and 21 deletions
+6 -1
View File
@@ -340,7 +340,12 @@ accepts an edit, and never hands your own accepted edits back through `onReplace
keeps your draft and rejects `flush()`. On `conflict` it calls `merge(base, outside, mine)` once,
where `base` is the last accepted value, and sends the result. Without `merge` the outside
document wins, since an agent may have written the file: the draft is dropped, `onReplace` gets
the outside value, and `flush()` rejects with `conflict`. Pass `merge` to keep both sides. It answers Orca's `editor.flush`
the outside value, and `flush()` rejects with `conflict`. Pass `merge` to keep both sides. With
`merge`, a write to the file on disk while the tab has unsaved edits also reaches the editor:
Orca calls `merge(base, outside, mine)` with `base` the disk content those edits started from,
the tab keeps the merged result unsaved, and its Reload/Overwrite banner is not shown. This
applies to a local file open in one editor view; elsewhere, without `merge`, or when the editor
does not answer within a second, that banner decides. It answers Orca's `editor.flush`
itself before a save. Bytes editors pass both `decode` and `encode`. Check `'session' in
orca.document` to support older Orca.
File diff suppressed because one or more lines are too long
+6 -1
View File
@@ -23,6 +23,9 @@ declare module 'orca/frame' {
readonly content: EditorContent
/** Bytes documents: the manifest suffix that matched, e.g. `xlsx`. */
readonly fileType?: string
/** Only for a session with `merge`: the file changed on disk while the tab had unsaved edits;
* this is the disk content those edits started from. */
readonly ancestor?: EditorContent
}
/** The whole new content, made against document `baseVersion`. */
export type EditorEdit = { baseVersion: number; content: EditorContent }
@@ -55,7 +58,9 @@ declare module 'orca/frame' {
export type EditorSessionOptions<T> = {
decode?: (content: EditorContent) => T
encode?: (value: T) => EditorContent
/** On a conflict: `base` is the last accepted value. Without it the outside document wins. */
/** On a conflict `base` is the last accepted value; when the file changes on disk under the
* tab's unsaved edits, `base` is the disk content those edits started from and Orca skips its
* banner. Without it the outside document wins, or Orca shows its banner. */
merge?: (base: T, outside: T, mine: T) => T
/** Wait after the last change before sending it; default 250. */
debounceMs?: number
@@ -140,7 +140,12 @@ test('merges an agent write that lands while an edit waits', async () => {
})
```
Without `merge` an outside write wins and the draft is dropped. `ORCA plugin check <dir> --json`
`merge` covers both outside writes: one that lands while an edit waits (base: your last accepted
value), and one made on disk while the tab has unsaved edits (base: the disk text those edits
started from; the tab stays unsaved with the result and Orca shows no banner, for a local file
in one editor view). Without `merge` an
outside write wins and the draft is dropped, and a disk write under unsaved edits gets Orca's
Reload/Overwrite banner. `ORCA plugin check <dir> --json`
then reads every file the entry loads without running it, and
`ORCA plugin open <plugin-key> --editor <file>` shows it on a real file.
`ORCA plugin shot <plugin-key> --editor <file> --theme dark` saves a PNG of the editor on that
+6 -1
View File
@@ -3398,7 +3398,12 @@ const ORCA_PLUGIN_AUTHORING_RECIPES_REFERENCE_MARKDOWN = [
"})",
"```",
"",
"Without `merge` an outside write wins and the draft is dropped. `ORCA plugin check <dir> --json`",
"`merge` covers both outside writes: one that lands while an edit waits (base: your last accepted",
"value), and one made on disk while the tab has unsaved edits (base: the disk text those edits",
"started from; the tab stays unsaved with the result and Orca shows no banner, for a local file",
"in one editor view). Without `merge` an",
"outside write wins and the draft is dropped, and a disk write under unsaved edits gets Orca's",
"Reload/Overwrite banner. `ORCA plugin check <dir> --json`",
"then reads every file the entry loads without running it, and",
"`ORCA plugin open <plugin-key> --editor <file>` shows it on a real file.",
"`ORCA plugin shot <plugin-key> --editor <file> --theme dark` saves a PNG of the editor on that",
@@ -0,0 +1,12 @@
import type { OpenFile } from '@/store/slices/editor'
import { ExternalFileChangeBanner } from './ExternalFileChangeBanner'
import { useCustomEditorMerging } from './use-custom-editor-outside-merge'
/** The external-change banner, hidden only while a custom editor is merging the change itself. */
export function CustomEditorExternalChangeBanner(props: {
file: OpenFile
currentContent: string
reloadContent: (file: OpenFile) => void
}): React.JSX.Element | null {
return useCustomEditorMerging(props.file.id) ? null : <ExternalFileChangeBanner {...props} />
}
@@ -18,7 +18,7 @@ import type { EditorConflictNavigation } from './useEditorConflictNavigation'
import { EditorFileLoadErrorView } from './EditorFileLoadErrorView'
import { RecoverableRenderErrorBoundary } from '../error-boundaries/RecoverableRenderErrorBoundary'
import type { FileContent } from './editor-panel-content-types'
import { ExternalFileChangeBanner } from './ExternalFileChangeBanner'
import { CustomEditorExternalChangeBanner } from './CustomEditorExternalChangeBanner'
import { PluginFileTypeSuggestion } from './PluginFileTypeSuggestion'
import type { useMarkdownDocuments } from './useMarkdownDocuments'
import { EditorMarkdownFileSurface } from './EditorMarkdownFileSurface'
@@ -200,7 +200,7 @@ export function EditorEditFileSurface({
const currentContent = editBuffer ?? fileContent.content
const externalChangeBanner =
activeFile.externalMutation === 'changed' ? (
<ExternalFileChangeBanner
<CustomEditorExternalChangeBanner
file={activeFile}
currentContent={currentContent}
reloadContent={reloadContent}
@@ -17,6 +17,10 @@ import {
import { usePluginFrameHost } from '../right-sidebar/use-plugin-frame-host'
import { usePluginFrameEntry } from '../right-sidebar/use-plugin-frame-entry'
import { editorShortcutMatches } from './editor-shortcuts'
import {
countCustomEditorMergingView,
useCustomEditorOutsideMerge
} from './use-custom-editor-outside-merge'
import { registerEditorFrameFlush } from './editor-pending-flush'
import { useCustomEditorReloadFlush } from './use-custom-editor-reload-flush'
import { PluginEditorNeedsDesktopNotice } from './PluginEditorNeedsDesktopNotice'
@@ -104,6 +108,8 @@ export default function PluginCustomEditorFrame({
remountGone
} = usePluginFrameEntry('editor', editor.pluginKey, editor.id)
const progressRef = useRef<(step: 'delivered' | 'subscribed') => void>(() => {})
const mergingRef = useRef<(merging: boolean) => void>(() => {})
const countedRef = useRef(false)
const host = useMemo(() => {
const created = createCustomEditorHost({
@@ -130,6 +136,7 @@ export default function PluginCustomEditorFrame({
}
},
onFrameProgress: (step) => progressRef.current(step),
onMergesOutside: () => mergingRef.current(true),
onKeyChord: (chord, context) => {
const event = new KeyboardEvent('keydown', chord)
// Why: chords are posted by frame code, so only one riding real user input may save.
@@ -167,7 +174,15 @@ export default function PluginCustomEditorFrame({
)
useLayoutEffect(() => {
progressRef.current = reportProgress
// Counts this view once while its session merges; text editors only.
mergingRef.current = (merging) => {
if (fileId !== undefined && !isBinary && merging !== countedRef.current) {
countedRef.current = merging
countCustomEditorMergingView(fileId, merging ? 1 : -1)
}
}
})
useCustomEditorOutsideMerge(isBinary ? undefined : fileId, host, content)
const { onFrameLoad } = usePluginFrameHost({
surface: 'editor',
@@ -187,7 +202,10 @@ export default function PluginCustomEditorFrame({
if (!frameKey) {
return
}
return () => host.reset()
return () => {
host.reset()
mergingRef.current(false)
}
}, [frameKey, host])
useEffect(() => {
@@ -185,6 +185,31 @@ describe('editor autosave changed-on-disk conflict flow', () => {
})
})
// A custom editor's Cmd+S reaches this same explicit save after flushing its frame.
it('refuses an explicit save while the banner shows, keeping the edits and the disk', async () => {
const fakeDisk = stubEditorWindowWithDisk(
createFakeEditorDisk({ '/repo/file.ts': 'agent write' })
)
const store = createEditorStore()
openDirtyFile(store, 'user stroke')
store.getState().setLastKnownDiskSignature('/repo/file.ts', getDiskBaselineSignature('base'))
store.getState().setExternalMutation('/repo/file.ts', 'changed')
const cleanup = attachEditorAutosaveController(store)
try {
await expect(requestEditorFileSave({ fileId: '/repo/file.ts' })).rejects.toThrow(
'changed on disk'
)
expect(fakeDisk.files.get('/repo/file.ts')).toBe('agent write')
expect(store.getState().editorDrafts['/repo/file.ts']).toBe('user stroke')
expect(store.getState().openFiles[0]).toMatchObject({
isDirty: true,
externalMutation: 'changed'
})
} finally {
cleanup()
}
})
it("clears the changed-on-disk mark after the banner's Overwrite", async () => {
const writeFile = stubEditorWindow()
const store = createEditorStore()
@@ -0,0 +1,28 @@
import { readCustomEditorKeyChord } from '../../../../shared/plugins/frames/plugin-custom-editor-bridge'
import { FRAME_EVENTS } from '../../../../shared/plugins/frames/plugin-frame-bridge'
import type { PluginEvent } from '../../../../shared/plugins/frames/plugin-protocol'
import type { PluginFrameMessageContext } from '../right-sidebar/plugin-frame-host'
import type { CustomEditorHostOptions } from './plugin-custom-editor-host-types'
/** A custom editor frame's own events. True when it announced that its session merges outside
* changes, which the host remembers for the frame's life. */
export function handleCustomEditorFrameEvent(
event: PluginEvent,
context: PluginFrameMessageContext,
options: Pick<CustomEditorHostOptions, 'onFrameProgress' | 'onKeyChord' | 'onMergesOutside'>
): boolean {
if (event.event === FRAME_EVENTS.editorMerges) {
options.onMergesOutside?.()
return true
}
if (event.event === FRAME_EVENTS.editorSubscribed) {
options.onFrameProgress?.('subscribed')
return false
}
const chord =
event.event === FRAME_EVENTS.editorKeydown ? readCustomEditorKeyChord(event.payload) : null
if (chord) {
options.onKeyChord?.(chord, context)
}
return false
}
@@ -30,6 +30,8 @@ export type CustomEditorHostOptions = {
onKeyChord?: (chord: CustomEditorKeyChord, context: PluginFrameMessageContext) => void
/** The frame got a document, or asked for one; `orca plugin shot --editor` waits on both. */
onFrameProgress?: (step: 'delivered' | 'subscribed') => void
/** The frame's document session merges outside changes; set once per frame. */
onMergesOutside?: () => void
/** The edit lane; every other message spends the frame's shared budget. */
budget?: PanelMessageBudget
now?: () => number
@@ -48,4 +50,8 @@ export type CustomEditorHost = {
currentText: () => string
/** A new document mounted in the frame; it must announce ready again. */
reset: () => void
/** Sends a disk change to a frame whose session merges it into the unsaved edits, and resolves
* with the merged text once the session's merged edit is in the buffer; null when the frame
* cannot or does not within the flush deadline, so the caller keeps the banner. */
mergeOutsideChange: (diskText: string, ancestorText: string) => Promise<string | null>
}
@@ -74,6 +74,27 @@ function harness(
return { host, posted, send, messages, documents, answers, answerFlushes }
}
function setupWith(initial: {
text: string
onMergesOutside: () => void
onKeyChord?: CustomEditorHostOptions['onKeyChord']
}) {
const doc = { text: initial.text, readOnly: false }
const onEdit = vi.fn((text: string) => {
doc.text = text
})
return {
...harness({
getDocument: () => doc,
onEdit,
onMergesOutside: initial.onMergesOutside,
flushTimeoutMs: 1_000,
...(initial.onKeyChord ? { onKeyChord: initial.onKeyChord } : {})
}),
onEdit
}
}
function setup(initial: {
text: string
readOnly?: boolean
@@ -498,4 +519,64 @@ describe('custom editor document channel', () => {
send(READY)
expect(documents()).toHaveLength(2)
})
it('settles a merge only on the edit made against its own version', async () => {
const onMergesOutside = vi.fn()
const onKeyChord = vi.fn()
const { host, send, documents, onEdit } = setupWith({
text: 'a+mine',
onMergesOutside,
onKeyChord
})
send(READY)
await expect(host.mergeOutsideChange('a+agent', 'a')).resolves.toBeNull()
send({ orca: 1, kind: 'evt', event: 'editor.merges', payload: {} })
expect(onMergesOutside).toHaveBeenCalledOnce()
const merged = host.mergeOutsideChange('a+mine+agent', 'a')
expect(documents().at(-1)).toMatchObject({
payload: {
version: 2,
content: { kind: 'text', text: 'a+mine+agent' },
ancestor: { kind: 'text', text: 'a' }
}
})
// The merge result equals the disk text just sent; it still has to reach the buffer.
send(edit('a+mine+agent', 2))
await expect(merged).resolves.toBe('a+mine+agent')
expect(onEdit).toHaveBeenLastCalledWith('a+mine+agent')
// Events after the capability still reach their handlers (Save included).
send(
keydown({
key: 's',
code: 'KeyS',
metaKey: true,
ctrlKey: false,
altKey: false,
shiftKey: false
})
)
expect(onKeyChord).toHaveBeenCalledOnce()
})
it('answers null when the session never acknowledges, or the frame remounts', async () => {
vi.useFakeTimers()
try {
const { host, send } = setupWith({ text: 'a', onMergesOutside: vi.fn() })
send(READY)
send({ orca: 1, kind: 'evt', event: 'editor.merges', payload: {} })
const silent = host.mergeOutsideChange('b', 'a')
await vi.advanceTimersByTimeAsync(1_000)
await expect(silent).resolves.toBeNull()
// A frame whose merge threw edits against the old version: refused, never the acknowledgement.
const failed = host.mergeOutsideChange('b2', 'a')
send(edit('a+typed', 1, 77))
await vi.advanceTimersByTimeAsync(1_000)
await expect(failed).resolves.toBeNull()
const remounted = host.mergeOutsideChange('c', 'a')
host.reset()
await expect(remounted).resolves.toBeNull()
} finally {
vi.useRealTimers()
}
})
})
@@ -6,7 +6,6 @@ import {
CUSTOM_EDITOR_TOO_LARGE,
isCustomEditorEditAttempt,
readCustomEditorEdit,
readCustomEditorKeyChord,
type CustomEditorDocument,
type CustomEditorEdit
} from '../../../../shared/plugins/frames/plugin-custom-editor-bridge'
@@ -27,6 +26,8 @@ import {
structuredCloneMessageBytes
} from '../../../../shared/plugins/frames/plugin-panel-message-budget'
import { handleCustomEditorFrameEvent } from './plugin-custom-editor-frame-events'
import { createPendingOutsideMerge } from './plugin-custom-editor-outside-merge'
import type {
CustomEditorBinaryDocument,
CustomEditorEditRefusal,
@@ -61,6 +62,8 @@ export function createCustomEditorHost(options: CustomEditorHostOptions): Custom
let supersededIds: (number | null)[] = []
let heldTimer: ReturnType<typeof setTimeout> | null = null
let lostEdit = false
let mergesOutside = false
const outsideMerge = createPendingOutsideMerge()
// An oversized edit came after the held one, so applying the held one leaves it still lost.
let lostAfterHeld = false
let unapplied: CustomEditorUnappliedEdit = null
@@ -151,7 +154,8 @@ export function createCustomEditorHost(options: CustomEditorHostOptions): Custom
options.onBytesEdit?.(frameBinary)
return null
}
if (content.text !== frameText) {
// A merge's result always reaches the buffer, even when it equals the disk text just sent.
if (outsideMerge.accept(baseVersion, content.text) || content.text !== frameText) {
frameText = content.text
options.onEdit(content.text)
}
@@ -246,17 +250,9 @@ export function createCustomEditorHost(options: CustomEditorHostOptions): Custom
pushDocument(true)
},
onEvent(event, context) {
if (event.event === FRAME_EVENTS.editorSubscribed) {
options.onFrameProgress?.('subscribed')
return
}
const chord =
event.event === FRAME_EVENTS.editorKeydown
? readCustomEditorKeyChord(event.payload)
: null
if (chord) {
options.onKeyChord?.(chord, context)
}
// Every event is handled; announcing merge support only adds the capability.
const merges = handleCustomEditorFrameEvent(event, context, options)
mergesOutside = mergesOutside || merges
},
onResponse(response) {
pendingFlushes.get(response.id)?.(response.error === undefined)
@@ -266,6 +262,16 @@ export function createCustomEditorHost(options: CustomEditorHostOptions): Custom
syncDocument() {
pushDocument(false)
},
mergeOutsideChange(diskText, ancestorText) {
if (!ready || !mergesOutside || options.getBinaryDocument) {
return Promise.resolve(null)
}
version += 1
const ancestor = { kind: 'text' as const, text: ancestorText }
const { readOnly } = options.getDocument()
postDocument({ version, readOnly, content: { kind: 'text', text: diskText }, ancestor })
return outsideMerge.begin(version, flushTimeoutMs)
},
flush() {
if (!ready) {
return Promise.resolve(!lostEdit)
@@ -301,6 +307,8 @@ export function createCustomEditorHost(options: CustomEditorHostOptions): Custom
applyHeldEdit()
}
ready = false
mergesOutside = false
outsideMerge.cancel()
frameText = null
frameReadOnly = null
frameBinary = null
@@ -0,0 +1,45 @@
/**
* One outside merge in flight between Orca and an editor frame. It settles only on the session's
* acknowledgement: the first accepted edit made against the merge's own document version, whose
* text is the merged result. A merge that throws, a disposed session, a remount or silence past
* the deadline settles with null, and Orca keeps its banner.
*/
export type PendingOutsideMerge = {
/** Starts a merge for the document version just sent; a previous one settles with null. */
begin(version: number, timeoutMs: number): Promise<string | null>
/** An accepted text edit; true when it is the acknowledgement, which settles the merge. */
accept(baseVersion: number, text: string): boolean
cancel(): void
}
export function createPendingOutsideMerge(): PendingOutsideMerge {
let pending: { version: number; settle: (text: string | null) => void } | null = null
const finish = (text: string | null): void => {
const current = pending
pending = null
current?.settle(text)
}
return {
begin(version, timeoutMs) {
finish(null)
return new Promise((resolve) => {
const timer = setTimeout(() => finish(null), timeoutMs)
pending = {
version,
settle: (text) => {
clearTimeout(timer)
resolve(text)
}
}
})
},
accept(baseVersion, text) {
if (pending?.version !== baseVersion) {
return false
}
finish(text)
return true
},
cancel: () => finish(null)
}
}
@@ -0,0 +1,132 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature'
vi.mock('./ExternalFileChangeBanner', () => ({
ExternalFileChangeBanner: () => <div>banner</div>
}))
vi.mock('@/lib/connection-context', () => ({ getConnectionIdForFile: () => null }))
import { CustomEditorExternalChangeBanner } from './CustomEditorExternalChangeBanner'
import {
countCustomEditorMergingView,
mergeOutsideChangeIntoEditor,
recordCustomEditorAncestor,
useCustomEditorOutsideMergeStore
} from './use-custom-editor-outside-merge'
Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true)
const initialState = useAppStore.getState()
const FILE_ID = '/repo/board.sketch'
function openTab(baseline: string): void {
useAppStore.getState().openFile({
filePath: FILE_ID,
relativePath: 'board.sketch',
worktreeId: 'wt-1',
language: 'plaintext',
mode: 'edit'
})
useAppStore.getState().markFileDirty(FILE_ID, true)
useAppStore.getState().setLastKnownDiskSignature(FILE_ID, getDiskBaselineSignature(baseline))
useAppStore.getState().setExternalMutation(FILE_ID, 'changed')
}
const tab = (): OpenFile | undefined =>
useAppStore.getState().openFiles.find((file) => file.id === FILE_ID)
/** One merging view of a local tab whose edits started from `a`. */
function ready(): void {
openTab('a')
recordCustomEditorAncestor(FILE_ID, 'a')
countCustomEditorMergingView(FILE_ID, 1)
}
const afterSaved: (() => void)[] = []
afterEach(() => {
afterSaved.splice(0).forEach((stop) => stop())
useAppStore.setState(initialState, true)
useCustomEditorOutsideMergeStore.setState({ views: {}, busy: {}, ancestors: {} })
})
describe('custom editor outside merge', () => {
it('stamps exactly the disk snapshot the acknowledged merge used, and advances the ancestor', async () => {
ready()
// The tab's saved text moves to the disk text the merge used, as the panel hears after a save.
const saved: unknown[] = []
const onSaved = (event: Event): void => {
saved.push(event instanceof CustomEvent ? event.detail : null)
}
window.addEventListener('orca:editor-file-saved', onSaved)
afterSaved.push(() => window.removeEventListener('orca:editor-file-saved', onSaved))
const host = { mergeOutsideChange: vi.fn().mockResolvedValue('a+mine+agent') }
await expect(mergeOutsideChangeIntoEditor(FILE_ID, host, async () => 'a+agent')).resolves.toBe(
true
)
expect(host.mergeOutsideChange).toHaveBeenCalledWith('a+agent', 'a')
expect(tab()?.externalMutation).toBeUndefined()
expect(tab()?.lastKnownDiskSignature).toBe(getDiskBaselineSignature('a+agent'))
expect(useCustomEditorOutsideMergeStore.getState().ancestors[FILE_ID]?.text).toBe('a+agent')
expect(saved).toEqual([{ fileId: FILE_ID, content: 'a+agent', recordViewIds: [FILE_ID] }])
})
it('puts the conflict back for a write that landed while the merge ran', async () => {
ready()
const reads = ['a+agent', 'a+agent+second']
const host = { mergeOutsideChange: vi.fn().mockResolvedValue('merged') }
await mergeOutsideChangeIntoEditor(FILE_ID, host, async () => reads.shift() ?? null)
// Only the first write is stamped; the second waits for its own merge.
expect(tab()).toMatchObject({
externalMutation: 'changed',
lastKnownDiskSignature: getDiskBaselineSignature('a+agent')
})
})
it('keeps the conflict for the banner when the session never acknowledges', async () => {
ready()
const host = { mergeOutsideChange: vi.fn().mockResolvedValue(null) }
await expect(mergeOutsideChangeIntoEditor(FILE_ID, host, async () => 'a+agent')).resolves.toBe(
false
)
expect(tab()).toMatchObject({
externalMutation: 'changed',
lastKnownDiskSignature: getDiskBaselineSignature('a')
})
expect(useCustomEditorOutsideMergeStore.getState().busy[FILE_ID]).toBeUndefined()
})
it('leaves it to the banner with no known ancestor, or two editor views', async () => {
const host = { mergeOutsideChange: vi.fn().mockResolvedValue('x') }
openTab('a')
countCustomEditorMergingView(FILE_ID, 1)
// The recorded ancestor is not the disk the tab's baseline names.
recordCustomEditorAncestor(FILE_ID, 'older')
await expect(mergeOutsideChangeIntoEditor(FILE_ID, host, async () => 'b')).resolves.toBe(false)
recordCustomEditorAncestor(FILE_ID, 'a')
countCustomEditorMergingView(FILE_ID, 1)
await expect(mergeOutsideChangeIntoEditor(FILE_ID, host, async () => 'b')).resolves.toBe(false)
expect(host.mergeOutsideChange).not.toHaveBeenCalled()
})
it('hides the banner only while a merge runs', async () => {
const container = document.createElement('div')
const root = createRoot(container)
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the banner gate reads only the tab's id.
const file = { id: FILE_ID } as OpenFile
await act(async () =>
root.render(
<CustomEditorExternalChangeBanner file={file} currentContent="x" reloadContent={vi.fn()} />
)
)
expect(container.textContent).toBe('banner')
await act(async () => useCustomEditorOutsideMergeStore.setState({ busy: { [FILE_ID]: true } }))
expect(container.textContent).toBe('')
await act(async () => root.unmount())
})
})
@@ -0,0 +1,185 @@
import { useEffect, useLayoutEffect, useRef } from 'react'
import { create } from 'zustand'
import { getConnectionIdForFile } from '@/lib/connection-context'
import { editorTabFileAccess } from '@/lib/local-file-access'
import { getRuntimeTargetForFileOwner } from '@/lib/file-owner-runtime-target'
import { readRuntimeFileContent } from '@/runtime/runtime-file-read-client'
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { getDiskBaselineSignature } from '../../../../shared/disk-baseline-signature'
import { editorModelSiblingTabs, setEditorDiskBaseline } from './editor-save-disk-precondition'
import type { CustomEditorHost } from './plugin-custom-editor-host-types'
import { ORCA_EDITOR_FILE_SAVED_EVENT, type EditorFileSavedDetail } from './editor-autosave'
/**
* A write to the file on disk while a custom editor tab holds unsaved edits, merged by the editor
* instead of Orca's banner. Deliberately narrow, the banner on any doubt: one mounted editor view
* of a local file with no other tab on its buffer, a session that merges, a known ancestor, and an
* acknowledged merge. Only then is the disk snapshot that merge used stamped as the baseline.
*/
type OutsideMergeState = {
/** Mounted merging editor views per tab; the merge runs only with exactly one. */
views: Record<string, number>
/** A merge in flight per tab: the banner stays hidden until it settles. */
busy: Record<string, true>
/** The disk text the tab's unsaved edits started from: recorded while the tab is clean and
* after each acknowledged merge, with its signature to match the tab's disk baseline. */
ancestors: Record<string, { text: string; signature: string }>
}
export const useCustomEditorOutsideMergeStore = create<OutsideMergeState>()(() => ({
views: {},
busy: {},
ancestors: {}
}))
const patch = <K extends keyof OutsideMergeState>(
key: K,
fileId: string,
value: OutsideMergeState[K][string] | undefined
): void =>
useCustomEditorOutsideMergeStore.setState((state) => {
const next = { ...state[key] }
if (value === undefined) {
delete next[fileId]
} else {
next[fileId] = value
}
return { [key]: next }
})
/** A merging editor view of the tab mounted (+1) or went away (-1). */
export function countCustomEditorMergingView(fileId: string, delta: 1 | -1): void {
const count = (useCustomEditorOutsideMergeStore.getState().views[fileId] ?? 0) + delta
patch('views', fileId, count > 0 ? count : undefined)
}
export function recordCustomEditorAncestor(fileId: string, text: string): void {
patch('ancestors', fileId, { text, signature: getDiskBaselineSignature(text) })
}
/** True while the editor is taking a disk change itself, so Orca's banner stays hidden. */
export function useCustomEditorMerging(fileId: string): boolean {
return useCustomEditorOutsideMergeStore((state) => state.busy[fileId] === true)
}
/** The tab may merge: one merging view, a local file, no other tab sharing its buffer, and an
* ancestor that is exactly the disk the tab's baseline names. */
function mergeAncestor(file: OpenFile): string | null {
const state = useAppStore.getState()
const merge = useCustomEditorOutsideMergeStore.getState()
const ancestor = merge.ancestors[file.id]
const local =
!file.runtimeEnvironmentId &&
!file.externalSshTargetId &&
!getConnectionIdForFile(file.worktreeId, file.filePath)
const alone = editorModelSiblingTabs(state, file).length === 1
return merge.views[file.id] === 1 &&
local &&
alone &&
ancestor &&
file.lastKnownDiskSignature === ancestor.signature
? ancestor.text
: null
}
/** Reads the file as it is on disk now; null when it cannot be read as text. */
async function readDiskText(fileId: string): Promise<string | null> {
const state = useAppStore.getState()
const file = state.openFiles.find((openFile) => openFile.id === fileId)
const target = file
? getRuntimeTargetForFileOwner(state, file.worktreeId, file.runtimeEnvironmentId)
: null
if (!file || !target) {
return null
}
const result = await readRuntimeFileContent({
target,
filePath: file.filePath,
relativePath: file.relativePath,
worktreeId: file.worktreeId,
access: editorTabFileAccess(state, file)
})
return result.isBinary ? null : result.content
}
const liveTab = (fileId: string): OpenFile | undefined =>
useAppStore.getState().openFiles.find((openFile) => openFile.id === fileId)
/**
* One merge of the disk as it is now. On acknowledgement, stamps that exact disk snapshot as the
* baseline and its text as the new ancestor; if the disk moved on meanwhile, marks the tab changed
* again so the next round merges the newer write. Anything else leaves the conflict for the banner.
*/
export async function mergeOutsideChangeIntoEditor(
fileId: string,
host: Pick<CustomEditorHost, 'mergeOutsideChange'>,
readDisk: (fileId: string) => Promise<string | null> = readDiskText
): Promise<boolean> {
const file = liveTab(fileId)
const ancestor = file ? mergeAncestor(file) : null
if (!file || ancestor === null || file.externalMutation !== 'changed') {
return false
}
patch('busy', fileId, true)
try {
const disk = await readDisk(fileId).catch(() => null)
const merged = disk === null ? null : await host.mergeOutsideChange(disk, ancestor)
if (disk === null || merged === null || liveTab(fileId)?.externalMutation !== 'changed') {
return false
}
const state = useAppStore.getState()
setEditorDiskBaseline(state, fileId, getDiskBaselineSignature(disk))
state.setExternalMutation(fileId, null)
recordCustomEditorAncestor(fileId, disk)
// The tab's saved text (what its dirty state compares to) is now this disk text, as after a
// save; otherwise typing back to the old ancestor would look clean while disk differs.
window.dispatchEvent(
new CustomEvent<EditorFileSavedDetail>(ORCA_EDITOR_FILE_SAVED_EVENT, {
detail: { fileId, content: disk, recordViewIds: [fileId] }
})
)
// A write that landed after the snapshot is not merged yet: put the conflict back for it.
const now = await readDisk(fileId).catch(() => null)
if (now !== disk) {
useAppStore.getState().setExternalMutation(fileId, 'changed')
}
return true
} finally {
patch('busy', fileId, undefined)
}
}
/** Keeps the ancestor current and runs a merge each time the tab's file changes on disk. */
export function useCustomEditorOutsideMerge(
fileId: string | undefined,
host: Pick<CustomEditorHost, 'mergeOutsideChange'>,
/** The tab's buffer; recorded as the ancestor whenever the tab is clean (disk equals it). */
content: string
): void {
const contentRef = useRef(content)
useLayoutEffect(() => {
contentRef.current = content
})
const clean = useAppStore((state) =>
fileId === undefined
? false
: state.openFiles.find((openFile) => openFile.id === fileId)?.isDirty === false
)
const changed = useAppStore(
(state) =>
fileId !== undefined &&
state.openFiles.find((openFile) => openFile.id === fileId)?.externalMutation === 'changed'
)
useEffect(() => {
if (fileId !== undefined && clean) {
recordCustomEditorAncestor(fileId, contentRef.current)
}
}, [fileId, clean, content])
useEffect(() => {
if (fileId !== undefined && changed) {
void mergeOutsideChangeIntoEditor(fileId, host)
}
}, [fileId, changed, host])
}
@@ -32,6 +32,9 @@ export type CustomEditorDocument = {
content: CustomEditorContent
/** Bytes documents: the manifest suffix that matched, e.g. `xlsx`; never a path or file name. */
fileType?: string
/** Sent only to a session that merges: the file changed on disk while the tab held unsaved
* edits. `content` is the new disk text and `ancestor` the disk text those edits started from. */
ancestor?: CustomEditorContent
}
const customEditorEditSchema = z.strictObject({
@@ -27,6 +27,8 @@ export const FRAME_EVENTS = {
editorDocument: 'editor.document',
/** frame → host, once: the editor asked for its document, so `plugin shot` knows it is wired. */
editorSubscribed: 'editor.subscribed',
/** frame → host, once: its document session merges outside changes into unsaved work. */
editorMerges: 'editor.merges',
/** host → frame, payload `PluginPanelPush`: a worker post, an event or a data change. */
push: 'plugin.push'
} as const
@@ -422,4 +422,67 @@ describe('orca.document.session', () => {
expect(replaced).not.toHaveBeenCalled()
expect(posted().at(-1)).toEqual({ orca: 1, kind: 'res', id: 4, result: null })
})
it('merges a disk change made under unsaved edits, and tells Orca it does', async () => {
const merge = vi.fn(
(base: string, outside: string, mine: string) => `${outside}|${mine}|${base}`
)
const { session, fromHost, replaced, edits, posted } = await open({ debounceMs: 0, merge })
expect(posted()).toContainEqual(
expect.objectContaining({ kind: 'evt', event: 'editor.merges' })
)
session.change('a+mine')
await settle()
fromHost(accept(0))
await settle()
// Accepted into the tab's buffer, still unsaved: the agent writes the file on disk.
fromHost({
...doc(2, 'a+agent'),
payload: {
version: 2,
readOnly: false,
content: { kind: 'text', text: 'a+agent' },
ancestor: { kind: 'text', text: 'a' }
}
})
await settle()
expect(merge).toHaveBeenCalledWith('a', 'a+agent', 'a+mine')
expect(replaced).toHaveBeenLastCalledWith('a+agent|a+mine|a')
expect(edits().at(-1)).toMatchObject({
params: { baseVersion: 2, content: { kind: 'text', text: 'a+agent|a+mine|a' } }
})
})
it('never claims to merge without a merge function', async () => {
const { posted } = await open({ debounceMs: 0 })
expect(posted().some((message) => message.event === 'editor.merges')).toBe(false)
})
it('moves nothing when a disk merge throws, so the next edit cannot acknowledge it', async () => {
const merge = vi.fn(() => {
throw new Error('cannot merge')
})
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
try {
const { session, fromHost, edits, replaced } = await open({ debounceMs: 0, merge })
fromHost({
...doc(2, 'a+agent'),
payload: {
version: 2,
readOnly: false,
content: { kind: 'text', text: 'a+agent' },
ancestor: { kind: 'text', text: 'a' }
}
})
await settle()
expect(replaced).not.toHaveBeenCalled()
expect(session.value).toBe('a')
session.change('a+typed')
await settle()
// Still against the document before the merge: Orca refuses it and keeps its banner.
expect(edits().at(-1)).toMatchObject({ params: { baseVersion: 1 } })
} finally {
error.mockRestore()
}
})
})
@@ -168,6 +168,24 @@ export const PLUGIN_FRAME_DOCUMENT_SCRIPT = `
}
var documents = on(E.editorDocument, function (next) {
if (disposed || next.version === doc.version) return
// A disk change under the tab's unsaved edits: merge it with them and send the result.
// Nothing moves unless the merge succeeds, so a later edit never acknowledges a failed one.
if (next.ancestor && merge) {
var merged
try {
merged = merge(decode(next.ancestor), decode(next.content), value)
} catch (error) {
console.error(error)
return
}
rebase(next)
value = merged
dirty = true
generation += 1
emitReplace(value)
if (!inFlight) send().catch(function () {})
return
}
doc = next
// Unaccepted work waits for its edit's conflict, where merge runs.
if (dirty || inFlight) return
@@ -179,6 +197,8 @@ export const PLUGIN_FRAME_DOCUMENT_SCRIPT = `
var flushes = on(C.editor.flush, function () {
return disposed || doc.readOnly ? undefined : flush()
})
// Orca then hands this session disk changes made under unsaved edits, instead of a banner.
if (merge) post({ kind: 'evt', event: E.editorMerges, payload: {} })
return Object.freeze({
get value() { return value },
get readOnly() { return doc.readOnly },