diff --git a/docs/imperative-history-control.md b/docs/imperative-history-control.md new file mode 100644 index 00000000..428b88af --- /dev/null +++ b/docs/imperative-history-control.md @@ -0,0 +1,60 @@ +# Imperative undo and redo control + +Status: `implemented_on_active_pr` + +Inkspan exposes a narrow history-control surface on `CwlEditorHandle` for hosts that replace the built-in toolbar, provide an application command palette, or map desktop menu actions to the editor. This active-PR contract is not protected-main authority until the implementing pull request is integrated. + +## Public handle contract + +`CwlEditorHandle` provides four boolean-returning operations: + +```ts +const handle = editorRef.current; + +if (handle?.canUndo()) { + handle.undo(); +} + +if (handle?.canRedo()) { + handle.redo(); +} +``` + +- `canUndo()` reports whether the currently registered editor history can undo. +- `undo()` asks that history implementation to undo and returns whether the command ran. +- `canRedo()` reports whether the currently registered editor history can redo. +- `redo()` asks that history implementation to redo and returns whether the command ran. + +A newly created editor with no history reports both capabilities as `false`. After a document-changing edit, undo capability becomes available. A successful undo exposes redo capability, and a successful redo restores undo capability when history remains. + +When the editor surface is read-only (`editable={false}`), all four history operations return `false` and no history entry is consumed. Restoring `editable={true}` exposes the same still-available history again. This read-only guard applies specifically to user-facing history commands; explicit host-control mutators such as `setValue()` and `restoreDocumentEnvelope()` retain their existing programmatic authority so a host can update a read-only presentation from its own trusted state transition. + +## Lifecycle safety + +A retained host handle must not keep a destroyed TipTap editor authoritative. Before an editor exists or after its editor has been destroyed, all four history methods return `false` without throwing. The same active-editor guard is used by the rest of the retained-handle read, mutation, envelope, revision, selection, and restore surface so stale destroyed-editor state cannot be presented as current Inkspan state. + +Hosts should still dispose of retained refs normally; fail-closed behavior is a defensive boundary, not a replacement for application lifecycle management. + +## Standalone and collaborative history + +Standalone `CwlEditor` delegates to the history commands already registered by Inkspan's TipTap/StarterKit configuration. `CollaborativeCwlEditor` delegates through the same public handle to the collaboration-aware history commands registered with Yjs. Inkspan does not re-enable a second StarterKit local history engine in collaboration mode. + +This means a host can wire one stable command surface without reaching into TipTap internals while preserving the history semantics selected by the active Inkspan editor configuration. + +## Authority boundary + +The four methods are local editor commands only. A successful `undo()` or `redo()` does **not** prove a durable save, actor identity, authorization, timestamp, audit event, persistence result, collaboration-provider acknowledgement, or server-side revision transition. Normal document-change callbacks and host persistence workflows remain responsible for whatever durable consequences the embedding application requires. + +Inkspan does not acquire transport, authentication, authorization, tenancy, durable persistence, retention, provider-room, model-use, or audit authority through this API. + +## Acceptance evidence + +The implementing branch is expected to prove: + +- initial, post-edit, post-undo, and post-redo capability transitions on a real editor; +- read-only history commands are inert without consuming history and become available again when editability is restored; +- no-active-editor and destroyed-editor fail-closed behavior; +- collaborative convergence through two in-memory `Y.Doc` editors without duplicate local history; +- unchanged package consumers, accessibility/browser evidence, Office evidence, security scans, and exact 100% owned-production statement/branch/function/line coverage. + +Until the branch reaches protected `main`, downstream products must treat this document as active-PR guidance rather than a shipped-version guarantee. diff --git a/src/collaboration/CollaborativeCwlEditor.test.tsx b/src/collaboration/CollaborativeCwlEditor.test.tsx index 5f4e3b43..a3e2d1d8 100644 --- a/src/collaboration/CollaborativeCwlEditor.test.tsx +++ b/src/collaboration/CollaborativeCwlEditor.test.tsx @@ -262,7 +262,7 @@ describe('CollaborativeCwlEditor contract', () => { }); describe('CollaborativeCwlEditor convergence and lifecycle', () => { - it('converges rich text, shares undo, merges concurrent edits, and preserves host-owned documents across remount', async () => { + it('converges rich text, shares imperative undo/redo, merges concurrent edits, and preserves host-owned documents across remount', async () => { const leftDocument = new Y.Doc(); const rightDocument = new Y.Doc(); let disconnect = connectDocuments(leftDocument, rightDocument); @@ -307,7 +307,18 @@ describe('CollaborativeCwlEditor convergence and lifecycle', () => { await waitFor(() => expect(rightRef.current!.getHTML()).toContain('Undo this sentence'), ); - act(() => leftRef.current!.getEditor()!.commands.undo()); + expect(leftRef.current!.canUndo()).toBe(true); + act(() => expect(leftRef.current!.undo()).toBe(true)); + await waitFor(() => + expect(rightRef.current!.getHTML()).not.toContain('Undo this sentence'), + ); + expect(leftRef.current!.canRedo()).toBe(true); + act(() => expect(leftRef.current!.redo()).toBe(true)); + await waitFor(() => + expect(rightRef.current!.getHTML()).toContain('Undo this sentence'), + ); + expect(leftRef.current!.canUndo()).toBe(true); + act(() => expect(leftRef.current!.undo()).toBe(true)); await waitFor(() => expect(rightRef.current!.getHTML()).not.toContain('Undo this sentence'), ); diff --git a/src/components/CwlEditorHistoryHandle.test.tsx b/src/components/CwlEditorHistoryHandle.test.tsx new file mode 100644 index 00000000..78304f30 --- /dev/null +++ b/src/components/CwlEditorHistoryHandle.test.tsx @@ -0,0 +1,167 @@ +import { act, render, waitFor } from '@testing-library/react'; +import { forwardRef, createRef, useRef } from 'react'; +import { describe, expect, it } from 'vitest'; +import type { CwlEditorHandle, EditorMode } from '../types.js'; +import { CwlEditor } from './CwlEditor.js'; +import { useEditorHandle } from './useEditorHandle.js'; + +const NullEditorHandleHarness = forwardRef((_, ref) => { + const modeRef = useRef('markdown'); + useEditorHandle(ref, null, modeRef); + return null; +}); + +describe('CwlEditor imperative history control', () => { + it('exposes host-safe undo/redo capability and execution on a real editor', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy()); + + const handle = editorRef.current!; + expect(typeof handle.canUndo).toBe('function'); + expect(typeof handle.undo).toBe('function'); + expect(typeof handle.canRedo).toBe('function'); + expect(typeof handle.redo).toBe('function'); + expect(handle.canUndo()).toBe(false); + expect(handle.canRedo()).toBe(false); + + await act(async () => { + handle.getEditor()!.chain().focus('end').insertContent(' updated').run(); + }); + expect(handle.getMarkdown()).toBe('Original updated'); + expect(handle.canUndo()).toBe(true); + expect(handle.canRedo()).toBe(false); + + await act(async () => { + expect(handle.undo()).toBe(true); + }); + expect(handle.getMarkdown()).toBe('Original'); + expect(handle.canUndo()).toBe(false); + expect(handle.canRedo()).toBe(true); + + await act(async () => { + expect(handle.redo()).toBe(true); + }); + expect(handle.getMarkdown()).toBe('Original updated'); + expect(handle.canUndo()).toBe(true); + expect(handle.canRedo()).toBe(false); + }); + + it('keeps imperative history inert while read-only without consuming it', async () => { + const editorRef = createRef(); + const { rerender } = render( + , + ); + await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy()); + + const handle = editorRef.current!; + await act(async () => { + handle.getEditor()!.chain().focus('end').insertContent(' updated').run(); + }); + expect(handle.getMarkdown()).toBe('Original updated'); + expect(handle.canUndo()).toBe(true); + + rerender(); + await waitFor(() => expect(handle.getEditor()?.isEditable).toBe(false)); + + expect(handle.canUndo()).toBe(false); + expect(handle.undo()).toBe(false); + expect(handle.canRedo()).toBe(false); + expect(handle.redo()).toBe(false); + expect(handle.getMarkdown()).toBe('Original updated'); + + rerender(); + await waitFor(() => expect(handle.getEditor()?.isEditable).toBe(true)); + + expect(handle.canUndo()).toBe(true); + await act(async () => { + expect(handle.undo()).toBe(true); + }); + expect(handle.getMarkdown()).toBe('Original'); + expect(handle.canRedo()).toBe(true); + }); + + it('fails closed when the shared handle has no active editor instance', () => { + const editorRef = createRef(); + render(); + + const handle = editorRef.current!; + expect(typeof handle.canUndo).toBe('function'); + expect(typeof handle.undo).toBe('function'); + expect(typeof handle.canRedo).toBe('function'); + expect(typeof handle.redo).toBe('function'); + expect(handle.canUndo()).toBe(false); + expect(handle.undo()).toBe(false); + expect(handle.canRedo()).toBe(false); + expect(handle.redo()).toBe(false); + }); + + it('fails closed on a retained handle after its editor is destroyed', async () => { + const editorRef = createRef(); + render(); + await waitFor(() => expect(editorRef.current?.getEditor()).toBeTruthy()); + + const handle = editorRef.current!; + const editor = handle.getEditor()!; + await act(async () => { + editor.chain().focus('end').insertContent(' updated').run(); + }); + expect(handle.canUndo()).toBe(true); + + act(() => editor.destroy()); + expect(editor.isDestroyed).toBe(true); + expect(handle.getEditor()).toBeNull(); + expect(handle.canUndo()).toBe(false); + expect(handle.undo()).toBe(false); + expect(handle.canRedo()).toBe(false); + expect(handle.redo()).toBe(false); + + expect(handle.getValue()).toBe(''); + expect(handle.getHTML()).toBe(''); + expect(handle.getMarkdown()).toBe(''); + expect(handle.getSnapshot()).toEqual({ + mode: 'markdown', + value: '', + html: '', + markdown: '', + plainText: '', + documentJson: null, + isEmpty: true, + }); + expect(handle.getDocumentEnvelope()).toBeNull(); + expect(handle.getDocumentEnvelopeJson()).toBe(''); + expect(handle.getDocumentEnvelopeBytes()).toEqual(new Uint8Array()); + await expect(handle.getDocumentEnvelopeRevision()).resolves.toBeNull(); + await expect( + handle.getDocumentEnvelopeRevisionEvidence(), + ).resolves.toBeNull(); + await expect(handle.getSelectionRevisionEvidence()).resolves.toBeNull(); + await expect(handle.getTextPositionSelectorEvidence()).resolves.toBeNull(); + expect(handle.validateDocumentEnvelope({})).toBe(false); + expect(handle.validateDocumentEnvelopeBytes(new Uint8Array())).toBe(false); + expect(handle.restoreDocumentEnvelope({})).toBeNull(); + expect(handle.restoreDocumentEnvelopeBytes(new Uint8Array())).toBeNull(); + await expect( + handle.restoreDocumentEnvelopeIfMatch('"sha256-deadbeef"', {}), + ).resolves.toBeNull(); + await expect( + handle.restoreDocumentEnvelopeBytesIfMatch( + '"sha256-deadbeef"', + new Uint8Array(), + ), + ).resolves.toBeNull(); + expect(handle.validateDocumentJson({ type: 'doc', content: [] })).toBe(false); + expect(handle.isEmpty()).toBe(true); + expect(() => handle.focus()).not.toThrow(); + expect(() => handle.blur()).not.toThrow(); + expect(() => handle.setValue('ignored')).not.toThrow(); + expect(() => + handle.setDocumentJson({ type: 'doc', content: [] }), + ).not.toThrow(); + expect(() => handle.insertValue('ignored')).not.toThrow(); + expect(() => + handle.insertDocumentJson({ type: 'paragraph' }), + ).not.toThrow(); + expect(() => handle.clear()).not.toThrow(); + }); +}); diff --git a/src/components/useEditorHandle.ts b/src/components/useEditorHandle.ts index 710fe883..440f657f 100644 --- a/src/components/useEditorHandle.ts +++ b/src/components/useEditorHandle.ts @@ -42,6 +42,17 @@ function createCurrentDocumentEnvelope( return createDocumentEnvelope(editor.getJSON(), limits); } +/** Return the current usable editor instance, if one still exists. */ +function activeEditor(editor: Editor | null): Editor | null { + return editor && !editor.isDestroyed ? editor : null; +} + +/** Return an editor that currently permits user-facing history commands. */ +function editableHistoryEditor(editor: Editor | null): Editor | null { + const current = activeEditor(editor); + return current?.isEditable ? current : null; +} + /** Expose the stable host-control contract shared by editor surfaces. */ export function useEditorHandle( ref: ForwardedRef, @@ -51,55 +62,74 @@ export function useEditorHandle( useImperativeHandle( ref, (): CwlEditorHandle => ({ - getEditor: () => editor, + getEditor: () => activeEditor(editor), focus: () => { - editor?.chain().focus().run(); + activeEditor(editor)?.chain().focus().run(); }, blur: () => { - editor?.commands.blur(); + activeEditor(editor)?.commands.blur(); }, + canUndo: () => editableHistoryEditor(editor)?.can().undo() ?? false, + undo: () => + editableHistoryEditor(editor)?.chain().focus().undo().run() ?? false, + canRedo: () => editableHistoryEditor(editor)?.can().redo() ?? false, + redo: () => + editableHistoryEditor(editor)?.chain().focus().redo().run() ?? false, getValue: () => { - if (!editor) return ''; - return editorHtmlToValue(editor.getHTML(), modeRef.current); + const current = activeEditor(editor); + if (!current) return ''; + return editorHtmlToValue(current.getHTML(), modeRef.current); }, - getHTML: () => editor?.getHTML() ?? '', + getHTML: () => activeEditor(editor)?.getHTML() ?? '', getMarkdown: () => { - if (!editor) return ''; - return editorHtmlToValue(editor.getHTML(), 'markdown'); + const current = activeEditor(editor); + if (!current) return ''; + return editorHtmlToValue(current.getHTML(), 'markdown'); }, getSnapshot: () => - createEditorDocumentSnapshot(editor, modeRef.current), - getDocumentEnvelope: (limits) => - editor ? createCurrentDocumentEnvelope(editor, limits) : null, - getDocumentEnvelopeJson: (limits) => - editor + createEditorDocumentSnapshot(activeEditor(editor), modeRef.current), + getDocumentEnvelope: (limits) => { + const current = activeEditor(editor); + return current ? createCurrentDocumentEnvelope(current, limits) : null; + }, + getDocumentEnvelopeJson: (limits) => { + const current = activeEditor(editor); + return current ? serializeValidatedDocumentEnvelope( - createCurrentDocumentEnvelope(editor, limits), + createCurrentDocumentEnvelope(current, limits), ) - : '', - getDocumentEnvelopeBytes: (limits) => - editor + : ''; + }, + getDocumentEnvelopeBytes: (limits) => { + const current = activeEditor(editor); + return current ? encodeValidatedDocumentEnvelope( - createCurrentDocumentEnvelope(editor, limits), + createCurrentDocumentEnvelope(current, limits), ) - : new Uint8Array(), - getDocumentEnvelopeRevision: (limits, digestProvider) => - editor + : new Uint8Array(); + }, + getDocumentEnvelopeRevision: (limits, digestProvider) => { + const current = activeEditor(editor); + return current ? createValidatedDocumentEnvelopeRevision( - createCurrentDocumentEnvelope(editor, limits), + createCurrentDocumentEnvelope(current, limits), digestProvider, ) - : Promise.resolve(null), - getDocumentEnvelopeRevisionEvidence: (limits, digestProvider) => - editor + : Promise.resolve(null); + }, + getDocumentEnvelopeRevisionEvidence: (limits, digestProvider) => { + const current = activeEditor(editor); + return current ? createValidatedDocumentEnvelopeRevisionEvidence( - createCurrentDocumentEnvelope(editor, limits), + createCurrentDocumentEnvelope(current, limits), digestProvider, ) - : Promise.resolve(null), + : Promise.resolve(null); + }, getSelectionRevisionEvidence: async (limits, digestProvider) => { - if (!editor) return null; - const state = editor.state; + const current = activeEditor(editor); + if (!current) return null; + const state = current.state; const selection = Object.freeze({ anchor: state.selection.anchor, head: state.selection.head, @@ -115,8 +145,9 @@ export function useEditorHandle( return Object.freeze({ revision, selection }); }, getTextPositionSelectorEvidence: async (limits, digestProvider) => { - if (!editor) return null; - const state = editor.state; + const current = activeEditor(editor); + if (!current) return null; + const state = current.state; const { selector, textProjection } = createTextPositionSelector( state.doc, state.selection, @@ -129,77 +160,97 @@ export function useEditorHandle( return Object.freeze({ revision, selector, textProjection }); }, setValue: (next: string) => { - if (!editor) return; - editor.commands.setContent( + const current = activeEditor(editor); + if (!current) return; + current.commands.setContent( editorValueToHtml(next, modeRef.current), false, ); }, - validateDocumentEnvelope: (source, limits) => - editor - ? validateDocumentEnvelopeForEditor(editor, source, limits) - : false, - validateDocumentEnvelopeBytes: (source, limits) => - editor - ? validateDocumentEnvelopeBytesForEditor(editor, source, limits) - : false, - restoreDocumentEnvelope: (source, limits) => - editor ? restoreDocumentEnvelope(editor, source, limits) : null, - restoreDocumentEnvelopeBytes: (source, limits) => - editor ? restoreDocumentEnvelopeBytes(editor, source, limits) : null, + validateDocumentEnvelope: (source, limits) => { + const current = activeEditor(editor); + return current + ? validateDocumentEnvelopeForEditor(current, source, limits) + : false; + }, + validateDocumentEnvelopeBytes: (source, limits) => { + const current = activeEditor(editor); + return current + ? validateDocumentEnvelopeBytesForEditor(current, source, limits) + : false; + }, + restoreDocumentEnvelope: (source, limits) => { + const current = activeEditor(editor); + return current ? restoreDocumentEnvelope(current, source, limits) : null; + }, + restoreDocumentEnvelopeBytes: (source, limits) => { + const current = activeEditor(editor); + return current + ? restoreDocumentEnvelopeBytes(current, source, limits) + : null; + }, restoreDocumentEnvelopeIfMatch: ( expectedStrongEntityTag, source, limits, digestProvider, - ) => - editor + ) => { + const current = activeEditor(editor); + return current ? restoreDocumentEnvelopeIfMatch( - editor, + current, expectedStrongEntityTag, source, limits, digestProvider, ) - : Promise.resolve(null), + : Promise.resolve(null); + }, restoreDocumentEnvelopeBytesIfMatch: ( expectedStrongEntityTag, source, limits, digestProvider, - ) => - editor + ) => { + const current = activeEditor(editor); + return current ? restoreDocumentEnvelopeBytesIfMatch( - editor, + current, expectedStrongEntityTag, source, limits, digestProvider, ) - : Promise.resolve(null), - validateDocumentJson: (documentJson) => - editor ? validateDocumentJson(editor, documentJson) : false, + : Promise.resolve(null); + }, + validateDocumentJson: (documentJson) => { + const current = activeEditor(editor); + return current ? validateDocumentJson(current, documentJson) : false; + }, setDocumentJson: (documentJson) => { - if (!editor) return; - const documentNode = parseDocumentJsonForEditor(editor, documentJson); - editor.commands.setContent(documentNode, false); + const current = activeEditor(editor); + if (!current) return; + const documentNode = parseDocumentJsonForEditor(current, documentJson); + current.commands.setContent(documentNode, false); }, insertValue: (next: string) => { - if (!editor) return; - editor + const current = activeEditor(editor); + if (!current) return; + current .chain() .focus() .insertContent(editorValueToHtml(next, modeRef.current)) .run(); }, insertDocumentJson: (documentJson) => { - if (!editor) return; - editor.chain().focus().insertContent(documentJson).run(); + const current = activeEditor(editor); + if (!current) return; + current.chain().focus().insertContent(documentJson).run(); }, clear: () => { - editor?.commands.clearContent(true); + activeEditor(editor)?.commands.clearContent(true); }, - isEmpty: () => editor?.isEmpty ?? true, + isEmpty: () => activeEditor(editor)?.isEmpty ?? true, }), [editor, modeRef], ); diff --git a/src/imperativeHistoryPackageContract.test.ts b/src/imperativeHistoryPackageContract.test.ts new file mode 100644 index 00000000..fa9a06ac --- /dev/null +++ b/src/imperativeHistoryPackageContract.test.ts @@ -0,0 +1,18 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const verifier = readFileSync( + resolve(process.cwd(), 'tests/package/verify-package.mjs'), + 'utf8', +); + +describe('imperative history packed-package contract', () => { + it('compiles every public history operation through the strict packed consumer', () => { + expect(verifier).toContain('editorHandle.canUndo()'); + expect(verifier).toContain('editorHandle.undo()'); + expect(verifier).toContain('editorHandle.canRedo()'); + expect(verifier).toContain('editorHandle.redo()'); + }); +}); diff --git a/src/types.ts b/src/types.ts index 0292d4a2..66c9916b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,6 +125,20 @@ export interface CwlEditorHandle { focus(): void; /** Blur the editable surface. */ blur(): void; + /** Whether the active editor's registered history can currently undo. */ + canUndo(): boolean; + /** + * Run the active editor's registered undo command and return whether it ran. + * Returns `false` before editor creation and does not imply durable persistence. + */ + undo(): boolean; + /** Whether the active editor's registered history can currently redo. */ + canRedo(): boolean; + /** + * Run the active editor's registered redo command and return whether it ran. + * Returns `false` before editor creation and does not imply durable persistence. + */ + redo(): boolean; /** Serialized document in the active `mode` (`markdown` or `html`). */ getValue(): string; /** Always HTML (ProseMirror document dump). */ diff --git a/tests/package/verify-imperative-history-package.mjs b/tests/package/verify-imperative-history-package.mjs new file mode 100644 index 00000000..281c5fac --- /dev/null +++ b/tests/package/verify-imperative-history-package.mjs @@ -0,0 +1,61 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repositoryRoot = resolve( + dirname(fileURLToPath(import.meta.url)), + '..', + '..', +); +const verificationDirectory = mkdtempSync( + join(repositoryRoot, '.history-package-verification-'), +); + +try { + const consumerPath = join(verificationDirectory, 'consumer.ts'); + writeFileSync( + consumerPath, + `import type { CwlEditorHandle } from '@contextualwisdomlab/cwl-editor'; + +declare const editorHandle: CwlEditorHandle; + +const undoAvailable: boolean = editorHandle.canUndo(); +const undoExecuted: boolean = editorHandle.undo(); +const redoAvailable: boolean = editorHandle.canRedo(); +const redoExecuted: boolean = editorHandle.redo(); + +void [undoAvailable, undoExecuted, redoAvailable, redoExecuted]; +`, + 'utf8', + ); + + execFileSync( + 'pnpm', + [ + 'exec', + 'tsc', + '--noEmit', + '--strict', + '--skipLibCheck', + 'false', + '--module', + 'NodeNext', + '--moduleResolution', + 'NodeNext', + '--target', + 'ES2022', + '--lib', + 'ES2022,DOM,DOM.Iterable', + consumerPath, + ], + { + cwd: repositoryRoot, + stdio: 'inherit', + }, + ); + + console.log('Verified imperative history methods through public package declarations.'); +} finally { + rmSync(verificationDirectory, { recursive: true, force: true }); +} diff --git a/tests/package/verify-package.mjs b/tests/package/verify-package.mjs index 82a03e31..8e41b226 100644 --- a/tests/package/verify-package.mjs +++ b/tests/package/verify-package.mjs @@ -200,6 +200,10 @@ const expectedStrongEntityTag = '"sha256-' + '0'.repeat(64) + '"'; const currentSnapshot: CwlEditorDocumentSnapshot = editorHandle.getSnapshot(); const currentRevision: Promise = editorHandle.getDocumentEnvelopeRevision(undefined, digestProvider); +const canUndo: boolean = editorHandle.canUndo(); +const undoResult: boolean = editorHandle.undo(); +const canRedo: boolean = editorHandle.canRedo(); +const redoResult: boolean = editorHandle.redo(); const conditionalRestore: Promise = editorHandle.restoreDocumentEnvelopeIfMatch( expectedStrongEntityTag, @@ -272,6 +276,10 @@ void [ documentSnapshot, currentSnapshot, currentRevision, + canUndo, + undoResult, + canRedo, + redoResult, conditionalRestore, conditionalEvidence, conditionalByteRestoreResult,