mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
feat(plugins): orca.document.session() for custom editors (#37285)
* feat(plugins): add orca.document.session() for custom editors One call replaces the document sync every editor rebuilds: decode/encode (plain text by default), optional merge, debounce. One edit in flight with later changes coalesced; the baseline moves only on accept; a refused edit keeps the draft and rejects flush(); own accepted edits never reach onReplace; a conflict merges once against the last accepted value and resubmits, or keeps the draft without merge. The session answers editor.flush itself and resolves after hello and the first document. The --template editor now uses it. Docs and typings state the session and the undo model (the editor owns undo). * fix(plugins): let the outside document win a session conflict without merge Agents write files while people edit them, so keeping the draft lost the agent's write on the next keystroke. Without merge the session now drops the draft, fires onReplace with the outside value and rejects flush() with conflict; editors that want both sides pass merge. * fix(plugins): serialize session edits through saves and settle before answering - One edit in flight at all times, saves included; editor.flush is answered only once every change is accepted or refused with nothing newer to send. - The editor host commits a held edit when a save starts and admits edits during the save at once, so waiting fits the 1 s deadline. - A change generation replaces identity checks, and sent bytes are snapshotted, so a reused buffer is never mistaken for an accepted edit. - A waiting flush keeps its conflict rejection when the outside document wins, and nothing merges or sends after dispose. * test(plugins): read session test edits through typed helpers * fix(plugins): drain session flushes, reject dropped drafts, snapshot the baseline - flush() keeps going until no change made during it is left unsent. - A waiting flush rejects with conflict whenever the outside document replaced a draft, newer ones included. - The accepted baseline is decoded from the sent snapshot, so a reused value buffer cannot change merge's base. * test(plugins): name the session test option and message types
This commit is contained in:
@@ -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? }
|
||||
|
||||
+24
-1
@@ -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<null>
|
||||
/** Document sync in one call; resolves once theme, locale and the first document are in. */
|
||||
session<T = string>(options?: EditorSessionOptions<T>): Promise<EditorSession<T>>
|
||||
}
|
||||
/** `decode`/`encode` default to plain text; a bytes editor passes both. */
|
||||
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. */
|
||||
merge?: (base: T, outside: T, mine: T) => T
|
||||
/** Wait after the last change before sending it; default 250. */
|
||||
debounceMs?: number
|
||||
}
|
||||
export type EditorSession<T> = {
|
||||
/** 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<void>
|
||||
/** 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<T>)`: Document sync in one call; resolves once theme, locale and the first document are in.
|
||||
|
||||
## Methods
|
||||
|
||||
|
||||
@@ -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<T>)`: Document sync in one call; resolves once theme, locale and the first document are in.",
|
||||
"",
|
||||
"## Methods",
|
||||
"",
|
||||
|
||||
@@ -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(/<body>([\s\S]*?)<script>/)?.[1] ?? ''
|
||||
const shell = mountShell('editor')
|
||||
// A block scopes the template's top-level consts to this mount.
|
||||
window.eval(`{${html.match(/<script>([\s\S]*?)<\/script>/)?.[1] ?? ''}}`)
|
||||
shell.fromHost(hello)
|
||||
shell.fromHost(doc(1, 'start'))
|
||||
await settle()
|
||||
const textarea = document.querySelector('textarea')!
|
||||
const notice = document.getElementById('notice')!
|
||||
const type = (value: string) => {
|
||||
textarea.value = value
|
||||
textarea.dispatchEvent(new Event('input'))
|
||||
}
|
||||
return { ...shell, textarea, notice, type }
|
||||
const edits = () => shell.posted().filter((message) => message.method === 'editor.applyEdit')
|
||||
return { ...shell, textarea, notice, type, edits }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('editor template', () => {
|
||||
it('keeps typing Orca has not accepted when the file changes outside', async () => {
|
||||
const { fromHost, textarea, type } = mountEditorTemplate()
|
||||
fromHost(doc(1, 'start'))
|
||||
await flush()
|
||||
const { fromHost, textarea, type } = await mountEditorTemplate()
|
||||
expect(textarea.value).toBe('start')
|
||||
type('start typed')
|
||||
fromHost(doc(2, 'outside'))
|
||||
@@ -41,54 +49,31 @@ describe('editor template', () => {
|
||||
})
|
||||
|
||||
it('follows outside changes once its edits are accepted', async () => {
|
||||
const { fromHost, textarea, type } = mountEditorTemplate()
|
||||
fromHost(doc(1, 'start'))
|
||||
await flush()
|
||||
const { fromHost, textarea, type, edits } = await mountEditorTemplate()
|
||||
type('mine')
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
expect(edits()).toHaveLength(1)
|
||||
fromHost({ orca: 1, kind: 'res', id: 0, result: null })
|
||||
await flush()
|
||||
await settle()
|
||||
fromHost(doc(2, 'outside'))
|
||||
expect(textarea.value).toBe('outside')
|
||||
})
|
||||
|
||||
it('shows a refused edit and clears it once an edit is accepted', async () => {
|
||||
const { fromHost, notice, posted, type } = mountEditorTemplate()
|
||||
fromHost(doc(1, 'start'))
|
||||
await flush()
|
||||
it('shows a refused edit once typing pauses, and clears it once an edit is accepted', async () => {
|
||||
const { fromHost, notice, type, edits } = await mountEditorTemplate()
|
||||
type('mine')
|
||||
fromHost({
|
||||
orca: 1,
|
||||
kind: 'res',
|
||||
id: 0,
|
||||
error: { code: 'conflict', message: 'The file changed outside this editor.' }
|
||||
})
|
||||
await flush()
|
||||
expect(notice.textContent).toBe('Not saved: The file changed outside this editor.')
|
||||
fromHost(doc(2, 'outside'))
|
||||
type('mine again')
|
||||
expect(posted().at(-1)).toMatchObject({ params: { baseVersion: 2 } })
|
||||
fromHost({ orca: 1, kind: 'res', id: 1, result: null })
|
||||
await flush()
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
fromHost({ orca: 1, kind: 'res', id: 0, error: { code: 'too_large', message: 'Too big.' } })
|
||||
await vi.advanceTimersByTimeAsync(250)
|
||||
// The pause check resends the kept draft and shows its refusal.
|
||||
fromHost({ orca: 1, kind: 'res', id: 1, error: { code: 'too_large', message: 'Too big.' } })
|
||||
await settle()
|
||||
expect(notice.textContent).toBe('Not saved: Too big.')
|
||||
type('min')
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
expect(edits().at(-1)).toMatchObject({ params: { content: { text: 'min' } } })
|
||||
fromHost({ orca: 1, kind: 'res', id: 2, result: null })
|
||||
await settle()
|
||||
expect(notice.textContent).toBe('')
|
||||
})
|
||||
|
||||
it('lets only the newest input settle dirty and the notice', async () => {
|
||||
const { fromHost, textarea, notice, type } = mountEditorTemplate()
|
||||
fromHost(doc(1, 'start'))
|
||||
await flush()
|
||||
type('A')
|
||||
type('AB')
|
||||
type('A')
|
||||
fromHost({ orca: 1, kind: 'res', id: 0, result: null })
|
||||
await flush()
|
||||
// The final `A` is still unanswered, so an outside change must not replace it.
|
||||
fromHost(doc(2, 'outside'))
|
||||
expect(textarea.value).toBe('A')
|
||||
fromHost({ orca: 1, kind: 'res', id: 2, error: { code: 'conflict', message: 'Changed.' } })
|
||||
await flush()
|
||||
// An older success arriving after a newer refusal keeps the notice.
|
||||
fromHost({ orca: 1, kind: 'res', id: 1, result: null })
|
||||
await flush()
|
||||
expect(notice.textContent).toBe('Not saved: Changed.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,35 +63,23 @@ export function editor(input: PluginTemplateInput): PluginTemplate {
|
||||
<script>
|
||||
const text = document.getElementById('text')
|
||||
const notice = document.getElementById('notice')
|
||||
let version = 0
|
||||
// True while the textarea holds typing Orca has not accepted yet.
|
||||
let dirty = false
|
||||
// Only the newest input's answer settles dirty and the notice; older ones are stale.
|
||||
let generation = 0
|
||||
// Orca sends the document now and again whenever the file changes outside the editor.
|
||||
orca.document.onDidChange((doc) => {
|
||||
version = doc.version
|
||||
// The session sends edits, keeps unaccepted typing, and answers Orca's save.
|
||||
orca.document.session().then((doc) => {
|
||||
text.value = doc.value
|
||||
text.readOnly = doc.readOnly
|
||||
if (!dirty && doc.content.kind === 'text' && text.value !== doc.content.text) {
|
||||
text.value = doc.content.text
|
||||
}
|
||||
})
|
||||
text.addEventListener('input', () => {
|
||||
dirty = true
|
||||
const current = ++generation
|
||||
orca.document
|
||||
.applyEdit({ baseVersion: version, content: { kind: 'text', text: text.value } })
|
||||
.then(
|
||||
() => {
|
||||
if (current !== generation) return
|
||||
dirty = false
|
||||
notice.textContent = ''
|
||||
},
|
||||
(error) => {
|
||||
// Your text stays; the next keystroke edits the latest document.
|
||||
if (current === generation) notice.textContent = 'Not saved: ' + error.message
|
||||
}
|
||||
)
|
||||
doc.onReplace((value) => (text.value = value))
|
||||
let check = 0
|
||||
text.addEventListener('input', () => {
|
||||
doc.change(text.value)
|
||||
clearTimeout(check)
|
||||
// Once typing pauses, show a refused edit; on a conflict the outside change replaces it.
|
||||
check = setTimeout(() => {
|
||||
doc.flush().then(
|
||||
() => (notice.textContent = ''),
|
||||
(error) => (notice.textContent = 'Not saved: ' + error.message)
|
||||
)
|
||||
}, 500)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
</body>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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++
|
||||
|
||||
@@ -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<string, unknown> | 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, unknown>): string | undefined => {
|
||||
const content = sentContent(message)
|
||||
return content.kind === 'text' ? content.text : undefined
|
||||
}
|
||||
const sentBytes = (message: Record<string, unknown> | 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 })
|
||||
})
|
||||
})
|
||||
@@ -24,9 +24,30 @@ export type FrameOrca = {
|
||||
get: () => Promise<unknown>
|
||||
onDidChange: (handler: (document: unknown) => void) => { dispose: () => void }
|
||||
applyEdit: (edit: unknown) => Promise<unknown>
|
||||
session: (options?: FrameDocumentSessionOptions) => Promise<FrameDocumentSession>
|
||||
}
|
||||
}
|
||||
|
||||
// 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<string, unknown>
|
||||
|
||||
export type FrameDocumentSession = {
|
||||
readonly value: unknown
|
||||
readonly readOnly: boolean
|
||||
change: (next: unknown) => void
|
||||
flush: () => Promise<void>
|
||||
onReplace: (handler: (value: unknown) => void) => { dispose: () => void }
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
const realParent = window.parent
|
||||
|
||||
// Registered by importing: each test gets the real `window.parent` back.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
`
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user