mirror of
https://github.com/stablyai/orca.git
synced 2026-11-14 01:47:24 +00:00
* 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
322 lines
10 KiB
TypeScript
322 lines
10 KiB
TypeScript
import {
|
|
CUSTOM_EDITOR_CONFLICT,
|
|
CUSTOM_EDITOR_DOCUMENT_MAX_BYTES,
|
|
CUSTOM_EDITOR_EDIT_RATE_LIMIT,
|
|
CUSTOM_EDITOR_FLUSH_TIMEOUT_MS,
|
|
CUSTOM_EDITOR_TOO_LARGE,
|
|
isCustomEditorEditAttempt,
|
|
readCustomEditorEdit,
|
|
readCustomEditorKeyChord,
|
|
type CustomEditorDocument,
|
|
type CustomEditorEdit
|
|
} from '../../../../shared/plugins/frames/plugin-custom-editor-bridge'
|
|
import { EDITOR_METHODS, FRAME_EVENTS } from '../../../../shared/plugins/frames/plugin-frame-bridge'
|
|
import {
|
|
pluginError,
|
|
pluginEvent,
|
|
pluginFailure,
|
|
pluginRequest,
|
|
pluginRequestId,
|
|
pluginResult,
|
|
readPluginEnvelope,
|
|
type PluginEnvelope,
|
|
type PluginError
|
|
} from '../../../../shared/plugins/frames/plugin-protocol'
|
|
import {
|
|
createPanelMessageBudget,
|
|
structuredCloneMessageBytes
|
|
} from '../../../../shared/plugins/frames/plugin-panel-message-budget'
|
|
|
|
import type {
|
|
CustomEditorBinaryDocument,
|
|
CustomEditorEditRefusal,
|
|
CustomEditorHost,
|
|
CustomEditorHostOptions,
|
|
CustomEditorUnappliedEdit
|
|
} from './plugin-custom-editor-host-types'
|
|
|
|
export type {
|
|
CustomEditorBinaryDocument,
|
|
CustomEditorEditRefusal,
|
|
CustomEditorHost,
|
|
CustomEditorHostOptions,
|
|
CustomEditorUnappliedEdit
|
|
} from './plugin-custom-editor-host-types'
|
|
|
|
/** The frame only ever holds the document: Orca owns the buffer, dirty state and save. */
|
|
export function createCustomEditorHost(options: CustomEditorHostOptions): CustomEditorHost {
|
|
const budget =
|
|
options.budget ??
|
|
createPanelMessageBudget({
|
|
maxBytes: CUSTOM_EDITOR_DOCUMENT_MAX_BYTES,
|
|
...CUSTOM_EDITOR_EDIT_RATE_LIMIT
|
|
})
|
|
const now = options.now ?? (() => Date.now())
|
|
const flushTimeoutMs = options.flushTimeoutMs ?? CUSTOM_EDITOR_FLUSH_TIMEOUT_MS
|
|
let ready = false
|
|
// What the frame is known to hold, so its own edits are never echoed back.
|
|
let frameText: string | null = null
|
|
let frameReadOnly: boolean | null = null
|
|
let frameBinary: CustomEditorBinaryDocument | null = null
|
|
// The version of the last document sent; edits against an older one are refused.
|
|
let version = 0
|
|
let nextFlushId = 0
|
|
const pendingFlushes = new Map<number, (answered: boolean) => void>()
|
|
// The newest edit refused for the rate limit, unparsed until applied; full-text, so it wins.
|
|
let heldEdit: unknown = null
|
|
let heldId: number | null = null
|
|
// Held edits a newer one replaced; they get that edit's answer, so success means committed.
|
|
let supersededIds: (number | null)[] = []
|
|
let heldTimer: ReturnType<typeof setTimeout> | null = null
|
|
let lostEdit = false
|
|
// An oversized edit came after the held one, so applying the held one leaves it still lost.
|
|
let lostAfterHeld = false
|
|
let unapplied: CustomEditorUnappliedEdit = null
|
|
|
|
const reportUnapplied = (): void => {
|
|
const next = lostEdit ? 'lost' : heldEdit !== null ? 'held' : null
|
|
if (next !== unapplied) {
|
|
unapplied = next
|
|
options.onUnappliedEdit?.(next)
|
|
}
|
|
}
|
|
|
|
const post = (message: PluginEnvelope, transfer: Transferable[] = []) => {
|
|
// Opaque srcdoc origins require targetOrigin '*'.
|
|
options.getFrameWindow()?.postMessage(message, '*', transfer)
|
|
}
|
|
|
|
const answer = (id: number | null, error: PluginError | null): void => {
|
|
if (id !== null) {
|
|
post(error ? pluginFailure(id, error) : pluginResult(id, null))
|
|
}
|
|
}
|
|
|
|
const postDocument = (document: CustomEditorDocument, transfer: Transferable[] = []): void => {
|
|
post(pluginEvent(FRAME_EVENTS.editorDocument, document), transfer)
|
|
}
|
|
|
|
const pushBinaryDocument = (force: boolean, binary: CustomEditorBinaryDocument | null): void => {
|
|
if (!binary || (!force && binary === frameBinary)) {
|
|
return
|
|
}
|
|
frameBinary = binary
|
|
// Why a copy: transferring detaches the buffer, and a remounted frame needs the bytes again.
|
|
const bytes = binary.bytes.slice().buffer
|
|
version += 1
|
|
postDocument(
|
|
{
|
|
version,
|
|
readOnly: options.getDocument().readOnly,
|
|
fileType: binary.fileType,
|
|
content: { kind: 'bytes', bytes }
|
|
},
|
|
[bytes]
|
|
)
|
|
}
|
|
|
|
const pushDocument = (force: boolean): void => {
|
|
if (!ready) {
|
|
return
|
|
}
|
|
if (options.getBinaryDocument) {
|
|
pushBinaryDocument(force, options.getBinaryDocument())
|
|
return
|
|
}
|
|
const { text, readOnly } = options.getDocument()
|
|
if (!force && text === frameText && readOnly === frameReadOnly) {
|
|
return
|
|
}
|
|
frameText = text
|
|
frameReadOnly = readOnly
|
|
version += 1
|
|
postDocument({ version, readOnly, content: { kind: 'text', text } })
|
|
}
|
|
|
|
const applyEdit = ({ content, baseVersion }: CustomEditorEdit): PluginError | null => {
|
|
const binary = options.getBinaryDocument
|
|
if (!ready || (binary && !frameBinary)) {
|
|
return pluginError('unavailable', 'The document is not loaded yet.')
|
|
}
|
|
if (options.getDocument().readOnly) {
|
|
return pluginError('not_permitted', 'This document is read-only.')
|
|
}
|
|
if (content.kind !== (binary ? 'bytes' : 'text')) {
|
|
return pluginError(
|
|
'invalid_params',
|
|
`content.kind: this editor edits ${binary ? 'bytes' : 'text'}`
|
|
)
|
|
}
|
|
if (baseVersion !== version) {
|
|
return CUSTOM_EDITOR_CONFLICT
|
|
}
|
|
// A newer full document supersedes anything held back or lost.
|
|
lostEdit = false
|
|
reportUnapplied()
|
|
if (content.kind === 'bytes') {
|
|
frameBinary = { bytes: new Uint8Array(content.bytes), fileType: frameBinary?.fileType ?? '' }
|
|
options.onBytesEdit?.(frameBinary)
|
|
return null
|
|
}
|
|
if (content.text !== frameText) {
|
|
frameText = content.text
|
|
options.onEdit(content.text)
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Applies an admitted edit message and answers it. */
|
|
const receiveEdit = (data: unknown): void => {
|
|
const message = readPluginEnvelope(data)
|
|
const edit = message?.kind === 'req' ? readCustomEditorEdit(message.params) : null
|
|
const error = edit ? applyEdit(edit) : pluginError('invalid_params', 'malformed editor edit')
|
|
answer(pluginRequestId(data), error)
|
|
for (const id of supersededIds.splice(0)) {
|
|
answer(id, error)
|
|
}
|
|
}
|
|
|
|
const applyHeldEdit = (): void => {
|
|
if (heldTimer !== null) {
|
|
clearTimeout(heldTimer)
|
|
heldTimer = null
|
|
}
|
|
const held = heldEdit
|
|
const stillLost = lostAfterHeld
|
|
heldEdit = null
|
|
heldId = null
|
|
lostAfterHeld = false
|
|
if (held !== null) {
|
|
receiveEdit(held)
|
|
lostEdit ||= stillLost
|
|
}
|
|
reportUnapplied()
|
|
}
|
|
|
|
/** A newer full-document edit carries the held one's intent, so it shares that edit's answer. */
|
|
const supersedeHeldEdit = (): void => {
|
|
if (heldEdit !== null) {
|
|
supersededIds.push(heldId)
|
|
}
|
|
lostAfterHeld = false
|
|
heldEdit = null
|
|
heldId = null
|
|
}
|
|
|
|
const refuseEdit = (data: unknown, refusal: CustomEditorEditRefusal): void => {
|
|
options.onEditRefused?.(refusal)
|
|
if (refusal === 'oversized') {
|
|
// The held edit stays: an edit Orca refuses carries nothing to replace it with.
|
|
lostAfterHeld ||= heldEdit !== null
|
|
lostEdit = true
|
|
answer(pluginRequestId(data), CUSTOM_EDITOR_TOO_LARGE)
|
|
} else {
|
|
supersedeHeldEdit()
|
|
heldEdit = data
|
|
heldId = pluginRequestId(data)
|
|
heldTimer ??= setTimeout(applyHeldEdit, CUSTOM_EDITOR_EDIT_RATE_LIMIT.perMs)
|
|
}
|
|
reportUnapplied()
|
|
}
|
|
|
|
const settleFlushes = (): void => {
|
|
for (const settle of pendingFlushes.values()) {
|
|
settle(true)
|
|
}
|
|
pendingFlushes.clear()
|
|
}
|
|
|
|
return {
|
|
channel: {
|
|
claim(data) {
|
|
if (!isCustomEditorEditAttempt(data)) {
|
|
return false
|
|
}
|
|
// Budget before parsing so floods cannot spend unmetered validation work.
|
|
const refusal = budget.admit(
|
|
now(),
|
|
structuredCloneMessageBytes(data, CUSTOM_EDITOR_DOCUMENT_MAX_BYTES)
|
|
)
|
|
// 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
|
|
}
|
|
// An admitted edit is newer than any held one.
|
|
supersedeHeldEdit()
|
|
receiveEdit(data)
|
|
reportUnapplied()
|
|
return true
|
|
},
|
|
onReady() {
|
|
ready = true
|
|
pushDocument(true)
|
|
},
|
|
onEvent(event, context) {
|
|
const chord =
|
|
event.event === FRAME_EVENTS.editorKeydown
|
|
? readCustomEditorKeyChord(event.payload)
|
|
: null
|
|
if (chord) {
|
|
options.onKeyChord?.(chord, context)
|
|
}
|
|
},
|
|
onResponse(response) {
|
|
pendingFlushes.get(response.id)?.(response.error === undefined)
|
|
pendingFlushes.delete(response.id)
|
|
}
|
|
},
|
|
syncDocument() {
|
|
pushDocument(false)
|
|
},
|
|
flush() {
|
|
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()) {
|
|
return Promise.resolve(!lostEdit)
|
|
}
|
|
const requestId = nextFlushId++
|
|
return new Promise<boolean>((resolve) => {
|
|
const settle = (answered: boolean): void => {
|
|
pendingFlushes.delete(requestId)
|
|
// A save applies the newest held edit now instead of waiting out the rate limit.
|
|
applyHeldEdit()
|
|
resolve(answered && !lostEdit)
|
|
}
|
|
const timer = setTimeout(() => settle(false), flushTimeoutMs)
|
|
pendingFlushes.set(requestId, (answered) => {
|
|
clearTimeout(timer)
|
|
settle(answered)
|
|
})
|
|
post(pluginRequest(requestId, EDITOR_METHODS.flush))
|
|
})
|
|
},
|
|
currentText() {
|
|
return frameText ?? options.getDocument().text
|
|
},
|
|
reset() {
|
|
// Why apply, not drop: a view switch unmounts the frame, and a held edit is the newest content.
|
|
if (heldEdit !== null) {
|
|
applyHeldEdit()
|
|
}
|
|
ready = false
|
|
frameText = null
|
|
frameReadOnly = null
|
|
frameBinary = null
|
|
heldEdit = null
|
|
heldId = null
|
|
lostEdit = false
|
|
if (heldTimer !== null) {
|
|
clearTimeout(heldTimer)
|
|
heldTimer = null
|
|
}
|
|
reportUnapplied()
|
|
settleFlushes()
|
|
}
|
|
}
|
|
}
|