diff --git a/docs/reference/plugin-authoring.md b/docs/reference/plugin-authoring.md index acd060d5260..34353f72b4e 100644 --- a/docs/reference/plugin-authoring.md +++ b/docs/reference/plugin-authoring.md @@ -308,7 +308,36 @@ desktop app with isolated frames switched off, then never run it in the fallback opens in Source with "This editor needs the Orca desktop app". The default, `any`, runs wherever the client can. -An editor's `window.orca` adds `document`: +An editor's `window.orca` adds `document`. Most editors need only `session()`, which does the +document sync for you: + +```js +const doc = await orca.document.session({ + decode: (content) => parseScene(content.text), // default: the text itself + encode: (scene) => ({ kind: 'text', text: serializeScene(scene) }), // default: plain text + merge: (base, outside, mine) => mergeScenes(base, outside, mine), // optional + debounceMs: 250 // optional, the default +}) +render(doc.value) // the latest value, including changes Orca has not accepted yet +doc.onReplace((scene) => render(scene)) // the file changed outside, or a conflict merged +onUserEdit((scene) => doc.change(scene)) // a no-op while doc.readOnly +await doc.flush() // send now; rejects with the refusal's code +``` + +`session()` resolves once Orca has sent the theme, locale and first document, so start the editor +from it. It keeps one edit in flight and coalesces later changes, moves its baseline only when Orca +accepts an edit, and never hands your own accepted edits back through `onReplace`. A refused edit +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` +itself before a save. Bytes editors pass both `decode` and `encode`. Check `'session' in +orca.document` to support older Orca. + +Orca keeps no undo history for a custom editor: Cmd/Ctrl+Z reaches the frame, and the editor owns +undo. + +The lower-level calls, for an editor that does its own sync: ```js const doc = await orca.document.get() // { version, readOnly, content, fileType? } diff --git a/resources/plugins/types/orca-frame.d.ts b/resources/plugins/types/orca-frame.d.ts index ab2ab8a5377..cee1387c0b3 100644 --- a/resources/plugins/types/orca-frame.d.ts +++ b/resources/plugins/types/orca-frame.d.ts @@ -48,6 +48,29 @@ declare module 'orca/frame' { onDidChange(handler: (document: EditorDocument) => void): Disposable /** Rejects with `conflict`, `too_large` or `not_permitted` (read-only). */ applyEdit(edit: EditorEdit): Promise + /** Document sync in one call; resolves once theme, locale and the first document are in. */ + session(options?: EditorSessionOptions): Promise> + } + /** `decode`/`encode` default to plain text; a bytes editor passes both. */ + export type EditorSessionOptions = { + decode?: (content: EditorContent) => T + encode?: (value: T) => EditorContent + /** On a conflict: `base` is the last accepted value. Without it the outside document wins. */ + merge?: (base: T, outside: T, mine: T) => T + /** Wait after the last change before sending it; default 250. */ + debounceMs?: number + } + export type EditorSession = { + /** The latest value, including changes Orca has not accepted yet. */ + readonly value: T + readonly readOnly: boolean + /** Schedules an edit; one is in flight at a time and later changes coalesce. No-op read-only. */ + change(next: T): void + /** Sends now; rejects with the refusal's `code`. A refused draft is kept, except on a conflict without merge. */ + flush(): Promise + /** The file changed outside the editor, or a conflict merged; never your own accepted edits. */ + onReplace(handler: (value: T) => void): Disposable + dispose(): void } export type OrcaFrameApi = { readonly api: HostApiVersion @@ -77,7 +100,7 @@ declare module 'orca/frame' { readonly files?: { saveAs(options: SaveAsOptions): Promise<{ status: 'saved' | 'cancelled' }> } - /** Custom editors only. */ + /** Custom editors only. Orca keeps no undo history for them: Cmd/Ctrl+Z reaches the frame. */ readonly document?: EditorDocumentApi } diff --git a/skill-guides/orca-plugin-authoring/references/host-api.md b/skill-guides/orca-plugin-authoring/references/host-api.md index 65028ca7cd5..3c0c6272ef5 100644 --- a/skill-guides/orca-plugin-authoring/references/host-api.md +++ b/skill-guides/orca-plugin-authoring/references/host-api.md @@ -35,10 +35,11 @@ Host API 1.2 (`"pluginApi": 1`). Call `host.call(method, params, { signal })`; a - `secrets: SecretsApi`: The plugin's encrypted secrets (`secrets:own`). - `shell.openExternal(url: string)`: `reason`: the person cancelled, or Orca's window was hidden so nobody could confirm. - `files.saveAs(options: SaveAsOptions)`: Desktop isolated frames only; `cancelled` when the person closes the dialog. -- `document?: EditorDocumentApi`: Custom editors only. +- `document?: EditorDocumentApi`: Custom editors only. Orca keeps no undo history for them: Cmd/Ctrl+Z reaches the frame. - `document.get()`: The latest document, with your accepted edits' content at the same version. - `document.onDidChange(handler: (document: EditorDocument) => void)`: Replays the current document, then each one Orca sends when the file changes outside. - `document.applyEdit(edit: EditorEdit)`: Rejects with `conflict`, `too_large` or `not_permitted` (read-only). +- `document.session(options?: EditorSessionOptions)`: Document sync in one call; resolves once theme, locale and the first document are in. ## Methods diff --git a/src/cli/bundled-skill-guides.ts b/src/cli/bundled-skill-guides.ts index f51031175ca..088b40e4ebb 100644 --- a/src/cli/bundled-skill-guides.ts +++ b/src/cli/bundled-skill-guides.ts @@ -2907,10 +2907,11 @@ const ORCA_PLUGIN_AUTHORING_HOST_API_REFERENCE_MARKDOWN = [ "- `secrets: SecretsApi`: The plugin's encrypted secrets (`secrets:own`).", "- `shell.openExternal(url: string)`: `reason`: the person cancelled, or Orca's window was hidden so nobody could confirm.", "- `files.saveAs(options: SaveAsOptions)`: Desktop isolated frames only; `cancelled` when the person closes the dialog.", - "- `document?: EditorDocumentApi`: Custom editors only.", + "- `document?: EditorDocumentApi`: Custom editors only. Orca keeps no undo history for them: Cmd/Ctrl+Z reaches the frame.", "- `document.get()`: The latest document, with your accepted edits' content at the same version.", "- `document.onDidChange(handler: (document: EditorDocument) => void)`: Replays the current document, then each one Orca sends when the file changes outside.", "- `document.applyEdit(edit: EditorEdit)`: Rejects with `conflict`, `too_large` or `not_permitted` (read-only).", + "- `document.session(options?: EditorSessionOptions)`: Document sync in one call; resolves once theme, locale and the first document are in.", "", "## Methods", "", diff --git a/src/cli/plugin-authoring/plugin-editor-template.test.ts b/src/cli/plugin-authoring/plugin-editor-template.test.ts index 3b2a8865a27..a979358f615 100644 --- a/src/cli/plugin-authoring/plugin-editor-template.test.ts +++ b/src/cli/plugin-authoring/plugin-editor-template.test.ts @@ -1,39 +1,47 @@ // @vitest-environment happy-dom -import { describe, expect, it } from 'vitest' -import { - flush, - mountShell -} from '../../shared/plugins/frames/plugin-frame-shell-mount.test-fixture' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mountShell } from '../../shared/plugins/frames/plugin-frame-shell-mount.test-fixture' import { editor } from './plugin-frame-templates' +const hello = { orca: 1, kind: 'evt', event: 'hello', payload: { locale: 'en' } } const doc = (version: number, text: string) => ({ orca: 1, kind: 'evt', event: 'editor.document', payload: { version, readOnly: false, content: { kind: 'text', text } } }) +const settle = () => vi.advanceTimersByTimeAsync(0) -function mountEditorTemplate() { +async function mountEditorTemplate() { const html = editor({ id: 'notes', name: 'Notes' }).files['editor/index.html'] document.body.innerHTML = html.match(/([\s\S]*?) diff --git a/src/renderer/src/components/editor/plugin-custom-editor-host.test.ts b/src/renderer/src/components/editor/plugin-custom-editor-host.test.ts index 6ec43686c58..acf873b32b0 100644 --- a/src/renderer/src/components/editor/plugin-custom-editor-host.test.ts +++ b/src/renderer/src/components/editor/plugin-custom-editor-host.test.ts @@ -421,6 +421,24 @@ describe('custom editor document channel', () => { await expect(flushed).resolves.toBe(false) }) + it('commits a held edit when a save starts, and every edit during the save at once', async () => { + const { host, onEdit, send, answers, answerFlushes } = setup({ text: 'a', maxEdits: 1 }) + send(READY) + send(edit('b', 1, 22)) + send(edit('c', 1, 23)) + expect(answers(23)).toEqual([]) + const flushed = host.flush() + // The frame answers a save only once its edits settle, so the held one commits first. + expect(onEdit).toHaveBeenLastCalledWith('c') + expect(answers(23)).toEqual([{ orca: 1, kind: 'res', id: 23, result: null }]) + send(edit('d', 1, 24)) + expect(onEdit).toHaveBeenLastCalledWith('d') + answerFlushes() + await expect(flushed).resolves.toBe(true) + send(edit('e', 1, 25)) + expect(answers(25)).toEqual([]) + }) + it('never spends the edit budget on chords or flush replies', () => { const { onEdit, send } = setup({ text: 'a', maxEdits: 1 }) send(READY) diff --git a/src/renderer/src/components/editor/plugin-custom-editor-host.ts b/src/renderer/src/components/editor/plugin-custom-editor-host.ts index efe4685d7a7..08f742975e1 100644 --- a/src/renderer/src/components/editor/plugin-custom-editor-host.ts +++ b/src/renderer/src/components/editor/plugin-custom-editor-host.ts @@ -237,7 +237,8 @@ export function createCustomEditorHost(options: CustomEditorHostOptions): Custom now(), structuredCloneMessageBytes(data, CUSTOM_EDITOR_DOCUMENT_MAX_BYTES) ) - if (refusal) { + // While a save waits, the edits it asked for commit at once; the rate limit resumes after. + if (refusal === 'oversized' || (refusal && pendingFlushes.size === 0)) { refuseEdit(data, refusal) return true } @@ -272,9 +273,10 @@ export function createCustomEditorHost(options: CustomEditorHostOptions): Custom if (!ready) { return Promise.resolve(!lostEdit) } + // Drain first: the frame answers only once its edits settle, and a held one would not. + applyHeldEdit() // A read-only document takes no edits, and a frame without a window cannot answer. if (options.getDocument().readOnly || !options.getFrameWindow()) { - applyHeldEdit() return Promise.resolve(!lostEdit) } const requestId = nextFlushId++ diff --git a/src/shared/plugins/frames/plugin-frame-document-session.test.ts b/src/shared/plugins/frames/plugin-frame-document-session.test.ts new file mode 100644 index 00000000000..02235ca1425 --- /dev/null +++ b/src/shared/plugins/frames/plugin-frame-document-session.test.ts @@ -0,0 +1,425 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + mountShell, + type FrameDocumentSessionOptions, + type FrameHostMessage +} from './plugin-frame-shell-mount.test-fixture' + +type Content = { kind: 'text'; text: string } | { kind: 'bytes'; bytes: ArrayBuffer } + +const hello = { + orca: 1, + kind: 'evt', + event: 'hello', + payload: { theme: { colorScheme: 'dark', tokens: '' }, locale: 'en' } +} +const doc = (version: number, text: string, readOnly = false) => ({ + orca: 1, + kind: 'evt', + event: 'editor.document', + payload: { version, readOnly, content: { kind: 'text', text } } +}) +const bytesDoc = (version: number, bytes: number[]) => ({ + orca: 1, + kind: 'evt', + event: 'editor.document', + payload: { + version, + readOnly: false, + content: { kind: 'bytes', bytes: new Uint8Array(bytes).buffer } + } +}) +const accept = (id: number) => ({ orca: 1, kind: 'res', id, result: null }) +const refuse = (id: number, code: string) => ({ + orca: 1, + kind: 'res', + id, + error: { code, message: code } +}) +const settle = () => vi.advanceTimersByTimeAsync(0) + +function isContent(value: unknown): value is Content { + if (typeof value !== 'object' || value === null || !('kind' in value)) { + return false + } + return value.kind === 'text' + ? 'text' in value && typeof value.text === 'string' + : value.kind === 'bytes' && 'bytes' in value && value.bytes instanceof ArrayBuffer +} + +/** The content an `editor.applyEdit` message carries. */ +function sentContent(message: Record | undefined): Content { + const params = message?.params + const content = + typeof params === 'object' && params !== null && 'content' in params ? params.content : null + if (!isContent(content)) { + throw new Error('not an editor edit') + } + return content +} +const sentText = (message: Record): string | undefined => { + const content = sentContent(message) + return content.kind === 'text' ? content.text : undefined +} +const sentBytes = (message: Record | undefined): number[] => { + const content = sentContent(message) + return content.kind === 'bytes' ? [...new Uint8Array(content.bytes)] : [] +} + +async function open(options?: FrameDocumentSessionOptions, first: FrameHostMessage = doc(1, 'a')) { + const shell = mountShell('editor') + fromHostAll(shell.fromHost, [hello, first]) + const session = await shell.orca.document!.session(options) + const replaced = vi.fn() + session.onReplace(replaced) + const edits = () => shell.posted().filter((message) => message.method === 'editor.applyEdit') + return { ...shell, session, replaced, edits } +} + +function fromHostAll(fromHost: (data: unknown) => unknown, messages: unknown[]): void { + messages.forEach((message) => fromHost(message)) +} + +beforeEach(() => { + vi.useFakeTimers() +}) +afterEach(() => { + vi.useRealTimers() +}) + +describe('orca.document.session', () => { + it('is feature-detectable and resolves only after hello and the first document', async () => { + const { orca, fromHost } = mountShell('editor') + expect('session' in orca.document!).toBe(true) + const started = vi.fn() + void orca.document!.session().then(started) + fromHost(doc(1, 'a')) + await settle() + expect(started).not.toHaveBeenCalled() + fromHost(hello) + await settle() + expect(started).toHaveBeenCalledWith(expect.objectContaining({ value: 'a', readOnly: false })) + }) + + it('coalesces changes in the debounce window into one edit', async () => { + const { session, edits } = await open() + session.change('b') + session.change('bc') + await vi.advanceTimersByTimeAsync(249) + expect(edits()).toHaveLength(0) + await vi.advanceTimersByTimeAsync(1) + expect(edits()).toEqual([ + expect.objectContaining({ params: { baseVersion: 1, content: { kind: 'text', text: 'bc' } } }) + ]) + expect(session.value).toBe('bc') + }) + + it('keeps one edit in flight and sends the newest change once it settles', async () => { + const { session, edits, fromHost } = await open({ debounceMs: 0 }) + session.change('b') + await settle() + session.change('c') + session.change('d') + await settle() + expect(edits()).toHaveLength(1) + fromHost(accept(0)) + await settle() + expect(edits().map(sentText)).toEqual(['b', 'd']) + }) + + it('moves the baseline only on accept and keeps a refused draft', async () => { + const { session, fromHost, edits } = await open({ debounceMs: 0 }) + session.change('b') + const flushed = session.flush() + fromHost(refuse(0, 'too_large')) + await expect(flushed).rejects.toMatchObject({ code: 'too_large' }) + expect(session.value).toBe('b') + const retried = session.flush() + expect(edits()).toHaveLength(2) + fromHost(accept(1)) + await expect(retried).resolves.toBeUndefined() + await expect(session.flush()).resolves.toBeUndefined() + expect(edits()).toHaveLength(2) + }) + + it('never reports its own accepted edits as replacements', async () => { + const { session, fromHost, replaced, edits } = await open({ debounceMs: 0 }) + session.change('b') + await settle() + fromHost(accept(0)) + await settle() + // The same text again, at a new version (a read-only flip would look like this). + fromHost(doc(2, 'b')) + expect(replaced).not.toHaveBeenCalled() + fromHost(doc(3, 'outside')) + expect(replaced).toHaveBeenCalledWith('outside') + expect(session.value).toBe('outside') + session.change('next') + await settle() + expect(edits().at(-1)).toMatchObject({ params: { baseVersion: 3 } }) + }) + + it('merges a conflict once against the last accepted value and resubmits', async () => { + const merge = vi.fn( + (base: string, outside: string, mine: string) => `${base}|${outside}|${mine}` + ) + const { session, fromHost, replaced, edits } = await open({ debounceMs: 0, merge }) + session.change('x') + await settle() + fromHost(accept(0)) + await settle() + session.change('mine') + fromHost(doc(2, 'outside')) + await settle() + expect(replaced).not.toHaveBeenCalled() + fromHost(refuse(1, 'conflict')) + await settle() + expect(merge).toHaveBeenCalledTimes(1) + expect(merge).toHaveBeenCalledWith('x', 'outside', 'mine') + expect(replaced).toHaveBeenCalledWith('x|outside|mine') + expect(edits().at(-1)).toMatchObject({ + params: { baseVersion: 2, content: { kind: 'text', text: 'x|outside|mine' } } + }) + }) + + it('lets the outside document win a conflict without merge', async () => { + const { session, fromHost, replaced, edits } = await open({ debounceMs: 0 }) + session.change('mine') + fromHost(doc(2, 'outside')) + const flushed = session.flush() + fromHost(refuse(0, 'conflict')) + await expect(flushed).rejects.toMatchObject({ code: 'conflict' }) + expect(session.value).toBe('outside') + expect(replaced).toHaveBeenCalledWith('outside') + // The draft is gone, so nothing overwrites the outside write. + await expect(session.flush()).resolves.toBeUndefined() + expect(edits()).toHaveLength(1) + session.change('next') + await settle() + expect(edits().at(-1)).toMatchObject({ params: { baseVersion: 2 } }) + }) + + it('answers editor.flush only once the change it posted is accepted', async () => { + const { session, fromHost, posted } = await open() + session.change('b') + const flushRequest = { orca: 1, kind: 'req', id: 9, method: 'editor.flush' } + fromHost(flushRequest) + await settle() + expect(posted().at(-1)).toMatchObject({ method: 'editor.applyEdit' }) + fromHost(accept(0)) + await settle() + expect(posted().at(-1)).toEqual({ orca: 1, kind: 'res', id: 9, result: null }) + }) + + it('keeps one edit in flight through a save, so the baseline never goes back', async () => { + const { orca, session, fromHost, edits, posted } = await open({ debounceMs: 0 }) + session.change('a1') + await settle() + // `a1` is held by Orca's rate limit; the save must not post `b` beside it. + session.change('b') + fromHost({ orca: 1, kind: 'req', id: 9, method: 'editor.flush' }) + await settle() + expect(edits()).toHaveLength(1) + fromHost(accept(0)) + await settle() + expect(edits().at(-1)).toMatchObject({ params: { content: { text: 'b' } } }) + fromHost(accept(1)) + await settle() + expect(posted().at(-1)).toMatchObject({ kind: 'res', id: 9, result: null }) + await expect(orca.document!.get()).resolves.toMatchObject({ content: { text: 'b' } }) + }) + + it('answers a save only after a conflict merge commits', async () => { + const merge = (_base: string, outside: string, mine: string) => `${outside}+${mine}` + const { session, fromHost, edits, posted } = await open({ debounceMs: 0, merge }) + session.change('mine') + await settle() + fromHost(doc(2, 'outside')) + fromHost({ orca: 1, kind: 'req', id: 9, method: 'editor.flush' }) + fromHost(refuse(0, 'conflict')) + await settle() + expect(edits().at(-1)).toMatchObject({ params: { content: { text: 'outside+mine' } } }) + expect(posted().some((message) => message.kind === 'res' && message.id === 9)).toBe(false) + fromHost(accept(1)) + await settle() + expect(posted().at(-1)).toMatchObject({ kind: 'res', id: 9, result: null }) + }) + + it('counts a reused buffer as a new change and sends what it held when sent', async () => { + const buffer = new Uint8Array([3]) + const { session, fromHost, edits } = await open( + { + debounceMs: 0, + decode: () => buffer, + encode: (value: Uint8Array) => ({ kind: 'bytes', bytes: value.buffer }) + }, + { + ...doc(1, ''), + payload: { + version: 1, + readOnly: false, + content: { kind: 'bytes', bytes: new ArrayBuffer(1) } + } + } + ) + session.change(buffer) + await settle() + buffer[0] = 4 + session.change(buffer) + fromHost(accept(0)) + await settle() + const sent = edits().map(sentBytes) + expect(sent).toEqual([[3], [4]]) + }) + + it('rejects a waiting flush with conflict when the outside document wins', async () => { + const { session, fromHost } = await open({ debounceMs: 0 }) + session.change('mine') + await settle() + const flushed = session.flush() + fromHost(doc(2, 'outside')) + fromHost(refuse(0, 'conflict')) + await expect(flushed).rejects.toMatchObject({ code: 'conflict' }) + expect(session.value).toBe('outside') + }) + + it('keeps a flush going until a change typed during it commits too', async () => { + const { session, fromHost, edits } = await open() + session.change('b') + const flushed = vi.fn() + void session.flush().then(flushed) + session.change('c') + fromHost(accept(0)) + await settle() + expect(flushed).not.toHaveBeenCalled() + expect(edits().map(sentText)).toEqual(['b', 'c']) + fromHost(accept(1)) + await settle() + expect(flushed).toHaveBeenCalled() + }) + + it('rejects a waiting flush when a conflict drops a newer draft too', async () => { + const { session, fromHost } = await open({ debounceMs: 0 }) + session.change('mine') + await settle() + session.change('mine, more') + const flushed = session.flush() + fromHost(doc(2, 'outside')) + fromHost(refuse(0, 'conflict')) + await expect(flushed).rejects.toMatchObject({ code: 'conflict' }) + expect(session.value).toBe('outside') + }) + + it("keeps merge's base apart from a value buffer reused after it was accepted", async () => { + const merge = vi.fn((base: Uint8Array, outside: Uint8Array, mine: Uint8Array) => [ + ...base, + ...outside, + ...mine + ]) + const buffer = new Uint8Array([1]) + const { session, fromHost } = await open( + { + debounceMs: 0, + merge, + decode: (content: Content) => + content.kind === 'bytes' ? new Uint8Array(content.bytes) : new Uint8Array(), + encode: (value: Uint8Array | number[]) => ({ + kind: 'bytes', + bytes: Uint8Array.from(value).buffer + }) + }, + bytesDoc(1, [0]) + ) + session.change(buffer) + await settle() + fromHost(accept(0)) + await settle() + buffer[0] = 9 + session.change(buffer) + await settle() + fromHost(bytesDoc(2, [5])) + fromHost(refuse(1, 'conflict')) + await settle() + expect([...(merge.mock.calls[0]?.[0] ?? [])]).toEqual([1]) + }) + + it('neither merges nor sends after dispose', async () => { + const merge = vi.fn((_base: string, outside: string) => outside) + const { session, fromHost, edits } = await open({ debounceMs: 0, merge }) + session.change('mine') + await settle() + fromHost(doc(2, 'outside')) + session.dispose() + fromHost(refuse(0, 'conflict')) + await settle() + expect(merge).not.toHaveBeenCalled() + expect(edits()).toHaveLength(1) + }) + + it('resends a kept draft on editor.flush, but not one still in flight', async () => { + const { session, fromHost, edits } = await open({ debounceMs: 0 }) + session.change('b') + await settle() + fromHost({ orca: 1, kind: 'req', id: 8, method: 'editor.flush' }) + await settle() + expect(edits()).toHaveLength(1) + fromHost(refuse(0, 'too_large')) + await settle() + fromHost({ orca: 1, kind: 'req', id: 9, method: 'editor.flush' }) + await settle() + expect(edits()).toHaveLength(2) + expect(session.value).toBe('b') + }) + + it('ignores changes while read-only', async () => { + const { session, edits } = await open({}, doc(1, 'a', true)) + expect(session.readOnly).toBe(true) + session.change('b') + await vi.advanceTimersByTimeAsync(500) + expect(session.value).toBe('a') + expect(edits()).toHaveLength(0) + }) + + it('needs decode and encode for bytes, and sends what encode returns', async () => { + const bytesDoc = (version: number, bytes: number[]) => ({ + orca: 1, + kind: 'evt', + event: 'editor.document', + payload: { + version, + readOnly: false, + content: { kind: 'bytes', bytes: new Uint8Array(bytes).buffer } + } + }) + const shell = mountShell('editor') + fromHostAll(shell.fromHost, [hello, bytesDoc(1, [1, 2])]) + await expect(shell.orca.document!.session()).rejects.toThrow(/decode and encode/) + const session = await shell.orca.document!.session({ + debounceMs: 0, + decode: (content: Content) => + content.kind === 'bytes' ? [...new Uint8Array(content.bytes)] : [], + encode: (value: number[]) => ({ kind: 'bytes', bytes: new Uint8Array(value).buffer }) + }) + expect(session.value).toEqual([1, 2]) + session.change([3]) + await settle() + const edit = shell.posted().find((message) => message.method === 'editor.applyEdit') + expect(sentBytes(edit)).toEqual([3]) + }) + + it('stops sending and reporting after dispose', async () => { + const { session, fromHost, replaced, edits, posted } = await open({ debounceMs: 0 }) + session.change('b') + session.dispose() + await settle() + session.change('c') + fromHost(doc(2, 'outside')) + fromHost({ orca: 1, kind: 'req', id: 4, method: 'editor.flush' }) + await settle() + expect(edits()).toHaveLength(0) + expect(replaced).not.toHaveBeenCalled() + expect(posted().at(-1)).toEqual({ orca: 1, kind: 'res', id: 4, result: null }) + }) +}) diff --git a/src/shared/plugins/frames/plugin-frame-shell-mount.test-fixture.ts b/src/shared/plugins/frames/plugin-frame-shell-mount.test-fixture.ts index c06d8d27e82..2aa2e390f2c 100644 --- a/src/shared/plugins/frames/plugin-frame-shell-mount.test-fixture.ts +++ b/src/shared/plugins/frames/plugin-frame-shell-mount.test-fixture.ts @@ -24,9 +24,30 @@ export type FrameOrca = { get: () => Promise onDidChange: (handler: (document: unknown) => void) => { dispose: () => void } applyEdit: (edit: unknown) => Promise + session: (options?: FrameDocumentSessionOptions) => Promise } } +// Method syntax: a test passes typed callbacks, which method parameters accept bivariantly. +export type FrameDocumentSessionOptions = { + debounceMs?: number + decode?(content: unknown): unknown + encode?(value: unknown): unknown + merge?(base: unknown, outside: unknown, mine: unknown): unknown +} + +/** A host → frame envelope, as tests deliver it. */ +export type FrameHostMessage = { orca: number; kind: string } & Record + +export type FrameDocumentSession = { + readonly value: unknown + readonly readOnly: boolean + change: (next: unknown) => void + flush: () => Promise + onReplace: (handler: (value: unknown) => void) => { dispose: () => void } + dispose: () => void +} + const realParent = window.parent // Registered by importing: each test gets the real `window.parent` back. diff --git a/src/shared/plugins/frames/plugin-panel-shell-document.ts b/src/shared/plugins/frames/plugin-panel-shell-document.ts new file mode 100644 index 00000000000..22fdfe749d0 --- /dev/null +++ b/src/shared/plugins/frames/plugin-panel-shell-document.ts @@ -0,0 +1,205 @@ +/** + * Part of the frame shell's IIFE (plugin-panel-shell.ts): an editor's `orca.document`, including + * `session()`, the document sync every editor otherwise rebuilds. Uses the shell's `call`, `on`, + * `latest`, `waiting`, `starting` and `helloSeen`. + * + * The baseline (`base`, `baseVersion`, `baseContent`) moves only when Orca accepts an edit or sends + * a document while nothing is unaccepted, so a refused edit leaves a draft that is still dirty + * (except a conflict without merge, where the outside document replaces it). + */ +export const PLUGIN_FRAME_DOCUMENT_SCRIPT = ` + function start() { + if (helloSeen && latest) starting.splice(0).forEach(function (resolve) { resolve(latest) }) + } + // Resolves with the latest document once hello brought theme and locale. + function whenStarted() { + return helloSeen && latest ? Promise.resolve(latest) : new Promise(function (resolve) { starting.push(resolve) }) + } + function applyEdit(edit) { + var base = edit && edit.baseVersion + var content = edit && edit.content ? Object.assign({}, edit.content) : null + // Copied: the caller may reuse its buffer, and get() must keep what Orca accepted. + if (content && content.bytes instanceof ArrayBuffer) content.bytes = content.bytes.slice(0) + return call(C.editor.applyEdit, edit).then(function (result) { + // Accepted edits keep their version, so get() takes the content without a new version. + if (latest && content && latest.version === base) { + latest = Object.freeze(Object.assign({}, latest, { content: Object.freeze(content) })) + } + return result + }) + } + if (C.surface === 'editor') { + orca.document = Object.freeze({ + get: function () { + return latest ? Promise.resolve(latest) : new Promise(function (resolve) { waiting.push(resolve) }) + }, + // Why replay: editors often subscribe after their bundle loads, when the first document is in. + onDidChange: function (fn) { + var subscription = on(E.editorDocument, fn) + var current = latest + var live = true + if (current) Promise.resolve().then(function () { if (live && latest === current) fn(current) }) + return Object.freeze({ dispose: function () { live = false; subscription.dispose() } }) + }, + applyEdit: applyEdit, + session: documentSession + }) + } + function sameContent(a, b) { + if (!a || !b || a.kind !== b.kind) return false + if (a.kind === 'text') return a.text === b.text + if (!(a.bytes instanceof ArrayBuffer) || !(b.bytes instanceof ArrayBuffer)) return false + if (a.bytes.byteLength !== b.bytes.byteLength) return false + var x = new Uint8Array(a.bytes), y = new Uint8Array(b.bytes) + for (var i = 0; i < x.length; i++) if (x[i] !== y[i]) return false + return true + } + function decodeText(content) { + if (content.kind !== 'text') throw new TypeError('a bytes document needs session decode and encode') + return content.text + } + function encodeText(value) { return { kind: 'text', text: String(value) } } + function documentSession(options) { + var o = options || {} + var decode = typeof o.decode === 'function' ? o.decode : decodeText + var encode = typeof o.encode === 'function' ? o.encode : encodeText + var merge = typeof o.merge === 'function' ? o.merge : null + // Why 250: the host takes 60 edits per 10 s, and typing must stay under that. + var debounceMs = typeof o.debounceMs === 'number' && o.debounceMs >= 0 ? o.debounceMs : 250 + return whenStarted().then(function (first) { + var doc = first + var base = decode(first.content) + var baseContent = first.content + var baseVersion = first.version + var value = base + var dirty = false + // Bumped by change() only, so a reused object or buffer still counts as a new change. + var generation = 0 + var sentGeneration = 0 + // Bumped when a conflict drops the draft for the outside document; a waiting flush rejects. + var discards = 0 + var inFlight = null + var timer = null + var disposed = false + var replaced = new Set() + function emitReplace(next) { + Array.from(replaced).forEach(function (fn) { + try { fn(next) } catch (e) { console.error(e) } + }) + } + function clearTimer() { if (timer !== null) { clearTimeout(timer); timer = null } } + function rebase(next) { + doc = next + base = decode(next.content) + baseContent = next.content + baseVersion = next.version + return base + } + function refused(error) { + if (disposed || !error || error.code !== 'conflict' || doc.version === baseVersion) throw error + var previous = base + var mine = value + var outside = rebase(doc) + // Without merge the outside document wins: an agent's write must never be lost silently. + if (!merge) { + value = outside + dirty = false + discards += 1 + emitReplace(outside) + throw error + } + value = merge(previous, outside, mine) + emitReplace(value) + return send() + } + function snapshot(content) { + return content && content.bytes instanceof ArrayBuffer + ? { kind: content.kind, bytes: content.bytes.slice(0) } + : content + } + // One edit in flight at a time: every send waits for the last to settle. + function send() { + clearTimer() + if (disposed) return Promise.resolve() + var sent = value + var content + try { content = snapshot(encode(sent)) } catch (e) { return Promise.reject(e) } + sentGeneration = generation + var mine = sentGeneration + var attempt = applyEdit({ baseVersion: baseVersion, content: content }).then(function () { + // Decoded from the sent snapshot, so a reused value buffer cannot change merge's base. + base = decode(content) + baseContent = content + if (generation === mine) dirty = false + }, refused) + inFlight = attempt + attempt.then(settled, settled) + function settled() { + if (inFlight === attempt) inFlight = null + if (!disposed && dirty && generation !== sentGeneration && timer === null && inFlight === null) { + send().catch(function () {}) + } + } + return attempt + } + // Settles once every change so far is accepted, or refused with nothing newer to send. + function flush() { + clearTimer() + if (disposed) return Promise.resolve() + var current = inFlight || (dirty && !doc.readOnly ? send() : null) + if (!current) return Promise.resolve() + var waited = sentGeneration + var discarded = discards + // Keeps draining: a change made while this edit was in flight must commit too. + return current.then(function () { + return generation !== waited ? flush() : undefined + }, function (error) { + if (!disposed && generation !== waited && discards === discarded) return flush() + throw error + }) + } + var documents = on(E.editorDocument, function (next) { + if (disposed || next.version === doc.version) return + doc = next + // Unaccepted work waits for its edit's conflict, where merge runs. + if (dirty || inFlight) return + if (sameContent(next.content, baseContent)) { baseVersion = next.version; return } + value = rebase(next) + emitReplace(value) + }) + // Orca drains its held edits during a save, so waiting here fits its deadline. + var flushes = on(C.editor.flush, function () { + return disposed || doc.readOnly ? undefined : flush() + }) + return Object.freeze({ + get value() { return value }, + get readOnly() { return doc.readOnly }, + change: function (next) { + if (disposed || doc.readOnly) return + value = next + generation += 1 + dirty = true + clearTimer() + timer = setTimeout(function () { + timer = null + if (!inFlight) send().catch(function () {}) + }, debounceMs) + }, + flush: function () { return flush().then(function () {}) }, + onReplace: function (fn) { + if (typeof fn !== 'function') throw new TypeError('onReplace needs a handler') + var entry = function (next) { fn(next) } + replaced.add(entry) + return Object.freeze({ dispose: function () { replaced.delete(entry) } }) + }, + dispose: function () { + disposed = true + clearTimer() + documents.dispose() + flushes.dispose() + replaced.clear() + } + }) + }) + } +` diff --git a/src/shared/plugins/frames/plugin-panel-shell.ts b/src/shared/plugins/frames/plugin-panel-shell.ts index 859999f358d..a3d166baabe 100644 --- a/src/shared/plugins/frames/plugin-panel-shell.ts +++ b/src/shared/plugins/frames/plugin-panel-shell.ts @@ -3,6 +3,7 @@ import { PLUGIN_ERROR_CODES, PLUGIN_PROTOCOL_VERSION } from './plugin-protocol' import { PLUGIN_DECLARATIVE_POST_GLOBAL, PLUGIN_FRAME_TRANSPORT_GLOBAL } from './plugin-frame-guest' import { PLUGIN_HOST_API_MAJOR } from '../workers/plugin-host-api-major' import { PLUGIN_FRAME_FACADES_SCRIPT } from './plugin-panel-shell-facades' +import { PLUGIN_FRAME_DOCUMENT_SCRIPT } from './plugin-panel-shell-document' import { PLUGIN_FRAME_USER_METHODS, pluginFrameMethodsFor, @@ -71,7 +72,10 @@ function frameApiScript(surface: PluginFrameSurface, transport: FrameTransport): var listeners = new Map() var api = Object.freeze(C.api) var methods = C.methods.slice() - var latest = null, waiting = [] + var latest = null + var waiting = [] + var helloSeen = false + var starting = [] var locale = null var send = null if (C.transportGlobal) { @@ -168,6 +172,8 @@ function frameApiScript(surface: PluginFrameSurface, transport: FrameTransport): if (Array.isArray(m.payload.methods)) methods = m.payload.methods.slice() if (typeof m.payload.locale === 'string') locale = m.payload.locale.slice(0, 64) applyTheme(m.payload.theme) + helloSeen = true + start() } else if (m.kind === 'evt' && m.event === E.theme) { applyTheme(m.payload) } else if (m.kind === 'evt' && m.event === E.push) { @@ -175,6 +181,7 @@ function frameApiScript(surface: PluginFrameSurface, transport: FrameTransport): } else if (m.kind === 'evt' && m.event === E.editorDocument && C.surface === 'editor') { latest = Object.freeze(m.payload) waiting.splice(0).forEach(function (resolve) { resolve(latest) }) + start() emit(E.editorDocument, latest) } else if (m.kind === 'evt') { emit(m.event, m.payload) @@ -216,33 +223,7 @@ ${PLUGIN_FRAME_FACADES_SCRIPT} if (C.user) { Object.defineProperty(orca, 'locale', { get: function () { return locale || navigator.language }, enumerable: true }) - if (C.surface === 'editor') { - orca.document = Object.freeze({ - get: function () { - return latest ? Promise.resolve(latest) : new Promise(function (resolve) { waiting.push(resolve) }) - }, - // Why replay: editors often subscribe after their bundle loads, when the first document is in. - onDidChange: function (fn) { - var subscription = on(E.editorDocument, fn) - var current = latest - var live = true - if (current) Promise.resolve().then(function () { if (live && latest === current) fn(current) }) - return Object.freeze({ dispose: function () { live = false; subscription.dispose() } }) - }, - applyEdit: function (edit) { - var base = edit && edit.baseVersion - var content = edit && edit.content ? Object.assign({}, edit.content) : null - // Copied: the caller may reuse its buffer, and get() must keep what Orca accepted. - if (content && content.bytes instanceof ArrayBuffer) content.bytes = content.bytes.slice(0) - return call(C.editor.applyEdit, edit).then(function (result) { - // Accepted edits keep their version, so get() takes the content without a new version. - if (latest && content && latest.version === base) { - latest = Object.freeze(Object.assign({}, latest, { content: Object.freeze(content) })) - } - return result - }) - } - }) +${PLUGIN_FRAME_DOCUMENT_SCRIPT} if (C.surface === 'editor') { window.addEventListener('keydown', function (event) { if (event.repeat || !(event.metaKey || event.ctrlKey)) return post({ kind: 'evt', event: E.editorKeydown, payload: {