diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index b2eb2f3fa..63ebcdd64 100644 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -397,7 +397,7 @@ function renderDiff(parsedPatches: ParsedPatch[], manager?: WorkerPoolManager) { const patchAnnotations = FAKE_DIFF_LINE_ANNOTATIONS[patchIndex] ?? []; let hunkIndex = 0; for (const fileDiff of parsedPatch.files) { - const editor = new Editor({ + const editor = new Editor('file-diff', { onAttach: (editor) => { editor.setSelections([ { @@ -960,7 +960,7 @@ if (renderFileButton != null) { virtualizer?.setup(globalThis.document); const wrap = getWrapped(); - const editor = new Editor({ + const editor = new Editor('file', { enabledSelectionAction: true, renderSelectionAction: (ctx) => { const div = document.createElement('div'); @@ -977,11 +977,11 @@ if (renderFileButton != null) { div.appendChild(button); return div; }, - onChange: (file, lineAnnotations) => { + onChange: ({ file, lineAnnotations }) => { console.log('change', file, lineAnnotations); }, onAttach: (editor) => { - const { selections } = editor.getState(); + const { selections } = editor.getViewState(); if (selections === undefined || selections.length === 0) { editor.setSelections([ { diff --git a/apps/docs/app/(diffs)/_docs/DocsPage.tsx b/apps/docs/app/(diffs)/_docs/DocsPage.tsx index 3e8aca780..e0ee80468 100644 --- a/apps/docs/app/(diffs)/_docs/DocsPage.tsx +++ b/apps/docs/app/(diffs)/_docs/DocsPage.tsx @@ -42,14 +42,12 @@ import { EDIT_ON_ATTACH_REACT_EXAMPLE, EDIT_ON_ATTACH_VANILLA_EXAMPLE, EDIT_ON_CHANGE_EXAMPLE, - EDIT_PERSIST_STATE_EXAMPLE, - EDIT_PERSIST_STATE_REACT_EXAMPLE, + EDIT_PERSISTED_DRAFT_EXAMPLE, EDIT_REACT_CODE_VIEW_EXAMPLE, EDIT_REACT_CREATE_EDITOR_EXAMPLE, EDIT_REACT_EXAMPLE, EDIT_REACT_FILE_DIFF_EXAMPLE, EDIT_REACT_MULTI_FILE_DIFF_EXAMPLE, - EDIT_REACT_SHARED_EDITOR_EXAMPLE, EDIT_SELECTION_ACTION_CONTEXT_TYPE, EDIT_SELECTION_ACTION_EXAMPLE, EDIT_UNDO_REDO_EXAMPLE, @@ -441,18 +439,16 @@ async function EditSection() { editLazyFileExample, editorOptionsType, editOnChangeExample, + editPersistedDraftExample, editOnAttachReactExample, editOnAttachVanillaExample, editFocusPositionExample, editorPublicApi, editSelectionActionContextType, editSelectionActionExample, - editPersistStateExample, - editPersistStateReactExample, editMarkerType, editMarkerExample, editReactCreateEditorExample, - editReactSharedEditorExample, editReactCodeViewExample, editReactExample, editReactFileDiffExample, @@ -468,18 +464,16 @@ async function EditSection() { preloadFile(EDIT_LAZY_FILE_EXAMPLE), preloadFile(EDITOR_OPTIONS_TYPE), preloadFile(EDIT_ON_CHANGE_EXAMPLE), + preloadFile(EDIT_PERSISTED_DRAFT_EXAMPLE), preloadFile(EDIT_ON_ATTACH_REACT_EXAMPLE), preloadFile(EDIT_ON_ATTACH_VANILLA_EXAMPLE), preloadFile(EDIT_FOCUS_POSITION_EXAMPLE), preloadFile(EDITOR_PUBLIC_API), preloadFile(EDIT_SELECTION_ACTION_CONTEXT_TYPE), preloadFile(EDIT_SELECTION_ACTION_EXAMPLE), - preloadFile(EDIT_PERSIST_STATE_EXAMPLE), - preloadFile(EDIT_PERSIST_STATE_REACT_EXAMPLE), preloadFile(EDIT_MARKER_TYPE), preloadFile(EDIT_MARKER_EXAMPLE), preloadFile(EDIT_REACT_CREATE_EDITOR_EXAMPLE), - preloadFile(EDIT_REACT_SHARED_EDITOR_EXAMPLE), preloadFile(EDIT_REACT_CODE_VIEW_EXAMPLE), preloadFile(EDIT_REACT_EXAMPLE), preloadFile(EDIT_REACT_FILE_DIFF_EXAMPLE), @@ -498,18 +492,16 @@ async function EditSection() { editLazyFileExample, editorOptionsType, editOnChangeExample, + editPersistedDraftExample, editOnAttachReactExample, editOnAttachVanillaExample, editFocusPositionExample, editorPublicApi, editSelectionActionContextType, editSelectionActionExample, - editPersistStateExample, - editPersistStateReactExample, editMarkerType, editMarkerExample, editReactCreateEditorExample, - editReactSharedEditorExample, editReactCodeViewExample, editReactExample, editReactFileDiffExample, diff --git a/apps/docs/app/(diffs)/_edit/EditReference.tsx b/apps/docs/app/(diffs)/_edit/EditReference.tsx index a30312377..d1834f025 100644 --- a/apps/docs/app/(diffs)/_edit/EditReference.tsx +++ b/apps/docs/app/(diffs)/_edit/EditReference.tsx @@ -108,7 +108,7 @@ const CAPABILITY_GROUPS: ReferenceGroup[] = [ { term: 'Themes & color modes', description: - 'Tokens and editor chrome follow the surface theme, re-tokenizing live when you switch themes or toggle light and dark.', + 'Tokens and editor chrome follow the component theme, re-tokenizing live when you switch themes or toggle light and dark.', }, { term: 'UI adapts to container', diff --git a/apps/docs/app/(diffs)/_edit/HistoryDemo.tsx b/apps/docs/app/(diffs)/_edit/HistoryDemo.tsx index 825a70492..d56fb8a18 100644 --- a/apps/docs/app/(diffs)/_edit/HistoryDemo.tsx +++ b/apps/docs/app/(diffs)/_edit/HistoryDemo.tsx @@ -277,7 +277,7 @@ export function HistoryDemo({ prerenderedFile }: HistoryDemoProps) { [applyEdit] ); - // Build the undo stack once the demo is on screen so the surface arrives + // Build the undo stack once the demo is on screen so the component arrives // already fully refactored with history intact. We defer until visible // because seeding scrolls the caret into view and would yank the page down // to this below-the-fold demo on first load. We poll until the content and @@ -314,7 +314,7 @@ export function HistoryDemo({ prerenderedFile }: HistoryDemoProps) { const startSeeding = () => { // Warm the shared highlighter before polling so the editor tokenizer can - // pick up the grammar synchronously once the surface attaches. + // pick up the grammar synchronously once the component attaches. void preloadHighlighter({ themes: [DEFAULT_THEMES.dark, DEFAULT_THEMES.light], langs: [LANGUAGE], @@ -389,7 +389,7 @@ export function HistoryDemo({ prerenderedFile }: HistoryDemoProps) { // Recover the guided demo after the user typed their own edit. We unwind the // whole undo stack (the stray edit plus the seeded steps) back to the original // document via the editor's programmatic `undo()`, which is reliable - // regardless of where focus sits, then replay all seeded edits so the surface + // regardless of where focus sits, then replay all seeded edits so the component // lands back at the fully-refactored 7/7 state with its history intact. const reset = useCallback(() => { const content = getContent(); diff --git a/apps/docs/app/(diffs)/_edit/SelectionDemo.tsx b/apps/docs/app/(diffs)/_edit/SelectionDemo.tsx index faa9c22e0..8ff14ddfb 100644 --- a/apps/docs/app/(diffs)/_edit/SelectionDemo.tsx +++ b/apps/docs/app/(diffs)/_edit/SelectionDemo.tsx @@ -93,7 +93,7 @@ function formatSelectionLineLabel( // selecting text immediately reveals a floating popover (anchored below the // selection) whose contents come from `renderSelectionAction`. Here it mimics an // editor's "Add to chat": the primary action sends the selected snippet to a -// mock chat panel beside the surface, and a secondary action copies it. +// mock chat panel beside the editor, and a secondary action copies it. export function SelectionDemo({ prerenderedFile }: SelectionDemoProps) { const [snippets, setSnippets] = useState([]); const snippetIdRef = useRef(0); @@ -237,7 +237,7 @@ export function SelectionDemo({ prerenderedFile }: SelectionDemoProps) { disableLineNumbers: true, }} // The page's shared worker pool is wired up for the editable - // editor surface; a dynamically mounted read-only File isn't + // editor component; a dynamically mounted read-only File isn't // highlighted through it, so highlight on the main thread. disableWorkerPool className="max-h-32 overflow-auto" diff --git a/apps/docs/app/(diffs)/_edit/constants.ts b/apps/docs/app/(diffs)/_edit/constants.ts index ebf2805ad..b9f8f20a5 100644 --- a/apps/docs/app/(diffs)/_edit/constants.ts +++ b/apps/docs/app/(diffs)/_edit/constants.ts @@ -4,7 +4,7 @@ import type { FileOptions } from '@pierre/diffs/react'; import type { PreloadFileOptions } from '@pierre/diffs/ssr'; // The editor requires the token transformer, so enabling it in the SSR preload -// keeps hydration from rerendering the surface after the editor attaches. +// keeps hydration from rerendering the component after the editor attaches. // Mirrors LiveEditing/constants.ts. const EDITABLE_FILE_OPTIONS: FileOptions = { theme: DEFAULT_THEMES, diff --git a/apps/docs/app/(diffs)/_examples/LiveEditing/LiveEditing.tsx b/apps/docs/app/(diffs)/_examples/LiveEditing/LiveEditing.tsx index 72e1affe6..f94ff58ad 100644 --- a/apps/docs/app/(diffs)/_examples/LiveEditing/LiveEditing.tsx +++ b/apps/docs/app/(diffs)/_examples/LiveEditing/LiveEditing.tsx @@ -23,21 +23,21 @@ import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; interface LiveEditingProps { - // Pre-rendered File surface (the additions-only view) and the FileDiff - // surface (before/after). We ship both so toggling between them hydrates from + // Pre-rendered File component (the additions-only view) and FileDiff component + // (before/after). We ship both so toggling between them hydrates from // server HTML instead of flashing in after client highlighting. prerenderedFile: PreloadedFileResult; prerenderedDiff: PreloadFileDiffResult; } -// Which surface the demo renders: a standalone File or a before/after FileDiff. -type Surface = 'file' | 'diff'; +// Which component the demo renders: a standalone File or a before/after FileDiff. +type DemoView = 'file' | 'diff'; -// Review renders the surface read-only (how diffs renders by default); Edit +// Review renders the component read-only (how diffs renders by default); Edit // attaches the editor and makes it editable in place. type EditMode = 'review' | 'edit'; -// Layout the diff renders in. Only applies to the FileDiff surface. +// Layout the diff renders in. Only applies to the FileDiff component. type DiffLayout = 'unified' | 'split'; export function LiveEditing({ @@ -45,17 +45,17 @@ export function LiveEditing({ prerenderedDiff, }: LiveEditingProps) { const [hasEdits, setHasEdits] = useState(false); - const [surface, setSurface] = useState('file'); + const [view, setView] = useState('file'); // Default to Edit so the editor is live on first paint; the toggle drops back - // to a read-only Review of the same surface. + // to a read-only Review of the same component. const [mode, setMode] = useState('edit'); // Default to the layout the diff was prerendered in (unified) so the first - // paint hydrates without a flash; toggling re-renders the surface client-side. + // paint hydrates without a flash; toggling re-renders the component client-side. const [diffLayout, setDiffLayout] = useState( prerenderedDiff.options?.diffStyle === 'split' ? 'split' : 'unified' ); - // Bumping this value remounts the editable surface from pristine input. - // Reset and surface changes use it as a deliberate new-session boundary. + // Bumping this value remounts the editable component from pristine input. + // Reset and view changes use it as a deliberate new-session boundary. const [resetKey, setResetKey] = useState(0); // Editing a FileDiff updates the diff metadata it renders from so the live // hunks stay in sync. Keep an untouched baseline and hand FileDiff a fresh @@ -73,7 +73,7 @@ export function LiveEditing({ [pristineFileDiff, resetKey] ); - // Both surfaces synchronously report the current new-file contents. + // Both components synchronously report the current new-file contents. const handleEditChange = useCallback( (event: EditorChangeEvent) => { setHasEdits(event.file.contents !== LIVE_EDITING_NEW_FILE.contents); @@ -81,19 +81,19 @@ export function LiveEditing({ [] ); - // Reset and surface switches deliberately discard the current edit session. + // Reset and component switches deliberately discard the current edit session. // The new key also rebuilds mutable FileDiff metadata from its pristine copy. - const resetEditableSurface = useCallback(() => { + const resetEditableComponent = useCallback(() => { setHasEdits(false); setResetKey((key) => key + 1); }, []); - const handleSurfaceChange = useCallback( - (value: Surface) => { - setSurface(value); - resetEditableSurface(); + const handleViewChange = useCallback( + (value: DemoView) => { + setView(value); + resetEditableComponent(); }, - [resetEditableSurface] + [resetEditableComponent] ); // Layout is only a view option, so changing it keeps the current edit session. @@ -101,12 +101,12 @@ export function LiveEditing({ setDiffLayout(value); }, []); - // The Reset button lives in the surface header for both File and FileDiff + // The Reset button lives in the component header for both File and FileDiff // views, so it's defined once and reused by each `renderHeaderMetadata`. const renderResetButton = useCallback( () => ( - ) : ( - )} @@ -681,7 +605,7 @@ export const EDIT_REACT_FILE_DIFF_EXAMPLE: PreloadFileOptions = { contents: `import { parseDiffFromFile, type DiffLineAnnotation, - type FileDiffEditCompleteEvent, + type FileDiffEditCompleteHandler, type FileDiffMetadata, type FileDiffOptions, } from '@pierre/diffs'; @@ -721,8 +645,12 @@ const virtualizerStyle = { borderRadius: '0.5rem', } as const; -function createEditor(options: EditorOptions) { - return new Editor(options); +function createEditor( + documentKind: 'file' | 'file-diff', + options: EditorOptions, + editStateKey?: string +) { + return new Editor(documentKind, options, editStateKey); } export function EditableFileDiff() { @@ -736,8 +664,10 @@ export function EditableFileDiff() { // On completion, accept the new diff and adopt the final annotation // collection, or revert. - const handleEditComplete = useCallback( - (event: FileDiffEditCompleteEvent) => { + const handleEditComplete = useCallback< + FileDiffEditCompleteHandler + >( + (event) => { if (cancelled.current) { cancelled.current = false; return 'reject'; @@ -769,15 +699,24 @@ export function EditableFileDiff() { > Cancel - ) : ( - )} @@ -816,14 +755,13 @@ export function EditableFileDiff() { export const EDIT_REACT_CODE_VIEW_EXAMPLE: PreloadFileOptions = { file: { name: 'editor_react_code_view.tsx', - contents: `import { - parseDiffFromFile, - type CodeViewDiffItem, - type CodeViewItem, - type FileDiffEditCompleteEvent, -} from '@pierre/diffs'; + contents: `import { parseDiffFromFile, type CodeViewItem } from '@pierre/diffs'; import { Editor, type EditorOptions } from '@pierre/diffs/edit'; -import { CodeView, EditProvider } from '@pierre/diffs/react'; +import { + CodeView, + EditProvider, + type CodeViewItemEditCompleteHandler, +} from '@pierre/diffs/react'; import { useCallback, useState } from 'react'; interface ThreadMetadata { @@ -864,8 +802,12 @@ const editorOptions: EditorOptions = { }, }; -function createEditor(options: EditorOptions) { - return new Editor(options); +function createEditor( + documentKind: 'file' | 'file-diff', + options: EditorOptions, + editStateKey?: string +) { + return new Editor(documentKind, options, editStateKey); } export function EditableCodeView() { @@ -886,12 +828,10 @@ export function EditableCodeView() { // annotations, edit: false, and a bumped version. Mirror it into state and // return 'accept' (edit mode already managed the annotations), or 'reject' to // revert. - const commitEdit = useCallback( - ( - event: FileDiffEditCompleteEvent, - item: CodeViewDiffItem, - nextItem: CodeViewDiffItem - ) => { + const commitEdit = useCallback>( + (event, item, nextItem) => { + if (!('fileDiff' in event)) return 'reject'; + event.fileDiff.cacheKey = item.id + ':v' + nextItem.version; setItems((current) => { // We must insert the new item into our controlled array. @@ -916,6 +856,7 @@ export function EditableCodeView() { items={items} style={codeViewStyle} editorOptions={editorOptions} + getEditStateKey={(item) => \`review:\${item.id}\`} onItemEditComplete={commitEdit} renderAnnotation={(annotation) => (
Thread {annotation.metadata.id}
@@ -955,7 +896,7 @@ fileInstance.render({ containerWrapper: document.body, }); -const editor = new Editor(); +const editor = new Editor('file'); editor.edit(fileInstance);`, }, options, @@ -988,8 +929,12 @@ const highlighterOptions = { useTokenTransformer: true, } as const; -function createEditor(options: EditorOptions) { - return new Editor(options); +function createEditor( + documentKind: 'file' | 'file-diff', + options: EditorOptions, + editStateKey?: string +) { + return new Editor(documentKind, options, editStateKey); } export function EditableFileWithWorkerPool() { @@ -1023,26 +968,25 @@ export const EDITOR_OPTIONS_TYPE: PreloadFileOptions = { } from '@pierre/diffs'; import { Editor, + type EditorEditCompleteEvent, + type EditorInitialState, type EditorKeymap, - type IStateStorage, } from '@pierre/diffs/edit'; interface EditorOptions { // Max undo stack entries historyMaxEntries?: number; - // Custom keymap checked before the default map. - keymap?: EditorKeymap; + // Retain and restore vertical scroll only when this component exclusively owns + // its scrollable HTMLElement viewport. Captured by the constructor; defaults to false. + ownsVerticalViewport?: boolean; - // Preserve each File's document and item-local editor state between renders. - // Requires every editable file to provide a unique, stable cacheKey. - // Default: false. - persistState?: boolean; + // State to adopt on first attach. Only documentKind is required; omitted + // fields are initialized from the attached component. + initialState?: EditorInitialState; - // Where serializable editor state is stored. Text documents and undo - // history remain in this Editor instance's in-memory cache. - // Defaults to 'inMemory' when persistState is enabled. - persistStateStorage?: 'inMemory' | 'indexedDB' | IStateStorage; + // Custom keymap checked before the default map. + keymap?: EditorKeymap; // Render rounded corners on selection ranges (default: true) roundedSelection?: boolean; @@ -1064,7 +1008,7 @@ interface EditorOptions { >; // Show the floating Selection Action popover after a user selection. - // Programmatic setSelections/setState calls do not open it (default: false). + // Programmatic setSelections/setViewState calls do not open it. enabledSelectionAction?: boolean; // Custom clipboard provider. Recommended in Electron apps — use the native @@ -1083,11 +1027,16 @@ interface EditorOptions { ) => void; // Editor-centric change stream. Fires after each edit with an - // EditorChangeEvent carrying the live file/fileDiff, the current - // lineAnnotations, and normalized text changes. Prefer a component's - // onEditChange prop/option for per-component handling. + // EditorChangeEvent carrying the editor, live file (the editable new side for + // a diff), current lineAnnotations, and normalized text changes. Prefer a + // component's onEditChange prop/option for per-component handling. onChange?: (event: EditorChangeEvent) => void; + // Editor-wide completion observer. Receives the same frozen event before the + // component callback and fires even when that callback is missing. You cannot + // accept or reject from this callback. + onComplete?: (event: EditorEditCompleteEvent) => void; + // Fires when the editable content area gains focus (tab, click, or editor.focus()). onFocus?: () => void; @@ -1119,13 +1068,13 @@ export const EDIT_ON_ATTACH_VANILLA_EXAMPLE: PreloadFileOptions = { file: { name: 'editor_on_attach_vanilla.ts', contents: `const viewer = new CodeView({ - createEditor(options) { - return new Editor({ + createEditor(documentKind, options, editStateKey) { + return new Editor(documentKind, { ...options, onAttach(editor) { editor.focus({ lineNumber: 'first-visible', preventScroll: true }); }, - }); + }, editStateKey); }, });`, }, @@ -1142,24 +1091,155 @@ export const EDIT_FOCUS_POSITION_EXAMPLE: PreloadFileOptions = { export const EDIT_ON_CHANGE_EXAMPLE: PreloadFileOptions = { file: { - name: 'editor_on_change.ts', - contents: `import { Editor } from '@pierre/diffs/edit'; + name: 'edit_component_handlers.tsx', + contents: `import type { + FileContents, + FileEditCompleteHandler, +} from '@pierre/diffs'; +import { File } from '@pierre/diffs/react'; +import { useState } from 'react'; -// The same EditorChangeEvent reaches a component's onEditChange prop/option. -// Generally you should avoid using this API unless you're using the Editor API -// more directly -new Editor({ - onChange: (event) => { - // \`event.file\` (or \`event.fileDiff\`) is the live document, and - // \`event.lineAnnotations\` the current collection. \`event.changes\` lists - // every normalized edit. - event.changes.forEach((change) => { - console.log('Text inserted/replaced:', change.text); - console.log('Range of the edit:', change.range); // { start: { line, character }, end: { line, character } } - console.log('Offset of the change:', change.start, change.end); - }); +const initialFile: FileContents = { + name: 'example.ts', + contents: 'export const answer = 42;', +}; + +// Render inside the stable EditProvider shown above. +export function EditableFileHandlers() { + const [file, setFile] = useState(initialFile); + + const handleEditComplete: FileEditCompleteHandler = (event) => { + if (!window.confirm('Keep these changes?')) { + return 'reject'; + } + + // Keep application state aligned with the value installed by the component. + setFile(event.file); + return 'accept'; + }; + + return ( + { + console.log('Current contents:', event.file.contents); + console.log('Normalized edits:', event.changes); + }} + onEditComplete={handleEditComplete} + /> + ); +}`, }, -});`, + options, +}; + +export const EDIT_PERSISTED_DRAFT_EXAMPLE: PreloadFileOptions = { + file: { + name: 'persisted_edit_draft.tsx', + contents: `import { + getFiletypeFromFileName, + type EditorViewState, + type FileContents, +} from '@pierre/diffs'; +import { + TextDocument, + type Editor, + type EditorOptions, +} from '@pierre/diffs/edit'; +import { File } from '@pierre/diffs/react'; +import { useRef, useState } from 'react'; + +const storageKey = 'draft:example.ts'; +const initialFile: FileContents = { + name: 'example.ts', + contents: 'export const answer = 42;', +}; + +interface PersistedDraft { + file: FileContents; + editorState?: EditorViewState; +} + +function loadDraft(): PersistedDraft { + const value = localStorage.getItem(storageKey); + return value == null + ? { file: initialFile } + : (JSON.parse(value) as PersistedDraft); +} + +function persistDraft(file: FileContents, editor: Editor) { + const draft: PersistedDraft = { + file, + editorState: editor.getViewState(), + }; + localStorage.setItem(storageKey, JSON.stringify(draft)); +} + +// Render inside the stable EditProvider shown above. +export function PersistedEditableFile() { + const [initialDraft] = useState(loadDraft); + const [file, setFile] = useState(initialDraft.file); + const [editing, setEditing] = useState(true); + const editorRef = useRef | null>(null); + const [editorOptions] = useState>(() => ({ + // Initialize the edit state from the latest version of the file, minus + // undo history + initialState: { + documentKind: 'file', + document: new TextDocument( + initialDraft.file.name, + initialDraft.file.contents, + initialDraft.file.lang ?? getFiletypeFromFileName(initialDraft.file.name) + ), + fileInfo: { + name: initialDraft.file.name, + lang: initialDraft.file.lang, + }, + editor: initialDraft.editorState, + }, + onAttach(editor) { + editorRef.current = editor; + }, + })); + + return ( + <> + + + { + // Save the latest document and state without feeding them back into + // the component during its active editing session. + persistDraft(event.file, event.editor); + }} + onEditComplete={(event) => { + // Persist the accepted final file as the next canonical input. + setFile(event.file); + persistDraft(event.file, event.editor); + return 'accept'; + }} + /> + + ); +}`, }, options, }; @@ -1169,13 +1249,18 @@ export const EDITOR_PUBLIC_API: PreloadFileOptions = { name: 'editor_public_api.ts', contents: `import { File, - type EditorState, + type EditorViewState, type FileContents, } from '@pierre/diffs'; -import { Editor, type EditorFocusOptions } from '@pierre/diffs/edit'; +import { + Editor, + EditStateManager, + type EditState, + type EditorFocusOptions, +} from '@pierre/diffs/edit'; // Editor -// Most methods require an attached surface via edit(). +// Most methods require an attached component via edit(). const fileInstance = new File(); fileInstance.render({ @@ -1183,30 +1268,19 @@ fileInstance.render({ containerWrapper: document.body, }); -const editor = new Editor(); +const editStateKey = 'review:example.ts'; +const editor = new Editor('file', {}, editStateKey); // Merge partial options at runtime. Existing fields are preserved. -// onChange and similar handlers read from the latest options on each call; -// pass onFocus/onBlur before edit() attaches, or set them in the constructor. editor.setOptions({ - onChange(event) { - // Observe the live document via event.file/event.fileDiff. Editing owns - // annotation positions during the session — no sync loop needed. - // Avoid using this API in favor of the \`onEditChange\` prop on - // File/FileDiff components directly - console.log(event.changes.length, 'change(s)'); - }, + roundedSelection: false, }); -// Attach to a rendered File, FileDiff, or virtualized variant. +// This file-kind editor attaches to a rendered File or VirtualizedFile. Create +// an Editor('file-diff', ...) for FileDiff or VirtualizedFileDiff. // Normalizes conflicting fileInstance options and returns a dispose function. const dispose = editor.edit(fileInstance); -// Detach, remove listeners, and clean up injected editor DOM. -// Pass recycle=true when a virtualized host is temporarily unmounting. -editor.cleanUp(); -editor.cleanUp(true); - // Apply text edits to the attached document. Positions are zero-based. // Edits always join the undo stack, exactly like typed input. The optional // updateHistory argument defaults to true; false remaps live selections instead @@ -1218,22 +1292,26 @@ editor.applyEdits([ }, ]); -// Live FileContents for the attached document. Undefined when nothing is -// attached. +// Live FileContents for the current session or queued initialState. Undefined +// when neither exists. const file: FileContents | undefined = editor.getFile(); -// Full document text, or '' when nothing is attached. +// Full document text, or '' when no current session or initialState exists. const text: string = editor.getText(); // Snapshot selections and scroll positions for explicit restoration: -const state: EditorState = editor.getState(); -// EditorState = { +const state: EditorViewState = editor.getViewState(); +// EditorViewState = { // selections?: EditorSelection[]; // view?: { scrollLeft: number; scrollTop?: number }; // } // Restore selections and scroll positions after re-rendering. -editor.setState(state); +editor.setViewState(state); + +// Borrow the complete live document, history, and editor-state checkpoint. This is +// available only while a complete edit session exists. +const editState: EditState | undefined = editor.getEditState(); // Replace all cursors and ranges programmatically. Positions are zero-based; // direction controls which end the caret uses for keyboard extension. @@ -1282,6 +1360,28 @@ editor.canRedo; // Undo the last edit or redo the last undone edit. No-ops when history is empty. editor.undo(); editor.redo(); + +// End the session through the disposer returned by edit(). It detaches the +// editor, then runs the component's onEditComplete accept/reject boundary. +dispose(); + +// cleanUp('discard') also runs completion, but never installs the result. +// Virtualized hosts use cleanUp('recycle') for temporary remounts. + +// Inspect complete active or inactive state without changing LRU recency. +EditStateManager.get('file', editStateKey); + +// Clear a complete inactive session, or keep its draft while resetting selected +// parts. Active sessions are never mutated by manager clearing. +EditStateManager.clear('file', editStateKey); +EditStateManager.clear('file', editStateKey, { history: true }); +EditStateManager.clear('file', editStateKey, { selections: true }); +EditStateManager.clear('file', editStateKey, { view: true }); +EditStateManager.clear('file', editStateKey, { editor: true }); + +// Clear all inactive state, or change each namespace's retained-state capacity. +EditStateManager.clearAll(); +EditStateManager.setCapacity(50); `, }, options, @@ -1293,7 +1393,7 @@ export const EDIT_REACT_MULTI_FILE_DIFF_EXAMPLE: PreloadFileOptions = name: 'editor_react_multi_file_diff.tsx', contents: `import type { FileContents, - FileDiffEditCompleteEvent, + FileDiffEditCompleteHandler, FileDiffOptions, } from '@pierre/diffs'; import { Editor, type EditorOptions } from '@pierre/diffs/edit'; @@ -1326,8 +1426,12 @@ const virtualizerStyle = { borderRadius: '0.5rem', } as const; -function createEditor(options: EditorOptions) { - return new Editor(options); +function createEditor( + documentKind: 'file' | 'file-diff', + options: EditorOptions, + editStateKey?: string +) { + return new Editor(documentKind, options, editStateKey); } export function EditableMultiFileDiff() { @@ -1340,8 +1444,10 @@ export function EditableMultiFileDiff() { // MultiFileDiff parses its diff from the file pair. Adopt the completed files // into state (re-keyed) — MultiFileDiff reuses the accepted diff once the // props catch up — then return 'accept'; return 'reject' to revert. - const handleEditComplete = useCallback( - (event: FileDiffEditCompleteEvent) => { + const handleEditComplete = useCallback< + FileDiffEditCompleteHandler + >( + (event) => { if (cancelled.current) { cancelled.current = false; return 'reject'; @@ -1376,12 +1482,24 @@ export function EditableMultiFileDiff() { > Cancel - ) : ( - )} diff --git a/apps/docs/app/(diffs)/docs/Edit/content.mdx b/apps/docs/app/(diffs)/docs/Edit/content.mdx index 62630e75a..fca3010cd 100644 --- a/apps/docs/app/(diffs)/docs/Edit/content.mdx +++ b/apps/docs/app/(diffs)/docs/Edit/content.mdx @@ -4,138 +4,49 @@ Edit mode is experimental and subject to change. -Edit mode is a pluggable editing layer for `File`-style code surfaces. It adds -keyboard-driven editing to an already-rendered `File` or `FileDiff` while the -existing renderer continues to own syntax highlighting, layout, annotations, and -virtualization. - -Edit mode features include: - -- Text editing -- Multiple cursors -- Automatic indentation -- Brace matching -- History (undo and redo) -- Find-in-file search and replace -- [Selection Action](#edit-mode-selection-action) (opt-in, custom UI) -- Markers (inline diagnostics) -- SSR support -- Mobile-friendly -- Lightweight - -Use the `Editor` API to add editing to `File`, `FileDiff`, `MultiFileDiff`, and -`PatchDiff`, or to individual `CodeView` items. In React, wrap editable surfaces -in `EditProvider`, then set a standalone surface's `edit` prop or a `CodeView` -item's `edit` flag. In vanilla JS, call `editor.edit(fileInstance)` after -rendering a standalone surface, or give `CodeView` its own `createEditor` -option. - -Edit mode is not a full-fledged IDE, though you can build IDE-like experiences -on top of it. It is purpose-built for code rendered by this library: the -existing file and diff surfaces keep their diff layout, annotations, syntax -highlighting, SSR, and virtualization, and edit mode adds editing, multiple -selections, history, search and replace, and markers. That focus makes it a -natural fit for “review and correct” flows with generative code changes. - -### How It Works - -Edit mode does not replace `File`, `FileDiff`, or their virtualized variants. -You render the surface first, then attach editing: - -1. **Attach** — Call `editor.edit(fileInstance)` in vanilla JS, or pass `edit` - on a React `File`, `FileDiff`, `MultiFileDiff`, or `PatchDiff` wrapped in - `EditProvider`. For React `CodeView`, use the same provider and set - `edit: true` on an item. Vanilla `CodeView` instead takes - `CodeViewOptions.createEditor`. The hookup returns a dispose function for a - standalone vanilla surface; React surfaces and `CodeView` manage it - automatically when editing toggles. -2. **Edit surface** — Edit mode sets `contentEditable` on the code content - element so mobile keyboards, paste, and native selection UI work as users - expect. Clipboard shortcuts are handled by the browser; edit mode commands - handle structure-aware actions like indent and undo. -3. **Selections** — Carets and ranges are tracked with the native `Selection` - API. Multiple non-overlapping selections are supported; Cmd/Ctrl-click adds a - cursor, while Alt/Option-drag adds column-aligned cursors or rectangular - selections after clearing existing ones. -4. **Updates** — Keystrokes update an internal `TextDocument`, then changed - lines are re-highlighted through the same tokenizer pipeline as read-only - mode. A component's `onEditChange` prop (recommended) or the editor's - `onChange` option receives an `EditorChangeEvent` carrying the updated - `file`/`fileDiff`, the current `lineAnnotations`, and normalized text - `changes` for every live edit. -5. **Completion** — When a changed session ends, `onEditComplete` runs once as - the accept/revert boundary (see - [Completing an edit](#edit-mode-completing-an-edit)). - -When attaching, edit mode turns on the token transformer and triggers a -re-render if necessary. Collapsed unchanged regions stay collapsed: arrow keys -skip over them like code folds, jumps that must land inside one (search matches, -undo, Cmd/Ctrl+End) expand it just enough to show the target, and the separator -expand buttons keep working. The diff layout is preserved, so a `FileDiff` or -`MultiFileDiff` stays in whatever view it was rendered in; in unified diffs, -deleted lines and annotation lines remain read-only. - -### The Edit Session - -Attaching an editor starts an **edit session**, and completion ends it. Knowing -who owns what across that lifecycle is the key to wiring edit mode correctly: - -- **Before editing**, your application owns the surface's inputs: the - `file`/`fileDiff` and the `lineAnnotations` collection you pass as props. -- **During an edit session**, edit mode creates a private, live copy of the - document and the annotation positions. Keystrokes mutate that private copy and - re-render the surface. Your original `file`/`fileDiff` and `lineAnnotations` - are held in case you want to reject the edit. This is why you should not feed - `onEditChange` (or `onChange`) data back into the surface. If your - `file`/`fileDiff` or `lineAnnotations` update externally from your app, we - will fully apply those changes to your edit session. You can still undo the - content changes if you decide to reject it and keep editing. As you edit, - we'll manage annotation positions for you to attempt to keep them matched with - their intentended lines. -- **On completion**, (either when you toggle edit off or unmount the component) - an edit complete callback is fired that allows you to accept or reject the - edit session. If you decide to accept, you'll need to store the new copies of - `file`/`fileDiff` and `lineAnnotations` that represent the new edited document - state. If you reject the edit you'll be returned to the state prior to - starting the edit session. See - [Completing an edit](#edit-mode-completing-an-edit). - -In other words at a high level, while editing, we internally manage the edited -states for you, and all you need to do at the end of the session is store or -reject the finalized edits. - -### React - -Wrap editable surfaces in a permanently mounted `EditProvider`, then toggle the -standalone surface's `edit` prop or a `CodeView` item's `edit` flag. Standalone -surface DOM and imperative file/diff instances stay mounted across the toggle. -The provider's `createEditor` factory gives each editable surface or `CodeView` -item its own `Editor`, cached by `editorOptions` object identity: an edit -session that restarts with the same options object reuses its editor, and -surfaces editable at the same time need distinct options objects. - -Pass creation-time callbacks and behavior through the surface's `editorOptions`; -the factory can spread shared defaults before the surface options so the surface -wins. Keep factories and object props stable: define static values at module -scope, or use `useCallback` and `useMemo` when they depend on component values. +Edit mode adds text editing, multiple selections, undo and redo, search and +replace, markers, and custom selection actions to Pierre file and diff +components. The existing component continues to own syntax highlighting, diff +layout, annotations, SSR markup, and virtualization. - +Use it when a user needs to review and correct rendered code without replacing +the surrounding file or diff UI with a separate editor. + +For the complete lifecycle, see +[Handle changes and completion](#edit-mode-handle-changes-and-completion). The +later sections cover +[history, focus, and view state](#edit-mode-control-the-editor), +[annotations and custom editing UI](#edit-mode-extend-the-editing-ui), and +[performance and loading](#edit-mode-performance-and-loading). For +component-specific integration details, see [React API](#react-api), +[Vanilla JS API](#vanilla-js-api), and [CodeView editing](#codeview-editing). + +### Enable editing + +An `Editor` attaches to one rendered `File`, `VirtualizedFile`, `FileDiff`, or +`VirtualizedFileDiff`. React components and `CodeView` create and attach editors +for you; standalone vanilla components use `editor.edit(instance)` directly. + +Each editor is dedicated to either `'file'` or `'file-diff'` and attaches to one +matching component at a time. -Because editors are cached by options identity, passing one stable -`editorOptions` object to every surface shares one `Editor` across them. This is -the pattern for IDE-like UIs that edit one file at a time across many files, -normally combined with `persistState` so each file's contents, history, -selections, and scroll position survive switches — see -[Persisting Editor State](#edit-mode-persisting-editor-state): +#### React - +Mount one stable `EditProvider`, forward all three factory arguments, and set +`edit` on a `File`, `FileDiff`, `MultiFileDiff`, or `PatchDiff` to begin +editing. -Changes to `createEditor` or `editorOptions` do not disturb an active edit -session; their latest values apply the next time `edit` transitions from false -to true. Use `onAttach` with your own ref when controls need imperative APIs -such as history, selections, markers, save, or search. Use `Virtualizer` for -large editable files. The `FileDiff` tab below wires `onEditComplete` to accept -or revert the session. +Pass `onEditChange` to monitor live changes while in edit mode. Use +`onEditComplete` to accept or reject the result when the edit session ends. See +[Handle changes and completion](#edit-mode-handle-changes-and-completion) for +their lifecycle and event payloads. + + + +`createEditor`, `editorOptions`, `editStateKey`, and `ownsVerticalViewport` are +creation-time inputs for an editor. Changing them does not replace an active +editor; the latest values apply when a later edit session creates another +editor. Keep factories and object props stable across renders. -### Vanilla JS +#### Vanilla JS -Render with `File` or `FileDiff` first, then attach the editor with -`editor.edit(component)`. Use `VirtualizedFile` and `VirtualizedFileDiff` for -large editable files. Set `onEditComplete` in the surface's options to accept or -revert when the session ends. Call `cleanUp()` when the editor is removed — its -disposer runs completion for you. The `FileDiff` tab below shows the accept -flow. +Render the component first, then attach an editor of the matching kind. Keep the +function returned by `edit()` and call it for a normal, install-capable +completion. + +Set `onEditChange` in the component's `FileOptions` or `FileDiffOptions` to +monitor live changes while in edit mode. Use `onEditComplete` to accept or +reject the result when the edit session ends. See +[Handle changes and completion](#edit-mode-handle-changes-and-completion) for +their lifecycle and event payloads. -### CodeView - -`CodeView` manages one `Editor` per editable item. In React, wrap it in the same -app-level `EditProvider` used by other editable surfaces, set `edit: true` on an -item, and pass creation-time item-editor behavior through the `CodeView` -`editorOptions` prop. In vanilla JS, pass `createEditor` in `CodeViewOptions` -instead. Increment the item's `version` whenever `edit` changes. - -`CodeView` uses item-aware callbacks. `onItemEditChange(event, item)` reports -each live change with the owning item; edit mode manages the item's annotation -positions during the session, so do not sync them back into the item (those will -reference the original un-modified `lineAnnotations`). - -`onItemEditComplete(event, item, nextItem)` is the accept/revert boundary for an -item, called once when a changed edit session ends (edit false, the item is -removed or `CodeView` unmounts). `nextItem` is the accepted replacement -`CodeView` built for you — the same item with the completed `file`/`fileDiff` -and annotations, `edit: false`, and a bumped `version`. Return `'accept'` -(re-key the event's value first if you use `cacheKey`s): `CodeView` installs the -value and applies `nextItem` for you. Return `'reject'` to revert. Sessions with -no changes do not fire it; a collapsed item suspends its session instead of -completing. Resetting, cleaning up, or unmounting completes changed sessions -too. - -In controlled mode (you pass `items`), you must also write `nextItem` into your -own item state before returning `'accept'`. `CodeView` applies it to its -internal records, but your `items` prop is the source of truth on the next -reconcile, so without this your next render would clobber the accepted edit with -the stale item. Because the versions match, that push reconciles as a no-op. +`editor.cleanUp('discard')` also runs completion, but never installs the +callback result. `cleanUp('recycle')` temporarily detaches rendering without +ending the session; virtualized hosts use it when a component leaves the render +window. + +#### CodeView + +`CodeView` owns one editor per editable item. React gets the factory from the +nearest `EditProvider`; vanilla `CodeView` takes `createEditor` in its options. +Set `edit: true` on an item and increment its `version` whenever `edit` changes. -Editors retain their document and history while items move in and out of the -virtualized window. - -`editorOptions` and provider-factory changes are creation-time inputs: active -item sessions keep their current editor, while the latest values apply to the -next item that enters edit mode. Simultaneously edited items always receive -independent editor instances. - -### Completing an edit - -Every editable surface takes two component-level callbacks — as `File`, -`FileDiff`, `MultiFileDiff`, and `PatchDiff` props, or as `FileOptions` / -`FileDiffOptions` members in vanilla JS: - -- `onEditChange(event)` is the per-surface live change stream. Prefer it over - `EditorOptions.onChange`: component options are live (swap the handler - mid-session and the next change routes to it), while `editorOptions` are - next-session inputs. `CodeView` items report through `onItemEditChange` - instead. -- `onEditComplete(event)` runs once when a changed session ends — the surface's - `edit` turns off, or it unmounts. It is the accept/revert boundary. - -The event is frozen and carries the finished edit alongside the values a revert -restores: - -- `file` / `fileDiff` — the detached result built from the session, installed on - accept. Re-key it in place if you use `cacheKey`s - (`event.file.cacheKey = '…'`). -- `originalFile` / `originalFileDiff` — the latest caller-provided value, which - a revert returns to. It is the current external input, not necessarily what - editing started from (compatible updates can arrive mid-session). -- `oldFile` / `newFile` (diff only) — complete files for updating file-pair or - patch inputs, or other file-based state. -- `lineAnnotations` — the final annotation collection; `originalLineAnnotations` - is the collection from before completion. - -Return `'accept'` or `'reject'` to decide the edit: - -- **`'accept'`** — the surface installs the event's `file`/`fileDiff` - immediately, so put that same object back into your application state so - future renders won't blow away your edits. The React bridge attempts to hold - the accepted value on screen until your state update's props catch up. -- **`'reject'`** — the surface restores `originalFile`/`originalFileDiff`. A - missing handler also automatically rejects. There's nothing to store with - state if you reject because that's the state you should already be holding - onto. - -The component owns the accepted value — you never hand it back, so a clone can't -slip in — but an accepted value may not reuse the replaced value's non-empty -`cacheKey` as cacheKeys must each represent unique pieces of content. - -Accepting installs `lineAnnotations` as the surface's new annotation baseline, -so commit that collection alongside the accepted `file`/`fileDiff`. Recycling a -virtualized surface is not completion; unmounting a changed session is, but the -result is not installed — the callback is your chance to capture it. - -### Editing with Line Annotations - -While editing a file or diff, edit mode owns annotation positions. As the -document changes, the editor remaps the current collection internally and -re-renders the annotation rows — you should **not** mirror those changes back -into the file or diff or else it can break the revert behavior. The live change -events (`onEditChange` on the component, or `onChange` on the editor) still -report the remapped collection as `lineAnnotations` if you want to observe them. - -You should bear in mind if you do update any annotations while editing, any -positional remaps the annotations had will be reset. For now we'd recommend -disabling annotation updates while an edit is in progress. It can also result -line annotation inconsistencies if you decide to reject the edit with undo. - -On completion, `event.lineAnnotations` is the final collection (see -[Completing an edit](#edit-mode-completing-an-edit)): accept installs it as the -surface's new annotation baseline, so commit it alongside the accepted -`file`/`fileDiff`; revert keeps whatever the surface currently shows and moves -nothing. - -Remapping follows these rules: - -- A `LineAnnotation`, or a `DiffLineAnnotation` on the `additions` side, follows - structural edits in the editable file. Inserting lines above it moves it down; - deleting earlier lines moves it up. -- A `DiffLineAnnotation` on the `deletions` side stays attached to the read-only - old-file side and is not remapped. -- `lineNumber: 0` remains a file-level annotation and is not remapped. -- If you delete the full content of a line and it's trailing newline that has an - annotation, it will also remove the annotation -- Deleting only the line break before an annotated line while retaining its text - merges that text into the previous line and moves the annotation to the merged - line. -- Undo and redo restore or remove annotations with the corresponding document - state. - -For annotations that survive, remapping changes only their coordinates. Metadata -is preserved, so give each interactive annotation a stable, position-independent -ID in `metadata` and key application-owned drafts or UI state by that ID. - -The change events carry either annotation shape: standalone `File` surfaces emit -`LineAnnotation[]`, while diff surfaces emit `DiffLineAnnotation[]`. Narrow -`lineAnnotations` before reading the diff-only `side` property. Use -`isFileAnnotationCollection` or `isDiffAnnotationCollection` when the surface -type is not already known. +Use `onItemEditChange(event, item)` to monitor an item's live changes while in +edit mode. Use `onItemEditComplete(event, item, nextItem)` to accept or reject +the result when its edit session ends. `nextItem` is the completed replacement. +In controlled React mode, write it into your `items` state before returning +`'accept'`. A collapsed or virtualized item keeps its session active; turning +editing off, removing the item, resetting, cleaning up, or unmounting ends it. +See [Handle changes and completion](#edit-mode-handle-changes-and-completion) +for the shared lifecycle and event payloads. -}> - React may recreate annotation content when annotations are removed, restored, - or reordered, or when a `CodeView` item leaves the virtualized window. Keep - drafts and other interactive state in application-owned state keyed by a - stable annotation metadata ID. We intend to preserve annotation node identity - in a future release, so current remounting behavior is not a long-term API - contract. - +Use `getEditStateKey(item)` for per-item edit state retention. It is evaluated +only when that item's editor is created. Item editor state retains selections +and horizontal code scroll, but they never own or restore the shared CodeView +scroll position. -Use one `Editor` per concurrently editable standalone surface. `CodeView` -instead creates and manages one editor per editable item. See the -[playground](/playground) for the complete interaction: submit a line -annotation, enable editing, insert a line above it, remove its logical line, -then use undo and redo. The playground controls demonstrate the integration; -they are not additional public editor options. +See [CodeView editing](#codeview-editing) for item ownership, removal, and +controlled-state details. -### Selection Action +### Handle changes and completion -Selection Action is an opt-in edit mode feature for showing custom UI alongside -the current selection — useful for quick transforms, refactor prompts, or other -selection-scoped tools. Set `enabledSelectionAction: true` and return your UI -from `renderSelectionAction`; a floating popover holding your UI appears after -the user creates a ranged selection. Programmatic `setSelections` and `setState` -calls update the selection without opening the popover. The popover can hold any -number of actions. +Use the callbacks exposed by each component: - +- React `File`, `FileDiff`, `MultiFileDiff`, and `PatchDiff` expose + `onEditChange` and `onEditComplete` as component props. +- Vanilla `File` and `FileDiff` expose the same callbacks in `FileOptions` and + `FileDiffOptions`. +- React and vanilla `CodeView` use `onItemEditChange` and `onItemEditComplete` + so each callback also receives the owning item. -`renderSelectionAction` runs when the popover opens. Its context includes the -active `selection`, the editable `textDocument`, helpers to read or modify the -selection (`getSelectionText`, `replaceSelectionText`, `applyEdits`), and -`close` to dismiss the popover: +`onEditChange(event)` is the live document change stream. Its +`EditorChangeEvent` contains: - +- `changes` contains normalized text edits with zero-based ranges and offsets. +- `file` is the current editable document, or the editable new side of a diff. +- `lineAnnotations` is the current remapped annotation collection when present. +- `editor` is the attached editor after the change. Pull state with + `event.editor.getViewState()` or `event.editor.getEditState()` only when + needed. -### Persisting Editor State +Do not feed `event.file` or `event.lineAnnotations` back into the active +component on every change. Edit mode already owns the live document and +annotation positions. Use the event for autosave, validation, or application +side effects. -By default, each edit session starts fresh: switching files (or re-entering edit -mode) discards the previous file's edits, history, and selections. Set -`persistState: true` to keep per-file state alive across file switches on the -same `Editor` instance. State is keyed by the file's `cacheKey`, so every -editable file must provide one — unique and stable (the editor throws -otherwise). Diffs parsed with `parseDiffFromFile` derive a cache key from the -file pair automatically. +Most of the time you probably won't need this API. -Two layers persist: + -- **File contents and undo history.** The editor caches each file's text - document on the instance. When a file whose `cacheKey` it has already seen - attaches again, the editor swaps the cached document back in — the host keeps - passing the original `contents`, and the edited text with its full undo - timeline returns. -- **Editor state.** Selections and scroll positions — the code scroller's - `scrollLeft` and the surrounding viewport's `scrollTop` — are written to - `persistStateStorage` when the editor detaches or switches files, and restored - on the next attach with the same `cacheKey`. If the user scrolls before an - async restore lands, the restore leaves the viewport alone. +Completion runs whenever an edit session ends, including untouched, +annotation-only, and fully undone sessions. For React components and CodeView +items, completion is triggered when `edit` changes from `true` to `false` or the +component or item unmounts. Direct `Editor` usage ends the session through the +function returned by `edit()`. You must return `'accept'` or `'reject'` string +literals to instruct the component what to do next. -A file with no stored record yet — its first open — starts scrolled to the top -instead of inheriting whatever offset the previous file left behind in a shared -scroll container. +The frozen completion event contains the detached result and the latest external +values a rejection restores: - +- `file` / `fileDiff` is the completed result. +- `originalFile` / `originalFileDiff` is the latest caller-provided value. +- Diff events also include complete `oldFile` and `newFile` values. +- `lineAnnotations` is the final collection; `originalLineAnnotations` is the + latest caller-provided collection. +- `editor` is detached, but final `getViewState()` and `getEditState()` remain + available during the callback. -`persistStateStorage` selects where the serializable state lives: `'inMemory'` -(the default) lasts as long as the `Editor` instance, `'indexedDB'` survives -page reloads, and a custom `IStateStorage` — an object with `get(cacheKey)` and -`set(cacheKey, state)`, synchronous or async — bridges to your own store. Cached -text documents always stay on the editor instance regardless of the storage -choice, so persisting content across file switches means reusing one editor -rather than creating one per file. +Return `'accept'` to install the completed value while the component remains +mounted, or `'reject'` to restore the latest external value. A missing handler +rejects automatically. Store an accepted value in application state so a later +render does not replace it with the original stale `file` or `fileDiff`. -In React, reuse one editor by passing the same `editorOptions` object to every -surface — `EditProvider` caches editors by options identity, so a stable object -yields one instance across file switches: +For `CodeView`, `onItemEditComplete(event, item, nextItem)` receives the +completed replacement item. Returning `'accept'` installs `nextItem`; a +controlled React owner must also write that item into its `items` state. +Returning `'reject'` restores the latest external item. - +Unmounting a component or item still runs completion, but does not install the +result. The callback remains the place to capture or persist the final state. -### Markers +Accept and reject control what the component displays. They do not clear a keyed +editing session. -Markers add inline diagnostics — errors, warnings, and other annotations — to -the editable surface, with a hover popover that shows each marker's message. -They are useful for surfacing linter or language-server output alongside live -edits. +### Retain edit state in memory + +By default during any `edit` session, the `Editor` will manage editor state for +you, including undo/redo history. However, once the `edit` session has ended, +that session data will be thrown away unless you provide an `editStateKey` to +your `Editor` instantiation. + +Future edit sessions with the same `editStateKey` can resume that undo history +and other editor state, including selections and cursor position. + +For React components, simply pass the key as props: -Each marker is positioned with zero-based `start` and `end` positions and a -`severity` that drives its color and popover styling: +```tsx + +``` - +For VanillaJS same key can be passed as the third argument to `new Editor()` or +returned by the `CodeView` `getEditStateKey` option or prop. A key retains: + +- Current document contents, name, language, and undo/redo history +- Cursor and selections +- Surface-local horizontal `scrollLeft` +- Vertical `scrollTop` only for an explicitly owned viewport +- The old-side baseline and hunk metadata needed to resume a FileDiff safely + +Retention is in memory, bounded by an LRU, and does not survive page reload. +File and file-diff keys use independent namespaces. The same kind and key cannot +be active in two editors at once. A retained FileDiff can resume only against a +compatible old-side baseline. -Call `editor.setMarkers(markers)` after the editor has attached to a surface -(calling it before attaching throws). Inlining the array lets TypeScript check -the `severity` literals against the `Marker` type without importing it. +Changing a key while an editor is active does not re-key that editor. The new +key applies when a later editor is created. - +#### Clear retained EditState + +`EditStateManager` clears inactive retained sessions. Clearing an active or +missing key returns `false` and does not alter the live editor. -Markers re-anchor as the document changes, so they stay attached to their text -while you edit. Pass an empty array to clear them. +```ts +import { EditStateManager } from '@pierre/diffs/edit'; -### History +const editStateKey = 'review:example.ts'; -Each editor keeps a single undo stack per file. Typed input and programmatic -`applyEdits` calls join the same timeline, so a mixed sequence undoes exactly -like a hand-typed one, and each undo or redo restores the selections that went -with the edit. Drive it with `undo()` and `redo()` — the same commands behind -`Cmd/Ctrl+Z` — and read `canUndo` and `canRedo` for toolbar state. History lives -in the file's text document, so with -[`persistState`](#edit-mode-persisting-editor-state) it survives file switches. +// Remove the complete retained session. +EditStateManager.clear('file', editStateKey); -You can limit the stack size with `historyMaxEntries` in -[Editor Options](#edit-mode-editor-options) (default is `100`). +// Keep the draft while selectively resetting retained state. +EditStateManager.clear('file', editStateKey, { history: true }); +EditStateManager.clear('file', editStateKey, { selections: true }); +EditStateManager.clear('file', editStateKey, { view: true }); +EditStateManager.clear('file', editStateKey, { editor: true }); + +// Remove every inactive retained file and file-diff session. +EditStateManager.clearAll(); + +// Retain up to 50 inactive entries in each namespace. +EditStateManager.setCapacity(50); +``` + +`{ document: true }` removes the complete entry because history and editor state +depend on that document. `{ editor: true }` clears both selections and view +state. `setCapacity(capacity)` changes the maximum number of inactive entries +retained by each namespace; the default is `100` per namespace. + +### Initialize state on first attach + +Use `initialState` to provide application-owned state when constructing an +editor. `documentKind` is required and must match the editor kind. Every other +field is optional: + +- `document` supplies the live `TextDocument`, including its current contents + and any undo/redo history. +- `fileInfo` supplies the file name and optional language override. +- `editor` supplies selections and editor-owned scroll state. +- `diffSession` supplies the old-side baseline and hunk metadata for a + `file-diff` editor. + +The editor adopts `initialState`, it doesn't clone it. On its first attachment, +it fills omitted fields from the attached component and defaults. `initialState` +is constructor-only and is consumed by that first attachment. + +For a `file-diff` editor, view-only initialization can omit the document and +diff-session fields. Transferring an edited document and its undo/redo history +requires the matching `document`, `fileInfo`, and `diffSession` from the same +complete `EditState`; diff-session metadata is not standalone state. + +### Persist a draft across reloads + +`editStateKey` retains an editing session only in memory. To continue editing in +a new page session, persist the latest `FileContents` and, optionally, its +`EditorViewState`. Rebuild those values as `initialState` when editing begins +again. See +[Initialize state on first attach](#edit-mode-initialize-state-on-first-attach) +and +[View state and viewport ownership](#edit-mode-view-state-and-viewport-ownership) +for the corresponding APIs. + +- The latest `FileContents`. Save `event.file` from `onEditChange` to recover an + in-progress draft, and save the accepted `event.file` from `onEditComplete` as + the application's final file state. +- The optional `EditorViewState` returned by `getViewState()`. It contains + copied selections and scroll positions and is safe to encode as JSON. + +On the next page load, construct a fresh `TextDocument` from the persisted file +and supply it through `initialState` with the file identity and optional +`EditorViewState`. Pass the same file to the component. The new document +restores the latest contents with fresh undo/redo history; the editor builds any +omitted state from the attached component. + + + +`onEditChange` runs for document changes, not selection or scroll movement. The +example therefore also captures the current file and `EditorViewState` at an +explicit save point. Applications can do the same before navigation or at +another application-owned checkpoint. + +`EditorViewState` is different from the complete `EditState` returned by +`getEditState()`. Complete state includes the live document and undo/redo +history, is borrowed by reference, and can contain non-JSON annotation values, +functions, DOM nodes, and cycles. It is not a serialization contract. + +Pierre does not currently expose serializable undo/redo history. Restoring a +persisted file and `EditorViewState` starts a new history: edits made before the +reload cannot be undone in the new page session. + +### Control the editor + +Use `onAttach` to capture an editor instance for toolbars and to opt into +initial focus. At this point the document and editable DOM are ready. + + + +Vanilla `CodeView` can add the same behavior in its factory: + + + +`'first-visible'` targets the first editable row whose top is inside the usable +viewport. `offset` adds a non-negative CSS-pixel inset and `preventScroll: true` +preserves vertical position. Numeric line numbers are one-based; character +offsets are zero-based. + + + +#### Undo and redo + +Typed input and `applyEdits()` share one timeline. `undo()` and `redo()` restore +the associated selections; `canUndo` and `canRedo` drive toolbar state. +`historyMaxEntries` defaults to `100`. -### Using Worker Pool +#### View state and viewport ownership -Edit mode works with a worker pool out of the box: a file being edited renders -on the main thread with the token metadata the editor needs, while the pool -keeps rendering every other file on the page. Keystroke re-highlighting always -runs on the main thread, with or without a pool. +`getViewState()` returns copied selections and restorable component-owned scroll +offsets. An attached component reports horizontal `scrollLeft`. It reports +vertical `scrollTop` only when the editor was constructed with +`ownsVerticalViewport: true` and the component exposes an exclusive element +viewport. -Setting `useTokenTransformer: true` on the pool's `highlighterOptions` is an -optional optimization for edit-heavy apps: pool-rendered markup is then already -editor-compatible, so entering edit mode skips a one-time re-render of that -file. It costs larger DOM for every rendered file, which is why it is off by -default. +`setViewState()` requires an attached editor. It restores selections and +horizontal scroll; `scrollTop` is applied only to an owned viewport. -See [Worker Pool](#worker-pool) for worker factory setup and pool options. In -vanilla JS, pass the pool as the second argument to `File` or `FileDiff`. In -React, wrap your tree in `WorkerPoolContextProvider`. Then attach the editor as -usual. +`ownsVerticalViewport` defaults to `false` and is captured by the constructor. +Enable it only when one editable component exclusively owns the vertical +scroller. Virtualization does not imply ownership, and later `setOptions()` +calls cannot change it. Never enable it for individual CodeView items because +they share one viewer viewport. - +The retained view-state checkpoint updates after synchronization, edits, +explicit `setViewState()` calls, recycling, and completion. Pure selection or +scroll movement does not update that checkpoint; call `getViewState()` for the +exact live position. -### Lazy Importing +### Extend the editing UI -Because `@pierre/diffs/edit` is a standalone entry point, you can dynamic-import -it only when the user enters edit mode. That keeps the initial page bundle -smaller and can improve LCP (Largest Contentful Paint) on pages where editing is -rare. +#### Selection Action - +Set `enabledSelectionAction: true` and return custom UI from +`renderSelectionAction` to show a popover beside a user-created ranged +selection. Programmatic `setSelections()` and `setViewState()` update the +selection without opening it. -### API Reference + -These methods are available on an `Editor` instance. Attach it to a rendered -`File` or `FileDiff` with `edit()` first. +The render context provides the active selection, live document, helpers to read +or replace selected text, `applyEdits`, and `close`: - + -#### Editor Options +#### Markers -Pass these when constructing `new Editor({ ... })`, or update them later with -[`setOptions`](#edit-mode-api-reference). +Markers add inline diagnostics with severity styling and hover messages. Call +`setMarkers()` after attachment; pass an empty array to clear them. Marker +positions re-anchor as the document changes. - + -`EditorKeymap` is an array of `{ platform?, bindings }` groups. `platform` can -be `mac`, `windows`, or `linux`; omit it to enable the group's bindings -everywhere. Later groups take precedence when bindings overlap. When set, custom -bindings take precedence, while unmatched shortcuts fall back to the built-in -keymap. Keymaps are immutable; pass a new array to `setOptions` to change one. + -#### `onChange` +#### Line annotations -The third argument is an `EditorChangeEvent`. Its `changes` array contains every -normalized edit in the change: +Edit mode owns annotation positions during a session. Additions-side and file +annotations follow structural edits; deletion-side annotations and file-level +`lineNumber: 0` annotations stay fixed. Undo and redo restore annotations with +their document state. - +Do not mirror remapped `event.lineAnnotations` back on every change. A genuinely +new caller-provided annotation array replaces the edit owned collection and may +trigger unintended positional reverts. Reject restores the latest external +collection; accept installs the final collection, which should be committed +alongside the accepted file or diff. -Ranges use zero-based line and character positions. +For interactive annotation content, keep application state under a stable, +position-independent metadata ID. React may recreate annotation content when +rows move, disappear, or leave a virtualized window. -#### `onAttach` +### Performance and loading -Use `onAttach` to opt into initial caret placement. At this point the text -document and editable DOM are ready. In React, pass a stable `editorOptions` -object to any editable surface, including `CodeView`: +#### Worker Pool - +The edited file renders on the main thread while a worker pool continues to +render other components. Setting `useTokenTransformer: true` in the pool's +highlighter options can avoid a one-time re-render when entering edit mode, at +the cost of larger markup for every pool-rendered file. -Vanilla `CodeView` can add the same behavior in its editor factory: + - +See [Worker Pool](#worker-pool) for setup details. -`'first-visible'` selects the first editable row whose top falls inside the -usable viewport and places the caret at character zero. If no editable row top -is visible, the call does nothing. Set `offset` to a non-negative number of CSS -pixels to move the usable top below the viewport if needed. With -`preventScroll: true`, the focus request does not change the vertical scroll -position. +#### Lazy import -You can also target a specific document position: +`@pierre/diffs/edit` is a separate entry point, so it can be loaded only when +the feature is used. - + + +### API reference -Numeric `lineNumber` values are one-based, while `character` values are -zero-based. Numeric targeting works on every attached editor. Any targeted focus -replaces the current selection, so use either this initial placement or restored -selection/view state as the owner of the edit session's starting position, not -both. `CodeView` retains an editor while its item is recycled, so `onAttach` is -not repeated for virtualized remounts in the same edit session. + -### Keyboard Shortcuts +#### Editor options + +Pass options to `new Editor(documentKind, options, editStateKey?)`. +`initialState` and `ownsVerticalViewport` are constructor-only. `setOptions()` +merges later values, but not every option is retroactive: for example, +`matchBrackets` is captured when the document synchronizes, and changing +`historyMaxEntries` does not resize history already created for a document. +Callbacks that are read when an event occurs use their latest values. +`EditorOptions.onComplete` is a low-level observer. You cannot accept or reject +from this API. Most integrations should use the component completion callbacks +described above instead. + + + +`EditorKeymap` is an array of `{ platform?, bindings }` groups. Platform values +are `mac`, `windows`, or `linux`; later groups win when bindings overlap. Custom +bindings take precedence and unmatched shortcuts fall back to the built-in +keymap. + +### Keyboard shortcuts Shortcuts use Cmd on macOS and Ctrl on Windows and Linux. -Jumping to the document start or end uses the modifier with the Home -and End keys; on macOS, the modifier with ↑ and ↓ arrows works too. +Jumping to the document start or end uses that modifier with Home and + +End; on macOS, the modifier with the up and down arrows works too. diff --git a/apps/docs/app/(diffs)/docs/Installation/content.mdx b/apps/docs/app/(diffs)/docs/Installation/content.mdx index e2bf59712..e8133ad3e 100644 --- a/apps/docs/app/(diffs)/docs/Installation/content.mdx +++ b/apps/docs/app/(diffs)/docs/Installation/content.mdx @@ -10,10 +10,10 @@ Install Diffs with the package manager of your choice: The package provides several entry points for different use cases: -| Package | Description | -| ---------------------- | --------------------------------------------------------------------------------------------------- | -| `@pierre/diffs` | [Vanilla JS components](#vanilla-js-api), plus [utility functions](#utilities) | -| `@pierre/diffs/react` | [React components](#react-api) for rendering diffs and files | -| `@pierre/diffs/edit` | Low-level [edit mode](#edit-mode) `Editor` for attaching editing to rendered file and diff surfaces | -| `@pierre/diffs/ssr` | [Server-side rendering utilities](#ssr) for pre-rendering diffs with syntax highlighting | -| `@pierre/diffs/worker` | [Worker pool utilities](#worker-pool) for offloading syntax highlighting to background threads | +| Package | Description | +| ---------------------- | ----------------------------------------------------------------------------------------------------- | +| `@pierre/diffs` | [Vanilla JS components](#vanilla-js-api), plus [utility functions](#utilities) | +| `@pierre/diffs/react` | [React components](#react-api) for rendering diffs and files | +| `@pierre/diffs/edit` | Low-level [edit mode](#edit-mode) `Editor` for attaching editing to rendered file and diff components | +| `@pierre/diffs/ssr` | [Server-side rendering utilities](#ssr) for pre-rendering diffs with syntax highlighting | +| `@pierre/diffs/worker` | [Worker pool utilities](#worker-pool) for offloading syntax highlighting to background threads | diff --git a/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx b/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx index cbe33393f..3ea1b9e9c 100644 --- a/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx +++ b/apps/docs/app/(diffs)/docs/ReactAPI/content.mdx @@ -31,12 +31,20 @@ The React API exposes six main components: /> For editing, mount one stable `EditProvider` high in the tree. Standalone -surfaces use `edit` and `editorOptions`; `CodeView` uses item `edit` flags and -its own `editorOptions`. Each active surface or item receives an independent -editor. `UnresolvedFile` is not editable. See -[Edit mode → React](#edit-mode-react) and +components use `edit`, and optional `editorOptions` and `editStateKey`; +`CodeView` uses item `edit` flags, `editorOptions`, and optional +`getEditStateKey`. Forward the provider factory's document kind, options, and +key to `new Editor()`. Each active component or item receives an independent +editor. Two active editors of the same kind cannot share one key. +`UnresolvedFile` is not editable. See +[Edit mode → Enable editing](#edit-mode-enable-editing) and [CodeView → Editing](#codeview-editing) for lifecycle and callback details. +`editStateKey` retains state only in memory. See +[Retain edit state in memory](#edit-mode-retain-edit-state-in-memory) and +[Persist a draft across reloads](#edit-mode-persist-a-draft-across-reloads) for +the distinction. + Keep non-primitive props stable across renders. Define static files, diffs, options, styles, and factories at module scope; when they depend on component state or props, use `useMemo` for objects and arrays and `useCallback` for @@ -76,12 +84,16 @@ common set of props for configuration, annotations, and styling. The `File` component has similar props, but uses `LineAnnotation` instead of `DiffLineAnnotation` (no `side` property). -Every editable surface (`File`, `FileDiff`, `MultiFileDiff`, `PatchDiff`) also -takes `onEditChange` — the per-surface live change stream — and `onEditComplete` -— the one-time accept/revert boundary. Edit mode owns annotation positions -during a session, so you do not sync them back; accepting installs the final -collection. See [Completing an edit](#edit-mode-completing-an-edit) and -[Editing with line annotations](#edit-mode-editing-with-line-annotations). +Every editable component (`File`, `FileDiff`, `MultiFileDiff`, `PatchDiff`) also +takes `onEditChange` — the component's live change stream — and `onEditComplete` +— the accept/reject boundary when an edit session ends. Change events expose the +attached editor; completion events expose the detached editor and final state +during the callback. A missing completion handler rejects. Edit mode owns +annotation positions during a session, so do not sync them back per keystroke; +accepting installs the final file or diff, but you should make sure to hold onto +the final versions provided to ensure you don't revert the changes. See +[Handle changes and completion](#edit-mode-handle-changes-and-completion) and +[Line annotations](#edit-mode-extend-the-editing-ui-line-annotations). `CodeView` reuses many of the same option names internally, but it has its own controlled `items` mode, imperative mode with optional `initialItems`, viewer diff --git a/apps/docs/app/(diffs)/docs/TokenHooks/content.mdx b/apps/docs/app/(diffs)/docs/TokenHooks/content.mdx index a95080037..36bd0abf6 100644 --- a/apps/docs/app/(diffs)/docs/TokenHooks/content.mdx +++ b/apps/docs/app/(diffs)/docs/TokenHooks/content.mdx @@ -34,7 +34,7 @@ Shared behavior: attributes are preserved. On large files or many mounted diffs, this can have a noticeable performance cost. - If you are using a [Worker Pool](#worker-pool), set - `useTokenTransformer: true` on `WorkerPoolManager` — pool-rendered surfaces + `useTokenTransformer: true` on `WorkerPoolManager` — pool-rendered components follow the pool's options, not component callbacks. Worker pools can move highlighting work off the main thread, but they do not reduce the extra DOM size created by token metadata. diff --git a/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx b/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx index ca05f9199..f890d06d5 100644 --- a/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx +++ b/apps/docs/app/(diffs)/docs/VanillaAPI/content.mdx @@ -24,8 +24,9 @@ for syntax highlighting, theming, layout, and interactivity. > `UnresolvedFile` is currently beta/experimental and may change in future > releases. -See [Edit mode → Vanilla JS](#edit-mode-vanilla-js) for attaching `Editor` to a -rendered `File` or `FileDiff` with `edit()`. +See [Edit mode → Vanilla JS](#edit-mode-enable-editing-vanilla-js) for attaching +`Editor` to a rendered `File` or `FileDiff` with `edit()`. Pass an optional +`editStateKey` as the constructor's third argument for same-runtime retention. `UnresolvedFile` in vanilla supports both uncontrolled and controlled callbacks (`onMergeConflictResolve` / `onMergeConflictAction`). @@ -40,12 +41,16 @@ Both `FileDiff` and `File` accept an options object in their constructor. The `File` component has similar options, but excludes diff-specific settings and uses `LineAnnotation` instead of `DiffLineAnnotation` (no `side` property). -Editable surfaces take `onEditChange` (the live change stream) and -`onEditComplete` (the accept/revert boundary) as `FileOptions` / -`FileDiffOptions` members. Edit mode owns annotation positions during a session, -so you do not sync them back; accepting installs the final collection. See -[Completing an edit](#edit-mode-completing-an-edit) and -[Editing with line annotations](#edit-mode-editing-with-line-annotations). +Editable components take `onEditChange` (the live change stream) and +`onEditComplete` (the accept/reject boundary when a session ends) as +`FileOptions` / `FileDiffOptions` props. Change events expose the attached +editor; completion events expose the detached editor and final state during the +callback. A missing completion handler automatically rejects. Edit mode owns +annotation positions during a session, so do not sync them back per keystroke; +accepting installs the final collection (just don't forget to grab references to +the updated file/fileDiff and annotations if accepting). See +[Handle changes and completion](#edit-mode-handle-changes-and-completion) and +[Line annotations](#edit-mode-extend-the-editing-ui-line-annotations). When rendering direct file contents with `FileDiff.render`, pass `FileContents` for each existing side. Use `oldFile: null` for a new file, or `newFile: null` @@ -65,7 +70,11 @@ default; set `disableErrorHandling: true` when you want errors to rethrow. adding CodeView-specific controls like `layout`, `itemMetrics`, `stickyHeaders`, `pointerEventsOnScroll`, and `smoothScrollSettings`. Its class instance also exposes item-level methods such as `addItems`, `getItem`, `removeItem`, and -`updateItem`. See [CodeView](#codeview) for the dedicated guide. +`updateItem`. Editable items use +`createEditor(documentKind, options, editStateKey)` and optional +`getEditStateKey(item)`. See [CodeView](#codeview) for the dedicated guide and +[Retain edit state in memory](#edit-mode-retain-edit-state-in-memory) for state +inspection and clearing. Header customization and collapsing behavior: diff --git a/apps/docs/app/(diffs)/docs/Virtualization/content.mdx b/apps/docs/app/(diffs)/docs/Virtualization/content.mdx index d1c56c3a2..ff4214026 100644 --- a/apps/docs/app/(diffs)/docs/Virtualization/content.mdx +++ b/apps/docs/app/(diffs)/docs/Virtualization/content.mdx @@ -5,7 +5,7 @@ renders fast. It renders placeholders and spacer buffers for off-screen content, then renders visible lines in hunk-sized batches as you scroll. If your scrollable region is only code, start with `CodeView` instead. It is the -more optimized path: it owns the entire code surface, only renders what you can +more optimized path: it owns the entire code region, only renders what you can actually see, and is generally more performant and less prone to blanking. Use the lower-level virtualization APIs when you need a more flexible, @@ -76,9 +76,6 @@ In vanilla JS, create a `Virtualizer` instance and pass it into - Use `resizeDebugging` with the `Virtualizer` temporarily when tuning metrics, and to confirm everything is working properly. Don't forget to disable it in production. -- Edit mode's annotation feedback contract is unchanged on virtualized surfaces: - replace the current collection from `onChange` or `onItemEditChange` before a - later render. A virtualized surface may recreate rendered annotation content - as it leaves and re-enters the rendered window, so keep interactive annotation - state outside that content. See - [Editing with line annotations](#edit-mode-editing-with-line-annotations). +- While in edit mode, annotations (both their visibility and positions) will be + managed for you. Do not feed the onChange results back into component state. + See [Line annotations](#edit-mode-extend-the-editing-ui-line-annotations). diff --git a/apps/docs/app/(diffs)/edit/page.tsx b/apps/docs/app/(diffs)/edit/page.tsx index d4add721f..56e2246a3 100644 --- a/apps/docs/app/(diffs)/edit/page.tsx +++ b/apps/docs/app/(diffs)/edit/page.tsx @@ -26,7 +26,7 @@ export const metadata: Metadata = pageMetadata({ }); // Server-renders every edit demo so they all paint highlighted on first load -// and hydrate cleanly (no flash): the "Live editing" File surface, and the +// and hydrate cleanly (no flash): the "Live editing" File component, and the // lint-marker, find-in-file, undo-history, shortcuts, and selection files. export default async function EditRoute() { const [ diff --git a/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx b/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx index 450f29aa7..8dc28db60 100644 --- a/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx +++ b/apps/docs/app/(diffs)/playground/PlaygroundClient.tsx @@ -131,7 +131,7 @@ function isDirectView(viewMode: ViewMode): boolean { // a ``-typed options object into an annotated FileDiff would otherwise // widen its annotation callbacks to `undefined`). The Virtualizer views take // this as their options prop: it carries no callback keys, so it also spreads -// cleanly into the plain-file FileOptions their README surface uses. +// cleanly into the plain-file FileOptions their README component uses. export type SharedRenderOptions = Pick< FileDiffOptions, | 'diffStyle' @@ -757,12 +757,18 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { () => ( setEdit(true)} + onEdit={() => { + cancelledEdit.current = false; + setEdit(true); + }} onCancel={() => { cancelledEdit.current = true; setEdit(false); }} - onSave={() => setEdit(false)} + onSave={() => { + cancelledEdit.current = false; + setEdit(false); + }} /> ), [edit] @@ -1020,7 +1026,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { }, [isControlsOpen]); // Switching views ends any direct-view edit session; the scrolling views - // own per-surface controls instead. + // own per-component controls instead. const setViewModeAndResetEditor = useCallback((mode: ViewMode) => { setViewMode(mode); setEdit(false); @@ -1084,7 +1090,7 @@ export function PlaygroundClient({ prerenderedDiff }: PlaygroundClientProps) { colorMode === 'system' ? (resolvedColorScheme ?? 'system') : colorMode; // Pure rendering options shared by every view mode. Interaction and - // edit-specific options are layered on per surface below. + // edit-specific options are layered on per component below. const renderOptions = useMemo( () => ({ diffStyle, diff --git a/apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx b/apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx index 5180e2650..bdb8433d1 100644 --- a/apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx +++ b/apps/docs/app/(diffs)/playground/PlaygroundCodeView.tsx @@ -331,12 +331,18 @@ export function PlaygroundCodeView({ const renderHeaderMetadata = useStableCallback((item: PlaygroundItem) => ( toggleEdit(item.id, true)} + onEdit={() => { + cancelledEdits.current.delete(item.id); + toggleEdit(item.id, true); + }} onCancel={() => { cancelledEdits.current.add(item.id); toggleEdit(item.id, false); }} - onSave={() => toggleEdit(item.id, false)} + onSave={() => { + cancelledEdits.current.delete(item.id); + toggleEdit(item.id, false); + }} /> )); diff --git a/apps/docs/app/(diffs)/playground/PlaygroundEditButtons.tsx b/apps/docs/app/(diffs)/playground/PlaygroundEditButtons.tsx index f8c82b13c..125ef2424 100644 --- a/apps/docs/app/(diffs)/playground/PlaygroundEditButtons.tsx +++ b/apps/docs/app/(diffs)/playground/PlaygroundEditButtons.tsx @@ -2,7 +2,7 @@ // Header controls for a playground edit session: an Edit button, replaced by // Cancel + Save while the session is active. The React component serves the -// React surfaces; the DOM builder serves the vanilla Virtualizer view. Both +// React components; the DOM builder serves the vanilla Virtualizer view. Both // share the `.playground-edit-*` styles in globals.css. interface EditSessionButtonsProps { diff --git a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx index d59036fc8..563f950d0 100644 --- a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx +++ b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerElementView.tsx @@ -85,7 +85,7 @@ interface ElementVirtualizerFileProps { const EMPTY_FILE_ANNOTATIONS: LineAnnotation[] = []; -// The long README plain-file surface leading the list. It owns the same edit, +// The long README plain-file component leading the list. It owns the same edit, // line-selection, and gutter-comment behavior as each diff below it. function ElementVirtualizerFile({ options, @@ -116,7 +116,7 @@ function ElementVirtualizerFile({ ); // Save accepts the completed file under a fresh cacheKey and stores it as - // the surface's file; Cancel reverts to the current one. + // the component's file; Cancel reverts to the current one. const handleEditComplete = useCallback( (event: FileEditCompleteEvent) => { if (cancelled.current) { @@ -230,12 +230,18 @@ function ElementVirtualizerFile({ () => ( setEditing(true)} + onEdit={() => { + cancelled.current = false; + setEditing(true); + }} onCancel={() => { cancelled.current = true; setEditing(false); }} - onSave={() => setEditing(false)} + onSave={() => { + cancelled.current = false; + setEditing(false); + }} /> ), [editing] @@ -267,9 +273,9 @@ interface ElementVirtualizerDiffProps { const EMPTY_ANNOTATIONS: DiffLineAnnotation[] = []; -// One diff in the element-scroll list. Each surface is its own state island +// One diff in the element-scroll list. Each component is its own state island // with an edit toggle, editor options, and annotations. The app-level -// EditProvider creates an independent editor when that surface enters edit +// EditProvider creates an independent editor when that component enters edit // mode. function ElementVirtualizerDiff({ fileDiff, @@ -301,7 +307,7 @@ function ElementVirtualizerDiff({ ); // Save accepts the completed diff under a fresh cacheKey and stores it as - // the surface's diff; Cancel reverts to the current one. + // the component's diff; Cancel reverts to the current one. const handleEditComplete = useCallback( (event: FileDiffEditCompleteEvent) => { if (cancelled.current) { @@ -437,12 +443,18 @@ function ElementVirtualizerDiff({ () => ( setEditing(true)} + onEdit={() => { + cancelled.current = false; + setEditing(true); + }} onCancel={() => { cancelled.current = true; setEditing(false); }} - onSave={() => setEditing(false)} + onSave={() => { + cancelled.current = false; + setEditing(false); + }} /> ), [editing] diff --git a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerView.tsx b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerView.tsx index 01dd0ebe3..e60e7154c 100644 --- a/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerView.tsx +++ b/apps/docs/app/(diffs)/playground/PlaygroundVirtualizerView.tsx @@ -68,7 +68,7 @@ function fileAnnotationKey(annotation: VirtualizerFileAnnotation): string { // // Each diff header carries its own Edit button (in the header metadata slot), // replaced by Cancel/Save while a session is active; Edit attaches a per-file -// Editor to that diff and flips its new-file surface into contentEditable. +// Editor to that diff and makes its new-file side contentEditable. // Files are edited independently because one Editor only binds to one instance // at a time. // @@ -134,7 +134,7 @@ export function PlaygroundVirtualizerView({ readmeContainer.style.display = 'block'; content.appendChild(readmeContainer); fileAnnotationsRef.current = []; - const readmeEditor = new Editor({ + const readmeEditor = new Editor('file', { onAttach(attachedEditor) { attachedEditor.focus({ lineNumber: 'first-visible', @@ -279,7 +279,7 @@ export function PlaygroundVirtualizerView({ fileContainer.style.display = 'block'; content.appendChild(fileContainer); - const editor = new Editor({ + const editor = new Editor('file-diff', { onAttach(attachedEditor) { attachedEditor.focus({ lineNumber: 'first-visible', diff --git a/apps/docs/app/(diffs)/playground/constants.ts b/apps/docs/app/(diffs)/playground/constants.ts index 06a91ef8f..210dff8ec 100644 --- a/apps/docs/app/(diffs)/playground/constants.ts +++ b/apps/docs/app/(diffs)/playground/constants.ts @@ -211,7 +211,7 @@ export function getPlaygroundPreloadOptions( // Every diff below is built with `parseDiffFromFile` from complete old/new file // contents, so they are full (non-partial) diffs. Partial diffs (e.g. from // `parsePatchFiles`) would need a `loadDiffFiles` loader to hydrate, which these -// demo surfaces intentionally avoid. +// demo components intentionally avoid. // ----------------------------------------------------------------------------- const OLD_STYLES_CONTENT = `.button { @@ -280,7 +280,7 @@ const user = await getUser('123'); \`\`\` `; -// The plain-file items ship the README repeated 10x so the file surfaces are +// The plain-file items ship the README repeated 10x so the file components are // long enough to scroll on their own. Only the file items use this — the diff // fixtures keep their authored sizes (one long fixture plus the shorter // variants). @@ -288,7 +288,7 @@ const LONG_README_CONTENT = NEW_README_CONTENT.repeat(10); // The long README as a ready-made plain file: leads the CodeView items and // both Virtualizer lists. FileContents is never mutated by the library, so -// one shared instance is fine across surfaces. +// one shared instance is fine across components. export const LONG_README_FILE: FileContents = { name: 'README.md', contents: LONG_README_CONTENT, @@ -559,7 +559,7 @@ function buildLongFixtureContents(): { const LONG_FIXTURE_CONTENTS = buildLongFixtureContents(); -// Fresh parse per call so each surface (Virtualizer list, CodeView items) gets +// Fresh parse per call so each component (Virtualizer list, CodeView items) gets // its own FileDiffMetadata instance, matching how `variantDiff` builds the // replicated fixtures. `context: 8` widens jsdiff's default of 4 so the added // import near the top keeps line 1 inside the first hunk instead of leaving a diff --git a/apps/docs/app/(diffs)/playground/searchParams.ts b/apps/docs/app/(diffs)/playground/searchParams.ts index 6252e2fa0..91ae00661 100644 --- a/apps/docs/app/(diffs)/playground/searchParams.ts +++ b/apps/docs/app/(diffs)/playground/searchParams.ts @@ -50,7 +50,7 @@ const HUNK_SEPARATOR_VALUES = [ const LINE_HOVER_HIGHLIGHTS = ['disabled', 'both', 'number', 'line'] as const; const LINE_MODES = ['select', 'comment', 'none'] as const; -// The rendering surface used by the playground. 'diff' and 'file' render one +// The rendering view used by the playground. 'diff' and 'file' render one // directly controlled component; the virtualizer modes render scrolling lists; // 'codeview' renders a mixed list in CodeView's own scroller. export type ViewMode = (typeof VIEW_MODES)[number]; diff --git a/apps/docs/components/AppEditProvider.tsx b/apps/docs/components/AppEditProvider.tsx index 9cf2a0033..9f39cdd72 100644 --- a/apps/docs/components/AppEditProvider.tsx +++ b/apps/docs/components/AppEditProvider.tsx @@ -1,13 +1,19 @@ 'use client'; -import { Editor, type EditorOptions } from '@pierre/diffs/edit'; +import { + Editor, + type EditorDocumentKind, + type EditorOptions, +} from '@pierre/diffs/edit'; import { EditProvider } from '@pierre/diffs/react'; import type { ReactNode } from 'react'; function createEditor( - options: EditorOptions + documentKind: EditorDocumentKind, + options: EditorOptions, + editStateKey?: string ): Editor { - return new Editor(options); + return new Editor(documentKind, options, editStateKey); } interface AppEditProviderProps { diff --git a/apps/docs/scripts/generate-llms-txt.ts b/apps/docs/scripts/generate-llms-txt.ts index 079d4e300..2ff95625c 100644 --- a/apps/docs/scripts/generate-llms-txt.ts +++ b/apps/docs/scripts/generate-llms-txt.ts @@ -97,7 +97,7 @@ const SECTION_DESCRIPTIONS: Record> = { 'Vanilla classes for CodeView, FileDiff, File, UnresolvedFile, props, and low-level renderers', CodeView: 'One virtualized scroll region for mixed file and diff lists, with scrollTo targeting, viewer-wide selection, sticky headers, and header/footer regions', - Edit: 'Edit mode for File surfaces via Editor and EditProvider, with selections, history, search, actions, and shortcuts', + Edit: 'Edit mode for File components via Editor and EditProvider, with selections, history, search, actions, and shortcuts', Virtualization: 'Virtual scrolling for large diffs and files', CustomHunkSeparators: 'Built-in separator presets, CSS customization hooks, and the discouraged vanilla escape hatch', diff --git a/packages/diffs/src/components/CodeView.ts b/packages/diffs/src/components/CodeView.ts index 6e70af478..06380b035 100644 --- a/packages/diffs/src/components/CodeView.ts +++ b/packages/diffs/src/components/CodeView.ts @@ -30,6 +30,7 @@ import type { DiffsEditor, EditCompletionDecision, EditorChangeEvent, + EditorDocumentKind, HunkSeparators, PendingCodeViewLayoutReset, SelectedLineRange, @@ -339,6 +340,14 @@ export interface CodeViewModeItemMap { diff: CodeViewDiffItem; } +export type CodeViewItemEditCompleteHandler = < + TMode extends CodeViewMode, +>( + event: CodeViewItemEditCompleteEventMap[TMode], + item: CodeViewModeItemMap[TMode], + nextItem: CodeViewModeItemMap[TMode] +) => EditCompletionDecision; + type CodeViewModeItemContext< LAnnotation, TMode extends CodeViewMode, @@ -516,24 +525,31 @@ export interface CodeViewOptions controlledSelection?: boolean; onSelectedLinesChange?(selection: CodeViewLineSelection | null): void; layout?: CodeViewLayout; + /** + * Return an in-memory retention key for an item's editable draft and + * undo/redo history. Called only when CodeView creates the item's editor. + */ + getEditStateKey?(item: CodeViewItem): string | undefined; /** * Create an editor for an item entering edit mode (`edit: true`). Providing * this option is what enables item editing. Pass the given options into the - * editor constructor — `new Editor(options)` — so CodeView can route - * document changes to `onItemEditChange`. CodeView owns the returned - * editor's lifecycle: it attaches when the edited item mounts, re-attaches - * across virtualization unmounts and collapse (which suspend the session), - * and tears the editor down when the session ends (edit off or removal). - * Returning undefined declines the attach; CodeView retries on later - * render passes. + * editor constructor — `new Editor(documentKind, options, editStateKey)` — + * so CodeView can route document changes to `onItemEditChange` and retain + * history when requested. CodeView owns the returned editor's lifecycle: it + * associates with the edited item, suspends and resumes rendering across + * virtualization unmounts and collapse, and tears the editor down when the + * session ends (edit off or removal). Returning undefined declines the + * attach; CodeView retries on later render passes. */ createEditor?( - options: CodeViewCreateEditorOptions + documentKind: EditorDocumentKind, + options: CodeViewCreateEditorOptions, + editStateKey?: string ): DiffsEditor | undefined; /** * Called with the editor's `EditorChangeEvent` and the owning item whenever * the edited document changes, from internal (edit) changes or external - * (CodeViewItem) changes. + * (CodeViewItem) changes. The event contains that same attached editor. * * Do not feed these changes back into item state. */ @@ -542,11 +558,10 @@ export interface CodeViewOptions item: CodeViewItem ): void; /** - * Called once when a changed edit session ends: edit turned off, the item - * is removed from items, or the viewer tears down (`reset()`/`cleanUp()`, - * where the result is not installed because the viewer is going away). Not - * called for sessions without changes, or for collapse (which suspends the - * session until the item expands). + * Called once when an edit session ends: edit to false, the item is removed + * from items, or the viewer tears down (`reset()`/`cleanUp()`, where the + * result is not installed because the viewer is going away). Collapse does + * not call it because the session remains active until the item expands. * * The event carries the completed `file`/`fileDiff` built from the edit * session. `item` is the item that owned the session, and `nextItem` is @@ -556,13 +571,10 @@ export interface CodeViewOptions * item update path when the item still exists, and a controlled owner puts * the same `nextItem` into its state — or `'reject'` to revert. The event is * frozen; re-key the accepted value in place (`event.fileDiff.cacheKey = - * '…'`) before accepting. + * '…'`) before accepting. The event contains the detached editor with its + * final pre-detach state. */ - onItemEditComplete?( - event: CodeViewItemEditCompleteEventMap[TMode], - item: CodeViewModeItemMap[TMode], - nextItem: CodeViewModeItemMap[TMode] - ): EditCompletionDecision; + onItemEditComplete?: CodeViewItemEditCompleteHandler; /** Render a non-virtualized element at the very start of the scroll content, * before the first item. It is always rendered and scrolls with the content. @@ -669,9 +681,9 @@ export class CodeView { private idToItem: CodeViewItemMap = new Map(); private selectedLines: CodeViewLineSelection | null = null; // One editor per edit-mode item, created lazily via options.createEditor. - // Entries survive virtualization unmounts so a remounted item re-attaches - // its existing editor; attachedEditors tracks which entries are currently - // bound to a mounted instance. Each record shares its instance's options + // Entries survive virtualization unmounts with their editor association; + // attachedEditors tracks which entries currently render an editor surface. + // Each record shares its instance's options // state, whose `id` updateItemId keeps pointed at the current item. private itemEditors: Map> = new Map(); @@ -1180,7 +1192,7 @@ export class CodeView { let failure: unknown; for (const { record, instance } of teardownSessions) { try { - record.editor.cleanUp('complete'); + record.editor.cleanUp('discard'); instance?.cleanUp(); } catch (error) { if (!failed) { @@ -1318,8 +1330,8 @@ export class CodeView { } item.instance.cleanUp(true); - // Instance cleanup fully detached any attached editor. The editor itself - // stays in itemEditors so the item re-attaches it on remount. + // Instance cleanup suspends the editor surface. The association stays in + // itemEditors so the same component and editor resume on remount. this.attachedEditors.delete(item.item.id); item.element = undefined; if (element == null) { @@ -2050,8 +2062,8 @@ export class CodeView { * from the render loop so every mounted item passes through it: fresh * mounts, remounts after virtualization released the item, and items whose * edit flag was just turned on. Editors persist across unmounts, so a - * remounted item re-attaches its existing editor and resumes the retained - * document; the instance retains its private session model so the remount + * remounted item resumes its existing editor and retained document; the + * instance retains its private session model so the remount * paints the edited text without changing the host input. */ private attachItemEditor(item: CodeViewContextItem): void { @@ -2081,15 +2093,19 @@ export class CodeView { // renames keep it pointed at the right item. It also reads the change // callback off this.options at invocation time so later setOptions // swaps aren't stranded on the callback captured at creation. - const editor = createEditor({ - onChange: (event) => { - const latest = this.idToItem.get(state.id); - if (latest == null) { - return; - } - this.options.onItemEditChange?.(event, latest.item); + const editor = createEditor( + item.instance.type === 'file-diff' ? 'file-diff' : 'file', + { + onChange: (event) => { + const latest = this.idToItem.get(state.id); + if (latest == null) { + return; + } + this.options.onItemEditChange?.(event, latest.item); + }, }, - }); + this.options.getEditStateKey?.(item.item) + ); if (editor == null) { return; } @@ -2136,11 +2152,10 @@ export class CodeView { if (removedItem == null && item != null) { const { edit = false, collapsed = false } = item.item; if (edit) { - // If we are collapsed, we'll tear down the editor until we need it - // again, but undo history/cursor/selections will still be saved - // if/when we resume + // Collapse suspends editor rendering. Keep its component association, + // document, and undo history for expansion; DOM-backed state resets. if (collapsed && this.attachedEditors.delete(id)) { - record.editor.cleanUp(); + record.editor.cleanUp('recycle'); } continue; } diff --git a/packages/diffs/src/components/File.ts b/packages/diffs/src/components/File.ts index 921069066..eaa3a8b31 100644 --- a/packages/diffs/src/components/File.ts +++ b/packages/diffs/src/components/File.ts @@ -13,6 +13,7 @@ import { THEME_CSS_ATTRIBUTE, UNSAFE_CSS_ATTRIBUTE, } from '../constants'; +import type { FileEditCompleteEvent } from '../editor/types'; import { type GetHoveredLineResult, InteractionManager, @@ -23,6 +24,7 @@ import { import { ResizeManager } from '../managers/ResizeManager'; import { FileRenderer, type FileRenderResult } from '../renderers/FileRenderer'; import { SVGSpriteSheet } from '../sprite'; +export type { FileEditCompleteEvent } from '../editor/types'; import type { AppliedThemeStyleCache, BaseCodeOptions, @@ -98,35 +100,13 @@ export interface FileHydrateProps extends Omit< prerenderedHTML?: string; } -/** - * `onEditComplete` event argument when an edit session ends with changed - * contents. - * - * `file` is a fresh FileContents with the edited contents and no `cacheKey` - * set; accepting installs it. - * - * `originalFile` is the last `file` the component was given externally; a - * revert restores it. - * - * `lineAnnotations` is the completed annotation collection, potentially - * modified based on the edit changes to keep annotations aligned to their - * intended targets - * - * `originalLineAnnotations` is the last collection provided to the component - * externally, which a revert keeps. - */ -export interface FileEditCompleteEvent { - file: FileContents; - lineAnnotations: LineAnnotation[] | undefined; - originalFile: FileContents; - originalLineAnnotations: LineAnnotation[]; -} - /** * Decides a completed edit synchronously: return `'accept'` to install the - * event's `file`, or `'reject'` to restore `originalFile`. The event is frozen, - * so re-key the accepted file in place (`event.file.cacheKey = '…'`) before - * accepting. A missing handler rejects. + * event's `file` and annotations, or `'reject'` to restore the original values. + * The event is frozen, so re-key the accepted file in place + * (`event.file.cacheKey = '…'`) before accepting. The event's editor is + * detached and returns its final state from `getViewState()`. A missing handler + * rejects. */ export type FileEditCompleteHandler = ( event: FileEditCompleteEvent @@ -167,10 +147,10 @@ export interface FileOptions onEditChange?(event: EditorChangeEvent): void; /** - * Fired when `edit` toggles false or a component unmounts. Only called if - * there are content changes resolving in a new file. If no callback is - * provided, then the component will always revert back to the last `file` - * passed into the component + * Fired when `edit` toggles false or a component unmounts, including when the + * final contents are unchanged. If no callback is provided, the component + * reverts to the last `file` and annotations passed into it. The callback + * receives the detached editor with its final pre-detach state. */ onEditComplete?: FileEditCompleteHandler; } @@ -190,25 +170,6 @@ interface HydrationSetup { lineAnnotations: LineAnnotation[] | undefined; } -interface PendingEditSessionReplacement { - /** - * The private editable file that was active before the new external file - * arrived. It is compared with the final replacement to determine whether - * the editor can preserve its history. - */ - editSessionFile: FileContents; - /** - * The cache key associated with the current editable file. The editor keeps - * using it until the replacement is rendered and ready to synchronize. - */ - prevExternalCacheKey: string | undefined; -} - -interface PendingPersistedDocumentRestore { - editSessionFile: FileContents; - previousContents: string; -} - function createEditSessionFile(file: FileContents): FileContents { const editSessionFile = { ...file }; delete editSessionFile.cacheKey; @@ -277,12 +238,9 @@ export class File< private editSessionAnnotations: | EditSessionAnnotations> | undefined; - private pendingEditSessionReplacement: - | PendingEditSessionReplacement - | undefined; - private pendingPersistedDocumentRestore: - | PendingPersistedDocumentRestore - | undefined; + // Keeps the outgoing editable file until its replacement is ready to sync, + // so the editor can decide whether to preserve or reset undo history. + private outgoingSessionFile: FileContents | undefined; protected renderedFile: FileContents | undefined; protected renderRange: RenderRange | undefined; protected enabled = true; @@ -372,8 +330,8 @@ export class File< // Install a replacement file from the caller; returns false when it is the // file already installed. During an edit session, the outgoing session file - // and cache key are kept aside until the editor syncs — it uses them to - // decide whether the swap keeps or resets undo history. + // is kept aside until the editor syncs so it can decide whether the swap + // keeps or resets undo history. protected updateExternalFile( incomingFile: FileContents, lineAnnotations?: LineAnnotation[] @@ -382,28 +340,20 @@ export class File< return false; } - const { - file: previousExternalFile, - pendingEditSessionReplacement: pendingReplacement, - } = this; const previousEditSessionFile = - pendingReplacement?.editSessionFile ?? this.editSessionFile; - const prevExternalCacheKey = - pendingReplacement?.prevExternalCacheKey ?? - previousExternalFile?.cacheKey; + this.outgoingSessionFile ?? this.editSessionFile; this.file = incomingFile; - this.pendingEditSessionReplacement = undefined; - this.pendingPersistedDocumentRestore = undefined; - - if (previousEditSessionFile != null) { - this.pendingEditSessionReplacement = { - editSessionFile: previousEditSessionFile, - prevExternalCacheKey, - }; - this.installExternalEditSession(incomingFile); - } else if (this.editor != null) { - this.createInitialEditSession(this.editor, incomingFile); + this.outgoingSessionFile = undefined; + + if (previousEditSessionFile != null || this.editor != null) { + this.outgoingSessionFile = previousEditSessionFile; + this.installEditSession( + incomingFile, + previousEditSessionFile == null + ? this.editor?.__getDocumentContents(incomingFile) + : undefined + ); } else { this.editSessionFile = undefined; } @@ -422,40 +372,24 @@ export class File< return true; } - private installExternalEditSession(externalFile: FileContents): void { - const editSessionFile = createEditSessionFile(externalFile); - this.editSessionFile = editSessionFile; - this.fileRenderer.beginEditSession(editSessionFile, externalFile); - } - - /** - * Create the first private editable file for an editor attachment. When a - * persisted document has different text, initialize the private file with - * that text so the restored editor document and rendered rows start in sync. - */ - private createInitialEditSession( - editor: DiffsEditor, - externalFile: FileContents + // A retained keyed document seeds a new session. Existing sessions omit it + // so incoming files render as external replacements and join undo history. + private installEditSession( + externalFile: FileContents, + retainedDocument?: FileContents ): void { - const editSessionFile = createEditSessionFile(externalFile); - const cachedContents = editor.__getCachedDocumentContents?.(externalFile); - const restoredPersistedContents = - cachedContents != null && cachedContents !== externalFile.contents; - - if (restoredPersistedContents) { - editSessionFile.contents = cachedContents; - this.pendingPersistedDocumentRestore = { - editSessionFile, - previousContents: externalFile.contents, - }; - } else { - this.pendingPersistedDocumentRestore = undefined; - } - + const usesExternalDocument = + retainedDocument == null || + (retainedDocument.name === externalFile.name && + retainedDocument.lang === externalFile.lang && + retainedDocument.contents === externalFile.contents); + const editSessionFile = createEditSessionFile( + retainedDocument ?? externalFile + ); this.editSessionFile = editSessionFile; this.fileRenderer.beginEditSession( editSessionFile, - restoredPersistedContents ? undefined : externalFile + usesExternalDocument ? externalFile : undefined ); } @@ -624,14 +558,14 @@ export class File< } public cleanUp(recycle = false): void { + const editor = this.editor; this.emitPostRender(true); // Tear the editor down while the code scroller still exists. A recycle - // persists its editor state; a full teardown ends the session, so the - // editor drops its stored document instead. - this.editor?.cleanUp(recycle ? 'recycle' : 'complete'); - this.editor = undefined; + // keeps its document and undo history; a full teardown drops them as the + // session ends. + editor?.cleanUp(recycle ? 'recycle' : 'discard'); if (!recycle) { - this.settleEditSession(false); + this.editor = undefined; } this.resizeManager.cleanUp(); this.interactionManager.cleanUp(); @@ -685,8 +619,7 @@ export class File< this.file = undefined; this.editSessionFile = undefined; this.editSessionAnnotations = undefined; - this.pendingEditSessionReplacement = undefined; - this.pendingPersistedDocumentRestore = undefined; + this.outgoingSessionFile = undefined; this.renderedFile = undefined; } this.enabled = false; @@ -716,7 +649,12 @@ export class File< ); } this.hydrateElements(fileContainer, prerenderedHTML); + // An editor attached before hydration may carry a retained keyed document. + // Render through the private edit session instead of adopting external + // markup, so the restored document owns the first hydrated paint. + const forceEditorRender = this.editor != null; if ( + forceEditorRender || shouldRenderCode(this.pre, file, this.options.collapsed) || shouldRenderHeader( this.headerElement, @@ -724,7 +662,11 @@ export class File< this.options.disableFileHeader ) ) { - this.render({ ...props, preventEmit: true }); + this.render({ + ...props, + forceRender: forceEditorRender || props.forceRender, + preventEmit: true, + }); } // Otherwise orchestrate our setup. else { @@ -837,42 +779,23 @@ export class File< ) { return; } - const { - pendingEditSessionReplacement: replacement, - file: externalFile, - pendingPersistedDocumentRestore: pendingRestore, - } = this; + const { outgoingSessionFile: replacement, file: externalFile } = this; const externalDocument = - replacement != null && - externalFile != null && - replacement.editSessionFile !== file; + replacement != null && externalFile != null && replacement !== file; const resetHistory = externalDocument - ? shouldResetUndoState(replacement.editSessionFile, externalFile) + ? shouldResetUndoState(replacement, externalFile) : false; - const externalCacheKey = - replacement != null && !externalDocument - ? replacement.prevExternalCacheKey - : externalFile?.cacheKey; - const restoredDocument = - pendingRestore?.editSessionFile === file - ? pendingRestore.previousContents - : undefined; if (externalDocument) { - this.pendingEditSessionReplacement = undefined; - } - if (restoredDocument != null) { - this.pendingPersistedDocumentRestore = undefined; + this.outgoingSessionFile = undefined; } editor.__syncRenderView({ highlighter, fileContainer, file, - externalCacheKey, lineAnnotations, renderRange, externalDocument, resetHistory, - restoredDocument, }); }); } @@ -886,15 +809,48 @@ export class File< onEditChange?.(event); } - public attachEditor(editor: DiffsEditor): () => void { - this.editor?.cleanUp(); + /** @internal Plain files have no component-owned diff session state. */ + public __captureDocumentSessionState(): undefined { + return undefined; + } + + /** @internal Associate this component with its editor for a render lifecycle. */ + public __attachEditor(editor: DiffsEditor): () => void { + if (this.editor != null) { + throw new Error('File.__attachEditor: an editor is already attached'); + } this.editor = editor; + const detach = () => { + this.editor = undefined; + this.fileRenderer.endEditSession(); + }; + try { + this.resumeEditorRendering(editor); + return detach; + } catch (error) { + detach(); + throw error; + } + } + + /** @internal Resume rendering for the editor already associated with this component. */ + public __resumeEditor(editor: DiffsEditor): void { + if (this.editor !== editor) { + throw new Error('File.__resumeEditor: editor association changed'); + } + this.resumeEditorRendering(editor); + } + + private resumeEditorRendering(editor: DiffsEditor): void { this.editSessionAnnotations ??= adoptEditSessionAnnotations( this.lineAnnotations, getLineAnnotationName ); if (this.editSessionFile == null && this.file != null) { - this.createInitialEditSession(editor, this.file); + this.installEditSession( + this.file, + editor.__getDocumentContents(this.file) + ); } else { this.fileRenderer.beginEditSession(this.editSessionFile); } @@ -909,38 +865,33 @@ export class File< // syncs the render view once it paints. this.rerender(); } - return () => { - this.editor = undefined; - this.fileRenderer.endEditSession(); - }; } /** + * @internal + * * Ends the edit session and settles which file this component renders. * Requires the editor to be detached first. Does nothing when no session * exists, so callers can invoke it again safely after it has settled. * - * When the edited text matches the external file (including edits undone - * all the way back), the session is discarded without calling - * `onEditComplete`: the external file stays, and annotations keep the - * session's collection so annotation writes made during the session - * survive. - * - * When the text changed, `onEditComplete` receives a fresh keyless - * `FileContents` holding the edited text, alongside the current external - * file and the session's annotations. Returning the event's `file` installs - * it as the new external value together with those annotations. Returning - * `null`, returning the exact `originalFile`, or having no handler restores - * the external file and its annotations. Any other return throws, as does - * an accepted file reusing the replaced file's `cacheKey` — in both cases - * the component still leaves the session on the external file before the - * error propagates. + * `onEditComplete` receives the completed file, current external file, and + * both annotation collections even when the final text is unchanged. In + * `install` mode, accepting installs the completed file and its annotations; + * rejecting or having no handler restores the external values. `discard` + * mode always restores the external values. An accepted file cannot reuse + * the replaced file's `cacheKey`. */ - public completeEditSession(): void { - this.settleEditSession(true); + public __completeEditSession( + editor: DiffsEditor, + mode: 'install' | 'discard' + ): void { + this.settleEditSession(mode === 'install', editor); } - private settleEditSession(installResult: boolean): void { + private settleEditSession( + installResult: boolean, + editor: DiffsEditor | undefined + ): void { const { editSessionFile, editSessionAnnotations, @@ -952,46 +903,48 @@ export class File< } if (this.editor != null) { throw new Error( - 'File.completeEditSession: detach the editor before completing the session' + 'File.__completeEditSession: detach the editor before completing the session' ); } const sessionAnnotationsCurrent = editSessionAnnotations?.current; - const contentsChanged = editSessionFile.contents !== externalFile.contents; let acceptedFile: FileContents | undefined; let failed = false; let failure: unknown; - if (contentsChanged) { - const completedFile = { ...editSessionFile }; - const event: FileEditCompleteEvent = { - file: completedFile, - originalFile: externalFile, - lineAnnotations: sessionAnnotationsCurrent, - originalLineAnnotations: externalAnnotations, - }; - const { onEditComplete } = this.options; - // Frozen so a handler cannot swap the event's file/originalFile - // references; nested mutation (a fresh cacheKey on event.file) still - // works. - Object.freeze(event); - try { - const decision = - onEditComplete != null ? onEditComplete(event) : 'reject'; - if (decision === 'accept') { - if ( - completedFile.cacheKey != null && - completedFile.cacheKey === externalFile.cacheKey - ) { - throw new Error( - 'File.completeEditSession: an accepted file must not reuse the replaced file cacheKey' - ); - } - acceptedFile = completedFile; + if (editor == null) { + throw new Error( + 'File.__completeEditSession: editor is required for completion' + ); + } + const completedFile = { ...editSessionFile }; + const event: FileEditCompleteEvent = { + file: completedFile, + editor, + originalFile: externalFile, + lineAnnotations: sessionAnnotationsCurrent, + originalLineAnnotations: externalAnnotations, + }; + // Frozen so a handler cannot swap the event's file/originalFile + // references; nested mutation (a fresh cacheKey on event.file) still + // works. + Object.freeze(event); + try { + editor.__emitEditComplete(event); + const decision = this.options.onEditComplete?.(event); + if (decision === 'accept') { + if ( + completedFile.cacheKey != null && + completedFile.cacheKey === externalFile.cacheKey + ) { + throw new Error( + 'File.__completeEditSession: an accepted file must not reuse the replaced file cacheKey' + ); } - } catch (error) { - failed = true; - failure = error; + acceptedFile = completedFile; } + } catch (error) { + failed = true; + failure = error; } if (installResult && acceptedFile != null) { @@ -999,17 +952,10 @@ export class File< if (sessionAnnotationsCurrent != null) { this.lineAnnotations = sessionAnnotationsCurrent; } - } else if ( - installResult && - !contentsChanged && - sessionAnnotationsCurrent != null - ) { - this.lineAnnotations = sessionAnnotationsCurrent; } this.editSessionFile = undefined; this.editSessionAnnotations = undefined; - this.pendingEditSessionReplacement = undefined; - this.pendingPersistedDocumentRestore = undefined; + this.outgoingSessionFile = undefined; // Ending the session with the settled file lets the renderer adopt it as // the rendered identity when its cache already shows this content, so // the next render treats it as current instead of a new file. diff --git a/packages/diffs/src/components/FileDiff.ts b/packages/diffs/src/components/FileDiff.ts index f85defe74..5427d578d 100644 --- a/packages/diffs/src/components/FileDiff.ts +++ b/packages/diffs/src/components/FileDiff.ts @@ -14,6 +14,7 @@ import { THEME_CSS_ATTRIBUTE, UNSAFE_CSS_ATTRIBUTE, } from '../constants'; +import type { FileDiffEditCompleteEvent } from '../editor/types'; import { type GetHoveredLineResult, type GetLineIndexUtility, @@ -34,10 +35,12 @@ import { type HunksRenderResult, } from '../renderers/DiffHunksRenderer'; import { SVGSpriteSheet } from '../sprite'; +export type { FileDiffEditCompleteEvent } from '../editor/types'; import type { AppliedThemeStyleCache, BaseCodeOptions, BaseDiffOptions, + CapturedDiffSessionState, CustomPreProperties, DiffLineAnnotation, DiffsEditableComponent, @@ -60,6 +63,7 @@ import type { RenderHeaderMetadataCallback, RenderHeaderPrefixCallback, RenderRange, + RetainedDiffSessionSnapshot, SelectedLineRange, SelectionSide, ThemeTypes, @@ -72,7 +76,10 @@ import { arePrePropertiesEqual } from '../utils/arePrePropertiesEqual'; import { areRenderRangesEqual } from '../utils/areRenderRangesEqual'; import { areThemesEqual } from '../utils/areThemesEqual'; import { awaitWithTimeout } from '../utils/awaitWithTimeout'; -import { cloneFileDiffMetadata } from '../utils/cloneFileDiffMetadata'; +import { + cloneFileDiffMetadata, + cloneHunks, +} from '../utils/cloneFileDiffMetadata'; import { createAnnotationWrapperNode } from '../utils/createAnnotationWrapperNode'; import { createGutterUtilityContentNode } from '../utils/createGutterUtilityContentNode'; import { createUnsafeCSSStyleNode } from '../utils/createUnsafeCSSStyleNode'; @@ -91,7 +98,6 @@ import { captureExpansionAnchors, finishEditSessionForDiff, rebuildExpansionFromAnchors, - rebuildSessionHunks, } from '../utils/editSessionHunks'; import { getDiffFileInput } from '../utils/getDiffFileInput'; import { getDiffHunksRendererOptions } from '../utils/getDiffHunksRendererOptions'; @@ -112,6 +118,7 @@ import { prerenderHTMLIfNecessary } from '../utils/prerenderHTMLIfNecessary'; import { getMeasuredScrollbarGutter } from '../utils/scrollbarGutter'; import { setPreNodeProperties } from '../utils/setWrapperNodeProps'; import { splitFileContents } from '../utils/splitFileContents'; +import { recomputeDiffRenderLineCounts } from '../utils/updateDiffHunks'; import { getExpandedRegion, getHunkAdditionLineRange, @@ -136,25 +143,6 @@ type DeferredEditorActiveLineWrite = [ options: EditorActiveLineOptions | undefined, ]; -interface PendingEditSessionReplacement { - /** - * The private editable diff that was active before the new external diff - * arrived. It remains active while a partial replacement is loading and is - * compared with the completed replacement to determine history compatibility. - */ - editSessionDiff: FileDiffMetadata; - /** - * The cache key associated with the current editable diff. - * The editor continues using it until the replacement is ready. - */ - prevExternalCacheKey: string | undefined; -} - -interface PendingPersistedDocumentRestore { - editSessionDiff: FileDiffMetadata; - previousContents: string; -} - function canHydrateDiff(fileDiff: FileDiffMetadata): boolean { return ( fileDiff.isPartial && @@ -198,6 +186,32 @@ function shouldResetUndoState( ); } +function areLinesEqual(first: string[], second: string[]): boolean { + return ( + first.length === second.length && + first.every((line, index) => line === second[index]) + ); +} + +// There are certain scenarios where if a diff changes in a certain way, we +// cannot consider the session resumable. Basically if the old file has +// changed in any way (name or contents), then our restored diff would be +// completely invalid +function canRestoreDiffSession( + snapshot: RetainedDiffSessionSnapshot, + externalDiff: FileDiffMetadata +): boolean { + const { oldFile } = snapshot; + if (oldFile == null) { + return externalDiff.type === 'new'; + } + return ( + externalDiff.type !== 'new' && + oldFile.name === (externalDiff.prevName ?? externalDiff.name) && + areLinesEqual(oldFile.lines, externalDiff.deletionLines) + ); +} + export interface FileDiffRenderBaseProps { fileDiff?: FileDiffMetadata; deferManagers?: boolean; @@ -223,41 +237,13 @@ export type FileDiffHydrationProps = Omit< export type FileDiffType = 'file-diff' | 'unresolved-file'; -/** - * `onEditComplete` event argument when an edit session ends with a changed - * diff. - * - * `fileDiff` is a freshly computed diff of the edited contents with no - * `cacheKey` set; accepting installs it. - * - * `originalFileDiff` is the last `fileDiff` the component was given externally; - * a revert restores it. - * - * `oldFile`/`newFile` are the completed contents as a file pair, for updating - * file-pair or patch inputs. `null` marks the absent side of an added file (a - * deleted file cannot be edited). - * - * `lineAnnotations` is the completed annotation collection, potentially - * modified based on the edit changes to keep annotations aligned to their - * intended targets - * - * `originalLineAnnotations` is the last collection provided to the component - * externally, which a revert keeps. - */ -export interface FileDiffEditCompleteEvent { - fileDiff: FileDiffMetadata; - originalFileDiff: FileDiffMetadata; - oldFile: FileContents | null; - newFile: FileContents | null; - lineAnnotations: DiffLineAnnotation[] | undefined; - originalLineAnnotations: DiffLineAnnotation[]; -} - /** * Decides a completed edit synchronously: return `'accept'` to install the - * event's `fileDiff`, or `'reject'` to restore `originalFileDiff`. The event is - * frozen, so re-key the accepted diff in place (`event.fileDiff.cacheKey = - * '…'`) before accepting. A missing handler rejects. + * event's `fileDiff` and annotations, or `'reject'` to restore the original + * values. The event is frozen, so re-key the accepted diff in place + * (`event.fileDiff.cacheKey = '…'`) before accepting. The event's editor is + * detached and returns its final state from `getViewState()`. A missing handler + * rejects. */ export type FileDiffEditCompleteHandler = ( event: FileDiffEditCompleteEvent @@ -309,10 +295,11 @@ export interface FileDiffOptions onEditChange?(event: EditorChangeEvent): void; /** - * Fired when `edit` toggles false or a component unmounts. Only called if - * there are content changes resolving in a new diff. If no callback is - * provided, then the component will always revert back to the last - * `fileDiff` or `oldFile`/`newFiles` passed into the component + * Fired when `edit` toggles false or a component unmounts, including when the + * final contents are unchanged. If no callback is provided, the component + * reverts to the last `fileDiff` or `oldFile`/`newFiles` and annotations + * passed into it. The callback receives the detached editor with its final + * pre-detach state. */ onEditComplete?: FileDiffEditCompleteHandler; } @@ -419,12 +406,9 @@ export class FileDiff< private editSessionAnnotations: | EditSessionAnnotations> | undefined; - private pendingEditSessionReplacement: - | PendingEditSessionReplacement - | undefined; - private pendingPersistedDocumentRestore: - | PendingPersistedDocumentRestore - | undefined; + // Keeps the outgoing editable diff until its replacement is ready to sync, + // so the editor can decide whether to preserve or reset undo history. + private outgoingSessionDiff: FileDiffMetadata | undefined; protected renderedDiff: FileDiffMetadata | undefined; protected renderRange: RenderRange | undefined; protected pendingFiles: PendingFileLoad | undefined; @@ -877,15 +861,15 @@ export class FileDiff< } public cleanUp(recycle: boolean = false): void { + const { editor } = this; dequeueRender(this.handleEditSessionRender); this.emitPostRender(true); // Tear the editor down while the code scrollers still exist. A recycle - // persists its editor state; a full teardown ends the session, so the - // editor drops its stored document instead. - this.editor?.cleanUp(recycle ? 'recycle' : 'complete'); - this.editor = undefined; + // keeps its document and undo history; a full teardown drops them as the + // session ends. + editor?.cleanUp(recycle ? 'recycle' : 'discard'); if (!recycle) { - this.settleEditSession(false); + this.editor = undefined; } this.resizeManager.cleanUp(); this.interactionManager.cleanUp(); @@ -947,8 +931,7 @@ export class FileDiff< this.fileDiff = undefined; this.editSessionDiff = undefined; this.editSessionAnnotations = undefined; - this.pendingEditSessionReplacement = undefined; - this.pendingPersistedDocumentRestore = undefined; + this.outgoingSessionDiff = undefined; this.renderedDiff = undefined; this.deletionFile = undefined; this.additionFile = undefined; @@ -990,7 +973,12 @@ export class FileDiff< const oldFile = fileInput?.oldFile; const newFile = fileInput?.newFile; this.hydrateElements(fileContainer, prerenderedHTML); + // An editor attached before hydration may carry a retained keyed document + // and its session-shaped hunks. Render through that private session instead + // of adopting external markup, so hydration cannot recompute or flash it. + const forceEditorRender = this.editor != null; if ( + forceEditorRender || shouldRenderCode( this.pre, hasDiffContent({ fileDiff, oldFile, newFile }), @@ -1007,6 +995,7 @@ export class FileDiff< fileContainer, lineAnnotations, fileDiff, + forceRender: forceEditorRender || fileInputProps.forceRender, preventEmit: true, }); } @@ -1244,22 +1233,26 @@ export class FileDiff< if (this.fileDiff !== expectedDiff) { return false; } - if (this.pendingEditSessionReplacement != null) { - this.installExternalEditSession(expectedDiff); + const { editor } = this; + if (this.outgoingSessionDiff != null) { + this.installEditSession(expectedDiff); return true; } - const { editor } = this; if (editor == null || this.editSessionDiff != null) { return false; } - this.createInitialEditSession(editor, expectedDiff); + this.installEditSession( + expectedDiff, + editor.__getDocumentContents(getAdditionFile(expectedDiff)), + editor.__getDocumentSessionState() + ); return true; } // Install a replacement diff from the caller; returns false when it is the // diff already installed. During an edit session, the outgoing session diff - // and cache key are kept aside until the editor syncs — it uses them to - // decide whether the swap keeps or resets undo history. A hydrated + // is kept aside until the editor syncs so it can decide whether the swap + // keeps or resets undo history. A hydrated // replacement starts its new session immediately; a partial one starts // loading its files and gets a session once hydrated. protected updateExternalDiff( @@ -1270,30 +1263,29 @@ export class FileDiff< return false; } - const { - fileDiff: prevFileDiff, - pendingEditSessionReplacement: pendingReplacement, - } = this; - const editSessionDiff = - pendingReplacement?.editSessionDiff ?? this.editSessionDiff; - const prevExternalCacheKey = - pendingReplacement?.prevExternalCacheKey ?? prevFileDiff?.cacheKey; + const editSessionDiff = this.outgoingSessionDiff ?? this.editSessionDiff; this.fileDiff = incomingExternalDiff; - this.pendingEditSessionReplacement = undefined; - this.pendingPersistedDocumentRestore = undefined; + this.outgoingSessionDiff = undefined; if (editSessionDiff != null) { - this.pendingEditSessionReplacement = { - editSessionDiff, - prevExternalCacheKey, - }; + this.outgoingSessionDiff = editSessionDiff; + if (incomingExternalDiff.isPartial) { + this.loadFilesIfNecessary(); + } else { + this.installEditSession(incomingExternalDiff); + } + } else if (this.editor != null) { if (incomingExternalDiff.isPartial) { this.loadFilesIfNecessary(); } else { - this.installExternalEditSession(incomingExternalDiff); + this.installEditSession( + incomingExternalDiff, + this.editor.__getDocumentContents( + getAdditionFile(incomingExternalDiff) + ), + this.editor.__getDocumentSessionState() + ); } - } else if (this.editor != null && !incomingExternalDiff.isPartial) { - this.createInitialEditSession(this.editor, incomingExternalDiff); } if (this.editSessionAnnotations != null && lineAnnotations != null) { // These annotations arrived with the new diff, so their line numbers @@ -1310,53 +1302,66 @@ export class FileDiff< return true; } - private installExternalEditSession(externalDiff: FileDiffMetadata): void { - const sessionDiff = createEditSessionDiff(externalDiff); - this.editSessionDiff = sessionDiff; - this.hunksRenderer.beginEditSession(sessionDiff, externalDiff); - } - - /** - * Create the first private editable diff for an editor attachment. When a - * persisted document has different text, rebuild the private diff from that - * text so the restored editor document and rendered rows start in sync. - */ - private createInitialEditSession( - editor: DiffsEditor, - externalDiff: FileDiffMetadata + // When an editor opens a document saved under a document key, + // `retainedDocument` restores its text and undo history. It is omitted when + // replacing the diff in an open editor so the replacement is handled as a + // new external update instead of overwriting the document being edited. + private installEditSession( + externalDiff: FileDiffMetadata, + retainedDocument?: FileContents, + retainedSession?: RetainedDiffSessionSnapshot ): void { - const editSessionDiff = createEditSessionDiff(externalDiff); - const cachedContents = editor.__getCachedDocumentContents?.(externalDiff); - const restoredPersistedContents = - cachedContents != null && - cachedContents !== externalDiff.additionLines.join(''); - - if (restoredPersistedContents) { - const { - collapsedContextThreshold = DEFAULT_COLLAPSED_CONTEXT_THRESHOLD, - } = this.options; - const anchors = captureExpansionAnchors( - externalDiff, - this.hunksRenderer.getExpandedHunksMap(), - collapsedContextThreshold + const externalContents = externalDiff.additionLines.join(''); + const retainedLines = + retainedDocument != null + ? splitFileContents(retainedDocument.contents) + : undefined; + const restoreRetainedSession = + retainedSession != null && + canRestoreDiffSession(retainedSession, externalDiff); + if (retainedSession != null && !restoreRetainedSession) { + throw new Error( + 'FileDiff: retained session cannot resume against a different old file' ); - editSessionDiff.additionLines = splitFileContents(cachedContents); - rebuildSessionHunks(editSessionDiff, this.options.parseDiffOptions); - this.hunksRenderer.setExpandedHunksMap( - rebuildExpansionFromAnchors(editSessionDiff, anchors) + } + if ( + retainedDocument != null && + retainedDocument.contents !== externalContents && + !restoreRetainedSession + ) { + throw new Error( + 'FileDiff: retained edits are missing their diff session state' ); - this.pendingPersistedDocumentRestore = { - editSessionDiff, - previousContents: externalDiff.additionLines.join(''), - }; - } else { - this.pendingPersistedDocumentRestore = undefined; } - - this.editSessionDiff = editSessionDiff; + const usesExternalDocument = + retainedDocument == null || + (retainedDocument.name === externalDiff.name && + retainedDocument.lang === externalDiff.lang && + retainedDocument.contents === externalContents); + const sessionDiff = createEditSessionDiff(externalDiff); + if ( + retainedDocument != null && + retainedLines != null && + !usesExternalDocument + ) { + sessionDiff.name = retainedDocument.name; + sessionDiff.lang = retainedDocument.lang; + } + if ( + restoreRetainedSession && + retainedSession != null && + retainedLines != null + ) { + sessionDiff.additionLines = retainedLines; + sessionDiff.type = retainedSession.type; + sessionDiff.hunks = retainedSession.hunks; + sessionDiff.editSessionDirty = true; + recomputeDiffRenderLineCounts(sessionDiff); + } + this.editSessionDiff = sessionDiff; this.hunksRenderer.beginEditSession( - editSessionDiff, - restoredPersistedContents ? undefined : externalDiff + sessionDiff, + usesExternalDocument && !restoreRetainedSession ? externalDiff : undefined ); } @@ -1705,42 +1710,23 @@ export class FileDiff< ) { return; } - const { - pendingEditSessionReplacement: replacement, - fileDiff: externalDiff, - pendingPersistedDocumentRestore: pendingRestore, - } = this; + const { outgoingSessionDiff: replacement, fileDiff: externalDiff } = this; const externalDocument = - replacement != null && - externalDiff != null && - replacement.editSessionDiff !== fileDiff; + replacement != null && externalDiff != null && replacement !== fileDiff; const resetHistory = externalDocument - ? shouldResetUndoState(replacement.editSessionDiff, externalDiff) + ? shouldResetUndoState(replacement, externalDiff) : false; - const externalCacheKey = - replacement != null && !externalDocument - ? replacement.prevExternalCacheKey - : externalDiff?.cacheKey; - const restoredDocument = - pendingRestore?.editSessionDiff === fileDiff - ? pendingRestore.previousContents - : undefined; if (externalDocument) { - this.pendingEditSessionReplacement = undefined; - } - if (restoredDocument != null) { - this.pendingPersistedDocumentRestore = undefined; + this.outgoingSessionDiff = undefined; } editor.__syncRenderView({ highlighter, fileContainer, fileDiff, - externalCacheKey, lineAnnotations, renderRange, externalDocument, resetHistory, - restoredDocument, }); }); } @@ -1804,34 +1790,94 @@ export class FileDiff< onEditChange?.(event); } - public attachEditor( - editor: DiffsEditor - ): (recycle?: boolean) => void { + /** + * @internal Capture the current diff session, or return `undefined` when no + * complete compatible session exists. + * + * When `clone` is true, the returned lines and hunks are copied. + */ + public __captureDocumentSessionState( + clone = true + ): CapturedDiffSessionState | undefined { + let { outgoingSessionDiff, fileDiff, editSessionDiff: sessionDiff } = this; + if (outgoingSessionDiff != null && fileDiff != null) { + if ( + !fileDiff.isPartial && + shouldResetUndoState(outgoingSessionDiff, fileDiff) + ) { + return undefined; + } + sessionDiff = outgoingSessionDiff; + } + if (sessionDiff == null || sessionDiff.isPartial) { + return undefined; + } + return { + diffSession: { + oldFile: + sessionDiff.type !== 'new' + ? { + name: sessionDiff.prevName ?? sessionDiff.name, + lines: clone + ? [...sessionDiff.deletionLines] + : sessionDiff.deletionLines, + } + : null, + type: sessionDiff.type, + hunks: clone ? cloneHunks(sessionDiff.hunks) : sessionDiff.hunks, + }, + hasChanges: sessionDiff.editSessionDirty === true, + }; + } + + /** @internal Associate this component with its editor for a render lifecycle. */ + public __attachEditor(editor: DiffsEditor): () => void { // Editing is a plain file-diff concern only. Subclasses with their own // hunk semantics (UnresolvedFile) are not editable, so an editor must // never attach to them. if (this.type !== 'file-diff') { throw new Error( - `FileDiff.attachEditor: cannot attach an editor to a "${this.type}" diff` + `FileDiff.__attachEditor: cannot attach an editor to a "${this.type}" diff` ); } - this.editor?.cleanUp(); - this.editor = editor; + if (this.editor != null) { + throw new Error('FileDiff.__attachEditor: an editor is already attached'); + } + const detach = () => { + this.editor = undefined; + this.finishEditSession(); + }; + try { + this.resumeEditorRendering(editor); + return detach; + } catch (error) { + detach(); + throw error; + } + } + + /** @internal Resume rendering for the editor already associated with this component. */ + public __resumeEditor(editor: DiffsEditor): void { + if (this.editor !== editor) { + throw new Error('FileDiff.__resumeEditor: editor association changed'); + } + this.resumeEditorRendering(editor); + } + + private resumeEditorRendering(editor: DiffsEditor): void { this.editSessionAnnotations ??= adoptEditSessionAnnotations( this.lineAnnotations, getLineAnnotationName ); - const { - fileDiff: externalDiff, - pendingEditSessionReplacement: pendingReplacement, - } = this; + const { fileDiff: externalDiff, outgoingSessionDiff: pendingReplacement } = + this; if ( pendingReplacement != null && externalDiff != null && !externalDiff.isPartial && - this.editSessionDiff === pendingReplacement.editSessionDiff + this.editSessionDiff === pendingReplacement ) { - this.installExternalEditSession(externalDiff); + this.installEditSession(externalDiff); } const initialExternalDiff = this.editSessionDiff == null && @@ -1840,10 +1886,15 @@ export class FileDiff< ? externalDiff : undefined; if (initialExternalDiff != null) { - this.createInitialEditSession(editor, initialExternalDiff); + this.installEditSession( + initialExternalDiff, + editor.__getDocumentContents(getAdditionFile(initialExternalDiff)), + editor.__getDocumentSessionState() + ); } else { this.hunksRenderer.beginEditSession(this.editSessionDiff); } + this.editor = editor; // The editor sync below refuses partial diffs (it needs the full file // contents); kick off hydration so the loaded re-render re-runs it. if (this.fileDiff?.isPartial === true) { @@ -1867,15 +1918,6 @@ export class FileDiff< // syncs the render view once it paints. this.rerender(); } - return (recycle: boolean = false) => { - this.editor = undefined; - // A recycle detach is a virtualized unmount mid-session: the session - // continues on remount, so hunks stay session-shaped. Only a - // non-recycle detach runs the exit recompute. - if (!recycle) { - this.finishEditSession(); - } - }; } // Session exit for the live detach path. @@ -1885,32 +1927,30 @@ export class FileDiff< } /** + * @internal + * * Ends the edit session and settles which diff this component renders. * Requires the editor to be detached first. Does nothing when no session * exists, so callers can invoke it again safely after it has settled. * - * When the edited new-side text matches the external diff (including edits - * undone all the way back), the session is discarded without calling - * `onEditComplete`: the external diff stays, and annotations keep the - * session's collection so annotation writes made during the session - * survive. - * - * When the text changed, `onEditComplete` receives a fresh keyless - * `FileDiffMetadata` with fully recomputed hunks, alongside the current - * external diff, complete `oldFile`/`newFile` representations derived from - * the completed diff, and the session's annotations. Returning the event's - * `fileDiff` installs it as the new external value together with those - * annotations. Returning `null`, returning the exact `originalFileDiff`, - * or having no handler restores the external diff and its annotations. Any - * other return throws, as does an accepted diff reusing the replaced - * diff's `cacheKey` — in both cases the component still leaves the session - * on the external diff before the error propagates. + * `onEditComplete` receives the completed diff, current external diff, + * complete file pair, and both annotation collections even when the final + * text is unchanged. In `install` mode, accepting installs the completed diff + * and its annotations; rejecting or having no handler restores the external + * values. `discard` mode always restores the external values. An accepted + * diff cannot reuse the replaced diff's `cacheKey`. */ - public completeEditSession(): void { - this.settleEditSession(true); + public __completeEditSession( + editor: DiffsEditor, + mode: 'install' | 'discard' + ): void { + this.settleEditSession(mode === 'install', editor); } - private settleEditSession(installResult: boolean): void { + private settleEditSession( + installResult: boolean, + editor: DiffsEditor | undefined + ): void { const { editSessionDiff, editSessionAnnotations, @@ -1922,69 +1962,69 @@ export class FileDiff< } if (this.editor != null) { throw new Error( - 'FileDiff.completeEditSession: detach the editor before completing the session' + 'FileDiff.__completeEditSession: detach the editor before completing the session' ); } this.hunksRenderer.endEditSession(); this.finalizeEditSessionHunks(); const sessionAnnotationsCurrent = editSessionAnnotations?.current; - const contentsChanged = - editSessionDiff.additionLines.join('') !== - externalDiff.additionLines.join(''); let acceptedDiff: FileDiffMetadata | undefined; let acceptedOldFile: FileContents | null = null; let acceptedNewFile: FileContents | null = null; let failed = false; let failure: unknown; - if (contentsChanged) { - const completedDiff = cloneFileDiffMetadata(editSessionDiff); - const newFile: FileContents = { - name: completedDiff.name, - contents: completedDiff.additionLines.join(''), - }; - if (completedDiff.lang != null) { - newFile.lang = completedDiff.lang; - } - const event: FileDiffEditCompleteEvent = { - fileDiff: completedDiff, - originalFileDiff: externalDiff, - oldFile: - completedDiff.type === 'new' - ? null - : { - name: completedDiff.prevName ?? completedDiff.name, - contents: completedDiff.deletionLines.join(''), - }, - newFile, - lineAnnotations: sessionAnnotationsCurrent, - originalLineAnnotations: externalAnnotations, - }; - const { onEditComplete } = this.options; - // Frozen so a handler cannot swap the event's fileDiff/originalFileDiff - // references; nested mutation (i.e. a fresh cacheKey on event.fileDiff) - // still works. - Object.freeze(event); - try { - const decision = - onEditComplete != null ? onEditComplete(event) : 'reject'; - if (decision === 'accept') { - if ( - completedDiff.cacheKey != null && - completedDiff.cacheKey === externalDiff.cacheKey - ) { - throw new Error( - 'FileDiff.completeEditSession: an accepted diff must not reuse the replaced diff cacheKey' - ); - } - acceptedDiff = completedDiff; - acceptedOldFile = event.oldFile; - acceptedNewFile = event.newFile; + if (editor == null) { + throw new Error( + 'FileDiff.__completeEditSession: editor is required for completion' + ); + } + const completedDiff = cloneFileDiffMetadata(editSessionDiff); + const newFile: FileContents = { + name: completedDiff.name, + contents: completedDiff.additionLines.join(''), + }; + if (completedDiff.lang != null) { + newFile.lang = completedDiff.lang; + } + const event: FileDiffEditCompleteEvent = { + fileDiff: completedDiff, + editor, + originalFileDiff: externalDiff, + oldFile: + completedDiff.type === 'new' + ? null + : { + name: completedDiff.prevName ?? completedDiff.name, + contents: completedDiff.deletionLines.join(''), + }, + newFile, + lineAnnotations: sessionAnnotationsCurrent, + originalLineAnnotations: externalAnnotations, + }; + // Frozen so a handler cannot swap the event's fileDiff/originalFileDiff + // references; nested mutation (i.e. a fresh cacheKey on event.fileDiff) + // still works. + Object.freeze(event); + try { + editor.__emitEditComplete(event); + const decision = this.options.onEditComplete?.(event); + if (decision === 'accept') { + if ( + completedDiff.cacheKey != null && + completedDiff.cacheKey === externalDiff.cacheKey + ) { + throw new Error( + 'FileDiff.__completeEditSession: an accepted diff must not reuse the replaced diff cacheKey' + ); } - } catch (error) { - failed = true; - failure = error; + acceptedDiff = completedDiff; + acceptedOldFile = event.oldFile; + acceptedNewFile = event.newFile; } + } catch (error) { + failed = true; + failure = error; } if (installResult && acceptedDiff != null) { @@ -2000,17 +2040,10 @@ export class FileDiff< if (sessionAnnotationsCurrent != null) { this.lineAnnotations = sessionAnnotationsCurrent; } - } else if ( - installResult && - !contentsChanged && - sessionAnnotationsCurrent != null - ) { - this.lineAnnotations = sessionAnnotationsCurrent; } this.editSessionDiff = undefined; this.editSessionAnnotations = undefined; - this.pendingEditSessionReplacement = undefined; - this.pendingPersistedDocumentRestore = undefined; + this.outgoingSessionDiff = undefined; if (installResult && this.fileContainer != null) { this.rerender(); } @@ -2026,7 +2059,7 @@ export class FileDiff< * the session render path — which also invalidates virtualized layout, * since nothing else does at exit now that editing does not flip * expandUnchanged. Marker-guarded and idempotent; CodeView also calls this - * when reaping a session whose detach closure was consumed by a recycle. + * when ending a session whose detach closure was consumed by a recycle. * Safe on a cleaned-up instance: the recompute is pure metadata work and * the deferred rerender is enabled-guarded. Returns true when a recompute * ran. @@ -3892,6 +3925,14 @@ interface HasContentProps { newFile: FileContents | null | undefined; } +function getAdditionFile(fileDiff: FileDiffMetadata): FileContents { + return { + name: fileDiff.name, + lang: fileDiff.lang, + contents: fileDiff.additionLines.join(''), + }; +} + function areOptionalFilesEqual( fileA: FileContents | null | undefined, fileB: FileContents | null | undefined diff --git a/packages/diffs/src/components/VirtualizedFileDiff.ts b/packages/diffs/src/components/VirtualizedFileDiff.ts index 2442fcdea..b6fe0ca7f 100644 --- a/packages/diffs/src/components/VirtualizedFileDiff.ts +++ b/packages/diffs/src/components/VirtualizedFileDiff.ts @@ -1023,8 +1023,8 @@ export class VirtualizedFileDiff< } protected override shouldSelfHealEditSession(): boolean { - // CodeView sessions survive recycling with no editor attached; CodeView - // itself runs the exit recompute when it reaps a session. + // CodeView sessions retain their editor association while rendering is + // suspended; CodeView itself runs the exit recompute when it reaps one. return !this.isAdvancedMode() && super.shouldSelfHealEditSession(); } diff --git a/packages/diffs/src/edit/index.ts b/packages/diffs/src/edit/index.ts index 009e0e94e..2a03dc2d1 100644 --- a/packages/diffs/src/edit/index.ts +++ b/packages/diffs/src/edit/index.ts @@ -6,9 +6,30 @@ export type { KeyboardModifier, } from '../editor/command'; export * from '../editor/editor'; -export type { - IStateStorage, - PersistStateStorage, -} from '../editor/stateStorage'; +export { + EditStateManager, + type ClearEditStateOptions, +} from '../editor/EditStateManager'; export * from '../editor/textDocument'; -export type { EditorChange, EditorChangeEvent } from '../types'; +export type { + DiffsEditor, + EditorChange, + EditorChangeEvent, + EditorDocumentKind, + EditorViewState, + RetainedDiffSessionSnapshot, +} from '../types'; +export type { + EditHistoryCoalescingMode, + EditHistoryEntry, + EditHistoryLineAnnotation, + EditHistoryState, + EditState, + EditorEditCompleteEvent, + EditorInitialState, + FileDiffEditCompleteEvent, + FileDiffEditState, + FileEditCompleteEvent, + FileEditState, +} from '../editor/types'; +export type { Marker } from '../editor/marker'; diff --git a/packages/diffs/src/editor/EditStateManager.ts b/packages/diffs/src/editor/EditStateManager.ts new file mode 100644 index 000000000..2152567fd --- /dev/null +++ b/packages/diffs/src/editor/EditStateManager.ts @@ -0,0 +1,313 @@ +import LRUMapPkg from 'lru_map'; + +import type { + DiffsEditor, + EditorDocumentKind, + EditorViewState, + FileContents, + RetainedDiffSessionSnapshot, +} from '../types'; +import { TextDocument } from './textDocument'; +import type { EditState, FileDiffEditState, FileEditState } from './types'; + +const DEFAULT_EDIT_STATE_CAPACITY = 100; + +interface ManagedEditSessionBase { + document?: TextDocument; + fileInfo?: Pick; + editor?: EditorViewState; +} + +export interface ManagedFileEditSession< + LAnnotation = unknown, +> extends ManagedEditSessionBase { + documentKind: 'file'; + diffSession?: never; +} + +export interface ManagedFileDiffEditSession< + LAnnotation = unknown, +> extends ManagedEditSessionBase { + documentKind: 'file-diff'; + diffSession?: RetainedDiffSessionSnapshot; +} + +export type ManagedEditSession = + | ManagedFileEditSession + | ManagedFileDiffEditSession; + +export type ManagedFileEditState = + FileEditState; + +export type ManagedFileDiffEditState = + FileDiffEditState; + +export type ManagedEditState = + | ManagedFileEditState + | ManagedFileDiffEditState; + +type ManagedEditSessionFor = Extract< + ManagedEditSession, + { documentKind: K } +>; + +type ManagedEditStateFor = Extract< + ManagedEditState, + { documentKind: K } +>; + +interface EditStateManagerSession { + owner: DiffsEditor; + session: ManagedEditSessionFor; +} + +/** Owns dormant edit state and active-key exclusion for one surface kind. */ +class EditStateManagerNamespace { + #states = new LRUMapPkg.LRUMap>( + DEFAULT_EDIT_STATE_CAPACITY + ); + #sessions = new Map>(); + + constructor(readonly documentKind: K) {} + + activate( + editStateKey: string, + owner: DiffsEditor, + initialState?: ManagedEditSessionFor + ): ManagedEditSessionFor { + const activeSession = this.#sessions.get(editStateKey); + if (activeSession != null && activeSession.owner !== owner) { + throw new Error( + `Editor: editStateKey "${editStateKey}" is already attached to another editor` + ); + } + if (activeSession != null) { + if (initialState != null) { + activeSession.session = initialState; + } + return activeSession.session; + } + const retainedState = this.#states.delete(editStateKey); + const emptyState = { + documentKind: this.documentKind, + } as ManagedEditSessionFor; + const state: ManagedEditSessionFor = + initialState ?? retainedState ?? emptyState; + this.#sessions.set(editStateKey, { + owner: owner as DiffsEditor, + session: state, + }); + return state; + } + + release( + editStateKey: string, + owner: DiffsEditor, + discard = false + ): void { + const activeSession = this.#sessions.get(editStateKey); + if (activeSession?.owner !== owner) { + return; + } + this.#sessions.delete(editStateKey); + if (discard) { + return; + } + const retainedState = toManagedEditState(activeSession.session); + if (retainedState != null) { + this.#states.set(editStateKey, retainedState as ManagedEditStateFor); + } + } + + get(editStateKey: string): EditState | undefined { + const activeSession = this.#sessions.get(editStateKey); + if (activeSession != null) { + const state = toManagedEditState( + activeSession.session as ManagedEditSession + ); + if (state == null) { + return undefined; + } + return state; + } + const state = this.#states.find(editStateKey); + return state as EditState | undefined; + } + + /** Clear dormant state for a key. Omit `parts` to clear everything. */ + clear(editStateKey: string, parts?: ClearEditStateOptions): boolean { + if (this.#sessions.has(editStateKey)) { + return false; + } + + const state = this.#states.find(editStateKey); + if (state == null) { + return false; + } + if (parts == null || parts.document === true) { + this.#states.delete(editStateKey); + } else { + applyClearEditStateOptions(state, parts); + } + return true; + } + + clearAll(): void { + this.#states.clear(); + } + + setCapacity(capacity: number): void { + if (!Number.isSafeInteger(capacity) || capacity < 1) { + throw new RangeError( + 'EditStateManager: capacity must be a positive integer' + ); + } + while (this.#states.size > capacity) { + this.#states.shift(); + } + this.#states.limit = capacity; + } +} + +/** Keeps file and diff edit state in independent persistence domains. */ +class EditStateManagerClass { + #files = new EditStateManagerNamespace('file'); + #diffs = new EditStateManagerNamespace('file-diff'); + + /** Mark a keyed session as active and return its initial or stored state. */ + activate( + documentKind: EditorDocumentKind, + editStateKey: string, + owner: DiffsEditor, + initialState?: ManagedEditSession + ): ManagedEditSession { + return ( + documentKind === 'file' + ? this.#files.activate( + editStateKey, + owner, + initialState as ManagedFileEditSession | undefined + ) + : this.#diffs.activate( + editStateKey, + owner, + initialState as ManagedFileDiffEditSession | undefined + ) + ) as ManagedEditSession; + } + + /** + * Stop tracking this file session as active and store its current state, + * unless `discard` is true. + */ + releaseFile( + editStateKey: string, + owner: DiffsEditor, + discard = false + ): void { + this.#files.release(editStateKey, owner, discard); + } + + /** + * Stop tracking this diff session as active and store its current state, + * unless `discard` is true. + */ + releaseFileDiff( + editStateKey: string, + owner: DiffsEditor, + discard = false + ): void { + this.#diffs.release(editStateKey, owner, discard); + } + + /** Return current state for an active or dormant editing session. */ + get( + documentKind: EditorDocumentKind, + editStateKey: string + ): EditState | undefined { + return documentKind === 'file' + ? this.#files.get(editStateKey) + : this.#diffs.get(editStateKey); + } + + /** Clear a dormant session, returning false when it is active or missing. */ + clear( + documentKind: EditorDocumentKind, + editStateKey: string, + parts?: ClearEditStateOptions + ): boolean { + return documentKind === 'file' + ? this.#files.clear(editStateKey, parts) + : this.#diffs.clear(editStateKey, parts); + } + + /** Clear all dormant sessions without affecting active editors. */ + clearAll(): void { + this.#files.clearAll(); + this.#diffs.clearAll(); + } + + setCapacity(capacity: number): void { + this.#files.setCapacity(capacity); + this.#diffs.setCapacity(capacity); + } +} + +export interface ClearEditStateOptions { + document?: boolean; + history?: boolean; + editor?: boolean; + selections?: boolean; + view?: boolean; +} + +export function cloneEditorViewState(state: EditorViewState): EditorViewState { + return { + selections: state.selections?.map((selection) => ({ + ...selection, + start: { ...selection.start }, + end: { ...selection.end }, + })), + view: state.view == null ? undefined : { ...state.view }, + }; +} + +export function toManagedEditState( + session: ManagedEditSession +): ManagedEditState | undefined { + if (session.document == null || session.fileInfo == null) { + return undefined; + } + if (session.documentKind === 'file') { + session.editor ??= {}; + return session as ManagedFileEditState; + } + if (session.diffSession == null) { + return undefined; + } + session.editor ??= {}; + return session as ManagedFileDiffEditState; +} + +function applyClearEditStateOptions( + session: ManagedEditSession | undefined, + parts: ClearEditStateOptions +): void { + if (session == null) { + return; + } + if (parts.history === true) { + session.document?.clearHistory(); + } + if (session.editor != null) { + if (parts.editor === true || parts.selections === true) { + session.editor.selections = undefined; + } + if (parts.editor === true || parts.view === true) { + session.editor.view = undefined; + } + } +} + +export const EditStateManager: EditStateManagerClass = + new EditStateManagerClass(); diff --git a/packages/diffs/src/editor/editStack.ts b/packages/diffs/src/editor/editStack.ts index 5de67cb32..af3dc74fb 100644 --- a/packages/diffs/src/editor/editStack.ts +++ b/packages/diffs/src/editor/editStack.ts @@ -4,42 +4,21 @@ import type { ResolvedTextEdit, } from '../types'; import type { TextDocument } from './textDocument'; +import type { + EditHistoryCoalescingMode, + EditHistoryEntry, + EditHistoryState, +} from './types'; /** Largest number of undo or redo entries kept; oldest entries drop first once exceeded. */ const DEFAULT_EDIT_STACK_MAX_ENTRIES = 100; -const COALESCING_MODE_INSERT = 1; -const COALESCING_MODE_BACKSPACE = 2; -const COALESCING_MODE_DELETE = 3; -type EditStackCoalescingMode = 1 | 2 | 3; - -/** An entry in the edit stack. */ -export interface EditStackEntry { - /** Forward offset edits from the entry's base text to its final text. */ - forwardEdits: ResolvedTextEdit[]; - /** Inverse offset edits from the entry's final text back to its base text. */ - inverseEdits: ResolvedTextEdit[]; - /** Document version before the entry is applied. */ - versionBefore: number; - /** Document version after the entry is applied. */ - versionAfter: number; - /** Selection before the transaction. */ - selectionsBefore?: EditorSelection[]; - /** Selection after the transaction. */ - selectionsAfter?: EditorSelection[]; - /** Line annotations before the transaction. */ - lineAnnotationsBefore?: DiffLineAnnotation[]; - /** Line annotations after the transaction. */ - lineAnnotationsAfter?: DiffLineAnnotation[]; - /** Input mode inferred from the edit or retained by a coalesced run. */ - coalescingMode?: EditStackCoalescingMode; - /** - * When `true`, this entry is its own undo step and never merges with the - * entry before or after it. Set for paste and cut, which otherwise look like - * typing and would merge into the previous keystroke. - */ - undoBoundary?: boolean; -} +const COALESCING_MODE_INSERT = 'insert'; +const COALESCING_MODE_BACKSPACE = 'backspace'; +const COALESCING_MODE_DELETE = 'delete'; + +export type EditStackEntry = EditHistoryEntry; +type EditStackCoalescingMode = EditHistoryCoalescingMode; /** Options for the edit stack. */ export interface EditStackOptions { @@ -69,13 +48,47 @@ export class EditStack { return this.#redoStack.length > 0; } - /** Create an independent copy of this undo and redo timeline. */ - clone(): EditStack { - const clone = new EditStack({ maxEntries: this.#maxEntries }); - clone.#undoStack = this.#undoStack.map(cloneEditStackEntry); - clone.#redoStack = this.#redoStack.map(cloneEditStackEntry); - clone.#canCoalesce = this.#canCoalesce; - return clone; + /** Return an independent, developer-editable history snapshot. */ + getState(): EditHistoryState { + return { + undoStack: this.#undoStack.map(cloneEditStackEntryForState), + redoStack: this.#redoStack.map(cloneEditStackEntryForState), + maxEntries: this.#maxEntries, + canCoalesce: this.#canCoalesce, + }; + } + + /** Return a live view over this stack without copying its entries. */ + getLiveState(): EditHistoryState { + return Object.defineProperties({} as EditHistoryState, { + undoStack: { + enumerable: true, + get: () => this.#undoStack, + }, + redoStack: { + enumerable: true, + get: () => this.#redoStack, + }, + maxEntries: { + enumerable: true, + get: () => this.#maxEntries, + }, + canCoalesce: { + enumerable: true, + get: () => this.#canCoalesce, + }, + }); + } + + /** Transfer developer-owned history into an edit stack. */ + static fromState( + state: EditHistoryState + ): EditStack { + const stack = new EditStack({ maxEntries: state.maxEntries }); + stack.#undoStack = state.undoStack; + stack.#redoStack = state.redoStack; + stack.#canCoalesce = state.canCoalesce; + return stack; } /** Clears both the undo and redo stacks. */ @@ -178,6 +191,20 @@ function cloneEditStackEntry( }; } +function cloneEditStackEntryForState( + entry: EditStackEntry +): EditStackEntry { + return { + ...cloneEditStackEntry(entry), + lineAnnotationsBefore: entry.lineAnnotationsBefore?.map((annotation) => ({ + ...annotation, + })), + lineAnnotationsAfter: entry.lineAnnotationsAfter?.map((annotation) => ({ + ...annotation, + })), + }; +} + function cloneSelection(selection: EditorSelection): EditorSelection { return { ...selection, diff --git a/packages/diffs/src/editor/editor.ts b/packages/diffs/src/editor/editor.ts index b69a02436..cc49540e7 100644 --- a/packages/diffs/src/editor/editor.ts +++ b/packages/diffs/src/editor/editor.ts @@ -9,8 +9,9 @@ import type { EditableInstance, EditorChange, EditorChangeEvent, + EditorDocumentKind, EditorSelection, - EditorState, + EditorViewState, FileContents, HighlightedToken, LineAnnotation, @@ -18,9 +19,11 @@ import type { Range, RenderRange, ResolvedTextEdit, + RetainedDiffSessionSnapshot, SelectionSide, TextEdit, } from '../types'; +import { cloneRetainedDiffSessionSnapshot } from '../utils/cloneFileDiffMetadata'; import { computeLineOffsets } from '../utils/computeFileOffsets'; import { getFiletypeFromFileName } from '../utils/getFiletypeFromFileName'; import { isGutterUtilityPath } from '../utils/isGutterUtilityPath'; @@ -32,6 +35,12 @@ import { } from './command'; import editorCSS from './editor.css?inline'; import { EditStack } from './editStack'; +import { + cloneEditorViewState, + EditStateManager, + type ManagedEditSession, + toManagedEditState, +} from './EditStateManager'; import { type LanguageConfigMap, resolveBlockCommentEdits, @@ -108,12 +117,6 @@ import { SelectionActionWidget, } from './selectionAction'; import { createSpriteElement } from './sprite'; -import { - cloneEditorState, - createStateStorage, - type IStateStorage, - type PersistStateStorage, -} from './stateStorage'; import { TextDocument, type TextDocumentChange } from './textDocument'; import { getExpandedAsciiTextColumns, @@ -122,6 +125,8 @@ import { snapTextOffsetToUnicodeBoundary, } from './textMeasure'; import { EditorTokenizer, renderLineTokens } from './tokenizer'; +import type { EditorEditCompleteEvent } from './types'; +import type { EditorInitialState, EditState } from './types'; import { addEventListener, clampDomOffset, @@ -158,17 +163,6 @@ function getShadowRootRange(shadowRoot: ShadowRoot): StaticRange | undefined { }; } -function requirePersistedCacheKey( - file: Pick -): string { - if (typeof file.cacheKey !== 'string' || file.cacheKey.length === 0) { - throw new Error( - `Editor persistState requires a non-empty file.cacheKey for "${file.name}". Provide a unique, stable cacheKey for every editable file.` - ); - } - return file.cacheKey; -} - /** Describe replacing the complete document from its previous text. */ function createFullDocumentChange( previousContents: string, @@ -190,26 +184,12 @@ function createFullDocumentChange( }; } -function isPromise(value: T | Promise): value is Promise { - return ( - typeof value === 'object' && - value !== null && - 'then' in value && - typeof value.then === 'function' - ); -} - interface EditorAttachState { generation: number; callback: (() => void) | undefined; delivered: boolean; } -interface ViewportInputWatch { - userScrolled(): boolean; - dispose(): void; -} - interface AltColumnDrag { pointerId: number; startClientX: number; @@ -222,18 +202,20 @@ interface AltColumnDrag { export interface EditorOptions { /** The maximum number of entries to keep in the undo stack. */ historyMaxEntries?: number; - /** Custom keymap groups checked before defaults; later groups take precedence. */ - keymap?: EditorKeymap; /** - * Preserve each file's document and item-local editor state when switching files. - * Every editable file must provide a unique, stable `cacheKey`. + * Retain and restore the attached component's vertical viewport position. + * Defaults to false because scroll views generally contain multiple items. + * This option is captured when the editor is constructed. */ - persistState?: boolean; + ownsVerticalViewport?: boolean; /** - * Storage for serializable editor state. Text documents stay in this Editor's - * in-memory cache. Defaults to `"inMemory"`. + * Document and editor state transferred to the first attachment. Missing + * fields are initialized from the attached component. The editor takes + * ownership and does not clone supplied objects. */ - persistStateStorage?: PersistStateStorage; + initialState?: EditorInitialState; + /** Custom keymap groups checked before defaults; later groups take precedence. */ + keymap?: EditorKeymap; /** Render rounded corners for selection ranges, default is true. */ roundedSelection?: boolean; /** Highlight matching brackets near the caret, default is true. */ @@ -273,6 +255,12 @@ export interface EditorOptions { * the editor or you will create loops. */ onChange?: (event: EditorChangeEvent) => void; + /** + * Observes completion with the same frozen event sent to the component. Runs + * before the component callback, including when the component callback is + * missing. There is no way to accept or reject from this API. + */ + onComplete?: (event: EditorEditCompleteEvent) => void; /** Callback when the editor gains focus. */ onFocus?: () => void; /** Callback when the editor loses focus. */ @@ -308,29 +296,16 @@ const MULTI_SELECTION_CLIPBOARD_TYPE = export class Editor implements DiffsEditor { #options: EditorOptions; + #initialState: EditorInitialState | undefined; + #editSession?: ManagedEditSession; #metrics = new Metrics(); #tokenizer?: EditorTokenizer; #popoverManager?: PopoverManager; - #textDocumentCache = new Map>(); - #stateStorage?: IStateStorage; - #stateStorageOption?: PersistStateStorage; - #pendingStateWrites = new Map>(); - #pendingStateRestore?: { - cacheKey: string; - textDocument: TextDocument; - documentVersion: number; - selections: EditorSelection[] | undefined; - view: EditorState['view']; - completion: Promise; - }; - #stateRestoreGeneration = 0; - #restoreStateOnNextSync = false; - // event disposes #editorEventDisposes?: (() => void)[]; #globalEventDisposes?: (() => void)[]; #selectEventDisposes?: (() => void)[]; - #detach?: (recycle?: boolean) => void; + #detach?: () => void; // onAttach is deferred until the synchronized document and DOM are usable. // Track the state so cleanup cannot notify an editor from an ended session. #attachState: EditorAttachState = { @@ -368,14 +343,15 @@ export class Editor implements DiffsEditor { // state #fileInstance?: DiffsEditableComponent; - #fileInfo?: Omit; - // FIXME(amadeus): This should be removed when we remove persistState - #externalCacheKey?: string; + #isRendering = false; + #restoreEditorStateOnSync = false; // FIXME(amadeus): We need this support both types. I think in an ideal // world, this instance becomes specific to the type of editor that it // is... and we can manage this without `as` ing thing #lineAnnotations?: DiffLineAnnotation[]; - #textDocument?: TextDocument; + readonly #editStateKey?: string; + readonly #documentKind: EditorDocumentKind; + readonly #ownsVerticalViewport: boolean; #renderRange?: RenderRange; // Bounded render-window size (~viewport + 2*hunkLineCount) from the last view // sync. Used to cap how far #applyChange widens the window for an edit, so a @@ -435,6 +411,9 @@ export class Editor implements DiffsEditor { lines: Map>, themeType: 'light' | 'dark' ) => { + if (!this.#isRendering) { + return; + } this.#fileInstance?.updateRenderCache(lines, themeType); // update the view if the render range is updated by scrolling // and the deferred tokenized lines inside the render range @@ -445,7 +424,7 @@ export class Editor implements DiffsEditor { const { startingLine, totalLines } = this.#renderRange; const endLine = Math.min( startingLine + totalLines, - this.#textDocument?.lineCount ?? 0 + (this.#editSession ?? this.#initialState)?.document?.lineCount ?? 0 ); for (const [line, tokens] of lines) { if (line >= startingLine && line < endLine) { @@ -458,38 +437,33 @@ export class Editor implements DiffsEditor { } }; - constructor(options: EditorOptions = {}) { + /** + * @param documentKind The component surface this editor can attach to. + * @param options Configure editor behavior and lifecycle callbacks. + * @param editStateKey Retain this editable draft and its undo/redo history + * in memory so a later editor using the same kind and key can resume them. + */ + constructor( + documentKind: EditorDocumentKind, + options: EditorOptions = {}, + editStateKey?: string + ) { + this.#documentKind = documentKind; + this.#editStateKey = editStateKey; + this.#ownsVerticalViewport = options.ownsVerticalViewport === true; this.#options = options; + this.#initialState = options.initialState; } setOptions(options: EditorOptions): void { - const previousStorageOption = - this.#options.persistStateStorage ?? 'inMemory'; - const nextOptions = { + this.#options = { ...this.#options, ...options, }; - if (nextOptions.persistState === true && this.#fileInfo != null) { - requirePersistedCacheKey({ - name: this.#fileInfo.name, - cacheKey: this.#externalCacheKey, - }); - } - this.#options = nextOptions; - if (this.#options.persistState !== true) { - this.#textDocumentCache.clear(); - this.#stateRestoreGeneration++; - this.#restoreStateOnNextSync = false; - } - if ( - (this.#options.persistStateStorage ?? 'inMemory') !== - previousStorageOption - ) { - this.#stateRestoreGeneration++; - this.#stateStorage = undefined; - this.#stateStorageOption = undefined; - this.#pendingStateWrites.clear(); - } + } + + __emitEditComplete(event: EditorEditCompleteEvent): void { + this.#options.onComplete?.(event); } // Small typescript hack to prevent UnresolvedFile from being editable. @@ -497,14 +471,79 @@ export class Editor implements DiffsEditor { fileInstance: EditableInstance ): () => void; edit(fileInstance: DiffsEditableComponent): () => void { - this.#invalidateOnAttach(); + const docKind: EditorDocumentKind = + fileInstance.type === 'file-diff' ? 'file-diff' : 'file'; + const previousSession = this.#editSession; + if (this.#documentKind !== docKind) { + throw new Error( + `Editor: a ${this.#documentKind} editor cannot edit a ${docKind} component` + ); + } + if (this.#isRendering) { + throw new Error('Editor: the editor is already rendering its component'); + } + if (this.#fileInstance != null && this.#fileInstance !== fileInstance) { + throw new Error( + 'Editor: a recycled edit session cannot attach to a different component' + ); + } + const initialState = this.#initialState; + const initialEditorState = initialState?.editor; + const editStateKey = this.#editStateKey; + const editSession = getEditSession({ + documentKind: docKind, + editStateKey, + owner: this, + initialState, + previousSession, + }); + const previousDiffSession = editSession.diffSession; + this.#initialState = undefined; + this.#editSession = editSession; this.#fileInstance = fileInstance; - this.#initialize(); - this.#detach = fileInstance.attachEditor(this); + this.#isRendering = true; + this.#restoreEditorStateOnSync = editSession.editor != null; + try { + this.#initialize(); + // First edit: attach the editor to the component. + if (this.#detach == null) { + this.#detach = fileInstance.__attachEditor(this); + } + // After recycling: restore rendering on the same component. + else { + fileInstance.__resumeEditor(this); + } + } catch (error: unknown) { + this.cleanUp('recycle'); + // A failed attachment never replaced the claimed session. Restore diff + // state that cleanup could not recapture before returning its key. + if ( + editSession.documentKind === 'file-diff' && + editSession.diffSession == null + ) { + editSession.diffSession = previousDiffSession; + } + // If we haven't ever attached, and failed to edit, release any edit + // state lock + if (this.#detach == null && editStateKey != null) { + if (docKind === 'file') { + EditStateManager.releaseFile(editStateKey, this); + } else { + EditStateManager.releaseFileDiff(editStateKey, this); + } + } + this.#editSession = previousSession; + if (this.#detach == null) { + this.#fileInstance = undefined; + } + if (initialState != null && initialEditorState != null) { + initialState.editor = initialEditorState; + } + this.#initialState = initialState; + throw error; + } return () => { - const attachedInstance = this.#fileInstance; this.cleanUp('complete'); - attachedInstance?.completeEditSession(); }; } @@ -519,7 +558,7 @@ export class Editor implements DiffsEditor { * are remapped during replay and the text edit still joins the undo timeline. */ applyEdits(edits: TextEdit[], updateHistory = true): void { - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; if (textDocument == null) { throw new Error('Editor is not attached'); } @@ -586,12 +625,16 @@ export class Editor implements DiffsEditor { /** Whether there is an edit to undo. */ get canUndo(): boolean { - return this.#textDocument?.canUndo ?? false; + return ( + (this.#editSession ?? this.#initialState)?.document?.canUndo ?? false + ); } /** Whether there is an undone edit to redo. */ get canRedo(): boolean { - return this.#textDocument?.canRedo ?? false; + return ( + (this.#editSession ?? this.#initialState)?.document?.canRedo ?? false + ); } /** Undo the last edit. Does nothing when there is nothing to undo. */ @@ -605,8 +648,9 @@ export class Editor implements DiffsEditor { } getFile(): FileContents | undefined { - const fileInfo = this.#fileInfo; - const textDocument = this.#textDocument; + const editSession = this.#editSession ?? this.#initialState; + const fileInfo = editSession?.fileInfo; + const textDocument = editSession?.document; if (fileInfo === undefined || textDocument === undefined) { return undefined; } @@ -619,25 +663,70 @@ export class Editor implements DiffsEditor { } getText(): string { - return this.#textDocument?.getText() ?? ''; + return (this.#editSession ?? this.#initialState)?.document?.getText() ?? ''; } - getState(): EditorState { - const fileInstance = this.#fileInstance; + /** Return an isolated copy of selections and restorable view state. */ + getViewState(): EditorViewState { + const fileInstance = this.#isRendering ? this.#fileInstance : undefined; + if (!this.#isRendering && this.#editSession?.editor != null) { + return cloneEditorViewState(this.#editSession.editor); + } + const selections = this.#selections?.map((selection) => ({ + ...selection, + start: { ...selection.start }, + end: { ...selection.end }, + })); + if (fileInstance == null) { + return { selections }; + } + + const viewport = this.#getOwnedVerticalViewport(); + const scrollLeft = fileInstance.getCodeScrollLeft(); + if (viewport == null) { + return { selections, view: { scrollLeft } }; + } return { - selections: this.#selections, - view: - fileInstance != null - ? { - scrollLeft: fileInstance.getCodeScrollLeft(), - scrollTop: this.#getViewportScrollTop(), - } - : undefined, + selections, + view: { scrollLeft, scrollTop: viewport.scrollTop }, }; } - setState({ selections, view }: EditorState): void { - if (this.#fileInstance === undefined || this.#textDocument === undefined) { + /** + * Return the objects that make up the active edit session, or undefined when + * no complete session exists. + */ + getEditState(): EditState | undefined { + const editSession = this.#editSession; + if (editSession == null) { + return undefined; + } + const fileInstance = this.#fileInstance; + if (fileInstance == null) { + throw new Error( + `Editor: active ${this.#documentKind} session has no component` + ); + } + if (this.#documentKind === 'file' && fileInstance.type !== 'file') { + throw new Error('Editor: active file session has no file component'); + } + if ( + this.#documentKind === 'file-diff' && + fileInstance.type !== 'file-diff' + ) { + throw new Error( + 'Editor: active file-diff session has no file-diff component' + ); + } + return toManagedEditState(editSession); + } + + setViewState({ selections, view }: EditorViewState): void { + if ( + !this.#isRendering || + this.#fileInstance === undefined || + this.#editSession?.document === undefined + ) { throw new Error('Editor is not attached'); } this.#canMountSelectionAction = false; @@ -647,20 +736,22 @@ export class Editor implements DiffsEditor { // sits outside that viewport (e.g. TreeApp remount restore). if (view != null) { this.#fileInstance.setCodeScrollLeft(view.scrollLeft); - // Records persisted before scrollTop existed lack it: leave the - // viewport where it is rather than guessing. - if (view.scrollTop !== undefined) { - this.#setViewportScrollTop(view.scrollTop); + // States without scrollTop leave the viewport where it is rather than + // guessing. + const viewport = this.#getOwnedVerticalViewport(); + if (view.scrollTop !== undefined && viewport != null) { + viewport.scrollTop = view.scrollTop; } - return; + } else if (this.#ownsVerticalViewport) { + this.#scrollToPrimaryCaret(); } - this.#scrollToPrimaryCaret(); + this.#checkpointEditSessionState(); } setSelections( selections: (Range & { direction: 'none' | 'backward' | 'forward' })[] ): void { - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; if (textDocument === undefined) { throw new Error('Text document is not initialized'); } @@ -695,7 +786,7 @@ export class Editor implements DiffsEditor { } setMarkers(markers: Marker[]): void { - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; if (textDocument === undefined) { throw new Error('Text document is not initialized'); } @@ -727,8 +818,12 @@ export class Editor implements DiffsEditor { const preventScroll = options?.preventScroll ?? false; const lineNumber = options?.lineNumber; if (lineNumber === 'first-visible' || typeof lineNumber === 'number') { - const textDocument = this.#textDocument; - if (textDocument == null || this.#fileInstance == null) { + const textDocument = this.#editSession?.document; + if ( + textDocument == null || + !this.#isRendering || + this.#fileInstance == null + ) { return; } const targetLineNumber = @@ -765,63 +860,47 @@ export class Editor implements DiffsEditor { this.#contentElement?.blur(); } - // 'discard' tears the editor down and nothing more: onEditComplete never - // fires, and the component keeps whatever session state it has. + // 'discard' ends the session and lets the component publish its completion + // event without installing the result. // - // 'recycle' is a virtualization unmount: the document and its history are - // kept so the session resumes on the next edit() against the same file. + // 'recycle' suspends rendering for collapse or virtualization. The component + // association, document, history, and editor state remain fixed so the same + // surface resumes on the next edit(). // - // 'complete' tears down like 'discard' as part of a session end; the - // caller runs completeEditSession() afterward, and any persisted document - // is dropped instead of stored. + // 'complete' ends the session and lets the component install an accepted + // completion result. Keyed documents outlive all three teardown modes until + // explicitly disposed or evicted from the manager. cleanUp(reason: 'discard' | 'recycle' | 'complete' = 'discard'): void { + const fileInstance = this.#fileInstance; const recycle = reason === 'recycle'; + const editSession = this.#editSession; + if (fileInstance != null && editSession != null) { + const discardDiffState = !this.#checkpointEditSessionState(); + if (!recycle) { + this.#releaseEditSession(editSession, discardDiffState); + } + } this.#invalidateOnAttach(); if (!recycle) { this.#attachState.delivered = false; } - const hadFileInstance = this.#fileInstance != null; - const shouldRestoreState = this.#options.persistState === true; - this.#stateRestoreGeneration++; - if (reason === 'complete') { - // The session is over either way: an accepted result lives in the - // returned external value, and a rejected one must not come back on - // the next edit(). Drop the stored document instead of writing the - // edited text under the replaced file's key. - this.#dropPersistedDocument(); - } else { - this.#persistCurrentState(); - } - if (hadFileInstance) { - this.#restoreStateOnNextSync = shouldRestoreState; - } + this.#isRendering = false; dequeueRender(this.#handleCustomPasteEvent); // The tokenizer is destroyed in both modes: it holds highlighter/worker // resources and writes into the (removed below) theme style element. - // __syncRenderView recreates one for a retained document on re-attach. + // __syncRenderView recreates one for a retained document on render resume. this.#tokenizer?.cleanUp(); this.#tokenizer = undefined; - // A full cleanUp (Edit-mode off, surface switch, unmount) drops the parsed - // document, render-model metadata, and external cache identity so the next - // edit() rebuilds from the host's current contents. A recycle cleanUp — a - // virtualized host temporarily unmounting — keeps them, along with the undo - // history living inside the document, so a later edit() against the same - // name/lang/external cache key resumes through __syncRenderView's reused- - // document path. - if (!recycle) { - this.#textDocument = undefined; - this.#fileInfo = undefined; - this.#externalCacheKey = undefined; - } - // dispse event listeners this.#globalEventDisposes?.forEach((dispose) => dispose()); this.#globalEventDisposes = undefined; this.#editorEventDisposes?.forEach((dispose) => dispose()); this.#editorEventDisposes = undefined; - this.#detach?.(recycle); - this.#detach = undefined; + if (!recycle) { + this.#detach?.(); + this.#detach = undefined; + } // cache this.#gutterWidthCache = undefined; @@ -861,26 +940,72 @@ export class Editor implements DiffsEditor { } this.#resetState(); - this.#fileInstance = undefined; + if (!recycle) { + try { + if (fileInstance != null && editSession != null) { + fileInstance.__completeEditSession( + this, + reason === 'complete' ? 'install' : 'discard' + ); + } + } finally { + this.#editSession = undefined; + this.#fileInstance = undefined; + } + } } - /** @internal Return cached text only when it belongs to the same document. */ - __getCachedDocumentContents( - file: Pick - ): string | undefined { - if (this.#options.persistState !== true) { - return undefined; + // Checkpoint editor and diff-component state after synchronization, edits, + // and teardown. Returns whether a keyed diff is worth retaining after the + // session ends. + #checkpointEditSessionState(): boolean { + const fileInstance = this.#fileInstance; + const editSession = this.#editSession; + if (fileInstance == null || editSession == null) { + return false; } - const cacheKey = requirePersistedCacheKey(file); - const textDocument = this.#getCachedTextDocument(file, cacheKey); - if ( - textDocument == null || - textDocument.uri !== new URL(file.name, 'file://').toString() - ) { - return undefined; + const editorState = this.#isRendering + ? this.getViewState() + : (editSession.editor ?? {}); + const { view } = editorState; + const hasNonDefaultView = + view != null && (view.scrollLeft !== 0 || (view.scrollTop ?? 0) !== 0); + const hasEditorState = editorState.selections != null || hasNonDefaultView; + editSession.editor = cloneEditorViewState(editorState); + + if (editSession.documentKind === 'file') { + return false; + } + + const capturedDiffSession = fileInstance.__captureDocumentSessionState(); + editSession.diffSession = capturedDiffSession?.diffSession; + if (capturedDiffSession == null) { + return false; + } + + const document = editSession.document; + return ( + capturedDiffSession.hasChanges || + hasEditorState || + document?.canUndo === true || + document?.canRedo === true + ); + } + + #releaseEditSession( + editSession: ManagedEditSession, + discardDiffState = false + ): void { + const editStateKey = this.#editStateKey; + if (editStateKey == null) { + return; + } + if (editSession.documentKind === 'file') { + EditStateManager.releaseFile(editStateKey, this); + } else { + EditStateManager.releaseFileDiff(editStateKey, this, discardDiffState); } - return textDocument.getText(); } /** @internal */ @@ -909,11 +1034,38 @@ export class Editor implements DiffsEditor { } } + /** @internal */ + __getDocumentContents(fallbackFile?: FileContents): FileContents | undefined { + const fileInfo = this.#editSession?.fileInfo; + const textDocument = this.#editSession?.document; + if (textDocument == null) { + return fileInfo == null || fallbackFile == null + ? undefined + : { ...fileInfo, contents: fallbackFile.contents }; + } + const resolvedFileInfo = fileInfo ?? fallbackFile; + if (resolvedFileInfo == null) { + return undefined; + } + return { + name: resolvedFileInfo.name, + lang: resolvedFileInfo.lang, + contents: textDocument.getText(), + }; + } + + /** @internal */ + __getDocumentSessionState(): RetainedDiffSessionSnapshot | undefined { + const snapshot = this.#editSession?.diffSession; + return snapshot != null + ? cloneRetainedDiffSessionSnapshot(snapshot) + : undefined; + } + /** @internal */ __syncRenderView: DiffsEditor['__syncRenderView'] = ({ highlighter, fileContainer, - externalCacheKey, renderRange, resetHistory = false, ...renderView @@ -921,10 +1073,11 @@ export class Editor implements DiffsEditor { const isDiff = 'fileDiff' in renderView; const fileOrDiff = isDiff ? renderView.fileDiff : renderView.file; const lineAnnotations = renderView.lineAnnotations; + const editStateKey = this.#editStateKey; const externalDocument = renderView.externalDocument === true; - const restoredDocument = renderView.restoredDocument; const fileInstance = this.#fileInstance; - if (fileInstance == null) { + const editSession = this.#editSession; + if (!this.#isRendering || fileInstance == null || editSession == null) { return; } const shadowRoot = fileContainer.shadowRoot; @@ -981,90 +1134,49 @@ export class Editor implements DiffsEditor { 'contents' in fileOrDiff ? fileOrDiff.contents : fileOrDiff.additionLines.join(''); - const previousTextDocument = this.#textDocument; + const previousTextDocument = editSession.document; const documentChanges = externalDocument && previousTextDocument !== undefined && previousTextDocument.getText() !== contents ? [createFullDocumentChange(previousTextDocument.getText(), contents)] - : restoredDocument != null && restoredDocument !== contents - ? [createFullDocumentChange(restoredDocument, contents)] - : undefined; - - // Components classify external replacements before this sync. A cache-key - // transition identifies new external data, but does not by itself reset - // the active document or its history. - const shouldRebuildDocument = - this.#textDocument === undefined || - this.#fileInfo === undefined || - this.#fileInfo.name !== fileOrDiff.name || - this.#textDocument.languageId !== languageId || - resetHistory || - (!externalDocument && this.#externalCacheKey !== externalCacheKey); - const persistedCacheKey = - this.#options.persistState === true - ? requirePersistedCacheKey({ - name: fileOrDiff.name, - cacheKey: externalCacheKey, - }) : undefined; - let persistedStateTarget: - | { cacheKey: string; textDocument: TextDocument } - | undefined; - - if (externalDocument) { - this.#restoreStateOnNextSync = false; - this.#stateRestoreGeneration++; - this.#persistCurrentState(); - if ( - !shouldRebuildDocument && - this.#options.persistState === true && - this.#textDocument !== undefined && - this.#fileInfo !== undefined - ) { - const previousCacheKey = requirePersistedCacheKey({ - name: this.#fileInfo.name, - cacheKey: this.#externalCacheKey, + const resetForExternalDocument = externalDocument && resetHistory; + const shouldRebuildDocument = + editSession.document == null || + editSession.fileInfo == null || + (editStateKey == null && + (editSession.fileInfo.name !== fileOrDiff.name || + editSession.document.languageId !== languageId)) || + resetForExternalDocument; + if (shouldRebuildDocument) { + this.#invalidateOnAttach(); + const { name, lang } = fileOrDiff; + let textDocument = + resetForExternalDocument || + (editStateKey == null && editSession.fileInfo != null) + ? undefined + : editSession.document; + if (previousTextDocument != null && textDocument == null) { + editSession.editor = undefined; + } + if (textDocument == null) { + const editStack = new EditStack({ + maxEntries: this.#options.historyMaxEntries, }); - this.#textDocumentCache.set( - previousCacheKey, - this.#textDocument.clone() + textDocument = new TextDocument( + fileOrDiff.name, + contents, + languageId, + 0, + editStack ); } - } - - if (shouldRebuildDocument) { - this.#invalidateOnAttach(); - const editStack = new EditStack({ - maxEntries: this.#options.historyMaxEntries, - }); - const { name, lang, cacheKey } = fileOrDiff; - const cachedTextDocument = - !externalDocument && persistedCacheKey !== undefined - ? this.#getCachedTextDocument(fileOrDiff, persistedCacheKey) - : undefined; - // File creates its private render model from cached text before syncing, - // so its DOM always matches a reused document. - const reusableTextDocument = - fileInstance.type === 'file' || - cachedTextDocument?.getText() === contents - ? cachedTextDocument - : undefined; - const textDocument = - reusableTextDocument ?? - new TextDocument(fileOrDiff.name, contents, languageId, 0, editStack); - this.#fileInfo = { name, lang, cacheKey }; - this.#externalCacheKey = externalCacheKey; - this.#textDocument = textDocument; - if (persistedCacheKey !== undefined) { - this.#textDocumentCache.set(persistedCacheKey, textDocument); - if (!externalDocument || !isDiff) { - persistedStateTarget = { - cacheKey: persistedCacheKey, - textDocument, - }; - } + editSession.fileInfo = { name, lang }; + editSession.document = textDocument; + if (resetForExternalDocument) { + editSession.diffSession = undefined; } this.#tokenizer?.cleanUp(); this.#tokenizer = undefined; @@ -1074,17 +1186,15 @@ export class Editor implements DiffsEditor { this.#wrapLineOffsetsCache.clear(); this.#selections = this.#initSelections; if (this.#options.__debug === true) { - // A reused document keeps its undo history; only the fallback path - // actually rebuilds one from the host's contents. console.log( - reusableTextDocument !== undefined - ? '[diffs/editor] cached text document reused for' - : '[diffs/editor] text document rebuilt from', + '[diffs/editor] text document initialized from', fileOrDiff.name ); } - } else if (externalDocument) { + } + if (externalDocument && !resetForExternalDocument) { this.#applyExternalDocumentReplacement( + editSession, contents, isDiff ? renderView.lineAnnotations @@ -1092,40 +1202,16 @@ export class Editor implements DiffsEditor { | DiffLineAnnotation[] | undefined) ); - const { name, lang, cacheKey } = fileOrDiff; - this.#fileInfo = { name, lang, cacheKey }; - this.#externalCacheKey = externalCacheKey; - if (persistedCacheKey !== undefined && this.#textDocument !== undefined) { - this.#textDocumentCache.set(persistedCacheKey, this.#textDocument); - if (!isDiff) { - persistedStateTarget = { - cacheKey: persistedCacheKey, - textDocument: this.#textDocument, - }; - } - } - } - - if ( - persistedStateTarget === undefined && - !externalDocument && - this.#restoreStateOnNextSync && - persistedCacheKey !== undefined && - this.#textDocument !== undefined - ) { - persistedStateTarget = { - cacheKey: persistedCacheKey, - textDocument: this.#textDocument, - }; + const { name, lang } = fileOrDiff; + editSession.fileInfo = { name, lang }; } // The tokenizer is (re)created whenever the current document lacks one: // right after a fresh document build above, or on the first sync after a - // recycle cleanUp re-attached a retained document. Tying it to the - // document (rather than the rebuild) is what keeps a re-attach with an - // unchanged external cache key — which skips the rebuild — able to paint - // edits. - const textDocument = this.#textDocument; + // recycled surface resumes its retained document. Tying it to the document + // (rather than the rebuild) keeps an unchanged resumed document, which + // skips the rebuild, able to paint edits. + const textDocument = editSession.document; if (this.#tokenizer == null && textDocument != null) { this.#tokenizer = new EditorTokenizer({ highlighter, @@ -1222,63 +1308,73 @@ export class Editor implements DiffsEditor { this.#viewportWindowLines = renderRange?.totalLines; this.#tokenizer?.prebuildStateStack(renderRange); - this.#markerRenderer?.removePopover(); + const retainedEditorState = editSession.editor; + const restoreEditorStateOnSync = this.#restoreEditorStateOnSync; - // re-render the existing selections, matches, and markers - if ( - this.#selections !== undefined || - this.#matches !== undefined || - this.#markerRenderer !== undefined - ) { - this.#updateSelections(this.#selections ?? []); - } + try { + this.#markerRenderer?.removePopover(); - if ( - this.#initSelections !== undefined && - this.#primaryCaretElement !== undefined - ) { - this.#initSelections = undefined; - this.#scrollToPrimaryCaret(false, 'center'); - } else if (this.#scrollingToLine !== undefined) { - this.#scrollToLine( - this.#scrollingToLine, - this.#scrollingToLineChar, - this.#scrollingToLineNoFocus - ); - } else if (this.#replacementFocusRequest !== undefined) { - this.#restoreReplacementFocus(); - } else if ( - this.#selections !== undefined && - this.#selections.length > 0 && - this.#contentHasFocus && - !this.#retainSearchPanelFocus - ) { - this.focus({ preventScroll: true }); - } + // re-render the existing selections, matches, and markers + if ( + this.#selections !== undefined || + this.#matches !== undefined || + this.#markerRenderer !== undefined + ) { + this.#updateSelections(this.#selections ?? []); + } - if (this.#retainSearchPanelFocus) { - this.#searchPanel?.focus(); - } + if ( + this.#initSelections !== undefined && + this.#primaryCaretElement !== undefined + ) { + this.#initSelections = undefined; + this.#scrollToPrimaryCaret(false, 'center'); + } else if (this.#scrollingToLine !== undefined) { + this.#scrollToLine( + this.#scrollingToLine, + this.#scrollingToLineChar, + this.#scrollingToLineNoFocus + ); + } else if (this.#replacementFocusRequest !== undefined) { + this.#restoreReplacementFocus(); + } else if ( + this.#selections !== undefined && + this.#selections.length > 0 && + this.#contentHasFocus && + !this.#retainSearchPanelFocus + ) { + this.focus({ preventScroll: true }); + } - if (this.#options.__debug === true && renderRange !== undefined) { - const { startingLine, totalLines } = renderRange; - console.log( - '[diffs/editor] render file:', - fileOrDiff.name, - 'RenderRange:', - startingLine + '-' + (startingLine + totalLines), - 'of', - this.#textDocument?.lineCount, - 'lines' - ); - } + if (this.#retainSearchPanelFocus) { + this.#searchPanel?.focus(); + } - if (persistedStateTarget !== undefined) { - this.#restoreStateOnNextSync = false; - this.#restorePersistedState( - persistedStateTarget.cacheKey, - persistedStateTarget.textDocument - ); + if (restoreEditorStateOnSync) { + this.#restoreEditorStateOnSync = false; + if (retainedEditorState !== undefined) { + this.setViewState(retainedEditorState); + } + } + + if (this.#options.__debug === true && renderRange !== undefined) { + const { startingLine, totalLines } = renderRange; + console.log( + '[diffs/editor] render file:', + fileOrDiff.name, + 'RenderRange:', + startingLine + '-' + (startingLine + totalLines), + 'of', + editSession.document?.lineCount, + 'lines' + ); + } + + this.#checkpointEditSessionState(); + } catch (error) { + this.#restoreEditorStateOnSync = restoreEditorStateOnSync; + editSession.editor = retainedEditorState; + throw error; } if (documentChanges != null) { @@ -1293,10 +1389,11 @@ export class Editor implements DiffsEditor { // applied twice. __syncRenderView emits the resulting onChange notification // afterward. #applyExternalDocumentReplacement( + editSession: ManagedEditSession, contents: string, lineAnnotations: DiffLineAnnotation[] | undefined ): void { - const textDocument = this.#textDocument; + const textDocument = editSession.document; if (textDocument == null || textDocument.getText() === contents) { return; } @@ -1352,248 +1449,6 @@ export class Editor implements DiffsEditor { } } - #getCachedTextDocument( - file: Pick, - cacheKey: string - ): TextDocument | undefined { - const textDocument = this.#textDocumentCache.get(cacheKey); - const languageId = file.lang ?? getFiletypeFromFileName(file.name); - return textDocument?.languageId === languageId ? textDocument : undefined; - } - - #getStateStorage(): IStateStorage { - const option = this.#options.persistStateStorage ?? 'inMemory'; - if ( - this.#stateStorage === undefined || - this.#stateStorageOption !== option - ) { - this.#stateStorage = createStateStorage(option); - this.#stateStorageOption = option; - } - return this.#stateStorage; - } - - // Drop the attached file's cached document. - // Selection/scroll records are left alone. - #dropPersistedDocument(): void { - const fileInfo = this.#fileInfo; - if ( - this.#options.persistState !== true || - this.#fileInstance === undefined || - fileInfo === undefined - ) { - return; - } - this.#textDocumentCache.delete( - requirePersistedCacheKey({ - name: fileInfo.name, - cacheKey: this.#externalCacheKey, - }) - ); - } - - #persistCurrentState(): void { - const fileInfo = this.#fileInfo; - const textDocument = this.#textDocument; - if ( - this.#options.persistState !== true || - // Requires an attached instance: a repeated cleanUp still holds the - // retained fileInfo but must not write (empty) state over the record - // the first cleanUp persisted. - this.#fileInstance === undefined || - fileInfo === undefined || - textDocument === undefined - ) { - return; - } - - const cacheKey = requirePersistedCacheKey({ - name: fileInfo.name, - cacheKey: this.#externalCacheKey, - }); - this.#textDocumentCache.set(cacheKey, textDocument); - - let storage: IStateStorage; - try { - storage = this.#getStateStorage(); - } catch { - return; - } - const state = cloneEditorState(this.getState()); - const pendingRestore = this.#pendingStateRestore; - if ( - pendingRestore?.cacheKey === cacheKey && - pendingRestore.textDocument === textDocument - ) { - this.#pendingStateRestore = undefined; - if ( - textDocument.version === pendingRestore.documentVersion && - this.#selections === pendingRestore.selections && - state.view?.scrollLeft === pendingRestore.view?.scrollLeft && - state.view?.scrollTop === pendingRestore.view?.scrollTop - ) { - return; - } - this.#writeState(storage, cacheKey, state, pendingRestore.completion); - return; - } - - this.#writeState(storage, cacheKey, state); - } - - // Writes for the same key stay ordered even when custom storage is async. - #writeState( - storage: IStateStorage, - cacheKey: string, - state: EditorState, - waitFor?: Promise - ): void { - const previousWrite = this.#pendingStateWrites.get(cacheKey); - if (waitFor !== undefined || previousWrite !== undefined) { - let pending = waitFor ?? Promise.resolve(); - if (previousWrite !== undefined) { - pending = pending.then(() => previousWrite); - } - this.#trackStateWrite( - cacheKey, - pending.then(() => storage.set(cacheKey, state)) - ); - return; - } - - let result: void | Promise; - try { - result = storage.set(cacheKey, state); - } catch { - return; - } - if (!isPromise(result)) { - return; - } - - this.#trackStateWrite(cacheKey, result); - } - - #trackStateWrite(cacheKey: string, result: Promise): void { - const pending = result.catch(() => {}); - this.#pendingStateWrites.set(cacheKey, pending); - void pending.finally(() => { - if (this.#pendingStateWrites.get(cacheKey) === pending) { - this.#pendingStateWrites.delete(cacheKey); - } - }); - } - - #restorePersistedState( - cacheKey: string, - textDocument: TextDocument - ): void { - const generation = ++this.#stateRestoreGeneration; - const documentVersion = textDocument.version; - const selections = this.#selections; - const view = this.getState().view; - let inputWatch: ViewportInputWatch | undefined; - const applyState = (state: EditorState | undefined): void => { - const currentView = this.getState().view; - // scrollTop is deliberately not part of this staleness check: the - // surface can legitimately adjust vertical scroll while an async read - // is in flight (height reconciliation, clamping), and that must not - // block the restore. User scrolls are detected by `inputWatch` instead. - if ( - generation !== this.#stateRestoreGeneration || - this.#textDocument !== textDocument || - textDocument.version !== documentVersion || - this.#selections !== selections || - currentView?.scrollLeft !== view?.scrollLeft || - this.#fileInfo === undefined || - this.#externalCacheKey !== cacheKey - ) { - return; - } - // Once the user engages the viewport, the vertical position is theirs: - // neither the first-open reset nor a stored scrollTop may move it. - const userScrolled = inputWatch?.userScrolled() === true; - if (state === undefined) { - // No stored record for this cacheKey — the file is opened for the - // first time. Start the code scroller and the viewport at the top - // instead of inheriting whatever offset the previous file left in a - // viewport that stays mounted across switches. - this.#fileInstance?.setCodeScrollLeft(0); - if (!userScrolled) { - this.#setViewportScrollTop(0); - } - return; - } - const restored = cloneEditorState(state); - if (userScrolled && restored.view !== undefined) { - restored.view = { scrollLeft: restored.view.scrollLeft }; - } - this.setState(restored); - }; - const readState = (): void | Promise => { - let result: EditorState | undefined | Promise; - try { - result = this.#getStateStorage().get(cacheKey); - } catch { - return; - } - if (isPromise(result)) { - return result.then(applyState).catch(() => {}); - } else { - try { - applyState(result); - } catch {} - } - }; - - const pendingWrite = this.#pendingStateWrites.get(cacheKey); - const result = - pendingWrite === undefined ? readState() : pendingWrite.then(readState); - if (isPromise(result)) { - inputWatch = this.#watchViewportUserInput(); - const pendingRestore = { - cacheKey, - textDocument, - documentVersion, - selections, - view, - completion: result.catch(() => {}), - }; - this.#pendingStateRestore = pendingRestore; - void pendingRestore.completion.finally(() => { - inputWatch?.dispose(); - if (this.#pendingStateRestore === pendingRestore) { - this.#pendingStateRestore = undefined; - } - }); - } - } - - #watchViewportUserInput(): ViewportInputWatch | undefined { - const viewport = this.#getScrollViewport(); - if (!(viewport instanceof HTMLElement)) { - return undefined; - } - let scrolled = false; - const eventTypes = ['wheel', 'touchstart', 'mousedown'] as const; - const dispose = (): void => { - for (const type of eventTypes) { - viewport.removeEventListener(type, onInput, { capture: true }); - } - }; - const onInput = (): void => { - scrolled = true; - dispose(); - }; - for (const type of eventTypes) { - viewport.addEventListener(type, onInput, { - capture: true, - passive: true, - }); - } - return { userScrolled: () => scrolled, dispose }; - } - // Whether a zero-based document line has (or will have on scroll) a // rendered row. False only for lines hidden inside a collapsed unchanged // region of a diff host; hosts without collapsible regions treat every @@ -1693,7 +1548,7 @@ export class Editor implements DiffsEditor { // once delivered, later recycled mounts stay silent. #scheduleOnAttach(fileInstance: DiffsEditableComponent): void { const attachState = this.#attachState; - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; if ( attachState.delivered || attachState.callback != null || @@ -1710,7 +1565,7 @@ export class Editor implements DiffsEditor { if ( generation !== attachState.generation || this.#fileInstance !== fileInstance || - this.#textDocument !== textDocument + this.#editSession?.document !== textDocument ) { return; } @@ -1746,21 +1601,15 @@ export class Editor implements DiffsEditor { return lookupScrollContainer(fileContainer); } - #getViewportScrollTop(): number { - const viewport = this.#getScrollViewport(); - if (viewport instanceof HTMLElement) { - return viewport.scrollTop; + #getOwnedVerticalViewport(): HTMLElement | undefined { + if (!this.#ownsVerticalViewport) { + return undefined; } - return viewport?.defaultView?.scrollY ?? 0; - } - - #setViewportScrollTop(scrollTop: number): void { - const viewport = this.#getScrollViewport(); - if (viewport instanceof HTMLElement) { - viewport.scrollTop = scrollTop; - } else if (viewport instanceof Document) { - viewport.defaultView?.scrollTo({ top: scrollTop }); + const viewport = this.#fileInstance?.getEditorViewport?.(); + if (typeof HTMLElement !== 'undefined' && viewport instanceof HTMLElement) { + return viewport; } + return undefined; } #initialize(): void { @@ -2252,7 +2101,7 @@ export class Editor implements DiffsEditor { // handle the cursor move events manually for multiple selections and virtual viewport const mvShortcut = isMoveCursorShortcut(e); - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; if ( this.#selections !== undefined && this.#selections.length > 0 && @@ -2362,7 +2211,7 @@ export class Editor implements DiffsEditor { } e.preventDefault(); const clipboardData = e.clipboardData; - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; if (clipboardData === null || textDocument === undefined) { return; } @@ -2584,7 +2433,7 @@ export class Editor implements DiffsEditor { return; } - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const lineIndex = resolveEditableLine(gutterRow); if (lineIndex === undefined || textDocument === undefined) { return; @@ -2615,7 +2464,7 @@ export class Editor implements DiffsEditor { if (!this.#isGutterMouseDown) { return; } - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const lineIndex = resolveEditableLine( resolveGutterTarget( e.composedPath()[0] as HTMLElement | undefined, @@ -2684,8 +2533,8 @@ export class Editor implements DiffsEditor { // Invalid selection metadata falls back to the plain-text value. } } - const textDocument = this.#textDocument; - if (textDocument !== undefined) { + const textDocument = this.#editSession?.document; + if (textDocument != null) { this.#replaceSelectionText( Array.isArray(text) ? text.map((t) => textDocument.normalizeEol(t)) @@ -2727,8 +2576,8 @@ export class Editor implements DiffsEditor { } #runCommand(command: EditorCommand) { - const textDocument = this.#textDocument; - if (textDocument === undefined) { + const textDocument = this.#editSession?.document; + if (textDocument == null) { return; } @@ -3036,8 +2885,8 @@ export class Editor implements DiffsEditor { // Replays history and remaps live selections when the entry intentionally // has no stored selection metadata. #applyHistoryChange(command: 'undo' | 'redo'): void { - const textDocument = this.#textDocument; - if (textDocument === undefined) { + const textDocument = this.#editSession?.document; + if (textDocument == null) { return; } if ( @@ -3073,7 +2922,11 @@ export class Editor implements DiffsEditor { selectionEdits ) : undefined); - this.#applyChange(change, nextSelections, lineAnnotations); + const replayedLineAnnotations = (lineAnnotations ?? + (this.#lineAnnotations != null + ? applyDocumentChangeToLineAnnotations(change, this.#lineAnnotations) + : undefined)) as DiffLineAnnotation[] | undefined; + this.#applyChange(change, nextSelections, replayedLineAnnotations); } /** Applies one undoable command batch and records its resulting selections. */ @@ -3083,7 +2936,7 @@ export class Editor implements DiffsEditor { textDocument: TextDocument ) => EditorSelection[] ): void { - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const selections = this.#selections; if (textDocument === undefined || selections === undefined) { return; @@ -3142,7 +2995,7 @@ export class Editor implements DiffsEditor { /** Copies selected line blocks and keeps the selection in the requested copy. */ #copySelectedLines(direction: -1 | 1): void { - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const selections = this.#selections; if (textDocument === undefined || selections === undefined) { return; @@ -3223,7 +3076,7 @@ export class Editor implements DiffsEditor { /** Inserts an indented blank line after each selection's final line. */ #insertBlankLine(): void { - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const selections = this.#selections; if (textDocument === undefined || selections === undefined) { return; @@ -3266,7 +3119,7 @@ export class Editor implements DiffsEditor { } #moveSelectedLines(direction: -1 | 1): void { - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const selections = this.#selections; if (textDocument === undefined || selections === undefined) { return; @@ -3450,7 +3303,7 @@ export class Editor implements DiffsEditor { ) { const tokenizer = this.#tokenizer; const fileInstance = this.#fileInstance; - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const gutterEl = this.#gutterElement; const contentEl = this.#contentElement; if ( @@ -3659,7 +3512,7 @@ export class Editor implements DiffsEditor { switch (inputType) { case 'insertText': { const text = data ?? ''; - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const selections = this.#selections; const autoSurroundTexts = textDocument !== undefined && selections !== undefined @@ -3677,7 +3530,7 @@ export class Editor implements DiffsEditor { break; case 'insertLineBreak': case 'insertParagraph': { - this.#replaceSelectionText(this.#textDocument?.eol ?? '\n'); + this.#replaceSelectionText(this.#editSession?.document?.eol ?? '\n'); break; } case 'deleteContentBackward': @@ -3831,7 +3684,7 @@ export class Editor implements DiffsEditor { const contentElement = this.#contentElement; const fileContainer = this.#fileContainer; if ( - this.#textDocument == null || + this.#editSession?.document == null || contentElement == null || fileContainer == null ) { @@ -4361,7 +4214,7 @@ export class Editor implements DiffsEditor { #updateAltColumnSelections(): boolean { const drag = this.#altColumnDrag; const selectionStart = this.#selectionStart; - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; if ( drag === undefined || drag.focusLine === undefined || @@ -4483,11 +4336,11 @@ export class Editor implements DiffsEditor { const bracketMatchRanges = this.#options.matchBrackets !== false && - this.#textDocument !== undefined && + this.#editSession?.document != null && this.#tokenizer !== undefined && isCollapsedSelection(primarySelection) ? findBracketMatchRanges( - this.#textDocument, + this.#editSession?.document, this.#tokenizer, primarySelection.start ) @@ -4499,7 +4352,7 @@ export class Editor implements DiffsEditor { } } - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; if (this.#matches !== undefined && textDocument !== undefined) { const matches = this.#matches; const renderRange = this.#renderRange; @@ -4603,7 +4456,7 @@ export class Editor implements DiffsEditor { range: Range, extraDataset?: string ) { - if (this.#textDocument === undefined) { + if (this.#editSession?.document == null) { return; } @@ -4624,11 +4477,11 @@ export class Editor implements DiffsEditor { const startChar = line === start.line ? start.character : 0; const endChar = isLastLine ? end.character - : this.#textDocument.getLineLength(line); + : this.#editSession?.document.getLineLength(line); if (this.#isWrap) { const contentWidth = this.#getContentWidth(); - const lineText = this.#textDocument.getLineText(line); + const lineText = this.#editSession?.document.getLineText(line); const textWidth = 2 * this.#metrics.ch + this.#metrics.measureTextWidth(lineText); if (textWidth > contentWidth) { @@ -4995,7 +4848,7 @@ export class Editor implements DiffsEditor { #updateSelectionActionPopover(): void { const primarySelection = this.#selections?.at(-1); const overlayElement = this.#overlayElement; - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const renderSelectionAction = this.#options.renderSelectionAction; const cleanup = () => { this.#selectionAction?.cleanup(); @@ -5034,7 +4887,7 @@ export class Editor implements DiffsEditor { }, applyEdits: (edits: TextEdit[]) => this.applyEdits(edits), getSelectionText: () => - this.#textDocument?.getText(getActiveSelection()) ?? '', + this.#editSession?.document?.getText(getActiveSelection()) ?? '', replaceSelectionText: (text: string) => { this.#replaceSelectionText(text, [getActiveSelection()]); }, @@ -5162,7 +5015,7 @@ export class Editor implements DiffsEditor { // cleanup the existing search panel this.#searchPanel?.cleanup(); - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const preElement = this.#fileContainer?.shadowRoot?.querySelector('pre'); const selections = this.#selections; @@ -5298,7 +5151,7 @@ export class Editor implements DiffsEditor { } #getSelectionText() { - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const selections = this.#selections; if (textDocument === undefined || selections === undefined) { return ''; @@ -5307,7 +5160,7 @@ export class Editor implements DiffsEditor { } #getSelectionClipboardTexts(): string[] { - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const selections = this.#selections; if (textDocument === undefined || selections === undefined) { return []; @@ -5334,7 +5187,7 @@ export class Editor implements DiffsEditor { } #cutSelectionText(): string { - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const selections = this.#selections; if ( textDocument === undefined || @@ -5359,7 +5212,7 @@ export class Editor implements DiffsEditor { edits: ResolvedTextEdit[], nextSelectionOffsets: number[] ): void { - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const selections = this.#selections; if ( textDocument === undefined || @@ -5408,7 +5261,7 @@ export class Editor implements DiffsEditor { if (selections === undefined) { return; } - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; const primarySelection = selections.at(-1); if (textDocument === undefined || primarySelection === undefined) { return; @@ -5447,7 +5300,7 @@ export class Editor implements DiffsEditor { #deleteSelectionText(forward: boolean = false) { const selections = this.#selections; - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; if (selections === undefined || textDocument === undefined) { return; } @@ -5471,7 +5324,7 @@ export class Editor implements DiffsEditor { #deleteSoftLineBackward() { const selections = this.#selections; - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; if (selections === undefined || textDocument === undefined) { return; } @@ -5506,7 +5359,7 @@ export class Editor implements DiffsEditor { #deleteWordBackward() { const selections = this.#selections; - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; if (selections === undefined || textDocument === undefined) { return; } @@ -5527,7 +5380,7 @@ export class Editor implements DiffsEditor { #deleteHardLineForward() { const selections = this.#selections; - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; if (selections === undefined || textDocument === undefined) { return; } @@ -5548,7 +5401,7 @@ export class Editor implements DiffsEditor { #insertTranspose() { const selections = this.#selections; - const textDocument = this.#textDocument; + const textDocument = this.#editSession?.document; if (selections === undefined || textDocument === undefined) { return; } @@ -5694,6 +5547,14 @@ export class Editor implements DiffsEditor { } this.#rerender(change, newLineAnnotations, renderRange, shouldUpdateBuffer); + if (newSelections != null) { + // Install the resulting selections before publishing so event state and + // synchronous getViewState() calls describe the edited document. + this.#updateSelections(newSelections); + } + + this.#checkpointEditSessionState(); + // Publish the change only after the host renderer agrees with the new // document. Consumers may synchronously render the returned annotations, // which must not observe the previous line structure. @@ -5710,13 +5571,10 @@ export class Editor implements DiffsEditor { this.#searchPanel.updateMatches({ syncSelection: false }); } - if (newSelections !== undefined) { - // Always re-render the selection range and caret overlay so editor state - // stays in sync. When skipFocus is set (a programmatic edit on an editor - // that is not focused) we stop here: focusing or scrolling would pull the - // caret and viewport toward an editor the user is not interacting with. - this.#updateSelections(newSelections); - + if (newSelections != null) { + // When skipFocus is set (a programmatic edit on an editor that is not + // focused) we stop here: focusing or scrolling would pull the caret and + // viewport toward an editor the user is not interacting with. // focus to update the native window selection, and scroll to the caret // to mock the 'contenteditable' behavior if (options?.skipFocus !== true) { @@ -5750,16 +5608,18 @@ export class Editor implements DiffsEditor { ): void { const file = this.getFile(); const onChange = this.#options.onChange; + const fileInstance = this.#fileInstance; if (file == null) { return; } const event: EditorChangeEvent = { changes, file, + editor: this, lineAnnotations, }; onChange?.(event); - this.#fileInstance?.emitEditChange(event); + fileInstance?.emitEditChange(event); } #applyChangeToLineAnnotations( @@ -5772,7 +5632,7 @@ export class Editor implements DiffsEditor { this.#lineAnnotations ); if (nextLineAnnotations !== undefined) { - this.#textDocument?.setLastUndoLineAnnotations( + this.#editSession?.document?.setLastUndoLineAnnotations( this.#lineAnnotations, nextLineAnnotations ); @@ -5951,7 +5811,7 @@ export class Editor implements DiffsEditor { return [this.#lastAccessedCharX[2], this.#lastAccessedCharX[3]]; } - const lineText = this.#textDocument?.getLineText(line); + const lineText = this.#editSession?.document?.getLineText(line); const offsetLeft = this.#getGutterWidth() + this.#metrics.ch; // gutter width + inline padding (1ch) if (lineText === undefined || lineText.length === 0 || char <= 0) { return [offsetLeft + (this.#activeContentOffset?.left ?? 0), 0]; @@ -6049,7 +5909,7 @@ export class Editor implements DiffsEditor { this.#wrapLineOffsetsCache.clear(); } - const lineText = this.#textDocument?.getLineText(line); + const lineText = this.#editSession?.document?.getLineText(line); if (lineText === undefined || lineText.length === 0) { const offsets = new Uint32Array([0]); this.#wrapLineOffsetsCache.set(line, offsets); @@ -6164,7 +6024,7 @@ export class Editor implements DiffsEditor { if (offsets !== undefined) { return offsets; } - const length = this.#textDocument?.getLineText(line)?.length ?? 0; + const length = this.#editSession?.document?.getLineText(line)?.length ?? 0; return Uint32Array.of(0, length); } @@ -6181,10 +6041,49 @@ export class Editor implements DiffsEditor { // Check whether a line is visible in the currently rendered line window. #isLineVisible(line: number): boolean { - const lineCount = this.#textDocument?.lineCount ?? 0; + const lineCount = this.#editSession?.document?.lineCount ?? 0; if (line < 0 || line >= lineCount) { return false; } return this.#getLineElement(line) !== undefined; } } + +interface GetEditSessionProps { + documentKind: EditorDocumentKind; + editStateKey: string | undefined; + owner: DiffsEditor; + initialState: EditorInitialState | undefined; + previousSession: ManagedEditSession | undefined; +} + +function getEditSession({ + documentKind, + editStateKey, + owner, + initialState, + previousSession, +}: GetEditSessionProps): ManagedEditSession { + if (initialState != null && initialState.documentKind !== documentKind) { + throw new TypeError( + `Editor: initialState: a ${initialState.documentKind} document cannot initialize a ${documentKind} editor` + ); + } + if (editStateKey != null) { + return EditStateManager.activate( + documentKind, + editStateKey, + owner, + initialState + ); + } + if (initialState != null) { + return initialState; + } + if (previousSession != null) { + return previousSession; + } + return documentKind === 'file' + ? { documentKind: 'file' } + : { documentKind: 'file-diff' }; +} diff --git a/packages/diffs/src/editor/stateStorage.ts b/packages/diffs/src/editor/stateStorage.ts deleted file mode 100644 index 88006c6d2..000000000 --- a/packages/diffs/src/editor/stateStorage.ts +++ /dev/null @@ -1,108 +0,0 @@ -import type { EditorState } from '../types'; - -export interface IStateStorage { - /** Read the editor state stored for a file cache key. */ - get( - cacheKey: string - ): EditorState | undefined | Promise; - /** Store the editor state for a file cache key. */ - set(cacheKey: string, state: EditorState): void | Promise; -} - -export type PersistStateStorage = 'inMemory' | 'indexedDB' | IStateStorage; - -export function cloneEditorState(state: EditorState): EditorState { - return { - selections: state.selections?.map((selection) => ({ - start: { ...selection.start }, - end: { ...selection.end }, - direction: selection.direction, - })), - view: state.view === undefined ? undefined : { ...state.view }, - }; -} - -export function createStateStorage( - storage: PersistStateStorage -): IStateStorage { - return typeof storage === 'object' - ? storage - : storage === 'indexedDB' - ? new IndexedDBStateStorage() - : new InMemoryStateStorage(); -} - -class InMemoryStateStorage implements IStateStorage { - #states = new Map(); - - get(cacheKey: string): EditorState | undefined { - const state = this.#states.get(cacheKey); - return state === undefined ? undefined : cloneEditorState(state); - } - - set(cacheKey: string, state: EditorState): void { - this.#states.set(cacheKey, cloneEditorState(state)); - } -} - -const DATABASE_NAME = 'pierre-diffs-editor-state'; -const DATABASE_VERSION = 1; -const STORE_NAME = 'states'; - -class IndexedDBStateStorage implements IStateStorage { - #database = openDatabase(); - - async get(cacheKey: string): Promise { - const database = await this.#database; - if (database === undefined) { - return undefined; - } - const state = await requestToPromise( - database - .transaction(STORE_NAME, 'readonly') - .objectStore(STORE_NAME) - .get(cacheKey) - ); - return state === undefined ? undefined : cloneEditorState(state); - } - - async set(cacheKey: string, state: EditorState): Promise { - const database = await this.#database; - if (database === undefined) { - return; - } - await requestToPromise( - database - .transaction(STORE_NAME, 'readwrite') - .objectStore(STORE_NAME) - .put(cloneEditorState(state), cacheKey) - ); - } -} - -function openDatabase(): Promise { - try { - const factory = globalThis.indexedDB; - if (factory === undefined) { - return Promise.resolve(undefined); - } - - const request = factory.open(DATABASE_NAME, DATABASE_VERSION); - request.onupgradeneeded = () => { - const database = request.result; - if (!database.objectStoreNames.contains(STORE_NAME)) { - database.createObjectStore(STORE_NAME); - } - }; - return requestToPromise(request); - } catch { - return Promise.resolve(undefined); - } -} - -function requestToPromise(request: IDBRequest): Promise { - return new Promise((resolve, reject) => { - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); -} diff --git a/packages/diffs/src/editor/textDocument.ts b/packages/diffs/src/editor/textDocument.ts index 05f10c649..aabd19a01 100644 --- a/packages/diffs/src/editor/textDocument.ts +++ b/packages/diffs/src/editor/textDocument.ts @@ -16,6 +16,7 @@ import { } from './editStack'; import { PieceTable } from './pieceTable'; import type { SearchParams } from './searchPanel'; +import type { EditHistoryLineAnnotation, EditHistoryState } from './types'; export type { Position, Range, TextEdit } from '../types'; @@ -56,7 +57,7 @@ export interface TextDocumentChange { type TextDocumentHistoryResult = [ change: TextDocumentChange, selections?: EditorSelection[], - lineAnnotations?: DiffLineAnnotation[], + lineAnnotations?: EditHistoryLineAnnotation[], selectionEdits?: ResolvedTextEdit[], ]; @@ -69,14 +70,15 @@ export class TextDocument { #version: number; #pieceTable: PieceTable; #editStack: EditStack; - #eol: string; + #eol: '\n' | '\r\n' | '\r'; constructor( uri: string, text: string, languageId = 'text', version = 0, - editStack: EditStack = new EditStack() + editStack: EditStack = new EditStack(), + eol?: '\n' | '\r\n' | '\r' ) { this.#uri = new URL(uri, 'file://').toString(); this.#languageId = languageId; @@ -88,13 +90,17 @@ export class TextDocument { // inserted or pasted text match the rest of the file instead of leaving // mixed endings behind and keeps that convention stable as the file is // edited. Defaults to Unix `\n` when the initial text has no line break. - const firstLineBreak = this.#pieceTable.getLineText(0, true); - if (firstLineBreak.endsWith('\r\n')) { - this.#eol = '\r\n'; - } else if (firstLineBreak.endsWith('\r')) { - this.#eol = '\r'; + if (eol != null) { + this.#eol = eol; } else { - this.#eol = '\n'; + const firstLineBreak = this.#pieceTable.getLineText(0, true); + if (firstLineBreak.endsWith('\r\n')) { + this.#eol = '\r\n'; + } else if (firstLineBreak.endsWith('\r')) { + this.#eol = '\r'; + } else { + this.#eol = '\n'; + } } } @@ -114,10 +120,14 @@ export class TextDocument { return this.#pieceTable.lineCount; } - get eol(): string { + get eol(): '\n' | '\r\n' | '\r' { return this.#eol; } + get history(): EditHistoryState { + return this.#editStack.getLiveState(); + } + get canUndo(): boolean { return this.#editStack.canUndo; } @@ -126,15 +136,9 @@ export class TextDocument { return this.#editStack.canRedo; } - /** Create an independent document with the same text and history. */ - clone(): TextDocument { - return new TextDocument( - this.#uri, - this.getText(), - this.#languageId, - this.#version, - this.#editStack.clone() - ); + /** Clear undo and redo history without changing the document contents. */ + clearHistory(): void { + this.#editStack.clear(); } positionAt(offset: number): Position { diff --git a/packages/diffs/src/editor/types.ts b/packages/diffs/src/editor/types.ts new file mode 100644 index 000000000..18d698e8a --- /dev/null +++ b/packages/diffs/src/editor/types.ts @@ -0,0 +1,138 @@ +import type { + DiffLineAnnotation, + DiffsEditor, + EditorSelection, + EditorViewState, + FileContents, + FileDiffMetadata, + LineAnnotation, + ResolvedTextEdit, + RetainedDiffSessionSnapshot, +} from '../types'; +import type { TextDocument } from './textDocument'; + +/** + * `onEditComplete` event argument when a file edit session ends. + * + * `file` is a fresh FileContents with the final contents and no `cacheKey` set; + * accepting installs it. + * + * `originalFile` is the last `file` the component was given externally; a + * revert restores it. + * + * `lineAnnotations` is the completed annotation collection, potentially + * modified based on the edit changes to keep annotations aligned to their + * intended targets. + * + * `originalLineAnnotations` is the last collection provided to the component + * externally, which a revert keeps. + */ +export interface FileEditCompleteEvent { + file: FileContents; + editor: DiffsEditor; + lineAnnotations: LineAnnotation[] | undefined; + originalFile: FileContents; + originalLineAnnotations: LineAnnotation[]; +} + +/** + * `onEditComplete` event argument when a diff edit session ends. + * + * `fileDiff` is a freshly computed diff of the final contents with no + * `cacheKey` set; accepting installs it. + * + * `originalFileDiff` is the last `fileDiff` the component was given externally; + * a revert restores it. + * + * `oldFile`/`newFile` are the completed contents as a file pair, for updating + * file-pair or patch inputs. `null` marks the absent side of an added file (a + * deleted file cannot be edited). + * + * `lineAnnotations` is the completed annotation collection, potentially + * modified based on the edit changes to keep annotations aligned to their + * intended targets. + * + * `originalLineAnnotations` is the last collection provided to the component + * externally, which a revert keeps. + */ +export interface FileDiffEditCompleteEvent { + fileDiff: FileDiffMetadata; + editor: DiffsEditor; + originalFileDiff: FileDiffMetadata; + oldFile: FileContents | null; + newFile: FileContents | null; + lineAnnotations: DiffLineAnnotation[] | undefined; + originalLineAnnotations: DiffLineAnnotation[]; +} + +/** + * The exact frozen file or diff completion event observed by the editor before + * the corresponding component completion callback. + */ +export type EditorEditCompleteEvent = + | FileEditCompleteEvent + | FileDiffEditCompleteEvent; + +export type EditHistoryCoalescingMode = 'insert' | 'backspace' | 'delete'; +export type EditHistoryLineAnnotation = + | LineAnnotation + | DiffLineAnnotation; + +/** One reversible document transaction. */ +export interface EditHistoryEntry { + forwardEdits: ResolvedTextEdit[]; + inverseEdits: ResolvedTextEdit[]; + versionBefore: number; + versionAfter: number; + selectionsBefore?: EditorSelection[]; + selectionsAfter?: EditorSelection[]; + lineAnnotationsBefore?: EditHistoryLineAnnotation[]; + lineAnnotationsAfter?: EditHistoryLineAnnotation[]; + coalescingMode?: EditHistoryCoalescingMode; + undoBoundary?: boolean; +} + +/** Undo and redo history for a document. */ +export interface EditHistoryState { + undoStack: EditHistoryEntry[]; + redoStack: EditHistoryEntry[]; + maxEntries: number; + canCoalesce: boolean; +} + +interface EditStateBase { + document: TextDocument; + fileInfo: Pick; + editor: EditorViewState; +} + +export interface FileEditState< + LAnnotation = unknown, +> extends EditStateBase { + documentKind: 'file'; + diffSession?: never; +} + +export interface FileDiffEditState< + LAnnotation = unknown, +> extends EditStateBase { + documentKind: 'file-diff'; + diffSession: RetainedDiffSessionSnapshot; +} + +/** + * The editor-owned objects that make up a complete session. This state is + * transferred by reference when supplied as `initialState`. + */ +export type EditState = + | FileEditState + | FileDiffEditState; + +/** State supplied to a new editor, completed from the attached component. */ +export type EditorInitialState = + | ({ documentKind: 'file' } & Partial< + Omit, 'documentKind'> + >) + | ({ documentKind: 'file-diff' } & Partial< + Omit, 'documentKind'> + >); diff --git a/packages/diffs/src/index.ts b/packages/diffs/src/index.ts index ba01e359d..3f0153ad3 100644 --- a/packages/diffs/src/index.ts +++ b/packages/diffs/src/index.ts @@ -12,6 +12,8 @@ export * from './components/VirtualizedFile'; export * from './components/VirtualizedFileDiff'; export * from './components/Virtualizer'; export * from './constants'; +export type { EditorFocusOptions } from './editor/editor'; +export type { Marker, MarkerSeverity } from './editor/marker'; export * from './highlighter/languages/areLanguagesAttached'; export * from './highlighter/languages/attachResolvedLanguages'; export * from './highlighter/languages/cleanUpResolvedLanguages'; diff --git a/packages/diffs/src/react/CodeView.tsx b/packages/diffs/src/react/CodeView.tsx index 7de85bc5d..de94957a4 100644 --- a/packages/diffs/src/react/CodeView.tsx +++ b/packages/diffs/src/react/CodeView.tsx @@ -25,6 +25,7 @@ import { type CodeViewCreateEditorOptions, type CodeViewItem, type CodeViewItemEditCompleteEventMap, + type CodeViewItemEditCompleteHandler, type CodeViewLineSelection, type CodeViewMode, type CodeViewModeItemMap, @@ -34,7 +35,7 @@ import { type CodeViewSlotSnapshot, type DiffLineAnnotation, type DiffsEditor, - type EditCompletionDecision, + type EditorDocumentKind, type GetHoveredLineResult, type LineAnnotation, } from '../index'; @@ -45,6 +46,8 @@ import { renderFileChildren } from './utils/renderFileChildren'; import { useStableCallback } from './utils/useStableCallback'; import { WorkerPoolContext } from './WorkerPoolContext'; +export type { CodeViewItemEditCompleteHandler }; + const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; @@ -64,6 +67,8 @@ interface CodeViewBaseProps { * CodeView supplies its item-specific change callback. */ editorOptions?: Omit, 'onChange'>; + /** Resolve an in-memory retention key for an item's draft and undo history. */ + getEditStateKey?(item: CodeViewItem): string | undefined; className?: string; style?: CSSProperties; containerRef?: Ref; @@ -87,22 +92,17 @@ interface CodeViewBaseProps { item: CodeViewItem ): void; /** - * Called once when a changed edit session ends: edit turned off, the item - * removed, or the CodeView unmounted (where the result is not installed). - * Not called for sessions without changes; collapse suspends the session - * instead of completing it. `nextItem` is the accepted replacement - * CodeView built from `item`: the event's completed `file`/`fileDiff` and - * annotations, `edit: false`, and a bumped `version`. Return `'accept'` to - * install the edit — CodeView applies `nextItem` through the item update - * path when the item still exists, and a controlled owner puts the same - * `nextItem` into its state — or `'reject'` to revert. The event is frozen; - * re-key the accepted value in place before accepting. + * Called once when an edit session ends: edit turned off, the item removed, + * or the CodeView unmounted (where the result is not installed). Collapse + * suspends the session instead of completing it. `nextItem` is the accepted + * replacement CodeView built from `item`: the event's completed + * `file`/`fileDiff` and annotations, `edit: false`, and a bumped `version`. + * Return `'accept'` to install the edit — CodeView applies `nextItem` through + * the item update path when the item still exists, and a controlled owner + * puts the same `nextItem` into its state — or `'reject'` to revert. The event + * is frozen; re-key the accepted value in place before accepting. */ - onItemEditComplete?( - event: CodeViewItemEditCompleteEventMap[TMode], - item: CodeViewModeItemMap[TMode], - nextItem: CodeViewModeItemMap[TMode] - ): EditCompletionDecision; + onItemEditComplete?: CodeViewItemEditCompleteHandler; renderCustomHeader?(item: CodeViewItem): ReactNode; renderHeaderPrefix?(item: CodeViewItem): ReactNode; renderHeaderFilenameSuffix?(item: CodeViewItem): ReactNode; @@ -198,6 +198,7 @@ function CodeViewInner( containerRef, disableWorkerPool = false, editorOptions, + getEditStateKey, initialItems, items: controlledItems, onItemEditChange, @@ -245,16 +246,22 @@ function CodeViewInner( // next item edit session without forcing CodeView to reconcile active editors. const createEditor = useStableCallback( ( - options: CodeViewCreateEditorOptions + documentKind: EditorDocumentKind, + options: CodeViewCreateEditorOptions, + editStateKey?: string ): DiffsEditor => { if (contextCreateEditor == null) { throw new Error('CodeView: EditContext is not attached'); } - const editor = contextCreateEditor({ - ...editorOptions, - ...options, - }); + const editor = contextCreateEditor( + documentKind, + { + ...editorOptions, + ...options, + }, + editStateKey + ); if (editor == null) { throw new Error( 'CodeView: EditProvider.createEditor must return an editor instance' @@ -290,6 +297,7 @@ function CodeViewInner( onSelectedLinesChange: onSelectedLinesChange != null ? emitSelectedLinesChange : undefined, controlledSelection, + getEditStateKey, createEditor: contextCreateEditor != null ? createEditor : undefined, onItemEditChange: onItemEditChange != null ? emitItemEditChange : undefined, @@ -307,6 +315,7 @@ function CodeViewInner( hasCodeViewHeader, hasCustomHeader, hasGutterRenderer, + getEditStateKey, onItemEditChange, onItemEditComplete, onSelectedLinesChange, @@ -725,6 +734,7 @@ interface CreateManagedCodeViewOptionsProps { hasCodeViewFooter: boolean; onSelectedLinesChange?(selection: CodeViewLineSelection | null): void; controlledSelection: boolean; + getEditStateKey: CodeViewOptions['getEditStateKey']; createEditor: CodeViewOptions['createEditor']; onItemEditChange: CodeViewOptions['onItemEditChange']; onItemEditComplete: CodeViewOptions['onItemEditComplete']; @@ -738,6 +748,7 @@ function createManagedCodeViewOptions({ hasCodeViewFooter, onSelectedLinesChange, controlledSelection, + getEditStateKey, createEditor, onItemEditChange, onItemEditComplete, @@ -749,6 +760,10 @@ function createManagedCodeViewOptions({ createEditor, }; + if (getEditStateKey != null) { + managedOptions.getEditStateKey = getEditStateKey; + } + // Prop-level editor callbacks win over their options-object counterparts, // but an absent prop must not clobber a value provided via `options`. if (onItemEditChange != null) { diff --git a/packages/diffs/src/react/EditContext.tsx b/packages/diffs/src/react/EditContext.tsx index e915b2762..a0cc5be24 100644 --- a/packages/diffs/src/react/EditContext.tsx +++ b/packages/diffs/src/react/EditContext.tsx @@ -2,15 +2,17 @@ 'use client'; import type { Context, PropsWithChildren } from 'react'; -import { createContext, useContext, useRef } from 'react'; +import { createContext, useContext } from 'react'; import type { EditorOptions } from '../edit'; -import type { DiffsEditor } from '../types'; +import type { DiffsEditor, EditorDocumentKind } from '../types'; import { useStableCallback } from './utils/useStableCallback'; /** Creates an Editor. Components manage the instance lifecycle. */ export type CreateEditor = ( - options: EditorOptions + documentKind: EditorDocumentKind, + options: EditorOptions, + editStateKey?: string ) => DiffsEditor; export interface EditProviderProps { @@ -25,21 +27,13 @@ export function EditProvider({ children, createEditor, }: PropsWithChildren>): React.JSX.Element { - // Editors cached by options-object identity: an edit session that restarts - // with the same `editorOptions` - const editorCacheRef = useRef( - new WeakMap, DiffsEditor>() - ); const stableCreateEditor = useStableCallback( - (options: EditorOptions): DiffsEditor => { - const cached = editorCacheRef.current.get(options); - if (cached != null) { - return cached; - } - const editor = createEditor(options); - editorCacheRef.current.set(options, editor); - return editor; - } + ( + documentKind: EditorDocumentKind, + options: EditorOptions, + editStateKey?: string + ): DiffsEditor => + createEditor(documentKind, options, editStateKey) ); return ( diff --git a/packages/diffs/src/react/File.tsx b/packages/diffs/src/react/File.tsx index f9c17e05d..d1ea2e80f 100644 --- a/packages/diffs/src/react/File.tsx +++ b/packages/diffs/src/react/File.tsx @@ -3,7 +3,6 @@ import { type FileEditCompleteEvent, type FileEditCompleteHandler, - type FileOptions, } from '../components/File'; import { DIFFS_TAG_NAME } from '../constants'; import type { FileProps } from './types'; @@ -11,7 +10,8 @@ import { renderFileChildren } from './utils/renderFileChildren'; import { templateRender } from './utils/templateRender'; import { useFileInstance } from './utils/useFileInstance'; -export type { FileEditCompleteEvent, FileEditCompleteHandler, FileOptions }; +export type { FileEditCompleteEvent, FileEditCompleteHandler }; +export type { FileOptions } from './types'; export function File({ file, @@ -19,6 +19,7 @@ export function File({ selectedLines, options, editorOptions, + editStateKey, metrics, className, style, @@ -38,6 +39,7 @@ export function File({ file, options, editorOptions, + editStateKey, metrics, lineAnnotations, selectedLines, diff --git a/packages/diffs/src/react/FileDiff.tsx b/packages/diffs/src/react/FileDiff.tsx index 317d93f72..79e2ca5f2 100644 --- a/packages/diffs/src/react/FileDiff.tsx +++ b/packages/diffs/src/react/FileDiff.tsx @@ -28,6 +28,7 @@ export function FileDiff({ fileDiff, options, editorOptions, + editStateKey, metrics, lineAnnotations, selectedLines, @@ -49,6 +50,7 @@ export function FileDiff({ fileDiff, options, editorOptions, + editStateKey, metrics, lineAnnotations, selectedLines, diff --git a/packages/diffs/src/react/MultiFileDiff.tsx b/packages/diffs/src/react/MultiFileDiff.tsx index 34ea51fff..f32cc1693 100644 --- a/packages/diffs/src/react/MultiFileDiff.tsx +++ b/packages/diffs/src/react/MultiFileDiff.tsx @@ -23,6 +23,7 @@ export function MultiFileDiff({ newFile, options, editorOptions, + editStateKey, metrics, lineAnnotations, selectedLines, @@ -46,6 +47,7 @@ export function MultiFileDiff({ newFile, options, editorOptions, + editStateKey, metrics, lineAnnotations, selectedLines, diff --git a/packages/diffs/src/react/PatchDiff.tsx b/packages/diffs/src/react/PatchDiff.tsx index 94f419f39..1fcef70fb 100644 --- a/packages/diffs/src/react/PatchDiff.tsx +++ b/packages/diffs/src/react/PatchDiff.tsx @@ -21,6 +21,7 @@ export function PatchDiff({ patch, options, editorOptions, + editStateKey, metrics, lineAnnotations, selectedLines, @@ -43,6 +44,7 @@ export function PatchDiff({ fileDiff, options, editorOptions, + editStateKey, metrics, lineAnnotations, selectedLines, diff --git a/packages/diffs/src/react/UnresolvedFile.tsx b/packages/diffs/src/react/UnresolvedFile.tsx index 70e708de9..88ec13483 100644 --- a/packages/diffs/src/react/UnresolvedFile.tsx +++ b/packages/diffs/src/react/UnresolvedFile.tsx @@ -2,7 +2,6 @@ import type { ReactNode } from 'react'; -import type { FileDiffOptions } from '../components/FileDiff'; import type { UnresolvedFile as UnresolvedFileClass } from '../components/UnresolvedFile'; import { DIFFS_TAG_NAME } from '../constants'; import type { UnresolvedFileHunksRendererOptions } from '../renderers/UnresolvedFileHunksRenderer'; @@ -14,6 +13,7 @@ import type { } from '../types'; import { type MergeConflictDiffAction } from '../utils/parseMergeConflictDiffFromFile'; import type { FileDiffProps } from './FileDiff'; +import type { FileDiffOptions } from './types'; import { renderDiffChildren } from './utils/renderDiffChildren'; import { templateRender } from './utils/templateRender'; import { useUnresolvedFileInstance } from './utils/useUnresolvedFileInstance'; diff --git a/packages/diffs/src/react/index.ts b/packages/diffs/src/react/index.ts index eee3bb398..69839b6c2 100644 --- a/packages/diffs/src/react/index.ts +++ b/packages/diffs/src/react/index.ts @@ -2,6 +2,8 @@ /// export * from '../types'; +export type { EditorFocusOptions } from '../editor/editor'; +export type { Marker, MarkerSeverity } from '../editor/marker'; export * from './CodeView'; export * from './File'; export * from './FileDiff'; diff --git a/packages/diffs/src/react/types.ts b/packages/diffs/src/react/types.ts index 2012f71c6..b246a9b49 100644 --- a/packages/diffs/src/react/types.ts +++ b/packages/diffs/src/react/types.ts @@ -1,9 +1,12 @@ import { type CSSProperties, type ReactNode } from 'react'; -import type { FileEditCompleteHandler, FileOptions } from '../components/File'; import type { + FileOptions as FileClassOptions, + FileEditCompleteHandler, +} from '../components/File'; +import type { + FileDiffOptions as FileDiffClassOptions, FileDiffEditCompleteHandler, - FileDiffOptions, } from '../components/FileDiff'; import type { EditorOptions } from '../edit'; import type { GetHoveredLineResult } from '../managers/InteractionManager'; @@ -17,12 +20,26 @@ import type { VirtualFileMetrics, } from '../types'; +type ReactOwnedEditCallbacks = 'onEditChange' | 'onEditComplete'; + +export type FileDiffOptions = Omit< + FileDiffClassOptions, + ReactOwnedEditCallbacks +>; + +export type FileOptions = Omit< + FileClassOptions, + ReactOwnedEditCallbacks +>; + export interface DiffBasePropsReact { options?: FileDiffOptions; /** Whether this surface has an active edit session. */ edit?: boolean; /** Creation-time options passed to the nearest EditProvider factory. */ editorOptions?: EditorOptions; + /** Retain this editable draft and its undo/redo history in memory. */ + editStateKey?: string; /** * Fired for every document change of an active edit session, with the same * `EditorChangeEvent` the editor reports through its own `onChange`. Don't @@ -34,9 +51,10 @@ export interface DiffBasePropsReact { */ onEditChange?(event: EditorChangeEvent): void; /** - * Fired when `edit` toggles false or the component unmounts with content - * changes. Return the event's `fileDiff` to accept the edit or `null` to - * revert. + * Fired when `edit` toggles false or the component unmounts. Return `'accept'` + * to install the completed diff and annotations or `'reject'` to restore the + * external values. The event contains the detached editor with its final + * state. */ onEditComplete?: FileDiffEditCompleteHandler; metrics?: VirtualFileMetrics; @@ -62,6 +80,8 @@ export interface FileProps { edit?: boolean; /** Creation-time options passed to the nearest EditProvider factory. */ editorOptions?: EditorOptions; + /** Retain this editable draft and its undo/redo history in memory. */ + editStateKey?: string; /** * Fired for every document change of an active edit session, with the same * `EditorChangeEvent` the editor reports through its own `onChange`. Don't @@ -69,9 +89,10 @@ export interface FileProps { */ onEditChange?(event: EditorChangeEvent): void; /** - * Fired when `edit` toggles false or the component unmounts with content - * changes. Return the event's `file` to accept the edit or `null` to - * revert. + * Fired when `edit` toggles false or the component unmounts. Return `'accept'` + * to install the completed file and annotations or `'reject'` to restore the + * external values. The event contains the detached editor with its final + * state. */ onEditComplete?: FileEditCompleteHandler; metrics?: VirtualFileMetrics; diff --git a/packages/diffs/src/react/utils/useFileDiffInstance.ts b/packages/diffs/src/react/utils/useFileDiffInstance.ts index 5a964c404..ea8ab095e 100644 --- a/packages/diffs/src/react/utils/useFileDiffInstance.ts +++ b/packages/diffs/src/react/utils/useFileDiffInstance.ts @@ -19,6 +19,7 @@ import type { EditorChangeEvent, EditorOptions } from '../../edit'; import type { GetHoveredLineResult } from '../../managers/InteractionManager'; import type { DiffLineAnnotation, + DiffsEditor, FileContents, FileDiffMetadata, SelectedLineRange, @@ -65,6 +66,7 @@ interface UseFileDiffInstanceProps { newFile?: FileContents | null; options: FileDiffOptions | undefined; editorOptions: EditorOptions | undefined; + editStateKey: string | undefined; lineAnnotations: DiffLineAnnotation[] | undefined; selectedLines: SelectedLineRange | null | undefined; prerenderedHTML: string | undefined; @@ -90,6 +92,7 @@ export function useFileDiffInstance({ newFile, options, editorOptions, + editStateKey, lineAnnotations, selectedLines, prerenderedHTML, @@ -170,6 +173,13 @@ export function useFileDiffInstance({ const instanceRef = useRef< FileDiff | VirtualizedFileDiff | null >(null); + const disposeEditorRef = useRef<() => void>(null); + const getEditor = useStableCallback(() => { + if (createEditor == null) { + throw new Error('FileDiff: EditContext is not attached'); + } + return createEditor('file-diff', editorOptions ?? {}, editStateKey); + }); const ref = useStableCallback((fileContainer: HTMLElement | null) => { if (fileContainer != null) { if (instanceRef.current != null) { @@ -206,6 +216,9 @@ export function useFileDiffInstance({ true ); } + if (edit && disposeEditorRef.current == null) { + disposeEditorRef.current = applyEdit(instanceRef.current, getEditor); + } void instanceRef.current.hydrate({ fileDiff: effectiveFileDiff, fileContainer, @@ -220,6 +233,7 @@ export function useFileDiffInstance({ } instanceRef.current.cleanUp(); instanceRef.current = null; + disposeEditorRef.current = null; } }); @@ -240,12 +254,18 @@ export function useFileDiffInstance({ const forceRender = newOptions !== undefined && !areOptionsEqual(instance.options, newOptions); + instance.setOptions(newOptions); + // Detach editor before rendering if required + if (!edit && disposeEditorRef.current != null) { + const { current: disposeEditor } = disposeEditorRef; + disposeEditorRef.current = null; + disposeEditor(); + } const resolved = resolveAcceptedValues( effectiveFileDiff, lineAnnotations, acceptedCache ); - instance.setOptions(newOptions); void instance.render({ forceRender, fileDiff: resolved.fileDiff, @@ -254,28 +274,11 @@ export function useFileDiffInstance({ if (selectedLines !== undefined) { instance.setSelectedLines(selectedLines); } - }); - - useIsomorphicLayoutEffect(() => { - if (edit && instanceRef.current != null) { - if (createEditor === undefined) { - throw new Error('FileDiff: EditContext is not attached'); - } - const editor = createEditor(editorOptions ?? {}); - if (editor == null) { - throw new Error( - 'FileDiff: EditProvider.createEditor must return an editor instance' - ); - } - try { - return editor.edit(instanceRef.current); - } catch (error) { - editor.cleanUp(); - throw error; - } + // Attach editor after rendering if required + if (edit && disposeEditorRef.current == null) { + disposeEditorRef.current = applyEdit(instance, getEditor); } - return undefined; - }, [edit]); + }); const getHoveredLine = useCallback((): | GetHoveredLineResult<'diff'> @@ -394,3 +397,16 @@ function mergeFileDiffOptions({ onEditComplete, }; } + +function applyEdit( + instance: FileDiff, + getEditor: () => DiffsEditor +): () => void { + const editor = getEditor(); + try { + return editor.edit(instance); + } catch (error) { + editor.cleanUp(); + throw error; + } +} diff --git a/packages/diffs/src/react/utils/useFileInstance.ts b/packages/diffs/src/react/utils/useFileInstance.ts index 105b57274..d5b7738cd 100644 --- a/packages/diffs/src/react/utils/useFileInstance.ts +++ b/packages/diffs/src/react/utils/useFileInstance.ts @@ -17,6 +17,7 @@ import { VirtualizedFile } from '../../components/VirtualizedFile'; import type { EditorChangeEvent, EditorOptions } from '../../edit'; import type { GetHoveredLineResult } from '../../managers/InteractionManager'; import type { + DiffsEditor, FileContents, LineAnnotation, SelectedLineRange, @@ -49,6 +50,7 @@ interface UseFileInstanceProps { file: FileContents; options: FileOptions | undefined; editorOptions: EditorOptions | undefined; + editStateKey: string | undefined; lineAnnotations: LineAnnotation[] | undefined; selectedLines: SelectedLineRange | null | undefined; prerenderedHTML: string | undefined; @@ -71,6 +73,7 @@ export function useFileInstance({ file, options, editorOptions, + editStateKey, lineAnnotations, selectedLines, prerenderedHTML, @@ -115,6 +118,13 @@ export function useFileInstance({ const instanceRef = useRef< File | VirtualizedFile | null >(null); + const disposeEditorRef = useRef<() => void>(null); + const getEditor = useStableCallback(() => { + if (createEditor == null) { + throw new Error('File: EditContext is not attached'); + } + return createEditor('file', editorOptions ?? {}, editStateKey); + }); const ref = useStableCallback((node: HTMLElement | null) => { if (node != null) { if (instanceRef.current != null) { @@ -151,6 +161,9 @@ export function useFileInstance({ true ); } + if (edit && disposeEditorRef.current == null) { + disposeEditorRef.current = applyEdit(instanceRef.current, getEditor); + } void instanceRef.current.hydrate({ file, fileContainer: node, @@ -163,11 +176,13 @@ export function useFileInstance({ } instanceRef.current.cleanUp(); instanceRef.current = null; + disposeEditorRef.current = null; } }); useIsomorphicLayoutEffect(() => { - if (instanceRef.current == null) return; + const { current: instance } = instanceRef; + if (instance == null) return; const newOptions = mergeFileOptions({ controlledSelection, hasCustomHeader, @@ -181,43 +196,32 @@ export function useFileInstance({ // constructor-default options would force a full render on every commit. const forceRender = newOptions !== undefined && - !areOptionsEqual(instanceRef.current.options, newOptions); + !areOptionsEqual(instance.options, newOptions); + instance.setOptions(newOptions); + // Detach editor before rendering if required + if (!edit && disposeEditorRef.current != null) { + const { current: disposeEditor } = disposeEditorRef; + disposeEditorRef.current = null; + disposeEditor(); + } const resolved = resolveAcceptedValues( file, lineAnnotations, acceptedCache ); - instanceRef.current.setOptions(newOptions); - void instanceRef.current.render({ + void instance.render({ file: resolved.file, lineAnnotations: resolved.lineAnnotations, forceRender, }); if (selectedLines !== undefined) { - instanceRef.current.setSelectedLines(selectedLines); + instance.setSelectedLines(selectedLines); } - }); - - useIsomorphicLayoutEffect(() => { - if (edit && instanceRef.current != null) { - if (createEditor === undefined) { - throw new Error('File: EditContext is not attached'); - } - const editor = createEditor(editorOptions ?? {}); - if (editor == null) { - throw new Error( - 'File: EditProvider.createEditor must return an editor instance' - ); - } - try { - return editor.edit(instanceRef.current); - } catch (error) { - editor.cleanUp(); - throw error; - } + // Attach editor after rendering if required + if (edit && disposeEditorRef.current == null) { + disposeEditorRef.current = applyEdit(instance, getEditor); } - return undefined; - }, [edit]); + }); const getHoveredLine = useCallback((): | GetHoveredLineResult<'file'> @@ -312,3 +316,16 @@ function mergeFileOptions({ onEditComplete, }; } + +function applyEdit( + instance: File, + getEditor: () => DiffsEditor +): () => void { + const editor = getEditor(); + try { + return editor.edit(instance); + } catch (error) { + editor.cleanUp(); + throw error; + } +} diff --git a/packages/diffs/src/renderers/DiffHunksRenderer.ts b/packages/diffs/src/renderers/DiffHunksRenderer.ts index ce811b7ed..14b37ce82 100644 --- a/packages/diffs/src/renderers/DiffHunksRenderer.ts +++ b/packages/diffs/src/renderers/DiffHunksRenderer.ts @@ -649,14 +649,13 @@ export class DiffHunksRenderer { diff.additionLines.length <= 1 && diff.additionLines.join('') === '' ) { - Object.assign( + this.applyRecomputePreservingSessionType( diff, recomputeEmptyDocumentDiff(diff, this.options.parseDiffOptions) ); - this.markEditSessionPass(diff); regionsChanged = true; } else if (shouldTopAlignAdditionRecompute(diff, diff.additionLines)) { - Object.assign( + this.applyRecomputePreservingSessionType( diff, recomputeTopAlignedAdditionDiff( diff, @@ -664,7 +663,6 @@ export class DiffHunksRenderer { this.options.parseDiffOptions ) ); - this.markEditSessionPass(diff); regionsChanged = true; } else { const change = applySessionChangedLines( @@ -737,7 +735,7 @@ export class DiffHunksRenderer { // line element for its caret (the additions column vanishes in split; // unified shows only deletions). Keep one empty editable line instead. if (diff.additionLines.length <= 1 && diff.additionLines.join('') === '') { - Object.assign( + this.applyRecomputePreservingSessionType( diff, recomputeEmptyDocumentDiff(diff, this.options.parseDiffOptions) ); @@ -745,7 +743,6 @@ export class DiffHunksRenderer { 0, textDocument.getLineText(0) ); - this.markEditSessionPass(diff); } else if (this.editSessionActive) { this.applySessionDocumentChange(diff); } else { @@ -772,18 +769,30 @@ export class DiffHunksRenderer { const { parseDiffOptions } = this.options; const rawLines = diff.additionLines; if (shouldTopAlignAdditionRecompute(diff, rawLines)) { - Object.assign( + this.applyRecomputePreservingSessionType( diff, recomputeTopAlignedAdditionDiff(diff, rawLines, parseDiffOptions) ); - this.markEditSessionPass(diff); return; } this.applyExpansionRemap(rebuildSessionHunks(diff, parseDiffOptions)); } - // Records a session pass that replaced hunks wholesale (empty-document or - // top-aligned shims). + // Empty-document and top-aligned recomputes rebuild the complete diff. While + // editing, keep the session's original classification until finalization. + private applyRecomputePreservingSessionType( + diff: FileDiffMetadata, + update: ReturnType + ): void { + const sessionType = this.editSessionActive ? diff.type : undefined; + Object.assign(diff, update); + if (sessionType != null) { + diff.type = sessionType; + } + this.markEditSessionPass(diff); + } + + // Records a session pass that replaced hunks wholesale. private markEditSessionPass(diff: FileDiffMetadata): void { if (!this.editSessionActive) { return; diff --git a/packages/diffs/src/renderers/FileRenderer.ts b/packages/diffs/src/renderers/FileRenderer.ts index 8cfd2c32b..de144d5cb 100644 --- a/packages/diffs/src/renderers/FileRenderer.ts +++ b/packages/diffs/src/renderers/FileRenderer.ts @@ -189,8 +189,8 @@ export class FileRenderer { /** * Enter edit-session mode: rendering happens locally with the token * transformer forced on, and worker-pool requests/results are suspended - * for this renderer. Called on every editor attach, including a re-attach - * after recycle. + * for this renderer. Called on initial editor association and whenever its + * rendering resumes after recycle. */ public beginEditSession( file?: FileContents, diff --git a/packages/diffs/src/types.ts b/packages/diffs/src/types.ts index 1ad782ba9..16f71b3e4 100644 --- a/packages/diffs/src/types.ts +++ b/packages/diffs/src/types.ts @@ -13,6 +13,8 @@ import type { ThemeRegistrationResolved, } from 'shiki'; +import type { EditorEditCompleteEvent, EditState } from './editor/types'; + export type { CreatePatchOptionsNonabortable }; export type CodeViewScrollBehavior = 'instant' | 'smooth' | 'smooth-auto'; @@ -32,11 +34,7 @@ export interface FileContents { lang?: SupportedLanguages; /** Optional header passed to the jsdiff library's `createTwoFilesPatch`. */ header?: string; - /** - * Identifies a file for caching. Optional for read-only rendering, but - * required and expected to be unique and stable when Editor `persistState` - * is enabled. - */ + /** Identifies a file for worker pool highlight caching. */ cacheKey?: string; } @@ -374,6 +372,19 @@ export interface FileDiffMetadata { cacheKey?: string; } +/** FileDiff baseline and hunk state needed to resume an editing session. */ +export interface RetainedDiffSessionSnapshot { + oldFile: { name: string; lines: string[] } | null; + type: ChangeTypes; + hunks: Hunk[]; +} + +/** @internal Current diff state and whether the document changed while editing. */ +export interface CapturedDiffSessionState { + diffSession: RetainedDiffSessionSnapshot; + hasChanges: boolean; +} + export type MergeConflictMarkerRowType = | 'marker-start' | 'marker-base' @@ -1004,6 +1015,8 @@ export interface EditorActiveLineOptions { side?: SelectionSide; } +export type EditorDocumentKind = 'file' | 'file-diff'; + export interface DiffsBaseComponent { readonly type: 'file' | 'file-diff' | 'unresolved-file'; readonly top?: number; @@ -1036,6 +1049,15 @@ export interface DiffsEditableComponent< * shared highlighter is actually loaded with and the pool's tokenize limit. */ __getEffectiveCodeOptions(): BaseCodeOptions; + /** + * @internal Capture the current diff session, or return `undefined` when no + * complete compatible session exists. + * + * When `clone` is true, the returned lines and hunks are copied. + */ + __captureDocumentSessionState: ( + clone?: boolean + ) => CapturedDiffSessionState | undefined; /** @internal Keep the editor caret decoration separate from line selection. */ setEditorActiveLine: ( lineNumber: number | null, @@ -1082,15 +1104,10 @@ export interface DiffsEditableComponent< * follow, possibly deferred). */ revealLine?: (lineNumber: number) => boolean; - /** - * Attach an editor to this component. The returned detach closure receives - * `recycle: true` when the editor is only being released by a virtualized - * unmount (the session continues on remount) and no argument/false when the - * session ends. - */ - attachEditor: ( - editor: DiffsEditor - ) => (recycle?: boolean) => void; + /** @internal Associate an editor with this component */ + __attachEditor: (editor: DiffsEditor) => () => void; + /** @internal Resume rendering for the editor already associated with this component. */ + __resumeEditor: (editor: DiffsEditor) => void; /** * Deliver `EditorChangeEvent` to the component's owner. The attached * editor calls this with the same event object it reports through its own @@ -1098,11 +1115,18 @@ export interface DiffsEditableComponent< */ emitEditChange(event: EditorChangeEvent): void; /** + * @internal + * * End the edit session and settle which external value the component - * renders, running the component's `onEditComplete` when the session - * changed the contents. Does nothing once no session exists. + * renders and run the component's `onEditComplete`. The caller supplies the + * editor that owns the session, which may already be detached. `install` + * applies an accepted result; `discard` still runs completion but never + * installs session output. Does nothing once no session exists. */ - completeEditSession(): void; + __completeEditSession( + editor: DiffsEditor, + mode: 'install' | 'discard' + ): void; /** * Resolve the shadow-DOM slot name for one of this component's line * annotations. While an edit session is active, the name each annotation @@ -1150,7 +1174,6 @@ export type EditableInstance = T extends { interface SyncRenderViewBaseProps { highlighter: DiffsHighlighter; fileContainer: HTMLElement; - externalCacheKey: string | undefined; renderRange: RenderRange | undefined; /** Start fresh history instead of retaining or extending the current history. */ resetHistory?: boolean; @@ -1163,8 +1186,6 @@ export interface SyncFileRenderViewProps< lineAnnotations: LineAnnotation[] | undefined; /** Treat the supplied contents as an externally provided document update. */ externalDocument?: boolean; - /** The external contents replaced by a restored persisted document. */ - restoredDocument?: string; } export interface SyncDiffRenderViewProps< @@ -1174,11 +1195,6 @@ export interface SyncDiffRenderViewProps< lineAnnotations: DiffLineAnnotation[] | undefined; /** Treat the supplied contents as an externally provided document update. */ externalDocument?: boolean; - /** - * The private diff was initialized from a persisted document. The previous - * external contents are used only to report that restored document change. - */ - restoredDocument?: string; } export type SyncRenderViewProps = @@ -1186,17 +1202,28 @@ export type SyncRenderViewProps = | SyncDiffRenderViewProps; export interface DiffsEditor { - /** @internal Return cached text for the same persisted document identity. */ - __getCachedDocumentContents?( - file: Pick - ): string | undefined; + /** Return an isolated copy of selections and editor-owned viewport state. */ + getViewState(): EditorViewState; + /** + * Returns the raw objects for the active edit session, or undefined when no + * complete state is available. State remains available while rendering is + * recycled and during `onEditComplete`. The result is borrowed editor-owned + * state and can be transferred directly as `initialState`. + */ + getEditState(): EditState | undefined; __postponeBgTokenizeToNextFrame(): void; /** @internal Capture focus intent before replacing the editable view. */ __captureFocusForDOMReplacement(): void; + /** @internal Return the active document that an edit-session render should use. */ + __getDocumentContents(fallbackFile?: FileContents): FileContents | undefined; + /** @internal Return component state retained with the active document. */ + __getDocumentSessionState(): RetainedDiffSessionSnapshot | undefined; __syncRenderView(props: SyncRenderViewProps): void; edit>( fileInstance: EditableInstance ): () => void; + /** @internal Notify the editor that its active edit session completed. */ + __emitEditComplete(event: EditorEditCompleteEvent): void; cleanUp(reason?: 'discard' | 'recycle' | 'complete'): void; } @@ -1289,10 +1316,11 @@ export interface EditorChange extends ResolvedTextEdit { range: Range; } -/** The document state and normalized edits reported after an editor change. */ +/** The document and normalized edits reported after an editor change. */ export interface EditorChangeEvent { changes: EditorChange[]; file: FileContents; + editor: DiffsEditor; lineAnnotations?: TMode extends 'file' ? LineAnnotation[] : DiffLineAnnotation[]; @@ -1310,16 +1338,16 @@ export interface EditorSelection extends Range { direction: SelectionDirection; } -export interface EditorViewState { +export interface EditorViewportState { /** Horizontal position owned by the current editable code scroller. */ scrollLeft: number; /** Vertical position of the editor viewport. */ scrollTop?: number; } -export interface EditorState { +export interface EditorViewState { selections?: EditorSelection[]; - view?: EditorViewState; + view?: EditorViewportState; } export interface DiffsTextDocument { @@ -1331,10 +1359,11 @@ export interface DiffsTextDocument { /** * Options CodeView passes to its `createEditor` factory. A structural subset * of `EditorOptions` from `@pierre/diffs/edit`, so factories can spread - * them straight into the constructor — `new Editor({ ...options })` — and - * layer any editor configuration of their own on top. Forwarding `onChange` - * is what lets CodeView resolve document changes back to the owning item and - * emit them through its own `onItemEditChange` option. + * them straight into the constructor — + * `new Editor(documentKind, { ...options })` — and layer any editor + * configuration of their own on top. Forwarding `onChange` is what lets + * CodeView resolve document changes back to the owning item and emit them + * through its own `onItemEditChange` option. */ export interface CodeViewCreateEditorOptions { onChange(event: EditorChangeEvent): void; diff --git a/packages/diffs/src/utils/cloneFileDiffMetadata.ts b/packages/diffs/src/utils/cloneFileDiffMetadata.ts index e0040c2bd..97f5e27c6 100644 --- a/packages/diffs/src/utils/cloneFileDiffMetadata.ts +++ b/packages/diffs/src/utils/cloneFileDiffMetadata.ts @@ -1,14 +1,35 @@ -import type { FileDiffMetadata } from '../types'; +import type { + FileDiffMetadata, + Hunk, + RetainedDiffSessionSnapshot, +} from '../types'; + +export function cloneHunks(hunks: Hunk[]): Hunk[] { + return hunks.map((hunk) => ({ + ...hunk, + hunkContent: hunk.hunkContent.map((content) => ({ ...content })), + })); +} + +export function cloneRetainedDiffSessionSnapshot( + snapshot: RetainedDiffSessionSnapshot +): RetainedDiffSessionSnapshot { + return { + ...snapshot, + oldFile: + snapshot.oldFile != null + ? { ...snapshot.oldFile, lines: [...snapshot.oldFile.lines] } + : null, + hunks: cloneHunks(snapshot.hunks), + }; +} export function cloneFileDiffMetadata( fileDiff: FileDiffMetadata ): FileDiffMetadata { return { ...fileDiff, - hunks: fileDiff.hunks.map((hunk) => ({ - ...hunk, - hunkContent: hunk.hunkContent.map((content) => ({ ...content })), - })), + hunks: cloneHunks(fileDiff.hunks), deletionLines: [...fileDiff.deletionLines], additionLines: [...fileDiff.additionLines], }; diff --git a/packages/diffs/test/CodeView.edit.test.ts b/packages/diffs/test/CodeView.edit.test.ts index 1b73fb62e..25b4a34af 100644 --- a/packages/diffs/test/CodeView.edit.test.ts +++ b/packages/diffs/test/CodeView.edit.test.ts @@ -16,6 +16,8 @@ import type { DiffsEditableComponent, DiffsEditor, EditCompletionDecision, + EditorDocumentKind, + EditorViewState, FileContents, FileDiffLoadedFiles, FileDiffMetadata, @@ -39,12 +41,14 @@ interface StubEditor extends DiffsEditor { edits: DiffsEditableComponent[]; fullCleanUps: number; recycleCleanUps: number; + state: EditorViewState; /** The CodeView-built onChange handed to the factory. */ emitChange( file: FileContents, lineAnnotations?: | LineAnnotation[] - | DiffLineAnnotation[] + | DiffLineAnnotation[], + state?: EditorViewState ): void; } @@ -57,34 +61,43 @@ function createEditorHarness({ attachmentError?: Error; } = {}) { const editors: StubEditor[] = []; + const editStateKeys: Array = []; const createEditor = ( - options: CodeViewCreateEditorOptions - ): StubEditor => { - let detach: ((recycle?: boolean) => void) | undefined; + _documentKind: EditorDocumentKind, + options: CodeViewCreateEditorOptions, + editStateKey?: string + ): DiffsEditor => { + editStateKeys.push(editStateKey); + let detach: (() => void) | undefined; + let sessionOwner: DiffsEditableComponent | undefined; const editor = { edits: [], fullCleanUps: 0, recycleCleanUps: 0, + state: {}, emitChange( file: FileContents, lineAnnotations?: | LineAnnotation[] - | DiffLineAnnotation[] + | DiffLineAnnotation[], + state: EditorViewState = {} ) { - options.onChange({ changes: [], file, lineAnnotations }); + editor.state = state; + options.onChange({ changes: [], editor, file, lineAnnotations }); }, edit(instance: DiffsEditableComponent) { editor.edits.push(instance); - detach = instance.attachEditor(editor); + if (sessionOwner != null && sessionOwner !== instance) { + throw new Error('StubEditor: recycled with a different component'); + } + if (sessionOwner == null) { + sessionOwner = instance; + detach = instance.__attachEditor(editor); + } if (attachmentError != null) { throw attachmentError; } - // Mirror the real editor's disposer: tear down, then complete the - // session on the attached instance. - return () => { - editor.cleanUp('complete'); - instance.completeEditSession(); - }; + return () => editor.cleanUp('complete'); }, cleanUp(reason: 'discard' | 'recycle' | 'complete' = 'discard') { if (reason === 'recycle') { @@ -92,19 +105,31 @@ function createEditorHarness({ } else { editor.fullCleanUps += 1; } - // Like the real editor, the detach closure learns whether this is a - // virtualized recycle or a genuine session end. - detach?.(reason === 'recycle'); - detach = undefined; + if (reason !== 'recycle') { + try { + detach?.(); + sessionOwner?.__completeEditSession( + editor, + reason === 'complete' ? 'install' : 'discard' + ); + } finally { + detach = undefined; + sessionOwner = undefined; + } + } }, + getViewState: () => editor.state, __captureFocusForDOMReplacement() {}, + __emitEditComplete() {}, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, __postponeBgTokenizeToNextFrame() {}, __syncRenderView() {}, } as unknown as StubEditor; editors.push(editor); - return editor; + return editor as unknown as DiffsEditor; }; - return { editors, createEditor }; + return { editors, createEditor, editStateKeys }; } // Write text into an attached instance's private session file, standing in @@ -313,6 +338,63 @@ describe('CodeView item edit mode', () => { } }); + test('resolves an edit state key only when creating an item editor', async () => { + const { cleanup } = installDom(); + const { createEditor, editStateKeys, editors } = createEditorHarness(); + const resolvedIds: string[] = []; + let revision = 'first'; + const viewer = new CodeView({ + createEditor, + getEditStateKey(item) { + resolvedIds.push(item.id); + return `${item.id}:${revision}`; + }, + }); + const item = makeEditFileItem('a'); + + try { + viewer.setup(createRoot()); + await renderItems(viewer, [item]); + expect(editStateKeys).toEqual(['a:first']); + expect(resolvedIds).toEqual(['a']); + + expect(viewer.updateItemId('a', 'renamed')).toBe(true); + const renamed = viewer.getItem('renamed')!; + await applyItemUpdate(viewer, { + ...renamed, + collapsed: true, + version: 1, + }); + await applyItemUpdate(viewer, { + ...renamed, + collapsed: false, + version: 2, + }); + expect(editors).toHaveLength(1); + expect(editStateKeys).toEqual(['a:first']); + expect(resolvedIds).toEqual(['a']); + + await applyItemUpdate(viewer, { + ...renamed, + edit: false, + version: 3, + }); + revision = 'second'; + await applyItemUpdate(viewer, { + ...renamed, + edit: true, + version: 4, + }); + expect(editors).toHaveLength(2); + expect(editStateKeys).toEqual(['a:first', 'renamed:second']); + expect(resolvedIds).toEqual(['a', 'renamed']); + } finally { + viewer.cleanUp(); + await wait(0); + cleanup(); + } + }); + test('attaches factory editors to edit-mode items on mount', async () => { const { cleanup } = installDom(); const { editors, createEditor } = createEditorHarness(); @@ -476,7 +558,8 @@ describe('CodeView item edit mode', () => { expect(viewer.getEditor('a')).toBe(editors[0]); await applyItemUpdate(viewer, { ...item, collapsed: true, version: 2 }); - expect(editors[0].fullCleanUps).toBe(1); + expect(editors[0].recycleCleanUps).toBe(1); + expect(editors[0].fullCleanUps).toBe(0); expect(viewer.getEditor('a')).toBe(editors[0]); // Expanding re-attaches the same editor; no new editor is created. @@ -490,6 +573,57 @@ describe('CodeView item edit mode', () => { } }); + for (const [itemType, makeItem] of [ + ['file', () => makeEditFileItem('a')], + ['diff', () => makeEditDiffItem('a')], + ] as const) { + test(`collapse preserves ${itemType} contents and undo history`, async () => { + const { cleanup } = installDom(); + const viewer = new CodeView({ + createEditor: (documentKind, options) => + new Editor(documentKind, options), + }); + const item = makeItem(); + try { + viewer.setup(createRoot()); + await renderItems(viewer, [item]); + const editor = viewer.getEditor(item.id) as Editor; + const originalContents = editor.getText(); + + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'edited ', + }, + ]); + expect(editor.getText()).toBe(`edited ${originalContents}`); + + await applyItemUpdate(viewer, { + ...item, + collapsed: true, + version: 1, + }); + await applyItemUpdate(viewer, { + ...item, + collapsed: false, + version: 2, + }); + + expect(viewer.getEditor(item.id)).toBe(editor); + expect(editor.getText()).toBe(`edited ${originalContents}`); + editor.undo(); + expect(editor.getText()).toBe(originalContents); + } finally { + viewer.cleanUp(); + await wait(0); + cleanup(); + } + }); + } + test('entering edit mode preserves the item line selection', async () => { const { cleanup } = installDom(); const { createEditor } = createEditorHarness(); @@ -590,114 +724,6 @@ describe('CodeView item edit mode', () => { } }); - test('recycle restores persisted item-local state without moving CodeView', async () => { - const { cleanup } = installDom(); - const editors: Editor[] = []; - const viewer = new CodeView({ - createEditor(options) { - const editor = new Editor({ - ...options, - persistState: true, - }); - editors.push(editor); - return editor; - }, - }); - const edited = makeTextEditFileItem('edited', true, 30); - if (edited.type !== 'file') { - throw new Error('Expected an edited file item.'); - } - edited.file = { ...edited.file, cacheKey: 'edited' }; - const items: CodeViewItem[] = [ - edited, - ...Array.from({ length: 39 }, (_, index) => - makeTextEditFileItem(`file-${index}`, false, 30) - ), - ]; - - try { - const root = createRoot(); - viewer.setup(root); - await renderItems(viewer, items); - await waitFor(() => { - const rendered = viewer - .getRenderedItems() - .find((item) => item.id === edited.id); - return ( - viewer.getEditor(edited.id) !== undefined && - rendered?.element.shadowRoot?.querySelector('[data-code]') instanceof - HTMLElement - ); - }); - - const editor = viewer.getEditor(edited.id) as Editor; - const firstRendered = viewer - .getRenderedItems() - .find((item) => item.id === edited.id); - const firstCode = - firstRendered?.element.shadowRoot?.querySelector('[data-code]'); - expect(firstCode).toBeInstanceOf(HTMLElement); - editor.setSelections([ - { - start: { line: 2, character: 3 }, - end: { line: 2, character: 3 }, - direction: 'none', - }, - ]); - (firstCode as HTMLElement).scrollLeft = 64; - - root.scrollTop = 20_000; - dispatchScroll(root); - viewer.render(true); - await wait(0); - expect( - viewer.getRenderedItems().some((item) => item.id === edited.id) - ).toBe(false); - - root.scrollTop = 0; - dispatchScroll(root); - viewer.render(true); - await waitFor(() => { - const rendered = viewer - .getRenderedItems() - .find((item) => item.id === edited.id); - return ( - rendered?.element.shadowRoot?.querySelector('[data-code]') instanceof - HTMLElement - ); - }); - const remounted = viewer - .getRenderedItems() - .find((item) => item.id === edited.id); - const remountedCode = - remounted?.element.shadowRoot?.querySelector('[data-code]'); - await waitFor(() => { - const selection = editor.getState().selections?.[0]; - return ( - selection?.start.line === 2 && - selection.start.character === 3 && - (remountedCode as HTMLElement | undefined)?.scrollLeft === 64 - ); - }); - expect(editors).toHaveLength(1); - expect(viewer.getEditor(edited.id)).toBe(editor); - expect(editor.getState().selections).toEqual([ - { - start: { line: 2, character: 3 }, - end: { line: 2, character: 3 }, - direction: 0, - }, - ]); - expect(remountedCode).toBeInstanceOf(HTMLElement); - expect(viewer.getScrollTop()).toBe(0); - expect((remountedCode as HTMLElement).scrollLeft).toBe(64); - } finally { - viewer.cleanUp(); - await wait(0); - cleanup(); - } - }); - test('remounts an edited file whose document grew without crashing', async () => { const { cleanup } = installDom(); const { createEditor } = createEditorHarness(); @@ -956,10 +982,11 @@ describe('CodeView item edit mode', () => { // file and return the nextItem CodeView built. CodeView installs the // file and applies nextItem through updateItem itself. const viewer: CodeView = new CodeView({ - createEditor: (options) => new Editor({ ...options }), + createEditor: (documentKind, options) => + new Editor(documentKind, { ...options }), onItemEditComplete(event, item, nextItem) { if (item.type !== 'file' || !('file' in event)) { - return 'reject'; + return 'accept'; } event.file.cacheKey = `${item.id}:v${nextItem.version}`; return 'accept'; @@ -1014,6 +1041,100 @@ describe('CodeView item edit mode', () => { } }); + test('a real editor restores horizontal state without moving the shared viewport', async () => { + const { cleanup } = installDom(); + const completions: FileEditCompleteEvent[] = []; + const completionStates: EditorViewState[] = []; + const viewer = new CodeView({ + createEditor: (documentKind, options) => + new Editor(documentKind, options), + onItemEditComplete(event) { + if ('file' in event) { + completions.push(event); + completionStates.push(event.editor.getViewState()); + } + return 'reject'; + }, + }); + const item = makeEditFileItem('recycled-state'); + if (item.type !== 'file') { + throw new Error('Expected a file item'); + } + const root = createRoot(); + + try { + viewer.setup(root); + await renderItems(viewer, [item]); + const editor = viewer.getEditor(item.id) as Editor; + await waitFor(() => editor?.getText() === item.file.contents); + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'X', + }, + ]); + editor.setSelections([ + { + start: { line: 0, character: 3 }, + end: { line: 0, character: 3 }, + direction: 'none', + }, + ]); + const code = viewer + .getRenderedItems()[0] + ?.element.shadowRoot?.querySelector('[data-code]'); + expect(code).toBeInstanceOf(HTMLElement); + (code as HTMLElement).scrollLeft = 24; + root.scrollTop = 48; + const finalState: EditorViewState = { + selections: [ + { + start: { line: 0, character: 3 }, + end: { line: 0, character: 3 }, + direction: 0, + }, + ], + view: { scrollLeft: 24 }, + }; + + await applyItemUpdate(viewer, { + ...item, + collapsed: true, + version: 1, + }); + expect(completions).toHaveLength(0); + expect(viewer.getEditor(item.id)?.getViewState()).toEqual(finalState); + root.scrollTop = 72; + await applyItemUpdate(viewer, { + ...item, + collapsed: false, + version: 2, + }); + const remountedCode = viewer + .getRenderedItems()[0] + ?.element.shadowRoot?.querySelector('[data-code]'); + expect(remountedCode).toBeInstanceOf(HTMLElement); + await waitFor(() => (remountedCode as HTMLElement).scrollLeft === 24); + expect(root.scrollTop).toBe(72); + await applyItemUpdate(viewer, { + ...item, + collapsed: true, + edit: false, + version: 3, + }); + + expect(completions).toHaveLength(1); + expect(completionStates[0]).toEqual(finalState); + } finally { + viewer.cleanUp(); + await wait(0); + cleanup(); + } + }); + test('updateItemId keeps the editor and routes changes to the renamed item', async () => { const { cleanup } = installDom(); const { editors, createEditor } = createEditorHarness(); @@ -1044,19 +1165,36 @@ describe('CodeView item edit mode', () => { test('onItemEditChange receives the owning item and contents', async () => { const { cleanup } = installDom(); const { editors, createEditor } = createEditorHarness(); - const changes: Array<[string, string]> = []; + const changes: Array<[string, string, EditorViewState]> = []; const viewer = new CodeView({ createEditor, onItemEditChange(event, item) { - changes.push([item.id, event.file.contents]); + changes.push([ + item.id, + event.file.contents, + event.editor.getViewState(), + ]); }, }); try { viewer.setup(createRoot()); await renderItems(viewer, [makeEditFileItem('a')]); - editors[0].emitChange({ name: 'a.ts', contents: 'edited' }); - expect(changes).toEqual([['a', 'edited']]); + const state: EditorViewState = { + selections: [ + { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + direction: 0, + }, + ], + }; + editors[0].emitChange( + { name: 'a.ts', contents: 'edited' }, + undefined, + state + ); + expect(changes).toEqual([['a', 'edited', state]]); } finally { viewer.cleanUp(); await wait(0); @@ -1074,8 +1212,8 @@ describe('CodeView item edit mode', () => { } initial.fileDiff.cacheKey = 'active:v1'; const viewer = new CodeView({ - createEditor(options) { - const editor = new Editor(options); + createEditor(documentKind, options) { + const editor = new Editor(documentKind, options); editors.push(editor); return editor; }, @@ -1157,8 +1295,8 @@ describe('CodeView item edit mode', () => { ), ]; const viewer = new CodeView({ - createEditor(options) { - const editor = new Editor(options); + createEditor(documentKind, options) { + const editor = new Editor(documentKind, options); editors.push(editor); return editor; }, @@ -1251,8 +1389,8 @@ describe('CodeView item edit mode', () => { ), ]; const viewer = new CodeView({ - createEditor(options) { - const editor = new Editor(options); + createEditor(documentKind, options) { + const editor = new Editor(documentKind, options); editors.push(editor); return editor; }, @@ -1343,8 +1481,8 @@ describe('CodeView item edit mode', () => { } initial.fileDiff.cacheKey = 'active:v1'; const viewer = new CodeView({ - createEditor(options) { - const editor = new Editor(options); + createEditor(documentKind, options) { + const editor = new Editor(documentKind, options); editors.push(editor); return editor; }, @@ -1633,6 +1771,7 @@ describe('CodeView item edit mode', () => { const viewer = new CodeView({ createEditor, onItemEditComplete(event, item) { + expect(event.editor).toBe(editors[0]); completions.push({ event, item }); return 'reject'; }, @@ -1660,6 +1799,7 @@ describe('CodeView item edit mode', () => { // The event's original value is the item's exact external file, and // the item handed to the callback is the one that ended the session. expect(event.originalFile).toBe(item.file); + expect(editors[0].getViewState()).toEqual({}); expect(completedItem.edit).toBe(false); expect(completedItem.version).toBe(1); } finally { @@ -1821,7 +1961,8 @@ describe('CodeView item edit mode', () => { setSessionText(editors[0], 'kept across collapse'); await applyItemUpdate(viewer, { ...item, collapsed: true, version: 1 }); expect(completions.length).toBe(0); - expect(editors[0].fullCleanUps).toBe(1); + expect(editors[0].recycleCleanUps).toBe(1); + expect(editors[0].fullCleanUps).toBe(0); expect(viewer.getEditor('a')).toBe(editors[0]); await applyItemUpdate(viewer, { @@ -1846,7 +1987,7 @@ describe('CodeView item edit mode', () => { } }); - test('does not fire for sessions without changes', async () => { + test('fires for sessions without changes', async () => { const { cleanup } = installDom(); const { createEditor } = createEditorHarness(); let completions = 0; @@ -1863,7 +2004,7 @@ describe('CodeView item edit mode', () => { await renderItems(viewer, [item]); await applyItemUpdate(viewer, { ...item, edit: false, version: 1 }); - expect(completions).toBe(0); + expect(completions).toBe(1); } finally { viewer.cleanUp(); await wait(0); @@ -1970,7 +2111,7 @@ describe('CodeView item edit mode', () => { } }); - test('a direct reset completes changed sessions once', async () => { + test('a direct reset discards accepted session output', async () => { const { cleanup } = installDom(); const { editors, createEditor } = createEditorHarness(); const completions: Completion[] = []; @@ -1978,7 +2119,7 @@ describe('CodeView item edit mode', () => { createEditor, onItemEditComplete(event, item) { completions.push({ event, item }); - return 'reject'; + return 'accept'; }, }); try { @@ -1993,7 +2134,8 @@ describe('CodeView item edit mode', () => { expect(completions[0].item.id).toBe('a'); expect(editors[0].fullCleanUps).toBeGreaterThanOrEqual(1); - // cleanUp after the reset finds no session left to complete. + // Accepting cannot retain the removed item, and cleanUp after the reset + // finds no session left to complete. viewer.cleanUp(); await wait(0); expect(completions.length).toBe(1); diff --git a/packages/diffs/test/DiffHunksRendererRecompute.test.ts b/packages/diffs/test/DiffHunksRendererRecompute.test.ts index 95303ca3a..cebe2e29a 100644 --- a/packages/diffs/test/DiffHunksRendererRecompute.test.ts +++ b/packages/diffs/test/DiffHunksRendererRecompute.test.ts @@ -780,6 +780,26 @@ describe('DiffHunksRenderer edit-session hunk updates', () => { if (result == null) return; expect(result.rowCount).toBeGreaterThan(0); }); + + test('keeps rename classification frozen until session exit', async () => { + const renderer = new DiffHunksRenderer({ + theme: 'github-light', + diffStyle: 'split', + }); + const diff = parseDiffFromFile( + { name: 'before.ts', contents: '' }, + { name: 'after.ts', contents: 'temporary\n' } + ); + await renderer.asyncRender(diff); + renderer.renderDiff(diff); + renderer.beginEditSession(); + + renderer.applyDocumentChange(makeTextDocument([''])); + + expect(diff.type).toBe('rename-changed'); + expect(finishEditSessionForDiff(diff)).toBe(true); + expect(diff.type).toBe('rename-pure'); + }); }); // Deleting every character empties the editor's document, whose text is "". diff --git a/packages/diffs/test/File.editSessionOwnership.test.ts b/packages/diffs/test/File.editSessionOwnership.test.ts index 75cc7ae4f..45908073e 100644 --- a/packages/diffs/test/File.editSessionOwnership.test.ts +++ b/packages/diffs/test/File.editSessionOwnership.test.ts @@ -5,9 +5,12 @@ import type { FileEditCompleteEvent, FileEditCompleteHandler, } from '../src/components/File'; -import { Editor } from '../src/editor/editor'; +import { Editor, type EditorOptions } from '../src/editor/editor'; +import { EditStateManager } from '../src/editor/EditStateManager'; import type { + DiffsEditor, EditorChangeEvent, + EditorViewState, FileContents, LineAnnotation, } from '../src/types'; @@ -45,6 +48,22 @@ const EXTERNAL_FILE: FileContents = { cacheKey: 'external:file-v1', }; +function createEditorStub( + overrides: Partial> = {} +): DiffsEditor { + return { + cleanUp() {}, + edit: () => () => {}, + __captureFocusForDOMReplacement() {}, + __emitEditComplete() {}, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, + __postponeBgTokenizeToNextFrame() {}, + __syncRenderView() {}, + ...overrides, + } as unknown as DiffsEditor; +} + async function createFixture(options?: { lineAnnotations?: LineAnnotation[]; onChange?( @@ -60,7 +79,7 @@ async function createFixture(options?: { disableErrorHandling: true, disableFileHeader: true, }); - const editor = new Editor({ + const editor = new Editor('file', { onChange({ file, lineAnnotations }) { options?.onChange?.( file.contents, @@ -139,6 +158,38 @@ describe('editing a File without changing its input', () => { } }); + test('failed attachment releases the editor association', () => { + const dom = installDom(); + const fileContainer = document.createElement('div'); + const instance = new TestFile({ + disableErrorHandling: true, + disableFileHeader: true, + }); + const attachmentError = new Error('attachment failed'); + try { + instance.render({ + file: { ...EXTERNAL_FILE }, + fileContainer, + forceRender: true, + }); + expect(() => + instance.__attachEditor( + createEditorStub({ + __getDocumentContents() { + throw attachmentError; + }, + }) + ) + ).toThrow(attachmentError); + + const detach = instance.__attachEditor(createEditorStub()); + detach(); + } finally { + instance.cleanUp(); + dom.cleanup(); + } + }); + test('attaching an editor creates a separate file without a cache key', async () => { const fixture = await createFixture(); try { @@ -389,13 +440,21 @@ describe('component onEditChange', () => { const externalFile = { ...EXTERNAL_FILE }; const editorEvents: EditorChangeEvent[] = []; const componentEvents: EditorChangeEvent[] = []; + const editorCallbackEditors: DiffsEditor[] = []; + const callbackEditors: DiffsEditor[] = []; const instance = new TestFile({ disableErrorHandling: true, disableFileHeader: true, - onEditChange: (event) => componentEvents.push(event), + onEditChange: (event) => { + componentEvents.push(event); + callbackEditors.push(event.editor); + }, }); - const editor = new Editor({ - onChange: (event) => editorEvents.push(event), + const editor = new Editor('file', { + onChange: (event) => { + editorEvents.push(event); + editorCallbackEditors.push(event.editor); + }, }); try { instance.render({ file: externalFile, fileContainer, forceRender: true }); @@ -403,11 +462,38 @@ describe('component onEditChange', () => { await waitFor(() => editor.getText() === externalFile.contents, { timeout: 4_000, }); - replaceDocument(editor, 'edited\n'); + editor.setSelections([ + { + start: { line: 0, character: 5 }, + end: { line: 0, character: 5 }, + direction: 'none', + }, + ]); + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'X', + }, + ]); expect(editorEvents).toHaveLength(1); expect(componentEvents).toHaveLength(1); expect(componentEvents[0]).toBe(editorEvents[0]); - expect(componentEvents[0]?.file.contents).toBe('edited\n'); + expect(editorCallbackEditors).toEqual([editor]); + expect(callbackEditors).toEqual([editor]); + expect(componentEvents[0]?.file.contents).toBe('Xalpha\nbravo\n'); + expect(callbackEditors[0]?.getViewState()).toEqual({ + selections: [ + { + start: { line: 0, character: 6 }, + end: { line: 0, character: 6 }, + direction: 0, + }, + ], + view: { scrollLeft: 0 }, + }); } finally { editor.cleanUp(); instance.cleanUp(); @@ -427,7 +513,7 @@ describe('component onEditChange', () => { disableFileHeader: true, onEditChange: (event) => firstEvents.push(event), }); - const editor = new Editor({}); + const editor = new Editor('file'); try { instance.render({ file: externalFile, fileContainer, forceRender: true }); editor.edit(instance); @@ -463,7 +549,7 @@ describe('component onEditChange', () => { disableFileHeader: true, onEditChange: (event) => events.push(event), }); - const editor = new Editor({}); + const editor = new Editor('file'); try { instance.render({ file: externalFile, fileContainer, forceRender: true }); editor.edit(instance); @@ -502,7 +588,7 @@ describe('session-owned line annotations', () => { disableFileHeader: true, onEditChange: (event) => events.push(event), }); - const editor = new Editor({}); + const editor = new Editor('file'); instance.render({ file: externalFile, fileContainer, @@ -796,8 +882,10 @@ describe('session-owned line annotations', () => { }); }); -describe('completeEditSession', () => { +describe('__completeEditSession', () => { async function createCompletionFixture(config?: { + editStateKey?: string; + editorOnComplete?: NonNullable['onComplete']>; onEditComplete?: FileEditCompleteHandler; onEditChange?: (event: EditorChangeEvent) => void; lineAnnotations?: LineAnnotation[]; @@ -812,7 +900,11 @@ describe('completeEditSession', () => { onEditComplete: config?.onEditComplete, onEditChange: config?.onEditChange, }); - const editor = new Editor({}); + const editor = new Editor( + 'file', + { onComplete: config?.editorOnComplete }, + config?.editStateKey + ); instance.render({ file: externalFile, fileContainer, @@ -829,7 +921,7 @@ describe('completeEditSession', () => { fileContainer, instance, detach() { - editor.cleanUp(); + editor.cleanUp('recycle'); }, cleanup() { editor.cleanUp(); @@ -851,29 +943,124 @@ describe('completeEditSession', () => { ]); } + test('editor completion receives the component event first and cannot accept', async () => { + const calls: string[] = []; + let editorEvent: FileEditCompleteEvent | undefined; + let componentEvent: FileEditCompleteEvent | undefined; + const fixture = await createCompletionFixture({ + editorOnComplete(event) { + if ('file' in event) { + editorEvent = event; + } + calls.push('editor'); + return 'accept'; + }, + onEditComplete(event) { + componentEvent = event; + calls.push('component'); + return 'reject'; + }, + }); + try { + replaceDocument(fixture.editor, 'edited\nbravo\n'); + fixture.editor.cleanUp('complete'); + + expect(calls).toEqual(['editor', 'component']); + expect(editorEvent).toBe(componentEvent); + expect(editorEvent?.editor).toBe(fixture.editor); + expect(fixture.instance.file).toBe(fixture.externalFile); + } finally { + fixture.cleanup(); + } + }); + test('a changed session delivers a fresh keyless file and the exact external file', async () => { const events: FileEditCompleteEvent[] = []; + let completionEditor: DiffsEditor | undefined; + let completionState: EditorViewState | undefined; + let completionEditState: ReturnType['getEditState']>; const fixture = await createCompletionFixture({ onEditComplete(event) { events.push(event); + completionEditor = event.editor; + completionState = event.editor.getViewState(); + completionEditState = event.editor.getEditState(); return 'reject'; }, }); try { - const { editor, externalFile, instance } = fixture; + const { editor, externalFile } = fixture; replaceDocument(editor, 'edited\nbravo\n'); - fixture.detach(); - instance.completeEditSession(); + editor.setSelections([ + { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + direction: 'none', + }, + ]); + editor.cleanUp('complete'); expect(events).toHaveLength(1); + expect(completionEditor).toBe(editor); + expect(completionState).toEqual({ + selections: [ + { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + direction: 0, + }, + ], + view: { scrollLeft: 0 }, + }); + expect(completionEditState?.document.getText()).toBe('edited\nbravo\n'); + expect(completionEditState?.editor).toEqual(completionState); + expect(editor.getEditState()).toBeUndefined(); const event = events[0]; expect(event.file.contents).toBe('edited\nbravo\n'); expect(event.file.cacheKey).toBeUndefined(); expect(event.file).not.toBe(externalFile); expect(event.originalFile).toBe(externalFile); expect(externalFile.contents).toBe(EXTERNAL_FILE.contents); + expect(Object.isFrozen(event)).toBe(true); + } finally { + fixture.cleanup(); + } + }); + + test('releases keyed ownership before the completion callback', async () => { + const editStateKey = 'completion-callback-release'; + let replacementEditor: Editor | undefined; + let replacementInstance: TestFile | undefined; + const fixture = await createCompletionFixture({ + editStateKey, + onEditComplete() { + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + replacementInstance = new TestFile({ disableFileHeader: true }); + replacementInstance.render({ + file: { ...EXTERNAL_FILE }, + fileContainer, + forceRender: true, + }); + replacementEditor = new Editor( + 'file', + undefined, + editStateKey + ); + replacementEditor.edit(replacementInstance); + return 'reject'; + }, + }); + try { + insertLinesAtStart(fixture.editor); + fixture.editor.cleanUp('complete'); + + expect(replacementEditor).toBeDefined(); } finally { + replacementEditor?.cleanUp(); + replacementInstance?.cleanUp(); fixture.cleanup(); + EditStateManager.clear('file', editStateKey); } }); @@ -893,8 +1080,7 @@ describe('completeEditSession', () => { try { const { editor, instance } = fixture; insertLinesAtStart(editor); - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(events).toHaveLength(1); const event = events[0]; @@ -910,7 +1096,7 @@ describe('completeEditSession', () => { ]); // Settled: calling again does not fire the handler a second time. - instance.completeEditSession(); + instance.__completeEditSession(editor, 'install'); expect(events).toHaveLength(1); } finally { fixture.cleanup(); @@ -948,8 +1134,7 @@ describe('completeEditSession', () => { expect(instance.getExternalAnnotationsForTest()).toBe(added); // Reverting keeps the added annotation; the baseline is not rewound. - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(instance.getLatestAnnotationsForTest()).toBe(added); } finally { fixture.cleanup(); @@ -970,8 +1155,7 @@ describe('completeEditSession', () => { expect(instance.getLatestAnnotationsForTest()).toEqual([ { lineNumber: 4 }, ]); - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(instance.file).toBe(externalFile); expect(instance.getLatestFileForTest()).toBe(externalFile); @@ -989,8 +1173,7 @@ describe('completeEditSession', () => { try { const { editor, externalFile, instance } = fixture; replaceDocument(editor, 'edited\nbravo\n'); - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(instance.file).toBe(externalFile); expect(instance.getLatestFileForTest()).toBe(externalFile); @@ -1004,8 +1187,7 @@ describe('completeEditSession', () => { try { const { editor, externalFile, instance } = fixture; replaceDocument(editor, 'edited\nbravo\n'); - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(instance.file).toBe(externalFile); expect(instance.getLatestFileForTest()).toBe(externalFile); @@ -1024,8 +1206,7 @@ describe('completeEditSession', () => { try { const { editor, externalFile, instance } = fixture; replaceDocument(editor, 'edited\nbravo\n'); - fixture.detach(); - expect(() => instance.completeEditSession()).toThrow( + expect(() => editor.cleanUp('complete')).toThrow( 'must not reuse the replaced file cacheKey' ); @@ -1045,8 +1226,7 @@ describe('completeEditSession', () => { try { const { editor, externalFile, instance } = fixture; replaceDocument(editor, 'edited\nbravo\n'); - fixture.detach(); - expect(() => instance.completeEditSession()).toThrow('owner exploded'); + expect(() => editor.cleanUp('complete')).toThrow('owner exploded'); expect(instance.file).toBe(externalFile); expect(instance.getLatestFileForTest()).toBe(externalFile); @@ -1055,7 +1235,7 @@ describe('completeEditSession', () => { } }); - test('unchanged contents skip the handler and keep session annotation writes', async () => { + test('unchanged contents complete and can accept session annotation writes', async () => { const externalAnnotations: LineAnnotation[] = [ { lineNumber: 2 }, ]; @@ -1064,7 +1244,7 @@ describe('completeEditSession', () => { lineAnnotations: externalAnnotations, onEditComplete(event) { events.push(event); - return 'reject'; + return 'accept'; }, }); try { @@ -1076,11 +1256,12 @@ describe('completeEditSession', () => { forceRender: true, lineAnnotations: written, }); - fixture.detach(); - instance.completeEditSession(); + fixture.editor.cleanUp('complete'); - expect(events).toHaveLength(0); - expect(instance.file).toBe(externalFile); + expect(events).toHaveLength(1); + expect(events[0].file.contents).toBe(externalFile.contents); + expect(events[0].file.cacheKey).toBeUndefined(); + expect(instance.file).toBe(events[0].file); expect(instance.getExternalAnnotationsForTest()).toBe(written); expect(instance.getLatestAnnotationsForTest()).toBe(written); } finally { @@ -1088,7 +1269,7 @@ describe('completeEditSession', () => { } }); - test('editing and undoing back to the external contents does not complete', async () => { + test('editing and undoing back to the external contents still completes', async () => { const events: FileEditCompleteEvent[] = []; const fixture = await createCompletionFixture({ onEditComplete(event) { @@ -1101,10 +1282,11 @@ describe('completeEditSession', () => { insertLinesAtStart(editor); editor.undo(); expect(editor.getText()).toBe(externalFile.contents); - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); - expect(events).toHaveLength(0); + expect(events).toHaveLength(1); + expect(events[0].file.contents).toBe(externalFile.contents); + expect(events[0].file.cacheKey).toBeUndefined(); expect(instance.file).toBe(externalFile); } finally { fixture.cleanup(); @@ -1121,11 +1303,10 @@ describe('completeEditSession', () => { }, }); try { - const { editor, instance } = fixture; + const { editor } = fixture; replaceDocument(editor, 'edited\nbravo\n'); - fixture.detach(); const changesBefore = changeEvents.length; - instance.completeEditSession(); + editor.cleanUp('complete'); expect(changeEvents.length).toBe(changesBefore); } finally { fixture.cleanup(); @@ -1137,7 +1318,7 @@ describe('completeEditSession', () => { try { const { editor, instance } = fixture; replaceDocument(editor, 'edited\nbravo\n'); - expect(() => instance.completeEditSession()).toThrow( + expect(() => instance.__completeEditSession(editor, 'install')).toThrow( 'detach the editor before completing the session' ); } finally { @@ -1169,8 +1350,7 @@ describe('completeEditSession', () => { timeout: 4_000, }); replaceDocument(editor, 'edited again\nbravo\n'); - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(events).toHaveLength(1); expect(events[0].originalFile).toBe(replacementFile); @@ -1194,7 +1374,7 @@ describe('editor session lifecycle', () => { disableFileHeader: true, onEditComplete: config?.onEditComplete, }); - const editor = new Editor({}); + const editor = new Editor('file'); instance.render({ file: externalFile, fileContainer, forceRender: true }); const finishSession = editor.edit(instance); await waitFor(() => editor.getText() === externalFile.contents, { @@ -1239,7 +1419,7 @@ describe('editor session lifecycle', () => { } }); - test('editor.cleanUp() tears down without finishing the session', async () => { + test('editor.cleanUp() completes without installing the session', async () => { const events: FileEditCompleteEvent[] = []; const fixture = await createLifecycleFixture({ onEditComplete(event) { @@ -1252,9 +1432,11 @@ describe('editor session lifecycle', () => { replaceDocument(editor, 'edited\nbravo\n'); editor.cleanUp(); - expect(events).toHaveLength(0); - // The session survives the silent teardown. - expect(instance.getLatestFileForTest()?.contents).toBe('edited\nbravo\n'); + expect(events).toHaveLength(1); + expect(events[0].file.contents).toBe('edited\nbravo\n'); + expect(instance.getLatestFileForTest()?.contents).toBe( + EXTERNAL_FILE.contents + ); } finally { fixture.cleanup(); } @@ -1275,7 +1457,7 @@ describe('editor session lifecycle', () => { }, }); let attachCount = 0; - const editor = new Editor({ + const editor = new Editor('file', { onAttach: () => { attachCount += 1; }, @@ -1340,6 +1522,7 @@ describe('editor session lifecycle', () => { finishSession(); expect(events).toHaveLength(1); + instance.__completeEditSession(editor, 'install'); instance.cleanUp(); expect(events).toHaveLength(1); } finally { @@ -1347,199 +1530,57 @@ describe('editor session lifecycle', () => { } }); - test('recycle cleanUp of the component keeps the session silent', async () => { + test('a recycled session keeps final state through later editor cleanup', async () => { const events: FileEditCompleteEvent[] = []; + const completionStates: EditorViewState[] = []; const fixture = await createLifecycleFixture({ onEditComplete(event) { events.push(event); + completionStates.push(event.editor.getViewState()); return 'reject'; }, }); try { const { editor, instance } = fixture; replaceDocument(editor, 'edited\nbravo\n'); + editor.setSelections([ + { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + direction: 'none', + }, + ]); editor.cleanUp('recycle'); instance.cleanUp(true); expect(events).toHaveLength(0); - } finally { - fixture.cleanup(); - } - }); -}); - -describe('persistState at completion', () => { - async function createPersistFixture(config?: { - onEditComplete?: FileEditCompleteHandler; - }) { - const dom = installDom(); - const fileContainer = document.createElement('div'); - document.body.appendChild(fileContainer); - const externalFile = { ...EXTERNAL_FILE }; - const instance = new TestFile({ - disableErrorHandling: true, - disableFileHeader: true, - onEditComplete: config?.onEditComplete, - }); - const editor = new Editor({ persistState: true }); - instance.render({ file: externalFile, fileContainer, forceRender: true }); - const finishSession = editor.edit(instance); - await waitFor(() => editor.getText() === externalFile.contents, { - timeout: 4_000, - }); - return { - editor, - externalFile, - fileContainer, - finishSession, - instance, - cleanup() { - editor.cleanUp(); - instance.cleanUp(); - dom.cleanup(); - }, - }; - } - - test('a rejected completion cannot come back through persistState', async () => { - const fixture = await createPersistFixture({ - onEditComplete: () => 'reject', - }); - try { - const { editor, externalFile, finishSession, instance } = fixture; - replaceDocument(editor, 'rejected draft\n'); - finishSession(); - expect(instance.file).toBe(externalFile); - - editor.edit(instance); - await waitFor(() => editor.getText() === externalFile.contents, { - timeout: 4_000, + expect(editor.getViewState()).toEqual({ + selections: [ + { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + direction: 0, + }, + ], + view: { scrollLeft: 0 }, }); - expect(instance.getLatestFileForTest()?.contents).toBe( - externalFile.contents - ); - } finally { - fixture.cleanup(); - } - }); - test('an accepted completion starts the next session from the accepted text', async () => { - const events: FileEditCompleteEvent[] = []; - const fixture = await createPersistFixture({ - onEditComplete(event) { - events.push(event); - event.file.cacheKey = 'external:file-v2'; - return 'accept'; - }, - }); - try { - const { editor, finishSession, instance } = fixture; - replaceDocument(editor, 'accepted draft\n'); - finishSession(); - expect(instance.file).toBe(events[0].file); - - editor.edit(instance); - await waitFor(() => editor.getText() === 'accepted draft\n', { - timeout: 4_000, - }); + editor.cleanUp('complete'); + expect(events).toHaveLength(1); + expect(completionStates).toEqual([ + { + selections: [ + { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + direction: 0, + }, + ], + view: { scrollLeft: 0 }, + }, + ]); } finally { fixture.cleanup(); } }); - - test('component cleanUp drops the stored draft for the next mount', async () => { - const dom = installDom(); - const fileContainer = document.createElement('div'); - document.body.appendChild(fileContainer); - const externalFile = { ...EXTERNAL_FILE }; - const instance = new TestFile({ - disableErrorHandling: true, - disableFileHeader: true, - }); - const editor = new Editor({ persistState: true }); - try { - instance.render({ file: externalFile, fileContainer, forceRender: true }); - editor.edit(instance); - await waitFor(() => editor.getText() === externalFile.contents, { - timeout: 4_000, - }); - replaceDocument(editor, 'discarded draft\n'); - instance.cleanUp(); - - const remounted = new TestFile({ - disableErrorHandling: true, - disableFileHeader: true, - }); - const remountContainer = document.createElement('div'); - document.body.appendChild(remountContainer); - try { - remounted.render({ - file: { ...EXTERNAL_FILE }, - fileContainer: remountContainer, - forceRender: true, - }); - editor.edit(remounted); - await waitFor(() => editor.getText() === EXTERNAL_FILE.contents, { - timeout: 4_000, - }); - expect(remounted.getLatestFileForTest()?.contents).toBe( - EXTERNAL_FILE.contents - ); - } finally { - editor.cleanUp(); - remounted.cleanUp(); - } - } finally { - editor.cleanUp(); - instance.cleanUp(); - dom.cleanup(); - } - }); - - test('silent editor teardown still stores the draft for a fresh mount', async () => { - const dom = installDom(); - const fileContainer = document.createElement('div'); - document.body.appendChild(fileContainer); - const externalFile = { ...EXTERNAL_FILE }; - const instance = new TestFile({ - disableErrorHandling: true, - disableFileHeader: true, - }); - const editor = new Editor({ persistState: true }); - try { - instance.render({ file: externalFile, fileContainer, forceRender: true }); - editor.edit(instance); - await waitFor(() => editor.getText() === externalFile.contents, { - timeout: 4_000, - }); - replaceDocument(editor, 'kept draft\n'); - editor.cleanUp(); - instance.cleanUp(); - - const remounted = new TestFile({ - disableErrorHandling: true, - disableFileHeader: true, - }); - const remountContainer = document.createElement('div'); - document.body.appendChild(remountContainer); - try { - remounted.render({ - file: { ...EXTERNAL_FILE }, - fileContainer: remountContainer, - forceRender: true, - }); - editor.edit(remounted); - await waitFor(() => editor.getText() === 'kept draft\n', { - timeout: 4_000, - }); - } finally { - editor.cleanUp(); - remounted.cleanUp(); - } - } finally { - editor.cleanUp(); - instance.cleanUp(); - dom.cleanup(); - } - }); }); diff --git a/packages/diffs/test/FileDiff.editSessionOwnership.test.ts b/packages/diffs/test/FileDiff.editSessionOwnership.test.ts index 7b75578c1..785f5f7af 100644 --- a/packages/diffs/test/FileDiff.editSessionOwnership.test.ts +++ b/packages/diffs/test/FileDiff.editSessionOwnership.test.ts @@ -12,12 +12,13 @@ import type { FileDiffEditCompleteHandler, FileDiffOptions, } from '../src/components/FileDiff'; -import { Editor } from '../src/editor/editor'; +import { Editor, type EditorOptions } from '../src/editor/editor'; import type { DiffLineAnnotation, DiffsEditor, DiffsTextDocument, EditorChangeEvent, + EditorViewState, FileContents, FileDiffMetadata, HighlightedToken, @@ -29,6 +30,8 @@ afterAll(async () => { }); class TestFileDiff extends FileDiff { + throwOnRerender = false; + getLatestDiffForTest(): FileDiffMetadata | undefined { return this.getLatestDiff(); } @@ -48,16 +51,33 @@ class TestFileDiff extends FileDiff { getLatestAnnotationsForTest(): DiffLineAnnotation[] { return this.getLatestAnnotations(); } + + clearRenderCacheForTest(): void { + this.hunksRenderer.clearRenderCache(); + } + + override rerender(): void { + if (this.throwOnRerender) { + throw new Error('attachment failed'); + } + super.rerender(); + } } -function createEditorStub(): DiffsEditor { +function createEditorStub( + overrides: Partial> = {} +): DiffsEditor { return { cleanUp() {}, edit: () => () => {}, __captureFocusForDOMReplacement() {}, + __emitEditComplete() {}, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, __postponeBgTokenizeToNextFrame() {}, __syncRenderView() {}, - }; + ...overrides, + } as unknown as DiffsEditor; } function createExternalDiff(): FileDiffMetadata { @@ -113,7 +133,7 @@ function makeTextDocument(lines: string[]): DiffsTextDocument { async function createAttachedFixture(): Promise<{ cleanup(): void; - detach(recycle?: boolean): void; + detach(): void; externalDiff: FileDiffMetadata; fileContainer: HTMLElement; instance: TestFileDiff; @@ -132,7 +152,7 @@ async function createAttachedFixture(): Promise<{ fileContainer, forceRender: true, }); - const detach = instance.attachEditor(createEditorStub()); + const detachEditor = instance.__attachEditor(createEditorStub()); await waitFor( () => { @@ -155,7 +175,9 @@ async function createAttachedFixture(): Promise<{ instance.cleanUp(); dom.cleanup(); }, - detach, + detach() { + detachEditor(); + }, externalDiff, fileContainer, instance, @@ -202,6 +224,34 @@ describe('FileDiff edit-session ownership', () => { } }); + test('failed attachment releases the editor association', () => { + const dom = installDom(); + const fileContainer = document.createElement('div'); + const instance = new TestFileDiff({ + disableErrorHandling: true, + disableFileHeader: true, + }); + try { + instance.render({ + fileDiff: createExternalDiff(), + fileContainer, + forceRender: true, + }); + instance.clearRenderCacheForTest(); + instance.throwOnRerender = true; + expect(() => instance.__attachEditor(createEditorStub())).toThrow( + 'attachment failed' + ); + + instance.throwOnRerender = false; + const detach = instance.__attachEditor(createEditorStub()); + detach(); + } finally { + instance.cleanUp(); + dom.cleanup(); + } + }); + test('attach creates a private keyless shallow session diff', async () => { const fixture = await createAttachedFixture(); const { detach, externalDiff, instance } = fixture; @@ -432,7 +482,7 @@ describe('FileDiff edit-session ownership', () => { test('recycling clears renderer state without writing edits to the external diff', async () => { const fixture = await createAttachedFixture(); - const { detach, externalDiff, instance } = fixture; + const { externalDiff, instance } = fixture; const externalBefore = captureExternalDiffState(externalDiff); try { instance.updateRenderCache( @@ -443,7 +493,6 @@ describe('FileDiff edit-session ownership', () => { expect(sessionDiff).toBeDefined(); expect(sessionDiff?.additionLines[1]).toBe('edited value\n'); - detach(true); instance.cleanUp(true); expect(instance.getLatestDiffForTest()).toBe(sessionDiff); @@ -465,7 +514,7 @@ describe('FileDiff edit-session ownership', () => { disableErrorHandling: true, disableFileHeader: true, }); - const editor = new Editor({ + const editor = new Editor('file-diff', { onChange: (event) => changedFiles.push(event.file), }); @@ -517,7 +566,7 @@ describe('FileDiff edit-session ownership', () => { disableFileHeader: true, onEditChange: (event) => componentEvents.push(event), }); - const editor = new Editor({ + const editor = new Editor('file-diff', { onChange: (event) => editorEvents.push(event), }); @@ -545,6 +594,7 @@ describe('FileDiff edit-session ownership', () => { expect(editorEvents).toHaveLength(1); expect(componentEvents).toHaveLength(1); expect(componentEvents[0]).toBe(editorEvents[0]); + expect(componentEvents[0]?.editor).toBe(editor); expect(componentEvents[0]?.file.contents).toBe( 'alpha\nedited value\nomega\n' ); @@ -569,7 +619,7 @@ describe('FileDiff edit-session ownership', () => { disableErrorHandling: true, disableFileHeader: true, }); - const editor = new Editor({}); + const editor = new Editor('file-diff'); try { instance.render({ @@ -627,10 +677,11 @@ describe('FileDiff edit-session ownership', () => { }); }); -describe('completeEditSession', () => { +describe('__completeEditSession', () => { const EXTERNAL_CONTENTS = 'alpha\nnew value\nomega\n'; async function createCompletionFixture(config?: { + editorOnComplete?: NonNullable['onComplete']>; onEditComplete?: FileDiffEditCompleteHandler; onEditChange?: (event: EditorChangeEvent) => void; lineAnnotations?: DiffLineAnnotation[]; @@ -648,7 +699,9 @@ describe('completeEditSession', () => { onEditChange: config?.onEditChange, loadDiffFiles: config?.loadDiffFiles, }); - const editor = new Editor({}); + const editor = new Editor('file-diff', { + onComplete: config?.editorOnComplete, + }); instance.render({ fileDiff: externalDiff, fileContainer, @@ -666,7 +719,7 @@ describe('completeEditSession', () => { fileContainer, instance, detach() { - editor.cleanUp(); + editor.cleanUp('recycle'); }, cleanup() { editor.cleanUp(); @@ -703,11 +756,42 @@ describe('completeEditSession', () => { ]); } + test('editor completion fires without a component handler and cannot accept', async () => { + const events: FileDiffEditCompleteEvent[] = []; + const fixture = await createCompletionFixture({ + editorOnComplete(event) { + if ('fileDiff' in event) { + events.push(event); + } + return 'accept'; + }, + }); + try { + replaceDocument(fixture.editor, 'alpha\nedited value\nomega\n'); + fixture.editor.cleanUp('complete'); + + expect(events).toHaveLength(1); + expect(events[0]?.editor).toBe(fixture.editor); + expect(events[0]?.fileDiff.additionLines.join('')).toBe( + 'alpha\nedited value\nomega\n' + ); + expect(fixture.instance.fileDiff).toBe(fixture.externalDiff); + } finally { + fixture.cleanup(); + } + }); + test('a changed session delivers a recomputed detached diff and complete files', async () => { const events: FileDiffEditCompleteEvent[] = []; + let completionEditor: DiffsEditor | undefined; + let completionState: EditorViewState | undefined; + let completionEditState: ReturnType['getEditState']>; const fixture = await createCompletionFixture({ onEditComplete(event) { events.push(event); + completionEditor = event.editor; + completionState = event.editor.getViewState(); + completionEditState = event.editor.getEditState(); return 'reject'; }, }); @@ -715,14 +799,36 @@ describe('completeEditSession', () => { try { const { editor, externalDiff, instance } = fixture; replaceDocument(editor, 'alpha\nedited value\nomega\n'); + editor.setSelections([ + { + start: { line: 1, character: 6 }, + end: { line: 1, character: 6 }, + direction: 'none', + }, + ]); const sessionDiff = instance.getLatestDiffForTest(); if (sessionDiff == null) { throw new Error('Expected an active session diff'); } - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(events).toHaveLength(1); + expect(completionEditor).toBe(editor); + expect(completionState).toEqual({ + selections: [ + { + start: { line: 1, character: 6 }, + end: { line: 1, character: 6 }, + direction: 0, + }, + ], + view: { scrollLeft: 0 }, + }); + expect(completionEditState?.document.getText()).toBe( + 'alpha\nedited value\nomega\n' + ); + expect(completionEditState?.editor).toEqual(completionState); + expect(editor.getEditState()).toBeUndefined(); const event = events[0]; expect(event.originalFileDiff).toBe(externalDiff); expect(event.fileDiff.cacheKey).toBeUndefined(); @@ -753,6 +859,7 @@ describe('completeEditSession', () => { name: 'session.ts', contents: 'alpha\nedited value\nomega\n', }); + expect(Object.isFrozen(event)).toBe(true); expectExternalDiffUnchanged(instance, externalDiff, externalBefore); } finally { fixture.cleanup(); @@ -775,8 +882,7 @@ describe('completeEditSession', () => { try { const { editor, instance } = fixture; insertLinesAtStart(editor); - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(events).toHaveLength(1); const event = events[0]; @@ -792,7 +898,7 @@ describe('completeEditSession', () => { ]); // Settled: calling again does not fire the handler a second time. - instance.completeEditSession(); + instance.__completeEditSession(editor, 'install'); expect(events).toHaveLength(1); } finally { fixture.cleanup(); @@ -830,8 +936,7 @@ describe('completeEditSession', () => { expect(instance.getExternalAnnotationsForTest()).toBe(added); // The changed diff reverts, but the added annotation is kept. - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(instance.getLatestAnnotationsForTest()).toBe(added); } finally { fixture.cleanup(); @@ -853,8 +958,7 @@ describe('completeEditSession', () => { expect(instance.getLatestAnnotationsForTest()).toEqual([ { side: 'additions', lineNumber: 4 }, ]); - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(instance.fileDiff).toBe(externalDiff); expect(instance.getLatestDiffForTest()).toBe(externalDiff); @@ -875,8 +979,7 @@ describe('completeEditSession', () => { try { const { editor, externalDiff, instance } = fixture; replaceDocument(editor, 'alpha\nedited value\nomega\n'); - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(instance.fileDiff).toBe(externalDiff); expect(instance.getLatestDiffForTest()).toBe(externalDiff); @@ -890,8 +993,7 @@ describe('completeEditSession', () => { try { const { editor, externalDiff, instance } = fixture; replaceDocument(editor, 'alpha\nedited value\nomega\n'); - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(instance.fileDiff).toBe(externalDiff); expect(instance.getLatestDiffForTest()).toBe(externalDiff); @@ -910,8 +1012,7 @@ describe('completeEditSession', () => { try { const { editor, externalDiff, instance } = fixture; replaceDocument(editor, 'alpha\nedited value\nomega\n'); - fixture.detach(); - expect(() => instance.completeEditSession()).toThrow( + expect(() => editor.cleanUp('complete')).toThrow( 'must not reuse the replaced diff cacheKey' ); @@ -931,8 +1032,7 @@ describe('completeEditSession', () => { try { const { editor, externalDiff, instance } = fixture; replaceDocument(editor, 'alpha\nedited value\nomega\n'); - fixture.detach(); - expect(() => instance.completeEditSession()).toThrow('owner exploded'); + expect(() => editor.cleanUp('complete')).toThrow('owner exploded'); expect(instance.fileDiff).toBe(externalDiff); expect(instance.getLatestDiffForTest()).toBe(externalDiff); @@ -941,7 +1041,7 @@ describe('completeEditSession', () => { } }); - test('unchanged contents skip the handler and keep session annotation writes', async () => { + test('unchanged contents complete and can accept session annotation writes', async () => { const externalAnnotations: DiffLineAnnotation[] = [ { side: 'additions', lineNumber: 2 }, ]; @@ -950,7 +1050,7 @@ describe('completeEditSession', () => { lineAnnotations: externalAnnotations, onEditComplete(event) { events.push(event); - return 'reject'; + return 'accept'; }, }); try { @@ -964,11 +1064,14 @@ describe('completeEditSession', () => { forceRender: true, lineAnnotations: written, }); - fixture.detach(); - instance.completeEditSession(); + fixture.editor.cleanUp('complete'); - expect(events).toHaveLength(0); - expect(instance.fileDiff).toBe(externalDiff); + expect(events).toHaveLength(1); + expect(events[0].fileDiff.additionLines).toEqual( + externalDiff.additionLines + ); + expect(events[0].fileDiff.cacheKey).toBeUndefined(); + expect(instance.fileDiff).toBe(events[0].fileDiff); expect(instance.getExternalAnnotationsForTest()).toBe(written); expect(instance.getLatestAnnotationsForTest()).toBe(written); } finally { @@ -976,7 +1079,7 @@ describe('completeEditSession', () => { } }); - test('editing and undoing back to the external contents does not complete', async () => { + test('editing and undoing back to the external contents still completes', async () => { const events: FileDiffEditCompleteEvent[] = []; const fixture = await createCompletionFixture({ onEditComplete(event) { @@ -989,10 +1092,11 @@ describe('completeEditSession', () => { insertLinesAtStart(editor); editor.undo(); expect(editor.getText()).toBe(EXTERNAL_CONTENTS); - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); - expect(events).toHaveLength(0); + expect(events).toHaveLength(1); + expect(events[0].fileDiff.additionLines.join('')).toBe(EXTERNAL_CONTENTS); + expect(events[0].fileDiff.cacheKey).toBeUndefined(); expect(instance.fileDiff).toBe(externalDiff); } finally { fixture.cleanup(); @@ -1009,11 +1113,10 @@ describe('completeEditSession', () => { }, }); try { - const { editor, instance } = fixture; + const { editor } = fixture; replaceDocument(editor, 'alpha\nedited value\nomega\n'); - fixture.detach(); const changesBefore = changeEvents.length; - instance.completeEditSession(); + editor.cleanUp('complete'); expect(changeEvents.length).toBe(changesBefore); } finally { fixture.cleanup(); @@ -1025,7 +1128,7 @@ describe('completeEditSession', () => { try { const { editor, instance } = fixture; replaceDocument(editor, 'alpha\nedited value\nomega\n'); - expect(() => instance.completeEditSession()).toThrow( + expect(() => instance.__completeEditSession(editor, 'install')).toThrow( 'detach the editor before completing the session' ); } finally { @@ -1055,7 +1158,7 @@ describe('completeEditSession', () => { return 'accept'; }, }); - const editor = new Editor({}); + const editor = new Editor('file-diff'); try { instance.render({ oldFile, newFile, fileContainer, forceRender: true }); editor.edit(instance); @@ -1063,8 +1166,7 @@ describe('completeEditSession', () => { timeout: 4_000, }); replaceDocument(editor, 'alpha\nedited value\nomega\n'); - editor.cleanUp(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(events).toHaveLength(1); const event = events[0]; @@ -1118,8 +1220,7 @@ describe('completeEditSession', () => { try { const { editor, instance } = fixture; replaceDocument(editor, 'alpha\nedited value\nomega\n'); - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(events).toHaveLength(1); const event = events[0]; @@ -1176,8 +1277,7 @@ describe('completeEditSession', () => { const { editor, instance } = fixture; expect(partial.isPartial).toBe(false); replaceDocument(editor, 'keep 1\nedited value\nkeep 3\nkeep 4\n'); - fixture.detach(); - instance.completeEditSession(); + editor.cleanUp('complete'); expect(events).toHaveLength(1); const event = events[0]; @@ -1193,6 +1293,57 @@ describe('completeEditSession', () => { fixture.cleanup(); } }); + + test('a recycled session keeps final state through later editor cleanup', async () => { + const completionStates: EditorViewState[] = []; + const fixture = await createCompletionFixture({ + onEditComplete(event) { + completionStates.push(event.editor.getViewState()); + return 'reject'; + }, + }); + try { + const { editor, instance } = fixture; + replaceDocument(editor, 'alpha\nedited value\nomega\n'); + editor.setSelections([ + { + start: { line: 1, character: 6 }, + end: { line: 1, character: 6 }, + direction: 'none', + }, + ]); + editor.cleanUp('recycle'); + instance.cleanUp(true); + + expect(completionStates).toHaveLength(0); + expect(editor.getViewState()).toEqual({ + selections: [ + { + start: { line: 1, character: 6 }, + end: { line: 1, character: 6 }, + direction: 0, + }, + ], + view: { scrollLeft: 0 }, + }); + + editor.cleanUp('complete'); + expect(completionStates).toEqual([ + { + selections: [ + { + start: { line: 1, character: 6 }, + end: { line: 1, character: 6 }, + direction: 0, + }, + ], + view: { scrollLeft: 0 }, + }, + ]); + } finally { + fixture.cleanup(); + } + }); }); describe('editor session lifecycle', () => { @@ -1211,7 +1362,7 @@ describe('editor session lifecycle', () => { return 'accept'; }, }); - const editor = new Editor({}); + const editor = new Editor('file-diff'); try { instance.render({ fileDiff: externalDiff, @@ -1246,7 +1397,7 @@ describe('editor session lifecycle', () => { } }); - test('editor.cleanUp() is silent and component cleanUp finishes without installing', async () => { + test('editor.cleanUp() completes without installing', async () => { const dom = installDom(); const fileContainer = document.createElement('div'); document.body.appendChild(fileContainer); @@ -1262,7 +1413,7 @@ describe('editor session lifecycle', () => { return 'accept'; }, }); - const editor = new Editor({}); + const editor = new Editor('file-diff'); try { instance.render({ fileDiff: externalDiff, @@ -1284,10 +1435,9 @@ describe('editor session lifecycle', () => { }, ]); editor.cleanUp(); - expect(events).toHaveLength(0); + expect(events).toHaveLength(1); instance.cleanUp(); - expect(events).toHaveLength(1); expect(events[0].fileDiff.additionLines.join('')).toBe( 'inserted\nalpha\nnew value\nomega\n' ); @@ -1298,53 +1448,3 @@ describe('editor session lifecycle', () => { } }); }); - -describe('persistState at completion', () => { - test('a rejected completion cannot come back through persistState', async () => { - const dom = installDom(); - const fileContainer = document.createElement('div'); - document.body.appendChild(fileContainer); - const externalDiff = createExternalDiff(); - const instance = new TestFileDiff({ - disableErrorHandling: true, - disableFileHeader: true, - onEditComplete: () => 'reject', - }); - const editor = new Editor({ persistState: true }); - try { - instance.render({ - fileDiff: externalDiff, - fileContainer, - forceRender: true, - }); - const finishSession = editor.edit(instance); - const externalContents = externalDiff.additionLines.join(''); - await waitFor(() => editor.getText() === externalContents, { - timeout: 4_000, - }); - editor.applyEdits([ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 0 }, - }, - newText: 'rejected draft\n', - }, - ]); - finishSession(); - expect(instance.fileDiff).toBe(externalDiff); - - editor.edit(instance); - await waitFor(() => editor.getText() === externalContents, { - timeout: 4_000, - }); - expect(instance.getLatestDiffForTest()?.additionLines.join('')).toBe( - externalContents - ); - } finally { - editor.cleanUp(); - instance.cleanUp(); - dom.cleanup(); - } - }); -}); diff --git a/packages/diffs/test/FileDiff.headerSlots.test.ts b/packages/diffs/test/FileDiff.headerSlots.test.ts index 603d6782a..53bfd60e3 100644 --- a/packages/diffs/test/FileDiff.headerSlots.test.ts +++ b/packages/diffs/test/FileDiff.headerSlots.test.ts @@ -20,9 +20,12 @@ function createEditorStub(): DiffsEditor { cleanUp() {}, edit: () => () => {}, __captureFocusForDOMReplacement() {}, + __emitEditComplete() {}, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, __postponeBgTokenizeToNextFrame() {}, __syncRenderView() {}, - }; + } as unknown as DiffsEditor; } function makeTextDocument(lines: string[]): DiffsTextDocument { @@ -84,13 +87,13 @@ describe('FileDiff header slots', () => { collapsed: true, disableErrorHandling: true, }); - let detach: (() => void) | undefined; + let detach: ReturnType['__attachEditor']> | undefined; try { instance.render({ fileDiff: externalDiff, fileContainer }); await waitForHeaderCount(fileContainer, '[data-additions-count]', '+1'); - detach = instance.attachEditor(createEditorStub()); + detach = instance.__attachEditor(createEditorStub()); instance.applyDocumentChange(makeTextDocument(['new\n', 'extra\n'])); await waitForHeaderCount(fileContainer, '[data-additions-count]', '+2'); diff --git a/packages/diffs/test/FileDiff.partialHydration.test.ts b/packages/diffs/test/FileDiff.partialHydration.test.ts index 3109b4e3d..74f90a557 100644 --- a/packages/diffs/test/FileDiff.partialHydration.test.ts +++ b/packages/diffs/test/FileDiff.partialHydration.test.ts @@ -13,10 +13,9 @@ import type { FileDiffLoadedFiles, FileDiffMetadata, HighlightedToken, - SyncRenderViewProps, } from '../src/types'; import type { WorkerPoolManager } from '../src/worker'; -import { installDom, wait, waitFor } from './domHarness'; +import { installDom, wait } from './domHarness'; import { assertDefined, createDeferred } from './testUtils'; afterAll(async () => { @@ -59,15 +58,17 @@ class TestFileDiff extends FileDiff { } } -function createEditorStub(cachedContents?: string): DiffsEditor { +function createEditorStub(): DiffsEditor { return { cleanUp() {}, edit: () => () => {}, __captureFocusForDOMReplacement() {}, - __getCachedDocumentContents: () => cachedContents, + __emitEditComplete() {}, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, __postponeBgTokenizeToNextFrame() {}, __syncRenderView() {}, - }; + } as unknown as DiffsEditor; } function makeDirtyLines( @@ -314,7 +315,7 @@ describe('FileDiff partial hydration', () => { return deferred.promise; }, }); - let detach: (() => void) | undefined; + let detach: ReturnType | undefined; try { instance.render({ @@ -322,7 +323,7 @@ describe('FileDiff partial hydration', () => { fileContainer, forceRender: true, }); - detach = instance.attachEditor(createEditorStub()); + detach = instance.__attachEditor(createEditorStub()); const partialSession = instance.getLatestDiffForTest(); expect(partialSession).toBeDefined(); @@ -372,7 +373,7 @@ describe('FileDiff partial hydration', () => { disableFileHeader: true, loadDiffFiles: () => deferred.promise, }); - let detach: ((recycle?: boolean) => void) | undefined; + let detach: ReturnType | undefined; let loadPromise: Promise | undefined; try { @@ -382,7 +383,7 @@ describe('FileDiff partial hydration', () => { fileContainer, forceRender: true, }); - detach = instance.attachEditor(createEditorStub()); + detach = instance.__attachEditor(createEditorStub()); loadPromise = instance.getPendingFileLoadPromiseForTest(); assertDefined(loadPromise, 'expected partial hydration to be pending'); @@ -417,77 +418,6 @@ describe('FileDiff partial hydration', () => { } }); - test('a hydrated diff starts its private edit model from cached contents', async () => { - const { cleanup } = installDom(); - const { oldFile, newFile, partial } = createPartialChange('persisted.ts'); - partial.cacheKey = 'external:persisted-partial'; - const cachedContents = [ - 'local start\n', - 'keep 1\n', - 'new value\n', - 'keep 3\n', - 'keep 4\n', - ].join(''); - const deferred = createDeferred(); - const fileContainer = document.createElement('div'); - document.body.appendChild(fileContainer); - const instance = new TestFileDiff({ - disableErrorHandling: true, - disableFileHeader: true, - loadDiffFiles: () => deferred.promise, - }); - const syncedViews: SyncRenderViewProps[] = []; - const editor = createEditorStub(cachedContents); - editor.__syncRenderView = (props) => syncedViews.push(props); - let detach: (() => void) | undefined; - - try { - instance.render({ - fileDiff: partial, - fileContainer, - forceRender: true, - }); - detach = instance.attachEditor(editor); - - const loadPromise = instance.getPendingFileLoadPromiseForTest(); - expect(loadPromise).toBeDefined(); - deferred.resolve({ oldFile, newFile }); - await loadPromise; - - const restoredDiff = instance.getLatestDiffForTest(); - expect(restoredDiff).toBeDefined(); - expect(restoredDiff).not.toBe(partial); - expect(restoredDiff?.cacheKey).toBeUndefined(); - expect(restoredDiff?.additionLines.join('')).toBe(cachedContents); - expect(restoredDiff?.additionLines).not.toBe(partial.additionLines); - expect(restoredDiff?.deletionLines).toBe(partial.deletionLines); - expect(restoredDiff?.hunks).not.toBe(partial.hunks); - expect(restoredDiff?.editSessionDirty).toBe(true); - expect(partial.additionLines.join('')).toBe(newFile.contents); - expect(partial.deletionLines.join('')).toBe(oldFile.contents); - expect(partial.editSessionDirty).not.toBe(true); - await waitFor(() => - syncedViews.some( - (view) => 'fileDiff' in view && view.restoredDocument !== undefined - ) - ); - expect( - syncedViews.filter( - (view) => 'fileDiff' in view && view.restoredDocument !== undefined - ) - ).toEqual([ - expect.objectContaining({ - fileDiff: restoredDiff, - restoredDocument: newFile.contents, - }), - ]); - } finally { - detach?.(); - instance.cleanUp(); - cleanup(); - } - }); - test('recycle reuses an in-flight edit-session hydration request', async () => { const { cleanup } = installDom(); const { oldFile, newFile, partial } = createPartialChange( @@ -507,8 +437,7 @@ describe('FileDiff partial hydration', () => { return deferred.promise; }, }); - let firstDetach: ((recycle?: boolean) => void) | undefined; - let secondDetach: (() => void) | undefined; + let detach: ReturnType | undefined; try { instance.render({ @@ -516,9 +445,13 @@ describe('FileDiff partial hydration', () => { fileContainer, forceRender: true, }); - const firstEditor = createEditorStub(); - firstEditor.cleanUp = (reason) => firstDetach?.(reason === 'recycle'); - firstDetach = instance.attachEditor(firstEditor); + const editor = createEditorStub(); + editor.cleanUp = (reason) => { + if (reason !== 'recycle') { + detach?.(); + } + }; + detach = instance.__attachEditor(editor); const loadPromise = instance.getPendingFileLoadPromiseForTest(); expect(loadPromise).toBeDefined(); expect(loadCalls).toBe(1); @@ -526,7 +459,7 @@ describe('FileDiff partial hydration', () => { instance.cleanUp(true); instance.virtualizedSetup(); instance.rerender(); - secondDetach = instance.attachEditor(createEditorStub()); + instance.__resumeEditor(editor); expect(loadCalls).toBe(1); deferred.resolve({ oldFile, newFile }); @@ -549,7 +482,6 @@ describe('FileDiff partial hydration', () => { expect(hydratedSession?.deletionLines.join('')).toBe(oldFile.contents); expect(loadCalls).toBe(1); } finally { - secondDetach?.(); instance.cleanUp(); cleanup(); } @@ -570,7 +502,7 @@ describe('FileDiff partial hydration', () => { return deferred.promise; }, }); - let detach: (() => void) | undefined; + let detach: ReturnType | undefined; try { instance.render({ @@ -578,7 +510,7 @@ describe('FileDiff partial hydration', () => { fileContainer, forceRender: true, }); - detach = instance.attachEditor(createEditorStub()); + detach = instance.__attachEditor(createEditorStub()); const loadPromise = instance.getPendingFileLoadPromiseForTest(); expect(loadPromise).toBeDefined(); deferred.resolve({ oldFile: null, newFile }); diff --git a/packages/diffs/test/FileDiff.rerenderInputs.test.ts b/packages/diffs/test/FileDiff.rerenderInputs.test.ts index f88c15fbc..bbc022547 100644 --- a/packages/diffs/test/FileDiff.rerenderInputs.test.ts +++ b/packages/diffs/test/FileDiff.rerenderInputs.test.ts @@ -42,9 +42,12 @@ function createEditorStub(): DiffsEditor { cleanUp() {}, edit: () => () => {}, __captureFocusForDOMReplacement() {}, + __emitEditComplete() {}, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, __postponeBgTokenizeToNextFrame() {}, __syncRenderView() {}, - }; + } as unknown as DiffsEditor; } function createDiff(name: string, marker: string): FileDiffMetadata { @@ -151,7 +154,7 @@ test('a dirty unkeyed session remains authoritative when the same object is re-p try { instance.render({ fileDiff: externalDiff, fileContainer }); await waitForStableRow(fileContainer); - const detach = instance.attachEditor(createEditorStub()); + const detach = instance.__attachEditor(createEditorStub()); instance.updateRenderCache( new Map([ [0, [[0, '', 'const editedMarker = 3;']]], diff --git a/packages/diffs/test/FileDiff.unifiedEditSeparators.test.ts b/packages/diffs/test/FileDiff.unifiedEditSeparators.test.ts index 2dbf85ce8..2eb625807 100644 --- a/packages/diffs/test/FileDiff.unifiedEditSeparators.test.ts +++ b/packages/diffs/test/FileDiff.unifiedEditSeparators.test.ts @@ -50,9 +50,12 @@ function createEditorStub(): DiffsEditor { cleanUp() {}, edit: () => () => {}, __captureFocusForDOMReplacement() {}, + __emitEditComplete() {}, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, __postponeBgTokenizeToNextFrame() {}, __syncRenderView() {}, - }; + } as unknown as DiffsEditor; } async function waitForRenderedCode(container: HTMLElement): Promise { @@ -76,7 +79,7 @@ describe('FileDiff unified edit separators', () => { test('applyDocumentChange refreshes function hunk separators from the session diff', async () => { const { cleanup } = installDom(); - let detach: (() => void) | undefined; + let detach: ReturnType['__attachEditor']> | undefined; let instance: FileDiff | undefined; try { const fileDiff = createTwoHunkDiff(); @@ -99,7 +102,7 @@ describe('FileDiff unified edit separators', () => { deferManagers: true, }); await waitForRenderedCode(fileContainer); - detach = instance.attachEditor(createEditorStub()); + detach = instance.__attachEditor(createEditorStub()); const initialSeparatorCount = countSeparatorSlots(fileContainer); expect(initialSeparatorCount).toBeGreaterThan(0); diff --git a/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts b/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts index 2aadac00a..8e0f35478 100644 --- a/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts +++ b/packages/diffs/test/VirtualizedFileDiff.partialHydration.test.ts @@ -31,9 +31,12 @@ function createEditorStub(): DiffsEditor { cleanUp() {}, edit: () => () => {}, __captureFocusForDOMReplacement() {}, + __emitEditComplete() {}, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, __postponeBgTokenizeToNextFrame() {}, __syncRenderView() {}, - }; + } as unknown as DiffsEditor; } function createVirtualizer(visible = true): { @@ -464,12 +467,14 @@ describe('VirtualizedFileDiff partial hydration', () => { }, virtualizerState.virtualizer ); - let detach: (() => void) | undefined; + let detach: + | ReturnType + | undefined; try { instance.updateCodeViewLayout(partial, 0); const editor = createEditorStub(); - detach = instance.attachEditor(editor); + detach = instance.__attachEditor(editor); const loadPromise = instance.getPendingFileLoadPromiseForTest(); assertDefined(loadPromise, 'expected edit hydration to be pending'); @@ -480,17 +485,19 @@ describe('VirtualizedFileDiff partial hydration', () => { expect(instance.getLatestDiffForTest()).toBe(partial); instance.cleanUp(true); - detach = undefined; instance.updateCodeViewLayout(partial, 0); - expect(instance.getLatestDiffForTest()).toBe(partial); + const recycledSession = instance.getLatestDiffForTest(); + expect(recycledSession).not.toBe(partial); + expect(recycledSession?.cacheKey).toBeUndefined(); expect(partial.isPartial).toBe(false); instance.virtualizedSetup(); instance.updateCodeViewLayout(partial, 0); - detach = instance.attachEditor(editor); + instance.__resumeEditor(editor); const sessionDiff = instance.getLatestDiffForTest(); + expect(sessionDiff).toBe(recycledSession); expect(instance.fileDiff).toBe(partial); expect(partial.isPartial).toBe(false); expect(partial.cacheKey).toBe('external:advanced-partial:hydrated'); @@ -531,11 +538,13 @@ describe('VirtualizedFileDiff partial hydration', () => { }, virtualizerState.virtualizer ); - let detach: (() => void) | undefined; + let detach: + | ReturnType + | undefined; try { instance.updateCodeViewLayout(initial, 0); - detach = instance.attachEditor(createEditorStub()); + detach = instance.__attachEditor(createEditorStub()); const previousSession = instance.getLatestDiffForTest(); expect(previousSession).not.toBe(initial); @@ -582,11 +591,13 @@ describe('VirtualizedFileDiff partial hydration', () => { }, virtualizerState.virtualizer ); - let detach: (() => void) | undefined; + let detach: + | ReturnType + | undefined; try { instance.updateCodeViewLayout(partial, 0); - detach = instance.attachEditor(createEditorStub()); + detach = instance.__attachEditor(createEditorStub()); const loadPromise = instance.getPendingFileLoadPromiseForTest(); assertDefined(loadPromise, 'expected edit hydration to be pending'); @@ -667,11 +678,13 @@ describe('VirtualizedFileDiff partial hydration', () => { { disableFileHeader: true }, virtualizerState.virtualizer ); - let detach: (() => void) | undefined; + let detach: + | ReturnType + | undefined; try { instance.render({ fileContainer, fileDiff: externalDiff }); - detach = instance.attachEditor(createEditorStub()); + detach = instance.__attachEditor(createEditorStub()); const sessionDiff = instance.getLatestDiffForTest(); instance.updateCodeViewLayout(equivalentDiff, 0); diff --git a/packages/diffs/test/e2e/e2e-globals.d.ts b/packages/diffs/test/e2e/e2e-globals.d.ts index 4137972ae..f6977801c 100644 --- a/packages/diffs/test/e2e/e2e-globals.d.ts +++ b/packages/diffs/test/e2e/e2e-globals.d.ts @@ -23,7 +23,7 @@ interface E2ESelection { direction?: 'none' | 'backward' | 'forward'; } -interface E2EEditorState { +interface E2EEditorViewState { selections?: E2ESelection[]; view?: { scrollLeft: number; @@ -44,7 +44,7 @@ interface E2EEditor { canRedo: boolean; getText: () => string; getFile: () => { contents: string } | undefined; - getState: () => E2EEditorState; + getViewState: () => E2EEditorViewState; setSelections: (selections: E2ESelection[]) => void; applyEdits: (edits: E2ETextEdit[], updateHistory?: boolean) => void; focus: () => void; @@ -77,6 +77,7 @@ interface Window { // Editor handle exposed by the editable fixtures. __editor?: E2EEditor; + __completeEdit?: () => void; __forceEditorFullRender?: () => void; __moveEditorContainer?: () => void; __syncCount?: number; diff --git a/packages/diffs/test/e2e/edit-collapsed.pw.ts b/packages/diffs/test/e2e/edit-collapsed.pw.ts index 882d8a3a2..c53d58f47 100644 --- a/packages/diffs/test/e2e/edit-collapsed.pw.ts +++ b/packages/diffs/test/e2e/edit-collapsed.pw.ts @@ -71,7 +71,7 @@ for (const diffStyle of ['split', 'unified'] as const) { expect(lines).not.toContain(30); // Genuine session end: the reverted region collapses away. - await page.evaluate(() => window.__editor?.cleanUp()); + await page.evaluate(() => window.__completeEdit?.()); await expect .poll(async () => (await renderedLines(page)).includes(10)) .toBe(false); diff --git a/packages/diffs/test/e2e/edit.pw.ts b/packages/diffs/test/e2e/edit.pw.ts index 91328f238..51682704a 100644 --- a/packages/diffs/test/e2e/edit.pw.ts +++ b/packages/diffs/test/e2e/edit.pw.ts @@ -429,7 +429,9 @@ test.describe('edit mode', () => { .toContain('removed'); await expect .poll(() => - page.evaluate(() => window.__editor?.getState().selections?.length ?? 0) + page.evaluate( + () => window.__editor?.getViewState().selections?.length ?? 0 + ) ) .toBe(0); await expect(page.locator('pre[data-deleted-text-selection]')).toHaveCount( diff --git a/packages/diffs/test/e2e/fixtures/edit-collapsed.html b/packages/diffs/test/e2e/fixtures/edit-collapsed.html index 3a8ead6f9..58d9654e6 100644 --- a/packages/diffs/test/e2e/fixtures/edit-collapsed.html +++ b/packages/diffs/test/e2e/fixtures/edit-collapsed.html @@ -62,13 +62,16 @@ const events = []; window.__editorEvents = events; - const editor = new Editor({}); + const editor = new Editor('file-diff'); window.__editor = editor; const instance = new FileDiff({ onEditChange({ file }) { events.push(file.contents); }, + onEditComplete() { + return 'accept'; + }, disableFileHeader: true, theme: { dark: 'pierre-dark', light: 'pierre-light' }, themeType: 'dark', @@ -81,7 +84,7 @@ newFile: { name: 'edit.ts', contents: newContents }, containerWrapper: mount, }); - editor.edit(instance); + window.__completeEdit = editor.edit(instance); const findEditableContent = () => { const host = mount.querySelector('diffs-container'); @@ -117,7 +120,7 @@ }; window.__caretLine = () => { - const selection = editor.getState().selections?.at(-1); + const selection = editor.getViewState().selections?.at(-1); return selection == null ? undefined : selection.end.line; }; diff --git a/packages/diffs/test/e2e/fixtures/edit.html b/packages/diffs/test/e2e/fixtures/edit.html index 577f23b89..f92c81642 100644 --- a/packages/diffs/test/e2e/fixtures/edit.html +++ b/packages/diffs/test/e2e/fixtures/edit.html @@ -99,7 +99,7 @@ const events = []; window.__editorEvents = events; - const editor = new Editor({}); + const editor = new Editor('file-diff'); window.__editor = editor; const instance = new FileDiff({ diff --git a/packages/diffs/test/e2e/fixtures/editable.html b/packages/diffs/test/e2e/fixtures/editable.html index 207dac228..8f08afb14 100644 --- a/packages/diffs/test/e2e/fixtures/editable.html +++ b/packages/diffs/test/e2e/fixtures/editable.html @@ -54,7 +54,7 @@ const events = []; window.__editorEvents = events; - const editor = new Editor({}); + const editor = new Editor('file'); window.__editor = editor; // Editable single-file surface: `useTokenTransformer` mirrors what the diff --git a/packages/diffs/test/e2e/fixtures/markers.html b/packages/diffs/test/e2e/fixtures/markers.html index e3e938161..aefdf32f1 100644 --- a/packages/diffs/test/e2e/fixtures/markers.html +++ b/packages/diffs/test/e2e/fixtures/markers.html @@ -52,7 +52,7 @@ `, }; - const editor = new Editor({}); + const editor = new Editor('file'); window.__editor = editor; const instance = new File({ diff --git a/packages/diffs/test/e2e/fixtures/persist-state-indexeddb.html b/packages/diffs/test/e2e/fixtures/persist-state-indexeddb.html deleted file mode 100644 index 6b4ff8162..000000000 --- a/packages/diffs/test/e2e/fixtures/persist-state-indexeddb.html +++ /dev/null @@ -1,136 +0,0 @@ - - - - - - diffs persisted editor state fixture - - -
-
- - - - diff --git a/packages/diffs/test/e2e/fixtures/selection-action-edges.html b/packages/diffs/test/e2e/fixtures/selection-action-edges.html index 0a937c55d..e064d6ecf 100644 --- a/packages/diffs/test/e2e/fixtures/selection-action-edges.html +++ b/packages/diffs/test/e2e/fixtures/selection-action-edges.html @@ -64,7 +64,7 @@ contents: lines.join('\n'), }; - const editor = new Editor({ + const editor = new Editor('file', { enabledSelectionAction: true, renderSelectionAction({ getSelectionText, close }) { const container = document.createElement('div'); diff --git a/packages/diffs/test/e2e/fixtures/selection-action.html b/packages/diffs/test/e2e/fixtures/selection-action.html index 90aa01704..08c075116 100644 --- a/packages/diffs/test/e2e/fixtures/selection-action.html +++ b/packages/diffs/test/e2e/fixtures/selection-action.html @@ -57,7 +57,7 @@ const actionClicks = []; window.__actionClicks = actionClicks; - const editor = new Editor({ + const editor = new Editor('file', { enabledSelectionAction: true, renderSelectionAction({ getSelectionText, close }) { const container = document.createElement('div'); diff --git a/packages/diffs/test/e2e/fixtures/theme.html b/packages/diffs/test/e2e/fixtures/theme.html index 9daf59f40..ec0922fec 100644 --- a/packages/diffs/test/e2e/fixtures/theme.html +++ b/packages/diffs/test/e2e/fixtures/theme.html @@ -50,7 +50,7 @@ `, }; - const editor = new Editor({}); + const editor = new Editor('file'); window.__editor = editor; const searchParams = new URLSearchParams(window.location.search); diff --git a/packages/diffs/test/e2e/multi-edit.pw.ts b/packages/diffs/test/e2e/multi-edit.pw.ts index 983bc52ea..f944cec36 100644 --- a/packages/diffs/test/e2e/multi-edit.pw.ts +++ b/packages/diffs/test/e2e/multi-edit.pw.ts @@ -13,7 +13,7 @@ const contents = (page: Page): Promise => const selectionTuples = (page: Page): Promise => page.evaluate(() => window.__editor - ?.getState() + ?.getViewState() .selections?.map((selection) => [ selection.start.line, selection.start.character, @@ -82,7 +82,7 @@ test.describe('multi-cursor and indentation', () => { return; } const selections = window.__editor - ?.getState() + ?.getViewState() .selections?.map((selection) => [ selection.start.line, selection.start.character, diff --git a/packages/diffs/test/e2e/persist-state-indexeddb.pw.ts b/packages/diffs/test/e2e/persist-state-indexeddb.pw.ts deleted file mode 100644 index 0186e25b2..000000000 --- a/packages/diffs/test/e2e/persist-state-indexeddb.pw.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test('indexedDB storage restores state in a fresh editor', async ({ page }) => { - await page.goto('/test/e2e/fixtures/persist-state-indexeddb.html'); - - const result = page.locator('[data-persist-state-result]'); - await expect(result).toHaveAttribute('data-status', 'ready'); - await expect(result).toHaveAttribute('data-stored-character', '3'); - await expect(result).toHaveAttribute('data-restored-character', '3'); -}); diff --git a/packages/diffs/test/e2e/theme.pw.ts b/packages/diffs/test/e2e/theme.pw.ts index 1c900c5fd..bea31cafc 100644 --- a/packages/diffs/test/e2e/theme.pw.ts +++ b/packages/diffs/test/e2e/theme.pw.ts @@ -16,7 +16,7 @@ async function openFixture( } const selections = (page: Page): Promise => - page.evaluate(() => window.__editor?.getState().selections); + page.evaluate(() => window.__editor?.getViewState().selections); const squaredRgbDistance = (left: number[], right: number[]): number => left diff --git a/packages/diffs/test/editStateManager.test.ts b/packages/diffs/test/editStateManager.test.ts new file mode 100644 index 000000000..0d312b969 --- /dev/null +++ b/packages/diffs/test/editStateManager.test.ts @@ -0,0 +1,449 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; + +import { EditStack } from '../src/editor/editStack'; +import { + cloneEditorViewState, + EditStateManager, + type ManagedFileEditSession, + type ManagedFileEditState, +} from '../src/editor/EditStateManager'; +import { TextDocument } from '../src/editor/textDocument'; +import type { EditState } from '../src/editor/types'; +import type { + DiffLineAnnotation, + DiffsEditor, + EditorViewState, +} from '../src/types'; + +function createOwner( + getState: () => ManagedFileEditState = createState +): DiffsEditor { + return { + getEditState: getState, + } as DiffsEditor; +} + +function createState(): ManagedFileEditState { + const document = new TextDocument( + 'file:///example.ts', + 'const value = 1;', + 'typescript' + ); + document.applyResolvedEdits([{ start: 14, end: 15, text: '2' }]); + return { + documentKind: 'file', + document, + fileInfo: { name: 'example.ts', lang: 'typescript' }, + editor: { + selections: [ + { + start: { line: 0, character: 6 }, + end: { line: 0, character: 11 }, + direction: 1, + }, + ], + view: { scrollLeft: 24, scrollTop: 80 }, + }, + }; +} + +function retainFileState(key: string, state = createState()): void { + const owner = createOwner(() => state); + EditStateManager.activate('file', key, owner, state); + EditStateManager.releaseFile(key, owner); +} + +function inspectFileState(key: string): ManagedFileEditState | undefined { + const state = EditStateManager.get('file', key); + return state as ManagedFileEditState | undefined; +} + +beforeEach(() => { + EditStateManager.clearAll(); + EditStateManager.setCapacity(100); +}); + +afterEach(() => { + EditStateManager.clearAll(); + EditStateManager.setCapacity(100); +}); + +describe('EditStateManager', () => { + test('adopts caller-owned state without cloning its data', () => { + const document = new TextDocument( + 'file:///example.ts', + 'const value = 1;', + 'typescript', + 3, + new EditStack({ maxEntries: 25 }) + ); + const state: EditState = { + documentKind: 'file', + document, + fileInfo: { name: 'example.ts', lang: 'typescript' }, + editor: { + view: { scrollLeft: 24 }, + }, + }; + const session = state; + + expect(session).toBe(state); + expect(session.document).toBe(document); + expect(session.fileInfo).toBe(state.fileInfo); + expect(session.editor).toBe(state.editor); + }); + + test('keeps one session object from empty activation through resume', () => { + const key = 'empty-session'; + const firstOwner = createOwner(); + const session = EditStateManager.activate( + 'file', + key, + firstOwner + ) as ManagedFileEditSession; + const state = createState(); + session.document = state.document; + session.fileInfo = state.fileInfo; + session.editor = state.editor; + + EditStateManager.releaseFile(key, firstOwner); + + const nextOwner = createOwner(); + expect(EditStateManager.activate('file', key, nextOwner)).toBe(session); + EditStateManager.releaseFile(key, nextOwner); + }); + + test('reads active state from the managed session without querying its owner', () => { + const state = createState(); + const owner = createOwner(() => { + throw new Error('owner state should not be queried'); + }); + EditStateManager.activate('file', 'active-session', owner, state); + + expect(EditStateManager.get('file', 'active-session')).toBe(state); + EditStateManager.releaseFile('active-session', owner); + }); + + test('keeps complete dormant state in independent surface namespaces', () => { + const key = 'shared-key'; + const fileOwner = createOwner(); + const diffOwner = createOwner(); + const fileState = createState(); + expect(EditStateManager.activate('file', key, fileOwner, fileState)).toBe( + fileState + ); + EditStateManager.releaseFile(key, fileOwner); + + expect(EditStateManager.activate('file-diff', key, diffOwner)).toEqual({ + documentKind: 'file-diff', + }); + EditStateManager.releaseFileDiff(key, diffOwner); + + const nextOwner = createOwner(); + const resumed = EditStateManager.activate('file', key, nextOwner); + expect(resumed).toBe(fileState); + expect(resumed.document!.getText()).toBe('const value = 2;'); + expect(resumed.document!.canUndo).toBe(true); + expect(resumed?.editor).toEqual({ + selections: [ + { + start: { line: 0, character: 6 }, + end: { line: 0, character: 11 }, + direction: 1, + }, + ], + view: { scrollLeft: 24, scrollTop: 80 }, + }); + EditStateManager.releaseFile(key, nextOwner); + }); + + test('preserves ownership while moving the same state between active and dormant', () => { + const key = 'managed-state'; + const firstOwner = createOwner(); + const secondOwner = createOwner(); + const state = createState(); + expect(EditStateManager.activate('file', key, firstOwner, state)).toBe( + state + ); + expect(EditStateManager.activate('file', key, firstOwner)).toBe(state); + expect(() => EditStateManager.activate('file', key, secondOwner)).toThrow( + `Editor: editStateKey "${key}" is already attached to another editor` + ); + EditStateManager.releaseFile(key, secondOwner); + expect(() => EditStateManager.activate('file', key, secondOwner)).toThrow(); + + EditStateManager.releaseFile(key, firstOwner); + + expect(EditStateManager.activate('file', key, secondOwner)).toBe(state); + EditStateManager.releaseFile(key, secondOwner); + + const finalOwner = createOwner(); + const restored = EditStateManager.activate('file', key, finalOwner); + expect(restored).toBe(state); + expect(restored.document!.getText()).toBe('const value = 2;'); + EditStateManager.releaseFile(key, finalOwner); + }); + + test('evicts complete dormant entries without counting active sessions', () => { + EditStateManager.setCapacity(1); + const firstOwner = createOwner(); + const firstState = createState(); + EditStateManager.activate('file', 'first', firstOwner, firstState); + EditStateManager.releaseFile('first', firstOwner); + + const activeOwner = createOwner(); + expect(EditStateManager.activate('file', 'first', activeOwner)).toBe( + firstState + ); + + const secondOwner = createOwner(); + const secondState = createState(); + EditStateManager.activate('file', 'second', secondOwner, secondState); + EditStateManager.releaseFile('second', secondOwner); + EditStateManager.releaseFile('first', activeOwner); + + const checkSecondOwner = createOwner(); + expect( + EditStateManager.activate('file', 'second', checkSecondOwner) + ).toEqual({ documentKind: 'file' }); + EditStateManager.releaseFile('second', checkSecondOwner); + + const checkFirstOwner = createOwner(); + expect(EditStateManager.activate('file', 'first', checkFirstOwner)).toBe( + firstState + ); + EditStateManager.releaseFile('first', checkFirstOwner); + }); + + test('restores a reactivated state as most recently used', () => { + EditStateManager.setCapacity(2); + for (const key of ['first', 'second']) { + const owner = createOwner(); + EditStateManager.activate('file', key, owner, createState()); + EditStateManager.releaseFile(key, owner); + } + + const reactivatedOwner = createOwner(); + const firstState = EditStateManager.activate( + 'file', + 'first', + reactivatedOwner + ); + EditStateManager.releaseFile('first', reactivatedOwner); + + const thirdOwner = createOwner(); + EditStateManager.activate('file', 'third', thirdOwner, createState()); + EditStateManager.releaseFile('third', thirdOwner); + + const secondOwner = createOwner(); + expect(EditStateManager.activate('file', 'second', secondOwner)).toEqual({ + documentKind: 'file', + }); + EditStateManager.releaseFile('second', secondOwner); + + const firstOwner = createOwner(); + expect(EditStateManager.activate('file', 'first', firstOwner)).toBe( + firstState + ); + EditStateManager.releaseFile('first', firstOwner); + }); + + test('exposes application annotation records through live state', () => { + const metadata = { id: 'annotation' }; + const annotation: DiffLineAnnotation = { + side: 'additions', + lineNumber: 1, + metadata, + }; + const publicSource = createState(); + publicSource.document.setLastUndoLineAnnotations( + [annotation], + [annotation] + ); + const publicState = publicSource; + const publicAnnotation = + publicState.document.history.undoStack[0].lineAnnotationsBefore![0]; + expect(publicAnnotation).toBe(annotation); + expect(publicAnnotation.metadata).toBe(metadata); + }); + + test('does not clear an active session', () => { + const key = 'active-state'; + const state = createState(); + retainFileState(key, state); + + const secondOwner = createOwner(); + expect(EditStateManager.activate('file', key, secondOwner)).toBe(state); + expect(EditStateManager.clear('file', key)).toBe(false); + expect(EditStateManager.get('file', key)).toBe(state); + EditStateManager.releaseFile(key, secondOwner); + + const thirdOwner = createOwner(); + expect(EditStateManager.activate('file', key, thirdOwner)).toBe(state); + EditStateManager.releaseFile(key, thirdOwner); + }); + + test('gets live state without changing LRU recency', () => { + EditStateManager.setCapacity(2); + const firstState = createState(); + retainFileState('first', firstState); + retainFileState('second'); + + const inspected = EditStateManager.get('file', 'first')!; + expect(inspected.document).toBe(firstState.document); + expect(inspected.fileInfo).toBe(firstState.fileInfo); + expect(inspected.document.getText()).toBe('const value = 2;'); + expect(inspected.document.languageId).toBe('typescript'); + expect(inspected.document.version).toBe(1); + expect(inspected.document.eol).toBe('\n'); + expect(inspected.document.history.undoStack).toBe( + firstState.document.history.undoStack + ); + expect(inspected.editor).toBe(firstState.editor); + inspected.document.history.undoStack.length = 0; + Object.assign(inspected.editor.selections![0].start, { character: 0 }); + inspected.editor.view!.scrollLeft = 0; + + expect(firstState.document.getText()).toBe('const value = 2;'); + expect(firstState.document.canUndo).toBe(false); + expect(firstState.editor?.selections?.[0].start.character).toBe(0); + expect(firstState.editor?.view?.scrollLeft).toBe(0); + + retainFileState('third'); + expect(EditStateManager.get('file', 'first')).toBeUndefined(); + expect(EditStateManager.get('file', 'second')).toBeDefined(); + }); + + test('clears dormant state parts independently', () => { + const state = createState(); + retainFileState('partial', state); + + expect( + EditStateManager.clear('file', 'partial', { + history: true, + }) + ).toBe(true); + let retained = inspectFileState('partial')!; + expect(retained.document).toBe(state.document); + expect(retained.document.getText()).toBe('const value = 2;'); + expect(retained.document.canUndo).toBe(false); + expect(retained.editor?.selections).toHaveLength(1); + expect(retained.editor?.view).toEqual({ scrollLeft: 24, scrollTop: 80 }); + + expect( + EditStateManager.clear('file', 'partial', { selections: true }) + ).toBe(true); + retained = inspectFileState('partial')!; + expect(retained.editor).toEqual({ + selections: undefined, + view: { scrollLeft: 24, scrollTop: 80 }, + }); + + expect(EditStateManager.clear('file', 'partial', { view: true })).toBe( + true + ); + expect(inspectFileState('partial')?.editor).toEqual({ + selections: undefined, + view: undefined, + }); + expect(EditStateManager.clear('file', 'partial', {})).toBe(true); + expect(EditStateManager.clear('file', 'partial')).toBe(true); + expect(EditStateManager.get('file', 'partial')).toBeUndefined(); + expect(EditStateManager.clear('file', 'partial')).toBe(false); + + const editorState = createState(); + retainFileState('editor', editorState); + expect(EditStateManager.clear('file', 'editor', { editor: true })).toBe( + true + ); + expect(inspectFileState('editor')?.document.canUndo).toBe(true); + expect(inspectFileState('editor')?.editor).toEqual({}); + + retainFileState('document'); + expect( + EditStateManager.clear('file', 'document', { + document: true, + history: false, + }) + ).toBe(true); + expect(EditStateManager.get('file', 'document')).toBeUndefined(); + }); + + test('does not clear parts of an active session', () => { + const state = createState(); + const owner = createOwner(() => state); + state.document.applyResolvedEdits([{ start: 14, end: 15, text: '3' }]); + state.document.undo(); + expect(state.document.canUndo).toBe(true); + expect(state.document.canRedo).toBe(true); + EditStateManager.activate('file', 'active', owner, state); + const document = state.document; + const text = document.getText(); + const version = document.version; + + expect( + EditStateManager.clear('file', 'active', { + history: true, + selections: true, + }) + ).toBe(false); + expect(state.document).toBe(document); + expect(document.getText()).toBe(text); + expect(document.version).toBe(version); + expect(document.canUndo).toBe(true); + expect(document.canRedo).toBe(true); + expect(state.editor?.selections).toHaveLength(1); + expect(state.editor?.view).toEqual({ + scrollLeft: 24, + scrollTop: 80, + }); + + EditStateManager.releaseFile('active', owner); + const dormantSnapshot = inspectFileState('active')!; + expect(dormantSnapshot.document.canUndo).toBe(true); + expect(dormantSnapshot.document.canRedo).toBe(true); + expect(dormantSnapshot.editor?.selections).toHaveLength(1); + }); + + test('clearAll leaves active sessions untouched', () => { + const state = createState(); + const owner = createOwner(() => state); + const session = EditStateManager.activate( + 'file', + 'active-document', + owner, + state + ); + const document = state.document; + + EditStateManager.clearAll(); + expect(EditStateManager.get('file', 'active-document')).toBe(state); + expect(session.document).toBe(document); + expect(session.fileInfo).toBe(state.fileInfo); + expect(session.editor).toBe(state.editor); + expect(document.getText()).toBe('const value = 2;'); + + EditStateManager.releaseFile('active-document', owner); + expect(EditStateManager.get('file', 'active-document')).toBe(state); + }); +}); + +describe('cloneEditorViewState', () => { + test('deeply clones selections and view state', () => { + const state: EditorViewState = { + selections: [ + { + start: { line: 1, character: 2 }, + end: { line: 3, character: 4 }, + direction: -1, + }, + ], + view: { scrollLeft: 5, scrollTop: 6 }, + }; + const clone = cloneEditorViewState(state); + Object.assign(clone.selections![0].start, { line: 10 }); + clone.view!.scrollTop = 20; + expect(state.selections?.[0].start.line).toBe(1); + expect(state.view?.scrollTop).toBe(6); + }); +}); diff --git a/packages/diffs/test/editorActiveLineHighlight.test.ts b/packages/diffs/test/editorActiveLineHighlight.test.ts index 71f51d4eb..18624e088 100644 --- a/packages/diffs/test/editorActiveLineHighlight.test.ts +++ b/packages/diffs/test/editorActiveLineHighlight.test.ts @@ -48,7 +48,7 @@ async function createEditorFixture( theme: DEFAULT_THEMES, ...fileOptions, }); - const editor = new Editor(); + const editor = new Editor('file'); const initialFile: FileContents = { name: 'highlight.ts', contents }; file.render({ file: initialFile, fileContainer, forceRender: true }); diff --git a/packages/diffs/test/editorApplyEdits.test.ts b/packages/diffs/test/editorApplyEdits.test.ts index 496a915e5..e8ad197bf 100644 --- a/packages/diffs/test/editorApplyEdits.test.ts +++ b/packages/diffs/test/editorApplyEdits.test.ts @@ -2,7 +2,7 @@ import { afterAll, describe, expect, mock, spyOn, test } from 'bun:test'; import { File, type FileOptions } from '../src/components/File'; import { DEFAULT_THEMES } from '../src/constants'; -import { Editor, type EditorOptions, type IStateStorage } from '../src/edit'; +import { Editor, type EditorOptions } from '../src/edit'; import { applyTextChangeToSelections, DirectionForward, @@ -80,11 +80,10 @@ async function createEditorFixture( theme: DEFAULT_THEMES, ...fileOptions, }); - const editor = new Editor(editorOptions); + const editor = new Editor('file', editorOptions); const initialFile: FileContents = { name: 'edits.ts', contents, - ...(editorOptions?.persistState === true ? { cacheKey: 'edits-file' } : {}), ...fileContents, }; @@ -108,6 +107,45 @@ async function createEditorFixture( }; } +describe('Editor file state', () => { + test('revisiting a file rebuilds its document from external contents', async () => { + const fixture = await createEditorFixture('alpha\nbravo\n'); + try { + fixture.editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'X', + }, + ]); + fixture.file.render({ + file: { name: 'other.ts', contents: 'one\n', cacheKey: 'other' }, + fileContainer: fixture.fileContainer, + forceRender: true, + }); + await waitFor(() => fixture.editor.getFile()?.name === 'other.ts'); + + fixture.file.render({ + file: { + name: 'edits.ts', + contents: 'alpha\nbravo\n', + cacheKey: 'edits-file', + }, + fileContainer: fixture.fileContainer, + forceRender: true, + }); + await waitFor(() => fixture.editor.getFile()?.name === 'edits.ts'); + + expect(fixture.editor.getText()).toBe('alpha\nbravo\n'); + expect(fixture.editor.canUndo).toBe(false); + } finally { + fixture.cleanup(); + } + }); +}); + // Drives the editor's undo/redo keyboard shortcut. The harness navigator // reports macOS, so the primary modifier is the meta key; `shift` selects redo. function pressUndoRedo( @@ -158,138 +196,6 @@ function pressKey( return event; } -function insertAtStart(editor: Editor, text: string): void { - editor.applyEdits( - [ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 0 }, - }, - newText: text, - }, - ], - true - ); -} - -async function renderFileAndWait( - fixture: EditorFixture, - fileContents: FileContents -): Promise { - fixture.file.render({ - file: fileContents, - fileContainer: fixture.fileContainer, - forceRender: true, - }); - await waitFor(() => { - const file = fixture.editor.getFile(); - return ( - file?.name === fileContents.name && - file.cacheKey === fileContents.cacheKey - ); - }); -} - -describe('Editor persisted file state', () => { - test('rejects enabling persistence for an attached unkeyed document', async () => { - const fixture = await createEditorFixture('alpha\n'); - try { - expect(() => fixture.editor.setOptions({ persistState: true })).toThrow( - 'Editor persistState requires a non-empty file.cacheKey for "edits.ts".' - ); - } finally { - fixture.cleanup(); - } - }); - - test('is disabled by default', async () => { - const storageCalls: string[] = []; - const storage: IStateStorage = { - get(cacheKey) { - storageCalls.push(`get:${cacheKey}`); - return undefined; - }, - set(cacheKey) { - storageCalls.push(`set:${cacheKey}`); - }, - }; - const fixture = await createEditorFixture('alpha\nbravo\n', { - persistStateStorage: storage, - }); - - try { - insertAtStart(fixture.editor, 'X'); - await renderFileAndWait(fixture, { - name: 'other.ts', - contents: 'one\n', - cacheKey: 'other', - }); - await renderFileAndWait(fixture, { - name: 'edits.ts', - contents: 'alpha\nbravo\n', - cacheKey: 'edits-file', - }); - - expect(fixture.editor.getText()).toBe('alpha\nbravo\n'); - expect(fixture.editor.canUndo).toBe(false); - expect(storageCalls).toEqual([]); - } finally { - fixture.cleanup(); - } - }); - - test('uses a custom state storage with the explicit file key', async () => { - const states = new Map['getState']>>(); - const calls: string[] = []; - const storage: IStateStorage = { - get(cacheKey) { - calls.push(`get:${cacheKey}`); - return states.get(cacheKey); - }, - set(cacheKey, state) { - calls.push(`set:${cacheKey}`); - states.set(cacheKey, state); - }, - }; - const fixture = await createEditorFixture('alpha\nbravo\n', { - persistState: true, - persistStateStorage: storage, - }); - - try { - fixture.editor.setSelections([ - { - start: { line: 0, character: 2 }, - end: { line: 0, character: 2 }, - direction: 'none', - }, - ]); - await renderFileAndWait(fixture, { - name: 'other.ts', - contents: 'one\n', - cacheKey: 'other-revision', - }); - await renderFileAndWait(fixture, { - name: 'edits.ts', - contents: 'alpha\nbravo\n', - cacheKey: 'edits-file', - }); - - expect(calls).toContain('set:edits-file'); - expect(calls).toContain('set:other-revision'); - expect(calls.at(-1)).toBe('get:edits-file'); - expect(fixture.editor.getState().selections?.[0]).toMatchObject({ - start: { line: 0, character: 2 }, - end: { line: 0, character: 2 }, - direction: 0, - }); - } finally { - fixture.cleanup(); - } - }); -}); - describe('Editor.applyEdits selection sync', () => { test('reports normalized changes against the pre-edit document', async () => { const onChange = mock( @@ -417,7 +323,7 @@ describe('Editor.applyEdits selection sync', () => { expect(editor.getText()).toBe('NEW\nalpha\nbravo\ncharlie'); // The caret was inside "charlie"; inserting a line above must move it down // one line so it still points at the same character of "charlie". - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 3, character: 3 }, end: { line: 3, character: 3 }, @@ -454,7 +360,7 @@ describe('Editor.applyEdits selection sync', () => { expect(editor.getText()).toBe('alXYZpha\nbravo'); // The caret must follow the inserted text so the next keystroke lands // after it, not in front of it. - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 5 }, end: { line: 0, character: 5 }, @@ -489,7 +395,7 @@ describe('Editor.applyEdits selection sync', () => { ]); expect(editor.getText()).toBe('a📚 plans'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 1 }, end: { line: 0, character: 1 }, @@ -525,7 +431,7 @@ describe('Editor.applyEdits selection sync', () => { }, ]); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 3, character: 1 }, end: { line: 3, character: 4 }, @@ -562,7 +468,7 @@ describe('Editor.applyEdits selection sync', () => { ]); expect(editor.getText()).toBe('alpha\nbravo\nNEW\ncharlie'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 2 }, end: { line: 0, character: 2 }, @@ -600,7 +506,7 @@ describe('Editor.applyEdits selection sync', () => { pressUndoRedo(window, content, false); expect(editor.getText()).toBe('alpha\nbravo\ncharlie'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 2, character: 3 }, end: { line: 2, character: 3 }, @@ -612,7 +518,7 @@ describe('Editor.applyEdits selection sync', () => { expect(editor.getText()).toBe('NEW\nalpha\nbravo\ncharlie'); // Redo must restore the caret to the post-edit (remapped) position, not // leave it where undo placed it. - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 3, character: 3 }, end: { line: 3, character: 3 }, @@ -652,7 +558,7 @@ describe('Editor.applyEdits selection sync', () => { editor.undo(); expect(editor.getText()).toBe('alpha\nbravo\ncharlie'); - expect(editor.getState().selections).toEqual([caret(2, 3)]); + expect(editor.getViewState().selections).toEqual([caret(2, 3)]); editor.setSelections([ { @@ -663,7 +569,7 @@ describe('Editor.applyEdits selection sync', () => { ]); editor.redo(); expect(editor.getText()).toBe('NEW\nalpha\nbravo\ncharlie'); - expect(editor.getState().selections).toEqual([caret(1, 2)]); + expect(editor.getViewState().selections).toEqual([caret(1, 2)]); } finally { cleanup(); } @@ -702,7 +608,7 @@ describe('Editor.applyEdits selection sync', () => { ]); // Selection state is still remapped so it stays correct... - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 3, character: 3 }, end: { line: 3, character: 3 }, @@ -744,7 +650,7 @@ describe('Editor.applyEdits selection sync', () => { }, ]); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 3, character: 3 }, end: { line: 3, character: 3 }, @@ -789,7 +695,7 @@ describe('Editor.applyEdits selection sync', () => { }, ]); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 3, character: 3 }, end: { line: 3, character: 3 }, @@ -855,7 +761,7 @@ describe('Editor.applyEdits selection sync', () => { content.dispatchEvent(new Event('focus')); content.dispatchEvent(new Event('blur')); document.dispatchEvent(new Event('selectionchange')); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 2, character: 3 }, end: { line: 2, character: 3 }, @@ -868,7 +774,7 @@ describe('Editor.applyEdits selection sync', () => { // the unfocused case above. content.dispatchEvent(new Event('focus')); document.dispatchEvent(new Event('selectionchange')); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 0 }, end: { line: 0, character: 0 }, @@ -977,7 +883,7 @@ describe('Editor move line commands', () => { pressMoveLine(window, content, 'up'); expect(editor.getText()).toBe('bravo\nalpha\ncharlie'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 2 }, end: { line: 0, character: 2 }, @@ -987,7 +893,7 @@ describe('Editor move line commands', () => { pressMoveLine(window, content, 'down'); expect(editor.getText()).toBe('alpha\nbravo\ncharlie'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 1, character: 2 }, end: { line: 1, character: 2 }, @@ -1015,7 +921,7 @@ describe('Editor move line commands', () => { pressMoveLine(window, content, 'up'); expect(editor.getText()).toBe('alpha\ncharlie\nbravo'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 1, character: 3 }, end: { line: 1, character: 3 }, @@ -1043,7 +949,7 @@ describe('Editor move line commands', () => { pressMoveLine(window, content, 'down'); expect(editor.getText()).toBe('zero\nfour\none\ntwo\nthree'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 2, character: 1 }, end: { line: 4, character: 2 }, @@ -1071,7 +977,7 @@ describe('Editor move line commands', () => { pressMoveLine(window, content, 'down'); expect(editor.getText()).toBe('alpha\ncharlie\nbravo'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 2, character: 0 }, end: { line: 2, character: 5 }, @@ -1103,7 +1009,7 @@ describe('Editor move line commands', () => { pressMoveLine(window, content, 'up'); expect(editor.getText()).toBe('b\na\nc\ne\nd\nf'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 0 }, end: { line: 0, character: 1 }, @@ -1142,7 +1048,7 @@ describe('Editor editing commands', () => { newText: 'ALPHA', }, ]); - editor.setState({ selections: [caret(1, 3)] }); + editor.setViewState({ selections: [caret(1, 3)] }); content.dispatchEvent( new window.InputEvent('beforeinput', { @@ -1160,7 +1066,7 @@ describe('Editor editing commands', () => { (row) => row.textContent ) ).toEqual(['ALPHA', 'bra', 'vo', 'charlie']); - expect(editor.getState().selections).toEqual([caret(2, 0)]); + expect(editor.getViewState().selections).toEqual([caret(2, 0)]); } finally { cleanup(); } @@ -1187,7 +1093,7 @@ describe('Editor editing commands', () => { expect(keydown.defaultPrevented).toBe(true); expect(editor.getText()).toBe('hello\nnext'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 5 }, end: { line: 0, character: 5 }, @@ -1219,7 +1125,7 @@ describe('Editor editing commands', () => { shiftKey: true, }); expect(editor.getText()).toBe('alpha\nbravo\nbravo\ncharlie'); - expect(editor.getState().selections?.[0].start).toEqual({ + expect(editor.getViewState().selections?.[0].start).toEqual({ line: 1, character: 2, }); @@ -1230,7 +1136,7 @@ describe('Editor editing commands', () => { shiftKey: true, }); expect(editor.getText()).toBe('alpha\nbravo\nbravo\nbravo\ncharlie'); - expect(editor.getState().selections?.[0].start).toEqual({ + expect(editor.getViewState().selections?.[0].start).toEqual({ line: 2, character: 2, }); @@ -1258,7 +1164,7 @@ describe('Editor editing commands', () => { ]); pressKey(window, content, { key: 'Escape' }); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 1, character: 1 }, end: { line: 1, character: 4 }, @@ -1267,7 +1173,7 @@ describe('Editor editing commands', () => { ]); pressKey(window, content, { key: 'Escape' }); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 1, character: 4 }, end: { line: 1, character: 4 }, @@ -1300,7 +1206,7 @@ describe('Editor editing commands', () => { pressKey(window, content, { key: 'Enter', metaKey: true }); expect(editor.getText()).toBe('zero\n\n one\n \ntwo'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 1, character: 0 }, end: { line: 1, character: 0 }, @@ -1333,7 +1239,7 @@ describe('Editor editing commands', () => { pressKey(window, content, { key: 'Enter', metaKey: true }); expect(editor.getText()).toBe('zero\n\n one\ntwo'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 1, character: 0 }, end: { line: 1, character: 0 }, @@ -1360,11 +1266,11 @@ describe('Editor editing commands', () => { pressKey(window, content, { key: ']', metaKey: true }); expect(editor.getText()).toBe(' alpha'); - expect(editor.getState().selections?.[0].start.character).toBe(5); + expect(editor.getViewState().selections?.[0].start.character).toBe(5); pressKey(window, content, { key: '[', metaKey: true }); expect(editor.getText()).toBe('alpha'); - expect(editor.getState().selections?.[0].start.character).toBe(3); + expect(editor.getViewState().selections?.[0].start.character).toBe(3); } finally { cleanup(); } @@ -1485,7 +1391,7 @@ describe('Editor editing commands', () => { shiftKey: true, }); expect(editor.getText()).toBe('alpha /* beta */'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 9 }, end: { line: 0, character: 13 }, @@ -1500,7 +1406,7 @@ describe('Editor editing commands', () => { shiftKey: true, }); expect(editor.getText()).toBe('alpha beta'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 6 }, end: { line: 0, character: 10 }, @@ -1532,7 +1438,7 @@ describe('Editor editing commands', () => { shiftKey: true, }); expect(editor.getText()).toBe('alpha /* */'); - expect(editor.getState().selections?.[0].start.character).toBe(9); + expect(editor.getViewState().selections?.[0].start.character).toBe(9); pressKey(window, content, { key: 'A', @@ -1541,7 +1447,7 @@ describe('Editor editing commands', () => { shiftKey: true, }); expect(editor.getText()).toBe('alpha '); - expect(editor.getState().selections?.[0].start.character).toBe(6); + expect(editor.getViewState().selections?.[0].start.character).toBe(6); } finally { cleanup(); } @@ -2349,12 +2255,15 @@ describe('line-based indent commands with selections sharing a line', () => { await createEditorFixture('quartz vein'); try { - editor.setState({ selections: [caret(0, 2), caret(0, 6)] }); + editor.setViewState({ selections: [caret(0, 2), caret(0, 6)] }); pressKey(window, content, { key: ']', metaKey: true }); expect(editor.getText()).toBe(' quartz vein'); - expect(editor.getState().selections).toEqual([caret(0, 4), caret(0, 8)]); + expect(editor.getViewState().selections).toEqual([ + caret(0, 4), + caret(0, 8), + ]); } finally { cleanup(); } @@ -2365,7 +2274,7 @@ describe('line-based indent commands with selections sharing a line', () => { await createEditorFixture('ada\nberyl\ncobalt'); try { - editor.setState({ + editor.setViewState({ selections: [range(0, 1, 1, 2), range(1, 3, 2, 1)], }); @@ -2382,12 +2291,15 @@ describe('line-based indent commands with selections sharing a line', () => { await createEditorFixture('\tquartz vein'); try { - editor.setState({ selections: [caret(0, 3), caret(0, 7)] }); + editor.setViewState({ selections: [caret(0, 3), caret(0, 7)] }); pressKey(window, content, { key: 'Tab', shiftKey: true }); expect(editor.getText()).toBe('quartz vein'); - expect(editor.getState().selections).toEqual([caret(0, 2), caret(0, 6)]); + expect(editor.getViewState().selections).toEqual([ + caret(0, 2), + caret(0, 6), + ]); } finally { cleanup(); } diff --git a/packages/diffs/test/editorBracketMatch.test.ts b/packages/diffs/test/editorBracketMatch.test.ts index ef1f0f9fc..79733cdf1 100644 --- a/packages/diffs/test/editorBracketMatch.test.ts +++ b/packages/diffs/test/editorBracketMatch.test.ts @@ -50,7 +50,7 @@ async function createBracketMatchFixture( disableFileHeader: true, theme: DEFAULT_THEMES, }); - const editor = new Editor(editorOptions); + const editor = new Editor('file', editorOptions); const initialFile: FileContents = { name: 'brackets.ts', contents }; file.render({ file: initialFile, fileContainer, forceRender: true }); diff --git a/packages/diffs/test/editorClipboard.test.ts b/packages/diffs/test/editorClipboard.test.ts index 51b2ed126..db74cb4b1 100644 --- a/packages/diffs/test/editorClipboard.test.ts +++ b/packages/diffs/test/editorClipboard.test.ts @@ -65,7 +65,7 @@ async function createDiffEditorFixture( }); const oldFile: FileContents = { name: 'example.txt', contents: oldContents }; const newFile: FileContents = { name: 'example.txt', contents: newContents }; - const editor = new Editor(); + const editor = new Editor('file-diff'); fileDiff.render({ oldFile, @@ -148,6 +148,10 @@ class TestEditableComponent implements DiffsEditableComponent { return this.options; } + __captureDocumentSessionState(): undefined { + return undefined; + } + getCodeScrollLeft(): number { return 0; } @@ -185,9 +189,12 @@ class TestEditableComponent implements DiffsEditableComponent { getAnnotationSlotName = getLineAnnotationName; - completeEditSession(): void {} + __completeEditSession( + _editor: DiffsEditor, + _mode: 'install' | 'discard' + ): void {} - attachEditor(editor: DiffsEditor): () => void { + __attachEditor(editor: DiffsEditor): () => void { this.#editor = editor; this.#syncRenderView(); return () => { @@ -195,6 +202,13 @@ class TestEditableComponent implements DiffsEditableComponent { }; } + __resumeEditor(editor: DiffsEditor): void { + if (this.#editor !== editor) { + throw new Error('TestEditableComponent: editor association changed'); + } + this.rerender(); + } + applyDocumentChange( textDocument: DiffsTextDocument, newLineAnnotations?: DiffLineAnnotation[] @@ -216,7 +230,6 @@ class TestEditableComponent implements DiffsEditableComponent { highlighter: createTestHighlighter(), fileContainer: this.fileContainer, file: this.#file, - externalCacheKey: this.#file.cacheKey, lineAnnotations: this.#lineAnnotations, renderRange: this.#renderRange, }); @@ -379,7 +392,7 @@ describe('Editor clipboard events', () => { test('cuts the current line when the primary selection is collapsed', () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'alpha\nbravo\ncharlie', @@ -400,7 +413,7 @@ describe('Editor clipboard events', () => { expect(writes).toEqual([['text', 'bravo\n']]); expect(editor.getText()).toBe('alpha\ncharlie'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 1, character: 0 }, end: { line: 1, character: 0 }, @@ -416,7 +429,7 @@ describe('Editor clipboard events', () => { test('cuts every collapsed selection line in a multi-cursor cut', () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'alpha\nbravo\ncharlie\ndelta', @@ -448,7 +461,7 @@ describe('Editor clipboard events', () => { ], ]); expect(editor.getText()).toBe('bravo\ndelta'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 0 }, end: { line: 0, character: 0 }, @@ -469,7 +482,7 @@ describe('Editor clipboard events', () => { test('cuts mixed ranges and collapsed selection lines together', () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'alpha\nbravo\ncharlie\ndelta', @@ -498,7 +511,7 @@ describe('Editor clipboard events', () => { [MULTI_SELECTION_CLIPBOARD_TYPE, JSON.stringify(['rav', 'charlie\n'])], ]); expect(editor.getText()).toBe('alpha\nbo\ndelta'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 1, character: 1 }, end: { line: 1, character: 1 }, @@ -519,7 +532,7 @@ describe('Editor clipboard events', () => { test('cuts a line once when multiple carets share it', () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'alpha\nbravo\ncharlie', @@ -551,7 +564,7 @@ describe('Editor clipboard events', () => { ], ]); expect(editor.getText()).toBe('alpha\ncharlie'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 1, character: 0 }, end: { line: 1, character: 0 }, @@ -567,7 +580,7 @@ describe('Editor clipboard events', () => { test('cuts a line once when a range overlaps a caret on the same line', () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'alpha\nbravo\ncharlie', @@ -596,7 +609,7 @@ describe('Editor clipboard events', () => { [MULTI_SELECTION_CLIPBOARD_TYPE, JSON.stringify(['br', 'bravo\n'])], ]); expect(editor.getText()).toBe('alpha\ncharlie'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 1, character: 0 }, end: { line: 1, character: 0 }, @@ -612,7 +625,7 @@ describe('Editor clipboard events', () => { test('copies the whole line including its break when collapsed', () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'alpha\nbravo\ncharlie', @@ -643,7 +656,7 @@ describe('Editor clipboard events', () => { test('copies the final line without a trailing break', () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'alpha\nbravo\ncharlie', @@ -672,7 +685,7 @@ describe('Editor clipboard events', () => { test('pastes copied selection texts into matching selections', async () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'one two\nthree four\n---\nAA\nBB', @@ -725,7 +738,7 @@ describe('Editor clipboard events', () => { test('uses plain text when metadata and selection counts differ', () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'AA\nBB\nCC', @@ -807,7 +820,7 @@ describe('Editor clipboard events', () => { const { cleanup } = installDom(); let reads = 0; - const editor = new Editor({ + const editor = new Editor('file', { clipboard: { readText: () => { reads++; @@ -850,7 +863,7 @@ describe('Editor clipboard events', () => { const { cleanup } = installDom(); let reads = 0; - const editor = new Editor({ + const editor = new Editor('file', { clipboard: { readText: () => { reads++; @@ -890,7 +903,7 @@ describe('Editor clipboard events', () => { const { cleanup } = installDom(); const reads: Array = []; - const editor = new Editor({ + const editor = new Editor('file', { clipboard: { readText: (type) => { reads.push(type); @@ -936,7 +949,7 @@ describe('Editor clipboard events', () => { test('uses custom clipboard plain text when selection counts differ', async () => { const { cleanup } = installDom(); - const editor = new Editor({ + const editor = new Editor('file', { clipboard: { readText: (type) => type === MULTI_SELECTION_CLIPBOARD_TYPE @@ -983,7 +996,7 @@ describe('Editor clipboard events', () => { test('rewrites Windows clipboard line breaks to the document EOL on paste', () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'alpha\nbravo\ncharlie', @@ -1015,7 +1028,7 @@ describe('Editor clipboard events', () => { test('rewrites lone carriage returns to the document EOL on paste', () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'alpha\nbravo', @@ -1045,7 +1058,7 @@ describe('Editor clipboard events', () => { test('matches the document EOL when the file uses CRLF', () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'alpha\r\nbravo', @@ -1076,7 +1089,7 @@ describe('Editor clipboard events', () => { test('matches the document EOL when the file uses lone CR', () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'a\rb', @@ -1109,7 +1122,7 @@ describe('Editor clipboard events', () => { const { cleanup } = installDom(); let reads = 0; - const editor = new Editor({ + const editor = new Editor('file', { clipboard: { readText: () => { reads++; @@ -1151,7 +1164,7 @@ describe('Editor line break input', () => { test(`${inputType} inserts the document EOL when the file uses CRLF`, () => { const { cleanup } = installDom(); - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'example.txt', contents: 'alpha\r\nbravo', diff --git a/packages/diffs/test/editorCollapsedEdit.test.ts b/packages/diffs/test/editorCollapsedEdit.test.ts index d2b70a1bd..652d42878 100644 --- a/packages/diffs/test/editorCollapsedEdit.test.ts +++ b/packages/diffs/test/editorCollapsedEdit.test.ts @@ -1,8 +1,10 @@ import { afterAll, describe, expect, spyOn, test } from 'bun:test'; +import type { CreatePatchOptionsNonabortable } from 'diff'; import { FileDiff } from '../src/components/FileDiff'; import { DEFAULT_THEMES } from '../src/constants'; import { Editor } from '../src/editor/editor'; +import { EditStateManager } from '../src/editor/EditStateManager'; import { PieceTable } from '../src/editor/pieceTable'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; import { installDom, wait, waitFor } from './domHarness'; @@ -65,7 +67,10 @@ interface CollapsedEditFixture { // Editing starts with the still-forced expansion, then the collapse is // re-enabled mid-session — the standalone vector for exercising // collapse-during-edit before the option forcing is removed. -async function createCollapsedEditFixture(): Promise { +async function createCollapsedEditFixture( + documentKey?: string, + parseDiffOptions?: CreatePatchOptionsNonabortable +): Promise { const dom = installDom(); const container = document.createElement('div'); document.body.appendChild(container); @@ -81,8 +86,9 @@ async function createCollapsedEditFixture(): Promise { disableFileHeader: true, theme: DEFAULT_THEMES, diffStyle: 'split', + parseDiffOptions, }); - const editor = new Editor(); + const editor = new Editor('file-diff', {}, documentKey); fileDiff.render({ oldFile: { name: 'edit.ts', contents: oldContents }, newFile: { name: 'edit.ts', contents: newContents }, @@ -152,7 +158,7 @@ describe('diff editor: attach-time markup normalization', () => { if (phase === 'update') updates++; }, }); - const editor = new Editor(); + const editor = new Editor('file-diff'); try { fileDiff.render({ oldFile: { name: 'edit.ts', contents: 'a\nb\n' }, @@ -411,6 +417,82 @@ describe('diff editor: collapsed regions during edit', () => { } }); + test('a keyed editor retains a context-only session region after undo', async () => { + EditStateManager.clearAll(); + const documentKey = 'collapsed-session-hunks'; + const first = await createCollapsedEditFixture(documentKey); + let firstCleaned = false; + try { + first.fileDiff.handleExpandHunk(1, 'both'); + await wait(10); + typeAt(first.editor, 29, 0, 'typed '); + await wait(30); + first.editor.undo(); + await wait(30); + expect(first.fileDiff.isLineRenderable(30)).toBe(true); + + await first.cleanup(); + firstCleaned = true; + + const second = await createCollapsedEditFixture(documentKey); + try { + expect(second.editor.getText()).not.toContain('typed line 30'); + expect(second.fileDiff.isLineRenderable(30)).toBe(true); + expect( + findAdditionContent(second.container)?.querySelector( + '[data-line="30"]' + )?.textContent + ).toBe('line 30'); + + second.editor.redo(); + await wait(30); + expect(second.editor.getText()).toContain('typed line 30'); + expect(second.fileDiff.isLineRenderable(30)).toBe(true); + } finally { + await second.cleanup(); + } + } finally { + if (!firstCleaned) { + await first.cleanup(); + } + EditStateManager.clearAll(); + } + }); + + test('a keyed editor retains session hunks across parse option changes', async () => { + EditStateManager.clearAll(); + const documentKey = 'incompatible-session-hunks'; + const first = await createCollapsedEditFixture(documentKey); + let firstCleaned = false; + try { + first.fileDiff.handleExpandHunk(1, 'both'); + await wait(10); + typeAt(first.editor, 29, 0, 'typed '); + await wait(30); + first.editor.undo(); + await wait(30); + expect(first.fileDiff.isLineRenderable(30)).toBe(true); + + await first.cleanup(); + firstCleaned = true; + + const second = await createCollapsedEditFixture(documentKey, { + ignoreWhitespace: true, + }); + try { + expect(second.editor.getText()).not.toContain('typed line 30'); + expect(second.fileDiff.isLineRenderable(30)).toBe(true); + } finally { + await second.cleanup(); + } + } finally { + if (!firstCleaned) { + await first.cleanup(); + } + EditStateManager.clearAll(); + } + }); + test('a mid-session expansion whose gap still exists survives session exit', async () => { const fixture = await createCollapsedEditFixture(); const { container, editor, fileDiff } = fixture; diff --git a/packages/diffs/test/editorComposition.test.ts b/packages/diffs/test/editorComposition.test.ts index e0f2b3518..d6b0129a0 100644 --- a/packages/diffs/test/editorComposition.test.ts +++ b/packages/diffs/test/editorComposition.test.ts @@ -75,7 +75,7 @@ async function createEditorFixture( name: 'editor.ts', contents, }; - const editor = new Editor(); + const editor = new Editor('file'); file.render({ file: initialFile, @@ -270,7 +270,7 @@ describe('Editor composition input', () => { }); expect(event.defaultPrevented).toBe(false); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 2 }, end: { line: 0, character: 2 }, @@ -332,7 +332,7 @@ describe('Editor keyboard editing', () => { }); expect(event.defaultPrevented).toBe(true); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 0 }, end: { line: 1, character: 4 }, @@ -568,7 +568,7 @@ describe('Editor keyboard editing', () => { expect(event.defaultPrevented).toBe(true); expect(editor.getText()).toBe('foo'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 0 }, end: { line: 0, character: 0 }, @@ -597,7 +597,7 @@ describe('Editor keyboard editing', () => { expect(event.defaultPrevented).toBe(true); expect(editor.getText()).toBe('foo'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 0 }, end: { line: 0, character: 0 }, @@ -626,7 +626,7 @@ describe('Editor keyboard editing', () => { expect(event.defaultPrevented).toBe(true); expect(editor.getText()).toBe(' foo'); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 1 }, end: { line: 0, character: 1 }, @@ -662,7 +662,7 @@ describe('Editor keyboard editing', () => { expect(editor.getText()).toBe(' abcde fgh'); // The second caret follows its own inserted indent (column 9), not the // pre-shift column 7 that lands before it. - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 2 }, end: { line: 0, character: 2 }, diff --git a/packages/diffs/test/editorDiffActiveLineHighlight.test.ts b/packages/diffs/test/editorDiffActiveLineHighlight.test.ts index 752e67a51..69dbbd323 100644 --- a/packages/diffs/test/editorDiffActiveLineHighlight.test.ts +++ b/packages/diffs/test/editorDiffActiveLineHighlight.test.ts @@ -134,7 +134,7 @@ async function createDiffEditorFixture( diffStyle, ...options, }); - const editor = new Editor(); + const editor = new Editor('file-diff'); const oldFile: FileContents = { name: 'edit.ts', contents: oldContents }; const newFile: FileContents = { name: 'edit.ts', contents: newContents }; @@ -295,7 +295,7 @@ describe('editor active-line highlight on a diff', () => { }, ]); content.dispatchEvent(new Event('focus')); - const selectionBefore = fixture.editor.getState().selections; + const selectionBefore = fixture.editor.getViewState().selections; getSelectionStub = spyOn(document, 'getSelection').mockReturnValue({ getComposedRanges: () => [ { @@ -337,7 +337,7 @@ describe('editor active-line highlight on a diff', () => { }) ); document.dispatchEvent(new Event('selectionchange')); - expect(fixture.editor.getState().selections).toEqual(selectionBefore); + expect(fixture.editor.getViewState().selections).toEqual(selectionBefore); utility?.dispatchEvent( new PointerEvent('pointerup', { @@ -353,7 +353,7 @@ describe('editor active-line highlight on a diff', () => { document.dispatchEvent(new Event('selectionchange')); expect(clickedRanges).toEqual([{ start: 1, end: 1, side: 'deletions' }]); - expect(fixture.editor.getState().selections).toEqual(selectionBefore); + expect(fixture.editor.getViewState().selections).toEqual(selectionBefore); expect(editorActiveGutterNumbers(additions)).toEqual([2]); expect( fixture.container.shadowRoot @@ -694,7 +694,7 @@ describe('editor active-line highlight on a diff', () => { }, ]); content.dispatchEvent(new Event('focus')); - const editorSelection = fixture.editor.getState().selections; + const editorSelection = fixture.editor.getViewState().selections; getSelectionStub = spyOn(document, 'getSelection').mockReturnValue({ getComposedRanges: () => [ { @@ -732,7 +732,7 @@ describe('editor active-line highlight on a diff', () => { }) ); document.dispatchEvent(new Event('selectionchange')); - expect(fixture.editor.getState().selections).toEqual(editorSelection); + expect(fixture.editor.getViewState().selections).toEqual(editorSelection); fourth.dispatchEvent( new PointerEvent('pointermove', { @@ -745,7 +745,7 @@ describe('editor active-line highlight on a diff', () => { }) ); document.dispatchEvent(new Event('selectionchange')); - expect(fixture.editor.getState().selections).toEqual(editorSelection); + expect(fixture.editor.getViewState().selections).toEqual(editorSelection); fourth.dispatchEvent( new PointerEvent('pointerup', { @@ -760,7 +760,7 @@ describe('editor active-line highlight on a diff', () => { document.dispatchEvent(new Event('selectionchange')); expect(selectedRanges).toEqual([{ start: 1, end: 4, side: 'deletions' }]); - expect(fixture.editor.getState().selections).toEqual(editorSelection); + expect(fixture.editor.getViewState().selections).toEqual(editorSelection); expect( fixture.container.shadowRoot ?.querySelector('pre') @@ -890,7 +890,7 @@ describe('editor active-line highlight on a diff', () => { }, ]); content.dispatchEvent(new Event('focus')); - const editorSelection = fixture.editor.getState().selections; + const editorSelection = fixture.editor.getViewState().selections; getSelectionStub = spyOn(document, 'getSelection').mockReturnValue({ getComposedRanges: () => [ { @@ -931,7 +931,7 @@ describe('editor active-line highlight on a diff', () => { }) ); document.dispatchEvent(new Event('selectionchange')); - expect(fixture.editor.getState().selections).toEqual(editorSelection); + expect(fixture.editor.getViewState().selections).toEqual(editorSelection); fourth.dispatchEvent( new PointerEvent('pointermove', { @@ -950,7 +950,7 @@ describe('editor active-line highlight on a diff', () => { side: 'additions', }; expect(changedRanges.at(-1)).toEqual(selectedRange); - expect(fixture.editor.getState().selections).toEqual(editorSelection); + expect(fixture.editor.getViewState().selections).toEqual(editorSelection); await waitFor(() => arraysEqual(highlightedLineNumbers(additions), [2, 3, 4]) ); @@ -969,7 +969,7 @@ describe('editor active-line highlight on a diff', () => { document.dispatchEvent(new Event('selectionchange')); expect(selectedRanges).toEqual([selectedRange]); - expect(fixture.editor.getState().selections).toEqual(editorSelection); + expect(fixture.editor.getViewState().selections).toEqual(editorSelection); expect(highlightedLineNumbers(additions)).toEqual([2, 3, 4]); expect(editorActiveLineNumbers(additions)).toEqual([1]); @@ -984,7 +984,7 @@ describe('editor active-line highlight on a diff', () => { }) ); document.dispatchEvent(new Event('selectionchange')); - expect(fixture.editor.getState().selections).toEqual([ + expect(fixture.editor.getViewState().selections).toEqual([ { start: { line: 0, character: 0 }, end: { line: 0, character: 0 }, @@ -1018,7 +1018,7 @@ describe('editor active-line highlight on a diff', () => { if (content == null || firstLine == null || secondGutter == null) { throw new Error('missing editor rows'); } - expect(fixture.editor.getState().selections).toBeUndefined(); + expect(fixture.editor.getViewState().selections).toBeUndefined(); getSelectionStub = spyOn(document, 'getSelection').mockReturnValue({ getComposedRanges: () => [ @@ -1051,7 +1051,7 @@ describe('editor active-line highlight on a diff', () => { content.dispatchEvent(new Event('focus')); document.dispatchEvent(new Event('selectionchange')); - expect(fixture.editor.getState().selections).toEqual([ + expect(fixture.editor.getViewState().selections).toEqual([ { start: { line: 0, character: 0 }, end: { line: 0, character: 0 }, @@ -1097,7 +1097,7 @@ describe('editor active-line highlight on a diff', () => { new MouseEvent('mousemove', { bubbles: true, composed: true }) ); await waitFor(() => editorActiveGutterNumbers(additions).includes(4)); - expect(fixture.editor.getState().selections).toEqual([ + expect(fixture.editor.getViewState().selections).toEqual([ { start: { line: 1, character: 0 }, end: { line: 3, character: 2 }, diff --git a/packages/diffs/test/editorDiffEmptyDocument.test.ts b/packages/diffs/test/editorDiffEmptyDocument.test.ts index ea04b868e..2e7cc3f65 100644 --- a/packages/diffs/test/editorDiffEmptyDocument.test.ts +++ b/packages/diffs/test/editorDiffEmptyDocument.test.ts @@ -83,7 +83,7 @@ async function createDiffEditorFixture( }); const oldFile: FileContents = { name: 'edit.ts', contents: oldContents }; const newFile: FileContents = { name: 'edit.ts', contents: newContents }; - const editor = new Editor(); + const editor = new Editor('file-diff'); fileDiff.render({ oldFile, @@ -193,7 +193,7 @@ describe('diff editor: empty document', () => { } expect(fixture.fileDiff.fileDiff?.additionLines).toEqual([]); - expect(getEditSessionDiff(fixture.fileDiff)?.additionLines).toEqual([]); + expect(getEditSessionDiff(fixture.fileDiff)).toBeUndefined(); const content = findAdditionContent(fixture.container); expect(content == null ? 0 : countEditableLineEls(content)).toBe(0); } finally { diff --git a/packages/diffs/test/editorDisplayOptionResync.test.ts b/packages/diffs/test/editorDisplayOptionResync.test.ts index 15841d105..2070bf04f 100644 --- a/packages/diffs/test/editorDisplayOptionResync.test.ts +++ b/packages/diffs/test/editorDisplayOptionResync.test.ts @@ -93,7 +93,7 @@ async function createFixture( }); const oldFile: FileContents = { name: 'edit.ts', contents: oldContents }; const newFile: FileContents = { name: 'edit.ts', contents: newContents }; - const editor = new Editor(); + const editor = new Editor('file-diff'); fileDiff.render({ oldFile, @@ -367,7 +367,7 @@ describe('diff editor: display-option toggle mid-edit', () => { theme: DEFAULT_THEMES, diffStyle: 'split', }); - const editor = new Editor(); + const editor = new Editor('file-diff'); const oldContents = 'alpha\nbravo\n'; const newContents = 'alpha\nCHANGED\n'; const file = { name: 'edit.ts' }; @@ -452,7 +452,7 @@ describe('file editor: theme toggle mid-edit', () => { name: 'edit.ts', contents: 'alpha\nbravo\n', }; - const editor = new Editor(); + const editor = new Editor('file'); file.render({ file: fileContents, fileContainer: container, @@ -509,11 +509,11 @@ describe('file editor: theme toggle mid-edit', () => { describe('diff editor: detach then re-attach', () => { // Mirrors the demo's Edit-mode toggle and surface switch: turning editing off - // detaches the editor (editor.cleanUp), turning it back on re-attaches the - // same instance (editor.edit). cleanUp tears down the tokenizer, so the - // re-attach must rebuild it before any edit can render. Pre-fix cleanUp kept - // the parsed document and its cacheKey, so __syncRenderView treated the - // re-attach as "same file" and skipped the rebuild that creates the + // recycles the editor, turning it back on re-attaches the same instance. + // Recycling tears down the tokenizer, so the re-attach must rebuild it before + // any edit can render. Pre-fix recycling kept the parsed document and its + // cacheKey, so __syncRenderView treated the re-attach as "same file" and + // skipped the rebuild that creates the // tokenizer; #rerender then bailed on the missing tokenizer and edits never // reached the DOM, even though the model still recorded them. test('renders edits typed after a detach/re-attach cycle', async () => { @@ -526,7 +526,7 @@ describe('diff editor: detach then re-attach', () => { expect(lineText(container, 1)).toBe('alphaX'); // Detach (Edit-mode off) then re-attach the same editor (Edit-mode on). - editor.cleanUp(); + editor.cleanUp('recycle'); await wait(0); editor.edit(fileDiff); await waitForEditable(container); @@ -559,7 +559,7 @@ describe('diff editor: detach then re-attach', () => { contents: 'alpha\nCHANGED\n', cacheKey: 'new:initial', }; - const editor = new Editor(); + const editor = new Editor('file-diff'); const fileDiff = new FileDiff( { disableFileHeader: true, diff --git a/packages/diffs/test/editorEventTypes.test.ts b/packages/diffs/test/editorEventTypes.test.ts new file mode 100644 index 000000000..53beba09f --- /dev/null +++ b/packages/diffs/test/editorEventTypes.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from 'bun:test'; + +import type { FileEditCompleteHandler } from '../src/components/File'; +import type { FileDiffEditCompleteHandler } from '../src/components/FileDiff'; +import type { EditorOptions } from '../src/editor/editor'; +import type { DiffsEditor } from '../src/types'; + +interface AnnotationMetadata { + id: string; +} + +test('editor events preserve annotation types', () => { + const onChange: NonNullable['onChange']> = ( + event + ) => { + const editor: DiffsEditor = event.editor; + const metadata: AnnotationMetadata | undefined = + event.lineAnnotations?.[0]?.metadata ?? + editor.getEditState()?.document.history.undoStack[0] + ?.lineAnnotationsBefore?.[0]?.metadata; + void metadata; + }; + const onComplete: NonNullable< + EditorOptions['onComplete'] + > = (event) => { + const editor: DiffsEditor = event.editor; + const metadata: AnnotationMetadata | undefined = + event.lineAnnotations?.[0]?.metadata; + if ('file' in event) { + const fileName: string = event.file.name; + void fileName; + } else { + const diffName: string = event.fileDiff.name; + void diffName; + } + void editor; + void metadata; + }; + const onFileComplete: FileEditCompleteHandler = ( + event + ) => { + const editor: DiffsEditor = event.editor; + const metadata: AnnotationMetadata | undefined = + event.lineAnnotations?.[0]?.metadata; + void editor; + void metadata; + return 'reject'; + }; + const onFileDiffComplete: FileDiffEditCompleteHandler = ( + event + ) => { + const editor: DiffsEditor = event.editor; + const metadata: AnnotationMetadata | undefined = + event.lineAnnotations?.[0]?.metadata; + void editor; + void metadata; + return 'reject'; + }; + + expect(onChange).toBeFunction(); + expect(onComplete).toBeFunction(); + expect(onFileComplete).toBeFunction(); + expect(onFileDiffComplete).toBeFunction(); +}); diff --git a/packages/diffs/test/editorFoldNavigation.test.ts b/packages/diffs/test/editorFoldNavigation.test.ts index c9f6ee1a2..65da0b063 100644 --- a/packages/diffs/test/editorFoldNavigation.test.ts +++ b/packages/diffs/test/editorFoldNavigation.test.ts @@ -66,7 +66,7 @@ async function createFoldFixture(): Promise { theme: DEFAULT_THEMES, diffStyle: 'split', }); - const editor = new Editor(); + const editor = new Editor('file-diff'); fileDiff.render({ oldFile: { name: 'edit.ts', contents: oldContents }, newFile: { name: 'edit.ts', contents: newContents }, @@ -145,7 +145,7 @@ describe('diff editor: fold-skip navigation', () => { setCaret(editor, 13); await wait(0); pressKey(content, 'ArrowDown'); - const selections = editor.getState().selections; + const selections = editor.getViewState().selections; // The caret jumps over lines 15-45 to line 46 (index 45). expect(selections?.at(-1)?.start.line).toBe(45); } finally { @@ -160,7 +160,7 @@ describe('diff editor: fold-skip navigation', () => { setCaret(editor, 45); await wait(0); pressKey(content, 'ArrowUp'); - expect(editor.getState().selections?.at(-1)?.start.line).toBe(13); + expect(editor.getViewState().selections?.at(-1)?.start.line).toBe(13); } finally { await fixture.cleanup(); } @@ -174,7 +174,7 @@ describe('diff editor: fold-skip navigation', () => { setCaret(editor, 13, 'line 14'.length); await wait(0); pressKey(content, 'ArrowRight'); - const selection = editor.getState().selections?.at(-1); + const selection = editor.getViewState().selections?.at(-1); // The caret lands at the start of the next renderable line (46). expect(selection?.start.line).toBe(45); expect(selection?.start.character).toBe(0); @@ -191,7 +191,7 @@ describe('diff editor: fold-skip navigation', () => { setCaret(editor, 45, 0); await wait(0); pressKey(content, 'ArrowLeft'); - const selection = editor.getState().selections?.at(-1); + const selection = editor.getViewState().selections?.at(-1); // The caret lands at the end of the previous renderable line (14). expect(selection?.start.line).toBe(13); expect(selection?.start.character).toBe('line 14'.length); @@ -207,7 +207,7 @@ describe('diff editor: fold-skip navigation', () => { setCaret(editor, 13, 'line 14'.length); await wait(0); pressKey(content, 'ArrowRight', { shiftKey: true }); - const selection = editor.getState().selections?.at(-1); + const selection = editor.getViewState().selections?.at(-1); expect(selection?.start.line).toBe(13); expect(selection?.end.line).toBe(45); expect(selection?.end.character).toBe(0); @@ -224,7 +224,7 @@ describe('diff editor: fold-skip navigation', () => { setCaret(editor, 53, 'line 54'.length); await wait(0); pressKey(content, 'ArrowRight'); - let selection = editor.getState().selections?.at(-1); + let selection = editor.getViewState().selections?.at(-1); expect(selection?.start.line).toBe(53); expect(selection?.start.character).toBe('line 54'.length); @@ -232,7 +232,7 @@ describe('diff editor: fold-skip navigation', () => { setCaret(editor, 5, 0); await wait(0); pressKey(content, 'ArrowLeft'); - selection = editor.getState().selections?.at(-1); + selection = editor.getViewState().selections?.at(-1); expect(selection?.start.line).toBe(5); expect(selection?.start.character).toBe(0); } finally { @@ -247,7 +247,7 @@ describe('diff editor: fold-skip navigation', () => { setCaret(editor, 13); await wait(0); pressKey(content, 'ArrowDown', { shiftKey: true }); - const selection = editor.getState().selections?.at(-1); + const selection = editor.getViewState().selections?.at(-1); // Fold semantics: the selection covers the hidden lines. expect(selection?.start.line).toBe(13); expect(selection?.end.line).toBe(45); diff --git a/packages/diffs/test/editorMarkerPopover.test.ts b/packages/diffs/test/editorMarkerPopover.test.ts index 600aed7fb..5e35d3cfa 100644 --- a/packages/diffs/test/editorMarkerPopover.test.ts +++ b/packages/diffs/test/editorMarkerPopover.test.ts @@ -47,7 +47,7 @@ async function createMarkerFixture(contents: string): Promise { disableFileHeader: true, theme: DEFAULT_THEMES, }); - const editor = new Editor(); + const editor = new Editor('file'); const initialFile: FileContents = { name: 'edits.ts', contents }; file.render({ file: initialFile, fileContainer, forceRender: true }); diff --git a/packages/diffs/test/editorPersistStateLifecycle.test.ts b/packages/diffs/test/editorPersistStateLifecycle.test.ts deleted file mode 100644 index e4895fbbd..000000000 --- a/packages/diffs/test/editorPersistStateLifecycle.test.ts +++ /dev/null @@ -1,1160 +0,0 @@ -import { afterAll, describe, expect, test } from 'bun:test'; - -import { File } from '../src/components/File'; -import { FileDiff } from '../src/components/FileDiff'; -import { DEFAULT_THEMES } from '../src/constants'; -import { Editor, type IStateStorage } from '../src/edit'; -import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; -import type { - EditorChange, - EditorState, - FileContents, - FileDiffMetadata, -} from '../src/types'; -import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; -import { installDom, wait, waitFor } from './domHarness'; -import { createDeferred } from './testUtils'; - -afterAll(async () => { - await disposeHighlighter(); -}); - -const ORIGINAL_FILE: FileContents = { - name: 'persisted.ts', - contents: 'alpha\nbravo\n', - cacheKey: 'persisted.ts', -}; - -interface AttachedFile { - container: HTMLElement; - file: File; -} - -class TestFileDiff extends FileDiff { - getLatestDiffForTest(): FileDiffMetadata | undefined { - return this.getLatestDiff(); - } -} - -async function attachFile( - editor: Editor, - fileContents: FileContents -): Promise { - const container = document.createElement('div'); - document.body.appendChild(container); - const file = new File({ - disableErrorHandling: true, - disableFileHeader: true, - theme: DEFAULT_THEMES, - }); - - file.render({ - file: fileContents, - fileContainer: container, - forceRender: true, - }); - editor.edit(file); - await waitFor(() => { - const content = container.shadowRoot?.querySelector('[data-content]'); - return ( - content instanceof HTMLElement && - (content.contentEditable === 'true' || - content.getAttribute('contenteditable') === 'true') - ); - }); - - return { container, file }; -} - -async function renderFile( - editor: Editor, - attached: AttachedFile, - fileContents: FileContents -): Promise { - attached.file.render({ - file: fileContents, - fileContainer: attached.container, - forceRender: true, - }); - await waitFor(() => { - const file = editor.getFile(); - return ( - file?.name === fileContents.name && - file.cacheKey === fileContents.cacheKey - ); - }); -} - -function savedCaret(character: number): EditorState { - return { - selections: [ - { - start: { line: 0, character }, - end: { line: 0, character }, - direction: 0, - }, - ], - }; -} - -describe('Editor persisted state lifecycle', () => { - test('an async restore survives an unchanged file rerender', async () => { - const dom = installDom(); - const pendingState = createDeferred(); - const gets: string[] = []; - const sets: string[] = []; - const storage: IStateStorage = { - get(cacheKey) { - gets.push(cacheKey); - return pendingState.promise; - }, - set(cacheKey) { - sets.push(cacheKey); - }, - }; - const editor = new Editor({ - persistState: true, - persistStateStorage: storage, - }); - let attached: AttachedFile | undefined; - - try { - attached = await attachFile(editor, { ...ORIGINAL_FILE }); - await waitFor(() => gets.length === 1); - - await renderFile(editor, attached, { ...ORIGINAL_FILE }); - pendingState.resolve(savedCaret(3)); - await waitFor( - () => editor.getState().selections?.[0]?.start.character === 3 - ); - - expect(gets).toEqual(['persisted.ts']); - expect(sets).toEqual([]); - expect(editor.getState().selections).toEqual(savedCaret(3).selections); - } finally { - editor.cleanUp(); - attached?.file.cleanUp(); - dom.cleanup(); - } - }); - - test('a stale async restore cannot overwrite the next file state', async () => { - const dom = installDom(); - const pendingState = createDeferred(); - const storage: IStateStorage = { - get(cacheKey) { - return cacheKey === 'persisted.ts' ? pendingState.promise : undefined; - }, - set() {}, - }; - const editor = new Editor({ - persistState: true, - persistStateStorage: storage, - }); - let attached: AttachedFile | undefined; - - try { - attached = await attachFile(editor, { ...ORIGINAL_FILE }); - await renderFile(editor, attached, { - name: 'next.ts', - contents: 'zulu\n', - cacheKey: 'next.ts', - }); - editor.setSelections([ - { - start: { line: 0, character: 1 }, - end: { line: 0, character: 1 }, - direction: 'none', - }, - ]); - - pendingState.resolve(savedCaret(4)); - await wait(0); - - expect(editor.getFile()?.name).toBe('next.ts'); - expect(editor.getState().selections).toEqual(savedCaret(1).selections); - } finally { - editor.cleanUp(); - attached?.file.cleanUp(); - dom.cleanup(); - } - }); - - test('leaving during the first read does not clobber stored state', async () => { - const dom = installDom(); - const pendingRead = createDeferred(); - const storedState = savedCaret(4); - const states = new Map([['saved.ts', storedState]]); - let delayedReadStarted = false; - const storage: IStateStorage = { - get(cacheKey) { - if (cacheKey === 'saved.ts' && !delayedReadStarted) { - delayedReadStarted = true; - return pendingRead.promise; - } - return states.get(cacheKey); - }, - set(cacheKey, state) { - states.set(cacheKey, state); - }, - }; - const editor = new Editor({ - persistState: true, - persistStateStorage: storage, - }); - let attached: AttachedFile | undefined; - - try { - attached = await attachFile(editor, { ...ORIGINAL_FILE }); - await renderFile(editor, attached, { - name: 'saved.ts', - contents: 'saved\n', - cacheKey: 'saved.ts', - }); - await waitFor(() => delayedReadStarted); - - await renderFile(editor, attached, { - name: 'next.ts', - contents: 'next\n', - cacheKey: 'next.ts', - }); - pendingRead.resolve(storedState); - await wait(0); - - expect(states.get('saved.ts')).toEqual(storedState); - - await renderFile(editor, attached, { - name: 'saved.ts', - contents: 'saved\n', - cacheKey: 'saved.ts', - }); - expect(editor.getState().selections).toEqual(storedState.selections); - } finally { - editor.cleanUp(); - attached?.file.cleanUp(); - dom.cleanup(); - } - }); - - test('serializes delayed writes so the newest state wins', async () => { - const dom = installDom(); - const firstWrite = createDeferred(); - const secondWrite = createDeferred(); - const writeGates = [firstWrite, secondWrite]; - const writes: EditorState[] = []; - const states = new Map(); - const storage: IStateStorage = { - get(cacheKey) { - return states.get(cacheKey); - }, - set(cacheKey, state) { - if (cacheKey !== 'persisted.ts') { - states.set(cacheKey, state); - return; - } - const gate = writeGates[writes.length]; - if (gate === undefined) { - throw new Error('unexpected persisted.ts write'); - } - writes.push(state); - return gate.promise.then(() => { - states.set(cacheKey, state); - }); - }, - }; - const editor = new Editor({ - persistState: true, - persistStateStorage: storage, - }); - let attached: AttachedFile | undefined; - - try { - attached = await attachFile(editor, { ...ORIGINAL_FILE }); - editor.setSelections([ - { - start: { line: 0, character: 1 }, - end: { line: 0, character: 1 }, - direction: 'none', - }, - ]); - await renderFile(editor, attached, { - name: 'next.ts', - contents: 'next\n', - cacheKey: 'next.ts', - }); - - await renderFile(editor, attached, { ...ORIGINAL_FILE }); - editor.setSelections([ - { - start: { line: 0, character: 2 }, - end: { line: 0, character: 2 }, - direction: 'none', - }, - ]); - await renderFile(editor, attached, { - name: 'next.ts', - contents: 'next\n', - cacheKey: 'next.ts', - }); - - expect(writes).toHaveLength(1); - secondWrite.resolve(); - firstWrite.resolve(); - await waitFor( - () => states.get('persisted.ts')?.selections?.[0]?.start.character === 2 - ); - - expect(writes).toHaveLength(2); - expect(states.get('persisted.ts')?.selections).toEqual( - savedCaret(2).selections - ); - - await renderFile(editor, attached, { ...ORIGINAL_FILE }); - expect(editor.getState().selections).toEqual(savedCaret(2).selections); - } finally { - editor.cleanUp(); - attached?.file.cleanUp(); - dom.cleanup(); - } - }); - - test('full cleanup restores cached text into a fresh File mount', async () => { - const dom = installDom(); - const editor = new Editor({ persistState: true }); - let first: AttachedFile | undefined; - let second: AttachedFile | undefined; - - try { - first = await attachFile(editor, { ...ORIGINAL_FILE }); - editor.applyEdits( - [ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 0 }, - }, - newText: 'X', - }, - ], - true - ); - - editor.cleanUp(); - first.file.cleanUp(); - first = undefined; - - second = await attachFile(editor, { ...ORIGINAL_FILE }); - await waitFor( - () => - second?.container.shadowRoot?.querySelector( - '[data-content] [data-line="1"]' - )?.textContent === 'Xalpha' - ); - - expect(editor.getText()).toBe('Xalpha\nbravo\n'); - expect( - second.container.shadowRoot?.querySelector( - '[data-content] [data-line="1"]' - )?.textContent - ).toBe('Xalpha'); - expect(editor.canUndo).toBe(true); - } finally { - editor.cleanUp(); - first?.file.cleanUp(); - second?.file.cleanUp(); - dom.cleanup(); - } - }); - - test('cached document contents require the same persisted identity', async () => { - const dom = installDom(); - const editor = new Editor({ persistState: true }); - const editorWithoutPersistence = new Editor(); - let attached: AttachedFile | undefined; - - try { - attached = await attachFile(editor, { ...ORIGINAL_FILE }); - editor.applyEdits([ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 0 }, - }, - newText: 'X', - }, - ]); - editor.cleanUp(); - - expect( - editor.__getCachedDocumentContents({ - name: ORIGINAL_FILE.name, - cacheKey: ORIGINAL_FILE.cacheKey, - }) - ).toBe('Xalpha\nbravo\n'); - expect( - editor.__getCachedDocumentContents({ - name: ORIGINAL_FILE.name, - cacheKey: ORIGINAL_FILE.cacheKey, - }) - ).toBe('Xalpha\nbravo\n'); - expect( - editor.__getCachedDocumentContents({ - name: ORIGINAL_FILE.name, - cacheKey: 'another-document', - }) - ).toBeUndefined(); - expect( - editor.__getCachedDocumentContents({ - name: 'renamed.ts', - cacheKey: ORIGINAL_FILE.cacheKey, - }) - ).toBeUndefined(); - expect( - editor.__getCachedDocumentContents({ - name: ORIGINAL_FILE.name, - lang: 'css', - cacheKey: ORIGINAL_FILE.cacheKey, - }) - ).toBeUndefined(); - expect( - editorWithoutPersistence.__getCachedDocumentContents({ - name: ORIGINAL_FILE.name, - cacheKey: ORIGINAL_FILE.cacheKey, - }) - ).toBeUndefined(); - } finally { - editor.cleanUp(); - editorWithoutPersistence.cleanUp(); - attached?.file.cleanUp(); - dom.cleanup(); - } - }); - - test('a rename with the same cache key keeps text, state, and history', async () => { - const dom = installDom(); - const editor = new Editor({ persistState: true }); - let attached: AttachedFile | undefined; - - try { - attached = await attachFile(editor, { - ...ORIGINAL_FILE, - cacheKey: 'logical-file', - }); - editor.applyEdits( - [ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 0 }, - }, - newText: 'X', - }, - ], - true - ); - editor.setSelections([ - { - start: { line: 0, character: 3 }, - end: { line: 0, character: 3 }, - direction: 'none', - }, - ]); - - await renderFile(editor, attached, { - ...ORIGINAL_FILE, - name: 'renamed.ts', - cacheKey: 'logical-file', - }); - await waitFor( - () => - editor.getFile()?.name === 'renamed.ts' && - editor.getText() === 'Xalpha\nbravo\n' - ); - - expect(editor.getState().selections).toEqual(savedCaret(3).selections); - expect(editor.canUndo).toBe(true); - } finally { - editor.cleanUp(); - attached?.file.cleanUp(); - dom.cleanup(); - } - }); - - test('repeated recycle cleanup preserves state restoration', async () => { - const dom = installDom(); - const editor = new Editor({ persistState: true }); - let first: AttachedFile | undefined; - let second: AttachedFile | undefined; - - try { - first = await attachFile(editor, { ...ORIGINAL_FILE }); - editor.setSelections([ - { - start: { line: 0, character: 2 }, - end: { line: 0, character: 2 }, - direction: 'none', - }, - ]); - - editor.cleanUp('recycle'); - // A repeated teardown has no attached instance, but must not erase the - // state-restoration request captured by the first cleanup. - editor.cleanUp('recycle'); - first.file.cleanUp(true); - first = undefined; - - second = await attachFile(editor, { ...ORIGINAL_FILE }); - - expect(editor.getState().selections).toEqual(savedCaret(2).selections); - } finally { - editor.cleanUp(); - first?.file.cleanUp(); - second?.file.cleanUp(); - dom.cleanup(); - } - }); - - // Renders a file inside a scrollable wrapper (the editor's default viewport - // is its nearest overflow-y:auto ancestor) so the restore path's scroll - // behavior is observable. The wrapper starts scrolled away from 0,0 as if a - // previously open file had left an offset behind. - function attachFileInScrolledViewport( - editor: Editor, - fileContents: FileContents - ): AttachedFile & { viewport: HTMLElement } { - const viewport = document.createElement('div'); - viewport.style.overflowY = 'auto'; - document.body.appendChild(viewport); - const container = document.createElement('div'); - viewport.appendChild(container); - viewport.scrollTop = 40; - viewport.scrollLeft = 8; - - const file = new File({ - disableErrorHandling: true, - disableFileHeader: true, - theme: DEFAULT_THEMES, - }); - file.render({ - file: fileContents, - fileContainer: container, - forceRender: true, - }); - editor.edit(file); - return { container, file, viewport }; - } - - test('a missing state record resets the viewport scroll to 0,0', async () => { - const dom = installDom(); - const storage: IStateStorage = { - get: () => undefined, - set() {}, - }; - const editor = new Editor({ - persistState: true, - persistStateStorage: storage, - }); - let attached: (AttachedFile & { viewport: HTMLElement }) | undefined; - - try { - attached = attachFileInScrolledViewport(editor, { - ...ORIGINAL_FILE, - }); - const { viewport } = attached; - // The reset targets the viewport's vertical position (and the code - // scroller's own horizontal offset); the viewport's scrollLeft is left - // to the host. - await waitFor(() => viewport.scrollTop === 0); - } finally { - editor.cleanUp(); - attached?.file.cleanUp(); - dom.cleanup(); - } - }); - - test('a record with scrollTop restores the viewport position', async () => { - const dom = installDom(); - const storage: IStateStorage = { - get: () => ({ ...savedCaret(3), view: { scrollLeft: 0, scrollTop: 25 } }), - set() {}, - }; - const editor = new Editor({ - persistState: true, - persistStateStorage: storage, - }); - let attached: (AttachedFile & { viewport: HTMLElement }) | undefined; - - try { - attached = attachFileInScrolledViewport(editor, { - ...ORIGINAL_FILE, - }); - const { viewport } = attached; - await waitFor(() => viewport.scrollTop === 25); - } finally { - editor.cleanUp(); - attached?.file.cleanUp(); - dom.cleanup(); - } - }); - - // The shared-editor pattern (e.g. an EditProvider handing every keyed - // surface the same Editor): the outgoing surface's cleanUp persists its - // record into the editor's per-instance storage, and re-attaching a new - // surface for the same cacheKey restores it — including the viewport's - // vertical position. - test('a reused editor restores viewport scrollTop across surface remounts', async () => { - const dom = installDom(); - const editor = new Editor({ persistState: true }); - let attached: (AttachedFile & { viewport: HTMLElement }) | undefined; - let reattached: (AttachedFile & { viewport: HTMLElement }) | undefined; - - try { - attached = attachFileInScrolledViewport(editor, { - ...ORIGINAL_FILE, - }); - // First visit has no record: the viewport resets to 0. Then the user - // scrolls, and leaving the file persists that position. - await waitFor(() => attached!.viewport.scrollTop === 0); - attached.viewport.scrollTop = 33; - editor.cleanUp(); - attached.file.cleanUp(); - - reattached = attachFileInScrolledViewport(editor, { - ...ORIGINAL_FILE, - }); - await waitFor(() => reattached!.viewport.scrollTop === 33); - } finally { - editor.cleanUp(); - attached?.file.cleanUp(); - reattached?.file.cleanUp(); - dom.cleanup(); - } - }); - - // A user scroll while an async read is in flight is signalled by its input - // event (wheel/touch/pointer press); the late restore must leave the - // user's vertical position alone while still applying selections. - test('a user scroll during an async read wins over the restored scrollTop', async () => { - const dom = installDom(); - const pendingState = createDeferred(); - const gets: string[] = []; - const storage: IStateStorage = { - get(cacheKey) { - gets.push(cacheKey); - return pendingState.promise; - }, - set() {}, - }; - const editor = new Editor({ - persistState: true, - persistStateStorage: storage, - }); - let attached: (AttachedFile & { viewport: HTMLElement }) | undefined; - - try { - attached = attachFileInScrolledViewport(editor, { ...ORIGINAL_FILE }); - const { viewport } = attached; - await waitFor(() => gets.length === 1); - - viewport.dispatchEvent(new Event('wheel')); - viewport.scrollTop = 77; - pendingState.resolve({ - ...savedCaret(3), - view: { scrollLeft: 0, scrollTop: 25 }, - }); - await waitFor( - () => editor.getState().selections?.[0]?.start.character === 3 - ); - expect(editor.getState().selections).toEqual(savedCaret(3).selections); - expect(viewport.scrollTop).toBe(77); - } finally { - editor.cleanUp(); - attached?.file.cleanUp(); - dom.cleanup(); - } - }); - - // The counterpart: scrollTop moved by layout work alone (no input event — - // height reconciliation, clamping) must not block the async restore. - test('a layout-driven scroll change does not block the async scrollTop restore', async () => { - const dom = installDom(); - const pendingState = createDeferred(); - const gets: string[] = []; - const storage: IStateStorage = { - get(cacheKey) { - gets.push(cacheKey); - return pendingState.promise; - }, - set() {}, - }; - const editor = new Editor({ - persistState: true, - persistStateStorage: storage, - }); - let attached: (AttachedFile & { viewport: HTMLElement }) | undefined; - - try { - attached = attachFileInScrolledViewport(editor, { ...ORIGINAL_FILE }); - const { viewport } = attached; - await waitFor(() => gets.length === 1); - - viewport.scrollTop = 5; - pendingState.resolve({ - ...savedCaret(3), - view: { scrollLeft: 0, scrollTop: 25 }, - }); - await waitFor(() => viewport.scrollTop === 25); - expect(viewport.scrollTop).toBe(25); - expect(editor.getState().selections).toEqual(savedCaret(3).selections); - } finally { - editor.cleanUp(); - attached?.file.cleanUp(); - dom.cleanup(); - } - }); - - // Diff documents, history, and serializable editor state are stored under - // the cacheKey derived from the file pair. A first attach has no state record - // and resets to 0,0; a later FileDiff for the same pair restores its viewport. - test('with persistState, a diff resets on first attach and restores on revisit', async () => { - const dom = installDom(); - const editor = new Editor({ persistState: true }); - const viewport = document.createElement('div'); - viewport.style.overflowY = 'auto'; - document.body.appendChild(viewport); - const container = document.createElement('div'); - viewport.appendChild(container); - viewport.scrollTop = 40; - viewport.scrollLeft = 8; - - const oldFile: FileContents = { - name: 'diffed.ts', - contents: 'alpha\n', - cacheKey: 'diffed:old', - }; - const newFile: FileContents = { - name: 'diffed.ts', - contents: 'alpha\nbravo\n', - cacheKey: 'diffed:new', - }; - const fileDiff = new FileDiff({ - disableErrorHandling: true, - disableFileHeader: true, - theme: DEFAULT_THEMES, - }); - const revisitDiff = new FileDiff({ - disableErrorHandling: true, - disableFileHeader: true, - theme: DEFAULT_THEMES, - }); - try { - fileDiff.render({ - oldFile, - newFile, - fileContainer: container, - forceRender: true, - }); - editor.edit(fileDiff); - await waitFor(() => viewport.scrollTop === 0); - - // Scroll the diff, tear the surface down, and revisit the same pair on - // a fresh component with the same editor — its record must restore the - // position. - viewport.scrollTop = 27; - editor.cleanUp(); - fileDiff.cleanUp(); - container.innerHTML = ''; - viewport.scrollTop = 5; - - revisitDiff.render({ - oldFile, - newFile, - fileContainer: container, - forceRender: true, - }); - editor.edit(revisitDiff); - await waitFor(() => viewport.scrollTop === 27); - } finally { - editor.cleanUp(); - fileDiff.cleanUp(); - revisitDiff.cleanUp(); - dom.cleanup(); - } - }); - - test('a fresh FileDiff restores cached text into its private edit model', async () => { - const dom = installDom(); - const changes: Array<{ - cacheKey: string | undefined; - changes: EditorChange[]; - contents: string; - }> = []; - const editor = new Editor({ - persistState: true, - onChange(event) { - changes.push({ - cacheKey: event.file.cacheKey, - changes: event.changes, - contents: event.file.contents, - }); - }, - }); - const container = document.createElement('div'); - document.body.appendChild(container); - const oldFile: FileContents = { - name: 'diffed-reparse.ts', - contents: 'alpha\n', - cacheKey: 'diffed-reparse:old', - }; - const newFile: FileContents = { - name: 'diffed-reparse.ts', - contents: 'alpha\nbravo\n', - cacheKey: 'diffed-reparse:new', - }; - const externalDiff = parseDiffFromFile(oldFile, newFile); - const externalBefore = structuredClone(externalDiff); - const externalAdditionLines = externalDiff.additionLines; - const externalDeletionLines = externalDiff.deletionLines; - const externalHunks = externalDiff.hunks; - const first = new TestFileDiff({ - disableErrorHandling: true, - disableFileHeader: true, - theme: DEFAULT_THEMES, - }); - const second = new TestFileDiff({ - disableErrorHandling: true, - disableFileHeader: true, - theme: DEFAULT_THEMES, - }); - try { - first.render({ - fileDiff: externalDiff, - fileContainer: container, - forceRender: true, - }); - editor.edit(first); - await waitFor(() => editor.getText() === 'alpha\nbravo\n'); - editor.applyEdits([ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 0 }, - }, - newText: 'edited ', - }, - ]); - expect(editor.getText()).toBe('edited alpha\nbravo\n'); - expect(changes.map((change) => change.contents)).toEqual([ - 'edited alpha\nbravo\n', - ]); - editor.cleanUp(); - first.cleanUp(); - container.innerHTML = ''; - changes.length = 0; - - second.render({ - fileDiff: externalDiff, - fileContainer: container, - forceRender: true, - }); - editor.edit(second); - await waitFor( - () => - editor.getText() === 'edited alpha\nbravo\n' && - container.shadowRoot?.querySelector('[data-content] [data-line="1"]') - ?.textContent === 'edited alpha' && - changes.length === 1 - ); - - const restoredDiff = second.getLatestDiffForTest(); - expect(restoredDiff).toBeDefined(); - expect(restoredDiff).not.toBe(externalDiff); - expect(restoredDiff?.cacheKey).toBeUndefined(); - expect(restoredDiff?.additionLines.join('')).toBe( - 'edited alpha\nbravo\n' - ); - expect(restoredDiff?.additionLines).not.toBe(externalAdditionLines); - expect(restoredDiff?.deletionLines).toBe(externalDeletionLines); - expect(restoredDiff?.hunks).not.toBe(externalHunks); - expect(externalDiff.additionLines).toBe(externalAdditionLines); - expect(externalDiff.deletionLines).toBe(externalDeletionLines); - expect(externalDiff.hunks).toBe(externalHunks); - expect(externalDiff).toEqual(externalBefore); - expect(editor.canUndo).toBe(true); - expect(editor.canRedo).toBe(false); - expect(changes).toEqual([ - { - cacheKey: undefined, - changes: [ - { - start: 0, - end: 'alpha\nbravo\n'.length, - text: 'edited alpha\nbravo\n', - range: { - start: { line: 0, character: 0 }, - end: { line: 2, character: 0 }, - }, - }, - ], - contents: 'edited alpha\nbravo\n', - }, - ]); - - editor.undo(); - expect(editor.getText()).toBe('alpha\nbravo\n'); - expect(editor.canUndo).toBe(false); - expect(editor.canRedo).toBe(true); - editor.redo(); - expect(editor.getText()).toBe('edited alpha\nbravo\n'); - expect(changes.map((change) => change.contents)).toEqual([ - 'edited alpha\nbravo\n', - 'alpha\nbravo\n', - 'edited alpha\nbravo\n', - ]); - expect(externalDiff).toEqual(externalBefore); - } finally { - editor.cleanUp(); - first.cleanUp(); - second.cleanUp(); - dom.cleanup(); - } - }); - - test('restoring an identical cached FileDiff does not emit a change', async () => { - const dom = installDom(); - const changes: string[] = []; - const editor = new Editor({ - persistState: true, - onChange: (event) => changes.push(event.file.contents), - }); - const container = document.createElement('div'); - document.body.appendChild(container); - const externalDiff = parseDiffFromFile( - { - name: 'identical.ts', - contents: 'before\n', - cacheKey: 'identical:old', - }, - { - name: 'identical.ts', - contents: 'after\n', - cacheKey: 'identical:new', - } - ); - const first = new FileDiff({ - disableErrorHandling: true, - disableFileHeader: true, - theme: DEFAULT_THEMES, - }); - const second = new FileDiff({ - disableErrorHandling: true, - disableFileHeader: true, - theme: DEFAULT_THEMES, - }); - - try { - first.render({ - fileDiff: externalDiff, - fileContainer: container, - forceRender: true, - }); - editor.edit(first); - await waitFor(() => editor.getText() === 'after\n'); - editor.cleanUp(); - first.cleanUp(); - container.innerHTML = ''; - - second.render({ - fileDiff: externalDiff, - fileContainer: container, - forceRender: true, - }); - editor.edit(second); - await waitFor( - () => - editor.getText() === 'after\n' && - container.shadowRoot - ?.querySelector('[data-content]') - ?.getAttribute('contenteditable') === 'true' - ); - - expect(changes).toEqual([]); - } finally { - editor.cleanUp(); - first.cleanUp(); - second.cleanUp(); - dom.cleanup(); - } - }); - - test('a restored FileDiff accepts a later external update through normal history', async () => { - const dom = installDom(); - const changes: string[] = []; - const editor = new Editor({ - persistState: true, - onChange: (event) => changes.push(event.file.contents), - }); - const container = document.createElement('div'); - document.body.appendChild(container); - const oldFile: FileContents = { - name: 'restored-update.ts', - contents: 'base\n', - }; - const initialDiff = parseDiffFromFile(oldFile, { - name: oldFile.name, - contents: 'alpha\n', - }); - initialDiff.cacheKey = 'restored-update:v1'; - const replacementDiff = parseDiffFromFile(oldFile, { - name: oldFile.name, - contents: 'charlie\n', - }); - replacementDiff.cacheKey = 'restored-update:v2'; - const initialBefore = structuredClone(initialDiff); - const replacementBefore = structuredClone(replacementDiff); - const first = new FileDiff({ - disableErrorHandling: true, - disableFileHeader: true, - theme: DEFAULT_THEMES, - }); - const second = new FileDiff({ - disableErrorHandling: true, - disableFileHeader: true, - theme: DEFAULT_THEMES, - }); - - try { - first.render({ - fileDiff: initialDiff, - fileContainer: container, - forceRender: true, - }); - editor.edit(first); - await waitFor(() => editor.getText() === 'alpha\n'); - editor.applyEdits([ - { - range: { - start: { line: 0, character: 0 }, - end: { line: 0, character: 0 }, - }, - newText: 'local ', - }, - ]); - expect(editor.getText()).toBe('local alpha\n'); - - editor.cleanUp(); - first.cleanUp(); - container.innerHTML = ''; - changes.length = 0; - - second.render({ - fileDiff: initialDiff, - fileContainer: container, - forceRender: true, - }); - editor.edit(second); - await waitFor( - () => editor.getText() === 'local alpha\n' && changes.length === 1 - ); - - second.render({ - fileDiff: replacementDiff, - fileContainer: container, - forceRender: true, - }); - await waitFor( - () => editor.getText() === 'charlie\n' && changes.length === 2 - ); - - expect(changes).toEqual(['local alpha\n', 'charlie\n']); - editor.undo(); - expect(editor.getText()).toBe('local alpha\n'); - editor.undo(); - expect(editor.getText()).toBe('alpha\n'); - editor.redo(); - expect(editor.getText()).toBe('local alpha\n'); - editor.redo(); - expect(editor.getText()).toBe('charlie\n'); - expect(changes).toEqual([ - 'local alpha\n', - 'charlie\n', - 'local alpha\n', - 'alpha\n', - 'local alpha\n', - 'charlie\n', - ]); - expect(initialDiff).toEqual(initialBefore); - expect(replacementDiff).toEqual(replacementBefore); - } finally { - editor.cleanUp(); - first.cleanUp(); - second.cleanUp(); - dom.cleanup(); - } - }); - - test('without persistState, attaching a diff leaves the viewport alone', async () => { - const dom = installDom(); - const editor = new Editor(); - const viewport = document.createElement('div'); - viewport.style.overflowY = 'auto'; - document.body.appendChild(viewport); - const container = document.createElement('div'); - viewport.appendChild(container); - viewport.scrollTop = 40; - - const fileDiff = new FileDiff({ - disableErrorHandling: true, - disableFileHeader: true, - theme: DEFAULT_THEMES, - }); - try { - fileDiff.render({ - oldFile: { name: 'diffed.ts', contents: 'alpha\n' }, - newFile: { name: 'diffed.ts', contents: 'alpha\nbravo\n' }, - fileContainer: container, - forceRender: true, - }); - editor.edit(fileDiff); - // Attach work settles asynchronously; give it time to (incorrectly) - // move the viewport before asserting it stayed put. - await wait(50); - expect(viewport.scrollTop).toBe(40); - } finally { - editor.cleanUp(); - fileDiff.cleanUp(); - dom.cleanup(); - } - }); - - test('a record without scrollTop leaves the viewport scroll alone', async () => { - const dom = installDom(); - const storage: IStateStorage = { - // A record persisted before scrollTop existed: setState still honors - // scrollLeft exactly but must not move the viewport vertically. - get: () => ({ ...savedCaret(3), view: { scrollLeft: 0 } }), - set() {}, - }; - const editor = new Editor({ - persistState: true, - persistStateStorage: storage, - }); - let attached: (AttachedFile & { viewport: HTMLElement }) | undefined; - - try { - attached = attachFileInScrolledViewport(editor, { - ...ORIGINAL_FILE, - }); - const { viewport } = attached; - await waitFor( - () => editor.getState().selections?.[0]?.start.character === 3 - ); - expect(viewport.scrollTop).toBe(40); - expect(viewport.scrollLeft).toBe(8); - } finally { - editor.cleanUp(); - attached?.file.cleanUp(); - dom.cleanup(); - } - }); -}); diff --git a/packages/diffs/test/editorPoolTheme.test.ts b/packages/diffs/test/editorPoolTheme.test.ts index 687f2e0c2..8e9bc3966 100644 --- a/packages/diffs/test/editorPoolTheme.test.ts +++ b/packages/diffs/test/editorPoolTheme.test.ts @@ -53,7 +53,7 @@ test('File edit mode works when the pool theme differs from component options', // the state a WorkerPoolContextProvider host starts from. await disposeHighlighter(); const { manager } = await createInitializedManager({ theme: 'github-dark' }); - const editor = new Editor(); + const editor = new Editor('file'); let instance: File | undefined; try { // Component options stay untouched: the default pierre-* themes. @@ -84,7 +84,7 @@ test('FileDiff edit mode works when the pool theme differs from component option const dom = installDom(); await disposeHighlighter(); const { manager } = await createInitializedManager({ theme: 'github-dark' }); - const editor = new Editor(); + const editor = new Editor('file-diff'); let instance: FileDiff | undefined; try { instance = new FileDiff( diff --git a/packages/diffs/test/editorPublicApi.test.ts b/packages/diffs/test/editorPublicApi.test.ts index 4e36ce7db..11be596c5 100644 --- a/packages/diffs/test/editorPublicApi.test.ts +++ b/packages/diffs/test/editorPublicApi.test.ts @@ -8,10 +8,20 @@ import { type EditorFocusOptions, type EditorOptions, } from '../src/editor/editor'; +import { EditStateManager } from '../src/editor/EditStateManager'; import type { Marker } from '../src/editor/marker'; +import { TextDocument } from '../src/editor/textDocument'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; -import type { DiffsEditableComponent, FileContents } from '../src/types'; -import { installDom, wait } from './domHarness'; +import type { + DiffsEditableComponent, + EditorViewState, + FileContents, + FileDiffMetadata, + LineAnnotation, +} from '../src/types'; +import { getFiletypeFromFileName } from '../src/utils/getFiletypeFromFileName'; +import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; +import { installDom, wait, waitFor } from './domHarness'; afterAll(async () => { await disposeHighlighter(); @@ -40,24 +50,52 @@ async function waitForEditableContent( interface EditorFixture { cleanup(): void; + complete(): void; + container: HTMLElement; content: HTMLElement; editor: Editor; file: File; } +class TestFileDiff extends FileDiff { + getCurrentDiff(): FileDiffMetadata | undefined { + return this.getLatestDiff(); + } + + getCurrentType(): FileDiffMetadata['type'] | undefined { + return this.getLatestDiff()?.type; + } +} + +class TestFile extends File { + getCurrentFile(): FileContents | undefined { + return this.getLatestFile(); + } +} + interface DiffEditorFixture { cleanup(): void; + complete(): void; container: HTMLElement; content: HTMLElement; editor: Editor; - fileDiff: FileDiff; + fileDiff: TestFileDiff; +} + +interface PendingEditorFixture { + cleanup(): void; + complete(): void; + editor: Editor; } // Mounts a real File-backed editor, mirroring the harness the applyEdits and // marker suites use, and returns the editor plus its contenteditable element. async function createEditorFixture( contents: string, - editorOptions?: EditorOptions + editorOptions?: EditorOptions, + lineAnnotations?: LineAnnotation[], + documentKey?: string, + fileInfo: Pick = { name: 'edits.ts' } ): Promise { const dom = installDom(); const fileContainer = document.createElement('div'); @@ -67,11 +105,16 @@ async function createEditorFixture( disableFileHeader: true, theme: DEFAULT_THEMES, }); - const editor = new Editor(editorOptions); - const initialFile: FileContents = { name: 'edits.ts', contents }; + const editor = new Editor('file', editorOptions, documentKey); + const initialFile: FileContents = { ...fileInfo, contents }; - file.render({ file: initialFile, fileContainer, forceRender: true }); - editor.edit(file); + file.render({ + file: initialFile, + fileContainer, + forceRender: true, + lineAnnotations, + }); + const complete = editor.edit(file); const content = await waitForEditableContent(fileContainer); @@ -81,32 +124,97 @@ async function createEditorFixture( file.cleanUp(); dom.cleanup(); }, + complete, + container: fileContainer, content, editor, file, }; } +function createKeyedEditorFixture( + contents: string, + documentKey: string, + lineAnnotations?: LineAnnotation[], + fileInfo?: Pick +): Promise { + return createEditorFixture( + contents, + undefined, + lineAnnotations, + documentKey, + fileInfo + ); +} + +function createPendingEditorFixture( + contents: string, + documentKey: string +): PendingEditorFixture { + const dom = installDom(); + const fileContainer = document.createElement('div'); + document.body.appendChild(fileContainer); + const file = new File({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + const editor = new Editor('file', {}, documentKey); + file.render({ + file: { name: 'edits.ts', contents }, + fileContainer, + forceRender: true, + }); + const complete = editor.edit(file); + return { + cleanup() { + editor.cleanUp(); + file.cleanUp(); + dom.cleanup(); + }, + complete, + editor, + }; +} + async function createDiffEditorFixture( - diffStyle: 'split' | 'unified' + diffStyle: 'split' | 'unified', + editorOptions?: EditorOptions, + documentKey?: string, + newContents = 'alpha\nnew\ncharlie', + files?: { oldFile: FileContents; newFile: FileContents } ): Promise { const dom = installDom(); const container = document.createElement('div'); document.body.appendChild(container); - const fileDiff = new FileDiff({ + const fileDiff = new TestFileDiff({ disableFileHeader: true, diffStyle, theme: DEFAULT_THEMES, }); - const editor = new Editor(); + const editor = new Editor('file-diff', editorOptions, documentKey); fileDiff.render({ - oldFile: { name: 'edits.ts', contents: 'alpha\nold\ncharlie' }, - newFile: { name: 'edits.ts', contents: 'alpha\nnew\ncharlie' }, + oldFile: + files?.oldFile ?? + ({ + name: 'edits.ts', + contents: 'alpha\nold\ncharlie', + } satisfies FileContents), + newFile: + files?.newFile ?? + ({ name: 'edits.ts', contents: newContents } satisfies FileContents), fileContainer: container, forceRender: true, }); - editor.edit(fileDiff); + let complete: () => void; + try { + complete = editor.edit(fileDiff); + } catch (error) { + editor.cleanUp(); + fileDiff.cleanUp(); + dom.cleanup(); + throw error; + } const content = await waitForEditableContent(container); return { @@ -115,6 +223,7 @@ async function createDiffEditorFixture( fileDiff.cleanUp(); dom.cleanup(); }, + complete, container, content, editor, @@ -199,7 +308,1117 @@ function hoverMarkerLine(content: HTMLElement, oneIndexedLine: number): void { content.dispatchEvent(event); } +describe('component editor attachment', () => { + test('File rejects another editor until the current editor detaches', async () => { + const fixture = await createEditorFixture('alpha\nbravo'); + const replacement = new Editor('file'); + try { + expect(() => replacement.edit(fixture.file)).toThrow( + 'File.__attachEditor: an editor is already attached' + ); + expect(fixture.editor.getFile()).toBeDefined(); + expect(replacement.getFile()).toBeUndefined(); + + fixture.complete(); + replacement.edit(fixture.file); + await waitFor(() => replacement.getFile() !== undefined); + expect(replacement.getFile()?.contents).toBe('alpha\nbravo'); + } finally { + fixture.cleanup(); + } + }); + + test('FileDiff rejects another editor until the current editor detaches', async () => { + const fixture = await createDiffEditorFixture('split'); + const replacement = new Editor('file-diff'); + try { + expect(() => replacement.edit(fixture.fileDiff)).toThrow( + 'FileDiff.__attachEditor: an editor is already attached' + ); + expect(fixture.editor.getFile()).toBeDefined(); + expect(replacement.getFile()).toBeUndefined(); + + fixture.complete(); + replacement.edit(fixture.fileDiff); + await waitFor(() => replacement.getFile() !== undefined); + expect(replacement.getFile()?.contents).toBe('alpha\nnew\ncharlie'); + } finally { + fixture.cleanup(); + } + }); +}); + +describe('Editor document registry surfaces', () => { + for (const documentKind of ['file', 'file-diff'] as const) { + for (const stateKind of ['selection', 'view'] as const) { + test(`${documentKind} restores a clean ${stateKind}-only session`, async () => { + EditStateManager.clearAll(); + const editStateKey = `${documentKind}-${stateKind}-only`; + const createFixture = () => + documentKind === 'file' + ? createKeyedEditorFixture('alpha\nbravo', editStateKey) + : createDiffEditorFixture( + 'split', + undefined, + editStateKey, + 'alpha\nnew\ncharlie' + ); + const first = await createFixture(); + const state = + stateKind === 'selection' + ? { + selections: [ + { + start: { line: 1, character: 2 }, + end: { line: 1, character: 2 }, + direction: 0 as const, + }, + ], + } + : { view: { scrollLeft: 24 } }; + first.editor.setViewState(state); + first.cleanup(); + + const second = await createFixture(); + try { + expect(second.editor.canUndo).toBe(false); + expect(second.editor.getViewState()).toEqual({ + selections: + stateKind === 'selection' ? state.selections : undefined, + view: + stateKind === 'selection' + ? { scrollLeft: 0 } + : { scrollLeft: 24 }, + }); + } finally { + second.cleanup(); + EditStateManager.clearAll(); + } + }); + } + } + + test('complete FileDiff state restores an unkeyed session independently', async () => { + const first = await createDiffEditorFixture('split'); + insertText(first.editor, 1, 3, ' retained'); + const state = first.editor.getEditState(); + first.cleanup(); + let second: DiffEditorFixture | undefined; + + try { + second = await createDiffEditorFixture('split', { initialState: state }); + expect(second.editor.getText()).toBe('alpha\nnew retained\ncharlie'); + expect(second.editor.canUndo).toBe(true); + + second.editor.undo(); + expect(second.editor.getText()).toBe('alpha\nnew\ncharlie'); + } finally { + second?.cleanup(); + } + }); + + test('File restores a keyed draft when its editor is attached before hydrate', async () => { + EditStateManager.clearAll(); + const editStateKey = 'file-hydration'; + const first = await createKeyedEditorFixture( + 'alpha\nbravo\ncharlie', + editStateKey + ); + const prerenderedHTML = first.container.shadowRoot?.innerHTML; + insertText(first.editor, 1, 5, ' retained'); + const retainedFile = first.editor.getFile(); + first.cleanup(); + + const dom = installDom(); + const container = document.createElement('div'); + document.body.appendChild(container); + const file = new TestFile({ + disableFileHeader: true, + theme: DEFAULT_THEMES, + }); + const editor = new Editor('file', {}, editStateKey); + try { + editor.edit(file); + file.hydrate({ + file: { name: 'edits.ts', contents: 'pristine replacement' }, + fileContainer: container, + prerenderedHTML, + }); + + expect(file.getCurrentFile()).toEqual(retainedFile); + expect(editor.getFile()).toEqual(retainedFile); + await waitFor(() => + Boolean(container.shadowRoot?.textContent?.includes('bravo retained')) + ); + expect(container.shadowRoot?.textContent).toContain('bravo retained'); + } finally { + file.cleanUp(); + EditStateManager.clear('file', editStateKey); + dom.cleanup(); + } + }); + + test('FileDiff restores exact keyed session hunks when attached before hydrate', async () => { + EditStateManager.clearAll(); + const editStateKey = 'file-diff-hydration'; + const oldFile = { name: 'edits.ts', contents: 'alpha\nold\ncharlie' }; + const newFile = { name: 'edits.ts', contents: 'alpha\nnew\ncharlie' }; + const first = await createDiffEditorFixture( + 'split', + undefined, + editStateKey, + newFile.contents, + { oldFile, newFile } + ); + const prerenderedHTML = first.container.shadowRoot?.innerHTML; + insertText(first.editor, 1, 3, ' retained'); + await waitFor( + () => + first.fileDiff.getCurrentDiff()?.additionLines.join('') === + first.editor.getText() + ); + const retainedFile = first.editor.getFile(); + const retainedHunks = structuredClone( + first.fileDiff.getCurrentDiff()?.hunks + ); + first.cleanup(); + + const dom = installDom(); + const container = document.createElement('div'); + document.body.appendChild(container); + const fileDiff = new TestFileDiff({ + disableFileHeader: true, + diffStyle: 'split', + theme: DEFAULT_THEMES, + }); + const editor = new Editor('file-diff', {}, editStateKey); + const externalDiff = parseDiffFromFile(oldFile, newFile); + try { + editor.edit(fileDiff); + fileDiff.hydrate({ + fileDiff: externalDiff, + fileContainer: container, + prerenderedHTML, + }); + + expect(editor.getFile()).toEqual(retainedFile); + expect(fileDiff.getCurrentDiff()?.hunks).toEqual(retainedHunks); + expect(fileDiff.getCurrentDiff()).not.toBe(externalDiff); + await waitFor(() => + Boolean(container.shadowRoot?.textContent?.includes('new retained')) + ); + expect(container.shadowRoot?.textContent).toContain('new retained'); + } finally { + fileDiff.cleanUp(); + EditStateManager.clear('file-diff', editStateKey); + dom.cleanup(); + } + }); + + test('a retained document repaints a File surface', async () => { + EditStateManager.clearAll(); + const documentKey = 'file-surface'; + const first = await createKeyedEditorFixture( + 'alpha\nbravo\ncharlie', + documentKey + ); + insertText(first.editor, 1, 5, ' retained'); + const editedText = first.editor.getText(); + first.cleanup(); + + const second = await createKeyedEditorFixture( + 'new external baseline', + documentKey + ); + try { + expect(second.editor.getText()).toBe(editedText); + await waitFor(() => + Boolean( + second.container.shadowRoot?.textContent?.includes('bravo retained') + ) + ); + expect(second.container.shadowRoot?.textContent).toContain( + 'bravo retained' + ); + second.file.render({ + file: { name: 'edits.ts', contents: 'external replacement' }, + fileContainer: second.container, + forceRender: true, + }); + await waitFor(() => second.editor.getText() === 'external replacement'); + second.editor.undo(); + expect(second.editor.getText()).toBe(editedText); + second.editor.undo(); + expect(second.editor.getText()).toBe('alpha\nbravo\ncharlie'); + } finally { + second.cleanup(); + EditStateManager.clearAll(); + } + }); + + test('a File adopts retained name and explicit language with its text', async () => { + EditStateManager.clearAll(); + const documentKey = 'file-identity'; + const retainedFile = { + name: 'retained.txt', + lang: 'typescript', + } as const; + const first = await createKeyedEditorFixture( + 'const retained = true;', + documentKey, + undefined, + retainedFile + ); + insertText(first.editor, 0, 22, '\n'); + const retainedContents = first.editor.getText(); + first.cleanup(); + const retainedHistory = EditStateManager.get( + 'file', + documentKey + )!.document.history; + + let second: EditorFixture | undefined; + try { + second = await createKeyedEditorFixture( + 'incoming baseline', + documentKey, + undefined, + { name: 'incoming.txt', lang: 'text' } + ); + expect(second.editor.getEditState()!.document.history.undoStack).toBe( + retainedHistory.undoStack + ); + expect(second.editor.getFile()).toEqual({ + ...retainedFile, + contents: retainedContents, + }); + expect(second.content.ariaLabel).toBe(retainedFile.name); + second.file.render({ + file: { + ...retainedFile, + contents: 'const external = true;', + }, + fileContainer: second.container, + forceRender: true, + }); + await waitFor( + () => second?.editor.getText() === 'const external = true;' + ); + second.editor.undo(); + expect(second.editor.getText()).toBe(retainedContents); + await waitFor( + () => (second?.content.querySelectorAll('span[style]').length ?? 0) > 0 + ); + second.editor.undo(); + expect(second.editor.getText()).toBe('const retained = true;'); + second.editor.redo(); + expect(second.editor.getText()).toBe(retainedContents); + second.editor.redo(); + expect(second.editor.getText()).toBe('const external = true;'); + } finally { + second?.cleanup(); + EditStateManager.clearAll(); + } + }); + + test('a File retains annotation history without diff sides', async () => { + EditStateManager.clearAll(); + const documentKey = 'file-annotations'; + const first = await createKeyedEditorFixture('alpha\nbravo', documentKey, [ + { lineNumber: 2, metadata: undefined }, + ]); + insertText(first.editor, 0, 0, 'inserted\n'); + first.cleanup(); + + const second = await createKeyedEditorFixture('alpha\nbravo', documentKey, [ + { lineNumber: 3, metadata: undefined }, + ]); + try { + expect(second.editor.getText()).toBe('inserted\nalpha\nbravo'); + second.editor.undo(); + expect(second.editor.getText()).toBe('alpha\nbravo'); + } finally { + second.cleanup(); + EditStateManager.clearAll(); + } + }); + + test('File and FileDiff retain independent documents for the same key', async () => { + EditStateManager.clearAll(); + const documentKey = 'shared-surface-key'; + const fileBaseline = 'plain file contents'; + const diffBaseline = 'alpha\nnew\ncharlie'; + const firstFile = await createKeyedEditorFixture(fileBaseline, documentKey); + insertText(firstFile.editor, 0, fileBaseline.length, ' retained'); + const retainedFileText = firstFile.editor.getText(); + + const firstDiff = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + diffBaseline + ); + expect(firstDiff.editor.getText()).toBe(diffBaseline); + insertText(firstDiff.editor, 1, 3, ' retained'); + const retainedDiffText = firstDiff.editor.getText(); + firstDiff.cleanup(); + firstFile.cleanup(); + + const resumedFile = await createKeyedEditorFixture( + 'incoming file contents', + documentKey + ); + expect(resumedFile.editor.getText()).toBe(retainedFileText); + resumedFile.cleanup(); + + const resumedDiff = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + diffBaseline + ); + expect(resumedDiff.editor.getText()).toBe(retainedDiffText); + resumedDiff.cleanup(); + + expect(EditStateManager.clear('file', documentKey)).toBe(true); + expect(EditStateManager.clear('file-diff', documentKey)).toBe(true); + + const freshFile = await createKeyedEditorFixture( + 'fresh file contents', + documentKey + ); + expect(freshFile.editor.getText()).toBe('fresh file contents'); + freshFile.cleanup(); + + const freshDiff = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + diffBaseline + ); + try { + expect(freshDiff.editor.getText()).toBe(diffBaseline); + expect(freshDiff.editor.canUndo).toBe(false); + } finally { + freshDiff.cleanup(); + EditStateManager.clearAll(); + } + }); + + test('does not clear history from an active editor', async () => { + EditStateManager.clearAll(); + const documentKey = 'clear-active-history'; + const fixture = await createKeyedEditorFixture('alpha', documentKey); + + try { + insertText(fixture.editor, 0, 5, '!'); + const editedText = fixture.editor.getText(); + const document = fixture.editor.getEditState()!.document; + expect(fixture.editor.canUndo).toBe(true); + + expect( + EditStateManager.clear('file', documentKey, { history: true }) + ).toBe(false); + expect(fixture.editor.getText()).toBe(editedText); + expect(fixture.editor.getEditState()!.document).toBe(document); + expect(fixture.editor.canUndo).toBe(true); + expect(fixture.editor.canRedo).toBe(false); + + insertText(fixture.editor, 0, 0, '>'); + fixture.editor.undo(); + expect(fixture.editor.getText()).toBe(editedText); + expect(fixture.editor.canUndo).toBe(true); + } finally { + fixture.cleanup(); + EditStateManager.clearAll(); + } + }); + + test('a never-edited FileDiff does not create retained state', async () => { + EditStateManager.clearAll(); + const documentKey = 'clean-diff'; + const first = await createDiffEditorFixture( + 'split', + undefined, + documentKey + ); + first.cleanup(); + + expect(EditStateManager.clear('file-diff', documentKey)).toBe(false); + + const second = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + 'fresh incoming diff' + ); + try { + expect(second.editor.getText()).toBe('fresh incoming diff'); + expect(second.editor.canUndo).toBe(false); + } finally { + second.cleanup(); + EditStateManager.clearAll(); + } + }); + + test('a retained document repaints a FileDiff surface', async () => { + EditStateManager.clearAll(); + const documentKey = 'diff-surface'; + const first = await createDiffEditorFixture( + 'split', + undefined, + documentKey + ); + const originalText = first.editor.getText(); + insertText(first.editor, 1, 3, ' retained'); + const editedText = first.editor.getText(); + first.cleanup(); + + const second = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + 'new external baseline' + ); + try { + expect(second.editor.getText()).toBe(editedText); + await waitFor(() => + Boolean( + second.container.shadowRoot?.textContent?.includes('new retained') + ) + ); + expect(second.container.shadowRoot?.textContent).toContain( + 'new retained' + ); + second.fileDiff.render({ + oldFile: { name: 'edits.ts', contents: 'alpha\nold\ncharlie' }, + newFile: { name: 'edits.ts', contents: 'external replacement' }, + fileContainer: second.container, + forceRender: true, + }); + await waitFor(() => second.editor.getText() === 'external replacement'); + second.editor.undo(); + expect(second.editor.getText()).toBe(editedText); + second.editor.undo(); + expect(second.editor.getText()).toBe(originalText); + } finally { + second.cleanup(); + EditStateManager.clearAll(); + } + }); + + test('a FileDiff adopts retained addition identity with a compatible old side', async () => { + EditStateManager.clearAll(); + const documentKey = 'diff-identity'; + const retainedFile = { + name: 'retained.txt', + lang: 'typescript', + contents: 'const retained = true;\n', + } as const; + const first = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + undefined, + { + oldFile: { + name: 'original-base.txt', + contents: 'const retained = false;\n', + }, + newFile: retainedFile, + } + ); + insertText(first.editor, 0, 22, '// kept\n'); + const retainedContents = first.editor.getText(); + first.cleanup(); + const retainedHistory = EditStateManager.get( + 'file-diff', + documentKey + )!.document.history; + + let second: DiffEditorFixture | undefined; + try { + second = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + undefined, + { + oldFile: { + name: 'original-base.txt', + contents: 'const retained = false;\n', + }, + newFile: { + name: 'incoming.txt', + lang: 'text', + contents: 'incoming new side\n', + }, + } + ); + expect(second.editor.getEditState()!.document.history.undoStack).toBe( + retainedHistory.undoStack + ); + expect(second.editor.getFile()).toEqual({ + name: retainedFile.name, + lang: retainedFile.lang, + contents: retainedContents, + }); + expect(second.content.ariaLabel).toBe(retainedFile.name); + expect( + second.container.shadowRoot?.querySelector( + '[data-code][data-deletions]' + )?.textContent + ).toContain('const retained = false;'); + second.fileDiff.render({ + oldFile: { + name: 'original-base.txt', + contents: 'const retained = false;\n', + }, + newFile: { + name: retainedFile.name, + lang: retainedFile.lang, + contents: 'const external = true;\n', + }, + fileContainer: second.container, + forceRender: true, + }); + await waitFor( + () => second?.editor.getText() === 'const external = true;\n' + ); + second.editor.undo(); + expect(second.editor.getText()).toBe(retainedContents); + await waitFor( + () => (second?.content.querySelectorAll('span[style]').length ?? 0) > 0 + ); + second.editor.undo(); + expect(second.editor.getText()).toBe(retainedFile.contents); + second.editor.redo(); + expect(second.editor.getText()).toBe(retainedContents); + second.editor.redo(); + expect(second.editor.getText()).toBe('const external = true;\n'); + } finally { + second?.cleanup(); + EditStateManager.clearAll(); + } + }); + + test('a FileDiff rejects retained edits against a different old file', async () => { + EditStateManager.clearAll(); + const documentKey = 'diff-incompatible-old-file'; + const first = await createDiffEditorFixture( + 'split', + undefined, + documentKey + ); + insertText(first.editor, 1, 3, ' retained'); + first.cleanup(); + + try { + expect( + createDiffEditorFixture( + 'split', + undefined, + documentKey, + 'alpha\nnew\ncharlie', + { + oldFile: { + name: 'edits.ts', + contents: 'different old side', + }, + newFile: { + name: 'edits.ts', + contents: 'alpha\nnew\ncharlie', + }, + } + ) + ).rejects.toThrow( + 'FileDiff: retained session cannot resume against a different old file' + ); + + const resumed = await createDiffEditorFixture( + 'split', + undefined, + documentKey + ); + try { + expect(resumed.editor.getText()).toBe('alpha\nnew retained\ncharlie'); + expect(resumed.editor.canUndo).toBe(true); + } finally { + resumed.cleanup(); + } + } finally { + EditStateManager.clearAll(); + } + }); + + test('a clean external replacement drops previously retained diff state', async () => { + EditStateManager.clearAll(); + const documentKey = 'diff-clean-replacement'; + const first = await createDiffEditorFixture( + 'split', + undefined, + documentKey + ); + insertText(first.editor, 1, 3, ' retained'); + first.cleanup(); + + const second = await createDiffEditorFixture( + 'split', + undefined, + documentKey + ); + second.fileDiff.render({ + oldFile: { name: 'edits.ts', contents: 'replacement old side' }, + newFile: { name: 'edits.ts', contents: 'replacement new side' }, + fileContainer: second.container, + forceRender: true, + }); + await waitFor(() => second.editor.getText() === 'replacement new side'); + expect(second.editor.canUndo).toBe(false); + expect(second.editor.canRedo).toBe(false); + second.cleanup(); + + expect(EditStateManager.clear('file-diff', documentKey)).toBe(false); + + const fresh = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + 'fresh incoming diff' + ); + try { + expect(fresh.editor.getText()).toBe('fresh incoming diff'); + expect(fresh.editor.canUndo).toBe(false); + } finally { + fresh.cleanup(); + EditStateManager.clearAll(); + } + }); + + test('a compatible external replacement retains its undo history', async () => { + EditStateManager.clearAll(); + const documentKey = 'diff-compatible-replacement'; + const first = await createDiffEditorFixture( + 'split', + undefined, + documentKey + ); + first.fileDiff.render({ + oldFile: { name: 'edits.ts', contents: 'alpha\nold\ncharlie' }, + newFile: { name: 'edits.ts', contents: 'replacement contents' }, + fileContainer: first.container, + forceRender: true, + }); + await waitFor(() => first.editor.getText() === 'replacement contents'); + first.cleanup(); + + const resumed = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + 'replacement contents' + ); + try { + expect(resumed.editor.canUndo).toBe(true); + resumed.editor.undo(); + expect(resumed.editor.getText()).toBe('alpha\nnew\ncharlie'); + } finally { + resumed.cleanup(); + EditStateManager.clearAll(); + } + }); + + test('a pending compatible replacement retains the outgoing edits', async () => { + EditStateManager.clearAll(); + const documentKey = 'diff-pending-compatible-replacement'; + const first = await createDiffEditorFixture( + 'split', + undefined, + documentKey + ); + insertText(first.editor, 1, 3, ' retained'); + const retainedText = first.editor.getText(); + first.fileDiff.render({ + oldFile: { name: 'edits.ts', contents: 'alpha\nold\ncharlie' }, + newFile: { name: 'edits.ts', contents: 'replacement contents' }, + fileContainer: first.container, + forceRender: true, + }); + first.cleanup(); + + const resumed = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + 'replacement contents' + ); + try { + expect(resumed.editor.getText()).toBe(retainedText); + expect(resumed.editor.canUndo).toBe(true); + } finally { + resumed.cleanup(); + EditStateManager.clearAll(); + } + }); + + test('discarding a resumed diff restores its external type', async () => { + EditStateManager.clearAll(); + const documentKey = 'diff-type'; + const files = { + oldFile: { name: 'before.ts', contents: 'same contents\n' }, + newFile: { name: 'after.ts', contents: 'changed contents\n' }, + }; + const first = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + undefined, + files + ); + expect(first.fileDiff.getCurrentType()).toBe('rename-changed'); + first.editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { + line: Number.MAX_SAFE_INTEGER, + character: Number.MAX_SAFE_INTEGER, + }, + }, + newText: files.oldFile.contents, + }, + ]); + expect(first.editor.getText()).toBe(files.oldFile.contents); + expect(first.fileDiff.getCurrentType()).toBe('rename-changed'); + first.cleanup(); + + const second = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + undefined, + files + ); + try { + expect(second.editor.getText()).toBe(files.oldFile.contents); + expect(second.fileDiff.getCurrentType()).toBe('rename-changed'); + + second.editor.cleanUp(); + expect(second.fileDiff.getCurrentType()).toBe('rename-changed'); + } finally { + second.cleanup(); + EditStateManager.clearAll(); + } + }); + + for (const [mismatch, replacement] of [ + ['name', { name: 'renamed.ts', contents: 'const replacement = true;' }], + [ + 'language', + { + name: 'retained.ts', + lang: 'javascript', + contents: 'const replacement = true;', + }, + ], + ] as const) { + test(`a keyed File ${mismatch} update starts fresh history`, async () => { + EditStateManager.clearAll(); + const documentKey = `file-incompatible-${mismatch}`; + const fileInfo = { name: 'retained.ts', lang: 'typescript' } as const; + const first = await createKeyedEditorFixture( + 'const original = true;', + documentKey, + undefined, + fileInfo + ); + insertText(first.editor, 0, 0, '// retained\n'); + first.cleanup(); + + const second = await createKeyedEditorFixture( + 'incoming baseline', + documentKey, + undefined, + fileInfo + ); + try { + second.file.render({ + file: replacement, + fileContainer: second.container, + forceRender: true, + }); + await waitFor(() => second.editor.getText() === replacement.contents); + expect(second.editor.canUndo).toBe(false); + expect(second.editor.canRedo).toBe(false); + } finally { + second.cleanup(); + EditStateManager.clearAll(); + } + }); + } + + for (const decision of ['accept', 'reject'] as const) { + test(`a keyed File ${decision} completion retains history until disposal`, async () => { + EditStateManager.clearAll(); + const documentKey = `file-completion-${decision}`; + const baseline = 'const original = true;'; + const first = await createKeyedEditorFixture(baseline, documentKey); + first.file.setOptions({ + ...first.file.options, + onEditComplete: () => decision, + }); + insertText(first.editor, 0, 0, `// ${decision}\n`); + const editedText = first.editor.getText(); + first.complete(); + first.cleanup(); + + const resumed = await createKeyedEditorFixture(baseline, documentKey); + expect(resumed.editor.getText()).toBe(editedText); + resumed.editor.undo(); + expect(resumed.editor.getText()).toBe(baseline); + resumed.editor.redo(); + expect(resumed.editor.getText()).toBe(editedText); + resumed.cleanup(); + + expect(EditStateManager.clear('file', documentKey)).toBe(true); + const fresh = await createKeyedEditorFixture(baseline, documentKey); + try { + expect(fresh.editor.getText()).toBe(baseline); + expect(fresh.editor.canUndo).toBe(false); + expect(fresh.editor.canRedo).toBe(false); + } finally { + fresh.cleanup(); + EditStateManager.clearAll(); + } + }); + + test(`a keyed FileDiff ${decision} completion retains history until disposal`, async () => { + EditStateManager.clearAll(); + const documentKey = `diff-completion-${decision}`; + const baseline = 'alpha\nnew\ncharlie'; + const first = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + baseline + ); + first.fileDiff.setOptions({ + ...first.fileDiff.options, + onEditComplete: () => decision, + }); + insertText(first.editor, 1, 3, ` ${decision}`); + const editedText = first.editor.getText(); + first.complete(); + first.cleanup(); + + const resumed = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + baseline + ); + expect(resumed.editor.getText()).toBe(editedText); + resumed.editor.undo(); + expect(resumed.editor.getText()).toBe(baseline); + resumed.editor.redo(); + expect(resumed.editor.getText()).toBe(editedText); + resumed.cleanup(); + + expect(EditStateManager.clear('file-diff', documentKey)).toBe(true); + const fresh = await createDiffEditorFixture( + 'split', + undefined, + documentKey, + baseline + ); + try { + expect(fresh.editor.getText()).toBe(baseline); + expect(fresh.editor.canUndo).toBe(false); + expect(fresh.editor.canRedo).toBe(false); + } finally { + fresh.cleanup(); + EditStateManager.clearAll(); + } + }); + } + + test('completion before initial sync retains a claimed document', async () => { + EditStateManager.clearAll(); + const documentKey = 'pending-completion'; + const first = await createKeyedEditorFixture('alpha\nbravo', documentKey); + insertText(first.editor, 1, 5, ' retained'); + first.cleanup(); + + const pending = createPendingEditorFixture('alpha\nbravo', documentKey); + pending.complete(); + pending.cleanup(); + + const fresh = await createKeyedEditorFixture('alpha\nbravo', documentKey); + try { + expect(fresh.editor.getText()).toBe('alpha\nbravo retained'); + expect(fresh.editor.canUndo).toBe(true); + } finally { + fresh.cleanup(); + EditStateManager.clearAll(); + } + }); + + test('clearing a pending adoption is ignored while active', async () => { + EditStateManager.clearAll(); + const documentKey = 'pending-disposal'; + const first = await createKeyedEditorFixture('alpha\nbravo', documentKey); + insertText(first.editor, 1, 5, ' retained'); + const retainedText = first.editor.getText(); + first.cleanup(); + + const pending = createPendingEditorFixture('alpha\nbravo', documentKey); + expect(EditStateManager.clear('file', documentKey)).toBe(false); + await waitFor(() => pending.editor.getText() === retainedText); + expect(pending.editor.getText()).toBe(retainedText); + pending.cleanup(); + + const fresh = await createKeyedEditorFixture('alpha\nbravo', documentKey); + try { + expect(fresh.editor.getText()).toBe(retainedText); + expect(fresh.editor.canUndo).toBe(true); + } finally { + fresh.cleanup(); + EditStateManager.clearAll(); + } + }); +}); + describe('Editor state round trip', () => { + test('restores a JSON-persisted file and editor state with fresh history', async () => { + const first = await createEditorFixture('alpha\nbravo'); + let serializedDraft: string; + try { + insertText(first.editor, 1, 5, ' charlie'); + first.editor.setViewState({ + selections: [ + { + start: { line: 1, character: 13 }, + end: { line: 1, character: 13 }, + direction: 0, + }, + ], + view: { scrollLeft: 24 }, + }); + expect(first.editor.canUndo).toBe(true); + + serializedDraft = JSON.stringify({ + file: first.editor.getFile(), + editorState: first.editor.getViewState(), + }); + } finally { + first.cleanup(); + } + + const persisted = JSON.parse(serializedDraft) as { + file: FileContents; + editorState: EditorViewState; + }; + const initialDocument = new TextDocument( + persisted.file.name, + persisted.file.contents, + persisted.file.lang ?? getFiletypeFromFileName(persisted.file.name) + ); + let restoredState = false; + const restored = await createEditorFixture( + persisted.file.contents, + { + initialState: { + documentKind: 'file', + document: initialDocument, + fileInfo: { + name: persisted.file.name, + lang: persisted.file.lang, + }, + editor: persisted.editorState, + }, + onAttach() { + restoredState = true; + }, + }, + undefined, + undefined, + persisted.file + ); + try { + await waitFor(() => restoredState); + expect(restored.editor.getEditState()?.document).toBe(initialDocument); + expect(restored.editor.getFile()).toEqual(persisted.file); + expect(restored.editor.getViewState()).toEqual(persisted.editorState); + expect(restored.editor.canUndo).toBe(false); + expect(restored.editor.canRedo).toBe(false); + } finally { + restored.cleanup(); + } + }); + + test('completes partial FileDiff initialState from the attached diff', async () => { + const editorState: EditorViewState = { + selections: [ + { + start: { line: 1, character: 2 }, + end: { line: 1, character: 2 }, + direction: 0, + }, + ], + view: { scrollLeft: 24 }, + }; + const restored = await createDiffEditorFixture('split', { + initialState: { + documentKind: 'file-diff', + editor: editorState, + }, + }); + + try { + expect(restored.editor.getViewState()).toEqual(editorState); + expect(restored.editor.getEditState()).toMatchObject({ + documentKind: 'file-diff', + fileInfo: { name: 'edits.ts' }, + }); + expect(restored.editor.getEditState()?.diffSession).toBeDefined(); + expect(restored.editor.getText()).toBe('alpha\nnew\ncharlie'); + expect(restored.editor.canUndo).toBe(false); + } finally { + restored.cleanup(); + } + }); + + test('adopts a partial initialState document and builds its missing fields', async () => { + const first = await createEditorFixture('alpha\nbravo'); + insertText(first.editor, 1, 5, ' charlie'); + const document = first.editor.getEditState()!.document; + first.cleanup(); + + const restored = await createEditorFixture('fallback', { + initialState: { + documentKind: 'file', + document, + }, + }); + + try { + expect(restored.editor.getEditState()?.document).toBe(document); + expect(restored.editor.getText()).toBe('alpha\nbravo charlie'); + expect(restored.editor.getFile()).toEqual({ + name: 'edits.ts', + lang: undefined, + contents: 'alpha\nbravo charlie', + }); + expect(restored.editor.getViewState()).toEqual({ + selections: undefined, + view: { scrollLeft: 0 }, + }); + expect(restored.editor.canUndo).toBe(true); + } finally { + restored.cleanup(); + } + }); + test('setState restores selections without rebuilding the document or dropping undo history', async () => { const { cleanup, editor } = await createEditorFixture( 'alpha\nbravo\ncharlie' @@ -223,7 +1442,7 @@ describe('Editor state round trip', () => { direction: 'forward', }, ]); - const state = editor.getState(); + const state = editor.getViewState(); // Move the caret elsewhere, then restore the captured state. editor.setSelections([ @@ -233,9 +1452,9 @@ describe('Editor state round trip', () => { direction: 'none', }, ]); - editor.setState(state); + editor.setViewState(state); - expect(editor.getState().selections).toEqual(state.selections); + expect(editor.getViewState().selections).toEqual(state.selections); // getState/setState carry no cacheKey, so restoring state neither rebuilds // the document nor discards its undo history. expect(editor.canUndo).toBe(true); @@ -371,7 +1590,7 @@ describe('Editor focus lifecycle', () => { preventScroll: true, }; editor.focus(firstTarget); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 1, character: 0 }, end: { line: 1, character: 0 }, @@ -380,7 +1599,7 @@ describe('Editor focus lifecycle', () => { ]); editor.focus({ lineNumber: 99, character: 99, preventScroll: true }); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 2, character: 7 }, end: { line: 2, character: 7 }, @@ -393,7 +1612,7 @@ describe('Editor focus lifecycle', () => { character: Number.NaN, preventScroll: true, }); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 0 }, end: { line: 0, character: 0 }, @@ -421,10 +1640,10 @@ describe('Editor focus lifecycle', () => { }); test('targeted focus before attachment is a no-op', () => { - const editor = new Editor(); + const editor = new Editor('file'); try { editor.focus({ lineNumber: 2 }); - expect(editor.getState().selections).toBeUndefined(); + expect(editor.getViewState().selections).toBeUndefined(); } finally { editor.cleanUp(); } @@ -449,7 +1668,7 @@ describe('Editor focus lifecycle', () => { character: 4, preventScroll: true, }); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 1, character: 0 }, end: { line: 1, character: 0 }, @@ -477,7 +1696,7 @@ describe('Editor focus lifecycle', () => { setRect(rows[2], 39, 59); editor.focus({ lineNumber: 'first-visible', preventScroll: true }); - expect(editor.getState().selections?.[0]?.start).toEqual({ + expect(editor.getViewState().selections?.[0]?.start).toEqual({ line: 1, character: 0, }); @@ -505,7 +1724,7 @@ describe('Editor focus lifecycle', () => { setRect(rows[2], 40, 60); editor.focus({ lineNumber: 'first-visible', preventScroll: true }); - expect(editor.getState().selections?.[0]?.start).toEqual({ + expect(editor.getViewState().selections?.[0]?.start).toEqual({ line: 1, character: 0, }); @@ -555,7 +1774,7 @@ describe('Editor focus lifecycle', () => { preventScroll: true, offset: 10, }); - expect(editor.getState().selections?.[0]?.start.line).toBe(2); + expect(editor.getViewState().selections?.[0]?.start.line).toBe(2); } finally { cleanup(); } @@ -568,13 +1787,13 @@ describe('Editor focus lifecycle', () => { try { editor.focus({ lineNumber: 2, preventScroll: true }); await wait(0); - const selections = editor.getState().selections; + const selections = editor.getViewState().selections; const button = document.createElement('button'); document.body.appendChild(button); button.focus(); editor.focus({ lineNumber: 'first-visible', preventScroll: true }); - expect(editor.getState().selections).toEqual(selections); + expect(editor.getViewState().selections).toEqual(selections); expect(document.activeElement).toBe(button); const viewport = document.createElement('div'); @@ -586,7 +1805,7 @@ describe('Editor focus lifecycle', () => { setRect(rows[2], 15, 25); editor.focus({ lineNumber: 'first-visible', preventScroll: true }); - expect(editor.getState().selections).toEqual(selections); + expect(editor.getViewState().selections).toEqual(selections); expect(document.activeElement).toBe(button); } finally { cleanup(); @@ -622,7 +1841,7 @@ describe('Editor focus lifecycle', () => { setRect(trailingContext!, 30, 50); editor.focus({ lineNumber: 'first-visible', preventScroll: true }); - expect(editor.getState().selections?.[0]?.start.line).toBe(2); + expect(editor.getViewState().selections?.[0]?.start.line).toBe(2); } finally { cleanup(); } @@ -655,7 +1874,7 @@ describe('Editor focus lifecycle', () => { setRect(target!, 30, 50); editor.focus({ lineNumber: 'first-visible', preventScroll: true }); - expect(editor.getState().selections?.[0]?.start.line).toBe(2); + expect(editor.getViewState().selections?.[0]?.start.line).toBe(2); } finally { cleanup(); } diff --git a/packages/diffs/test/editorRecycle.test.ts b/packages/diffs/test/editorRecycle.test.ts index 2f0c98904..2bdf6f029 100644 --- a/packages/diffs/test/editorRecycle.test.ts +++ b/packages/diffs/test/editorRecycle.test.ts @@ -1,8 +1,10 @@ -import { describe, expect, mock, test } from 'bun:test'; +import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'; import { Editor } from '../src/editor/editor'; +import { EditStateManager } from '../src/editor/EditStateManager'; import { queueRender } from '../src/managers/UniversalRenderingManager'; import type { + CapturedDiffSessionState, DiffLineAnnotation, DiffsEditableComponent, DiffsEditor, @@ -26,7 +28,7 @@ function createTestHighlighter(): DiffsHighlighter { } class TestEditableComponent implements DiffsEditableComponent { - readonly type = 'file' as const; + readonly type: 'file' | 'file-diff'; readonly top = 0; readonly fileContainer = document.createElement('div'); options: DiffsEditableComponent['options'] = { @@ -39,20 +41,27 @@ class TestEditableComponent implements DiffsEditableComponent { #lineAnnotations?: DiffLineAnnotation[]; #renderRange?: RenderRange; #queueRerender: boolean; + #syncOnAttach: boolean; #onContentFocus?: (content: HTMLElement) => void; constructor( file: FileContents, { queueRerender = false, + syncOnAttach = true, onContentFocus, + type = 'file', }: { queueRerender?: boolean; + syncOnAttach?: boolean; onContentFocus?: (content: HTMLElement) => void; + type?: 'file' | 'file-diff'; } = {} ) { + this.type = type; this.#file = file; this.#queueRerender = queueRerender; + this.#syncOnAttach = syncOnAttach; this.#onContentFocus = onContentFocus; this.#renderShadowDom(); } @@ -68,6 +77,10 @@ class TestEditableComponent implements DiffsEditableComponent { return contentElement; } + get lineAnnotations(): DiffLineAnnotation[] | undefined { + return this.#lineAnnotations; + } + setOptions(options: Partial['options']>) { this.options = { ...this.options, ...options }; } @@ -80,6 +93,19 @@ class TestEditableComponent implements DiffsEditableComponent { return this.options; } + __captureDocumentSessionState(): CapturedDiffSessionState | undefined { + return this.type === 'file-diff' + ? { + diffSession: { + oldFile: null, + type: 'new', + hunks: [], + }, + hasChanges: true, + } + : undefined; + } + getCodeScrollLeft(): number { return 0; } @@ -116,24 +142,58 @@ class TestEditableComponent implements DiffsEditableComponent { this.#syncRenderView(); } - cleanUp(): void { - this.#editor = undefined; + renderExternalFile( + file: FileContents, + lineAnnotations = this.#lineAnnotations + ): void { + this.#file = file; + this.#lineAnnotations = lineAnnotations; + this.#renderShadowDom(); + this.#syncRenderView(true); + } + + cleanUp(recycle = false): void { + if (!recycle) { + this.#editor = undefined; + } } emitEditChange(): void {} getAnnotationSlotName = getLineAnnotationName; - completeEditSession(): void {} + __completeEditSession( + _editor: DiffsEditor, + _mode: 'install' | 'discard' + ): void {} - attachEditor(editor: DiffsEditor): () => void { + __attachEditor(editor: DiffsEditor): () => void { + if (this.#editor != null) { + throw new Error('TestEditableComponent: an editor is already attached'); + } + const retainedDocument = editor.__getDocumentContents(); + if (retainedDocument != null) { + this.#file = { ...this.#file, ...retainedDocument }; + this.#renderShadowDom(); + } this.#editor = editor; - this.#syncRenderView(); - return () => { - this.#editor = undefined; + if (this.#syncOnAttach) { + this.#syncRenderView(); + } + return (recycle = false) => { + if (!recycle) { + this.#editor = undefined; + } }; } + __resumeEditor(editor: DiffsEditor): void { + if (this.#editor !== editor) { + throw new Error('TestEditableComponent: editor association changed'); + } + this.rerender(); + } + applyDocumentChange( textDocument: DiffsTextDocument, newLineAnnotations?: DiffLineAnnotation[] @@ -150,14 +210,15 @@ class TestEditableComponent implements DiffsEditableComponent { _themeType: 'dark' | 'light' ): void {} - #syncRenderView(): void { + #syncRenderView(externalDocument = false): void { this.#editor?.__syncRenderView({ highlighter: createTestHighlighter(), fileContainer: this.fileContainer, file: this.#file, - externalCacheKey: this.#file.cacheKey, lineAnnotations: this.#lineAnnotations, renderRange: this.#renderRange, + externalDocument, + resetHistory: false, }); } @@ -206,10 +267,73 @@ class TestEditableComponent implements DiffsEditableComponent { } } +class IncompleteDiffEditableComponent extends TestEditableComponent { + override __captureDocumentSessionState(): undefined { + return undefined; + } +} + +class ThrowingEditableComponent extends TestEditableComponent { + shouldThrow = true; + + override rerender(): void { + if (this.shouldThrow) { + throw new Error('attachment failed'); + } + super.rerender(); + } +} + +class SyncingThrowingEditableComponent extends TestEditableComponent { + override __attachEditor(editor: DiffsEditor): () => void { + super.__attachEditor(editor); + throw new Error('attachment failed after sync'); + } +} + +class EditingSyncingThrowingEditableComponent extends TestEditableComponent { + override __attachEditor(editor: DiffsEditor): () => void { + super.__attachEditor(editor); + (editor as Editor).applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'failed:', + }, + ]); + throw new Error('attachment failed after edit'); + } +} + +class ExternalSyncThrowingEditableComponent extends TestEditableComponent { + #replacement: FileContents; + shouldThrow = true; + + constructor(file: FileContents, replacement: FileContents) { + super(file); + this.#replacement = replacement; + } + + override rerender(): void { + if (this.shouldThrow) { + this.renderExternalFile(this.#replacement); + throw new Error('attachment failed after external sync'); + } + super.rerender(); + } +} + const FILE_CONTENTS = 'alpha\nbravo\ncharlie'; -function createFile(): FileContents { - return { name: 'sample.ts', contents: FILE_CONTENTS, lang: 'text' }; +function createFile(overrides: Partial = {}): FileContents { + return { + name: 'sample.ts', + contents: FILE_CONTENTS, + lang: 'text', + ...overrides, + }; } // Insert `text` at the very start of the document, recording undo history. @@ -235,7 +359,7 @@ describe('Editor onAttach lifecycle', () => { const onAttach = mock((attachedEditor: Editor) => { attachedEditor.focus({ preventScroll: true }); }); - const editor = new Editor({ onAttach }); + const editor = new Editor('file', { onAttach }); const component = new TestEditableComponent(createFile(), { queueRerender: true, onContentFocus: (content) => focusTargets.push(content), @@ -266,7 +390,7 @@ describe('Editor onAttach lifecycle', () => { _component: DiffsEditableComponent ) => {} ); - const editor = new Editor({ onAttach }); + const editor = new Editor('file', { onAttach }); const component = new TestEditableComponent(createFile()); try { editor.edit(component); @@ -281,7 +405,6 @@ describe('Editor onAttach lifecycle', () => { highlighter: createTestHighlighter(), fileContainer: component.fileContainer, file, - externalCacheKey: file.cacheKey, lineAnnotations: undefined, renderRange: undefined, }); @@ -308,41 +431,35 @@ describe('Editor onAttach lifecycle', () => { onAttachCompleted++; } ); - const editor = new Editor({ onAttach }); - const first = new TestEditableComponent(createFile()); - let second: TestEditableComponent | undefined; - let third: TestEditableComponent | undefined; + const editor = new Editor('file', { onAttach }); + const component = new TestEditableComponent(createFile()); try { - editor.edit(first); + editor.edit(component); editor.cleanUp('recycle'); - first.cleanUp(); + component.cleanUp(true); await wait(0); expect(onAttach).not.toHaveBeenCalled(); - second = new TestEditableComponent(createFile()); - editor.edit(second); - second.rerender(); - second.rerender(); + editor.edit(component); + component.rerender(); + component.rerender(); await wait(0); expect(onAttach).toHaveBeenCalledTimes(1); expect(onAttachCompleted).toBe(1); - expect(onAttach.mock.calls[0]?.[1]).toBe(second); + expect(onAttach.mock.calls[0]?.[1]).toBe(component); editor.cleanUp('recycle'); - second.cleanUp(); - third = new TestEditableComponent(createFile()); - editor.edit(third); + component.cleanUp(true); + editor.edit(component); await wait(0); expect(onAttach).toHaveBeenCalledTimes(1); expect(onAttachCompleted).toBe(1); } finally { editor.cleanUp(); - first.cleanUp(); - second?.cleanUp(); - third?.cleanUp(); + component.cleanUp(); dom.cleanup(); } }); @@ -359,7 +476,7 @@ describe('Editor onAttach lifecycle', () => { onAttachCompleted++; } ); - const editor = new Editor({ onAttach }); + const editor = new Editor('file', { onAttach }); const first = new TestEditableComponent(createFile()); let second: TestEditableComponent | undefined; let replacementStarted = false; @@ -400,7 +517,7 @@ describe('Editor onAttach lifecycle', () => { _component: DiffsEditableComponent ) => {} ); - const editor = new Editor({ onAttach }); + const editor = new Editor('file', { onAttach }); const first = new TestEditableComponent(createFile()); let second: TestEditableComponent | undefined; try { @@ -425,25 +542,1105 @@ describe('Editor onAttach lifecycle', () => { }); }); +describe('Editor edit-state manager', () => { + beforeEach(() => { + EditStateManager.clearAll(); + EditStateManager.setCapacity(100); + }); + afterEach(() => { + EditStateManager.clearAll(); + EditStateManager.setCapacity(100); + }); + + test('the same key resumes contents and undo history over incoming contents', () => { + const dom = installDom(); + const firstEditor = new Editor('file', {}, 'shared'); + const first = new TestEditableComponent(createFile()); + const secondEditor = new Editor('file', {}, 'shared'); + const second = new TestEditableComponent( + createFile({ contents: 'new external baseline' }) + ); + const thirdEditor = new Editor('file', {}, 'shared'); + const third = new TestEditableComponent(createFile()); + try { + firstEditor.edit(first); + insertAtStart(firstEditor, 'X'); + firstEditor.cleanUp('discard'); + first.cleanUp(); + + secondEditor.edit(second); + expect(secondEditor.getText()).toBe(`X${FILE_CONTENTS}`); + expect(second.contentElement.textContent).toContain('Xalpha'); + expect(secondEditor.canUndo).toBe(true); + + secondEditor.undo(); + expect(secondEditor.getText()).toBe(FILE_CONTENTS); + expect(secondEditor.canRedo).toBe(true); + secondEditor.cleanUp('discard'); + second.cleanUp(); + + thirdEditor.edit(third); + expect(thirdEditor.canRedo).toBe(true); + thirdEditor.redo(); + expect(thirdEditor.getText()).toBe(`X${FILE_CONTENTS}`); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + thirdEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + third.cleanUp(); + dom.cleanup(); + } + }); + + test('no key builds fresh without reading cacheKey', () => { + const dom = installDom(); + const file = createFile(); + Object.defineProperty(file, 'cacheKey', { + get() { + throw new Error('cacheKey should not be read by Editor'); + }, + }); + const firstEditor = new Editor('file'); + const first = new TestEditableComponent(file); + const secondEditor = new Editor('file'); + const second = new TestEditableComponent(createFile()); + try { + firstEditor.edit(first); + insertAtStart(firstEditor, 'X'); + firstEditor.cleanUp('discard'); + first.cleanUp(); + + secondEditor.edit(second); + expect(secondEditor.getText()).toBe(FILE_CONTENTS); + expect(secondEditor.canUndo).toBe(false); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + + test('a different key starts a fresh document', () => { + const dom = installDom(); + const firstEditor = new Editor('file', {}, 'first'); + const first = new TestEditableComponent(createFile()); + const secondEditor = new Editor('file', {}, 'second'); + const second = new TestEditableComponent(createFile()); + try { + firstEditor.edit(first); + insertAtStart(firstEditor, 'X'); + firstEditor.cleanUp('discard'); + first.cleanUp(); + + secondEditor.edit(second); + expect(secondEditor.getText()).toBe(FILE_CONTENTS); + expect(secondEditor.canUndo).toBe(false); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + + for (const [mismatch, changedFile] of [ + ['name', createFile({ name: 'other.ts' })], + ['language', createFile({ lang: 'typescript' })], + ] as const) { + test(`${mismatch} mismatch does not replace a retained document`, () => { + const dom = installDom(); + const firstEditor = new Editor('file', {}, mismatch); + const first = new TestEditableComponent(createFile()); + const secondEditor = new Editor('file', {}, mismatch); + const second = new TestEditableComponent(changedFile); + try { + firstEditor.edit(first); + insertAtStart(firstEditor, 'X'); + firstEditor.cleanUp('discard'); + first.cleanUp(); + + secondEditor.edit(second); + expect(secondEditor.getText()).toBe(`X${FILE_CONTENTS}`); + expect(secondEditor.canUndo).toBe(true); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + } + + for (const reason of ['discard', 'recycle'] as const) { + test(`${reason} keeps a retained document`, () => { + const dom = installDom(); + const firstEditor = new Editor('file', {}, reason); + const first = new TestEditableComponent(createFile()); + const secondEditor = new Editor('file', {}, reason); + const second = new TestEditableComponent(createFile()); + try { + firstEditor.edit(first); + insertAtStart(firstEditor, 'X'); + firstEditor.cleanUp(reason); + first.cleanUp(); + if (reason === 'recycle') { + firstEditor.cleanUp('discard'); + } + + secondEditor.edit(second); + expect(secondEditor.getText()).toBe(`X${FILE_CONTENTS}`); + expect(secondEditor.canUndo).toBe(true); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + } + + test('complete retains the current document and undo history', () => { + const dom = installDom(); + const firstEditor = new Editor('file', {}, 'complete'); + const first = new TestEditableComponent(createFile()); + const secondEditor = new Editor('file', {}, 'complete'); + const second = new TestEditableComponent(createFile()); + try { + const complete = firstEditor.edit(first); + insertAtStart(firstEditor, 'X'); + complete(); + + secondEditor.edit(second); + expect(secondEditor.getText()).toBe(`X${FILE_CONTENTS}`); + expect(secondEditor.canUndo).toBe(true); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + + test('file-diff completion after recycle retains its document and history', () => { + const dom = installDom(); + const firstEditor = new Editor( + 'file-diff', + {}, + 'recycled-diff-complete' + ); + const first = new TestEditableComponent(createFile(), { + type: 'file-diff', + }); + const secondEditor = new Editor( + 'file-diff', + {}, + 'recycled-diff-complete' + ); + const second = new TestEditableComponent(createFile(), { + type: 'file-diff', + }); + try { + const complete = firstEditor.edit(first); + insertAtStart(firstEditor, 'X'); + firstEditor.cleanUp('recycle'); + complete(); + + secondEditor.edit(second); + expect(secondEditor.getText()).toBe(`X${FILE_CONTENTS}`); + expect(secondEditor.canUndo).toBe(true); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + + test('a clean file-diff retains selections with its compatible baseline', () => { + const dom = installDom(); + const firstEditor = new Editor( + 'file-diff', + {}, + 'diff-selections' + ); + const first = new TestEditableComponent(createFile(), { + type: 'file-diff', + }); + const secondEditor = new Editor( + 'file-diff', + {}, + 'diff-selections' + ); + const second = new TestEditableComponent(createFile(), { + type: 'file-diff', + }); + try { + firstEditor.edit(first); + firstEditor.setSelections([ + { + start: { line: 1, character: 2 }, + end: { line: 1, character: 2 }, + direction: 'none', + }, + ]); + firstEditor.cleanUp('discard'); + first.cleanUp(); + + secondEditor.edit(second); + expect(secondEditor.getViewState().selections).toEqual([ + { + start: { line: 1, character: 2 }, + end: { line: 1, character: 2 }, + direction: 0, + }, + ]); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + + test('complete before initial sync retains the keyed document', () => { + const dom = installDom(); + const firstEditor = new Editor('file', {}, 'complete'); + const first = new TestEditableComponent(createFile()); + const completingEditor = new Editor('file', {}, 'complete'); + const mismatched = new TestEditableComponent( + createFile({ name: 'other.ts' }), + { syncOnAttach: false } + ); + const freshEditor = new Editor('file', {}, 'complete'); + const fresh = new TestEditableComponent(createFile()); + try { + firstEditor.edit(first); + insertAtStart(firstEditor, 'X'); + firstEditor.cleanUp('discard'); + first.cleanUp(); + + const complete = completingEditor.edit(mismatched); + complete(); + freshEditor.edit(fresh); + expect(freshEditor.getText()).toBe(`X${FILE_CONTENTS}`); + expect(freshEditor.canUndo).toBe(true); + } finally { + firstEditor.cleanUp(); + completingEditor.cleanUp(); + freshEditor.cleanUp(); + first.cleanUp(); + mismatched.cleanUp(); + fresh.cleanUp(); + dom.cleanup(); + } + }); + + test('clearing a fresh pending attachment is ignored while active', () => { + const dom = installDom(); + const pendingEditor = new Editor('file', {}, 'pending-disposal'); + const pending = new TestEditableComponent(createFile(), { + syncOnAttach: false, + }); + const freshEditor = new Editor('file', {}, 'pending-disposal'); + const fresh = new TestEditableComponent( + createFile({ contents: 'fresh contents' }) + ); + try { + pendingEditor.edit(pending); + expect(EditStateManager.clear('file', 'pending-disposal')).toBe(false); + + pending.rerender(); + expect(pendingEditor.getText()).toBe(FILE_CONTENTS); + pendingEditor.cleanUp('discard'); + pending.cleanUp(); + + freshEditor.edit(fresh); + expect(freshEditor.getText()).toBe(FILE_CONTENTS); + expect(freshEditor.canUndo).toBe(false); + } finally { + pendingEditor.cleanUp(); + freshEditor.cleanUp(); + pending.cleanUp(); + fresh.cleanUp(); + dom.cleanup(); + } + }); + + test('clearAll leaves fresh pending attachments active', () => { + const dom = installDom(); + const pendingEditor = new Editor('file', {}, 'pending-clear'); + const pending = new TestEditableComponent(createFile(), { + syncOnAttach: false, + }); + const freshEditor = new Editor('file', {}, 'pending-clear'); + const fresh = new TestEditableComponent( + createFile({ contents: 'fresh contents' }) + ); + try { + pendingEditor.edit(pending); + EditStateManager.clearAll(); + + pending.rerender(); + expect(pendingEditor.getText()).toBe(FILE_CONTENTS); + pendingEditor.cleanUp('discard'); + pending.cleanUp(); + + freshEditor.edit(fresh); + expect(freshEditor.getText()).toBe(FILE_CONTENTS); + expect(freshEditor.canUndo).toBe(false); + } finally { + pendingEditor.cleanUp(); + freshEditor.cleanUp(); + pending.cleanUp(); + fresh.cleanUp(); + dom.cleanup(); + } + }); + + test('disposeFile and clearDocuments evict retained documents', () => { + const dom = installDom(); + const firstEditor = new Editor('file', {}, 'first'); + const first = new TestEditableComponent(createFile()); + const secondEditor = new Editor('file', {}, 'second'); + const second = new TestEditableComponent(createFile()); + try { + firstEditor.edit(first); + insertAtStart(firstEditor, 'A'); + firstEditor.cleanUp('discard'); + first.cleanUp(); + secondEditor.edit(second); + insertAtStart(secondEditor, 'B'); + secondEditor.cleanUp('discard'); + second.cleanUp(); + + expect(EditStateManager.clear('file', 'first')).toBe(true); + expect(EditStateManager.clear('file', 'first')).toBe(false); + EditStateManager.clearAll(); + + const freshFirst = new Editor('file', {}, 'first'); + const freshFirstComponent = new TestEditableComponent(createFile()); + const freshSecond = new Editor('file', {}, 'second'); + const freshSecondComponent = new TestEditableComponent(createFile()); + freshFirst.edit(freshFirstComponent); + freshSecond.edit(freshSecondComponent); + expect(freshFirst.getText()).toBe(FILE_CONTENTS); + expect(freshSecond.getText()).toBe(FILE_CONTENTS); + freshFirst.cleanUp(); + freshSecond.cleanUp(); + freshFirstComponent.cleanUp(); + freshSecondComponent.cleanUp(); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + + test('getEditState omits retained state without a complete current diff session', () => { + const dom = installDom(); + const sourceEditor = new Editor('file-diff'); + const source = new TestEditableComponent(createFile(), { + type: 'file-diff', + }); + let editor: Editor | undefined; + let incomplete: IncompleteDiffEditableComponent | undefined; + try { + sourceEditor.edit(source); + const initialState = sourceEditor.getEditState(); + expect(initialState).toBeDefined(); + + editor = new Editor('file-diff', { + initialState: initialState!, + }); + incomplete = new IncompleteDiffEditableComponent(createFile(), { + type: 'file-diff', + }); + editor.edit(incomplete); + + expect(editor.getEditState()).toBeUndefined(); + editor.cleanUp('recycle'); + expect(editor.getEditState()).toBeUndefined(); + } finally { + sourceEditor.cleanUp(); + editor?.cleanUp(); + source.cleanUp(); + incomplete?.cleanUp(); + dom.cleanup(); + } + }); + + test('rejects concurrent editors using the same key', () => { + const dom = installDom(); + const firstEditor = new Editor('file', {}, 'shared'); + const first = new TestEditableComponent(createFile()); + const secondEditor = new Editor('file', {}, 'shared'); + const second = new TestEditableComponent(createFile()); + try { + firstEditor.edit(first); + insertAtStart(firstEditor, 'X'); + expect(() => secondEditor.edit(second)).toThrow( + 'editStateKey "shared" is already attached to another editor' + ); + + firstEditor.cleanUp('discard'); + first.cleanUp(); + secondEditor.edit(second); + expect(secondEditor.getText()).toBe(`X${FILE_CONTENTS}`); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + + test('failed recycled reattachment keeps ownership of keyed state', () => { + const dom = installDom(); + const editor = new Editor('file', {}, 'recycled'); + const component = new ThrowingEditableComponent(createFile()); + component.shouldThrow = false; + const competingEditor = new Editor('file', {}, 'recycled'); + const competing = new TestEditableComponent(createFile()); + try { + editor.edit(component); + insertAtStart(editor, 'X'); + editor.cleanUp('recycle'); + component.cleanUp(true); + const retainedUndoStack = EditStateManager.get( + 'file', + 'recycled' + )!.document.history.undoStack; + + component.shouldThrow = true; + expect(() => editor.edit(component)).toThrow('attachment failed'); + expect(() => competingEditor.edit(competing)).toThrow( + 'editStateKey "recycled" is already attached to another editor' + ); + + component.shouldThrow = false; + editor.edit(component); + expect(editor.getText()).toBe(`X${FILE_CONTENTS}`); + expect(editor.getEditState()!.document.history.undoStack).toBe( + retainedUndoStack + ); + } finally { + editor.cleanUp(); + competingEditor.cleanUp(); + component.cleanUp(); + competing.cleanUp(); + dom.cleanup(); + } + }); + + test('an editor cannot attach to a different surface kind', () => { + const dom = installDom(); + const editor = new Editor('file', {}, 'recycled-kind'); + const pendingFile = new TestEditableComponent(createFile(), { + syncOnAttach: false, + }); + const diff = new TestEditableComponent(createFile(), { + type: 'file-diff', + }); + try { + editor.edit(pendingFile); + editor.cleanUp('recycle'); + pendingFile.cleanUp(true); + + expect(() => editor.edit(diff)).toThrow( + 'a file editor cannot edit a file-diff component' + ); + + editor.edit(pendingFile); + expect(editor.getText()).toBe(FILE_CONTENTS); + } finally { + editor.cleanUp(); + pendingFile.cleanUp(); + diff.cleanUp(); + dom.cleanup(); + } + }); + + test('failed recycled external sync keeps the transferred document', () => { + const dom = installDom(); + const editor = new Editor('file', {}, 'recycled'); + const component = new ExternalSyncThrowingEditableComponent( + createFile(), + createFile({ contents: 'external replacement' }) + ); + component.shouldThrow = false; + try { + editor.edit(component); + insertAtStart(editor, 'retained:'); + editor.cleanUp('recycle'); + component.cleanUp(true); + + component.shouldThrow = true; + expect(() => editor.edit(component)).toThrow( + 'attachment failed after external sync' + ); + + component.shouldThrow = false; + editor.edit(component); + expect(editor.getText()).toBe('external replacement'); + editor.undo(); + expect(editor.getText()).toBe(`retained:${FILE_CONTENTS}`); + editor.undo(); + expect(editor.getText()).toBe(FILE_CONTENTS); + } finally { + editor.cleanUp(); + component.cleanUp(); + dom.cleanup(); + } + }); + + test('failed initial attachment rolls back document state and ownership', () => { + const dom = installDom(); + const editor = new Editor('file', {}, 'failed'); + const failing = new SyncingThrowingEditableComponent(createFile()); + const retry = new TestEditableComponent(createFile()); + const competingEditor = new Editor('file', {}, 'failed'); + const competing = new TestEditableComponent(createFile()); + try { + expect(() => editor.edit(failing)).toThrow( + 'attachment failed after sync' + ); + + competingEditor.edit(competing); + expect(() => editor.edit(retry)).toThrow( + 'editStateKey "failed" is already attached to another editor' + ); + + competingEditor.cleanUp('discard'); + competing.cleanUp(); + editor.edit(retry); + expect(editor.getText()).toBe(FILE_CONTENTS); + } finally { + editor.cleanUp(); + competingEditor.cleanUp(); + failing.cleanUp(); + retry.cleanUp(); + competing.cleanUp(); + dom.cleanup(); + } + }); + + test('failed keyed attachment returns the transferred registration', () => { + const dom = installDom(); + const firstEditor = new Editor('file', {}, 'failed'); + const first = new TestEditableComponent(createFile()); + const failingEditor = new Editor('file', {}, 'failed'); + const failing = new EditingSyncingThrowingEditableComponent( + createFile({ name: 'other.ts' }) + ); + const resumedEditor = new Editor('file', {}, 'failed'); + const resumed = new TestEditableComponent(createFile()); + try { + firstEditor.edit(first); + insertAtStart(firstEditor, 'retained:'); + firstEditor.cleanUp('discard'); + first.cleanUp(); + + expect(() => failingEditor.edit(failing)).toThrow( + 'attachment failed after edit' + ); + + resumedEditor.edit(resumed); + expect(resumedEditor.getText()).toBe(`failed:retained:${FILE_CONTENTS}`); + expect(resumedEditor.canUndo).toBe(true); + } finally { + firstEditor.cleanUp(); + failingEditor.cleanUp(); + resumedEditor.cleanUp(); + first.cleanUp(); + failing.cleanUp(); + resumed.cleanUp(); + dom.cleanup(); + } + }); + + test('undo remaps current annotations after a typed history handoff', () => { + const dom = installDom(); + const firstEditor = new Editor('file', {}, 'annotations'); + const first = new TestEditableComponent(createFile()); + const secondEditor = new Editor('file', {}, 'annotations'); + const second = new TestEditableComponent(createFile()); + try { + first.render({ + lineAnnotations: [ + { side: 'additions', lineNumber: 2, metadata: undefined }, + ], + }); + firstEditor.edit(first); + insertAtStart(firstEditor, 'X\n'); + expect(first.lineAnnotations?.[0]?.lineNumber).toBe(3); + const retainedAnnotations = first.lineAnnotations; + firstEditor.cleanUp('discard'); + first.cleanUp(); + + second.render({ lineAnnotations: retainedAnnotations }); + secondEditor.edit(second); + secondEditor.undo(); + expect(secondEditor.getText()).toBe(FILE_CONTENTS); + expect(second.lineAnnotations?.[0]?.lineNumber).toBe(2); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + + test('undo restores an annotation deleted before history handoff', () => { + const dom = installDom(); + const annotation: DiffLineAnnotation = { + side: 'additions', + lineNumber: 2, + metadata: undefined, + }; + const firstEditor = new Editor('file', {}, 'annotations'); + const first = new TestEditableComponent(createFile()); + const secondEditor = new Editor('file', {}, 'annotations'); + const second = new TestEditableComponent(createFile()); + try { + first.render({ lineAnnotations: [annotation] }); + firstEditor.edit(first); + firstEditor.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 2, character: 0 }, + }, + newText: '', + }, + ]); + expect(first.lineAnnotations).toEqual([]); + firstEditor.cleanUp('discard'); + first.cleanUp(); + + second.render({ lineAnnotations: [] }); + secondEditor.edit(second); + secondEditor.undo(); + expect(secondEditor.getText()).toBe(FILE_CONTENTS); + expect(second.lineAnnotations).toEqual([ + { side: 'additions', lineNumber: 2, metadata: undefined }, + ]); + expect(second.lineAnnotations?.[0]).toBe(annotation); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + + test('external replacement during keyed adoption joins retained history', () => { + const dom = installDom(); + const firstEditor = new Editor('file', {}, 'external'); + const first = new TestEditableComponent(createFile()); + const secondEditor = new Editor('file', {}, 'external'); + const second = new TestEditableComponent(createFile(), { + syncOnAttach: false, + }); + try { + firstEditor.edit(first); + insertAtStart(firstEditor, 'retained:'); + const retainedText = firstEditor.getText(); + firstEditor.cleanUp('discard'); + first.cleanUp(); + + secondEditor.edit(second); + second.renderExternalFile( + createFile({ contents: `${retainedText}\nexternal` }), + [{ side: 'additions', lineNumber: 2, metadata: undefined }] + ); + expect(secondEditor.getText()).toBe(`${retainedText}\nexternal`); + secondEditor.undo(); + expect(secondEditor.getText()).toBe(retainedText); + secondEditor.undo(); + expect(secondEditor.getText()).toBe(FILE_CONTENTS); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + + test('a released active document enters the LRU as most recent', () => { + const dom = installDom(); + const retain = (key: string, prefix: string): void => { + const editor = new Editor('file', {}, key); + const component = new TestEditableComponent(createFile()); + editor.edit(component); + insertAtStart(editor, prefix); + editor.cleanUp('discard'); + component.cleanUp(); + }; + const read = (key: string): string => { + const editor = new Editor('file', {}, key); + const component = new TestEditableComponent(createFile()); + editor.edit(component); + const text = editor.getText(); + editor.cleanUp('discard'); + component.cleanUp(); + return text; + }; + const activeEditor = new Editor('file', {}, 'active'); + const active = new TestEditableComponent(createFile()); + try { + activeEditor.edit(active); + insertAtStart(activeEditor, 'A'); + for (let index = 0; index < 99; index++) { + retain(`dormant-${index}`, `${index}:`); + } + + insertAtStart(activeEditor, 'B'); + retain('overflow', 'overflow:'); + activeEditor.cleanUp('discard'); + active.cleanUp(); + + expect(read('active')).toBe(`BA${FILE_CONTENTS}`); + expect(read('dormant-0')).toBe(FILE_CONTENTS); + } finally { + activeEditor.cleanUp(); + active.cleanUp(); + dom.cleanup(); + } + }); + + test('capacity shrink does not evict an active document', () => { + const dom = installDom(); + const retain = (key: string): void => { + const editor = new Editor('file', {}, key); + const component = new TestEditableComponent(createFile()); + editor.edit(component); + editor.cleanUp('discard'); + component.cleanUp(); + }; + const activeEditor = new Editor('file', {}, 'active-shrink'); + const active = new TestEditableComponent(createFile()); + const resumedEditor = new Editor('file', {}, 'active-shrink'); + const resumed = new TestEditableComponent(createFile()); + try { + EditStateManager.setCapacity(3); + activeEditor.edit(active); + insertAtStart(activeEditor, 'active:'); + retain('dormant-first'); + retain('dormant-second'); + retain('dormant-third'); + + EditStateManager.setCapacity(1); + activeEditor.cleanUp('discard'); + active.cleanUp(); + + resumedEditor.edit(resumed); + expect(resumedEditor.getText()).toBe(`active:${FILE_CONTENTS}`); + expect(resumedEditor.canUndo).toBe(true); + } finally { + activeEditor.cleanUp(); + resumedEditor.cleanUp(); + active.cleanUp(); + resumed.cleanUp(); + dom.cleanup(); + } + }); + + test('complete reinserts an active document as most recent', () => { + const dom = installDom(); + const retain = (key: string, prefix: string): void => { + const editor = new Editor('file', {}, key); + const component = new TestEditableComponent(createFile()); + editor.edit(component); + insertAtStart(editor, prefix); + editor.cleanUp('discard'); + component.cleanUp(); + }; + const read = (key: string): string => { + const editor = new Editor('file', {}, key); + const component = new TestEditableComponent(createFile()); + editor.edit(component); + const text = editor.getText(); + editor.cleanUp('discard'); + component.cleanUp(); + return text; + }; + const activeEditor = new Editor('file', {}, 'completed-mru'); + const active = new TestEditableComponent(createFile()); + try { + EditStateManager.setCapacity(2); + const complete = activeEditor.edit(active); + insertAtStart(activeEditor, 'completed:'); + retain('oldest', 'oldest:'); + retain('newer', 'newer:'); + + complete(); + + expect(read('completed-mru')).toBe(`completed:${FILE_CONTENTS}`); + expect(read('newer')).toBe(`newer:${FILE_CONTENTS}`); + expect(read('oldest')).toBe(FILE_CONTENTS); + } finally { + activeEditor.cleanUp(); + active.cleanUp(); + dom.cleanup(); + } + }); + + test('dormant LRU pressure does not evict an active document', () => { + const dom = installDom(); + const activeEditor = new Editor('file', {}, 'active'); + const active = new TestEditableComponent(createFile()); + const competingEditor = new Editor('file', {}, 'active'); + const competing = new TestEditableComponent(createFile()); + try { + activeEditor.edit(active); + insertAtStart(activeEditor, 'active:'); + for (let index = 0; index < 100; index++) { + const editor = new Editor('file', {}, `pressure-${index}`); + const component = new TestEditableComponent(createFile()); + editor.edit(component); + editor.cleanUp('discard'); + component.cleanUp(); + } + + expect(() => competingEditor.edit(competing)).toThrow( + 'editStateKey "active" is already attached to another editor' + ); + activeEditor.cleanUp('discard'); + active.cleanUp(); + + competingEditor.edit(competing); + expect(competingEditor.getText()).toBe(`active:${FILE_CONTENTS}`); + } finally { + activeEditor.cleanUp(); + competingEditor.cleanUp(); + active.cleanUp(); + competing.cleanUp(); + dom.cleanup(); + } + }); + + test('pending adoption survives dormant LRU pressure', () => { + const dom = installDom(); + const retain = (key: string, prefix: string): void => { + const editor = new Editor('file', {}, key); + const component = new TestEditableComponent(createFile()); + editor.edit(component); + insertAtStart(editor, prefix); + editor.cleanUp('discard'); + component.cleanUp(); + }; + const pendingEditor = new Editor('file', {}, 'pending'); + const pending = new TestEditableComponent(createFile(), { + syncOnAttach: false, + }); + const freshEditor = new Editor('file', {}, 'pending'); + const fresh = new TestEditableComponent(createFile()); + try { + retain('pending', 'retained:'); + pendingEditor.edit(pending); + for (let index = 0; index < 100; index++) { + retain(`pressure-${index}`, `${index}:`); + } + + pending.rerender(); + expect(pendingEditor.getText()).toBe(`retained:${FILE_CONTENTS}`); + pendingEditor.cleanUp('discard'); + pending.cleanUp(); + + freshEditor.edit(fresh); + expect(freshEditor.getText()).toBe(`retained:${FILE_CONTENTS}`); + expect(freshEditor.canUndo).toBe(true); + } finally { + pendingEditor.cleanUp(); + freshEditor.cleanUp(); + pending.cleanUp(); + fresh.cleanUp(); + dom.cleanup(); + } + }); + + test('evicts the least-recently-used document past capacity', () => { + const dom = installDom(); + const retain = (key: string, prefix: string): void => { + const editor = new Editor('file', {}, key); + const component = new TestEditableComponent(createFile()); + editor.edit(component); + insertAtStart(editor, prefix); + editor.cleanUp('discard'); + component.cleanUp(); + }; + const read = (key: string): string => { + const editor = new Editor('file', {}, key); + const component = new TestEditableComponent(createFile()); + editor.edit(component); + const text = editor.getText(); + editor.cleanUp('discard'); + component.cleanUp(); + return text; + }; + try { + for (let index = 0; index < 100; index++) { + retain(`key-${index}`, `${index}:`); + } + + expect(read('key-0')).toBe(`0:${FILE_CONTENTS}`); + retain('key-100', '100:'); + + expect(read('key-0')).toBe(`0:${FILE_CONTENTS}`); + expect(read('key-1')).toBe(FILE_CONTENTS); + } finally { + dom.cleanup(); + } + }); + + test('applies manager capacity independently to files and diffs', () => { + const dom = installDom(); + const retain = ( + type: 'file' | 'file-diff', + key: string, + prefix: string + ): void => { + const editor = new Editor(type, {}, key); + const component = new TestEditableComponent(createFile(), { type }); + editor.edit(component); + insertAtStart(editor, prefix); + editor.cleanUp('discard'); + component.cleanUp(); + }; + const read = (type: 'file' | 'file-diff', key: string): string => { + const editor = new Editor(type, {}, key); + const component = new TestEditableComponent(createFile(), { type }); + editor.edit(component); + const text = editor.getText(); + editor.cleanUp('discard'); + component.cleanUp(); + return text; + }; + try { + EditStateManager.setCapacity(1); + retain('file', 'file-key', 'file:'); + retain('file-diff', 'diff-key', 'diff:'); + + expect(read('file', 'file-key')).toBe(`file:${FILE_CONTENTS}`); + expect(read('file-diff', 'diff-key')).toBe(`diff:${FILE_CONTENTS}`); + } finally { + dom.cleanup(); + } + }); + + test('shrinking manager capacity immediately evicts least-recent documents', () => { + const dom = installDom(); + const retain = (key: string, prefix: string): void => { + const editor = new Editor('file', {}, key); + const component = new TestEditableComponent(createFile()); + editor.edit(component); + insertAtStart(editor, prefix); + editor.cleanUp('discard'); + component.cleanUp(); + }; + const read = (key: string): string => { + const editor = new Editor('file', {}, key); + const component = new TestEditableComponent(createFile()); + editor.edit(component); + const text = editor.getText(); + editor.cleanUp('discard'); + component.cleanUp(); + return text; + }; + try { + EditStateManager.setCapacity(3); + retain('first', 'first:'); + retain('second', 'second:'); + retain('third', 'third:'); + expect(read('first')).toBe(`first:${FILE_CONTENTS}`); + + EditStateManager.setCapacity(2); + + expect(read('first')).toBe(`first:${FILE_CONTENTS}`); + expect(read('third')).toBe(`third:${FILE_CONTENTS}`); + expect(read('second')).toBe(FILE_CONTENTS); + } finally { + dom.cleanup(); + } + }); + + test('growing manager capacity preserves documents and accepts more', () => { + const dom = installDom(); + const retain = (key: string, prefix: string): void => { + const editor = new Editor('file', {}, key); + const component = new TestEditableComponent(createFile()); + editor.edit(component); + insertAtStart(editor, prefix); + editor.cleanUp('discard'); + component.cleanUp(); + }; + const read = (key: string): string => { + const editor = new Editor('file', {}, key); + const component = new TestEditableComponent(createFile()); + editor.edit(component); + const text = editor.getText(); + editor.cleanUp('discard'); + component.cleanUp(); + return text; + }; + try { + EditStateManager.setCapacity(1); + retain('first', 'first:'); + EditStateManager.setCapacity(3); + retain('second', 'second:'); + retain('third', 'third:'); + + expect(read('first')).toBe(`first:${FILE_CONTENTS}`); + expect(read('second')).toBe(`second:${FILE_CONTENTS}`); + expect(read('third')).toBe(`third:${FILE_CONTENTS}`); + } finally { + dom.cleanup(); + } + }); + + test('manager capacity must be a positive integer', () => { + for (const capacity of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(() => EditStateManager.setCapacity(capacity)).toThrow( + 'EditStateManager: capacity must be a positive integer' + ); + } + }); +}); + describe('Editor recycle cleanUp', () => { test('recycle keeps document and undo history across re-attach', () => { const dom = installDom(); try { - const editor = new Editor(); + const editor = new Editor('file'); const first = new TestEditableComponent(createFile()); editor.edit(first); insertAtStart(editor, 'X'); expect(editor.getText()).toBe(`X${FILE_CONTENTS}`); // Simulate a virtualized unmount: the host recycles, the editor is - // detached non-destructively. + // unrendered non-destructively. editor.cleanUp('recycle'); - first.cleanUp(); + first.cleanUp(true); // Remount renders from the item's unchanged contents; the retained // document (holding the unsaved edit) must win over host contents. - const second = new TestEditableComponent(createFile()); - editor.edit(second); + editor.edit(first); expect(editor.getText()).toBe(`X${FILE_CONTENTS}`); // Undo history lives in the retained document and survives with it. @@ -461,14 +1658,14 @@ describe('Editor recycle cleanUp', () => { const onAttach = mock((attachedEditor: Editor) => { attachedEditor.focus({ lineNumber: 2, preventScroll: true }); }); - const editor = new Editor({ onAttach }); + const editor = new Editor('file', { onAttach }); const component = new TestEditableComponent(createFile()); try { editor.edit(component); await wait(20); expect(onAttach).toHaveBeenCalledTimes(1); - expect(editor.getState().selections?.[0]?.start.line).toBe(1); + expect(editor.getViewState().selections?.[0]?.start.line).toBe(1); component.contentElement.dispatchEvent(new Event('blur')); component.render({ @@ -502,7 +1699,7 @@ describe('Editor recycle cleanUp', () => { expect(restoredFocus).not.toHaveBeenCalled(); expect(onAttach).toHaveBeenCalledTimes(1); - expect(editor.getState().selections?.[0]?.start.line).toBe(1); + expect(editor.getViewState().selections?.[0]?.start.line).toBe(1); } finally { editor.cleanUp(); component.cleanUp(); @@ -522,7 +1719,7 @@ describe('Editor recycle cleanUp', () => { component.rerender(); component.contentElement.focus = restoredFocus; }); - const editor = new Editor({ onAttach }); + const editor = new Editor('file', { onAttach }); const component = new TestEditableComponent(createFile()); try { editor.edit(component); @@ -540,22 +1737,21 @@ describe('Editor recycle cleanUp', () => { test('recycled re-attach recreates a tokenizer so edits still paint', () => { const dom = installDom(); try { - const editor = new Editor(); + const editor = new Editor('file'); const first = new TestEditableComponent(createFile()); editor.edit(first); editor.cleanUp('recycle'); - first.cleanUp(); + first.cleanUp(true); // Re-attach with an unchanged name/lang/cacheKey skips the document // rebuild. The tokenizer must be recreated anyway, otherwise #rerender // bails and this edit would update the model without painting. - const second = new TestEditableComponent(createFile()); - editor.edit(second); + editor.edit(first); insertAtStart(editor, 'Y'); expect(editor.getText()).toBe(`Y${FILE_CONTENTS}`); - const firstLine = second.contentElement.children[0] as HTMLElement; + const firstLine = first.contentElement.children[0] as HTMLElement; expect(firstLine.textContent).toBe('Yalpha'); editor.cleanUp(); @@ -567,7 +1763,7 @@ describe('Editor recycle cleanUp', () => { test('full cleanUp still rebuilds from host contents', () => { const dom = installDom(); try { - const editor = new Editor(); + const editor = new Editor('file'); const first = new TestEditableComponent(createFile()); editor.edit(first); insertAtStart(editor, 'X'); @@ -600,26 +1796,34 @@ describe('Editor recycle cleanUp', () => { _component: DiffsEditableComponent ) => {} ); - const editor = new Editor({ onAttach }); + const editor = new Editor('file', { onAttach }); const first = new TestEditableComponent(createFile()); editor.edit(first); await wait(0); expect(onAttach).toHaveBeenCalledTimes(1); insertAtStart(editor, 'X'); + editor.setSelections([ + { + start: { line: 1, character: 2 }, + end: { line: 1, character: 2 }, + direction: 'none', + }, + ]); editor.cleanUp('recycle'); - first.cleanUp(); + first.cleanUp(true); - // Different file identity (name) — the retained document must not leak - // into an unrelated file. - const other = new TestEditableComponent({ + // The associated component can receive a different external file while + // unrendered; the retained document must not leak into that replacement. + first.renderExternalFile({ name: 'other.ts', contents: 'zulu', lang: 'text', }); - editor.edit(other); + editor.edit(first); await wait(0); - expect(other.contentElement.textContent).toBe('zulu'); + expect(first.contentElement.textContent).toBe('zulu'); + expect(editor.getViewState().selections).toBeUndefined(); expect(onAttach).toHaveBeenCalledTimes(1); editor.cleanUp(); diff --git a/packages/diffs/test/editorRenderUpdateLifecycle.test.ts b/packages/diffs/test/editorRenderUpdateLifecycle.test.ts index 3d180a981..aac398e27 100644 --- a/packages/diffs/test/editorRenderUpdateLifecycle.test.ts +++ b/packages/diffs/test/editorRenderUpdateLifecycle.test.ts @@ -3,14 +3,12 @@ import { createTwoFilesPatch } from 'diff'; import { disposeHighlighter, - File, FileDiff, parseDiffFromFile, parsePatchFiles, } from '../src'; import { Editor } from '../src/editor/editor'; import type { - FileContents, FileDiffLoadedFiles, FileDiffMetadata, SupportedLanguages, @@ -59,7 +57,6 @@ async function createFixture(options?: { initialType?: FileDiffMetadata['type']; loadDiffFiles?: (fileDiff: FileDiffMetadata) => Promise; onChange?: (contents: string) => void; - persistState?: boolean; }) { const dom = installDom(); const fileContainer = document.createElement('div'); @@ -78,8 +75,7 @@ async function createFixture(options?: { disableFileHeader: true, loadDiffFiles: options?.loadDiffFiles, }); - const editor = new Editor({ - persistState: options?.persistState, + const editor = new Editor('file-diff', { onChange: (event) => options?.onChange?.(event.file.contents), }); @@ -426,52 +422,4 @@ describe('external FileDiff updates during editing', () => { } }); } - - test('a cache-key transition keeps independent persisted documents', async () => { - const fixture = await createFixture({ persistState: true }); - const replacement = createDiff({ - cacheKey: 'session:v2', - newContents: 'charlie\n', - }); - let restoredFile: File | undefined; - const restoredContainer = document.createElement('div'); - document.body.appendChild(restoredContainer); - - try { - replaceDocument(fixture.editor, 'bravo\n'); - await renderReplacement(fixture, replacement, 'charlie\n'); - replaceDocument(fixture.editor, 'delta\n'); - - fixture.editor.cleanUp(); - fixture.instance.cleanUp(); - restoredFile = new File({ - disableErrorHandling: true, - disableFileHeader: true, - }); - const originalFile: FileContents = { - name: 'session.ts', - contents: 'alpha\n', - cacheKey: 'session:v1', - }; - restoredFile.render({ - file: originalFile, - fileContainer: restoredContainer, - forceRender: true, - }); - fixture.editor.edit(restoredFile); - await waitFor(() => fixture.editor.getText() === 'bravo\n', { - timeout: 4_000, - }); - - expect(fixture.editor.getText()).toBe('bravo\n'); - expect(fixture.editor.getText()).not.toBe('delta\n'); - expect(fixture.editor.canUndo).toBe(true); - fixture.editor.undo(); - expect(fixture.editor.getText()).toBe('alpha\n'); - } finally { - fixture.editor.cleanUp(); - restoredFile?.cleanUp(); - fixture.dom.cleanup(); - } - }); }); diff --git a/packages/diffs/test/editorSelection.test.ts b/packages/diffs/test/editorSelection.test.ts index a15d4ae9e..0332f874e 100644 --- a/packages/diffs/test/editorSelection.test.ts +++ b/packages/diffs/test/editorSelection.test.ts @@ -3915,7 +3915,7 @@ async function createEditorFixture(contents: string): Promise { disableFileHeader: true, theme: DEFAULT_THEMES, }); - const editor = new Editor(); + const editor = new Editor('file'); const initialFile: FileContents = { name: 'selections.txt', contents }; file.render({ file: initialFile, fileContainer, forceRender: true }); @@ -3991,28 +3991,28 @@ describe('Editor Alt-drag column selection', () => { pointerType: 'mouse', }) ); - expect(editor.getState().selections).toBeUndefined(); + expect(editor.getViewState().selections).toBeUndefined(); document.dispatchEvent(new Event('selectionchange')); nativeRange = rangeTo(2, 1); document.dispatchEvent(new Event('selectionchange')); - expect(editor.getState().selections).toEqual(selections.slice(0, 2)); + expect(editor.getViewState().selections).toEqual(selections.slice(0, 2)); nativeRange = rangeTo(3, 0); document.dispatchEvent(new Event('selectionchange')); - expect(editor.getState().selections).toEqual(selections.slice(0, 3)); + expect(editor.getViewState().selections).toEqual(selections.slice(0, 3)); nativeRange = rangeTo(4, 0); document.dispatchEvent(new Event('selectionchange')); - expect(editor.getState().selections).toEqual(selections.slice(0, 4)); + expect(editor.getViewState().selections).toEqual(selections.slice(0, 4)); nativeRange = rangeTo(5, 0); document.dispatchEvent(new Event('selectionchange')); - expect(editor.getState().selections).toEqual(selections.slice(0, 5)); + expect(editor.getViewState().selections).toEqual(selections.slice(0, 5)); nativeRange = rangeTo(6, 0); document.dispatchEvent(new Event('selectionchange')); - expect(editor.getState().selections).toEqual(selections.slice(0, 6)); + expect(editor.getViewState().selections).toEqual(selections.slice(0, 6)); // An empty focus line may not produce another native selectionchange, so // pointer movement must update the rectangle on its own. @@ -4022,7 +4022,7 @@ describe('Editor Alt-drag column selection', () => { pointerType: 'mouse', }) ); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ createSelection(1, 2, 1, 4, DirectionForward), caret(2, 1), caret(3, 3), @@ -4037,15 +4037,15 @@ describe('Editor Alt-drag column selection', () => { pointerType: 'mouse', }) ); - expect(editor.getState().selections).toEqual(selections.slice(0, 6)); + expect(editor.getViewState().selections).toEqual(selections.slice(0, 6)); nativeRange = rangeTo(7, 0); document.dispatchEvent(new Event('selectionchange')); - expect(editor.getState().selections).toEqual(selections); + expect(editor.getViewState().selections).toEqual(selections); nativeRange = rangeTo(7, 2); document.dispatchEvent(new Event('selectionchange')); - expect(editor.getState().selections).toEqual(selections); + expect(editor.getViewState().selections).toEqual(selections); // Cancellation tears down both native-selection and pointer tracking. document.dispatchEvent( @@ -4059,7 +4059,7 @@ describe('Editor Alt-drag column selection', () => { ); nativeRange = rangeTo(1, 0); document.dispatchEvent(new Event('selectionchange')); - expect(editor.getState().selections).toEqual(selections); + expect(editor.getViewState().selections).toEqual(selections); } finally { document.getSelection = originalGetSelection; cleanup(); @@ -4087,7 +4087,7 @@ describe('Editor.setSelections position clamping', () => { direction: 'none', }, ]); - expect(editor.getState().selections).toEqual([caret(1, 5)]); + expect(editor.getViewState().selections).toEqual([caret(1, 5)]); // Both line and character overshoot: the primary caret lands exactly at // the document end ("charlie" is line 2, length 7). @@ -4098,7 +4098,7 @@ describe('Editor.setSelections position clamping', () => { direction: 'none', }, ]); - expect(editor.getState().selections).toEqual([caret(2, 7)]); + expect(editor.getViewState().selections).toEqual([caret(2, 7)]); } finally { cleanup(); } @@ -4118,7 +4118,7 @@ describe('Editor.setSelections position clamping', () => { ]); // The valid start edge is untouched; the end edge clamps to doc end and // the direction survives. - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 2 }, end: { line: 2, character: 7 }, @@ -4144,7 +4144,7 @@ describe('Editor.setSelections with a reversed range', () => { direction: 'forward', }, ]); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 0, character: 2 }, end: { line: 1, character: 3 }, diff --git a/packages/diffs/test/editorSelectionAction.test.ts b/packages/diffs/test/editorSelectionAction.test.ts index 0a262e0f0..01dff31b0 100644 --- a/packages/diffs/test/editorSelectionAction.test.ts +++ b/packages/diffs/test/editorSelectionAction.test.ts @@ -64,7 +64,7 @@ async function createSelectionActionFixture( disableFileHeader: true, theme: DEFAULT_THEMES, }); - const editor = new Editor(editorOptions); + const editor = new Editor('file', editorOptions); const initialFile: FileContents = { name: 'edits.ts', contents }; file.render({ @@ -703,7 +703,7 @@ describe('Editor selection action', () => { direction: 'backward', }, ]); - expect(editor.getState().selections?.[0]?.direction).toBe( + expect(editor.getViewState().selections?.[0]?.direction).toBe( DirectionBackward ); // setSelections ends by re-focusing the caret, which sets @@ -732,7 +732,7 @@ describe('Editor selection action', () => { document.dispatchEvent(new Event('selectionchange')); - const primarySelection = editor.getState().selections?.at(-1); + const primarySelection = editor.getViewState().selections?.at(-1); expect(primarySelection?.direction).toBe(DirectionBackward); expect(getCaretPosition(primarySelection!)).toEqual({ line: 0, @@ -769,7 +769,7 @@ describe('Editor selection action', () => { direction: 'backward', }, ]); - expect(editor.getState().selections?.[0]?.direction).toBe( + expect(editor.getViewState().selections?.[0]?.direction).toBe( DirectionBackward ); // Flush setSelections' own re-focus (see the refocus test above) so the @@ -805,7 +805,7 @@ describe('Editor selection action', () => { document.dispatchEvent(new Event('selectionchange')); // Ignored: the pre-composition backward selection must be untouched. - const primarySelection = editor.getState().selections?.at(-1); + const primarySelection = editor.getViewState().selections?.at(-1); expect(primarySelection?.direction).toBe(DirectionBackward); expect(primarySelection?.start).toEqual({ line: 0, character: 0 }); expect(primarySelection?.end).toEqual({ line: 1, character: 0 }); @@ -1005,7 +1005,7 @@ describe('Editor selection action', () => { editor.setMarkers([]); await wait(0); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start, end, direction: DirectionForward }, ]); expect(renderCount).toBe(0); @@ -1038,11 +1038,11 @@ describe('Editor selection action', () => { end: { line: 0, character: 5 }, direction: DirectionForward, }; - editor.setState({ selections: [selection] }); + editor.setViewState({ selections: [selection] }); editor.setMarkers([]); await wait(0); - expect(editor.getState().selections).toEqual([selection]); + expect(editor.getViewState().selections).toEqual([selection]); expect(renderCount).toBe(0); expect( (content.getRootNode() as ShadowRoot).querySelector( diff --git a/packages/diffs/test/editorState.test.ts b/packages/diffs/test/editorState.test.ts index fead989d3..d74e0e871 100644 --- a/packages/diffs/test/editorState.test.ts +++ b/packages/diffs/test/editorState.test.ts @@ -1,17 +1,20 @@ import { describe, expect, test } from 'bun:test'; import { Editor } from '../src/editor/editor'; -import type { IStateStorage } from '../src/editor/stateStorage'; +import { EditStateManager } from '../src/editor/EditStateManager'; +import { TextDocument } from '../src/editor/textDocument'; +import type { EditState } from '../src/editor/types'; import type { DiffLineAnnotation, DiffsEditableComponent, DiffsEditor, DiffsHighlighter, - EditorState, + EditorViewState, FileContents, HighlightedToken, RenderRange, } from '../src/types'; +import { getFiletypeFromFileName } from '../src/utils/getFiletypeFromFileName'; import { getLineAnnotationName } from '../src/utils/getLineAnnotationName'; import { installDom } from './domHarness'; @@ -25,6 +28,22 @@ function createTestHighlighter(): DiffsHighlighter { } as unknown as DiffsHighlighter; } +function createInitialState( + file: FileContents, + editor: EditorViewState = {} +): EditState { + return { + documentKind: 'file', + document: new TextDocument( + file.name, + file.contents, + file.lang ?? getFiletypeFromFileName(file.name) + ), + fileInfo: { name: file.name, lang: file.lang }, + editor, + }; +} + class TestEditableComponent implements DiffsEditableComponent { readonly type = 'file' as const; readonly top = 0; @@ -38,7 +57,12 @@ class TestEditableComponent implements DiffsEditableComponent { #lineAnnotations?: DiffLineAnnotation[]; #renderRange?: RenderRange; codeScrollLeft = 0; + editorViewport: HTMLElement | Document | undefined; restoredCodeScrollLefts: number[] = []; + stateRestoreError: Error | undefined; + attachErrorAfterSync: Error | undefined; + deferAttachSync = false; + completedEditState: EditState | undefined; constructor(private file: FileContents) { this.#renderShadowDom(); @@ -56,15 +80,26 @@ class TestEditableComponent implements DiffsEditableComponent { return this.options; } + __captureDocumentSessionState(): undefined { + return undefined; + } + getCodeScrollLeft(): number { return this.codeScrollLeft; } setCodeScrollLeft(position: number): void { + if (this.stateRestoreError != null) { + throw this.stateRestoreError; + } this.codeScrollLeft = position; this.restoredCodeScrollLefts.push(position); } + getEditorViewport(): HTMLElement | Document | undefined { + return this.editorViewport; + } + render({ file, lineAnnotations, @@ -96,16 +131,33 @@ class TestEditableComponent implements DiffsEditableComponent { getAnnotationSlotName = getLineAnnotationName; - completeEditSession(): void {} + __completeEditSession( + editor: DiffsEditor, + _mode: 'install' | 'discard' + ): void { + this.completedEditState = editor.getEditState(); + } - attachEditor(editor: DiffsEditor): () => void { + __attachEditor(editor: DiffsEditor): () => void { this.#editor = editor; - this.#syncRenderView(); + if (!this.deferAttachSync) { + this.#syncRenderView(); + } + if (this.attachErrorAfterSync != null) { + throw this.attachErrorAfterSync; + } return () => { this.#editor = undefined; }; } + __resumeEditor(editor: DiffsEditor): void { + if (this.#editor !== editor) { + throw new Error('TestEditableComponent: editor association changed'); + } + this.rerender(); + } + applyDocumentChange( _textDocument: unknown, newLineAnnotations?: DiffLineAnnotation[] @@ -123,7 +175,6 @@ class TestEditableComponent implements DiffsEditableComponent { highlighter: createTestHighlighter(), fileContainer: this.fileContainer, file: this.file, - externalCacheKey: this.file.cacheKey, lineAnnotations: this.#lineAnnotations, renderRange: this.#renderRange, }); @@ -155,19 +206,142 @@ class TestEditableComponent implements DiffsEditableComponent { } describe('Editor state', () => { - test('getState captures horizontal state from the code scroller', () => { + test('keyed state restores selections and view in a later editor', () => { const dom = installDom(); - const editor = new Editor(); - const component = new TestEditableComponent({ + EditStateManager.clearAll(); + const first = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha\nbravo', + lang: 'text', + }); + first.editorViewport = document.createElement('div'); + const firstEditor = new Editor( + 'file', + { ownsVerticalViewport: true }, + 'complete-state' + ); + const second = new TestEditableComponent({ name: 'state.ts', contents: 'alpha\nbravo', + lang: 'text', }); + second.editorViewport = document.createElement('div'); + const secondEditor = new Editor( + 'file', + { ownsVerticalViewport: true }, + 'complete-state' + ); + + try { + firstEditor.edit(first); + firstEditor.setViewState({ + selections: [ + { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + direction: 0, + }, + ], + view: { scrollLeft: 18, scrollTop: 36 }, + }); + const detachedState = firstEditor.getViewState(); + firstEditor.cleanUp('discard'); + Object.assign(detachedState.selections![0].start, { + character: 0, + }); + first.cleanUp(); + + secondEditor.edit(second); + expect(secondEditor.getViewState()).toEqual({ + selections: [ + { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + direction: 0, + }, + ], + view: { scrollLeft: 18, scrollTop: 36 }, + }); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + EditStateManager.clearAll(); + dom.cleanup(); + } + }); + + test('initialState restores selections and an owned viewport on first attach', () => { + const dom = installDom(); + const viewport = document.createElement('div'); + const file = { name: 'state.ts', contents: 'alpha\nbravo' }; + const editor = new Editor('file', { + ownsVerticalViewport: true, + initialState: { + documentKind: 'file', + editor: { + selections: [ + { + start: { line: 1, character: 2 }, + end: { line: 1, character: 2 }, + direction: 0, + }, + ], + view: { scrollLeft: 24, scrollTop: 48 }, + }, + }, + }); + const component = new TestEditableComponent(file); + component.editorViewport = viewport; try { editor.edit(component); - component.codeScrollLeft = 24; - expect(editor.getState().view).toEqual({ scrollLeft: 24, scrollTop: 0 }); + expect(editor.getViewState()).toEqual({ + selections: [ + { + start: { line: 1, character: 2 }, + end: { line: 1, character: 2 }, + direction: 0, + }, + ], + view: { scrollLeft: 24, scrollTop: 48 }, + }); + expect(component.restoredCodeScrollLefts).toEqual([24]); + expect(viewport.scrollTop).toBe(48); + expect(editor.getEditState()).toMatchObject({ + documentKind: 'file', + fileInfo: { name: 'state.ts' }, + }); + expect(editor.getEditState()?.document.getText()).toBe('alpha\nbravo'); + + editor.cleanUp('recycle'); + expect(editor.getViewState()).toEqual({ + selections: [ + { + start: { line: 1, character: 2 }, + end: { line: 1, character: 2 }, + direction: 0, + }, + ], + view: { scrollLeft: 24, scrollTop: 48 }, + }); + component.codeScrollLeft = 8; + viewport.scrollTop = 16; + editor.edit(component); + + expect(editor.getViewState()).toEqual({ + selections: [ + { + start: { line: 1, character: 2 }, + end: { line: 1, character: 2 }, + direction: 0, + }, + ], + view: { scrollLeft: 24, scrollTop: 48 }, + }); + expect(component.restoredCodeScrollLefts).toEqual([24, 24]); } finally { editor.cleanUp(); component.cleanUp(); @@ -175,19 +349,31 @@ describe('Editor state', () => { } }); - test('setState restores view state through the editable component', () => { + test('initialState completes missing file state from the attached component', () => { const dom = installDom(); - const editor = new Editor(); const component = new TestEditableComponent({ name: 'state.ts', contents: 'alpha\nbravo', + lang: 'text', }); + const initialState = { + documentKind: 'file' as const, + editor: { view: { scrollLeft: 24 } }, + }; + const editor = new Editor('file', { initialState }); try { editor.edit(component); - editor.setState({ view: { scrollLeft: 24 } }); - expect(component.restoredCodeScrollLefts).toEqual([24]); + const state = editor.getEditState(); + expect(state === initialState).toBe(true); + expect(state).toMatchObject({ + documentKind: 'file', + fileInfo: { name: 'state.ts', lang: 'text' }, + }); + expect(state?.document.getText()).toBe('alpha\nbravo'); + expect(state?.document.canUndo).toBe(false); + expect(state?.editor).toEqual({ view: { scrollLeft: 24 } }); } finally { editor.cleanUp(); component.cleanUp(); @@ -195,33 +381,710 @@ describe('Editor state', () => { } }); - test('automatic persistence stores horizontal state', () => { + test('raw complete state transfers draft, history, and editor state', () => { const dom = installDom(); - let storedState: EditorState | undefined; - const storage: IStateStorage = { - get() { - return undefined; - }, - set(_cacheKey, state) { - storedState = state; + const first = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha\nbravo', + lang: 'text', + }); + first.editorViewport = document.createElement('div'); + const firstEditor = new Editor('file', { + ownsVerticalViewport: true, + }); + const second = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha\nbravo', + lang: 'text', + }); + second.editorViewport = document.createElement('div'); + let secondEditor: Editor | undefined; + + try { + firstEditor.edit(first); + firstEditor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 5 }, + }, + newText: 'ALPHA', + }, + ]); + firstEditor.setViewState({ + selections: [ + { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + direction: 0, + }, + ], + view: { scrollLeft: 18, scrollTop: 36 }, + }); + + const state = firstEditor.getEditState()!; + firstEditor.cleanUp('discard'); + secondEditor = new Editor('file', { + initialState: state, + ownsVerticalViewport: true, + }); + Object.assign(state.editor.selections![0].start, { character: 0 }); + state.editor.view!.scrollLeft = 0; + secondEditor.edit(second); + + expect(secondEditor.getEditState()!.document).toBe(state.document); + expect(secondEditor.getText()).toBe('ALPHA\nbravo'); + expect(secondEditor.canUndo).toBe(true); + expect(secondEditor.getViewState()).toEqual({ + selections: [ + { + start: { line: 1, character: 0 }, + end: { line: 1, character: 3 }, + direction: 0, + }, + ], + view: { scrollLeft: 0, scrollTop: 36 }, + }); + secondEditor.undo(); + expect(secondEditor.getText()).toBe('alpha\nbravo'); + } finally { + firstEditor.cleanUp(); + secondEditor?.cleanUp(); + first.cleanUp(); + second.cleanUp(); + dom.cleanup(); + } + }); + + test('getEditState returns the managed document and history', () => { + const dom = installDom(); + const component = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha', + lang: 'text', + }); + component.editorViewport = document.createElement('div'); + const editor = new Editor('file'); + + try { + editor.edit(component); + editor.setViewState({ + selections: [ + { + start: { line: 0, character: 1 }, + end: { line: 0, character: 1 }, + direction: 0, + }, + ], + }); + const state = editor.getEditState()!; + const history = state.document.history; + + editor.applyEdits([ + { + range: { + start: { line: 0, character: 5 }, + end: { line: 0, character: 5 }, + }, + newText: '!', + }, + ]); + editor.setViewState({ + selections: [ + { + start: { line: 0, character: 6 }, + end: { line: 0, character: 6 }, + direction: 0, + }, + ], + }); + + expect(state.document.getText()).toBe('alpha!'); + expect(state.document.version).toBe(1); + expect(state.document.history.undoStack).toBe(history.undoStack); + expect(state.document.history.redoStack).toBe(history.redoStack); + expect(history.undoStack).toHaveLength(1); + const updatedState = editor.getEditState()!; + expect(updatedState).toBe(state); + expect(updatedState.document).toBe(state.document); + expect(updatedState.document.history.undoStack).toBe(history.undoStack); + expect(state.editor.selections?.[0].start.character).toBe(6); + expect(updatedState.editor.selections?.[0].start.character).toBe(6); + const entry = history.undoStack[0]; + editor.undo(); + expect(state.document.getText()).toBe('alpha'); + expect(history.undoStack).toHaveLength(0); + expect(history.redoStack).toEqual([entry]); + editor.redo(); + expect(state.document.getText()).toBe('alpha!'); + expect(history.undoStack).toEqual([entry]); + expect(history.redoStack).toHaveLength(0); + } finally { + editor.cleanUp(); + component.cleanUp(); + dom.cleanup(); + } + }); + + test('keyed state is checkpointed directly on the managed session', () => { + const dom = installDom(); + const component = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha', + lang: 'text', + }); + const editor = new Editor('file', {}, 'managed-checkpoint'); + + try { + editor.edit(component); + const state = editor.getEditState()!; + expect(EditStateManager.get('file', 'managed-checkpoint')).toBe(state); + + editor.applyEdits([ + { + range: { + start: { line: 0, character: 5 }, + end: { line: 0, character: 5 }, + }, + newText: '!', + }, + ]); + + expect(EditStateManager.get('file', 'managed-checkpoint')).toBe(state); + expect(state.document.getText()).toBe('alpha!'); + expect(state.editor).toEqual(editor.getViewState()); + } finally { + editor.cleanUp(); + component.cleanUp(); + EditStateManager.clearAll(); + dom.cleanup(); + } + }); + + test('getEditState exposes initialState before synchronization', () => { + const dom = installDom(); + const file = { + name: 'state.ts', + contents: 'alpha', + lang: 'text', + }; + const initialState = createInitialState(file, { + view: { scrollLeft: 24 }, + }); + const component = new TestEditableComponent(file); + component.deferAttachSync = true; + const editor = new Editor('file', { + initialState, + }); + + try { + editor.edit(component); + expect(editor.getEditState()).toBe(initialState); + + component.rerender(); + expect(editor.getEditState()).toBe(initialState); + } finally { + editor.cleanUp(); + component.cleanUp(); + dom.cleanup(); + } + }); + + test('completion checkpoints initialState before synchronization', () => { + const dom = installDom(); + const file = { + name: 'state.ts', + contents: 'alpha', + lang: 'text', + }; + const editorState: EditorViewState = { + view: { scrollLeft: 24, scrollTop: 48 }, + }; + const initialState = createInitialState(file, editorState); + const component = new TestEditableComponent(file); + component.deferAttachSync = true; + const editor = new Editor('file', { initialState }); + + try { + editor.edit(component); + editor.cleanUp('complete'); + + expect(initialState.editor).toEqual({ + selections: undefined, + view: { scrollLeft: 0 }, + }); + expect(component.completedEditState).toBe(initialState); + } finally { + editor.cleanUp(); + component.cleanUp(); + dom.cleanup(); + } + }); + + test('completion can transfer state before the editor releases its session', () => { + const dom = installDom(); + const first = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha', + lang: 'text', + }); + const firstEditor = new Editor('file'); + const second = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha', + lang: 'text', + }); + const fresh = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha', + lang: 'text', + }); + let secondEditor: Editor | undefined; + + try { + firstEditor.edit(first); + firstEditor.applyEdits([ + { + range: { + start: { line: 0, character: 5 }, + end: { line: 0, character: 5 }, + }, + newText: '!', + }, + ]); + firstEditor.cleanUp('complete'); + firstEditor.cleanUp('complete'); + + const completedState = first.completedEditState; + expect(completedState?.document.getText()).toBe('alpha!'); + expect(firstEditor.getEditState()).toBeUndefined(); + + firstEditor.edit(fresh); + expect(firstEditor.getText()).toBe('alpha'); + expect(firstEditor.canUndo).toBe(false); + firstEditor.cleanUp(); + + secondEditor = new Editor('file', { + initialState: completedState, + }); + secondEditor.edit(second); + expect(secondEditor.getText()).toBe('alpha!'); + expect(secondEditor.canUndo).toBe(true); + } finally { + firstEditor.cleanUp(); + secondEditor?.cleanUp(); + first.cleanUp(); + second.cleanUp(); + fresh.cleanUp(); + dom.cleanup(); + } + }); + + test('initialState replaces retained keyed state', () => { + const dom = installDom(); + EditStateManager.clearAll(); + const first = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha', + lang: 'text', + }); + const firstEditor = new Editor('file', {}, 'authoritative'); + const second = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha', + lang: 'text', + }); + const secondEditor = new Editor( + 'file', + { + initialState: createInitialState({ + name: 'state.ts', + contents: 'replacement', + lang: 'text', + }), }, + 'authoritative' + ); + + try { + firstEditor.edit(first); + firstEditor.applyEdits([ + { + range: { + start: { line: 0, character: 5 }, + end: { line: 0, character: 5 }, + }, + newText: '!', + }, + ]); + firstEditor.cleanUp('complete'); + secondEditor.edit(second); + + expect(secondEditor.getText()).toBe('replacement'); + expect(secondEditor.canUndo).toBe(false); + } finally { + firstEditor.cleanUp(); + secondEditor.cleanUp(); + first.cleanUp(); + second.cleanUp(); + EditStateManager.clearAll(); + dom.cleanup(); + } + }); + + test('pre-sync completion keeps the keyed document but clears view state', () => { + const dom = installDom(); + EditStateManager.clearAll(); + const first = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha', + lang: 'text', + }); + const firstEditor = new Editor('file', {}, 'pending-hydration'); + const pending = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha', + lang: 'text', + }); + pending.deferAttachSync = true; + const pendingEditor = new Editor( + 'file', + {}, + 'pending-hydration' + ); + const restored = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha', + lang: 'text', + }); + const restoredEditor = new Editor( + 'file', + {}, + 'pending-hydration' + ); + + try { + firstEditor.edit(first); + firstEditor.applyEdits([ + { + range: { + start: { line: 0, character: 5 }, + end: { line: 0, character: 5 }, + }, + newText: '!', + }, + ]); + firstEditor.setSelections([ + { + start: { line: 0, character: 3 }, + end: { line: 0, character: 3 }, + direction: 'none', + }, + ]); + firstEditor.cleanUp('complete'); + + pendingEditor.edit(pending); + expect(EditStateManager.get('file', 'pending-hydration')?.editor).toEqual( + { + selections: [ + { + start: { line: 0, character: 3 }, + end: { line: 0, character: 3 }, + direction: 0, + }, + ], + view: { scrollLeft: 0 }, + } + ); + pendingEditor.cleanUp('complete'); + expect(EditStateManager.get('file', 'pending-hydration')?.editor).toEqual( + { + selections: undefined, + view: { scrollLeft: 0 }, + } + ); + + restoredEditor.edit(restored); + expect(restoredEditor.getText()).toBe('alpha!'); + expect(restoredEditor.canUndo).toBe(true); + expect(restoredEditor.getViewState().selections).toBeUndefined(); + } finally { + firstEditor.cleanUp(); + pendingEditor.cleanUp(); + restoredEditor.cleanUp(); + first.cleanUp(); + pending.cleanUp(); + restored.cleanUp(); + EditStateManager.clearAll(); + dom.cleanup(); + } + }); + + test('transfers caller-created document state without cloning it', () => { + const dom = installDom(); + const document = new TextDocument( + 'state.ts', + 'developer-owned', + 'text', + 7 + ); + const initialState: EditState = { + documentKind: 'file', + document, + fileInfo: { name: 'state.ts', lang: 'text' }, + editor: { view: { scrollLeft: 24 } }, + }; + const editor = new Editor('file', { initialState }); + const component = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha', + lang: 'text', + }); + + try { + expect(editor.getEditState()).toBeUndefined(); + document.applyResolvedEdits([ + { + start: 0, + end: document.getText().length, + text: 'changed-after-construction', + }, + ]); + editor.edit(component); + expect(editor.getText()).toBe('changed-after-construction'); + expect(editor.canUndo).toBe(true); + expect(editor.getEditState()!.document).toBe(document); + expect(editor.getEditState()!.fileInfo).toBe(initialState.fileInfo); + } finally { + editor.cleanUp(); + component.cleanUp(); + dom.cleanup(); + } + }); + + test('initialState ignores vertical scroll without an owned viewport', () => { + const dom = installDom(); + const originalScrollTo = window.scrollTo; + let pageScrollCalls = 0; + window.scrollTo = () => { + pageScrollCalls++; }; - const editor = new Editor({ - persistState: true, - persistStateStorage: storage, + const editor = new Editor('file', { + initialState: createInitialState( + { name: 'state.ts', contents: 'alpha\nbravo' }, + { view: { scrollLeft: 24, scrollTop: 48 } } + ), }); const component = new TestEditableComponent({ name: 'state.ts', contents: 'alpha\nbravo', - cacheKey: 'state.ts', + }); + const scrollAncestor = document.createElement('div'); + scrollAncestor.style.overflowY = 'scroll'; + scrollAncestor.scrollTop = 12; + scrollAncestor.appendChild(component.fileContainer); + document.body.appendChild(scrollAncestor); + + try { + editor.edit(component); + + expect(component.restoredCodeScrollLefts).toEqual([24]); + expect(scrollAncestor.scrollTop).toBe(12); + expect(pageScrollCalls).toBe(0); + } finally { + window.scrollTo = originalScrollTo; + editor.cleanUp(); + component.cleanUp(); + dom.cleanup(); + } + }); + + test('initialState remains available when hydration fails', () => { + const dom = installDom(); + const editor = new Editor('file', { + initialState: createInitialState( + { name: 'state.ts', contents: 'alpha\nbravo' }, + { view: { scrollLeft: 24 } } + ), + }); + const component = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha\nbravo', + }); + const stateRestoreError = new Error('state restoration failed'); + component.stateRestoreError = stateRestoreError; + + try { + expect(() => editor.edit(component)).toThrow(stateRestoreError); + component.stateRestoreError = undefined; + editor.edit(component); + + expect(component.restoredCodeScrollLefts).toEqual([24]); + } finally { + editor.cleanUp(); + component.cleanUp(); + dom.cleanup(); + } + }); + + test('keyed state is released for adoption when restoration fails', () => { + const dom = installDom(); + EditStateManager.clearAll(); + const file = { name: 'state.ts', contents: 'alpha\nbravo' }; + const initialState = createInitialState(file, { + view: { scrollLeft: 24 }, + }); + const failingEditor = new Editor( + 'file', + { initialState }, + 'failed-restoration' + ); + const failing = new TestEditableComponent(file); + failing.stateRestoreError = new Error('state restoration failed'); + const adoptingEditor = new Editor( + 'file', + {}, + 'failed-restoration' + ); + const adopting = new TestEditableComponent(file); + + try { + expect(() => failingEditor.edit(failing)).toThrow( + 'state restoration failed' + ); + + adoptingEditor.edit(adopting); + expect(adoptingEditor.getEditState()?.document).toBe( + initialState.document + ); + expect(adoptingEditor.getViewState().view).toEqual({ + scrollLeft: 24, + }); + } finally { + failingEditor.cleanUp(); + adoptingEditor.cleanUp(); + failing.cleanUp(); + adopting.cleanUp(); + EditStateManager.clearAll(); + dom.cleanup(); + } + }); + + test('initialState remains available when attachment fails after hydration', () => { + const dom = installDom(); + const editor = new Editor('file', { + initialState: createInitialState( + { name: 'state.ts', contents: 'alpha\nbravo' }, + { view: { scrollLeft: 24 } } + ), + }); + const component = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha\nbravo', + }); + const attachError = new Error('attachment failed'); + component.attachErrorAfterSync = attachError; + + try { + expect(() => editor.edit(component)).toThrow(attachError); + component.attachErrorAfterSync = undefined; + editor.edit(component); + + expect(component.restoredCodeScrollLefts).toEqual([24, 24]); + } finally { + editor.cleanUp(); + component.cleanUp(); + dom.cleanup(); + } + }); + + test('getState retains horizontal state without an owned element viewport', () => { + const dom = installDom(); + const editor = new Editor('file'); + const component = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha\nbravo', }); try { editor.edit(component); component.codeScrollLeft = 24; + + expect(editor.getViewState().view).toEqual({ scrollLeft: 24 }); + component.editorViewport = document; + expect(editor.getViewState().view).toEqual({ scrollLeft: 24 }); + } finally { editor.cleanUp(); + component.cleanUp(); + dom.cleanup(); + } + }); - expect(storedState?.view).toEqual({ scrollLeft: 24, scrollTop: 0 }); + test('getState captures view state from an owned element viewport', () => { + const dom = installDom(); + const editor = new Editor('file', { + ownsVerticalViewport: true, + }); + const component = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha\nbravo', + }); + const viewport = document.createElement('div'); + component.editorViewport = viewport; + + try { + editor.edit(component); + component.codeScrollLeft = 24; + viewport.scrollTop = 48; + + expect(editor.getViewState().view).toEqual({ + scrollLeft: 24, + scrollTop: 48, + }); + } finally { + editor.cleanUp(); + component.cleanUp(); + dom.cleanup(); + } + }); + + test('vertical viewport ownership is captured at construction', () => { + const dom = installDom(); + const editor = new Editor('file'); + const component = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha\nbravo', + }); + const viewport = document.createElement('div'); + component.editorViewport = viewport; + + try { + editor.setOptions({ ownsVerticalViewport: true }); + editor.edit(component); + component.codeScrollLeft = 24; + viewport.scrollTop = 48; + + expect(editor.getViewState().view).toEqual({ scrollLeft: 24 }); + } finally { + editor.cleanUp(); + component.cleanUp(); + dom.cleanup(); + } + }); + + test('setState restores view state through the editable component', () => { + const dom = installDom(); + const editor = new Editor('file'); + const component = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha\nbravo', + }); + + try { + editor.edit(component); + editor.setViewState({ view: { scrollLeft: 24 } }); + + expect(component.restoredCodeScrollLefts).toEqual([24]); } finally { editor.cleanUp(); component.cleanUp(); @@ -241,7 +1104,7 @@ describe('Editor state', () => { scrollIntoViewCalls++; }; - const editor = new Editor(); + const editor = new Editor('file'); const component = new TestEditableComponent({ name: 'state.ts', contents: 'alpha\nbravo\ncharlie\ndelta\necho\nfoxtrot\n', @@ -249,7 +1112,7 @@ describe('Editor state', () => { try { editor.edit(component); - editor.setState({ + editor.setViewState({ selections: [ { start: { line: 5, character: 0 }, @@ -262,7 +1125,7 @@ describe('Editor state', () => { expect(component.restoredCodeScrollLefts).toEqual([12]); expect(scrollIntoViewCalls).toBe(0); - expect(editor.getState().selections).toEqual([ + expect(editor.getViewState().selections).toEqual([ { start: { line: 5, character: 0 }, end: { line: 5, character: 0 }, @@ -276,4 +1139,39 @@ describe('Editor state', () => { dom.cleanup(); } }); + + test('selection-only restoration does not scroll a shared viewport', () => { + const dom = installDom(); + let scrollIntoViewCalls = 0; + const originalScrollIntoView = Element.prototype.scrollIntoView; + Element.prototype.scrollIntoView = function scrollIntoView() { + scrollIntoViewCalls++; + }; + + const editor = new Editor('file'); + const component = new TestEditableComponent({ + name: 'state.ts', + contents: 'alpha\nbravo\ncharlie\ndelta\necho\nfoxtrot\n', + }); + try { + editor.edit(component); + editor.setViewState({ + selections: [ + { + start: { line: 5, character: 0 }, + end: { line: 5, character: 0 }, + direction: 0, + }, + ], + }); + + expect(scrollIntoViewCalls).toBe(0); + expect(editor.getViewState().view).toEqual({ scrollLeft: 0 }); + } finally { + Element.prototype.scrollIntoView = originalScrollIntoView; + editor.cleanUp(); + component.cleanUp(); + dom.cleanup(); + } + }); }); diff --git a/packages/diffs/test/editorVirtualizedEdit.test.ts b/packages/diffs/test/editorVirtualizedEdit.test.ts index 85dd0629a..89a09415c 100644 --- a/packages/diffs/test/editorVirtualizedEdit.test.ts +++ b/packages/diffs/test/editorVirtualizedEdit.test.ts @@ -86,7 +86,7 @@ async function createWindowedEditor( disableFileHeader: true, theme: DEFAULT_THEMES, }); - const editor = new Editor(); + const editor = new Editor('file'); const initialFile: FileContents = { name: 'edits.ts', contents: makeContents(lineCount), @@ -166,7 +166,7 @@ describe('Editor edits at the bottom of a virtualized window', () => { ); await wait(0); - expect(editor.getState().selections?.at(-1)?.start.line).toBe(1_000); + expect(editor.getViewState().selections?.at(-1)?.start.line).toBe(1_000); expect( fileContainer.shadowRoot?.querySelector('[data-caret]') ?.style.transform @@ -211,7 +211,7 @@ describe('Editor edits at the bottom of a virtualized window', () => { ]); await wait(0); - const selections = editor.getState().selections ?? []; + const selections = editor.getViewState().selections ?? []; expect(selections).toHaveLength(1); const caretLine = selections[0].start.line + 1; expect(caretLine).toBe(152); @@ -248,7 +248,7 @@ describe('Editor edits at the bottom of a virtualized window', () => { ]); await wait(0); - const selections = editor.getState().selections ?? []; + const selections = editor.getViewState().selections ?? []; expect(selections).toHaveLength(1); const caretLine = selections[0].start.line + 1; expect(caretLine).toBe(153); @@ -288,7 +288,7 @@ describe('Editor edits at the bottom of a virtualized window', () => { expect(rendered).toBeLessThan(insertedLines); // The caret lands far below the window and is not rendered synchronously. - const selections = editor.getState().selections ?? []; + const selections = editor.getViewState().selections ?? []; const caretLine = selections[0].start.line + 1; expect(caretLine).toBeGreaterThan(1000); expect(renderedLineNumbers(content)).not.toContain(caretLine); @@ -379,7 +379,7 @@ describe('Editor selections in a virtualized window', () => { editor.setOptions({ roundedSelection: false }); try { - editor.setState({ + editor.setViewState({ selections: [ { start: { line: 0, character: 0 }, diff --git a/packages/diffs/test/editorVirtualizedReveal.test.ts b/packages/diffs/test/editorVirtualizedReveal.test.ts index c315d0a16..1fb60aa49 100644 --- a/packages/diffs/test/editorVirtualizedReveal.test.ts +++ b/packages/diffs/test/editorVirtualizedReveal.test.ts @@ -66,6 +66,10 @@ class VirtualizedEditableComponent implements DiffsEditableComponent return this.options; } + __captureDocumentSessionState(): undefined { + return undefined; + } + getCodeScrollLeft(): number { return 0; } @@ -90,9 +94,12 @@ class VirtualizedEditableComponent implements DiffsEditableComponent getAnnotationSlotName = getLineAnnotationName; - completeEditSession(): void {} + __completeEditSession( + _editor: DiffsEditor, + _mode: 'install' | 'discard' + ): void {} - attachEditor(editor: DiffsEditor): () => void { + __attachEditor(editor: DiffsEditor): () => void { this.#editor = editor; this.#syncRenderView(); return () => { @@ -100,6 +107,15 @@ class VirtualizedEditableComponent implements DiffsEditableComponent }; } + __resumeEditor(editor: DiffsEditor): void { + if (this.#editor !== editor) { + throw new Error( + 'VirtualizedEditableComponent: editor association changed' + ); + } + this.rerender(); + } + applyDocumentChange( _textDocument: DiffsTextDocument, _newLineAnnotations?: DiffLineAnnotation[] @@ -115,7 +131,6 @@ class VirtualizedEditableComponent implements DiffsEditableComponent highlighter: createTestHighlighter(), fileContainer: this.fileContainer, file: this.#file, - externalCacheKey: this.#file.cacheKey, lineAnnotations: undefined, renderRange: { // Render only line 3 so the selected line 2 remains virtualized. @@ -171,7 +186,7 @@ function revealOffscreenLine({ }, }); - const editor = new Editor(); + const editor = new Editor('file'); const component = new VirtualizedEditableComponent(modelLineHeight); try { diff --git a/packages/diffs/test/editorWorkerPool.test.ts b/packages/diffs/test/editorWorkerPool.test.ts index 52c6bcf1c..2dc34213d 100644 --- a/packages/diffs/test/editorWorkerPool.test.ts +++ b/packages/diffs/test/editorWorkerPool.test.ts @@ -1437,8 +1437,11 @@ describe('File component edit session', () => { __syncRenderView: () => undefined, __postponeBgTokenizeToNextFrame: () => undefined, __captureFocusForDOMReplacement: () => undefined, + __emitEditComplete: () => undefined, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, } as unknown as DiffsEditor; - const detach = instance.attachEditor(editorStub); + const detach = instance.__attachEditor(editorStub); instance.rerender(); await waitFor(() => { expect(fileContainer.shadowRoot?.innerHTML ?? '').toContain( @@ -1508,8 +1511,11 @@ describe('FileDiff component edit session', () => { __syncRenderView: () => undefined, __postponeBgTokenizeToNextFrame: () => undefined, __captureFocusForDOMReplacement: () => undefined, + __emitEditComplete: () => undefined, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, } as unknown as DiffsEditor; - instance.attachEditor(editorStub); + instance.__attachEditor(editorStub); instance.rerender(); await waitFor(() => { expect(fileContainer.shadowRoot?.innerHTML ?? '').toContain( @@ -1530,6 +1536,9 @@ function createEditorStub(): DiffsEditor { __syncRenderView: () => undefined, __postponeBgTokenizeToNextFrame: () => undefined, __captureFocusForDOMReplacement: () => undefined, + __emitEditComplete: () => undefined, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, } as unknown as DiffsEditor; } @@ -1589,7 +1598,7 @@ describe('rendering when an editor attaches', () => { const updatesBefore = updates; const lineBefore = fileContainer.shadowRoot?.querySelector('[data-line="1"]'); - const detach = instance.attachEditor(createEditorStub()); + const detach = instance.__attachEditor(createEditorStub()); await wait(50); expect(updates).toBe(updatesBefore); @@ -1663,7 +1672,7 @@ describe('rendering when an editor attaches', () => { const updatesBefore = updates; const siblingUpdatesBefore = siblingUpdates; - const detach = instance.attachEditor(createEditorStub()); + const detach = instance.__attachEditor(createEditorStub()); await waitFor(() => { expect(fileContainer.shadowRoot?.innerHTML ?? '').toContain( 'data-char' @@ -1703,7 +1712,7 @@ describe('rendering when an editor attaches', () => { instance.render({ file, fileContainer, forceRender: true }); const request = await withTimeout(worker.waitForFileRequest()); - const detach = instance.attachEditor(createEditorStub()); + const detach = instance.__attachEditor(createEditorStub()); // The local highlight lands without the pool ever answering. The plain // pool AST already carries data-char (transformer-shaped), so only the // highlight colors prove the attach-time session render ran. @@ -1743,7 +1752,9 @@ describe('rendering when an editor attaches', () => { useTokenTransformer: true, }); let attaches = 0; - const editor = new Editor({ onAttach: () => attaches++ }); + const editor = new Editor('file-diff', { + onAttach: () => attaches++, + }); try { const root = document.createElement('div'); document.body.appendChild(root); @@ -1866,7 +1877,7 @@ describe('rendering when an editor attaches', () => { const updatesBefore = updates; const lineBefore = fileContainer.shadowRoot?.querySelector('[data-line="1"]'); - const detach = instance.attachEditor(createEditorStub()); + const detach = instance.__attachEditor(createEditorStub()); await wait(50); expect(updates).toBe(updatesBefore); @@ -1908,7 +1919,7 @@ describe('rendering when an editor attaches', () => { }); const updatesBefore = updates; - const detach = instance.attachEditor(createEditorStub()); + const detach = instance.__attachEditor(createEditorStub()); await wait(50); expect(updates).toBe(updatesBefore); diff --git a/packages/diffs/test/editorWrapCaretPosition.test.ts b/packages/diffs/test/editorWrapCaretPosition.test.ts index 74c1dad0b..8a7827e27 100644 --- a/packages/diffs/test/editorWrapCaretPosition.test.ts +++ b/packages/diffs/test/editorWrapCaretPosition.test.ts @@ -210,7 +210,7 @@ async function createWrapEditor( theme: DEFAULT_THEMES, overflow: 'wrap', }); - const editor = new Editor(); + const editor = new Editor('file'); const initialFile: FileContents = { name: 'wrap.ts', contents, @@ -266,7 +266,7 @@ function expectCaret( line: number, character: number ): void { - const selection = editor.getState().selections?.at(-1); + const selection = editor.getViewState().selections?.at(-1); expect(selection?.start).toEqual({ line, character }); expect(selection?.end).toEqual({ line, character }); } @@ -277,7 +277,7 @@ function caretState(editor: Editor): { line: number; character: number; } { - const selection = editor.getState().selections?.at(-1); + const selection = editor.getViewState().selections?.at(-1); if (selection === undefined) { throw new Error('no selection in editor state'); } @@ -454,7 +454,7 @@ describe('editor wrap caret position', () => { theme: DEFAULT_THEMES, overflow: 'wrap', }); - const editor = new Editor(); + const editor = new Editor('file'); const initialFile: FileContents = { name: 'wrap.ts', contents: 'const a = 1;\nconst b = 2;\nconst c = 3;\nconst d = 4;', diff --git a/packages/diffs/test/reactCodeViewEditorOptions.test.ts b/packages/diffs/test/reactCodeViewEditorOptions.test.ts index ddd57e890..ea31bda8e 100644 --- a/packages/diffs/test/reactCodeViewEditorOptions.test.ts +++ b/packages/diffs/test/reactCodeViewEditorOptions.test.ts @@ -33,6 +33,7 @@ import type { DiffsEditor, EditCompletionDecision, EditorChangeEvent, + EditorDocumentKind, FileContents, } from '../src/types'; import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; @@ -81,7 +82,10 @@ function createTrackedEditor( options: EditorOptions, attachmentError?: Error ): TrackedCodeViewEditor { - let detach: ((recycle?: boolean) => void) | undefined; + let detach: + | ReturnType['__attachEditor']> + | undefined; + let sessionOwner: DiffsEditableComponent | undefined; const editor = { options, edits: [], @@ -91,20 +95,22 @@ function createTrackedEditor( file: FileContents, lineAnnotations?: DiffLineAnnotation[] ) { - options.onChange?.({ changes: [], file, lineAnnotations }); + options.onChange?.({ changes: [], editor, file, lineAnnotations }); }, + getViewState: () => ({}), edit(instance: DiffsEditableComponent) { editor.edits.push(instance); - detach = instance.attachEditor(editor); + if (sessionOwner != null && sessionOwner !== instance) { + throw new Error('TrackedCodeViewEditor: recycled component changed'); + } + if (sessionOwner == null) { + sessionOwner = instance; + detach = instance.__attachEditor(editor); + } if (attachmentError != null) { throw attachmentError; } - // Mirror the real editor's disposer: tear down, then complete the - // session on the attached instance. - return () => { - editor.cleanUp('complete'); - instance.completeEditSession(); - }; + return () => editor.cleanUp('complete'); }, cleanUp(reason: 'discard' | 'recycle' | 'complete' = 'discard') { if (reason === 'recycle') { @@ -112,10 +118,23 @@ function createTrackedEditor( } else { editor.fullCleanUps += 1; } - detach?.(reason === 'recycle'); - detach = undefined; + if (reason !== 'recycle') { + try { + detach?.(); + sessionOwner?.__completeEditSession( + editor, + reason === 'complete' ? 'install' : 'discard' + ); + } finally { + detach = undefined; + sessionOwner = undefined; + } + } }, __captureFocusForDOMReplacement() {}, + __emitEditComplete() {}, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, __postponeBgTokenizeToNextFrame() {}, __syncRenderView() {}, } as unknown as TrackedCodeViewEditor; @@ -146,14 +165,28 @@ function setSessionText(editor: TrackedCodeViewEditor, contents: string): void { function createEditorHarness(attachmentError?: Error) { const editors: TrackedCodeViewEditor[] = []; + const receivedDocumentKinds: EditorDocumentKind[] = []; const receivedOptions: EditorOptions[] = []; - const createEditor: CreateEditor = (options) => { + const receivedEditStateKeys: Array = []; + const createEditor: CreateEditor = ( + documentKind, + options, + editStateKey + ) => { + receivedDocumentKinds.push(documentKind); receivedOptions.push(options); + receivedEditStateKeys.push(editStateKey); const editor = createTrackedEditor(options, attachmentError); editors.push(editor); - return editor; + return editor as unknown as DiffsEditor; + }; + return { + createEditor, + editors, + receivedDocumentKinds, + receivedEditStateKeys, + receivedOptions, }; - return { createEditor, editors, receivedOptions }; } function makeFileItem( @@ -370,7 +403,13 @@ describe('React CodeView editor factory', () => { const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); document.body.appendChild(container); - const { createEditor, editors, receivedOptions } = createEditorHarness(); + const { + createEditor, + editors, + receivedDocumentKinds, + receivedEditStateKeys, + receivedOptions, + } = createEditorHarness(); const attemptedOnChange = mock(() => {}); const onAttach = mock(() => {}); const onItemEditChange = mock( @@ -397,6 +436,7 @@ describe('React CodeView editor factory', () => { createEditor, createCodeViewElement({ editorOptions, + getEditStateKey: (item) => `history:${item.id}`, items: [makeFileItem('a', { edit: true }), makeDiffItem('b', true)], onItemEditChange, }) @@ -405,6 +445,8 @@ describe('React CodeView editor factory', () => { expect(editors).toHaveLength(2); expect(new Set(editors).size).toBe(2); + expect(receivedDocumentKinds).toEqual(['file', 'file-diff']); + expect(receivedEditStateKeys).toEqual(['history:a', 'history:b']); expect(receivedOptions).toHaveLength(2); for (const options of receivedOptions) { expect(options.historyMaxEntries).toBe(17); @@ -423,10 +465,12 @@ describe('React CodeView editor factory', () => { expect(onItemEditChange.mock.calls[0]?.[0].file.contents).toBe( 'edited a' ); + expect(onItemEditChange.mock.calls[0]?.[0].editor).toBe(editors[0]); expect(onItemEditChange.mock.calls[1]?.[1].id).toBe('b'); expect(onItemEditChange.mock.calls[1]?.[0].file.contents).toBe( 'edited b' ); + expect(onItemEditChange.mock.calls[1]?.[0].editor).toBe(editors[1]); } finally { await unmountRoot(root); cleanupActEnvironment(); @@ -434,7 +478,7 @@ describe('React CodeView editor factory', () => { } }); - test('uses replacement factories and editor options only for later sessions', async () => { + test('uses replacement factories, options, and keys only for later sessions', async () => { const { cleanup } = installCodeViewDom(); const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); @@ -447,7 +491,8 @@ describe('React CodeView editor factory', () => { const render = async ( item: CodeViewItem, createEditor: CreateEditor, - historyMaxEntries: number + historyMaxEntries: number, + getEditStateKey: (item: CodeViewItem) => string ) => { await renderRoot( root!, @@ -455,6 +500,7 @@ describe('React CodeView editor factory', () => { createEditor, createCodeViewElement({ editorOptions: { historyMaxEntries }, + getEditStateKey, items: [item], }) ) @@ -463,11 +509,22 @@ describe('React CodeView editor factory', () => { try { root = createReactRoot(container); - await render(baseItem, first.createEditor, 10); + await render( + baseItem, + first.createEditor, + 10, + (item) => `first:${item.id}` + ); expect(first.editors).toHaveLength(1); expect(first.receivedOptions[0]?.historyMaxEntries).toBe(10); + expect(first.receivedEditStateKeys).toEqual(['first:a']); - await render(baseItem, second.createEditor, 20); + await render( + baseItem, + second.createEditor, + 20, + (item) => `second:${item.id}` + ); expect(first.editors).toHaveLength(1); expect(second.editors).toHaveLength(0); expect(first.editors[0].fullCleanUps).toBe(0); @@ -475,17 +532,20 @@ describe('React CodeView editor factory', () => { await render( { ...baseItem, edit: false, version: 1 }, second.createEditor, - 20 + 20, + (item) => `second:${item.id}` ); expect(first.editors[0].fullCleanUps).toBeGreaterThanOrEqual(1); await render( { ...baseItem, edit: true, version: 2 }, second.createEditor, - 20 + 20, + (item) => `second:${item.id}` ); expect(second.editors).toHaveLength(1); expect(second.receivedOptions[0]?.historyMaxEntries).toBe(20); + expect(second.receivedEditStateKeys).toEqual(['second:a']); await unmountRoot(root); root = undefined; @@ -497,7 +557,7 @@ describe('React CodeView editor factory', () => { } }); - test('completes only changed controlled sessions with their owning items', async () => { + test('completes controlled sessions with their owning items', async () => { const { cleanup } = installCodeViewDom(); const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); @@ -507,7 +567,8 @@ describe('React CodeView editor factory', () => { const onItemEditComplete = mock( ( _event: ItemCompletionEvent, - _item: CodeViewItem + _item: CodeViewItem, + _nextItem: CodeViewItem ): EditCompletionDecision => 'reject' ); const editOffItem = makeFileItem('edit-off', { @@ -567,16 +628,20 @@ describe('React CodeView editor factory', () => { const unchangedEnd = { ...unchangedItem, edit: false, version: 1 }; await render([editOffEnd, collapsedEnd, unchangedEnd]); - // Edit-off and removal complete; collapse suspends its session and the - // unchanged session ends without an event. - expect(onItemEditComplete).toHaveBeenCalledTimes(2); + // Edit-off, removal, and the unchanged edit complete. Collapse suspends + // its session. + expect(onItemEditComplete).toHaveBeenCalledTimes(3); const completions = new Map( onItemEditComplete.mock.calls.map(([event, item]) => [ item.id, { event, item }, ]) ); - expect([...completions.keys()].sort()).toEqual(['edit-off', 'removed']); + expect([...completions.keys()].sort()).toEqual([ + 'edit-off', + 'removed', + 'unchanged', + ]); expect(fileEventContents(completions.get('edit-off')?.event)).toBe( 'edit-off contents' ); @@ -585,7 +650,9 @@ describe('React CodeView editor factory', () => { 'removed contents' ); expect(completions.get('removed')?.item).toBe(removedItem); - expect(collapsedEditor!.fullCleanUps).toBe(1); + expect(completions.get('unchanged')?.item).toBe(unchangedEnd); + expect(collapsedEditor!.recycleCleanUps).toBe(1); + expect(collapsedEditor!.fullCleanUps).toBe(0); expect(handle.current?.getEditor('collapsed')).toBe(collapsedEditor); } finally { await unmountRoot(root); @@ -594,7 +661,7 @@ describe('React CodeView editor factory', () => { } }); - test('completes a changed session when a controlled empty list removes every item', async () => { + test('completes every session when a controlled empty list removes all items', async () => { const { cleanup } = installCodeViewDom(); const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); @@ -604,7 +671,8 @@ describe('React CodeView editor factory', () => { const onItemEditComplete = mock( ( _event: ItemCompletionEvent, - _item: CodeViewItem + _item: CodeViewItem, + _nextItem: CodeViewItem ): EditCompletionDecision => 'reject' ); const changedItem = makeFileItem('changed', { edit: true, lineCount: 2 }); @@ -641,11 +709,15 @@ describe('React CodeView editor factory', () => { await render([]); expect(editors.every((editor) => editor.fullCleanUps > 0)).toBe(true); - expect(onItemEditComplete).toHaveBeenCalledTimes(1); - expect(onItemEditComplete.mock.calls[0]?.[1]).toBe(changedItem); - expect(fileEventContents(onItemEditComplete.mock.calls[0]?.[0])).toBe( + expect(onItemEditComplete).toHaveBeenCalledTimes(2); + const completions = new Map( + onItemEditComplete.mock.calls.map(([event, item]) => [item.id, event]) + ); + expect(completions.get(changedItem.id)?.editor).toBe(changedEditor); + expect(fileEventContents(completions.get(changedItem.id))).toBe( 'changed contents' ); + expect(completions.has(unchangedItem.id)).toBe(true); } finally { await unmountRoot(root); cleanupActEnvironment(); diff --git a/packages/diffs/test/reactEditorOptions.test.ts b/packages/diffs/test/reactEditorOptions.test.ts index f0d82fb23..ed5196645 100644 --- a/packages/diffs/test/reactEditorOptions.test.ts +++ b/packages/diffs/test/reactEditorOptions.test.ts @@ -14,16 +14,15 @@ import { type FileEditCompleteEvent, type FileEditCompleteHandler, File as FileInstance, - type FileOptions, } from '../src/components/File'; import { type FileDiffEditCompleteEvent, type FileDiffEditCompleteHandler, FileDiff as FileDiffInstance, - type FileDiffOptions, } from '../src/components/FileDiff'; import { DEFAULT_THEMES } from '../src/constants'; import { Editor, type EditorOptions } from '../src/editor/editor'; +import { EditStateManager } from '../src/editor/EditStateManager'; import { disposeHighlighter } from '../src/highlighter/shared_highlighter'; import { MultiFileDiff, @@ -32,6 +31,8 @@ import { type PatchDiffProps, File as ReactFile, FileDiff as ReactFileDiff, + type FileDiffOptions as ReactFileDiffOptions, + type FileOptions as ReactFileOptions, type FileProps as ReactFileProps, Virtualizer, } from '../src/react'; @@ -42,9 +43,12 @@ import { } from '../src/react/EditContext'; import { type FileDiffProps as ReactFileDiffProps } from '../src/react/FileDiff'; import type { + DiffLineAnnotation, DiffsEditableComponent, + DiffsEditor, EditableInstance, EditorChangeEvent, + EditorDocumentKind, FileContents, LineAnnotation, } from '../src/types'; @@ -71,8 +75,11 @@ const PatchDiffComponent = PatchDiff as ComponentType< PatchDiffProps >; -function createEditor(options: EditorOptions): Editor { - return new Editor(options); +function createEditor( + documentKind: EditorDocumentKind, + options: EditorOptions +): Editor { + return new Editor(documentKind, options); } class TrackedEditor extends Editor { @@ -86,16 +93,16 @@ class TrackedEditor extends Editor { class AttachmentFailingEditor extends TrackedEditor { constructor( + documentKind: EditorDocumentKind, options: EditorOptions, private readonly attachmentError: Error ) { - super(options); + super(documentKind, options); } override edit>( - fileInstance: EditableInstance + _fileInstance: EditableInstance ): () => void { - super.edit(fileInstance); throw this.attachmentError; } } @@ -174,11 +181,24 @@ async function captureRenderError( await wait(10); }); } catch (error) { - return error; + return unwrapRenderError(error); } return undefined; } +function unwrapRenderError(error: unknown): unknown { + if (!(error instanceof AggregateError)) { + return error; + } + for (const nestedError of error.errors) { + const unwrapped = unwrapRenderError(nestedError); + if (unwrapped instanceof Error && unwrapped.message !== '') { + return unwrapped; + } + } + return error; +} + function insertAtStart(editor: Editor, newText: string): void { editor.applyEdits([ { @@ -191,6 +211,30 @@ function insertAtStart(editor: Editor, newText: string): void { ]); } +function deleteAndRetypeSecondLine( + editor: Editor, + lineText: string +): void { + editor.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 2, character: 0 }, + }, + newText: '', + }, + ]); + editor.applyEdits([ + { + range: { + start: { line: 1, character: 0 }, + end: { line: 1, character: 0 }, + }, + newText: lineText, + }, + ]); +} + type ReactEditableSurface = 'File' | 'FileDiff'; type ReactEditableSurfaceInstance = | FileInstance @@ -200,11 +244,12 @@ function createEditableSurfaceElement( surface: ReactEditableSurface, edit = true, editorOptions?: EditorOptions, - onInstance?: (instance: ReactEditableSurfaceInstance) => void + onInstance?: (instance: ReactEditableSurfaceInstance) => void, + editStateKey?: string ): ReactElement { const oldFile = { name: 'edit.ts', contents: 'const value = 1;\n' }; if (surface === 'File') { - const options: FileOptions = { + const options: ReactFileOptions = { disableFileHeader: true, theme: DEFAULT_THEMES, onPostRender(_node, instance, phase) { @@ -216,12 +261,13 @@ function createEditableSurfaceElement( return createElement(ReactFileComponent, { disableWorkerPool: true, edit, + editStateKey, editorOptions, file: oldFile, options, }); } - const options: FileDiffOptions = { + const options: ReactFileDiffOptions = { disableFileHeader: true, theme: DEFAULT_THEMES, onPostRender(_node, instance, phase) { @@ -233,6 +279,7 @@ function createEditableSurfaceElement( return createElement(ReactFileDiffComponent, { disableWorkerPool: true, edit, + editStateKey, editorOptions, fileDiff: parseDiffFromFile(oldFile, { name: 'edit.ts', @@ -261,8 +308,7 @@ describe('React edit surfaces', () => { document.body.appendChild(container); let instance: FileInstance | undefined; let root: Root | undefined; - let updates = 0; - const options: FileOptions = { + const options: ReactFileOptions = { disableFileHeader: true, theme: DEFAULT_THEMES, useTokenTransformer: false, @@ -270,9 +316,6 @@ describe('React edit surfaces', () => { if (phase !== 'unmount') { instance = current; } - if (phase === 'update') { - updates++; - } }, }; try { @@ -302,9 +345,6 @@ describe('React edit surfaces', () => { // waitFor times out silently; this is the real assertion. expect(shadowHTML(container)).toContain('data-char'); expect(instance!.options.useTokenTransformer).toBe(false); - // Mounting straight into edit renders once without a session and again - // at editor attach — the accepted pre-paint cost of mount-into-edit. - expect(updates).toBeGreaterThanOrEqual(1); } finally { await unmountRoot(root); cleanupActEnvironment(); @@ -319,7 +359,7 @@ describe('React edit surfaces', () => { document.body.appendChild(container); let instance: FileDiffInstance | undefined; let root: Root | undefined; - const options: FileDiffOptions = { + const options: ReactFileDiffOptions = { disableFileHeader: true, theme: DEFAULT_THEMES, useTokenTransformer: false, @@ -418,12 +458,14 @@ describe('React edit surfaces', () => { describe('React editor factory lifecycle', () => { for (const surface of ['File', 'FileDiff'] as const) { - test(`${surface} creates editors only for edit sessions and preserves the surface`, async () => { + test(`${surface} uses factory inputs only for new edit sessions`, async () => { const { cleanup } = installDom(); const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); document.body.appendChild(container); const editors: TrackedEditor[] = []; + const firstEditStateKey = `${surface}:first`; + const secondEditStateKey = `${surface}:second`; let instance: ReactEditableSurfaceInstance | undefined; let root: Root | undefined; const firstOnChange = mock( @@ -432,20 +474,21 @@ describe('React editor factory lifecycle', () => { const secondOnChange = mock( (_event: EditorChangeEvent) => {} ); - const firstFactory = mock((options: EditorOptions) => { - const editor = new TrackedEditor(options); + const firstFactory = mock((documentKind, options, editStateKey) => { + const editor = new TrackedEditor(documentKind, options, editStateKey); editors.push(editor); return editor; }); - const secondFactory = mock((options: EditorOptions) => { - const editor = new TrackedEditor(options); + const secondFactory = mock((documentKind, options, editStateKey) => { + const editor = new TrackedEditor(documentKind, options, editStateKey); editors.push(editor); return editor; }); const render = async ( edit: boolean, factory: CreateEditor, - onChange: NonNullable['onChange']> + onChange: NonNullable['onChange']>, + editStateKey: string ) => { await act(async () => { root!.render( @@ -458,7 +501,8 @@ describe('React editor factory lifecycle', () => { { onChange }, (current) => { instance = current; - } + }, + editStateKey ) ) ); @@ -468,7 +512,7 @@ describe('React editor factory lifecycle', () => { try { root = createReactRoot(container); - await render(false, firstFactory, firstOnChange); + await render(false, firstFactory, firstOnChange, firstEditStateKey); const host = container.firstElementChild; const initialInstance = instance; expect(host).not.toBeNull(); @@ -476,15 +520,19 @@ describe('React editor factory lifecycle', () => { expect(editors).toHaveLength(0); expect(firstFactory).not.toHaveBeenCalled(); - await render(true, firstFactory, firstOnChange); + await render(true, firstFactory, firstOnChange, firstEditStateKey); expect(editors).toHaveLength(1); expect(firstFactory).toHaveBeenCalledTimes(1); - expect(firstFactory.mock.calls[0]?.[0].onChange).toBe(firstOnChange); + expect(firstFactory.mock.calls[0]?.[0]).toBe( + surface === 'File' ? 'file' : 'file-diff' + ); + expect(firstFactory.mock.calls[0]?.[1].onChange).toBe(firstOnChange); + expect(firstFactory.mock.calls[0]?.[2]).toBe(firstEditStateKey); expect(editors[0]?.cleanUpCount).toBe(0); expect(container.firstElementChild).toBe(host); expect(instance).toBe(initialInstance); - await render(true, secondFactory, secondOnChange); + await render(true, secondFactory, secondOnChange, secondEditStateKey); expect(editors).toHaveLength(1); expect(firstFactory).toHaveBeenCalledTimes(1); expect(secondFactory).not.toHaveBeenCalled(); @@ -492,17 +540,21 @@ describe('React editor factory lifecycle', () => { expect(firstOnChange).toHaveBeenCalledTimes(1); expect(secondOnChange).not.toHaveBeenCalled(); - await render(false, secondFactory, secondOnChange); + await render(false, secondFactory, secondOnChange, secondEditStateKey); expect(editors[0]?.cleanUpCount).toBe(1); expect(container.firstElementChild).toBe(host); expect(instance).toBe(initialInstance); - await render(true, secondFactory, secondOnChange); + await render(true, secondFactory, secondOnChange, secondEditStateKey); expect(editors).toHaveLength(2); expect(editors[1]).not.toBe(editors[0]); expect(firstFactory).toHaveBeenCalledTimes(1); expect(secondFactory).toHaveBeenCalledTimes(1); - expect(secondFactory.mock.calls[0]?.[0].onChange).toBe(secondOnChange); + expect(secondFactory.mock.calls[0]?.[0]).toBe( + surface === 'File' ? 'file' : 'file-diff' + ); + expect(secondFactory.mock.calls[0]?.[1].onChange).toBe(secondOnChange); + expect(secondFactory.mock.calls[0]?.[2]).toBe(secondEditStateKey); expect(editors[1]?.cleanUpCount).toBe(0); expect(container.firstElementChild).toBe(host); expect(instance).toBe(initialInstance); @@ -515,6 +567,131 @@ describe('React editor factory lifecycle', () => { expect(editors[1]?.cleanUpCount).toBeGreaterThan(0); } finally { await unmountRoot(root); + const documentKind = surface === 'File' ? 'file' : 'file-diff'; + EditStateManager.clear(documentKind, firstEditStateKey); + EditStateManager.clear(documentKind, secondEditStateKey); + cleanupActEnvironment(); + cleanup(); + } + }); + } + + for (const surface of ['File', 'FileDiff'] as const) { + test(`${surface} forwards editStateKey when creating its editor`, async () => { + const { cleanup } = installDom(); + const cleanupActEnvironment = installReactActEnvironment(); + const container = document.createElement('div'); + document.body.appendChild(container); + const editStateKey = `${surface}-history`; + const editors: TrackedEditor[] = []; + const factory = mock( + ( + documentKind: EditorDocumentKind, + options: EditorOptions, + historyKey?: string + ) => { + const editor = new TrackedEditor(documentKind, options, historyKey); + editors.push(editor); + return editor; + } + ); + let root: Root | undefined; + + try { + root = createReactRoot(container); + await act(async () => { + root!.render( + createElement( + EditProviderComponent, + { createEditor: factory }, + createEditableSurfaceElement( + surface, + true, + {}, + undefined, + editStateKey + ) + ) + ); + await wait(10); + }); + await waitFor(() => editors[0]?.getFile() !== undefined); + expect(factory).toHaveBeenCalledTimes(1); + expect(factory.mock.calls[0]?.[2]).toBe(editStateKey); + expect(editors[0]?.cleanUpCount).toBe(0); + } finally { + await unmountRoot(root); + if (surface === 'File') { + EditStateManager.clear('file', editStateKey); + } else { + EditStateManager.clear('file-diff', editStateKey); + } + cleanupActEnvironment(); + cleanup(); + } + }); + } + + for (const surface of ['File', 'FileDiff'] as const) { + test(`${surface} rejects concurrent surfaces using the same editStateKey`, async () => { + const { cleanup } = installDom(); + const cleanupActEnvironment = installReactActEnvironment(); + const container = document.createElement('div'); + document.body.appendChild(container); + const editStateKey = `${surface}-shared-history`; + const editorOptions: EditorOptions = {}; + const editors: TrackedEditor[] = []; + const factory: CreateEditor = ( + documentKind, + options, + historyKey + ) => { + const editor = new TrackedEditor(documentKind, options, historyKey); + editors.push(editor); + return editor; + }; + let root: Root | undefined; + + try { + root = createReactRoot(container); + const renderError = await captureRenderError( + root, + createElement( + EditProviderComponent, + { createEditor: factory }, + createElement( + 'div', + null, + createEditableSurfaceElement( + surface, + true, + editorOptions, + undefined, + editStateKey + ), + createEditableSurfaceElement( + surface, + true, + editorOptions, + undefined, + editStateKey + ) + ) + ) + ); + expect(renderError).toBeInstanceOf(Error); + expect((renderError as Error).message).toBe( + `Editor: editStateKey "${editStateKey}" is already attached to another editor` + ); + expect(editors.length).toBeGreaterThan(1); + expect(new Set(editors).size).toBe(editors.length); + } finally { + await unmountRoot(root); + if (surface === 'File') { + EditStateManager.clear('file', editStateKey); + } else { + EditStateManager.clear('file-diff', editStateKey); + } cleanupActEnvironment(); cleanup(); } @@ -528,8 +705,8 @@ describe('React editor factory lifecycle', () => { const container = document.createElement('div'); document.body.appendChild(container); const editors: TrackedEditor[] = []; - const factory = (options: EditorOptions) => { - const editor = new TrackedEditor(options); + const factory: CreateEditor = (documentKind, options) => { + const editor = new TrackedEditor(documentKind, options); editors.push(editor); return editor; }; @@ -604,12 +781,15 @@ describe('React editor factory lifecycle', () => { expect(firstOnEditChange.mock.calls[0]?.[0]).toBe( editorOnChange.mock.calls[0]?.[0] ); + expect(firstOnEditChange.mock.calls[0]?.[0].editor).toBe(editors[0]); + expect(editorOnChange.mock.calls[0]?.[0].editor).toBe(editors[0]); await render(secondOnEditChange); expect(editors).toHaveLength(1); insertAtStart(editors[0], '/* two */'); expect(firstOnEditChange).toHaveBeenCalledTimes(1); expect(secondOnEditChange).toHaveBeenCalledTimes(1); + expect(secondOnEditChange.mock.calls[0]?.[0].editor).toBe(editors[0]); } finally { await unmountRoot(root); cleanupActEnvironment(); @@ -624,8 +804,8 @@ describe('React editor factory lifecycle', () => { const container = document.createElement('div'); document.body.appendChild(container); const editors: TrackedEditor[] = []; - const factory = (options: EditorOptions) => { - const editor = new TrackedEditor(options); + const factory: CreateEditor = (documentKind, options) => { + const editor = new TrackedEditor(documentKind, options); editors.push(editor); return editor; }; @@ -709,8 +889,9 @@ describe('React editor factory lifecycle', () => { (_event: EditorChangeEvent) => {} ); let root: Root | undefined; - const factory = mock((options: EditorOptions) => { - const editor = new TrackedEditor(options); + const editStateKey = `${wrapper}-history`; + const factory = mock((documentKind, options, historyKey?: string) => { + const editor = new TrackedEditor(documentKind, options, historyKey); editors.push(editor); return editor; }); @@ -719,6 +900,7 @@ describe('React editor factory lifecycle', () => { const sharedProps = { disableWorkerPool: true, edit: true, + editStateKey, editorOptions: { onChange }, options: { disableFileHeader: true, @@ -759,7 +941,9 @@ describe('React editor factory lifecycle', () => { expect(editors).toHaveLength(1); expect(factory).toHaveBeenCalledTimes(1); - expect(factory.mock.calls[0]?.[0].onChange).toBe(onChange); + expect(factory.mock.calls[0]?.[0]).toBe('file-diff'); + expect(factory.mock.calls[0]?.[1].onChange).toBe(onChange); + expect(factory.mock.calls[0]?.[2]).toBe(editStateKey); insertAtStart(editors[0], '/* wrapper */'); expect(onChange).toHaveBeenCalledTimes(1); expect(onChange.mock.calls[0]?.[0].file.contents).toBe( @@ -771,6 +955,7 @@ describe('React editor factory lifecycle', () => { expect(editors[0]?.cleanUpCount).toBeGreaterThan(0); } finally { await unmountRoot(root); + EditStateManager.clear('file-diff', editStateKey); cleanupActEnvironment(); cleanup(); } @@ -799,8 +984,8 @@ describe('React editor factory lifecycle', () => { ); const editors: TrackedEditor[] = []; let root: Root | undefined; - const factory = (options: EditorOptions) => { - const editor = new TrackedEditor(options); + const factory: CreateEditor = (documentKind, options) => { + const editor = new TrackedEditor(documentKind, options); editors.push(editor); return editor; }; @@ -895,7 +1080,7 @@ describe('React editor factory lifecycle', () => { }); for (const surface of ['File', 'FileDiff'] as const) { - test(`${surface} reports missing providers and invalid factories`, async () => { + test(`${surface} reports a missing provider`, async () => { const { cleanup } = installDom(); const cleanupActEnvironment = installReactActEnvironment(); const container = document.createElement('div'); @@ -914,22 +1099,6 @@ describe('React editor factory lifecycle', () => { ? 'File: EditContext is not attached' : 'FileDiff: EditContext is not attached' ); - - await unmountRoot(root); - root = undefined; - root = createReactRoot(container); - const invalidFactoryError = await captureRenderError( - root, - createElement( - EditProviderComponent, - { createEditor: () => undefined as never }, - createEditableSurfaceElement(surface) - ) - ); - expect(invalidFactoryError).toBeInstanceOf(Error); - expect((invalidFactoryError as Error).message).toBe( - `${surface}: EditProvider.createEditor must return an editor instance` - ); } finally { await unmountRoot(root); cleanupActEnvironment(); @@ -946,8 +1115,12 @@ describe('React editor factory lifecycle', () => { const editors: AttachmentFailingEditor[] = []; const onAttach = mock((_editor: Editor) => {}); let root: Root | undefined; - const factory = (options: EditorOptions) => { - const editor = new AttachmentFailingEditor(options, attachmentError); + const factory: CreateEditor = (documentKind, options) => { + const editor = new AttachmentFailingEditor( + documentKind, + options, + attachmentError + ); editors.push(editor); return editor; }; @@ -963,14 +1136,17 @@ describe('React editor factory lifecycle', () => { ) ); expect(renderError).toBe(attachmentError); - expect(editors).toHaveLength(1); - expect(editors[0]?.cleanUpCount).toBe(1); + expect(editors.length).toBeGreaterThan(0); + expect(editors.every((editor) => editor.cleanUpCount > 0)).toBe(true); + const cleanUpCounts = editors.map((editor) => editor.cleanUpCount); await wait(0); expect(onAttach).not.toHaveBeenCalled(); await unmountRoot(root); root = undefined; - expect(editors[0]?.cleanUpCount).toBe(1); + expect(editors.map((editor) => editor.cleanUpCount)).toEqual( + cleanUpCounts + ); } finally { await unmountRoot(root); cleanupActEnvironment(); @@ -989,8 +1165,8 @@ describe('React editor factory lifecycle', () => { document.body.appendChild(container); const editors: TrackedEditor[] = []; const onAttach = mock((_editor: Editor) => {}); - const factory = (options: EditorOptions) => { - const editor = new TrackedEditor(options); + const factory: CreateEditor = (documentKind, options) => { + const editor = new TrackedEditor(documentKind, options); editors.push(editor); return editor; }; @@ -1046,8 +1222,8 @@ describe('React editor factory lifecycle', () => { const editors: TrackedEditor[] = []; const onAttach = mock((_editor: Editor) => {}); let root: Root | undefined; - const factory = (options: EditorOptions) => { - const editor = new TrackedEditor(options); + const factory: CreateEditor = (documentKind, options) => { + const editor = new TrackedEditor(documentKind, options); editors.push(editor); return editor; }; @@ -1100,15 +1276,37 @@ describe('React editor factory lifecycle', () => { await waitFor(() => onAttach.mock.calls.length >= 2); await wait(0); - // The provider caches editors by options-object identity, so - // StrictMode's simulated destroy/re-create pass reuses each surface's - // editor instead of leaking a second one: exactly one editor per - // surface, cleaned up by the destroy pass and re-attached after it. - expect(editors).toHaveLength(2); + // StrictMode's simulated destroy/re-create pass gets fresh editors from + // the owner factory. The first pair is cleaned before the committed pair + // attaches, and the provider retains none of them. + expect(editors).toHaveLength(4); expect(onAttach).toHaveBeenCalledTimes(2); - expect(new Set(onAttach.mock.calls.map(([editor]) => editor))).toEqual( - new Set(editors) + const attachedEditors = onAttach.mock.calls.map( + ([editor]) => editor as TrackedEditor + ); + expect(new Set(attachedEditors).size).toBe(2); + expect(attachedEditors.every((editor) => editors.includes(editor))).toBe( + true ); + expect( + editors + .filter((editor) => !attachedEditors.includes(editor)) + .every((editor) => editor.cleanUpCount > 0) + ).toBe(true); + for (const editor of attachedEditors) { + const character = editor.getText().indexOf('\n'); + expect(() => + editor.applyEdits([ + { + range: { + start: { line: 0, character }, + end: { line: 0, character }, + }, + newText: ' // strict replay', + }, + ]) + ).not.toThrow(); + } await unmountRoot(root); root = undefined; @@ -1131,12 +1329,15 @@ describe('React editor factory lifecycle', () => { // onAttach must fire, while the surface's editorOptions (including its // onAttach) only reach the factory, which discards them. let attachedEditor: Editor | undefined; - const sharedEditor = new Editor({ + const sharedEditor = new Editor('file', { onAttach(editor) { attachedEditor = editor; }, }); - const factory = mock((_options: EditorOptions) => sharedEditor); + const factory = mock( + (_documentKind: EditorDocumentKind, _options: EditorOptions) => + sharedEditor + ); const surfaceOnAttach = mock((_editor: Editor) => {}); let root: Root | undefined; @@ -1199,8 +1400,8 @@ describe('React completion lifecycle', () => { document.body.appendChild(container); const editors: TrackedEditor[] = []; const events: FileEditCompleteEvent[] = []; - const factory = (options: EditorOptions) => { - const editor = new TrackedEditor(options); + const factory: CreateEditor = (documentKind, options) => { + const editor = new TrackedEditor(documentKind, options); editors.push(editor); return editor; }; @@ -1426,6 +1627,47 @@ describe('React completion lifecycle', () => { await harness.cleanup(); } }); + + test('accepting unchanged text does not restore stale annotation props', async () => { + const annotations: LineAnnotation[] = [{ lineNumber: 2 }]; + const events: FileEditCompleteEvent[] = []; + const harness = createFileHarness((event) => { + events.push(event); + return 'accept'; + }); + try { + await harness.render({ + file: FILE_A, + edit: true, + lineAnnotations: annotations, + }); + await waitFor(() => harness.editors[0]?.getText() === FILE_A.contents, { + timeout: 4_000, + }); + act(() => { + deleteAndRetypeSecondLine(harness.editors[0], 'const other = 2;\n'); + }); + expect(harness.editors[0].getText()).toBe(FILE_A.contents); + + await harness.render({ + file: FILE_A, + edit: false, + lineAnnotations: annotations, + }); + + expect(events).toHaveLength(1); + expect(events[0].lineAnnotations).toEqual([]); + const annotationRows = Array.from( + harness.container.querySelectorAll('*') + ).flatMap((element) => [ + ...(element.shadowRoot?.querySelectorAll('[data-line-annotation]') ?? + []), + ]); + expect(annotationRows).toHaveLength(0); + } finally { + await harness.cleanup(); + } + }); }); describe('React diff input bridges after acceptance', () => { @@ -1445,8 +1687,9 @@ describe('React diff input bridges after acceptance', () => { document.body.appendChild(container); const editors: TrackedEditor[] = []; const events: FileDiffEditCompleteEvent[] = []; - const factory = (options: EditorOptions) => { - const editor = new TrackedEditor(options); + const completionEditors: DiffsEditor[] = []; + const factory: CreateEditor = (documentKind, options) => { + const editor = new TrackedEditor(documentKind, options); editors.push(editor); return editor; }; @@ -1454,10 +1697,11 @@ describe('React diff input bridges after acceptance', () => { const root = createReactRoot(container); const onEditComplete: FileDiffEditCompleteHandler = (event) => { events.push(event); + completionEditors.push(event.editor); event.fileDiff.cacheKey = 'accepted:v2'; return 'accept'; }; - const baseOptions = (extra?: Partial>) => ({ + const baseOptions = (extra?: Partial>) => ({ disableFileHeader: true, theme: DEFAULT_THEMES, onPostRender( @@ -1474,6 +1718,7 @@ describe('React diff input bridges after acceptance', () => { return { container, editors, + completionEditors, events, factory, root, @@ -1525,6 +1770,7 @@ describe('React diff input bridges after acceptance', () => { // Stale pair: the accepted diff stays installed. await render(OLD_FILE, NEW_FILE, false); expect(harness.events).toHaveLength(1); + expect(harness.completionEditors).toEqual([harness.editors[0]]); const event = harness.events[0]; expect(harness.getInstance()?.fileDiff).toBe(event.fileDiff); @@ -1551,6 +1797,57 @@ describe('React diff input bridges after acceptance', () => { } }); + test('MultiFileDiff does not restore stale annotations after unchanged text', async () => { + const harness = createDiffHarness(); + const annotations: DiffLineAnnotation[] = [ + { side: 'additions', lineNumber: 2 }, + ]; + const render = async (edit: boolean) => { + await act(async () => { + harness.root.render( + createElement( + EditProviderComponent, + { createEditor: harness.factory }, + createElement(MultiFileDiffComponent, { + disableWorkerPool: true, + edit, + oldFile: OLD_FILE, + newFile: NEW_FILE, + lineAnnotations: annotations, + onEditComplete: harness.onEditComplete, + options: harness.baseOptions(), + }) + ) + ); + await wait(10); + }); + }; + try { + await render(true); + await waitFor(() => harness.editors[0]?.getText() === NEW_FILE.contents, { + timeout: 4_000, + }); + act(() => { + deleteAndRetypeSecondLine(harness.editors[0], 'common;\n'); + }); + expect(harness.editors[0].getText()).toBe(NEW_FILE.contents); + + await render(false); + + expect(harness.events).toHaveLength(1); + expect(harness.events[0].lineAnnotations).toEqual([]); + const annotationRows = Array.from( + harness.container.querySelectorAll('*') + ).flatMap((element) => [ + ...(element.shadowRoot?.querySelectorAll('[data-line-annotation]') ?? + []), + ]); + expect(annotationRows).toHaveLength(0); + } finally { + await harness.cleanup(); + } + }); + test('a newer MultiFileDiff pair supersedes a pending bridge', async () => { const harness = createDiffHarness(); try { diff --git a/packages/diffs/test/virtualizedApplyDocumentChange.test.ts b/packages/diffs/test/virtualizedApplyDocumentChange.test.ts index 647be66e6..0dc1f0f14 100644 --- a/packages/diffs/test/virtualizedApplyDocumentChange.test.ts +++ b/packages/diffs/test/virtualizedApplyDocumentChange.test.ts @@ -55,9 +55,12 @@ function createEditorStub(): DiffsEditor { cleanUp() {}, edit: () => () => {}, __captureFocusForDOMReplacement() {}, + __emitEditComplete() {}, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, __postponeBgTokenizeToNextFrame() {}, __syncRenderView() {}, - }; + } as unknown as DiffsEditor; } class BufferRecordingFile extends VirtualizedFile { @@ -98,7 +101,7 @@ describe('applyDocumentChange buffer updates', () => { ); advancedInstance.updateCodeViewLayout(makeFile(50), 0); const detachAdvancedEditor = - advancedInstance.attachEditor(createEditorStub()); + advancedInstance.__attachEditor(createEditorStub()); setRenderedEditSession(advancedInstance); advancedInstance.seedRenderRange(seeded); advancedInstance.applyDocumentChange(makeDocument(1), undefined, true); @@ -111,7 +114,8 @@ describe('applyDocumentChange buffer updates', () => { createStubVirtualizer('simple') ); simpleInstance.updateCodeViewLayout(makeFile(50), 0); - const detachSimpleEditor = simpleInstance.attachEditor(createEditorStub()); + const detachSimpleEditor = + simpleInstance.__attachEditor(createEditorStub()); setRenderedEditSession(simpleInstance); simpleInstance.seedRenderRange(seeded); simpleInstance.applyDocumentChange(makeDocument(1), undefined, true); diff --git a/packages/diffs/test/virtualizedEditorViewport.test.ts b/packages/diffs/test/virtualizedEditorViewport.test.ts index fcfb55da3..bd47e363d 100644 --- a/packages/diffs/test/virtualizedEditorViewport.test.ts +++ b/packages/diffs/test/virtualizedEditorViewport.test.ts @@ -1,9 +1,16 @@ import { describe, expect, test } from 'bun:test'; +import type { FileEditCompleteEvent } from '../src/components/File'; import { VirtualizedFile } from '../src/components/VirtualizedFile'; import { VirtualizedFileDiff } from '../src/components/VirtualizedFileDiff'; import { DEFAULT_THEMES } from '../src/constants'; -import type { DiffsEditor } from '../src/types'; +import { Editor } from '../src/editor/editor'; +import { TextDocument } from '../src/editor/textDocument'; +import type { + DiffsEditor, + EditorChangeEvent, + EditorViewState, +} from '../src/types'; import { parseDiffFromFile } from '../src/utils/parseDiffFromFile'; import { installDom, waitFor } from './domHarness'; @@ -193,15 +200,18 @@ describe('virtualized editor viewport', () => { expect(code.scrollLeft).toBe(61); let recyclePosition: number | undefined; - file.attachEditor({ + file.__attachEditor({ __captureFocusForDOMReplacement() {}, + __emitEditComplete() {}, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, __postponeBgTokenizeToNextFrame() {}, __syncRenderView() {}, edit: () => () => {}, cleanUp() { recyclePosition = file.getCodeScrollLeft(); }, - } as DiffsEditor); + } as unknown as DiffsEditor); code.scrollLeft = 62; file.cleanUp(true); expect(recyclePosition).toBe(62); @@ -211,6 +221,158 @@ describe('virtualized editor viewport', () => { } }); + test('hydrates horizontal state without moving the shared viewport', async () => { + const dom = installDom(); + const root = document.createElement('div'); + const virtualizer = createSimpleVirtualizer(root); + const file = new VirtualizedFile( + { disableFileHeader: true, theme: DEFAULT_THEMES }, + virtualizer + ); + const editor = new Editor('file', { + initialState: { + documentKind: 'file', + document: new TextDocument('state.txt', 'alpha\nbravo\n', 'text'), + fileInfo: { name: 'state.txt', lang: 'text' }, + editor: { + selections: [ + { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + direction: 0, + }, + ], + view: { scrollLeft: 24, scrollTop: 48 }, + }, + }, + }); + + try { + const code = await renderFile(file, root); + root.scrollTop = 12; + editor.edit(file); + await waitFor(() => code.scrollLeft === 24); + + expect(root.scrollTop).toBe(12); + + expect(editor.getViewState()).toEqual({ + selections: [ + { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + direction: 0, + }, + ], + view: { scrollLeft: 24 }, + }); + } finally { + editor.cleanUp(); + file.cleanUp(); + dom.cleanup(); + } + }); + + test('broadcasts local state without shared vertical position', async () => { + const dom = installDom(); + const root = document.createElement('div'); + const virtualizer = createSimpleVirtualizer(root); + const editorEvents: EditorChangeEvent[] = []; + const componentEvents: EditorChangeEvent[] = []; + const completionEvents: FileEditCompleteEvent[] = []; + const componentStates: EditorViewState[] = []; + const completionStates: EditorViewState[] = []; + const file = new VirtualizedFile( + { + disableFileHeader: true, + theme: DEFAULT_THEMES, + onEditChange(event) { + componentEvents.push(event); + componentStates.push(event.editor.getViewState()); + }, + onEditComplete(event) { + completionEvents.push(event); + completionStates.push(event.editor.getViewState()); + return 'reject'; + }, + }, + virtualizer + ); + const editor = new Editor('file', { + onChange(event) { + editorEvents.push(event); + }, + }); + + try { + const code = await renderFile(file, root); + const finishSession = editor.edit(file); + await waitFor(() => editor.getText() === 'alpha\nbravo\n'); + editor.setSelections([ + { + start: { line: 0, character: 5 }, + end: { line: 0, character: 5 }, + direction: 'none', + }, + ]); + code.scrollLeft = 24; + root.scrollTop = 48; + + editor.applyEdits([ + { + range: { + start: { line: 0, character: 0 }, + end: { line: 0, character: 0 }, + }, + newText: 'X', + }, + ]); + + expect(editorEvents).toHaveLength(1); + expect(componentEvents).toHaveLength(1); + expect(componentEvents[0]).toBe(editorEvents[0]); + expect(componentStates[0]).toEqual({ + selections: [ + { + start: { line: 0, character: 6 }, + end: { line: 0, character: 6 }, + direction: 0, + }, + ], + view: { scrollLeft: 24 }, + }); + + editor.setSelections([ + { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + direction: 'none', + }, + ]); + code.scrollLeft = 32; + root.scrollTop = 64; + finishSession(); + + expect(completionEvents).toHaveLength(1); + expect(completionStates[0]).toEqual({ + selections: [ + { + start: { line: 1, character: 3 }, + end: { line: 1, character: 3 }, + direction: 0, + }, + ], + view: { scrollLeft: 32 }, + }); + expect(root.scrollTop).toBe(64); + expect(Object.isFrozen(completionEvents[0])).toBe(true); + expect(componentStates[0]?.selections?.[0]?.start.character).toBe(6); + } finally { + editor.cleanUp(); + file.cleanUp(); + dom.cleanup(); + } + }); + for (const diffStyle of ['unified', 'split'] as const) { test(`reads ${diffStyle} FileDiff horizontal state from active code columns`, async () => { const dom = installDom(); @@ -266,15 +428,18 @@ describe('virtualized editor viewport', () => { expect(code.deletions?.scrollLeft).toBe(60); let recyclePosition: number | undefined; - fileDiff.attachEditor({ + fileDiff.__attachEditor({ __captureFocusForDOMReplacement() {}, + __emitEditComplete() {}, + __getDocumentContents: () => undefined, + __getDocumentSessionState: () => undefined, __postponeBgTokenizeToNextFrame() {}, __syncRenderView() {}, edit: () => () => {}, cleanUp() { recyclePosition = fileDiff.getCodeScrollLeft(); }, - } as DiffsEditor); + } as unknown as DiffsEditor); code.deletions!.scrollLeft = 64; fileDiff.cleanUp(true); expect(recyclePosition).toBe(64); diff --git a/packages/diffs/test/virtualizedFileLayout.test.ts b/packages/diffs/test/virtualizedFileLayout.test.ts new file mode 100644 index 000000000..575763a1a --- /dev/null +++ b/packages/diffs/test/virtualizedFileLayout.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, test } from 'bun:test'; + +import { VirtualizedFile } from '../src/components/VirtualizedFile'; +import type { FileContents, VirtualFileMetrics } from '../src/types'; +import { + getVirtualFileHeaderRegion, + getVirtualFilePaddingBottom, +} from '../src/utils/computeVirtualFileMetrics'; +import { installDom } from './domHarness'; + +const metrics: VirtualFileMetrics = { + hunkLineCount: 50, + lineHeight: 10, + diffHeaderHeight: 30, + spacing: 4, +}; + +const virtualizer = { + type: 'simple', + config: {}, + connect() {}, + disconnect() {}, + getWindowSpecs() { + return { top: 0, bottom: 0 }; + }, + getOffsetInScrollContainer() { + return 0; + }, + instanceChanged() {}, + isInstanceVisible() { + return false; + }, + markDOMDirty() {}, + requestHeightReconcile() {}, +} as never; + +const codeView = { type: 'advanced' } as never; + +describe('VirtualizedFile layout', () => { + test('recomputes height for a new unkeyed file', () => { + const dom = installDom(); + const file: FileContents = { + name: 'mutable.ts', + contents: 'one', + }; + const instance = new VirtualizedFile({}, virtualizer, metrics); + const fileContainer = document.createElement('div'); + + try { + instance.render({ file, fileContainer, forceRender: true }); + expect(instance.getVirtualizedHeight()).toBe( + getVirtualFileHeaderRegion(metrics, false) + + metrics.lineHeight + + getVirtualFilePaddingBottom(metrics) + ); + + const nextFile = { ...file, contents: 'one\ntwo\nthree' }; + instance.render({ file: nextFile, fileContainer, forceRender: true }); + expect(instance.getVirtualizedHeight()).toBe( + getVirtualFileHeaderRegion(metrics, false) + + 3 * metrics.lineHeight + + getVirtualFilePaddingBottom(metrics) + ); + } finally { + instance.cleanUp(); + dom.cleanup(); + } + }); + + test('recomputes CodeView height for a new unkeyed file', () => { + const file: FileContents = { + name: 'mutable.ts', + contents: 'one', + }; + const instance = new VirtualizedFile({}, codeView, metrics); + const headerHeight = getVirtualFileHeaderRegion(metrics, false); + const paddingBottom = getVirtualFilePaddingBottom(metrics); + + expect(instance.updateCodeViewLayout(file, 0)).toBe( + headerHeight + metrics.lineHeight + paddingBottom + ); + + const nextFile = { ...file, contents: 'one\ntwo\nthree' }; + expect(instance.updateCodeViewLayout(nextFile, 0)).toBe( + headerHeight + 3 * metrics.lineHeight + paddingBottom + ); + }); +}); diff --git a/packages/diffs/test/virtualizedFilePersistedLayout.test.ts b/packages/diffs/test/virtualizedFilePersistedLayout.test.ts deleted file mode 100644 index 330eb2e5a..000000000 --- a/packages/diffs/test/virtualizedFilePersistedLayout.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { describe, expect, test } from 'bun:test'; - -import { VirtualizedFile } from '../src/components/VirtualizedFile'; -import type { - DiffsEditor, - FileContents, - VirtualFileMetrics, -} from '../src/types'; -import { - getVirtualFileHeaderRegion, - getVirtualFilePaddingBottom, -} from '../src/utils/computeVirtualFileMetrics'; -import { installDom } from './domHarness'; - -const metrics: VirtualFileMetrics = { - hunkLineCount: 50, - lineHeight: 10, - diffHeaderHeight: 30, - spacing: 4, -}; - -const virtualizer = { - type: 'simple', - config: {}, - connect() {}, - disconnect() {}, - getWindowSpecs() { - return { top: 0, bottom: 0 }; - }, - getOffsetInScrollContainer() { - return 0; - }, - instanceChanged() {}, - isInstanceVisible() { - return false; - }, - markDOMDirty() {}, - requestHeightReconcile() {}, -} as never; - -const codeView = { type: 'advanced' } as never; - -class TestVirtualizedFile extends VirtualizedFile { - getLatestFileForTest(): FileContents | undefined { - return this.getLatestFile(); - } -} - -describe('VirtualizedFile persisted layout', () => { - test('restores cached contents before computing approximate height', () => { - const dom = installDom(); - const originalFile: FileContents = { - name: 'file.ts', - contents: 'one', - cacheKey: 'file', - }; - const cachedFile: FileContents = { - ...originalFile, - contents: 'one\ntwo\nthree\nfour', - }; - let restoreCalls = 0; - const editor: DiffsEditor = { - __getCachedDocumentContents() { - restoreCalls++; - return cachedFile.contents; - }, - __captureFocusForDOMReplacement() {}, - __postponeBgTokenizeToNextFrame() {}, - __syncRenderView() {}, - edit() { - return () => {}; - }, - cleanUp() {}, - }; - const instance = new TestVirtualizedFile({}, virtualizer, metrics); - const detach = instance.attachEditor(editor); - - try { - instance.render({ - file: originalFile, - fileContainer: document.createElement('div'), - }); - - expect(restoreCalls).toBe(1); - expect(instance.file).toBe(originalFile); - expect(instance.getLatestFileForTest()?.contents).toBe( - cachedFile.contents - ); - expect(instance.getVirtualizedHeight()).toBe( - getVirtualFileHeaderRegion(metrics, false) + - 4 * metrics.lineHeight + - getVirtualFilePaddingBottom(metrics) - ); - } finally { - detach(); - instance.cleanUp(); - dom.cleanup(); - } - }); - - test('does not restore cached contents over an attached host render', () => { - const dom = installDom(); - const originalFile: FileContents = { - name: 'file.ts', - contents: 'original', - cacheKey: 'file', - }; - const cachedFile: FileContents = { - ...originalFile, - contents: 'cached edit', - }; - const externalFile: FileContents = { - ...originalFile, - contents: 'external update', - cacheKey: 'file-v2', - }; - let restoreCalls = 0; - const editor: DiffsEditor = { - __getCachedDocumentContents(file) { - restoreCalls++; - return file.cacheKey === originalFile.cacheKey - ? cachedFile.contents - : undefined; - }, - __captureFocusForDOMReplacement() {}, - __postponeBgTokenizeToNextFrame() {}, - __syncRenderView() {}, - edit() { - return () => {}; - }, - cleanUp() {}, - }; - const instance = new TestVirtualizedFile({}, virtualizer, metrics); - const fileContainer = document.createElement('div'); - let detach: (() => void) | undefined; - - try { - instance.render({ file: originalFile, fileContainer }); - detach = instance.attachEditor(editor); - expect(restoreCalls).toBe(1); - expect(instance.file).toBe(originalFile); - expect(instance.getLatestFileForTest()?.contents).toBe( - cachedFile.contents - ); - - instance.render({ file: externalFile, fileContainer }); - expect(restoreCalls).toBe(1); - expect(instance.file).toBe(externalFile); - expect(instance.getLatestFileForTest()?.contents).toBe( - externalFile.contents - ); - } finally { - detach?.(); - instance.cleanUp(); - dom.cleanup(); - } - }); - - test('recomputes height for a new unkeyed file', () => { - const dom = installDom(); - const file: FileContents = { - name: 'mutable.ts', - contents: 'one', - }; - const instance = new VirtualizedFile({}, virtualizer, metrics); - const fileContainer = document.createElement('div'); - - try { - instance.render({ file, fileContainer, forceRender: true }); - expect(instance.getVirtualizedHeight()).toBe( - getVirtualFileHeaderRegion(metrics, false) + - metrics.lineHeight + - getVirtualFilePaddingBottom(metrics) - ); - - const nextFile = { ...file, contents: 'one\ntwo\nthree' }; - instance.render({ file: nextFile, fileContainer, forceRender: true }); - expect(instance.getVirtualizedHeight()).toBe( - getVirtualFileHeaderRegion(metrics, false) + - 3 * metrics.lineHeight + - getVirtualFilePaddingBottom(metrics) - ); - } finally { - instance.cleanUp(); - dom.cleanup(); - } - }); - - test('recomputes CodeView height for a new unkeyed file', () => { - const file: FileContents = { - name: 'mutable.ts', - contents: 'one', - }; - const instance = new VirtualizedFile({}, codeView, metrics); - const headerHeight = getVirtualFileHeaderRegion(metrics, false); - const paddingBottom = getVirtualFilePaddingBottom(metrics); - - expect(instance.updateCodeViewLayout(file, 0)).toBe( - headerHeight + metrics.lineHeight + paddingBottom - ); - - const nextFile = { ...file, contents: 'one\ntwo\nthree' }; - expect(instance.updateCodeViewLayout(nextFile, 0)).toBe( - headerHeight + 3 * metrics.lineHeight + paddingBottom - ); - }); -}); diff --git a/skills/diffs/references/api-editor.md b/skills/diffs/references/api-editor.md index 671d1195d..24f0d8444 100644 --- a/skills/diffs/references/api-editor.md +++ b/skills/diffs/references/api-editor.md @@ -1,31 +1,45 @@ # Editor API -This reference lists every export from `@pierre/diffs/edit` and every public -member of its classes. +This reference lists the primary exports from `@pierre/diffs/edit` and the +editor APIs used by common integrations. ## Exports -| Export | Kind | Purpose | -| --------------------- | ----- | --------------------------------------------------------- | -| `Editor` | Class | Adds text editing to a `File` or `FileDiff` instance. | -| `EditorChange` | Type | Describes one normalized editor change. | -| `EditorChangeEvent` | Type | Provides normalized edits and current document state. | -| `EditorOptions` | Type | Configures history, state, selections, and callbacks. | -| `TextDocument` | Class | Stores text, positions, edits, search, and undo history. | -| `TextDocumentChange` | Type | Describes the lines and characters changed by an edit. | -| `IStateStorage` | Type | Defines asynchronous or synchronous editor state storage. | -| `PersistStateStorage` | Type | Selects memory, IndexedDB, or custom state storage. | -| `Position` | Type | Identifies a zero-based line and character. | -| `Range` | Type | Identifies a start and end position. | -| `TextEdit` | Type | Replaces one range with new text. | +| Export | Kind | Purpose | +| --------------------------- | ----- | -------------------------------------------------------------- | +| `Editor` | Class | Adds text editing to a `File` or `FileDiff` instance. | +| `EditStateManager` | Value | Manages keyed in-memory edit history and state. | +| `ClearEditStateOptions` | Type | Selects retained state parts to clear. | +| `EditState` | Type | Holds one complete live editing session. | +| `EditorInitialState` | Type | Selects state fields to supply on first attachment. | +| `EditorChange` | Type | Describes one normalized editor change. | +| `EditorChangeEvent` | Type | Provides normalized edits and the current document. | +| `EditorEditCompleteEvent` | Type | Unites file and diff completion events for editor observation. | +| `FileEditCompleteEvent` | Type | Describes a completed file edit session. | +| `FileDiffEditCompleteEvent` | Type | Describes a completed diff edit session. | +| `EditorCommand` | Type | Names an editor command. | +| `EditorDocumentKind` | Type | Selects a `file` or `file-diff` editor surface. | +| `EditorFocusOptions` | Type | Selects a focus target and scroll behavior. | +| `EditorKeymap` | Type | Defines ordered custom shortcut groups. | +| `EditorOptions` | Type | Configures history, initial state, behavior, and events. | +| `EditorShortcut` | Type | Defines one command shortcut. | +| `EditorViewState` | Type | Holds selections and editor-owned viewport offsets. | +| `KeyboardKey` | Type | Names a key accepted by an editor shortcut. | +| `KeyboardModifier` | Type | Names a modifier accepted by an editor shortcut. | +| `TextDocument` | Class | Stores text, positions, edits, search, and undo history. | +| `TextDocumentChange` | Type | Describes the lines and characters changed by an edit. | +| `Position` | Type | Identifies a zero-based line and character. | +| `Range` | Type | Identifies a start and end position. | +| `TextEdit` | Type | Replaces one range with new text. | ## `EditorOptions` fields | Field | Purpose | | ------------------------ | -------------------------------------------------------- | | `historyMaxEntries` | Limits the undo stack. | -| `persistState` | Keeps editor state for each file cache key. | -| `persistStateStorage` | Selects the state store. | +| `ownsVerticalViewport` | Opts into vertical scroll retention at construction. | +| `initialState` | Supplies partial or complete state on first attach. | +| `keymap` | Adds shortcut groups checked before the defaults. | | `roundedSelection` | Controls rounded selection corners. | | `matchBrackets` | Controls matching-bracket highlights. | | `autoSurround` | Controls quote and bracket insertion around a selection. | @@ -34,31 +48,96 @@ member of its classes. | `clipboard` | Supplies a text clipboard reader. | | `renderSelectionAction` | Produces the selection action element. | | `onAttach` | Receives the editor and attached surface. | -| `onChange` | Receives file state, annotations, and a change event. | +| `onChange` | Receives the event, including its editor instance. | +| `onComplete` | Observes the completed file or diff event. | | `onFocus` | Runs after the editor gains focus. | | `onBlur` | Runs after the editor loses focus. | +For a `file-diff` editor, view-only `initialState` can omit document and diff +metadata. Transferring an edited document and its undo/redo history requires the +matching `document`, `fileInfo`, and `diffSession` from one complete +`EditState`. + +`onComplete` receives the exact frozen event that is also passed to the +component's `onEditComplete` or CodeView's `onItemEditComplete`. The editor +observer runs first and still runs when the component callback is missing. You +cannot accept or reject from this API. It is here for symmetry, but most +integrations should use those component callbacks instead. + +Use `editStateKey` for ordinary same-runtime restoration. It retains the draft, +undo/redo history, selections, horizontal code scroll, eligible vertical scroll, +and FileDiff resume metadata without an application synchronization loop. It is +bounded in-memory state and does not survive a reload. + +Use `getViewState()` and `setViewState()` for an isolated selection/view copy or +durable application persistence. An attached surface reports horizontal +`scrollLeft`. It reports `scrollTop` only when `ownsVerticalViewport: true` was +passed to the constructor and the surface exclusively owns an element viewport. +Virtualization alone does not imply ownership; page, ancestor, and CodeView +shared scrolling remain application/viewer state. + +`getEditState()` exposes the raw objects from the latest complete edit-lifecycle +checkpoint. Checkpoints run after synchronization, document edits, explicit +`setViewState()` calls, recycling, and completion. Selection or scroll movement +alone does not update it; use `getViewState()` for an exact live copy. State +remains available while rendering is recycled and during `onComplete`, and +returns `undefined` before synchronization or after completion. The result is +borrowed editor-owned state rather than a serialization format. + ## `Editor` members -| Member | Purpose | -| ----------------------------------- | --------------------------------------------------------- | -| `new Editor(options?)` | Creates one editor. | -| `edit(instance)` | Attaches to a file or diff and returns a detach function. | -| `setOptions(options)` | Replaces editor options. | -| `applyEdits(edits, updateHistory?)` | Applies programmatic text edits. | -| `canUndo` | Reports whether undo has an entry. | -| `canRedo` | Reports whether redo has an entry. | -| `undo()` | Reverts the latest edit. | -| `redo()` | Reapplies the latest reverted edit. | -| `getFile()` | Gets the current file contents. | -| `getText()` | Gets the current text. | -| `getState()` | Gets selections and view state. | -| `setState(state)` | Sets selections and view state. | -| `setSelections(selections)` | Sets directed selection ranges. | -| `setMarkers(markers)` | Sets diagnostic markers. | -| `focus(options?)` | Focuses the editor. | -| `blur()` | Removes editor focus. | -| `cleanUp(recycle?)` | Releases editor resources. | +| Member | Purpose | +| -------------------------------------------------------- | ------------------------------------------------------ | +| `new Editor(kind, options?, editStateKey?)` | Creates an editor with optional keyed state retention. | +| `edit(instance)` | Attaches and returns the normal completion disposer. | +| `setOptions(options)` | Merges editor options. | +| `applyEdits(edits, updateHistory?)` | Applies programmatic text edits. | +| `canUndo` | Reports whether undo has an entry. | +| `canRedo` | Reports whether redo has an entry. | +| `undo()` | Reverts the latest edit. | +| `redo()` | Reapplies the latest reverted edit. | +| `getFile()` | Gets the current file contents. | +| `getText()` | Gets the current text. | +| `getViewState()` | Gets selections and editor-owned view state. | +| `setViewState(state)` | Sets selections and editor-owned view state. | +| `getEditState()` | Gets the latest edit-lifecycle state checkpoint. | +| `setSelections(selections)` | Sets directed selection ranges. | +| `setMarkers(markers)` | Sets diagnostic markers. | +| `focus(options?)` | Focuses the editor. | +| `blur()` | Removes editor focus. | +| `cleanUp(reason?: 'discard' \| 'recycle' \| 'complete')` | Suspends rendering or completes the editing session. | + +`editStateKey` opts an editor into retained in-memory sessions across editor +instances. File and file-diff namespaces are independent and each keeps up to +100 inactive entries by default. The same kind/key cannot be active in two +editors at once. + +Call the disposer returned by `edit(instance)` for the normal session-ending +path; it is equivalent to `cleanUp('complete')` and installs an accepted +completion result. `cleanUp('discard')` still publishes the completion event but +does not install its result. `cleanUp('recycle')` suspends rendering without +ending the session or changing the editor-component association. Calling +`edit(instance)` again resumes rendering for that same component. + +## `EditStateManager` members + +| Member | Purpose | +| ----------------------------------- | --------------------------------------------------------------- | +| `get(kind, editStateKey)` | Borrows active or inactive complete state without touching LRU. | +| `clear(kind, editStateKey, parts?)` | Clears inactive complete or granular state. | +| `clearAll()` | Clears all inactive state without mutating active editors. | +| `setCapacity(capacity)` | Sets each namespace's inactive retained-state capacity. | + +`clear()` returns `false` for active or missing state. Omit `parts` to remove +the complete entry. `{ document: true }` also removes the complete entry because +all other parts depend on it. Use `{ history: true }`, `{ selections: true }`, +`{ view: true }`, or `{ editor: true }` to keep the document while clearing undo +history, cursor state, scroll state, or both view-state parts respectively. + +For durable persistence, store `FileContents` and optional JSON-safe +`EditorViewState` separately, then construct a fresh `TextDocument` when +restoring. Complete `EditState` and undo/redo history are not serialization +contracts. ## `TextDocument` members @@ -91,5 +170,3 @@ member of its classes. | `undo()` | Reverts one document history entry. | | `redo()` | Reapplies one document history entry. | | `normalizePosition(position)` | Clamps a position to the document. | - -`IStateStorage` has `get(cacheKey)` and `set(cacheKey, state)` methods. diff --git a/skills/diffs/references/api-react.md b/skills/diffs/references/api-react.md index 96e6bf76e..e58e8a5f0 100644 --- a/skills/diffs/references/api-react.md +++ b/skills/diffs/references/api-react.md @@ -40,8 +40,8 @@ entry also re-exports every type in [Shared types](api-types.md). | `UncontrolledCodeViewProps` | Defines `CodeView` props with `initialItems`. | | `CodeViewReactOptions` | Defines the React-safe `CodeView` option set. | | `CodeViewHandle` | Defines imperative list, selection, scroll, and editor controls. | -| `CreateEditor` | Defines the editor factory. | -| `EditProviderProps` | Defines the `EditProvider` factory prop. | +| `CreateEditor` | Defines a kind-aware editor factory. | +| `EditProviderProps` | Defines the kind-aware `EditProvider` factory prop. | | `MergeConflictActionsTypeOption` | Selects no actions, default actions, or a custom action renderer. | | `RenderMergeConflictActionContext` | Supplies conflict resolution to a custom action renderer. | | `RenderMergeConflictActions` | Defines a custom conflict action renderer. | diff --git a/skills/diffs/references/api-types.md b/skills/diffs/references/api-types.md index deed8152d..9d25809d3 100644 --- a/skills/diffs/references/api-types.md +++ b/skills/diffs/references/api-types.md @@ -82,22 +82,39 @@ The root, React, and SSR entries re-export these types. ## `CodeView` types -| Export | Purpose | -| ------------------------------ | -------------------------------------------------------- | -| `CodeViewFileItem` | Describes one file item in a virtualized list. | -| `CodeViewDiffItem` | Describes one diff item in a virtualized list. | -| `CodeViewItem` | Represents a file or diff list item. | -| `CodeViewCreateEditorOptions` | Adds an item ID to editor creation options. | -| `CodeViewScrollBehavior` | Selects instant, smooth, or automatic smooth scroll. | -| `CodeViewScrollTarget` | Represents any supported list scroll target. | -| `CodeViewPositionScrollTarget` | Scrolls to an absolute list position. | -| `CodeViewLineScrollTarget` | Scrolls to one item line. | -| `CodeViewRangeScrollTarget` | Scrolls to one item line range. | -| `CodeViewItemScrollTarget` | Scrolls to one item boundary. | -| `NumericScrollLineAnchor` | Describes a numeric position inside a line. | -| `CodeViewLayout` | Stores item offsets, heights, and total list height. | -| `PendingCodeViewLayoutReset` | Describes a deferred list layout reset. | -| `SmoothScrollSettings` | Configures duration and distance for smooth list scroll. | +| Export | Purpose | +| --------------------------------- | -------------------------------------------------------- | +| `CodeViewFileItem` | Describes one file item in a virtualized list. | +| `CodeViewDiffItem` | Describes one diff item in a virtualized list. | +| `CodeViewItem` | Represents a file or diff list item. | +| `CodeViewCreateEditorOptions` | Provides CodeView's routed editor option subset. | +| `CodeViewItemEditCompleteHandler` | Handles completion with correlated event and item types. | +| `CodeViewScrollBehavior` | Selects instant, smooth, or automatic smooth scroll. | +| `CodeViewScrollTarget` | Represents any supported list scroll target. | +| `CodeViewPositionScrollTarget` | Scrolls to an absolute list position. | +| `CodeViewLineScrollTarget` | Scrolls to one item line. | +| `CodeViewRangeScrollTarget` | Scrolls to one item line range. | +| `CodeViewItemScrollTarget` | Scrolls to one item boundary. | +| `NumericScrollLineAnchor` | Describes a numeric position inside a line. | +| `CodeViewLayout` | Stores item offsets, heights, and total list height. | +| `PendingCodeViewLayoutReset` | Describes a deferred list layout reset. | +| `SmoothScrollSettings` | Configures duration and distance for smooth list scroll. | + +`CodeViewCreateEditorOptions` currently contains the routed `onChange(event)` +callback that `CodeView` uses to emit `onItemEditChange(event, item)`; it does +not add an item ID. A `CodeViewOptions.createEditor` factory also receives the +optional `editStateKey` returned by `getEditStateKey(item)` as its third +argument. Forward that key separately to the editor constructor. It retains the +editable draft, undo/redo history, selections, and horizontal code scroll. Item +editors never own CodeView's shared vertical position. + +`onItemEditComplete(event, item, nextItem)` must return `'accept'` to accept the +completed item or `'reject'` to restore the original while the item remains +present; a missing callback rejects. During removal or teardown, neither +decision reinserts the item. Controlled owners must put the supplied `nextItem` +into their item state when accepting an item that should remain. If the item +uses keyed render caching, assigning a fresh `cacheKey` to the accepted event +file or diff invalidates that cache. ## Lines, hunks, and render state @@ -159,7 +176,10 @@ The root, React, and SSR entries re-export these types. | `Range` | Identifies start and end positions. | | `TextEdit` | Replaces one range with new text. | | `EditorSelection` | Adds direction to a range. | -| `EditorState` | Holds editor selections and view state. | +| `EditorViewState` | Holds editor selections and view state. | +| `EditorViewportState` | Holds horizontal and optional vertical scroll offsets. | +| `EditorChange` | Describes one normalized document edit. | +| `EditorChangeEvent` | Reports normalized changes and current document data. | ## Shiki and diff types diff --git a/skills/diffs/references/recipe-code-view.md b/skills/diffs/references/recipe-code-view.md index 36d818c0a..df93c5848 100644 --- a/skills/diffs/references/recipe-code-view.md +++ b/skills/diffs/references/recipe-code-view.md @@ -200,13 +200,31 @@ export function removeReviewSurface() { ## Enable item edit mode In React, wrap `CodeView` in `EditProvider`. In vanilla JavaScript, pass -`createEditor` in `CodeViewOptions`. Set `edit: true` on each editable item and -increment its version. - -Use `onItemEditChange` for live contents and annotation changes. Use -`onItemEditComplete` to write the final contents into the item, disable edit -mode, assign a fresh `cacheKey`, and increment `version`. Use `getEditor(id)` -for editor commands such as undo, redo, markers, or programmatic edits. +`createEditor(documentKind, options, editStateKey)` in `CodeViewOptions` and +forward all three arguments to `new Editor`. Set `edit: true` on each editable +item and increment its version. The factory receives `'file'` or `'file-diff'` +as its first argument so it can construct an editor for that item kind. + +`onItemEditChange(event, item)` reports live contents and annotation changes. +Read the current document from `event.file`, the complete annotation collection +from `event.lineAnnotations`. Treat it as a notification and do not feed the +changes back into the viewer. + +`onItemEditComplete(event, item, nextItem)` must return `'accept'` or `'reject'` +whenever a session ends, including when its final text is unchanged. `CodeView` +builds `nextItem` with the completed contents and annotations, `edit: false`, +and an incremented `version`. If you use keyed render caching, assign a fresh +`cacheKey` to `event.file` or `event.fileDiff` before accepting. Return +`'accept'` to install `nextItem` while the item remains present, or `'reject'` +to restore the original item while it remains present. During removal or viewer +teardown, neither decision reinserts the item. A missing callback rejects. When +React controls `items`, put `nextItem` into controlled state only when the item +should remain. + +Use `getEditStateKey(item)` to opt into retaining the draft, undo/redo history, +selections, and editor-owned view state across editor instances. The returned +`editStateKey` is passed to the editor factory. `getEditor(id)` returns the +current `DiffsEditor` handle. Read [Edit with React](recipe-edit-react.md) or [Edit with vanilla JavaScript](recipe-edit-vanilla.md) for the complete editor diff --git a/skills/diffs/references/recipe-edit-react.md b/skills/diffs/references/recipe-edit-react.md index c51e57dab..721d62bea 100644 --- a/skills/diffs/references/recipe-edit-react.md +++ b/skills/diffs/references/recipe-edit-react.md @@ -1,17 +1,14 @@ # Recipe: edit with React -Mount one stable `EditProvider` above the editable surfaces. The provider -supplies an editor factory. Each active surface or `CodeView` item owns a -separate editor instance, cached by `editorOptions` object identity — an edit -session restarting with the same options object reuses its editor, and -simultaneously editable surfaces need distinct options objects. - -To share one editor across surfaces, pass the same `editorOptions` object to -each of them: the cache then hands every surface the same instance. Instance -state — such as `persistState` records and their default `inMemory` storage — -survives surface remounts, so per-file selections and scroll positions restore -across file switches. Share an options object only where one surface is editable -at a time; simultaneously editable surfaces need distinct options objects. +Mount one `EditProvider` above the editable surfaces. Its factory receives the +document kind, the surface's creation-time `editorOptions`, and an optional +`editStateKey`. Each active surface or `CodeView` item owns its editor. + +Pass `editStateKey` to opt into bounded in-memory retention of the draft, +undo/redo history, selections, horizontal scroll, eligible vertical scroll, and +diff resume metadata across editor instances. Choose this key explicitly; it is +a stable application identity for the document. The same active key cannot be +shared by two editors of the same document kind. ## Contents @@ -22,11 +19,21 @@ at a time; simultaneously editable surfaces need distinct options objects. ## Edit a standalone file or diff Set `edit` on `File`, `FileDiff`, `MultiFileDiff`, or `PatchDiff`. Pass editor -behavior through `editOptions`. +creation options through `editorOptions` and optional retention through +`editStateKey`. ```tsx -import type { FileContents, FileDiffOptions } from '@pierre/diffs'; -import { Editor, type EditorOptions } from '@pierre/diffs/edit'; +import type { + FileContents, + FileDiffEditCompleteEvent, + FileDiffOptions, +} from '@pierre/diffs'; +import { + Editor, + type EditorChangeEvent, + type EditorDocumentKind, + type EditorOptions, +} from '@pierre/diffs/edit'; import { EditProvider, MultiFileDiff, Virtualizer } from '@pierre/diffs/react'; import { useMemo, useRef, useState } from 'react'; @@ -43,33 +50,42 @@ const diffOptions: FileDiffOptions = { diffStyle: 'split', }; -function createEditor(options: EditorOptions) { - return new Editor(options); +function createEditor( + documentKind: EditorDocumentKind, + options: EditorOptions, + editStateKey?: string +) { + return new Editor(documentKind, options, editStateKey); } export function EditableDiff() { const [edit, setEdit] = useState(false); const [newFile, setNewFile] = useState(initialNewFile); - const draftRef = useRef(newFile); const editorRef = useRef | null>(null); - const editOptions = useMemo>( + const editorOptions = useMemo>( () => ({ onAttach(editor) { editorRef.current = editor; }, - onChange(file) { - draftRef.current = file; - saveDraft(file); - }, }), [] ); function toggleEdit() { - if (edit) setNewFile(draftRef.current); setEdit((value) => !value); } + function handleEditChange(event: EditorChangeEvent) { + saveDraft(event.file); + } + + function handleEditComplete(event: FileDiffEditCompleteEvent) { + if (event.newFile != null) { + setNewFile(event.newFile); + } + return 'accept' as const; + } + return (